diff --git a/.github/commitizen-requirements.txt b/.github/commitizen-requirements.txt new file mode 100644 index 0000000000..a731e7b115 --- /dev/null +++ b/.github/commitizen-requirements.txt @@ -0,0 +1,18 @@ +# Hash-locked Linux/Python 3.12 environment for the contents-write bump job. +# Regenerate deliberately; every transitive package is exact and immutable. +argcomplete==3.6.3 --hash=sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce +charset-normalizer==3.4.9 --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd +colorama==0.4.6 --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +commitizen==4.16.4 --hash=sha256:054e957491e3c897226a631e5bbeca5f5db772ce23412a5d588a4d7b9bb0bbc2 +decli==0.6.3 --hash=sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3 +Deprecated==1.3.1 --hash=sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f +Jinja2==3.1.6 --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 +MarkupSafe==3.0.3 --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d +packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e +prompt-toolkit==3.0.51 --hash=sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07 +PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc +questionary==2.1.1 --hash=sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59 +termcolor==3.3.0 --hash=sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5 +tomlkit==0.15.0 --hash=sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738 +wcwidth==0.8.2 --hash=sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85 +wrapt==2.2.2 --hash=sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a diff --git a/.github/workflows/behave_pull_request.yml b/.github/workflows/behave_pull_request.yml index 1a37776723..6f6f7f7a33 100644 --- a/.github/workflows/behave_pull_request.yml +++ b/.github/workflows/behave_pull_request.yml @@ -14,12 +14,12 @@ jobs: name: Setup Environment runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Install Poetry run: pipx install poetry==1.8.4 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.12 @@ -31,7 +31,7 @@ jobs: - name: Get All Council Files That Have Changed id: changed-council-files - uses: tj-actions/changed-files@v47 + uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47 with: files: | uk_bin_collection/uk_bin_collection/councils/**.py @@ -58,12 +58,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.12] + python-version: [3.12, 3.13, 3.14] poetry-version: [1.8.4] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} @@ -77,7 +77,7 @@ jobs: run: make unit-tests - name: Upload Test Results to Codecov - uses: codecov/codecov-action@v7 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -92,9 +92,9 @@ jobs: python-version: [3.12] poetry-version: [1.8.4] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} @@ -111,6 +111,8 @@ jobs: run: make parity-check repo="$repo" branch="$branch" integration-tests: + # Preserve the established required-check identity while replacing the + # nondeterministic live scrape with a blocking fixture contract suite. name: Run Integration Tests needs: setup runs-on: ubuntu-latest @@ -118,16 +120,10 @@ jobs: matrix: python-version: [3.12] poetry-version: [1.8.4] - services: - selenium: - image: selenium/standalone-chrome:latest - options: --shm-size=2gb --name selenium --hostname selenium - ports: - - 4444:4444 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} @@ -137,19 +133,15 @@ jobs: - name: Install Dependencies run: make install-dev - - name: Run Integration Tests - env: - HEADLESS: True - COUNCIL_TESTS: ${{ needs.setup.outputs.council_tests }} - run: make matrix=${{ matrix.python-version }} councils="${{ env.COUNCIL_TESTS }}" integration-tests - continue-on-error: true + - name: Run Deterministic Parser Contract Tests + run: make matrix=${{ matrix.python-version }} parser-contract-tests - - name: Upload Integration Test Results to Codecov - uses: codecov/codecov-action@v7 + - name: Upload Parser Contract Test Results to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} report_type: test_results - file: build/${{ matrix.python-version }}/integration-test-results/junit.xml - flags: integrationtestspr - name: integration-tests-pr + file: build/${{ matrix.python-version }}/parser-contract-test-results/junit.xml + flags: parsercontractspr + name: parser-contract-tests-pr diff --git a/.github/workflows/behave_schedule.yml b/.github/workflows/behave_schedule.yml index feec23531c..f81feda876 100644 --- a/.github/workflows/behave_schedule.yml +++ b/.github/workflows/behave_schedule.yml @@ -10,12 +10,12 @@ jobs: name: Setup Environment runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Install Poetry run: pipx install poetry==1.8.4 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.12 @@ -40,12 +40,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.12] + python-version: [3.12, 3.13, 3.14] poetry-version: [1.8.4] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} @@ -59,7 +59,7 @@ jobs: run: make unit-tests - name: Upload Test Results to Codecov - uses: codecov/codecov-action@v7 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -74,9 +74,9 @@ jobs: python-version: [3.12] poetry-version: [1.8.4] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} @@ -102,14 +102,14 @@ jobs: poetry-version: [1.8.4] services: selenium: - image: selenium/standalone-chrome:latest + image: selenium/standalone-chrome:4.45.0-20260606@sha256:90fbfd0cec921daba37e87f92e0edbd2c1b6e03b14d3972dba6338dec3884b70 options: --shm-size=2gb --name selenium --hostname selenium ports: - 4444:4444 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} @@ -124,14 +124,14 @@ jobs: HEADLESS: True COUNCIL_TESTS: ${{ needs.setup.outputs.council_tests }} run: make matrix=${{ matrix.python-version }} councils="${{ env.COUNCIL_TESTS }}" integration-tests - continue-on-error: true - name: Upload Integration Test Results to Codecov - uses: codecov/codecov-action@v7 + if: always() + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} report_type: test_results file: build/${{ matrix.python-version }}/integration-test-results/junit.xml flags: integrationtestsfullnightly - name: integration-tests-full-nightly \ No newline at end of file + name: integration-tests-full-nightly diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index 178f977f22..673b82597b 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -19,52 +19,166 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 ssh-key: ${{ secrets.DEPLOY_KEY }} persist-credentials: true - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' cache: 'pip' - name: Cache Commitizen - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-commitizen-${{ hashFiles('**/pyproject.toml') }} + key: ${{ runner.os }}-pip-commitizen-${{ hashFiles('.github/commitizen-requirements.txt') }} restore-keys: | ${{ runner.os }}-pip-commitizen- - - name: Install Commitizen - run: pip install commitizen + - name: Install Poetry + uses: abatilo/actions-poetry@3765cf608f2d4a72178a9fc5b918668e542b89b1 # v4.0.0 + with: + poetry-version: '1.8.4' + + - name: Install hash-locked Commitizen toolchain + run: >- + python -m pip install + --require-hashes + --only-binary=:all: + --requirement .github/commitizen-requirements.txt - name: Configure git identity run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Bump version and create tag + - name: Bump version and prepare atomic release tag id: bump run: | + set -euo pipefail # Check if there are commits to bump - if cz bump --yes --changelog --dry-run 2>&1 | grep -q "No commits found"; then + DRY_RUN_OUTPUT="" + if ! DRY_RUN_OUTPUT=$(cz bump --yes --changelog --dry-run 2>&1); then + if ! grep -q "No commits found" <<< "$DRY_RUN_OUTPUT"; then + printf '%s\n' "$DRY_RUN_OUTPUT" >&2 + exit 1 + fi + fi + if grep -q "No commits found" <<< "$DRY_RUN_OUTPUT"; then echo "No version bump needed - no conventional commits since last release" echo "skip=true" >> $GITHUB_OUTPUT exit 0 fi cz bump --yes --changelog - echo "version=$(cz version --project)" >> $GITHUB_OUTPUT + VERSION=$(cz version --project) + + # Commitizen creates its commit and local annotated tag before the HA + # requirement can be changed from the development range. Amend that + # unpushed commit, then recreate the still-local tag on the exact tree. + python scripts/pin_release_manifest.py --version "$VERSION" + git add custom_components/uk_bin_collection/manifest.json + git commit --amend --no-edit + git tag --force --annotate "$VERSION" --message "Release $VERSION" HEAD + + python scripts/validate_release_contract.py \ + --version "$VERSION" \ + --require-exact + test "$(git rev-parse HEAD)" = "$(git rev-list -n 1 "$VERSION")" + + echo "version=$VERSION" >> $GITHUB_OUTPUT echo "skip=false" >> $GITHUB_OUTPUT - - name: Push changes and tags + - name: Build and validate core artifact before repository writes + if: steps.bump.outputs.skip != 'true' + run: | + set -euo pipefail + poetry check --lock + export SOURCE_DATE_EPOCH=315532800 + poetry build --format wheel + WHEEL=$(find dist -maxdepth 1 -type f -name '*.whl' -print -quit) + test -n "$WHEEL" + python scripts/validate_release_contract.py \ + --version "${{ steps.bump.outputs.version }}" \ + --require-exact \ + --wheel "$WHEEL" + + - name: Determine whether the exact core artifact already exists + if: steps.bump.outputs.skip != 'true' + id: pypi + run: | + set -euo pipefail + VERSION="${{ steps.bump.outputs.version }}" + WHEEL=$(find dist -maxdepth 1 -type f -name '*.whl' -print -quit) + STATUS=$(curl --silent --show-error --output /dev/null \ + --write-out '%{http_code}' \ + "https://pypi.org/pypi/uk-bin-collection/$VERSION/json") + case "$STATUS" in + 404) + echo "publish=true" >> "$GITHUB_OUTPUT" + ;; + 200) + curl --fail --silent --show-error \ + "https://pypi.org/pypi/uk-bin-collection/$VERSION/json" \ + > dist/pypi-release.json + python scripts/validate_release_contract.py \ + --version "$VERSION" \ + --require-exact \ + --wheel "$WHEEL" \ + --pypi-json dist/pypi-release.json + echo "publish=false" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Could not prove PyPI release state (HTTP $STATUS)" >&2 + exit 1 + ;; + esac + + # The core artifact is deliberately published and digest-verified before + # master can expose a manifest that requires it. A rerun after a partial + # failure accepts only the byte-identical wheel checked in the step above. + - name: Publish core to PyPI before repository writes + if: steps.bump.outputs.skip != 'true' && steps.pypi.outputs.publish == 'true' + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + poetry config pypi-token.pypi "${{ secrets.PYPI_API_KEY }}" + poetry publish + + - name: Verify published core bytes before repository writes + if: steps.bump.outputs.skip != 'true' + run: | + set -euo pipefail + VERSION="${{ steps.bump.outputs.version }}" + WHEEL=$(find dist -maxdepth 1 -type f -name '*.whl' -print -quit) + for attempt in {1..20}; do + if curl --fail --silent --show-error \ + "https://pypi.org/pypi/uk-bin-collection/$VERSION/json" \ + > dist/pypi-release.json; then + if python scripts/validate_release_contract.py \ + --version "$VERSION" \ + --require-exact \ + --wheel "$WHEEL" \ + --pypi-json dist/pypi-release.json; then + exit 0 + fi + fi + sleep 15 + done + echo "Exact core artifact did not become verifiably available" >&2 + exit 1 + + - name: Push installable release commit and tag if: steps.bump.outputs.skip != 'true' run: | - git push origin master - git push origin --tags + set -euo pipefail + git push --atomic origin master "refs/tags/${{ steps.bump.outputs.version }}" - name: Create workflow summary if: always() diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b5245331d4..0f9a795190 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -47,11 +47,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -65,7 +65,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -78,4 +78,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 9f92558244..99b2cf5915 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -22,9 +22,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Publish to Registry - uses: elgohr/Publish-Docker-Github-Action@v5 + uses: elgohr/Publish-Docker-Github-Action@1c2f28ccd9476e8a936ac9a1f287405504c93304 # v5 with: name: robbrad182/uk-bin-collection username: ${{ secrets.DOCKER_USERNAME }} diff --git a/.github/workflows/ha_compatibility_test.yml b/.github/workflows/ha_compatibility_test.yml index d29a15c15f..fe2fdbe65c 100644 --- a/.github/workflows/ha_compatibility_test.yml +++ b/.github/workflows/ha_compatibility_test.yml @@ -2,17 +2,34 @@ name: PR - Home Assistant Compatibility Test on: push: - branches: [ master, main ] + branches: [master, main] paths: - - 'custom_components/**' - - 'pyproject.toml' + - "custom_components/**" + - "uk_bin_collection/**" + - "tests/**" + - "scripts/**" + - "pyproject.toml" + - "poetry.lock" + - "pytest.ini" + - "conftest.py" + - ".github/workflows/ha_compatibility_test.yml" pull_request: - branches: [ master, main ] + branches: [master, main] paths: - - 'custom_components/**' - - 'pyproject.toml' + - "custom_components/**" + - "uk_bin_collection/**" + - "tests/**" + - "scripts/**" + - "pyproject.toml" + - "poetry.lock" + - "pytest.ini" + - "conftest.py" + - ".github/workflows/ha_compatibility_test.yml" schedule: - - cron: '0 6 * * 1' # Weekly on Monday at 6 AM UTC + - cron: "0 6 * * 1" + +permissions: + contents: read jobs: generate-matrix: @@ -21,218 +38,227 @@ jobs: outputs: matrix: ${{ steps.get-versions.outputs.matrix }} steps: - - name: Get HA versions from PyPI + - name: Get supported HA versions from PyPI id: get-versions run: | - MATRIX=$(curl -s https://pypi.org/pypi/homeassistant/json | jq -c ' + set -euo pipefail + MATRIX=$(curl --fail --silent --show-error \ + https://pypi.org/pypi/homeassistant/json | jq -c ' .releases | to_entries - # keep only x.y.z (skip betas/devs/post) | map(select(.key | test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))) - # group by major.minor, keep highest patch | group_by(.key | (split(".")[:2] | join("."))) | map(max_by(.key | (split(".")[2] | tonumber)) | .key) - # sort numerically and take latest 8 | sort_by(split(".") | map(tonumber)) | .[-8:] - # pick python version per HA series; adjust as needed - | map({ha_version: ., python_version: (if (split(".")[0] == "2025" and (split(".")[1]|tonumber) >= 2) then "3.13" else "3.12" end)}) - # also test latest dev on py 3.13 - | . + [{ha_version: "dev", python_version: "3.13"}] + # Keep the acceptance runtime in the matrix after it ages out of + # the latest-eight window. + | . + ["2026.7.2"] + | unique + | map({ + ha_version: ., + expected_python: ( + if ( + (split(".")[0] | tonumber) > 2026 + or ( + (split(".")[0] == "2026") + and ((split(".")[1] | tonumber) >= 3) + ) + ) then "3.14" + elif ( + (split(".")[0] | tonumber) > 2025 + or ( + (split(".")[0] == "2025") + and ((split(".")[1] | tonumber) >= 2) + ) + ) + then "3.13" + else "3.12" + end + ) + }) + | . + [{ha_version: "dev", expected_python: "3.14"}] | {include: .} ') echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + build-candidate: + name: Build Local Candidate Wheel + runs-on: ubuntu-latest + outputs: + wheel_sha256: ${{ steps.wheel.outputs.sha256 }} + wheel_name: ${{ steps.wheel.outputs.name }} + candidate_version: ${{ steps.wheel.outputs.version }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + + - name: Install pinned Poetry + run: pipx install poetry==1.8.4 + + - name: Validate lock and release metadata + run: | + poetry check --lock + python scripts/validate_release_contract.py \ + --version "$(poetry version -s)" + + - name: Build and hash candidate wheel + id: wheel + run: | + set -euo pipefail + poetry build --format wheel + WHEEL=$(find dist -maxdepth 1 -type f -name '*.whl' -print -quit) + test -n "$WHEEL" + SHA256=$(sha256sum "$WHEEL" | awk '{print $1}') + echo "$SHA256 $(basename "$WHEEL")" > dist/SHA256SUMS + echo "sha256=$SHA256" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$WHEEL")" >> "$GITHUB_OUTPUT" + echo "version=$(poetry version -s)" >> "$GITHUB_OUTPUT" + + - name: Upload immutable candidate input + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ha-candidate-wheel + path: | + dist/*.whl + dist/SHA256SUMS + if-no-files-found: error + retention-days: 7 + test-ha-compatibility: name: Test HA ${{ matrix.ha_version }} runs-on: ubuntu-latest - needs: generate-matrix + needs: [generate-matrix, build-candidate] strategy: fail-fast: false matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} - steps: - - name: Checkout code - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - name: Determine Docker tag - id: docker-tag - run: | - if [ "${{ matrix.ha_version }}" = "dev" ]; then - echo "tag=dev" >> "$GITHUB_OUTPUT" - else - echo "tag=${{ matrix.ha_version }}" >> "$GITHUB_OUTPUT" - fi + - name: Download local candidate wheel + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ha-candidate-wheel + path: candidate-dist - - name: Setup HA config directory - run: | - mkdir -p config/custom_components config/.storage - cp -r custom_components/uk_bin_collection config/custom_components/ - cat > config/configuration.yaml <<'YAML' - logger: - default: info - YAML - - # Create a config entry to trigger component setup - cat > config/.storage/core.config_entries <<'JSON' - { - "version": 1, - "minor_version": 1, - "key": "core.config_entries", - "data": { - "entries": [ - { - "entry_id": "test_uk_bin_collection", - "version": 3, - "domain": "uk_bin_collection", - "title": "Test Entry", - "data": { - "name": "Test Council", - "council": "GooglePublicCalendarCouncil", - "url": "https://calendar.google.com/calendar/ical/0d775884b4db6a7bae5204f06dae113c1a36e505b25991ebc27c6bd42edf5b5e%40group.calendar.google.com/public/basic.ics", - "timeout": 60, - "update_interval": 12, - "manual_refresh_only": true - }, - "options": {}, - "pref_disable_new_entities": false, - "pref_disable_polling": false, - "source": "user", - "unique_id": null, - "disabled_by": null - } - ] - } - } - JSON - - - name: Start Home Assistant in Docker - run: | - docker run -d \ - --name homeassistant \ - -v $(pwd)/config:/config \ - -e TZ=UTC \ - ghcr.io/home-assistant/home-assistant:${{ steps.docker-tag.outputs.tag }} - - echo "Waiting for container to start..." - sleep 5 - - - name: Wait for Home Assistant to boot - id: boot + - name: Verify downloaded wheel hash run: | set -euo pipefail - TIMEOUT=150 - SECS=0 - INIT_MARKER="Home Assistant initialized" - FAIL=0 - - echo "Waiting for HA to initialize..." - while (( SECS < TIMEOUT )); do - LOGS=$(docker logs homeassistant 2>&1) - if echo "$LOGS" | grep -q "$INIT_MARKER"; then - echo "✅ HA initialized successfully" - break - fi - sleep 1 - SECS=$((SECS+1)) - if (( SECS % 10 == 0 )); then - echo "Waiting... ${SECS}s" - fi - done - - # Check for dependency installation and component setup - LOGS=$(docker logs homeassistant 2>&1) - if echo "$LOGS" | grep -q "Attempting install of uk-bin-collection"; then - echo "✅ HA attempted to install uk-bin-collection dependency" - fi - - if echo "$LOGS" | grep -Eq "(ERROR|CRITICAL).*(uk_bin_collection|custom_components\.uk_bin_collection)"; then - echo "❌ Component has errors in logs:" - echo "$LOGS" | grep -E "(ERROR|CRITICAL).*(uk_bin_collection|custom_components\.uk_bin_collection)" || true - FAIL=1 - fi - - # Check timeout - if (( SECS >= TIMEOUT )) && ! echo "$LOGS" | grep -q "$INIT_MARKER"; then - echo "❌ HA did not finish booting within ${TIMEOUT}s" - FAIL=1 - fi - - # Expose pass/fail to later steps - echo "boot_failed=${FAIL}" >> "$GITHUB_OUTPUT" - exit ${FAIL} + cd candidate-dist + sha256sum --check SHA256SUMS + test "$(awk '{print $1}' SHA256SUMS)" = \ + "${{ needs.build-candidate.outputs.wheel_sha256 }}" - - name: Save HA logs to file - if: always() + - name: Stage tests without the core source tree run: | - docker logs homeassistant > home-assistant.log 2>&1 || true + set -euo pipefail + rm -rf .ha-test-workspace ha-results + mkdir -p .ha-test-workspace/dist ha-results + cp conftest.py pytest.ini .ha-test-workspace/ + cp -R custom_components .ha-test-workspace/ + cp candidate-dist/*.whl .ha-test-workspace/dist/ - - name: Show HA logs - if: always() + - name: Resolve HA container tag + id: image run: | - echo "--- Last 80 log lines ---" - tail -n 80 home-assistant.log 2>/dev/null || docker logs homeassistant 2>&1 | tail -n 80 + if [ "${{ matrix.ha_version }}" = "dev" ]; then + echo "ref=ghcr.io/home-assistant/home-assistant:dev" >> "$GITHUB_OUTPUT" + else + echo "ref=ghcr.io/home-assistant/home-assistant:${{ matrix.ha_version }}" >> "$GITHUB_OUTPUT" + fi - - name: Stop and remove container - if: always() + - name: Run component suite with installed local wheel run: | - docker stop homeassistant || true - docker rm homeassistant || true + set -euo pipefail + docker run --rm \ + --name "ukbcd-ha-${{ matrix.ha_version }}" \ + --mount "type=bind,source=$PWD/.ha-test-workspace,target=/workspace,readonly" \ + --mount "type=bind,source=$PWD/ha-results,target=/results" \ + --workdir /workspace \ + --entrypoint /bin/bash \ + "${{ steps.image.outputs.ref }}" -lc ' + set -euo pipefail + python -m venv --system-site-packages /tmp/ha-test + constraints=() + if [ -f /usr/src/homeassistant/package_constraints.txt ]; then + constraints=(-c /usr/src/homeassistant/package_constraints.txt) + fi + /tmp/ha-test/bin/python -m pip install \ + --disable-pip-version-check \ + "${constraints[@]}" \ + /workspace/dist/*.whl \ + pytest==9.0.2 \ + pytest-asyncio==1.3.0 \ + pytest-freezer==0.4.9 \ + freezegun==1.5.1 + /tmp/ha-test/bin/python -m pip install \ + --disable-pip-version-check \ + --force-reinstall \ + --no-deps \ + /workspace/dist/*.whl + /tmp/ha-test/bin/python - <<"PY" + from importlib.metadata import distribution + from pathlib import Path + import sys + + dist = distribution("uk-bin-collection") + origin = Path(dist.locate_file("")).resolve() + expected_python = tuple( + int(part) for part in "${{ matrix.expected_python }}".split(".") + ) + assert dist.version == "${{ needs.build-candidate.outputs.candidate_version }}", dist.version + assert not origin.is_relative_to(Path("/workspace")), origin + assert sys.version_info[:2] == expected_python, sys.version + if "${{ matrix.ha_version }}" == "2026.7.2": + assert expected_python == (3, 14), expected_python + print(f"candidate={dist.version} origin={origin} python={sys.version.split()[0]}") + PY + /tmp/ha-test/bin/python -m pytest \ + -q custom_components/uk_bin_collection/tests \ + --junitxml="/results/ha-${{ matrix.ha_version }}.xml" + ' - - name: Upload HA log (always) + - name: Upload compatibility result if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ha-log-${{ matrix.ha_version }} - path: home-assistant.log - overwrite: true - - - name: Test manifest validation - id: manifest - run: | - python <<'PY' - import json, sys - with open('custom_components/uk_bin_collection/manifest.json') as f: - m = json.load(f) - required = ['domain', 'name', 'version', 'requirements'] - missing = [k for k in required if k not in m] - if missing: - print(f'❌ Missing required manifest fields: {missing}') - sys.exit(1) - print('✅ Manifest validation passed') - print(f'Component version: {m.get("version")}') - print(f'Requirements: {m.get("requirements")}') - PY + name: ha-result-${{ matrix.ha_version }} + path: ha-results/ + if-no-files-found: warn + retention-days: 14 - - name: Create test result summary + - name: Record tested artifact if: always() run: | - echo "## Boot Results for HA ${{ matrix.ha_version }} (Python ${{ matrix.python_version }})" >> "$GITHUB_STEP_SUMMARY" - if [ "${{ steps.boot.outputs.boot_failed }}" = "0" ] && [ "${{ steps.manifest.outcome }}" = "success" ]; then - echo "✅ **PASSED** – HA booted with the custom component present" >> "$GITHUB_STEP_SUMMARY" - else - echo "❌ **FAILED** – HA failed to boot cleanly" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- boot step failed: \`${{ steps.boot.outputs.boot_failed }}\`" >> "$GITHUB_STEP_SUMMARY" - echo "- manifest step: \`${{ steps.manifest.outcome }}\`" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "See the uploaded **ha-log** artifact for details." >> "$GITHUB_STEP_SUMMARY" - fi - + echo "## HA ${{ matrix.ha_version }} compatibility" >> "$GITHUB_STEP_SUMMARY" + echo "- Expected Python generation: ${{ matrix.expected_python }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Candidate wheel: ${{ needs.build-candidate.outputs.wheel_name }}" >> "$GITHUB_STEP_SUMMARY" + echo "- SHA-256: ${{ needs.build-candidate.outputs.wheel_sha256 }}" >> "$GITHUB_STEP_SUMMARY" compatibility-report: name: Generate Compatibility Report runs-on: ubuntu-latest - needs: [generate-matrix, test-ha-compatibility] + needs: [generate-matrix, build-candidate, test-ha-compatibility] if: always() steps: - - name: Checkout code - uses: actions/checkout@v7 + - name: Write summary + run: | + echo "# Home Assistant Compatibility Report" >> "$GITHUB_STEP_SUMMARY" + echo "- Matrix: \`${{ needs.generate-matrix.outputs.matrix }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Wheel SHA-256: \`${{ needs.build-candidate.outputs.wheel_sha256 }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Matrix result: \`${{ needs.test-ha-compatibility.result }}\`" >> "$GITHUB_STEP_SUMMARY" - - name: Create compatibility report + - name: Enforce compatibility gate + if: always() + env: + GENERATE_MATRIX_RESULT: ${{ needs.generate-matrix.result }} + BUILD_CANDIDATE_RESULT: ${{ needs.build-candidate.result }} + HA_MATRIX_RESULT: ${{ needs.test-ha-compatibility.result }} run: | - echo "# Home Assistant Compatibility Report" > report.md - echo "" >> report.md - echo "Matrix tested: \`${{ needs.generate-matrix.outputs.matrix }}\`" >> report.md - echo "Last updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")" >> report.md - cat report.md >> "$GITHUB_STEP_SUMMARY" + set -euo pipefail + test "$GENERATE_MATRIX_RESULT" = "success" + test "$BUILD_CANDIDATE_RESULT" = "success" + test "$HA_MATRIX_RESULT" = "success" diff --git a/.github/workflows/hacs_validation.yml b/.github/workflows/hacs_validation.yml index 1c578b4688..6cb2d7c4b2 100644 --- a/.github/workflows/hacs_validation.yml +++ b/.github/workflows/hacs_validation.yml @@ -11,15 +11,15 @@ jobs: name: HassFest Validation runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v7" - - uses: home-assistant/actions/hassfest@master + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: home-assistant/actions/hassfest@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master hacs: name: HACS Action Validation runs-on: "ubuntu-latest" steps: - name: HACS Action - uses: "hacs/action@main" + uses: hacs/action@1ebf01c408f29afcb6406bd431bc98fd8cbb15aa # main with: category: "integration" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 72b886dcbf..2585b5538f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,8 +16,8 @@ jobs: name: Lint Commit Messages runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - run: "echo \"export default {extends: ['@commitlint/config-conventional'], rules: { 'subject-case': [0], 'body-max-line-length': [0], 'footer-max-line-length': [0] }}\" > commitlint.config.mjs" - - uses: wagoid/commitlint-github-action@v6 + - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63c9697b3a..f3a1e7e9c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,24 +10,23 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - id-token: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' cache: 'pip' - name: Install Poetry - uses: abatilo/actions-poetry@v4.0.0 + uses: abatilo/actions-poetry@3765cf608f2d4a72178a9fc5b918668e542b89b1 # v4.0.0 with: poetry-version: '1.8.4' - name: Cache Poetry dependencies - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/pypoetry key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }} @@ -45,27 +44,59 @@ jobs: exit 1 fi + - name: Validate locked dependency graph + run: poetry check --lock + + - name: Verify atomic core and integration release contract + run: >- + python scripts/validate_release_contract.py + --version "${{ env.RELEASE_VERSION }}" + --require-exact + - name: Build package - run: poetry build + run: | + set -euo pipefail + export SOURCE_DATE_EPOCH=315532800 + poetry build --format wheel + + - name: Validate built wheel metadata + run: | + set -euo pipefail + WHEEL=$(find dist -maxdepth 1 -type f -name '*.whl' -print -quit) + test -n "$WHEEL" + python scripts/validate_release_contract.py \ + --version "${{ env.RELEASE_VERSION }}" \ + --require-exact \ + --wheel "$WHEEL" + + - name: Verify pre-published exact core artifact from PyPI + run: | + set -euo pipefail + WHEEL=$(find dist -maxdepth 1 -type f -name '*.whl' -print -quit) + for attempt in {1..20}; do + if curl --fail --silent --show-error \ + "https://pypi.org/pypi/uk-bin-collection/${{ env.RELEASE_VERSION }}/json" \ + > dist/pypi-release.json; then + python scripts/validate_release_contract.py \ + --version "${{ env.RELEASE_VERSION }}" \ + --require-exact \ + --wheel "$WHEEL" \ + --pypi-json dist/pypi-release.json + exit $? + fi + sleep 15 + done + echo "Exact core artifact did not become available from PyPI" >&2 + exit 1 - name: Create GitHub release - uses: ncipollo/release-action@v1 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1 with: tag: ${{ env.RELEASE_VERSION }} generateReleaseNotes: true artifacts: "dist/*" token: ${{ secrets.GITHUB_TOKEN }} - - name: Publish to PyPI - uses: nick-fields/retry@v4 - with: - timeout_minutes: 5 - max_attempts: 3 - retry_wait_seconds: 30 - command: | - poetry config pypi-token.pypi "${{ secrets.PYPI_API_KEY }}" - poetry publish - - name: Create workflow summary if: always() run: | @@ -87,16 +118,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_API_KEY }} - name: Build and push Docker image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: ./uk_bin_collection_api_server push: true diff --git a/.github/workflows/rollback-release.yml b/.github/workflows/rollback-release.yml index 974a4f8597..1743bb4fcd 100644 --- a/.github/workflows/rollback-release.yml +++ b/.github/workflows/rollback-release.yml @@ -20,7 +20,7 @@ jobs: contents: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 ssh-key: ${{ secrets.DEPLOY_KEY }} @@ -65,28 +65,10 @@ jobs: echo "âš ī¸ Tag ${{ inputs.version }} not found locally" fi - - name: Setup Python (if PyPI yank requested) - if: inputs.delete_pypi == true - uses: actions/setup-python@v6 - with: - python-version: '3.12' - - - name: Install Poetry (if PyPI yank requested) - if: inputs.delete_pypi == true - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: '1.8.4' - - - name: Yank from PyPI + - name: Record manual PyPI yank requirement if: inputs.delete_pypi == true run: | - echo "âš ī¸ Yanking version ${{ inputs.version }} from PyPI..." - echo "Note: This marks the release as unsuitable for installation but doesn't delete it" - poetry config pypi-token.pypi "${{ secrets.PYPI_API_KEY }}" - # PyPI doesn't support yanking via poetry directly, need to use twine - pip install twine - # Note: You'll need to manually yank via PyPI web interface or use: - # twine upload --repository pypi --skip-existing dist/* + echo "No PyPI credentials are loaded by this rollback workflow." echo "âš ī¸ PyPI yanking must be done manually at: https://pypi.org/manage/project/uk-bin-collection/releases/" echo "Go to the release and click 'Options' -> 'Yank release'" diff --git a/.github/workflows/validate-release-ready.yml b/.github/workflows/validate-release-ready.yml index 3c43e7d12c..e9f6397993 100644 --- a/.github/workflows/validate-release-ready.yml +++ b/.github/workflows/validate-release-ready.yml @@ -11,17 +11,17 @@ jobs: name: Validate Release Prerequisites runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' cache: 'pip' - name: Cache Poetry - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.local/pipx key: ${{ runner.os }}-pipx-poetry-1.8.4 @@ -32,9 +32,32 @@ jobs: run: pipx install poetry==1.8.4 - name: Validate pyproject.toml - run: poetry check + run: poetry check --lock + + - name: Validate component and core versions agree + run: >- + python scripts/validate_release_contract.py + --version "$(poetry version -s)" + + - name: Prove the release manifest can be pinned atomically + run: >- + python scripts/pin_release_manifest.py + --version "$(poetry version -s)" + --check + + - name: Build wheel on Python 3.12 + run: poetry build --format wheel + + - name: Validate candidate wheel metadata + run: | + set -euo pipefail + WHEEL=$(find dist -maxdepth 1 -type f -name '*.whl' -print -quit) + test -n "$WHEEL" + python scripts/validate_release_contract.py \ + --version "$(poetry version -s)" \ + --wheel "$WHEEL" - name: Check for conventional commits - uses: wagoid/commitlint-github-action@v6 + uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 with: configFile: commitlint.config.mjs diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 91fa0629eb..1824d7888b 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -22,13 +22,13 @@ jobs: runs-on: ubuntu-latest environment: wiki steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' - name: Run image - uses: abatilo/actions-poetry@v4.0.0 + uses: abatilo/actions-poetry@3765cf608f2d4a72178a9fc5b918668e542b89b1 # v4.0.0 with: poetry-version: '1.8.4' @@ -48,7 +48,7 @@ jobs: continue-on-error: true - name: Deploy Wiki Changes - uses: Andrew-Chen-Wang/github-wiki-action@v5 + uses: Andrew-Chen-Wang/github-wiki-action@1bbb4280446f9630e8e21a18012cbacf3b0f992e # v5 with: # Make sure WIKI_DIR ends with / as action uses rsync path: wiki/ diff --git a/.gitignore b/.gitignore index 89fe81fb6c..d013f692fa 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,10 @@ !*.png !.github/ISSUE_TEMPLATE !.github/ISSUE_TEMPLATE/*.yaml -!.github/workflows -!.github/workflows/*.yml -!uk_bin_collection +!.github/workflows +!.github/workflows/*.yml +!.github/commitizen-requirements.txt +!uk_bin_collection !uk_bin_collection/**/* !uk_bin_collection_api_server !uk_bin_collection_api_server/**/* @@ -44,6 +45,13 @@ __pycache__ !.devcontainer uk_bin_collection/.DS_Store uk_bin_collection/uk_bin_collection/.DS_Store -!scripts -!.kiro +!scripts +!tests/ +!tests/disposable_ha/ +!tests/disposable_ha/*.py +!tests/disposable_ha/Containerfile +!tests/disposable_ha/*.Containerfile +!tests/disposable_ha/containerignore +tests/disposable_ha/*.whl +!.kiro ISSUE_RESOLUTION_PROGRESS.md diff --git a/Makefile b/Makefile index 8bd03eb8ce..8ff597c92f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install pre-build build black pycodestyle update-wiki +.PHONY: install pre-build build black pycodestyle update-wiki unit-tests parser-contract-tests integration-tests ## @CI_actions Installs the checked out version of the code to your poetry managed venv install: @@ -26,31 +26,25 @@ pycodestyle: ## @Testing runs unit tests integration-tests: - # Ensure directory exists mkdir -p build/$(matrix)/integration-test-results - - # Turn off "exit on error" so we can capture the code - set +e; \ + @result=0; \ if [ -z "$(councils)" ]; then \ poetry run pytest uk_bin_collection/tests/step_defs/ \ -n logical \ - --junit-xml=build/$(matrix)/integration-test-results/junit.xml; \ + --junit-xml=build/$(matrix)/integration-test-results/junit.xml \ + || result=$$?; \ else \ poetry run pytest uk_bin_collection/tests/step_defs/ \ -k "$(councils)" \ -n logical \ - --junit-xml=build/$(matrix)/integration-test-results/junit.xml; \ + --junit-xml=build/$(matrix)/integration-test-results/junit.xml \ + || result=$$?; \ fi; \ - RESULT=$$?; \ - set -e; \ - - # Double-check that the file exists (in case of a really early crash) - if [ ! -f build/$(matrix)/integration-test-results/junit.xml ]; then \ + if [ ! -f "build/$(matrix)/integration-test-results/junit.xml" ]; then \ echo "" \ - > build/$(matrix)/integration-test-results/junit.xml; \ + > "build/$(matrix)/integration-test-results/junit.xml"; \ fi; \ - - exit $$RESULT + exit $$result generate-test-map-test-results: poetry run python uk_bin_collection/tests/generate_map_test_results.py build/integration-test-results/junit.xml > build/integration-test-results/test_results.json @@ -60,8 +54,14 @@ parity-check: unit-tests: poetry run coverage erase - - poetry run coverage run --append --omit "*/tests/*" -m pytest -vv -s --log-cli-level=DEBUG uk_bin_collection/tests custom_components/uk_bin_collection/tests --ignore=uk_bin_collection/tests/step_defs/ + poetry run coverage run --append --omit "*/tests/*" -m pytest -vv -s --log-cli-level=DEBUG -m "not integration" uk_bin_collection/tests uk_bin_collection/uk_bin_collection/councils/tests tests --ignore=uk_bin_collection/tests/step_defs/ poetry run coverage xml +parser-contract-tests: + mkdir -p build/$(matrix)/parser-contract-test-results + poetry run pytest uk_bin_collection/uk_bin_collection/councils/tests \ + -m "not integration" \ + --junit-xml=build/$(matrix)/parser-contract-test-results/junit.xml + update-wiki: poetry run python wiki/generate_wiki.py diff --git a/conftest.py b/conftest.py index 5cf25e87bc..43c78fb1d6 100644 --- a/conftest.py +++ b/conftest.py @@ -3,58 +3,69 @@ import pytest from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest -from homeassistant.core import HomeAssistant from unittest.mock import AsyncMock, MagicMock, patch - - def pytest_addoption(parser: Parser) -> None: parser.addoption("--headless", action="store", default="True", type=str) parser.addoption("--local_browser", action="store", default="False", type=str) - parser.addoption("--selenium_url", action="store", default="http://localhost:4444", type=str) + parser.addoption( + "--selenium_url", action="store", default="http://localhost:4444", type=str + ) -@pytest.fixture(scope='session') + +@pytest.fixture(scope="session") def headless_mode(request: FixtureRequest) -> str: return request.config.getoption("--headless") -@pytest.fixture(scope='session') + +@pytest.fixture(scope="session") def local_browser(request: FixtureRequest) -> str: return request.config.getoption("--local_browser") -@pytest.fixture(scope='session') + +@pytest.fixture(scope="session") def selenium_url(request: FixtureRequest) -> str: return request.config.getoption("--selenium_url") + @pytest.fixture def hass(): """Mock HomeAssistant instance.""" + # Core-library tests do not install Home Assistant. Import it only for the + # dedicated component suite, which runs inside the exact HA container. + from homeassistant.core import HomeAssistant + hass = MagicMock(spec=HomeAssistant) - + # ``data`` is an instance attribute on a real HomeAssistant object, so it + # is not created by ``MagicMock(spec=HomeAssistant)`` automatically. + hass.data = {} + # Mock the event loop with create_task as AsyncMock hass.loop = MagicMock() hass.loop.create_task = AsyncMock() - + # Mock config_entries and its flow hass.config_entries = MagicMock() hass.config_entries.flow = MagicMock() - + # Mock asynchronous methods with AsyncMock hass.config_entries.flow.async_init = AsyncMock() hass.config_entries.flow.async_configure = AsyncMock() - - # Mock async_get_entry to return a MockConfigEntry when called - hass.config_entries.async_get_entry = AsyncMock() - + + # Home Assistant's async_get_entry is a synchronous lookup despite its name. + hass.config_entries.async_get_entry = MagicMock() + # Mock async_unload as an AsyncMock hass.config_entries.async_unload = AsyncMock(return_value=True) - + # Mock async_block_till_done as an AsyncMock hass.async_block_till_done = AsyncMock() hass.async_add_executor_job = AsyncMock() # Ensure compatibility with async calls - + return hass + @pytest.fixture def enable_custom_integrations(): """Fixture to enable custom integrations.""" diff --git a/custom_components/uk_bin_collection/__init__.py b/custom_components/uk_bin_collection/__init__.py index 1aa52c4168..dfd1a382e7 100644 --- a/custom_components/uk_bin_collection/__init__.py +++ b/custom_components/uk_bin_collection/__init__.py @@ -1,44 +1,252 @@ """The UK Bin Collection integration.""" import asyncio -import logging -from datetime import timedelta import json +import logging +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from datetime import datetime - from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOG_PREFIX, PLATFORMS, EXCLUDED_ARG_KEYS from uk_bin_collection.uk_bin_collection.collect_data import UKBinCollectionApp +try: + from uk_bin_collection.uk_bin_collection.exceptions import ( + AddressMismatchError, + BrowserUnavailableError, + ConfigurationError, + DependencyError, + DependencyShadowingError, + MissingDependencyError, + SiteChanged, + UKBinCollectionError, + UpstreamAccessDenied, + ) +except ImportError: # Compatibility with a core package predating typed errors. -from homeassistant.helpers import config_validation as cv + class UKBinCollectionError(Exception): + """Fallback base error used until the matching core package is installed.""" + + class ConfigurationError(UKBinCollectionError): + """Fallback invalid-configuration error.""" + + class DependencyError(UKBinCollectionError): + """Fallback used until the matching core package is installed.""" + + class DependencyShadowingError(DependencyError): + """Fallback dependency-shadowing error.""" + + class MissingDependencyError(DependencyError): + """Fallback missing-dependency error.""" + + class BrowserUnavailableError(UKBinCollectionError): + """Fallback browser-unavailable error.""" + + class AddressMismatchError(UKBinCollectionError): + """Fallback address-mismatch error.""" + + class UpstreamAccessDenied(UKBinCollectionError): + """Fallback upstream-access error.""" + + class SiteChanged(UKBinCollectionError): + """Fallback site-change error.""" + + +from .const import ( + CONFIG_ENTRY_VERSION, + DOMAIN, + LOG_PREFIX, + PLATFORMS, + SOUTH_KESTEVEN_COUNCIL, + SOUTH_KESTEVEN_URL, + STRING_ARGUMENTS, + TRUE_FLAG_ARGUMENTS, +) + +DEPENDENCY_ERRORS = (DependencyError,) PLATFORM_SCHEMA = cv.platform_only_config_schema _LOGGER = logging.getLogger(__name__) +_COLLECTOR_RUN_STATES = "_collector_run_states" + +# The South Kesteven collector has its own 90-second wall-clock deadline and +# uses a remote-command timeout of up to 30 seconds while closing the browser. +# Keep HA's outer wait beyond both bounds so the collector normally terminates +# and releases its browser before HA reports a timeout. This is deliberately +# council-specific; other collectors retain the configured timeout contract. +_SOUTH_KESTEVEN_MIN_HA_TIMEOUT_SECONDS = 125 + + +@dataclass(slots=True) +class _CollectorRunState: + """Entry-scoped state shared by coordinators across setup retries/reloads.""" + + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + active_run: asyncio.Future | None = None + discard_when_idle: bool = False + + +def _collector_run_state( + hass: HomeAssistant, entry_id: str | None +) -> _CollectorRunState: + """Return durable in-flight state for one entry before its first refresh.""" + if not entry_id: + return _CollectorRunState() + + domain_data = hass.data.setdefault(DOMAIN, {}) + run_states = domain_data.setdefault(_COLLECTOR_RUN_STATES, {}) + return run_states.setdefault(entry_id, _CollectorRunState()) + + +def _release_collector_run_state( + hass: HomeAssistant, + entry_id: str | None, + *, + expected: _CollectorRunState | None = None, +) -> None: + """Discard idle entry state, or defer cleanup until its executor finishes.""" + if not entry_id: + return + + domain_data = hass.data.get(DOMAIN) + if not isinstance(domain_data, dict): + return + run_states = domain_data.get(_COLLECTOR_RUN_STATES) + if not isinstance(run_states, dict): + return + + state = run_states.get(entry_id) + if state is None: + if not run_states: + domain_data.pop(_COLLECTOR_RUN_STATES, None) + return + if expected is not None and state is not expected: + return + + active_run = state.active_run + if active_run is not None and not active_run.done(): + state.discard_when_idle = True + return + + run_states.pop(entry_id, None) + if not run_states: + domain_data.pop(_COLLECTOR_RUN_STATES, None) + + +def _coerce_bool(value: Any, *, default: bool = False) -> bool: + """Return a stable boolean for values saved by older config flows.""" + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off", ""}: + return False + return bool(value) + + +def _issue_id(kind: str, entry_id: str) -> str: + """Return a stable Repairs issue id for an entry.""" + return f"{kind}_{entry_id}" + + +def _delete_issue_safely(hass: HomeAssistant, issue_id: str) -> None: + """Clear a resolved Repairs issue without breaking a data refresh.""" + try: + ir.async_delete_issue(hass, DOMAIN, issue_id) + except Exception: # Repairs cleanup is best-effort and contains no user data. + _LOGGER.debug( + "%s Could not clear a resolved Repairs issue.", + LOG_PREFIX, + ) + + +def _create_dependency_issue( + hass: HomeAssistant, entry_id: str, exc: BaseException +) -> None: + """Create an actionable Repairs issue without logging household data.""" + ir.async_create_issue( + hass, + DOMAIN, + _issue_id("dependency", entry_id), + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="dependency_error", + translation_placeholders={"details": str(exc)}, + ) + + +def _missing_required_configuration(config_data: dict[str, Any]) -> list[str]: + """Return required South Kesteven values absent from a migrated entry.""" + if config_data.get("council") != SOUTH_KESTEVEN_COUNCIL: + return [] + + missing = [ + field + for field in ("postcode", "number") + if not str(config_data.get(field, "")).strip() + ] + has_web_driver = bool(str(config_data.get("web_driver", "")).strip()) + has_local_browser = _coerce_bool(config_data.get("local_browser")) + if not has_web_driver: + missing.append("web_driver") + if has_local_browser: + missing.append("disable local_browser for this council") + return missing + + +def _create_missing_configuration_issue( + hass: HomeAssistant, entry_id: str, missing: list[str] +) -> None: + """Tell the user to use the integration's reconfigure flow.""" + ir.async_create_issue( + hass, + DOMAIN, + _issue_id("missing_configuration", entry_id), + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="missing_required_configuration", + translation_placeholders={"fields": ", ".join(missing)}, + ) + + +def _create_browser_issue( + hass: HomeAssistant, entry_id: str, exc: BaseException +) -> None: + """Create one stable, actionable issue for an unavailable WebDriver.""" + ir.async_create_issue( + hass, + DOMAIN, + _issue_id("browser", entry_id), + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="browser_unavailable", + translation_placeholders={"details": str(exc)}, + ) + async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the UK Bin Collection component.""" - _LOGGER.debug(f"{LOG_PREFIX} async_setup called with config: {config}") + _LOGGER.debug("%s async_setup called.", LOG_PREFIX) try: hass.data.setdefault(DOMAIN, {}) - _LOGGER.debug( - f"{LOG_PREFIX} hass.data[DOMAIN] initialized: {hass.data[DOMAIN]}" - ) + _LOGGER.debug("%s Integration runtime storage initialized.", LOG_PREFIX) async def handle_manual_refresh(call): """Refresh all bin sensors for a given config entry.""" - _LOGGER.debug( - f"{LOG_PREFIX} manual_refresh service called with data: {call.data}" - ) + _LOGGER.debug("%s manual_refresh service called.", LOG_PREFIX) entry_id = call.data.get("entry_id") if not entry_id: @@ -48,16 +256,13 @@ async def handle_manual_refresh(call): return if entry_id not in hass.data[DOMAIN]: - _LOGGER.error( - "[UKBinCollection] No config entry found for entry_id: %s", entry_id - ) + _LOGGER.error("[UKBinCollection] Requested config entry was not found") return coordinator = hass.data[DOMAIN][entry_id].get("coordinator") if not coordinator: _LOGGER.error( - "[UKBinCollection] Coordinator is missing for entry_id: %s", - entry_id, + "[UKBinCollection] Coordinator is missing for the requested entry" ) return @@ -80,49 +285,110 @@ async def handle_manual_refresh(call): return True except Exception as exc: - _LOGGER.exception("%s Unexpected error in async_setup: %s", LOG_PREFIX, exc) + _LOGGER.error( + "%s Unexpected error in async_setup (%s).", + LOG_PREFIX, + type(exc).__name__, + ) return False async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Migrate old config entries to new version.""" + """Migrate config entries sequentially to the current schema.""" try: - _LOGGER.debug( - f"{LOG_PREFIX} async_migrate_entry called for entry_id={config_entry.entry_id}, version={config_entry.version}" - ) - - if config_entry.version == 1: - _LOGGER.info( - f"{LOG_PREFIX} Migrating config entry {config_entry.entry_id} from version 1 to 2." + version = config_entry.version + if version > CONFIG_ENTRY_VERSION: + _LOGGER.error( + "%s Config entry %s has unsupported future version %s", + LOG_PREFIX, + config_entry.entry_id, + version, ) + return False - data = config_entry.data.copy() - if "update_interval" not in data: - data["update_interval"] = 12 - _LOGGER.debug( - f"{LOG_PREFIX} 'update_interval' not found. Setting default to 12 hours." - ) - else: - _LOGGER.debug( - f"{LOG_PREFIX} 'update_interval' found: {data['update_interval']} hours." - ) + if version == CONFIG_ENTRY_VERSION: + return True - hass.config_entries.async_update_entry(config_entry, data=data) + data = dict(config_entry.data) - _LOGGER.info( - f"{LOG_PREFIX} Migration of config entry {config_entry.entry_id} to version 2 successful." + if version < 2: + data.setdefault("update_interval", 12) + version = 2 + + if version < 3: + data.setdefault("manual_refresh_only", True) + version = 3 + + if version < 4: + if data.get("update_interval") is None: + data["update_interval"] = 12 + data["manual_refresh_only"] = _coerce_bool( + data.get("manual_refresh_only"), default=True + ) + data["headless"] = _coerce_bool(data.get("headless"), default=True) + data["local_browser"] = _coerce_bool(data.get("local_browser")) + data["skip_get_url"] = _coerce_bool(data.get("skip_get_url")) + + number_candidates = ( + data.get("number"), + data.pop("house_number", None), + data.pop("paon", None), + ) + normalized_number = next( + ( + str(candidate).strip() + for candidate in number_candidates + if candidate is not None and str(candidate).strip() + ), + "", ) + if normalized_number: + data["number"] = normalized_number + else: + data.pop("number", None) - else: - _LOGGER.debug( - f"{LOG_PREFIX} No migration needed for entry_id={config_entry.entry_id}" + web_driver_candidates = ( + data.get("web_driver"), + data.pop("selenium_url", None), + data.pop("webdriver", None), + ) + normalized_web_driver = next( + ( + str(candidate).strip().rstrip("/") + for candidate in web_driver_candidates + if candidate is not None and str(candidate).strip().rstrip("/") + ), + "", ) + if normalized_web_driver: + data["web_driver"] = normalized_web_driver + else: + data.pop("web_driver", None) + + if data.get("council") == SOUTH_KESTEVEN_COUNCIL: + data["url"] = SOUTH_KESTEVEN_URL + data["skip_get_url"] = True + version = 4 + + hass.config_entries.async_update_entry( + config_entry, + data=data, + version=CONFIG_ENTRY_VERSION, + ) + _LOGGER.info( + "%s Migrated config entry %s to version %s", + LOG_PREFIX, + config_entry.entry_id, + CONFIG_ENTRY_VERSION, + ) return True except Exception as exc: - _LOGGER.exception( - "%s Unexpected error during async_migrate_entry: %s", LOG_PREFIX, exc + _LOGGER.error( + "%s Unexpected error during async_migrate_entry (%s).", + LOG_PREFIX, + type(exc).__name__, ) return False @@ -137,19 +403,23 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b name = config_entry.data.get("name") if not name: _LOGGER.error(f"{LOG_PREFIX} 'name' is missing in config entry.") - raise ConfigEntryNotReady("Missing 'name' in configuration.") + raise ConfigEntryError( + "Missing 'name' in configuration. Reconfigure this integration entry." + ) timeout = config_entry.data.get("timeout", 60) - manual_refresh = config_entry.data.get("manual_refresh_only", False) - icon_color_mapping = config_entry.data.get("icon_color_mapping", "{}") + manual_refresh = _coerce_bool( + config_entry.data.get("manual_refresh_only"), default=True + ) update_interval_hours = config_entry.data.get("update_interval", 12) _LOGGER.debug( - f"{LOG_PREFIX} Retrieved configuration: " - f"name={name}, timeout={timeout}, " - f"manual_refresh_only={manual_refresh}, " - f"update_interval={update_interval_hours} hours, " - f"icon_color_mapping={icon_color_mapping}" + "%s Retrieved non-sensitive scheduling configuration: " + "timeout=%s, manual_refresh_only=%s, update_interval=%s hours", + LOG_PREFIX, + timeout, + manual_refresh, + update_interval_hours, ) # Validate 'timeout' @@ -166,8 +436,16 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) timeout = 60 - # Decide update interval based on manual_refresh + if config_entry.data.get("council") == SOUTH_KESTEVEN_COUNCIL: + timeout = max(timeout, _SOUTH_KESTEVEN_MIN_HA_TIMEOUT_SECONDS) + + # A true manual_refresh_only value must never schedule council requests. if manual_refresh: + update_interval = None + _LOGGER.info( + "%s Manual refresh only: no automatic updates scheduled.", LOG_PREFIX + ) + else: try: update_interval_hours = int(update_interval_hours) if update_interval_hours < 1: @@ -180,15 +458,24 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b LOG_PREFIX, update_interval_hours, ) - else: - update_interval = None - _LOGGER.info( - "%s Manual refresh only: no automatic updates scheduled.", LOG_PREFIX + + missing = _missing_required_configuration(config_entry.data) + if missing: + _create_missing_configuration_issue(hass, config_entry.entry_id, missing) + raise ConfigEntryError( + "Missing required configuration: " + f"{', '.join(missing)}. Reconfigure this integration entry." ) + _delete_issue_safely( + hass, _issue_id("missing_configuration", config_entry.entry_id) + ) # Prepare arguments for UKBinCollectionApp args = build_ukbcd_args(config_entry.data) - _LOGGER.debug(f"{LOG_PREFIX} UKBinCollectionApp args: {args}") + _LOGGER.debug( + "%s Built UKBinCollectionApp arguments for the configured council", + LOG_PREFIX, + ) # Initialize the UK Bin Collection Data application ukbcd = UKBinCollectionApp() @@ -196,12 +483,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug(f"{LOG_PREFIX} UKBinCollectionApp initialized and arguments set.") # Initialize the data coordinator + # This state must exist before the first refresh. Home Assistant may build + # another coordinator after ConfigEntryNotReady while the executor thread + # from the first attempt is still running. + run_state = _collector_run_state(hass, config_entry.entry_id) coordinator = HouseholdBinCoordinator( hass, ukbcd, name, timeout=timeout, update_interval=update_interval, + config_entry=config_entry, + run_state=run_state, ) _LOGGER.debug( @@ -209,7 +502,26 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) # Perform first refresh - await coordinator.async_config_entry_first_refresh() + try: + await coordinator.async_config_entry_first_refresh() + except asyncio.CancelledError: + _release_collector_run_state( + hass, config_entry.entry_id, expected=run_state + ) + raise + except Exception: + # Permanent setup failures must not leak idle per-entry state. If a + # timed-out executor is still running, cleanup is deferred until its + # completion callback observes that it is safe to remove. + _release_collector_run_state( + hass, config_entry.entry_id, expected=run_state + ) + raise + _delete_issue_safely(hass, _issue_id("dependency", config_entry.entry_id)) + _delete_issue_safely( + hass, _issue_id("missing_configuration", config_entry.entry_id) + ) + _delete_issue_safely(hass, _issue_id("browser", config_entry.entry_id)) _LOGGER.info( f"{LOG_PREFIX} Initial data fetched successfully for entry_id={config_entry.entry_id}" ) @@ -222,46 +534,72 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # Forward the setup to all platforms (sensor and calendar) _LOGGER.debug(f"{LOG_PREFIX} Forwarding setup to platforms: {PLATFORMS}") - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + try: + await hass.config_entries.async_forward_entry_setups( + config_entry, PLATFORMS + ) + except asyncio.CancelledError: + hass.data[DOMAIN].pop(config_entry.entry_id, None) + _release_collector_run_state( + hass, config_entry.entry_id, expected=run_state + ) + raise + except Exception: + # Platform setup needs the coordinator in hass.data, but a failed + # forward must not leave a setup-retry entry callable through the + # manual-refresh service. + hass.data[DOMAIN].pop(config_entry.entry_id, None) + _release_collector_run_state( + hass, config_entry.entry_id, expected=run_state + ) + raise _LOGGER.info( f"{LOG_PREFIX} async_setup_entry finished for entry_id={config_entry.entry_id}" ) return True - except UpdateFailed as e: - _LOGGER.error(f"{LOG_PREFIX} Unable to fetch initial data: {e}") - raise ConfigEntryNotReady from e + except (ConfigEntryError, ConfigEntryNotReady): + raise + + except DEPENDENCY_ERRORS as exc: + _create_dependency_issue(hass, config_entry.entry_id, exc) + raise ConfigEntryError( + "A Python dependency could not be loaded safely. " + "Open Repairs for the conflicting package path." + ) from exc + + except UpdateFailed as exc: + error_class = type(exc).__name__ + _LOGGER.error("%s Initial data fetch failed (%s).", LOG_PREFIX, error_class) + raise ConfigEntryNotReady( + f"Initial collection lookup failed ({error_class})." + ) from None except Exception as exc: - _LOGGER.exception( - "%s Unexpected error in async_setup_entry: %s", LOG_PREFIX, exc + error_class = type(exc).__name__ + _LOGGER.error( + "%s Unexpected error in async_setup_entry (%s).", + LOG_PREFIX, + error_class, ) - raise ConfigEntryNotReady from exc + raise ConfigEntryNotReady( + f"Unexpected integration setup error ({error_class})." + ) from None async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.info(f"{LOG_PREFIX} Unloading config entry {config_entry.entry_id}") - unload_ok = True try: - for platform in PLATFORMS: - platform_unload_ok = await hass.config_entries.async_forward_entry_unload( - config_entry, platform - ) - if not platform_unload_ok: - _LOGGER.warning( - f"{LOG_PREFIX} Failed to unload '{platform}' platform for entry_id={config_entry.entry_id}" - ) - unload_ok = False - else: - _LOGGER.debug( - f"{LOG_PREFIX} Successfully unloaded '{platform}' for entry_id={config_entry.entry_id}" - ) + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id, None) + _release_collector_run_state(hass, config_entry.entry_id) _LOGGER.debug( f"{LOG_PREFIX} Removed coordinator for entry_id={config_entry.entry_id}" ) @@ -271,40 +609,53 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) except Exception as exc: - _LOGGER.exception( - "%s Unexpected error in async_unload_entry: %s", LOG_PREFIX, exc + _LOGGER.error( + "%s Unexpected error in async_unload_entry (%s).", + LOG_PREFIX, + type(exc).__name__, ) unload_ok = False return unload_ok -def build_ukbcd_args(config_data: dict) -> list: - """Build the argument list for UKBinCollectionApp from config data.""" - council = config_data.get("original_parser") or config_data.get("council", "") - url = config_data.get("url", "") - args = [council, url] +async def async_remove_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Remove entry-scoped runtime state and Repairs issues.""" + domain_data = hass.data.get(DOMAIN) + if isinstance(domain_data, dict): + domain_data.pop(config_entry.entry_id, None) + _release_collector_run_state(hass, config_entry.entry_id) + for kind in ("dependency", "missing_configuration", "browser"): + _delete_issue_safely(hass, _issue_id(kind, config_entry.entry_id)) - # Per-key formatters: return a list of CLI args for that key - def _format_headless(v): - return ["--headless"] if v else ["--not-headless"] - def _format_web_driver(v): - return [f"--web_driver={v.rstrip('/')}"] if v is not None else [] - - formatters = { - "headless": _format_headless, - "web_driver": _format_web_driver, - } +def build_ukbcd_args(config_data: dict[str, Any]) -> list[str]: + """Build arguments using only the core library's declared CLI contract.""" + council = config_data.get("original_parser") or config_data.get("council", "") + url = config_data.get("url", "") + args = [str(council).strip(), str(url).strip()] - for key, value in config_data.items(): - if key in EXCLUDED_ARG_KEYS: + for key, option in STRING_ARGUMENTS.items(): + value = config_data.get(key) + if value is None: continue - fmt = formatters.get(key) - if fmt: - args.extend(fmt(value)) - else: - args.append(f"--{key}={value}") + normalized = str(value).strip() + if not normalized: + continue + if key == "web_driver": + normalized = normalized.rstrip("/") + args.append(f"{option}={normalized}") + + for key, option in TRUE_FLAG_ARGUMENTS.items(): + if _coerce_bool(config_data.get(key)): + args.append(option) + + if "headless" in config_data: + args.append( + "--headless" + if _coerce_bool(config_data.get("headless"), default=True) + else "--not-headless" + ) return args @@ -319,22 +670,33 @@ def __init__( name: str, timeout: int = 60, update_interval: timedelta = timedelta(hours=12), + config_entry: ConfigEntry | None = None, + config_entry_id: str | None = None, + run_state: _CollectorRunState | None = None, ) -> None: """Initialize the data coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="UK Bin Collection Data", update_interval=update_interval, ) self.ukbcd = ukbcd self.name = name self.timeout = timeout + self.config_entry_id = ( + config_entry.entry_id if config_entry is not None else config_entry_id + ) self._last_good_data = {} + self._run_state = run_state or _collector_run_state(hass, self.config_entry_id) _LOGGER.debug( - f"{LOG_PREFIX} HouseholdBinCoordinator __init__: name={name}, timeout={timeout}, update_interval={update_interval}" + "%s HouseholdBinCoordinator initialized: timeout=%s, update_interval=%s", + LOG_PREFIX, + timeout, + update_interval, ) async def _async_update_data(self) -> dict: @@ -345,14 +707,38 @@ async def _async_update_data(self) -> dict: ) try: + async with self._run_state.lock: + if ( + self._run_state.active_run is not None + and not self._run_state.active_run.done() + ): + raise UpdateFailed( + "The previous collector run is still finishing after its timeout." + ) + run_future = asyncio.ensure_future( + self.hass.async_add_executor_job(self.ukbcd.run) + ) + # A new setup has legitimately reused this state after a prior + # unload. Do not let the old run's deferred cleanup remove it. + self._run_state.discard_when_idle = False + self._run_state.active_run = run_future + run_future.add_done_callback(self._clear_active_run) + + # Shield the executor future: cancelling the asyncio wait cannot stop a + # running thread, and retaining it prevents a second overlapping scrape. data = await asyncio.wait_for( - self.hass.async_add_executor_job(self.ukbcd.run), + asyncio.shield(run_future), timeout=self.timeout, ) - _LOGGER.debug(f"{LOG_PREFIX} Raw data fetched from ukbcd.run(): {data}") - parsed_data = json.loads(data) - _LOGGER.debug(f"{LOG_PREFIX} JSON parsed data: {parsed_data}") + + if self.config_entry_id: + _delete_issue_safely( + self.hass, _issue_id("dependency", self.config_entry_id) + ) + _delete_issue_safely( + self.hass, _issue_id("browser", self.config_entry_id) + ) processed_data = self.process_bin_data(parsed_data) @@ -367,39 +753,136 @@ async def _async_update_data(self) -> dict: return {} self._last_good_data = processed_data - _LOGGER.debug(f"{LOG_PREFIX} Processed data: {processed_data}") - _LOGGER.info(f"{LOG_PREFIX} Bin collection data updated successfully.") return processed_data - except asyncio.TimeoutError as exc: - _LOGGER.error(f"{LOG_PREFIX} Timeout while updating data: {exc}") - raise UpdateFailed(f"Timeout while updating data: {exc}") from exc - except json.JSONDecodeError as exc: - _LOGGER.error(f"{LOG_PREFIX} JSON decode error: {exc}") - raise UpdateFailed(f"JSON decode error: {exc}") from exc + except asyncio.TimeoutError: + _LOGGER.error("%s Timeout while updating data.", LOG_PREFIX) + raise UpdateFailed("Timeout while updating data.") from None + except json.JSONDecodeError: + _LOGGER.error("%s JSON decode error in collector output.", LOG_PREFIX) + raise UpdateFailed("JSON decode error in collector output.") from None + except DEPENDENCY_ERRORS as exc: + if self.config_entry_id: + _delete_issue_safely( + self.hass, _issue_id("browser", self.config_entry_id) + ) + _create_dependency_issue(self.hass, self.config_entry_id, exc) + raise ConfigEntryError( + "A Python dependency is missing or shadowed. " + "Open Home Assistant Repairs for remediation details." + ) from exc + except BrowserUnavailableError as exc: + if self.config_entry_id: + _delete_issue_safely( + self.hass, _issue_id("dependency", self.config_entry_id) + ) + _create_browser_issue(self.hass, self.config_entry_id, exc) + _LOGGER.warning("%s Selenium WebDriver is unavailable.", LOG_PREFIX) + raise UpdateFailed( + "Selenium WebDriver is unavailable. Check Home Assistant Repairs " + "and the configured browser endpoint." + ) from exc + except (ConfigurationError, AddressMismatchError) as exc: + if self.config_entry_id and isinstance(exc, AddressMismatchError): + _delete_issue_safely( + self.hass, _issue_id("dependency", self.config_entry_id) + ) + _delete_issue_safely( + self.hass, _issue_id("browser", self.config_entry_id) + ) + _LOGGER.warning( + "%s Council lookup configuration requires user action: %s", + LOG_PREFIX, + type(exc).__name__, + ) + raise ConfigEntryError( + "The configured address or council settings need to be corrected. " + "Reconfigure this integration entry." + ) from exc + except UpstreamAccessDenied as exc: + if self.config_entry_id: + _delete_issue_safely( + self.hass, _issue_id("dependency", self.config_entry_id) + ) + _delete_issue_safely( + self.hass, _issue_id("browser", self.config_entry_id) + ) + _LOGGER.warning("%s Council browser access was denied.", LOG_PREFIX) + raise UpdateFailed( + "The council denied browser access to its collection checker." + ) from exc + except SiteChanged as exc: + if self.config_entry_id: + _delete_issue_safely( + self.hass, _issue_id("dependency", self.config_entry_id) + ) + _delete_issue_safely( + self.hass, _issue_id("browser", self.config_entry_id) + ) + _LOGGER.warning("%s Council website structure changed.", LOG_PREFIX) + raise UpdateFailed( + "The council collection checker no longer matches its supported layout." + ) from exc + except UKBinCollectionError as exc: + _LOGGER.warning( + "%s Expected council lookup failure: %s", + LOG_PREFIX, + type(exc).__name__, + ) + raise UpdateFailed( + "The council lookup failed with a recognized collector error." + ) from exc + except UpdateFailed: + raise except Exception as exc: - _LOGGER.exception(f"{LOG_PREFIX} Unexpected error: {exc}") - raise UpdateFailed(f"Unexpected error: {exc}") from exc + error_class = type(exc).__name__ + _LOGGER.error( + "%s Unexpected coordinator error (%s).", + LOG_PREFIX, + error_class, + ) + raise UpdateFailed(f"Unexpected collector error ({error_class}).") from None + + def _clear_active_run(self, completed: asyncio.Future) -> None: + """Release the in-flight marker and consume a late executor exception.""" + if self._run_state.active_run is completed: + self._run_state.active_run = None + if not completed.cancelled(): + try: + completed.exception() + except Exception: + # The next scheduled refresh reports its own fresh outcome; do not + # emit late thread exception details that may contain address data. + pass + if self._run_state.discard_when_idle: + _release_collector_run_state( + self.hass, + self.config_entry_id, + expected=self._run_state, + ) + + @property + def _active_run(self) -> asyncio.Future | None: + """Expose the shared in-flight future for diagnostics and tests.""" + return self._run_state.active_run @staticmethod def process_bin_data(data: dict) -> dict: """Process raw data to determine the next collection dates.""" - _LOGGER.debug(f"{LOG_PREFIX} process_bin_data called with data={data}") + _LOGGER.debug("%s Processing normalized collection data.", LOG_PREFIX) current_date = dt_util.now().date() next_collection_dates = {} bins = data.get("bins", []) - _LOGGER.debug(f"{LOG_PREFIX} Bins found: {bins}") + _LOGGER.debug("%s Collection rows received: %s", LOG_PREFIX, len(bins)) for bin_data in bins: bin_type = bin_data.get("type") collection_date_str = bin_data.get("collectionDate") - _LOGGER.debug(f"{LOG_PREFIX} Processing bin_data={bin_data}") - if not bin_type or not collection_date_str: _LOGGER.warning( - f"{LOG_PREFIX} Missing 'type' or 'collectionDate' in bin data: {bin_data}" + "%s Collection row is missing a type or date.", LOG_PREFIX ) continue @@ -409,7 +892,7 @@ def process_bin_data(data: dict) -> dict: ).date() except (ValueError, TypeError) as exc: _LOGGER.warning( - f"{LOG_PREFIX} Invalid date format '{collection_date_str}' for bin type '{bin_type}'. Error: {exc}" + "%s A collection row has an invalid date format.", LOG_PREFIX ) continue @@ -420,20 +903,19 @@ def process_bin_data(data: dict) -> dict: ): collection_date = collection_date.replace(year=current_date.year + 1) _LOGGER.debug( - f"{LOG_PREFIX} Corrected rollover year for '{bin_type}' to {collection_date}" + "%s Corrected a year-boundary collection date.", LOG_PREFIX ) - existing_date = next_collection_dates.get(bin_type) if collection_date >= current_date and ( not existing_date or collection_date < existing_date ): next_collection_dates[bin_type] = collection_date - _LOGGER.debug( - f"{LOG_PREFIX} Updated next collection for '{bin_type}' to {collection_date}" - ) + _LOGGER.debug("%s Updated a normalized collection date.", LOG_PREFIX) _LOGGER.debug( - f"{LOG_PREFIX} Final next_collection_dates={next_collection_dates}" + "%s Normalized collection types: %s", + LOG_PREFIX, + len(next_collection_dates), ) return next_collection_dates diff --git a/custom_components/uk_bin_collection/calendar.py b/custom_components/uk_bin_collection/calendar.py index 06a4df525d..459b2f122e 100644 --- a/custom_components/uk_bin_collection/calendar.py +++ b/custom_components/uk_bin_collection/calendar.py @@ -56,7 +56,7 @@ def event(self) -> Optional[CalendarEvent]: collection_date = self.coordinator.data.get(self._bin_type) if not collection_date: _LOGGER.debug( - f"{LOG_PREFIX} No collection date available for '{self._bin_type}'." + "%s No collection date available for this entity.", LOG_PREFIX ) return None @@ -127,9 +127,6 @@ async def async_setup_entry( "coordinator" ] - # Wait for the first refresh. This will raise if the update fails. - await coordinator.async_config_entry_first_refresh() - # Create calendar entities only for bin types that have a valid date entities = [] for bin_type, collection_date in coordinator.data.items(): @@ -148,9 +145,7 @@ async def async_setup_entry( # Register all calendar entities with Home Assistant async_add_entities(entities) - _LOGGER.debug( - f"{LOG_PREFIX} Calendar entities added: {[entity.name for entity in entities]}" - ) + _LOGGER.debug("%s Calendar entities added: %s", LOG_PREFIX, len(entities)) async def async_unload_entry( diff --git a/custom_components/uk_bin_collection/config_flow.py b/custom_components/uk_bin_collection/config_flow.py index e345a15d48..fc83d67db8 100644 --- a/custom_components/uk_bin_collection/config_flow.py +++ b/custom_components/uk_bin_collection/config_flow.py @@ -1,8 +1,9 @@ +import asyncio import json import logging import shutil -import asyncio -from typing import Any, Dict, Optional +from typing import Any, Dict, Mapping, Optional +from urllib.parse import urlsplit import aiohttp import homeassistant.helpers.config_validation as cv @@ -10,16 +11,205 @@ from homeassistant import config_entries from homeassistant.core import callback -import collections # At the top with other imports - -from .const import DOMAIN, LOG_PREFIX, SELENIUM_SERVER_URLS, BROWSER_BINARIES, INPUT_JSON_URL +from .const import ( + BROWSER_BINARIES, + CONFIG_ENTRY_VERSION, + DOMAIN, + INPUT_JSON_URL, + LOG_PREFIX, + SELENIUM_SERVER_URLS, + SOUTH_KESTEVEN_COUNCIL, +) _LOGGER = logging.getLogger(__name__) +COUNCIL_SCOPED_FIELDS = { + "url", + "postcode", + "number", + "paon", + "house_number", + "uprn", + "usrn", + "web_driver", + "headless", + "local_browser", + "skip_get_url", + "user_agent", + "artifact_dir", +} + + +def normalize_council_registry( + original_data: Mapping[str, Mapping[str, Any]], +) -> Dict[str, Dict[str, Any]]: + """Normalize real councils and aliases through one shared registry path.""" + normalized_data: Dict[str, Dict[str, Any]] = {} + for key, raw_value in original_data.items(): + value = dict(raw_value) + if "paon" in value and "house_number" not in value: + value["house_number"] = value["paon"] + normalized_data[key] = value + for alias in value.get("supported_councils", []): + alias_data = dict(value) + alias_data["original_parser"] = key + alias_data["wiki_name"] = ( + f"{alias.replace('Council', ' Council')} (via Google Calendar)" + ) + normalized_data[alias] = alias_data + return dict(sorted(normalized_data.items())) + + +async def async_fetch_council_registry() -> Dict[str, Dict[str, Any]]: + """Fetch and normalize the registry used by every config-entry flow.""" + try: + async with aiohttp.ClientSession() as session: + async with session.get(INPUT_JSON_URL, timeout=15) as response: + response.raise_for_status() + return normalize_council_registry(json.loads(await response.text())) + except Exception as exc: + _LOGGER.error("Error fetching council data (%s).", type(exc).__name__) + return {} + + +def apply_registry_metadata( + data: Dict[str, Any], council_key: str, council_info: Mapping[str, Any] +) -> None: + """Apply non-secret parser metadata consistently to persisted data.""" + data["council"] = council_key + original_parser = council_info.get("original_parser") + if original_parser: + data["original_parser"] = original_parser + else: + data.pop("original_parser", None) + + if council_info.get("skip_get_url", False): + data["skip_get_url"] = True + else: + data.pop("skip_get_url", None) + if data.get("skip_get_url") and not council_info.get( + "custom_component_show_url_field" + ): + data["url"] = council_info.get("url", data.get("url", "")) + + +def merge_council_data( + existing_data: Mapping[str, Any], + user_input: Mapping[str, Any], + *, + previous_council: str, + selected_council: str, +) -> Dict[str, Any]: + """Merge a form without carrying household data to a different council.""" + data = dict(existing_data) + if previous_council != selected_council: + for field in COUNCIL_SCOPED_FIELDS: + data.pop(field, None) + data.update(user_input) + return data + + +def validate_council_input( + council_info: Mapping[str, Any], data: Mapping[str, Any] +) -> Dict[str, str]: + """Validate registry-required fields, including browser selection.""" + errors: Dict[str, str] = {} + required_fields = [] + if not council_info.get("skip_get_url", False) or council_info.get( + "custom_component_show_url_field" + ): + required_fields.append("url") + for registry_key, form_key in ( + ("uprn", "uprn"), + ("postcode", "postcode"), + ("house_number", "number"), + ("usrn", "usrn"), + ): + if registry_key in council_info: + required_fields.append(form_key) + + for field in required_fields: + if not str(data.get(field, "")).strip(): + errors[field] = "required" + + if "web_driver" in council_info: + local_browser = bool(data.get("local_browser", False)) + web_driver = str(data.get("web_driver", "")).strip() + if data.get("council") == SOUTH_KESTEVEN_COUNCIL and ( + local_browser or not web_driver + ): + errors["web_driver"] = "remote_browser_required" + elif not local_browser and not web_driver: + errors["web_driver"] = "browser_required" + elif local_browser and web_driver: + errors["web_driver"] = "browser_conflict" + elif web_driver and not _is_valid_webdriver_url(web_driver): + errors["web_driver"] = "invalid_webdriver_url" + return errors + + +def _is_valid_webdriver_url(value: str) -> bool: + """Accept only an HTTP(S) endpoint with a syntactically valid host and port.""" + normalized = value.strip() + try: + parsed = urlsplit(normalized) + hostname = parsed.hostname + parsed.port + except (TypeError, ValueError): + return False + return bool( + parsed.scheme.casefold() in {"http", "https"} + and hostname + and not any(character.isspace() for character in normalized) + ) + + +def add_registry_fields( + fields: Dict[Any, Any], + council_info: Mapping[str, Any], + existing_data: Mapping[str, Any], +) -> None: + """Add registry-defined fields to setup, options, and reconfigure forms.""" + + def required(name: str, validator: Any) -> None: + if name in existing_data: + fields[vol.Required(name, default=existing_data[name])] = validator + else: + fields[vol.Required(name)] = validator + + if not council_info.get("skip_get_url", False) or council_info.get( + "custom_component_show_url_field" + ): + required("url", cv.string) + if "uprn" in council_info: + required("uprn", cv.string) + if "postcode" in council_info: + required("postcode", cv.string) + if "house_number" in council_info: + required("number", cv.string) + if "usrn" in council_info: + required("usrn", cv.string) + if "web_driver" in council_info: + fields[ + vol.Optional("web_driver", default=existing_data.get("web_driver", "")) + ] = cv.string + fields[ + vol.Optional("headless", default=existing_data.get("headless", True)) + ] = bool + fields[ + vol.Optional( + "local_browser", default=existing_data.get("local_browser", False) + ) + ] = bool + fields[ + vol.Optional("user_agent", default=existing_data.get("user_agent", "")) + ] = cv.string + + class UkBinCollectionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for UkBinCollection.""" - VERSION = 3 # Incremented version for config flow changes + VERSION = CONFIG_ENTRY_VERSION def __init__(self): self.councils_data: Optional[Dict[str, Any]] = None @@ -32,22 +222,12 @@ def __init__(self): self.chromium_checked: bool = False self.chromium_installed: bool = False - async def async_migrate_entry( - self, config_entry: config_entries.ConfigEntry - ) -> bool: - """Migrate old entry to the new version with manual refresh ticked.""" - _LOGGER.info("Migrating config entry from version %s", config_entry.version) - data = dict(config_entry.data) - - if config_entry.version < 3: - # If the manual_refresh_only key is not present, add it and set to True. - if "manual_refresh_only" not in data: - _LOGGER.info("Setting 'manual_refresh_only' to True in the migration") - data["manual_refresh_only"] = True - - self.hass.config_entries.async_update_entry(config_entry, data=data) - _LOGGER.info("Migration to version %s successful", self.VERSION) - return True + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Return an options flow; Home Assistant supplies its config entry.""" + del config_entry + return UkBinCollectionOptionsFlowHandler() async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): """Handle the initial step.""" @@ -57,7 +237,7 @@ async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): self.councils_data = await self.get_councils_json() if not self.councils_data: _LOGGER.error("Council data is unavailable.") - return self.async_abort(reason="Council Data Unavailable") + return self.async_abort(reason="council_data_unavailable") self.council_names = list(self.councils_data.keys()) self.council_options = [ @@ -66,32 +246,30 @@ async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): _LOGGER.debug("Loaded council data: %s", self.council_names) if user_input is not None: - _LOGGER.debug("User input received: %s", user_input) + _LOGGER.debug("User input fields received: %s", sorted(user_input)) # Validate user input if not user_input.get("name"): - errors["name"] = "Name is required." + errors["name"] = "name" if not user_input.get("council"): - errors["council"] = "Council is required." + errors["council"] = "council" # Validate JSON mapping if provided if user_input.get("icon_color_mapping"): if not self.is_valid_json(user_input["icon_color_mapping"]): - errors["icon_color_mapping"] = "Invalid JSON format." + errors["icon_color_mapping"] = "invalid_json" # Check for duplicate entries if not errors: existing_entry = await self._async_entry_exists(user_input) if existing_entry: errors["base"] = "duplicate_entry" - _LOGGER.warning( - "Duplicate entry found: %s", existing_entry.data.get("name") - ) + _LOGGER.warning("A duplicate UK Bin Collection entry was found.") if not errors: # Map selected wiki_name back to council key council_key = self.map_wiki_name_to_council_key(user_input["council"]) if not council_key: - errors["council"] = "Invalid council selected." + errors["council"] = "council" return self.async_show_form( step_id="user", data_schema=..., errors=errors ) @@ -105,7 +283,7 @@ async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): user_input["council"] = council_key self.data.update(user_input) - _LOGGER.debug("User input after mapping: %s", self.data) + _LOGGER.debug("Mapped council selection to %s", council_key) # Proceed to the council step return await self.async_step_council() @@ -133,24 +311,23 @@ async def async_step_council(self, user_input: Optional[Dict[str, Any]] = None): requires_selenium = "web_driver" in council_info if user_input is not None: - _LOGGER.debug("Council step user input: %s", user_input) + _LOGGER.debug("Council input fields received: %s", sorted(user_input)) # Validate JSON mapping if provided if user_input.get("icon_color_mapping"): if not self.is_valid_json(user_input["icon_color_mapping"]): - errors["icon_color_mapping"] = "Invalid JSON format." - - # Handle 'skip_get_url' if necessary - if council_info.get("skip_get_url", False): - user_input["skip_get_url"] = True - user_input["url"] = council_info.get("url", "") + errors["icon_color_mapping"] = "invalid_json" - # Merge user_input with existing data - self.data.update(user_input) + candidate = {**self.data, **user_input} + apply_registry_metadata(candidate, council_key, council_info) + errors.update(validate_council_input(council_info, candidate)) # If no errors, create the config entry if not errors: + self.data = candidate _LOGGER.info( - "%s Creating config entry with data: %s", LOG_PREFIX, self.data + "%s Creating config entry for council %s", + LOG_PREFIX, + council_key, ) return self.async_create_entry(title=self.data["name"], data=self.data) else: @@ -167,7 +344,7 @@ async def async_step_council(self, user_input: Optional[Dict[str, Any]] = None): # Show the form return self.async_show_form( step_id="council", - data_schema=await self.get_council_schema(council_key), + data_schema=await self.get_council_schema(council_key, user_input or {}), errors=errors, description_placeholders=description_placeholders, ) @@ -186,10 +363,12 @@ async def async_step_reconfigure_confirm( ) if existing_entry is None: _LOGGER.error("Reconfiguration failed: Config entry not found.") - return self.async_abort(reason="Reconfigure Failed") + return self.async_abort(reason="reconfigure_failed") if self.councils_data is None: self.councils_data = await self.get_councils_json() + if not self.councils_data: + return self.async_abort(reason="council_data_unavailable") self.council_names = list(self.councils_data.keys()) self.council_options = [ self.councils_data[name]["wiki_name"] for name in self.council_names @@ -201,10 +380,20 @@ async def async_step_reconfigure_confirm( council_wiki_name = council_info.get("wiki_name", "") if user_input is not None: - _LOGGER.debug("Reconfigure user input: %s", user_input) + _LOGGER.debug("Reconfigure fields received: %s", sorted(user_input)) # Map selected wiki_name back to council key + existing_council_key = existing_entry.data.get("council", "") council_key = self.map_wiki_name_to_council_key(user_input["council"]) - user_input["council"] = council_key + if not council_key: + errors["council"] = "council" + council_key = existing_council_key + elif council_key != existing_council_key: + # A council change needs a fresh schema before any address fields + # can be accepted. Existing-entry flows are deliberately immutable + # so stale postcode/UPRN/WebDriver values cannot cross councils. + errors["council"] = "council_change_not_supported" + council_key = existing_council_key + council_info = self.councils_data.get(council_key, {}) # Validate update_interval update_interval = user_input.get("update_interval") @@ -212,18 +401,25 @@ async def async_step_reconfigure_confirm( try: update_interval = int(update_interval) if update_interval < 1: - errors["update_interval"] = "Must be at least 1 hour." + errors["update_interval"] = "invalid_update_interval" except ValueError: - errors["update_interval"] = "Invalid number." + errors["update_interval"] = "invalid_update_interval" # Validate JSON mapping if provided if user_input.get("icon_color_mapping"): if not self.is_valid_json(user_input["icon_color_mapping"]): - errors["icon_color_mapping"] = "Invalid JSON format." + errors["icon_color_mapping"] = "invalid_json" + + data = merge_council_data( + existing_entry.data, + user_input, + previous_council=existing_entry.data.get("council", ""), + selected_council=council_key, + ) + apply_registry_metadata(data, council_key, council_info) + errors.update(validate_council_input(council_info, data)) if not errors: - # Merge the user input with existing data - data = {**existing_entry.data, **user_input} data["icon_color_mapping"] = user_input.get("icon_color_mapping", "") self.hass.config_entries.async_update_entry( @@ -236,12 +432,21 @@ async def async_step_reconfigure_confirm( _LOGGER.info( "Configuration updated for entry: %s", existing_entry.entry_id ) - return self.async_abort(reason="Reconfigure Successful") + return self.async_abort(reason="reconfigure_successful") else: _LOGGER.debug("Errors in reconfiguration: %s", errors) # Build the schema with existing data - schema = self.build_reconfigure_schema(existing_entry.data, council_wiki_name) + schema_data = ( + {**existing_entry.data, **user_input} + if user_input is not None + else dict(existing_entry.data) + ) + schema_data["council"] = council_key + council_wiki_name = self.councils_data.get(council_key, {}).get( + "wiki_name", council_wiki_name + ) + schema = self.build_reconfigure_schema(schema_data, council_wiki_name) return self.async_show_form( step_id="reconfigure_confirm", @@ -251,103 +456,54 @@ async def async_step_reconfigure_confirm( ) async def get_councils_json(self) -> Dict[str, Any]: - """Fetch and return the supported councils data, including aliases and sorted alphabetically.""" - try: - async with aiohttp.ClientSession() as session: - async with session.get(INPUT_JSON_URL) as response: - response.raise_for_status() - data_text = await response.text() - original_data = json.loads(data_text) - - normalized_data = {} - for key, value in original_data.items(): - normalized_data[key] = value - for alias in value.get("supported_councils", []): - alias_data = value.copy() - alias_data["original_parser"] = key - alias_data["wiki_name"] = ( - f"{alias.replace('Council', ' Council')} (via Google Calendar)" - ) - normalized_data[alias] = alias_data - - # Sort alphabetically by key (council ID) - sorted_data = dict(sorted(normalized_data.items())) - - _LOGGER.debug( - "Loaded and sorted %d councils (with aliases)", len(sorted_data) - ) - return sorted_data - - except Exception as e: - _LOGGER.exception("Error fetching council data: %s", e) - return {} + """Fetch the shared normalized council registry.""" + return await async_fetch_council_registry() - async def get_council_schema(self, council: str) -> vol.Schema: + async def get_council_schema( + self, council: str, existing_data: Mapping[str, Any] | None = None + ) -> vol.Schema: """Generate the form schema based on council requirements.""" council_info = self.councils_data.get(council, {}) + existing_data = existing_data or {} fields = {} + add_registry_fields(fields, council_info, existing_data) - if not council_info.get("skip_get_url", False) or council_info.get( - "custom_component_show_url_field" - ): - fields[vol.Required("url")] = cv.string - if "uprn" in council_info: - fields[vol.Required("uprn")] = cv.string - if "postcode" in council_info: - fields[vol.Required("postcode")] = cv.string - if "house_number" in council_info: - fields[vol.Required("number")] = cv.string - if "usrn" in council_info: - fields[vol.Required("usrn")] = cv.string - if "web_driver" in council_info: - fields[vol.Optional("web_driver", default="")] = cv.string - fields[vol.Optional("headless", default=True)] = bool - fields[vol.Optional("local_browser", default=False)] = bool - - fields[vol.Optional("timeout", default=60)] = vol.All( - vol.Coerce(int), vol.Range(min=10) + fields[vol.Optional("timeout", default=existing_data.get("timeout", 60))] = ( + vol.All(vol.Coerce(int), vol.Range(min=10)) ) - fields[vol.Optional("update_interval", default=12)] = vol.All( - cv.positive_int, vol.Range(min=1) - ) + fields[ + vol.Optional( + "update_interval", default=existing_data.get("update_interval", 12) + ) + ] = vol.All(cv.positive_int, vol.Range(min=1)) return vol.Schema(fields) def build_reconfigure_schema( self, existing_data: Dict[str, Any], council_wiki_name: str ) -> vol.Schema: - """Build the schema for reconfiguration with existing data.""" + """Build reconfigure fields from the registry, not saved-key presence.""" + council_info = (self.councils_data or {}).get( + existing_data.get("council", ""), {} + ) fields = { vol.Required("name", default=existing_data.get("name", "")): str, vol.Required("council", default=council_wiki_name): vol.In( - self.council_options + [council_wiki_name] ), vol.Optional( "manual_refresh_only", - default=existing_data.get("manual_refresh_only", False), + default=existing_data.get("manual_refresh_only", True), ): bool, vol.Required( "update_interval", default=existing_data.get("update_interval", 12) ): vol.All(cv.positive_int, vol.Range(min=1)), } - - optional_fields = [ - ("url", cv.string), - ("uprn", cv.string), - ("postcode", cv.string), - ("number", cv.string), - ("web_driver", cv.string), - ("headless", bool), - ("local_browser", bool), - ("timeout", vol.All(vol.Coerce(int), vol.Range(min=10))), - ] - - for field_name, validator in optional_fields: - if field_name in existing_data: - fields[vol.Optional(field_name, default=existing_data[field_name])] = ( - validator - ) + add_registry_fields(fields, council_info, existing_data) + fields[vol.Optional("timeout", default=existing_data.get("timeout", 60))] = ( + vol.All(vol.Coerce(int), vol.Range(min=10)) + ) fields[ vol.Optional( @@ -364,7 +520,7 @@ async def perform_selenium_checks(self, council_key: str) -> str: council_info = self.councils_data.get(council_key, {}) council_name = council_info.get("wiki_name", council_key) - custom_selenium_url = self.data.get("selenium_url") + custom_selenium_url = self.data.get("web_driver") selenium_results = await self.check_selenium_server(custom_selenium_url) self.selenium_available = any(accessible for _, accessible in selenium_results) self.selenium_checked = True @@ -405,15 +561,13 @@ async def check_selenium_server(self, custom_url: Optional[str] = None) -> list: response.raise_for_status() accessible = response.status == 200 results.append((url, accessible)) - _LOGGER.debug("Selenium server %s is accessible.", url) - except aiohttp.ClientError as e: - _LOGGER.warning( - "Failed to connect to Selenium server at %s: %s", url, e - ) + _LOGGER.debug("A configured Selenium endpoint is accessible.") + except aiohttp.ClientError: + _LOGGER.warning("A configured Selenium endpoint is unavailable.") results.append((url, False)) - except Exception as e: - _LOGGER.exception( - "Unexpected error checking Selenium server at %s: %s", url, e + except Exception: + _LOGGER.warning( + "Unexpected failure while checking a Selenium endpoint." ) results.append((url, False)) return results @@ -437,7 +591,9 @@ def _sync_check_chromium(self) -> bool: return True except Exception as e: _LOGGER.error( - f"Exception while checking for executable '{exec_name}': {e}" + "Executable availability check failed for '%s' (%s)", + exec_name, + type(e).__name__, ) continue # Continue checking other binaries _LOGGER.debug("No Chromium executable found.") @@ -463,7 +619,7 @@ def is_valid_json(json_str: str) -> bool: json.loads(json_str) return True except json.JSONDecodeError as e: - _LOGGER.debug("JSON decode error: %s", e) + _LOGGER.debug("JSON decode error (%s)", type(e).__name__) return False async def _async_entry_exists( @@ -489,12 +645,9 @@ async def async_step_import( class UkBinCollectionOptionsFlowHandler(config_entries.OptionsFlow): """Handle options flow for UkBinCollection.""" - def __init__(self, config_entry): - """Initialize options flow.""" - self.config_entry = config_entry - self.councils_data: Optional[Dict[str, Any]] = None - self.council_names: list = [] - self.council_options: list = [] + councils_data: Optional[Dict[str, Any]] = None + council_names: list = [] + council_options: list = [] async def async_step_init(self, user_input=None): """Manage the options.""" @@ -505,7 +658,7 @@ async def async_step_init(self, user_input=None): self.councils_data = await self.get_councils_json() if not self.councils_data: _LOGGER.error("Council data is unavailable for options flow.") - return self.async_abort(reason="Council Data Unavailable") + return self.async_abort(reason="council_data_unavailable") self.council_names = list(self.councils_data.keys()) self.council_options = [ @@ -514,10 +667,17 @@ async def async_step_init(self, user_input=None): _LOGGER.debug("Loaded council data for options flow.") if user_input is not None: - _LOGGER.debug("Options flow user input: %s", user_input) + _LOGGER.debug("Options fields received: %s", sorted(user_input)) # Map selected wiki_name back to council key + existing_council_key = existing_data.get("council", "") council_key = self.map_wiki_name_to_council_key(user_input["council"]) - user_input["council"] = council_key + if not council_key: + errors["council"] = "council" + council_key = existing_council_key + elif council_key != existing_council_key: + errors["council"] = "council_change_not_supported" + council_key = existing_council_key + council_info = self.councils_data.get(council_key, {}) # Validate update_interval update_interval = user_input.get("update_interval") @@ -525,23 +685,27 @@ async def async_step_init(self, user_input=None): try: update_interval = int(update_interval) if update_interval < 1: - errors["update_interval"] = "Must be at least 1 hour." + errors["update_interval"] = "invalid_update_interval" except ValueError: - errors["update_interval"] = "Invalid number." + errors["update_interval"] = "invalid_update_interval" # Validate JSON mapping if provided if user_input.get("icon_color_mapping"): if not UkBinCollectionConfigFlow.is_valid_json( user_input["icon_color_mapping"] ): - errors["icon_color_mapping"] = "Invalid JSON format." + errors["icon_color_mapping"] = "invalid_json" - if user_input.get("manual_refresh_only"): - user_input["update_interval"] = None + data = merge_council_data( + existing_data, + user_input, + previous_council=existing_data.get("council", ""), + selected_council=council_key, + ) + apply_registry_metadata(data, council_key, council_info) + errors.update(validate_council_input(council_info, data)) if not errors: - # Merge the user input with existing data - data = {**existing_data, **user_input} data["icon_color_mapping"] = user_input.get("icon_color_mapping", "") self.hass.config_entries.async_update_entry( @@ -556,7 +720,15 @@ async def async_step_init(self, user_input=None): _LOGGER.debug("Errors in options flow: %s", errors) # Build the form with existing data - schema = self.build_options_schema(existing_data) + schema_data = ( + {**existing_data, **user_input} + if user_input is not None + else dict(existing_data) + ) + schema_data["council"] = ( + council_key if user_input is not None else existing_data.get("council", "") + ) + schema = self.build_options_schema(schema_data) return self.async_show_form( step_id="init", @@ -566,28 +738,13 @@ async def async_step_init(self, user_input=None): ) async def get_councils_json(self) -> Dict[str, Any]: - """Fetch and return the supported councils data.""" - try: - async with aiohttp.ClientSession() as session: - async with session.get(INPUT_JSON_URL) as response: - response.raise_for_status() - data_text = await response.text() - return json.loads(data_text) - except aiohttp.ClientError as e: - _LOGGER.error( - "HTTP error while fetching council data for options flow: %s", e - ) - except json.JSONDecodeError as e: - _LOGGER.error("Error decoding council data JSON for options flow: %s", e) - except Exception as e: - _LOGGER.exception( - "Unexpected error while fetching council data for options flow: %s", e - ) - return {} + """Fetch the same normalized registry used by setup and reconfigure.""" + return await async_fetch_council_registry() def build_options_schema(self, existing_data: Dict[str, Any]) -> vol.Schema: - """Build the schema for the options flow with existing data.""" + """Build options fields from the current registry contract.""" council_current_key = existing_data.get("council", "") + council_info = (self.councils_data or {}).get(council_current_key, {}) try: council_current_wiki = self.council_options[ self.council_names.index(council_current_key) @@ -598,24 +755,27 @@ def build_options_schema(self, existing_data: Dict[str, Any]) -> vol.Schema: fields = { vol.Required("name", default=existing_data.get("name", "")): str, vol.Required("council", default=council_current_wiki): vol.In( - self.council_options + [council_current_wiki] ), - vol.Optional("manual_refresh_only", default=False): bool, + vol.Optional( + "manual_refresh_only", + default=existing_data.get("manual_refresh_only", True), + ): bool, vol.Required( "update_interval", default=existing_data.get("update_interval", 12) ): vol.All(cv.positive_int, vol.Range(min=1)), } - optional_fields = [ - ("icon_color_mapping", cv.string), - # Add other optional fields if necessary - ] - - for field_name, validator in optional_fields: - if field_name in existing_data: - fields[vol.Optional(field_name, default=existing_data[field_name])] = ( - validator - ) + add_registry_fields(fields, council_info, existing_data) + fields[vol.Optional("timeout", default=existing_data.get("timeout", 60))] = ( + vol.All(vol.Coerce(int), vol.Range(min=10)) + ) + fields[ + vol.Optional( + "icon_color_mapping", + default=existing_data.get("icon_color_mapping", ""), + ) + ] = cv.string return vol.Schema(fields) @@ -639,10 +799,11 @@ def is_valid_json(json_str: str) -> bool: json.loads(json_str) return True except json.JSONDecodeError as e: - _LOGGER.debug("JSON decode error in options flow: %s", e) + _LOGGER.debug("JSON decode error in options flow (%s)", type(e).__name__) return False -async def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return UkBinCollectionOptionsFlowHandler(config_entry) +@callback +def async_get_options_flow(config_entry): + """Backward-compatible wrapper around the registered options callback.""" + return UkBinCollectionConfigFlow.async_get_options_flow(config_entry) diff --git a/custom_components/uk_bin_collection/const.py b/custom_components/uk_bin_collection/const.py index 71132ab69e..7b845f14af 100644 --- a/custom_components/uk_bin_collection/const.py +++ b/custom_components/uk_bin_collection/const.py @@ -12,7 +12,7 @@ LOG_PREFIX = "[UKBinCollection]" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.CALENDAR] STATE_ATTR_COLOUR = "colour" STATE_ATTR_NEXT_COLLECTION = "next_collection" @@ -20,21 +20,28 @@ DEVICE_CLASS = "bin_collection_schedule" -PLATFORMS = ["sensor", "calendar"] - SELENIUM_SERVER_URLS = ["http://localhost:4444", "http://selenium:4444"] BROWSER_BINARIES = ["chromium", "chromium-browser", "google-chrome"] -EXCLUDED_ARG_KEYS = { - "name", - "council", - "url", - "skip_get_url", - "local_browser", - "timeout", - "icon_color_mapping", - "update_interval", - "manual_refresh_only", - "original_parser", +CONFIG_ENTRY_VERSION = 4 + +SOUTH_KESTEVEN_COUNCIL = "SouthKestevenDistrictCouncil" +SOUTH_KESTEVEN_URL = "https://www.southkesteven.gov.uk/binday" + +# Only these config-entry values are part of the core library's CLI contract. In +# particular, unknown Home Assistant fields must never be forwarded to argparse. +STRING_ARGUMENTS = { + "postcode": "--postcode", + "number": "--number", + "uprn": "--uprn", + "usrn": "--usrn", + "web_driver": "--web_driver", + "artifact_dir": "--artifact-dir", + "user_agent": "--user-agent", +} + +TRUE_FLAG_ARGUMENTS = { + "skip_get_url": "--skip_get_url", + "local_browser": "--local_browser", } diff --git a/custom_components/uk_bin_collection/sensor.py b/custom_components/uk_bin_collection/sensor.py index 7643a3e69e..7caa11014b 100644 --- a/custom_components/uk_bin_collection/sensor.py +++ b/custom_components/uk_bin_collection/sensor.py @@ -103,7 +103,8 @@ def load_icon_color_mapping(icon_color_mapping: str) -> Dict[str, Any]: return json.loads(icon_color_mapping) if icon_color_mapping else {} except JSONDecodeError: _LOGGER.warning( - f"{LOG_PREFIX} Invalid icon_color_mapping JSON: {icon_color_mapping}. Using default settings." + "%s Invalid icon_color_mapping JSON. Using default settings.", + LOG_PREFIX, ) return {} @@ -164,9 +165,7 @@ def update_state(self) -> None: # not an error, so show a friendly state rather than going # Unavailable (availability itself is driven by whether the # coordinator update as a whole succeeded). - _LOGGER.debug( - f"{LOG_PREFIX} No upcoming date for bin type '{self._bin_type}'." - ) + _LOGGER.debug("%s No upcoming date for this entity.", LOG_PREFIX) self._state = "No collections scheduled" self._days = None self._next_collection = None @@ -287,9 +286,7 @@ def state(self): elif self._attribute_type == "Days Until Collection": return self.calculate_days_until() else: - _LOGGER.warning( - f"{LOG_PREFIX} Undefined attribute type: {self._attribute_type}" - ) + _LOGGER.warning("%s Entity has an undefined attribute type.", LOG_PREFIX) return "Undefined" def calculate_human_readable(self) -> str: diff --git a/custom_components/uk_bin_collection/strings.json b/custom_components/uk_bin_collection/strings.json index 5431c9e8db..d65f626c73 100644 --- a/custom_components/uk_bin_collection/strings.json +++ b/custom_components/uk_bin_collection/strings.json @@ -1,59 +1,130 @@ { - "title": "UK Bin Collection Data", - "config": { - "step": { - "user": { - "title": "Select the council", - "data": { - "name": "Location name", - "council": "Council", - "manual_refresh_only":"Automatically refresh the sensor", - "icon_color_mapping": "JSON to map Bin Type for Colour and Icon (see documentation)" - }, - "description": "Please see the documentation if your council isn't listed" - }, - "council": { - "title": "Provide council details", - "data": { - "url": "URL to fetch bin collection data", - "timeout": "The time in seconds for how long the sensor should wait for data", - "update_interval": "Time in hours between updates", - "uprn": "UPRN (Unique Property Reference Number)", - "postcode": "Postcode of the address", - "number": "House number of the address", - "usrn": "USRN (Unique Street Reference Number)", - "web_driver": "To run on a remote Selenium Server add the Selenium Server URL", - "headless": "Run Selenium in headless mode (recommended)", - "local_browser": "Don't run on remote Selenium server, use local install of Chrome instead", - "submit": "Submit" - }, - "description": "Please refer to your council's wiki entry for details on what to enter.\n{selenium_message}" - }, - "reconfigure_confirm": { - "title": "Update council details", - "data": { - "url": "URL to fetch bin collection data", - "timeout": "The time in seconds for how long the sensor should wait for data", - "update_interval": "Time in hours between updates", - "uprn": "UPRN (Unique Property Reference Number)", - "postcode": "Postcode of the address", - "number": "House number of the address", - "usrn": "USRN (Unique Street Reference Number)", - "web_driver": "To run on a remote Selenium Server add the Selenium Server URL", - "headless": "Run Selenium in headless mode (recommended)", - "local_browser": "Don't run on remote Selenium server, use local install of Chrome instead", - "manual_refresh_only":"Automatically refresh the sensor", - "icon_color_mapping": "JSON to map Bin Type for Colour and Icon (see documentation)", - "submit": "Submit" - }, - "description": "Please refer to your council's wiki entry for details on what to enter." - } + "title": "UK Bin Collection Data", + "config": { + "step": { + "user": { + "title": "Select the council", + "data": { + "name": "Location name", + "council": "Council", + "manual_refresh_only": "Manual refresh only", + "icon_color_mapping": "JSON to map bin type to colour and icon" }, - "error": { - "name": "Please enter a location name", - "council": "Please select a council", - "selenium_unavailable": "Selenium server is not accessible. Please ensure it is running at localhost:4444 or selenium:4444", - "chromium_not_found": "Chromium browser is not installed. Please install Chromium or Google Chrome" - } + "description": "Please see the documentation if your council is not listed." + }, + "council": { + "title": "Provide council details", + "data": { + "url": "URL to fetch bin collection data", + "timeout": "Maximum lookup time in seconds", + "update_interval": "Time in hours between updates", + "uprn": "UPRN (Unique Property Reference Number)", + "postcode": "Postcode of the address", + "number": "House number or name", + "usrn": "USRN (Unique Street Reference Number)", + "web_driver": "Remote Selenium WebDriver URL", + "headless": "Run Selenium in headless mode (recommended)", + "local_browser": "Use a locally installed Chrome browser", + "user_agent": "Optional browser user agent", + "submit": "Submit" + }, + "description": "Please refer to your council's wiki entry for details on what to enter.\n{selenium_message}" + }, + "reconfigure_confirm": { + "title": "Update council details", + "data": { + "name": "Location name", + "council": "Council", + "url": "URL to fetch bin collection data", + "timeout": "Maximum lookup time in seconds", + "update_interval": "Time in hours between updates", + "uprn": "UPRN (Unique Property Reference Number)", + "postcode": "Postcode of the address", + "number": "House number or name", + "usrn": "USRN (Unique Street Reference Number)", + "web_driver": "Remote Selenium WebDriver URL", + "headless": "Run Selenium in headless mode (recommended)", + "local_browser": "Use a locally installed Chrome browser", + "user_agent": "Optional browser user agent", + "manual_refresh_only": "Manual refresh only", + "icon_color_mapping": "JSON to map bin type to colour and icon", + "submit": "Submit" + }, + "description": "Please refer to your council's wiki entry for details on what to enter." + } + }, + "error": { + "name": "Please enter a location name", + "council": "Please select a council", + "selenium_unavailable": "The Selenium server is not accessible", + "chromium_not_found": "Chromium or Google Chrome is not installed", + "required": "This field is required", + "invalid_json": "Enter valid JSON", + "invalid_update_interval": "Enter an update interval of at least one hour", + "browser_required": "Enter a remote Selenium WebDriver URL or select local browser", + "browser_conflict": "Choose either a remote Selenium WebDriver or local browser, not both", + "remote_browser_required": "South Kesteven requires a remote Selenium WebDriver; enter its HTTP(S) URL and turn off local browser", + "invalid_webdriver_url": "Enter a valid HTTP or HTTPS Selenium WebDriver URL with a host", + "council_change_not_supported": "An existing entry cannot change council. Add a new integration entry for a different council", + "duplicate_entry": "An equivalent UK Bin Collection entry already exists" + }, + "abort": { + "council_data_unavailable": "Council metadata is currently unavailable", + "reconfigure_failed": "The integration entry could not be found", + "reconfigure_successful": "The integration entry was updated successfully" + } + }, + "options": { + "step": { + "init": { + "title": "Update UK Bin Collection options", + "data": { + "name": "Location name", + "council": "Council", + "url": "URL to fetch bin collection data", + "timeout": "Maximum lookup time in seconds", + "update_interval": "Time in hours between updates", + "uprn": "UPRN (Unique Property Reference Number)", + "postcode": "Postcode of the address", + "number": "House number or name", + "usrn": "USRN (Unique Street Reference Number)", + "web_driver": "Remote Selenium WebDriver URL", + "headless": "Run Selenium in headless mode (recommended)", + "local_browser": "Use a locally installed Chrome browser", + "user_agent": "Optional browser user agent", + "manual_refresh_only": "Manual refresh only", + "icon_color_mapping": "JSON to map bin type to colour and icon" + }, + "description": "Update scheduling, address, and browser settings for this entry." + } + }, + "error": { + "council": "Please select a council", + "required": "This field is required", + "invalid_json": "Enter valid JSON", + "invalid_update_interval": "Enter an update interval of at least one hour", + "browser_required": "Enter a remote Selenium WebDriver URL or select local browser", + "browser_conflict": "Choose either a remote Selenium WebDriver or local browser, not both", + "remote_browser_required": "South Kesteven requires a remote Selenium WebDriver; enter its HTTP(S) URL and turn off local browser", + "invalid_webdriver_url": "Enter a valid HTTP or HTTPS Selenium WebDriver URL with a host", + "council_change_not_supported": "An existing entry cannot change council. Add a new integration entry for a different council" + }, + "abort": { + "council_data_unavailable": "Council metadata is currently unavailable" + } + }, + "issues": { + "dependency_error": { + "title": "A Python dependency is missing or shadowed", + "description": "UK Bin Collection could not safely load a browser dependency. {details} Check the reported module path; a package placed directly in /config can shadow the installed Python package. Move the conflicting folder out of /config, restart Home Assistant, and reload this entry." + }, + "missing_required_configuration": { + "title": "UK Bin Collection configuration is incomplete", + "description": "This entry is missing: {fields}. Use Reconfigure on the integration entry and supply the missing address/browser values." + }, + "browser_unavailable": { + "title": "The Selenium browser is unavailable", + "description": "UK Bin Collection could not reach or start its configured browser. {details} Check the WebDriver address or local browser, then reload this entry." } + } } diff --git a/custom_components/uk_bin_collection/tests/common_utils.py b/custom_components/uk_bin_collection/tests/common_utils.py index 47838e976a..92f2bc06d4 100644 --- a/custom_components/uk_bin_collection/tests/common_utils.py +++ b/custom_components/uk_bin_collection/tests/common_utils.py @@ -30,6 +30,12 @@ def __init__( self.entry_id = entry_id or uuid.uuid4().hex self.version = version self.state = config_entries.ConfigEntryState.NOT_LOADED + self._on_unload_callbacks = [] + + def async_on_unload(self, callback): + """Register an unload callback using the ConfigEntry contract.""" + self._on_unload_callbacks.append(callback) + return callback def add_to_hass(self, hass): """Add the mock config entry to Home Assistant.""" diff --git a/custom_components/uk_bin_collection/tests/test_calendar.py b/custom_components/uk_bin_collection/tests/test_calendar.py index 2351b49a5d..c246c3f909 100644 --- a/custom_components/uk_bin_collection/tests/test_calendar.py +++ b/custom_components/uk_bin_collection/tests/test_calendar.py @@ -21,6 +21,12 @@ pytest_plugins = ["freezegun"] +# Do not autospec UKBinCollectionCalendar in the setup tests below. Home +# Assistant 2026.4 and 2026.5 expose an EntityPlatform annotation that cannot +# be resolved when unittest.mock recursively inspects the inherited class. +# The tests assert every constructor call explicitly, so a plain class mock +# retains the intended contract without introspecting Home Assistant internals. + # Mock Data MOCK_COORDINATOR_DATA = { "Recycling": date(2024, 4, 25), @@ -199,12 +205,14 @@ def test_calendar_update_on_coordinator_change(hass_instance, mock_coordinator): # Update the coordinator's data mock_coordinator.data["Recycling"] = collection_date_updated - mock_coordinator.async_write_ha_state = AsyncMock() + mock_coordinator.async_write_ha_state = MagicMock() # Simulate coordinator update by calling the update handler - with patch.object(calendar, "async_write_ha_state", new=AsyncMock()) as mock_write: + with patch.object(calendar, "async_write_ha_state", new=MagicMock()) as mock_write: calendar._handle_coordinator_update() + mock_write.assert_called_once_with() + # The event should now be updated to April 26 expected_event_updated = CalendarEvent( summary="Recycling Collection", @@ -234,7 +242,6 @@ async def test_async_setup_entry_creates_calendar_entities( with patch( "custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", - autospec=True, ) as mock_calendar_cls: mock_calendar_instance_recycling = MagicMock() mock_calendar_instance_general_waste = MagicMock() @@ -279,7 +286,6 @@ async def test_async_setup_entry_handles_empty_data(hass_instance, mock_config_e with patch( "custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", - autospec=True, ) as mock_calendar_cls: await async_setup_entry(hass_instance, mock_config_entry, lambda entities: None) @@ -288,14 +294,15 @@ async def test_async_setup_entry_handles_empty_data(hass_instance, mock_config_e @pytest.mark.asyncio -async def test_async_setup_entry_handles_coordinator_failure( +async def test_async_setup_entry_does_not_refresh_coordinator( hass_instance, mock_config_entry ): - """Test that async_setup_entry raises ConfigEntryNotReady on coordinator failure.""" + """The integration setup owns the one and only initial refresh.""" mock_coordinator = MagicMock(spec=DataUpdateCoordinator) mock_coordinator.async_config_entry_first_refresh.side_effect = Exception( "Update failed" ) + mock_coordinator.data = {} mock_coordinator.name = "Test Council" # Patch the hass.data to include the coordinator @@ -303,8 +310,8 @@ async def test_async_setup_entry_handles_coordinator_failure( "coordinator": mock_coordinator, } - with pytest.raises(Exception, match="Update failed"): - await async_setup_entry(hass_instance, mock_config_entry, lambda entities: None) + await async_setup_entry(hass_instance, mock_config_entry, lambda entities: None) + mock_coordinator.async_config_entry_first_refresh.assert_not_called() @pytest.mark.asyncio @@ -363,7 +370,6 @@ async def test_async_setup_entry_creates_no_calendar_entities_on_empty_data( with patch( "custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", - autospec=True, ) as mock_calendar_cls: await async_setup_entry(hass_instance, mock_config_entry, lambda entities: None) @@ -372,14 +378,15 @@ async def test_async_setup_entry_creates_no_calendar_entities_on_empty_data( @pytest.mark.asyncio -async def test_async_setup_entry_with_coordinator_failure( +async def test_async_setup_entry_ignores_stale_refresh_side_effect( hass_instance, mock_config_entry ): - """Test that async_setup_entry handles coordinator failures gracefully.""" + """Calendar setup consumes coordinator data without refreshing it.""" mock_coordinator = MagicMock(spec=DataUpdateCoordinator) mock_coordinator.async_config_entry_first_refresh.side_effect = Exception( "Update failed" ) + mock_coordinator.data = {} mock_coordinator.name = "Test Council" # Patch the hass.data to include the coordinator @@ -387,15 +394,15 @@ async def test_async_setup_entry_with_coordinator_failure( "coordinator": mock_coordinator, } - with pytest.raises(Exception, match="Update failed"): - await async_setup_entry(hass_instance, mock_config_entry, lambda entities: None) + await async_setup_entry(hass_instance, mock_config_entry, lambda entities: None) + mock_coordinator.async_config_entry_first_refresh.assert_not_called() @pytest.mark.asyncio -async def test_async_setup_entry_handles_coordinator_failure( +async def test_async_setup_entry_never_calls_refresh_mock( hass_instance, mock_config_entry ): - """Test that async_setup_entry raises an exception when coordinator refresh fails.""" + """A configured failing refresh mock is not touched by calendar setup.""" mock_coordinator = MagicMock(spec=DataUpdateCoordinator) # Provide an empty data dictionary so that accessing .data does not fail immediately. mock_coordinator.data = {} @@ -409,8 +416,8 @@ async def test_async_setup_entry_handles_coordinator_failure( "coordinator": mock_coordinator } - with pytest.raises(Exception, match="Update failed"): - await async_setup_entry(hass_instance, mock_config_entry, lambda entities: None) + await async_setup_entry(hass_instance, mock_config_entry, lambda entities: None) + mock_coordinator.async_config_entry_first_refresh.assert_not_called() @pytest.mark.asyncio @@ -539,7 +546,6 @@ async def test_async_setup_entry_handles_coordinator_partial_data( with patch( "custom_components.uk_bin_collection.calendar.UKBinCollectionCalendar", - autospec=True, ) as mock_calendar_cls: mock_calendar_instance_recycling = MagicMock() mock_calendar_instance_garden_waste = MagicMock() diff --git a/custom_components/uk_bin_collection/tests/test_config_flow.py b/custom_components/uk_bin_collection/tests/test_config_flow.py index 9cfff054b3..6de35e5933 100644 --- a/custom_components/uk_bin_collection/tests/test_config_flow.py +++ b/custom_components/uk_bin_collection/tests/test_config_flow.py @@ -4,6 +4,7 @@ import asyncio import json +import logging from datetime import date, datetime, timedelta from json import JSONDecodeError from unittest.mock import AsyncMock, MagicMock, patch @@ -19,20 +20,16 @@ from custom_components.uk_bin_collection.config_flow import ( UkBinCollectionConfigFlow, UkBinCollectionOptionsFlowHandler, - async_get_options_flow, + apply_registry_metadata, + merge_council_data, + normalize_council_registry, + validate_council_input, ) from custom_components.uk_bin_collection.const import DOMAIN, LOG_PREFIX from custom_components.uk_bin_collection.sensor import load_icon_color_mapping from .common_utils import MockConfigEntry - -@pytest.fixture -def hass_with_loop(hass, event_loop): - hass.loop = event_loop - return hass - - # Mock council data representing different scenarios MOCK_COUNCILS_DATA = { "CouncilTest": { @@ -86,17 +83,17 @@ def hass_with_loop(hass, event_loop): # Create a dummy HomeAssistant object. class DummyHass: - def __init__(self, loop): + def __init__(self): self.data = {} self.config_entries = MagicMock() self.config_entries.async_update_entry = AsyncMock() self.config_entries.async_reload = AsyncMock() - self.loop = loop + self.loop = MagicMock() @pytest.fixture -def dummy_hass(event_loop): - return DummyHass(event_loop) +def dummy_hass(): + return DummyHass() # A sample councils data for the options flow tests. @@ -117,6 +114,8 @@ def options_flow(dummy_hass): data={ "name": "Test Options", "council": "CouncilTest", + "url": "https://example.com/council_test", + "uprn": "1234567890", "update_interval": 12, "icon_color_mapping": '{"CouncilTest": {"icon": "mdi:trash", "color": "green"}}', }, @@ -124,8 +123,11 @@ def options_flow(dummy_hass): unique_id="options_unique", ) config_entry.add_to_hass(dummy_hass) - flow = UkBinCollectionOptionsFlowHandler(config_entry) + flow = UkBinCollectionOptionsFlowHandler() flow.hass = dummy_hass + flow.handler = config_entry.entry_id + dummy_hass.config_entries.async_get_known_entry.return_value = config_entry + flow.councils_data = MOCK_COUNCILS_DATA_OPTIONS return flow, config_entry @@ -144,7 +146,7 @@ async def proceed_through_config_flow( # Start the flow and complete the `user` step result = await flow.async_step_user(user_input=user_input_initial) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "council" # Complete the `council` step @@ -168,6 +170,7 @@ async def test_config_flow_with_uprn(hass: HomeAssistant): "council": "Council with UPRN", } user_input_council = { + "url": "https://example.com/uprn", "uprn": "1234567890", "timeout": 60, } @@ -181,6 +184,7 @@ async def test_config_flow_with_uprn(hass: HomeAssistant): assert result["data"] == { "name": "Test Name", "council": "CouncilWithUPRN", + "url": "https://example.com/uprn", "uprn": "1234567890", "timeout": 60, } @@ -200,6 +204,7 @@ async def test_config_flow_with_postcode_and_number(hass: HomeAssistant): "council": "Council with Postcode and Number", } user_input_council = { + "url": "https://example.com/postcode", "postcode": "AB1 2CD", "number": "42", "timeout": 60, @@ -214,6 +219,7 @@ async def test_config_flow_with_postcode_and_number(hass: HomeAssistant): assert result["data"] == { "name": "Test Name", "council": "CouncilWithPostcodeNumber", + "url": "https://example.com/postcode", "postcode": "AB1 2CD", "number": "42", "timeout": 60, @@ -234,7 +240,8 @@ async def test_config_flow_with_web_driver(hass: HomeAssistant): "council": "Council with Web Driver", } user_input_council = { - "web_driver": "/path/to/webdriver", + "url": "https://example.com/webdriver", + "web_driver": "http://selenium:4444", "headless": True, "local_browser": False, "timeout": 60, @@ -249,7 +256,8 @@ async def test_config_flow_with_web_driver(hass: HomeAssistant): assert result["data"] == { "name": "Test Name", "council": "CouncilWithWebDriver", - "web_driver": "/path/to/webdriver", + "url": "https://example.com/webdriver", + "web_driver": "http://selenium:4444", "headless": True, "local_browser": False, "timeout": 60, @@ -336,9 +344,9 @@ async def test_config_flow_missing_name(hass: HomeAssistant): result = await flow.async_step_user(user_input=user_input_initial) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"name": "Name is required."} + assert result["errors"] == {"name": "name"} async def test_config_flow_invalid_icon_color_mapping(hass: HomeAssistant): @@ -359,9 +367,9 @@ async def test_config_flow_invalid_icon_color_mapping(hass: HomeAssistant): result = await flow.async_step_user(user_input=user_input_initial) # Should return to the user step with an error - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"icon_color_mapping": "Invalid JSON format."} + assert result["errors"] == {"icon_color_mapping": "invalid_json"} async def test_config_flow_with_usrn(hass: HomeAssistant): @@ -378,6 +386,7 @@ async def test_config_flow_with_usrn(hass: HomeAssistant): "council": "Council with USRN", } user_input_council = { + "url": "https://example.com/usrn", "usrn": "9876543210", "timeout": 60, } @@ -391,6 +400,7 @@ async def test_config_flow_with_usrn(hass: HomeAssistant): assert result["data"] == { "name": "Test Name", "council": "CouncilWithUSRN", + "url": "https://example.com/usrn", "usrn": "9876543210", "timeout": 60, } @@ -421,7 +431,7 @@ async def test_reconfigure_flow(hass): # Configure async_init to return a FlowResultType.FORM with step_id 'reconfigure_confirm' hass.config_entries.flow.async_init.return_value = { "flow_id": "test_flow_id", - "type": data_entry_flow.RESULT_TYPE_FORM, + "type": data_entry_flow.FlowResultType.FORM, "step_id": "reconfigure_confirm", } @@ -437,7 +447,7 @@ async def test_reconfigure_flow(hass): flow, "async_step_reconfigure_confirm", new=AsyncMock() ) as mock_step: mock_step.return_value = { - "type": data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, "title": "Test Name", "data": { "name": "Test Name", @@ -485,11 +495,11 @@ async def test_get_councils_json_failure(hass: HomeAssistant): ) as mock_session_cls: # Configure the mock session to simulate a network error mock_session = mock_session_cls.return_value.__aenter__.return_value - mock_session.get.side_effect = Exception("Network error") + mock_session.get = MagicMock(side_effect=Exception("Network error")) # Configure async_init to simulate flow abort due to council data being unavailable hass.config_entries.flow.async_init.return_value = { - "type": data_entry_flow.RESULT_TYPE_ABORT, + "type": data_entry_flow.FlowResultType.ABORT, "reason": "council_data_unavailable", } @@ -518,7 +528,7 @@ async def test_config_flow_user_input_none(hass: HomeAssistant): result = await flow.async_step_user(user_input=None) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" @@ -543,8 +553,9 @@ async def test_config_flow_with_optional_fields(hass: HomeAssistant): "council": "Council with Optional Fields", } user_input_council = { + "url": "https://example.com/optional", "uprn": "1234567890", - "web_driver": "/path/to/webdriver", + "web_driver": "http://selenium:4444", "headless": True, "local_browser": False, "timeout": 60, @@ -559,8 +570,9 @@ async def test_config_flow_with_optional_fields(hass: HomeAssistant): assert result["data"] == { "name": "Test Name", "council": "CouncilWithOptionalFields", + "url": "https://example.com/optional", "uprn": "1234567890", - "web_driver": "/path/to/webdriver", + "web_driver": "http://selenium:4444", "headless": True, "local_browser": False, "timeout": 60, @@ -579,7 +591,7 @@ async def test_get_councils_json_session_creation_failure(hass): # Configure async_init to simulate flow abort due to council data being unavailable hass.config_entries.flow.async_init.return_value = { - "type": data_entry_flow.RESULT_TYPE_ABORT, + "type": data_entry_flow.FlowResultType.ABORT, "reason": "council_data_unavailable", } @@ -615,13 +627,13 @@ async def test_config_flow_council_without_url(hass): # Configure async_init to return a FlowResultType.FORM with step_id 'council' hass.config_entries.flow.async_init.return_value = { "flow_id": "test_flow_id", - "type": data_entry_flow.RESULT_TYPE_FORM, + "type": data_entry_flow.FlowResultType.FORM, "step_id": "council", } # Configure async_configure to return a FlowResultType.CREATE_ENTRY hass.config_entries.flow.async_configure.return_value = { - "type": data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, "title": "Test Name", "data": { "name": "Test Name", @@ -672,9 +684,9 @@ async def test_config_flow_missing_council(hass: HomeAssistant): result = await flow.async_step_user(user_input=user_input_initial) # Should return to the user step with an error - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"council": "Council is required."} + assert result["errors"] == {"council": "council"} @pytest.mark.asyncio @@ -702,7 +714,7 @@ async def test_reconfigure_flow_with_errors(hass): # Configure async_init to return a FlowResultType.FORM with step_id 'reconfigure_confirm' hass.config_entries.flow.async_init.return_value = { "flow_id": "test_flow_id", - "type": data_entry_flow.RESULT_TYPE_FORM, + "type": data_entry_flow.FlowResultType.FORM, "step_id": "reconfigure_confirm", } @@ -718,7 +730,7 @@ async def test_reconfigure_flow_with_errors(hass): flow, "async_step_reconfigure_confirm", new=AsyncMock() ) as mock_step: mock_step.return_value = { - "type": data_entry_flow.RESULT_TYPE_FORM, + "type": data_entry_flow.FlowResultType.FORM, "step_id": "reconfigure_confirm", "errors": {"icon_color_mapping": "invalid_json"}, } @@ -740,7 +752,7 @@ async def test_reconfigure_flow_with_errors(hass): # Configure async_configure to return an error hass.config_entries.flow.async_configure.return_value = { - "type": data_entry_flow.RESULT_TYPE_FORM, + "type": data_entry_flow.FlowResultType.FORM, "step_id": "reconfigure_confirm", "errors": {"icon_color_mapping": "invalid_json"}, } @@ -774,7 +786,7 @@ async def test_reconfigure_flow_entry_missing(hass): # Assert that the flow aborts due to the missing config entry assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "Reconfigure Failed" + assert result["reason"] == "reconfigure_failed" @pytest.mark.asyncio @@ -796,13 +808,13 @@ async def test_reconfigure_flow_no_user_input(hass): ) existing_entry.add_to_hass(hass) - # Mock async_get_entry to return the entry directly, avoiding coroutine issues - hass.config_entries.async_get_entry = AsyncMock(return_value=existing_entry) + # async_get_entry is a synchronous lookup in Home Assistant. + hass.config_entries.async_get_entry = MagicMock(return_value=existing_entry) # Mock async_init and start the reconfigure flow hass.config_entries.flow.async_init.return_value = { "flow_id": "test_flow_id", - "type": data_entry_flow.RESULT_TYPE_FORM, + "type": data_entry_flow.FlowResultType.FORM, "step_id": "reconfigure_confirm", } @@ -815,7 +827,7 @@ async def test_reconfigure_flow_no_user_input(hass): flow, "async_step_reconfigure_confirm", new=AsyncMock() ) as mock_step: mock_step.return_value = { - "type": data_entry_flow.RESULT_TYPE_FORM, + "type": data_entry_flow.FlowResultType.FORM, "step_id": "reconfigure_confirm", "errors": {}, } @@ -845,6 +857,23 @@ async def test_check_selenium_server_exception(hass: HomeAssistant): assert result == expected_result +@pytest.mark.asyncio +async def test_selenium_preflight_logs_redact_configured_url(hass, caplog): + caplog.set_level(logging.DEBUG) + sentinel = "http://user:secret@selenium.internal:4444/?uprn=100012345678" + with patch( + "aiohttp.ClientSession.get", + side_effect=Exception(f"Connection error for {sentinel}"), + ): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + await flow.check_selenium_server(sentinel) + + assert sentinel not in caplog.text + assert "100012345678" not in caplog.text + assert "user:secret" not in caplog.text + + @pytest.mark.asyncio async def test_get_councils_json_exception(hass: HomeAssistant): """Test exception handling in get_councils_json.""" @@ -872,7 +901,7 @@ async def test_async_step_user_council_data_unavailable(hass: HomeAssistant): result = await flow.async_step_user(user_input={}) assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "Council Data Unavailable" + assert result["reason"] == "council_data_unavailable" @pytest.mark.asyncio @@ -891,6 +920,7 @@ async def test_async_step_council_invalid_icon_color_mapping(hass: HomeAssistant flow.councils_data = MOCK_COUNCILS_DATA user_input = { + "url": "https://example.com/uprn", "uprn": "1234567890", "icon_color_mapping": "invalid json", "timeout": 60, @@ -898,9 +928,9 @@ async def test_async_step_council_invalid_icon_color_mapping(hass: HomeAssistant result = await flow.async_step_council(user_input=user_input) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "council" - assert result["errors"] == {"icon_color_mapping": "Invalid JSON format."} + assert result["errors"] == {"icon_color_mapping": "invalid_json"} @pytest.mark.asyncio @@ -916,7 +946,7 @@ async def test_async_step_reconfigure_entry_none(hass: HomeAssistant): result = await flow.async_step_reconfigure() assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "Reconfigure Failed" + assert result["reason"] == "reconfigure_failed" async def test_async_step_reconfigure_confirm_user_input_none(hass: HomeAssistant): @@ -929,6 +959,7 @@ async def test_async_step_reconfigure_confirm_user_input_none(hass: HomeAssistan data={ "name": "Test Name", "council": "CouncilWithUPRN", + "url": "https://example.com/uprn", "uprn": "1234567890", "timeout": 60, }, @@ -943,7 +974,7 @@ async def test_async_step_reconfigure_confirm_user_input_none(hass: HomeAssistan hass.config_entries.async_get_entry = MagicMock(return_value=config_entry) result = await flow.async_step_reconfigure_confirm(user_input=None) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "reconfigure_confirm" @@ -960,7 +991,7 @@ async def test_async_step_council_missing_council_key(hass: HomeAssistant): result = await flow.async_step_council(user_input=None) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "council" @@ -1019,9 +1050,9 @@ async def test_async_step_reconfigure_confirm_invalid_json(hass: HomeAssistant): result = await flow.async_step_reconfigure_confirm(user_input=user_input) # Should return to the reconfigure_confirm step with an error - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "reconfigure_confirm" - assert result["errors"] == {"icon_color_mapping": "Invalid JSON format."} + assert result["errors"] == {"icon_color_mapping": "invalid_json"} @pytest.mark.asyncio @@ -1052,6 +1083,7 @@ async def test_config_flow_with_manual_refresh_only(hass: HomeAssistant): # Step 2: council details # minimal fields needed for council requiring UPRN user_input_council = { + "url": "https://example.com/uprn", "uprn": "1234567890", "timeout": 45, # note that if skip_get_url is False, you might need "url" or not @@ -1059,18 +1091,19 @@ async def test_config_flow_with_manual_refresh_only(hass: HomeAssistant): # Start user step result = await flow.async_step_user(user_input=user_input_initial) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "council" # Complete council step result = await flow.async_step_council(user_input=user_input_council) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Test Manual Refresh" # Confirm the config entry data now includes manual_refresh_only assert result["data"] == { "name": "Test Manual Refresh", "council": "CouncilWithUPRN", + "url": "https://example.com/uprn", "uprn": "1234567890", "timeout": 45, "manual_refresh_only": True, @@ -1094,7 +1127,8 @@ def test_load_icon_color_mapping_invalid(): result = load_icon_color_mapping(invalid_json) assert result == {} mock_warn.assert_called_once_with( - f"{LOG_PREFIX} Invalid icon_color_mapping JSON: {invalid_json}. Using default settings." + "%s Invalid icon_color_mapping JSON. Using default settings.", + LOG_PREFIX, ) @@ -1161,7 +1195,7 @@ async def test_async_step_user_invalid_icon_mapping(hass): } ) assert result["type"] == "form" - assert result["errors"] == {"icon_color_mapping": "Invalid JSON format."} + assert result["errors"] == {"icon_color_mapping": "invalid_json"} @pytest.mark.asyncio @@ -1175,7 +1209,7 @@ async def test_async_step_user_no_councils(hass): user_input={"name": "Test", "council": "CouncilTest"} ) assert result["type"] == "abort" - assert result["reason"] == "Council Data Unavailable" + assert result["reason"] == "council_data_unavailable" # --------------------------- @@ -1281,7 +1315,7 @@ async def test_get_councils_json_failure(hass): with patch("aiohttp.ClientSession") as mock_session_cls: # Simulate network error. mock_session = mock_session_cls.return_value.__aenter__.return_value - mock_session.get.side_effect = Exception("Network error") + mock_session.get = MagicMock(side_effect=Exception("Network error")) result = await flow.get_councils_json() assert result == {} @@ -1311,6 +1345,37 @@ async def test_get_council_schema(hass): assert field in schema.schema +@pytest.mark.asyncio +async def test_council_schema_preserves_values_after_validation_error(hass): + flow = UkBinCollectionConfigFlow() + flow.hass = hass + flow.councils_data = { + "CouncilTest": { + "wiki_name": "Council Test", + "skip_get_url": False, + "postcode": True, + "house_number": True, + "web_driver": True, + } + } + entered = { + "url": "https://example.com/collections", + "postcode": "NG31 8XG", + "number": "43", + "web_driver": "http://selenium:4444", + "headless": False, + "local_browser": False, + "timeout": 75, + "update_interval": 24, + } + + schema = await flow.get_council_schema("CouncilTest", entered) + restored = schema({}) + + for key, value in entered.items(): + assert restored[key] == value + + # --------------------------- # Test build_reconfigure_schema # --------------------------- @@ -1359,14 +1424,15 @@ async def test_options_flow_no_councils(dummy_hass): domain=DOMAIN, data={"name": "Test Options"}, entry_id="opt_test" ) config_entry.add_to_hass(dummy_hass) - flow = UkBinCollectionOptionsFlowHandler(config_entry) + flow = UkBinCollectionOptionsFlowHandler() flow.hass = dummy_hass + flow.handler = config_entry.entry_id + dummy_hass.config_entries.async_get_known_entry.return_value = config_entry # Patch get_councils_json to return an empty dict flow.get_councils_json = AsyncMock(return_value={}) result = await flow.async_step_init(user_input=None) - # Expect an abort with reason "Council Data Unavailable" - assert result["reason"] == "Council Data Unavailable" + assert result["reason"] == "council_data_unavailable" def test_build_options_schema(options_flow): @@ -1378,18 +1444,26 @@ def test_build_options_schema(options_flow): existing_data = { "name": "Test Options", "council": "CouncilTest", + "url": "https://example.com/council_test", + "uprn": "1234567890", "update_interval": 12, "icon_color_mapping": '{"CouncilTest": {"icon": "mdi:trash", "color": "green"}}', } schema = flow.build_options_schema(existing_data) sample = schema( - {"name": "Test Options", "council": "Council Test", "update_interval": 12} + { + "name": "Test Options", + "council": "Council Test", + "url": "https://example.com/council_test", + "update_interval": 12, + } ) assert isinstance(sample, dict) sample_with_optional = schema( { "name": "Test Options", "council": "Council Test", + "url": "https://example.com/council_test", "update_interval": 12, "icon_color_mapping": '{"key": "value"}', } @@ -1424,3 +1498,231 @@ def test_is_valid_json_options(): invalid = '{"key": "value",}' # trailing comma assert UkBinCollectionOptionsFlowHandler.is_valid_json(valid) is True assert UkBinCollectionOptionsFlowHandler.is_valid_json(invalid) is False + + +def test_shared_registry_normalizes_aliases_for_every_flow(): + registry = normalize_council_registry( + { + "CalendarParser": { + "wiki_name": "Calendar parser", + "supported_councils": ["AliasCouncil"], + } + } + ) + + assert registry["AliasCouncil"]["original_parser"] == "CalendarParser" + assert registry["AliasCouncil"]["wiki_name"].endswith("(via Google Calendar)") + + +def test_south_kesteven_fields_are_exposed_when_entry_is_incomplete(hass): + council_info = { + "wiki_name": "South Kesteven District Council", + "postcode": "example", + "house_number": "example", + "web_driver": "http://selenium:4444", + "skip_get_url": True, + "url": "https://www.southkesteven.gov.uk/binday", + } + existing = { + "name": "Bins", + "council": "SouthKestevenDistrictCouncil", + "update_interval": 12, + } + + flow = UkBinCollectionConfigFlow() + flow.hass = hass + flow.councils_data = {"SouthKestevenDistrictCouncil": council_info} + flow.council_names = ["SouthKestevenDistrictCouncil"] + flow.council_options = ["South Kesteven District Council"] + reconfigure = flow.build_reconfigure_schema( + existing, "South Kesteven District Council" + ) + + entry = MagicMock(data=existing) + options = UkBinCollectionOptionsFlowHandler() + options._config_entry = entry + options.councils_data = flow.councils_data + options.council_names = flow.council_names + options.council_options = flow.council_options + options_schema = options.build_options_schema(existing) + + for schema in (reconfigure, options_schema): + assert "postcode" in schema.schema + assert "number" in schema.schema + assert "web_driver" in schema.schema + assert "local_browser" in schema.schema + + +def test_south_kesteven_registry_metadata_and_browser_validation(): + council_info = { + "postcode": True, + "house_number": True, + "web_driver": True, + "skip_get_url": True, + "url": "https://www.southkesteven.gov.uk/binday", + } + data = { + "postcode": "NG31 8XG", + "number": "43", + "local_browser": False, + } + apply_registry_metadata(data, "SouthKestevenDistrictCouncil", council_info) + + assert data["skip_get_url"] is True + assert data["url"] == "https://www.southkesteven.gov.uk/binday" + assert validate_council_input(council_info, data) == { + "web_driver": "remote_browser_required" + } + + data["web_driver"] = "http://selenium:4444" + assert validate_council_input(council_info, data) == {} + data["local_browser"] = True + assert validate_council_input(council_info, data) == { + "web_driver": "remote_browser_required" + } + + +def test_webdriver_url_validation_rejects_non_http_or_missing_host(): + council_info = {"web_driver": True} + + for value in ("/path/to/webdriver", "selenium:4444", "http://:4444"): + assert validate_council_input( + council_info, + { + "council": "ExampleCouncil", + "url": "https://example.test/collections", + "web_driver": value, + }, + ) == {"web_driver": "invalid_webdriver_url"} + + +def test_registry_normalizes_paon_to_number_field(): + registry = normalize_council_registry( + { + "RotherhamCouncil": { + "wiki_name": "Rotherham Council", + "paon": "77", + "postcode": "S60 1AA", + "skip_get_url": True, + } + } + ) + + assert registry["RotherhamCouncil"]["house_number"] == "77" + assert validate_council_input( + registry["RotherhamCouncil"], {"postcode": "S60 1AA"} + ) == {"number": "required"} + + +def test_council_change_drops_stale_household_and_browser_fields(): + old = { + "name": "Bins", + "council": "OldCouncil", + "url": "https://old.invalid/?uprn=100012345678", + "postcode": "NG31 8XG", + "number": "43", + "uprn": "100012345678", + "usrn": "200012345", + "web_driver": "http://user:secret@selenium:4444", + "user_agent": "private-user-agent", + "manual_refresh_only": True, + } + + merged = merge_council_data( + old, + {"council": "New Council", "url": "https://new.invalid/collections"}, + previous_council="OldCouncil", + selected_council="NewCouncil", + ) + + assert merged["manual_refresh_only"] is True + assert merged["url"] == "https://new.invalid/collections" + for field in ( + "postcode", + "number", + "uprn", + "usrn", + "web_driver", + "user_agent", + ): + assert field not in merged + + +def test_existing_entry_schemas_do_not_offer_a_different_council(hass, options_flow): + """A saved address contract cannot be reused for another council.""" + config_flow = UkBinCollectionConfigFlow() + config_flow.hass = hass + config_flow.council_options = ["Council Test", "Other Council"] + reconfigure_schema = config_flow.build_reconfigure_schema( + { + "name": "Bins", + "council": "CouncilTest", + "update_interval": 12, + }, + "Council Test", + ) + + with pytest.raises(vol.Invalid): + reconfigure_schema( + { + "name": "Bins", + "council": "Other Council", + "update_interval": 12, + } + ) + + options, _ = options_flow + options.council_names = ["CouncilTest", "OtherCouncil"] + options.council_options = ["Council Test", "Other Council"] + options_schema = options.build_options_schema(options.config_entry.data) + + with pytest.raises(vol.Invalid): + options_schema( + { + "name": "Bins", + "council": "Other Council", + "url": "https://example.com/council_test", + "uprn": "1234567890", + "update_interval": 12, + } + ) + + +@pytest.mark.asyncio +async def test_options_flow_rejects_forged_council_switch(options_flow): + """Even a direct form submission cannot carry old address data to a new parser.""" + flow, config_entry = options_flow + registry = { + **MOCK_COUNCILS_DATA_OPTIONS, + "OtherCouncil": { + "wiki_name": "Other Council", + "postcode": True, + "house_number": True, + "skip_get_url": True, + }, + } + flow.get_councils_json = AsyncMock(return_value=registry) + + result = await flow.async_step_init( + user_input={ + "name": "Bins", + "council": "Other Council", + "url": "https://example.com/council_test", + "uprn": "1234567890", + "update_interval": 12, + } + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["errors"]["council"] == "council_change_not_supported" + flow.hass.config_entries.async_update_entry.assert_not_awaited() + assert config_entry.data["council"] == "CouncilTest" + + +def test_options_flow_callback_uses_framework_owned_entry(): + entry = MagicMock() + + flow = UkBinCollectionConfigFlow.async_get_options_flow(entry) + + assert isinstance(flow, UkBinCollectionOptionsFlowHandler) + assert getattr(flow, "_config_entry", None) is not entry diff --git a/custom_components/uk_bin_collection/tests/test_init.py b/custom_components/uk_bin_collection/tests/test_init.py index a2b37d3f5b..610d8ee820 100644 --- a/custom_components/uk_bin_collection/tests/test_init.py +++ b/custom_components/uk_bin_collection/tests/test_init.py @@ -1,18 +1,27 @@ # test_init.py import asyncio import json +import logging from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.config_entries import ConfigEntry -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import UpdateFailed # Import the functions and classes from your __init__.py file. from custom_components.uk_bin_collection import ( + AddressMismatchError, + BrowserUnavailableError, + DependencyShadowingError, HouseholdBinCoordinator, + SiteChanged, + UpstreamAccessDenied, + _COLLECTOR_RUN_STATES, + _SOUTH_KESTEVEN_MIN_HA_TIMEOUT_SECONDS, async_migrate_entry, + async_remove_entry, async_setup, async_setup_entry, async_unload_entry, @@ -58,6 +67,13 @@ def __init__(self, data, version=1, entry_id="dummy_entry"): self.data = data self.version = version self.entry_id = entry_id + self.title = data.get("name", "") + self.state = ConfigEntryState.SETUP_IN_PROGRESS + self._unload_callbacks = [] + + def async_on_unload(self, callback): + """Mirror the ConfigEntry unload callback contract used by HA 2026.7.""" + self._unload_callbacks.append(callback) # Create a dummy HomeAssistant object. @@ -81,16 +97,26 @@ def async_register(self, domain, service, service_func): class ConfigEntries: + def __init__(self): + self.update_calls = [] + self.unload_platform_calls = [] + async def async_forward_entry_setups(self, config_entry, platforms): # In tests, simply return an empty list (or you can simulate something). return [] - async def async_forward_entry_unload(self, config_entry, platform): - # Simulate a successful unload. + async def async_unload_platforms(self, config_entry, platforms): + """Mirror Home Assistant's aggregate platform-unload API.""" + self.unload_platform_calls.append((config_entry, platforms)) return True - def async_update_entry(self, config_entry, data): + def async_update_entry(self, config_entry, data, **kwargs): config_entry.data = data + if "version" in kwargs: + config_entry.version = kwargs["version"] + if "title" in kwargs: + config_entry.title = kwargs["title"] + self.update_calls.append((config_entry, data, kwargs)) @pytest.fixture @@ -195,13 +221,117 @@ async def test_async_migrate_entry_version_1(hass, dummy_config_entry): assert result is True # Now update_interval should be set to 12. assert dummy_config_entry.data["update_interval"] == 12 + assert dummy_config_entry.data["manual_refresh_only"] is True + assert dummy_config_entry.version == 4 @pytest.mark.asyncio async def test_async_migrate_entry_no_migration(hass, dummy_config_entry): - dummy_config_entry.version = 2 + dummy_config_entry.version = 4 result = await async_migrate_entry(hass, dummy_config_entry) assert result is True + assert hass.config_entries.update_calls == [] + + +@pytest.mark.asyncio +async def test_async_migrate_entry_version_2_adds_manual_mode(hass, dummy_config_entry): + dummy_config_entry.version = 2 + dummy_config_entry.data.pop("manual_refresh_only", None) + + assert await async_migrate_entry(hass, dummy_config_entry) is True + assert dummy_config_entry.version == 4 + assert dummy_config_entry.data["manual_refresh_only"] is True + + +@pytest.mark.asyncio +async def test_async_migrate_entry_v3_south_kesteven(hass, dummy_config_entry): + dummy_config_entry.version = 3 + dummy_config_entry.data.update( + { + "council": "SouthKestevenDistrictCouncil", + "url": "https://pre.southkesteven.gov.uk/old", + "house_number": "43", + "manual_refresh_only": "true", + "web_driver": "http://selenium:4444/", + } + ) + + assert await async_migrate_entry(hass, dummy_config_entry) is True + assert dummy_config_entry.version == 4 + assert dummy_config_entry.data["url"] == "https://www.southkesteven.gov.uk/binday" + assert dummy_config_entry.data["number"] == "43" + assert dummy_config_entry.data["skip_get_url"] is True + assert dummy_config_entry.data["web_driver"] == "http://selenium:4444" + + +@pytest.mark.asyncio +async def test_async_migrate_entry_v3_normalizes_paon_and_whitespace( + hass, dummy_config_entry +): + dummy_config_entry.version = 3 + dummy_config_entry.data.update( + { + "council": "SouthKestevenDistrictCouncil", + "postcode": "NG31 8XG", + "paon": "The Cottage", + "selenium_url": " http://selenium:4444/ ", + } + ) + + assert await async_migrate_entry(hass, dummy_config_entry) is True + assert dummy_config_entry.data["number"] == "The Cottage" + assert "paon" not in dummy_config_entry.data + assert "selenium_url" not in dummy_config_entry.data + assert dummy_config_entry.data["web_driver"] == "http://selenium:4444" + + +@pytest.mark.asyncio +async def test_async_migrate_entry_v3_removes_all_legacy_aliases( + hass, dummy_config_entry +): + dummy_config_entry.version = 3 + dummy_config_entry.data.update( + { + "house_number": "43", + "paon": "must-not-remain", + "selenium_url": "http://selenium-primary:4444/", + "webdriver": "http://selenium-stale:4444/", + } + ) + + assert await async_migrate_entry(hass, dummy_config_entry) is True + assert dummy_config_entry.data["number"] == "43" + assert dummy_config_entry.data["web_driver"] == "http://selenium-primary:4444" + for legacy_key in ("house_number", "paon", "selenium_url", "webdriver"): + assert legacy_key not in dummy_config_entry.data + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("legacy_number_key", "legacy_driver_key"), + [ + ("house_number", "selenium_url"), + ("paon", "webdriver"), + ], +) +async def test_async_migrate_entry_v3_uses_valid_aliases_when_canonical_is_whitespace( + hass, dummy_config_entry, legacy_number_key, legacy_driver_key +): + dummy_config_entry.version = 3 + dummy_config_entry.data.update( + { + "number": " ", + legacy_number_key: " The Cottage ", + "web_driver": " ", + legacy_driver_key: " http://selenium:4444/wd/hub/ ", + } + ) + + assert await async_migrate_entry(hass, dummy_config_entry) is True + assert dummy_config_entry.data["number"] == "The Cottage" + assert dummy_config_entry.data["web_driver"] == "http://selenium:4444/wd/hub" + for legacy_key in ("house_number", "paon", "selenium_url", "webdriver"): + assert legacy_key not in dummy_config_entry.data # --- Test async_setup_entry --- @@ -214,18 +344,105 @@ async def test_async_setup_entry_success(hass, dummy_config_entry): with patch( "custom_components.uk_bin_collection.UKBinCollectionApp", return_value=DummyUKBinCollectionApp(), - ): + ), patch("custom_components.uk_bin_collection.ir.async_delete_issue"): result = await async_setup_entry(hass, dummy_config_entry) assert result is True # Verify that the coordinator was stored in hass.data. assert dummy_config_entry.entry_id in hass.data[DOMAIN] + assert ( + hass.data[DOMAIN][dummy_config_entry.entry_id][ + "coordinator" + ].update_interval + is None + ) + + +@pytest.mark.asyncio +async def test_south_kesteven_collector_deadline_precedes_ha_timeout(hass): + entry = DummyConfigEntry( + { + "name": "South Kesteven", + "timeout": 10, + "manual_refresh_only": True, + "update_interval": 12, + "council": "SouthKestevenDistrictCouncil", + "url": "https://www.southkesteven.gov.uk/binday", + "postcode": "ZZ99 9ZZ", + "number": "Codex Test House", + "web_driver": "http://selenium:4444", + "skip_get_url": True, + }, + version=4, + entry_id="south-kesteven-timeout", + ) + hass.data.setdefault(DOMAIN, {}) + + with patch( + "custom_components.uk_bin_collection.UKBinCollectionApp", + return_value=DummyUKBinCollectionApp(), + ), patch("custom_components.uk_bin_collection.ir.async_delete_issue"): + assert await async_setup_entry(hass, entry) is True + + coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + assert coordinator.timeout == _SOUTH_KESTEVEN_MIN_HA_TIMEOUT_SECONDS + assert coordinator.timeout > 90 + 30 + + +@pytest.mark.asyncio +async def test_setup_unexpected_error_redacts_exception_text( + hass, dummy_config_entry, caplog +): + sentinel = "SECRET-POSTCODE-ZZ99-9ZZ" + hass.data.setdefault(DOMAIN, {}) + caplog.set_level(logging.ERROR) + + with patch( + "custom_components.uk_bin_collection.UKBinCollectionApp", + side_effect=RuntimeError(sentinel), + ): + with pytest.raises( + ConfigEntryNotReady, + match=r"Unexpected integration setup error \(RuntimeError\)", + ) as raised: + await async_setup_entry(hass, dummy_config_entry) + + assert sentinel not in caplog.text + assert sentinel not in str(raised.value) + assert raised.value.__cause__ is None + assert raised.value.__suppress_context__ is True + + +@pytest.mark.asyncio +async def test_setup_update_failed_redacts_exception_text( + hass, dummy_config_entry, caplog +): + sentinel = "SECRET-UPRN-100012345678" + hass.data.setdefault(DOMAIN, {}) + caplog.set_level(logging.ERROR) + + with patch( + "custom_components.uk_bin_collection.HouseholdBinCoordinator." + "async_config_entry_first_refresh", + side_effect=UpdateFailed(sentinel), + ): + with pytest.raises( + ConfigEntryNotReady, + match=r"Initial collection lookup failed \(UpdateFailed\)", + ) as raised: + await async_setup_entry(hass, dummy_config_entry) + + assert sentinel not in caplog.text + assert sentinel not in str(raised.value) + assert raised.value.__cause__ is None + assert raised.value.__suppress_context__ is True + assert _COLLECTOR_RUN_STATES not in hass.data[DOMAIN] @pytest.mark.asyncio async def test_async_setup_entry_missing_name(hass, dummy_config_entry): # Remove "name" to force a failure. dummy_config_entry.data.pop("name") - with pytest.raises(ConfigEntryNotReady): + with pytest.raises(ConfigEntryError, match="Reconfigure"): await async_setup_entry(hass, dummy_config_entry) @@ -233,31 +450,255 @@ async def test_async_setup_entry_missing_name(hass, dummy_config_entry): @pytest.mark.asyncio async def test_async_unload_entry_success(hass, dummy_config_entry): # Prepopulate hass.data with a dummy coordinator. - hass.data.setdefault(DOMAIN, {})[dummy_config_entry.entry_id] = { - "coordinator": "dummy" - } + domain_data = hass.data.setdefault(DOMAIN, {}) + domain_data[dummy_config_entry.entry_id] = {"coordinator": "dummy"} + coordinator = HouseholdBinCoordinator( + hass, + DummyUKBinCollectionApp(), + name="Test Bin", + config_entry_id=dummy_config_entry.entry_id, + ) + assert ( + domain_data[_COLLECTOR_RUN_STATES][dummy_config_entry.entry_id] + is coordinator._run_state + ) + result = await async_unload_entry(hass, dummy_config_entry) + assert result is True - # The coordinator should have been removed. assert dummy_config_entry.entry_id not in hass.data[DOMAIN] + assert _COLLECTOR_RUN_STATES not in hass.data[DOMAIN] + assert hass.config_entries.unload_platform_calls == [ + (dummy_config_entry, PLATFORMS) + ] + + +@pytest.mark.asyncio +async def test_async_unload_entry_failure_retains_runtime_data( + hass, dummy_config_entry +): + coordinator = HouseholdBinCoordinator( + hass, + DummyUKBinCollectionApp(), + name="Test Bin", + config_entry_id=dummy_config_entry.entry_id, + ) + hass.data[DOMAIN][dummy_config_entry.entry_id] = {"coordinator": coordinator} + + async def reject_unload(config_entry, platforms): + assert config_entry is dummy_config_entry + assert platforms == PLATFORMS + return False + + hass.config_entries.async_unload_platforms = reject_unload + + assert await async_unload_entry(hass, dummy_config_entry) is False + assert hass.data[DOMAIN][dummy_config_entry.entry_id]["coordinator"] is coordinator + assert ( + hass.data[DOMAIN][_COLLECTOR_RUN_STATES][dummy_config_entry.entry_id] + is coordinator._run_state + ) + + +@pytest.mark.asyncio +async def test_platform_forward_failure_cleans_registered_coordinator( + hass, dummy_config_entry +): + hass.data.setdefault(DOMAIN, {}) + + async def fail_forward(config_entry, platforms): + assert config_entry is dummy_config_entry + assert platforms == PLATFORMS + raise RuntimeError("platform setup failed") + + hass.config_entries.async_forward_entry_setups = fail_forward + + with patch( + "custom_components.uk_bin_collection.UKBinCollectionApp", + return_value=DummyUKBinCollectionApp(), + ), patch("custom_components.uk_bin_collection.ir.async_delete_issue"): + with pytest.raises( + ConfigEntryNotReady, + match=r"Unexpected integration setup error \(RuntimeError\)", + ): + await async_setup_entry(hass, dummy_config_entry) + + assert dummy_config_entry.entry_id not in hass.data[DOMAIN] + assert _COLLECTOR_RUN_STATES not in hass.data[DOMAIN] + + +@pytest.mark.asyncio +async def test_unload_defers_run_state_cleanup_until_executor_finishes(): + loop = asyncio.get_running_loop() + pending_run = loop.create_future() + + class ControlledHass(DummyHass): + def async_add_executor_job(self, func, *args, **kwargs): + del func, args, kwargs + return pending_run + + hass = ControlledHass() + entry = DummyConfigEntry( + { + "name": "Test Entry", + "council": "json", + "url": "http://example.com", + }, + version=4, + entry_id="entry-active-unload", + ) + coordinator = HouseholdBinCoordinator( + hass, + DummyUKBinCollectionApp(), + name="Test Bin", + timeout=0.01, + config_entry_id=entry.entry_id, + ) + hass.data[DOMAIN][entry.entry_id] = {"coordinator": coordinator} + + with pytest.raises(UpdateFailed, match="Timeout"): + await coordinator._async_update_data() + + assert await async_unload_entry(hass, entry) is True + assert entry.entry_id not in hass.data[DOMAIN] + state = hass.data[DOMAIN][_COLLECTOR_RUN_STATES][entry.entry_id] + assert state is coordinator._run_state + assert state.discard_when_idle is True + + pending_run.set_result(json.dumps({"bins": []})) + await asyncio.sleep(0) + assert _COLLECTOR_RUN_STATES not in hass.data[DOMAIN] # --- Test build_ukbcd_args --- -def test_build_ukbcd_args_excludes_keys(): +def test_build_ukbcd_args_uses_typed_contract(): config_data = { "council": "Test Council", "url": "http://example.com", - "skip_get_url": "should be excluded", + "skip_get_url": True, + "local_browser": True, + "headless": False, + "postcode": "NG31 8XG", + "number": "43", + "usrn": "200012345", + "web_driver": "http://selenium:4444/", + "user_agent": "UKBCD contract test", "custom_arg": "value", } args = build_ukbcd_args(config_data) # Check that the first two arguments are the council and url. assert args[0] == "Test Council" assert args[1] == "http://example.com" - # The custom_arg should be included, but skip_get_url should not. - args_str = " ".join(args) - assert "--custom_arg=value" in args_str - assert "skip_get_url" not in args_str + assert "--postcode=NG31 8XG" in args + assert "--number=43" in args + assert "--usrn=200012345" in args + assert "--web_driver=http://selenium:4444" in args + assert "--user-agent=UKBCD contract test" in args + assert "--skip_get_url" in args + assert "--local_browser" in args + assert "--not-headless" in args + assert all("custom_arg" not in arg for arg in args) + + +@pytest.mark.asyncio +async def test_south_kesteven_missing_fields_is_non_retryable(hass): + entry = DummyConfigEntry( + { + "name": "South Kesteven", + "council": "SouthKestevenDistrictCouncil", + "url": "https://www.southkesteven.gov.uk/binday", + }, + version=4, + ) + hass.data.setdefault(DOMAIN, {}) + + with patch( + "custom_components.uk_bin_collection._create_missing_configuration_issue" + ): + with pytest.raises(ConfigEntryError, match="postcode, number"): + await async_setup_entry(hass, entry) + + +@pytest.mark.asyncio +async def test_south_kesteven_conflicting_browser_settings_require_reconfigure(hass): + entry = DummyConfigEntry( + { + "name": "South Kesteven", + "council": "SouthKestevenDistrictCouncil", + "url": "https://www.southkesteven.gov.uk/binday", + "postcode": "NG31 8XG", + "number": "43", + "web_driver": "http://selenium:4444", + "local_browser": True, + }, + version=4, + ) + hass.data.setdefault(DOMAIN, {}) + + with patch( + "custom_components.uk_bin_collection._create_missing_configuration_issue" + ): + with pytest.raises(ConfigEntryError, match="disable local_browser"): + await async_setup_entry(hass, entry) + + +@pytest.mark.asyncio +async def test_permanent_first_refresh_failure_releases_idle_run_state( + hass, dummy_config_entry +): + hass.data.setdefault(DOMAIN, {}) + with patch( + "custom_components.uk_bin_collection.HouseholdBinCoordinator." + "async_config_entry_first_refresh", + side_effect=ConfigEntryError("permanent setup failure"), + ): + with pytest.raises(ConfigEntryError, match="permanent setup failure"): + await async_setup_entry(hass, dummy_config_entry) + + assert _COLLECTOR_RUN_STATES not in hass.data[DOMAIN] + + +@pytest.mark.asyncio +async def test_cancelled_first_refresh_releases_idle_run_state( + hass, dummy_config_entry +): + hass.data.setdefault(DOMAIN, {}) + + with patch( + "custom_components.uk_bin_collection.HouseholdBinCoordinator." + "async_config_entry_first_refresh", + side_effect=asyncio.CancelledError, + ): + with pytest.raises(asyncio.CancelledError): + await async_setup_entry(hass, dummy_config_entry) + + assert _COLLECTOR_RUN_STATES not in hass.data[DOMAIN] + + +@pytest.mark.asyncio +async def test_remove_entry_clears_repairs_and_runtime_state(hass): + entry = DummyConfigEntry( + {"name": "Removed bins", "council": "ExampleCouncil"}, + version=4, + entry_id="removed-entry", + ) + hass.data[DOMAIN] = { + entry.entry_id: {"coordinator": object()}, + _COLLECTOR_RUN_STATES: {}, + } + + with patch( + "custom_components.uk_bin_collection.ir.async_delete_issue" + ) as delete_issue: + await async_remove_entry(hass, entry) + + assert entry.entry_id not in hass.data[DOMAIN] + assert _COLLECTOR_RUN_STATES not in hass.data[DOMAIN] + assert {call.args[2] for call in delete_issue.call_args_list} == { + "dependency_removed-entry", + "missing_configuration_removed-entry", + "browser_removed-entry", + } # --- Test HouseholdBinCoordinator update --- @@ -275,6 +716,31 @@ async def test_household_bin_coordinator_update(hass): assert "waste" in data or "recycling" in data +@pytest.mark.asyncio +async def test_successful_empty_lookup_clears_stale_runtime_repairs(hass): + class EmptyApp: + def run(self): + return json.dumps({"bins": []}) + + coordinator = HouseholdBinCoordinator( + hass, + EmptyApp(), + name="Test Bin", + config_entry_id="entry-recovered", + ) + + with patch( + "custom_components.uk_bin_collection.ir.async_delete_issue" + ) as delete_issue: + assert await coordinator._async_update_data() == {} + + deleted_ids = {call.args[2] for call in delete_issue.call_args_list} + assert deleted_ids == { + "dependency_entry-recovered", + "browser_entry-recovered", + } + + def test_process_bin_data_valid(): # Test process_bin_data with valid bin data. now_str = datetime.now().strftime("%d/%m/%Y") @@ -301,3 +767,213 @@ def test_process_bin_data_invalid(): processed = HouseholdBinCoordinator.process_bin_data(data) # Should be empty because data was invalid. assert processed == {} + + +@pytest.mark.asyncio +async def test_dependency_shadowing_is_actionable_and_non_retryable(hass): + class ShadowedDependencyApp: + def run(self): + raise DependencyShadowingError( + "websocket resolved to /config/websocket/__init__.py" + ) + + coordinator = HouseholdBinCoordinator( + hass, + ShadowedDependencyApp(), + name="Test Bin", + config_entry_id="entry-1", + ) + + with patch( + "custom_components.uk_bin_collection._create_dependency_issue" + ) as issue, patch( + "custom_components.uk_bin_collection.ir.async_delete_issue" + ) as delete_issue: + with pytest.raises(ConfigEntryError, match="missing or shadowed"): + await coordinator._async_update_data() + issue.assert_called_once() + delete_issue.assert_called_once_with(hass, DOMAIN, "browser_entry-1") + + +@pytest.mark.asyncio +async def test_browser_unavailable_is_actionable_without_unexpected_traceback( + hass, caplog +): + class BrowserFailureApp: + def run(self): + raise BrowserUnavailableError("Unable to create the configured browser") + + coordinator = HouseholdBinCoordinator( + hass, + BrowserFailureApp(), + name="Test Bin", + config_entry_id="entry-browser", + ) + + with patch( + "custom_components.uk_bin_collection._create_browser_issue" + ) as issue, patch( + "custom_components.uk_bin_collection.ir.async_delete_issue" + ) as delete_issue: + with pytest.raises(UpdateFailed, match="WebDriver is unavailable"): + await coordinator._async_update_data() + + issue.assert_called_once() + delete_issue.assert_called_once_with(hass, DOMAIN, "dependency_entry-browser") + assert "Unexpected" not in caplog.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("error", "expected_exception", "message"), + [ + ( + AddressMismatchError("not unique"), + ConfigEntryError, + "need to be corrected", + ), + ( + UpstreamAccessDenied("blocked"), + UpdateFailed, + "denied browser access", + ), + ( + SiteChanged("selector missing"), + UpdateFailed, + "supported layout", + ), + ], +) +async def test_expected_collector_errors_keep_distinct_ha_diagnostics( + hass, error, expected_exception, message, caplog +): + class TypedFailureApp: + def run(self): + raise error + + coordinator = HouseholdBinCoordinator( + hass, + TypedFailureApp(), + name="Test Bin", + config_entry_id="entry-transition", + ) + + with patch( + "custom_components.uk_bin_collection.ir.async_delete_issue" + ) as delete_issue: + with pytest.raises(expected_exception, match=message): + await coordinator._async_update_data() + + assert "Unexpected" not in caplog.text + assert {call.args[2] for call in delete_issue.call_args_list} == { + "dependency_entry-transition", + "browser_entry-transition", + } + + +@pytest.mark.asyncio +async def test_coordinator_logs_do_not_include_household_sentinels(hass, caplog): + caplog.set_level(logging.DEBUG) + sentinel_values = ( + "SECRET-POSTCODE", + "SECRET-PAON", + "100012345678", + "200012345", + "token=secret-query", + ) + + class RedactionApp: + def run(self): + return json.dumps( + { + "postcode": sentinel_values[0], + "paon": sentinel_values[1], + "uprn": sentinel_values[2], + "usrn": sentinel_values[3], + "url": f"https://example.invalid/?{sentinel_values[4]}", + "bins": [ + { + "type": "waste", + "collectionDate": datetime.now().strftime("%d/%m/%Y"), + } + ], + } + ) + + coordinator = HouseholdBinCoordinator(hass, RedactionApp(), name="Test Bin") + await coordinator._async_update_data() + + for sentinel in sentinel_values: + assert sentinel not in caplog.text + + +@pytest.mark.asyncio +async def test_unexpected_coordinator_error_is_class_only(hass, caplog): + sentinel = "SECRET-PAON-CODEX-TEST-HOUSE" + caplog.set_level(logging.ERROR) + + class UnexpectedFailureApp: + def run(self): + raise RuntimeError(sentinel) + + coordinator = HouseholdBinCoordinator(hass, UnexpectedFailureApp(), name="Test Bin") + + with pytest.raises( + UpdateFailed, match=r"Unexpected collector error \(RuntimeError\)" + ) as raised: + await coordinator._async_update_data() + + assert sentinel not in caplog.text + assert sentinel not in str(raised.value) + assert raised.value.__cause__ is None + assert raised.value.__suppress_context__ is True + + +@pytest.mark.asyncio +async def test_coordinator_does_not_overlap_timed_out_executor_work(): + loop = asyncio.get_running_loop() + pending_run = loop.create_future() + + class ControlledHass(DummyHass): + def async_add_executor_job(self, func, *args, **kwargs): + del func, args, kwargs + return pending_run + + hass = ControlledHass() + first_coordinator = HouseholdBinCoordinator( + hass, + DummyUKBinCollectionApp(), + name="Test Bin", + timeout=0.01, + config_entry_id="entry-retry", + ) + retry_coordinator = HouseholdBinCoordinator( + hass, + DummyUKBinCollectionApp(), + name="Test Bin", + timeout=0.01, + config_entry_id="entry-retry", + ) + + with pytest.raises(UpdateFailed, match="Timeout"): + await first_coordinator._async_update_data() + + # A new coordinator created by a setup retry/reload must observe the first + # coordinator's still-running executor Future. + assert first_coordinator._run_state is retry_coordinator._run_state + assert ( + hass.data[DOMAIN][_COLLECTOR_RUN_STATES]["entry-retry"] + is first_coordinator._run_state + ) + assert first_coordinator._active_run is pending_run + assert not pending_run.done() + with pytest.raises(UpdateFailed, match="previous collector run"): + await retry_coordinator._async_update_data() + + # The failed retry must not replace or clear the first in-flight marker. + assert first_coordinator._active_run is pending_run + + pending_run.set_result(json.dumps({"bins": []})) + await asyncio.sleep(0) + assert first_coordinator._active_run is None + assert retry_coordinator._active_run is None diff --git a/custom_components/uk_bin_collection/tests/test_sensor.py b/custom_components/uk_bin_collection/tests/test_sensor.py index ae66736b9d..2167fc54a2 100644 --- a/custom_components/uk_bin_collection/tests/test_sensor.py +++ b/custom_components/uk_bin_collection/tests/test_sensor.py @@ -8,7 +8,7 @@ import pytest from freezegun import freeze_time from homeassistant.config_entries import ConfigEntryState -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util from homeassistant.core import ServiceCall @@ -61,7 +61,7 @@ @pytest.fixture def mock_config_entry(): """Create a mock ConfigEntry.""" - return MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, title="Test Entry", data={ @@ -74,6 +74,8 @@ def mock_config_entry(): entry_id="test", unique_id="test_unique_id", ) + entry.state = ConfigEntryState.SETUP_IN_PROGRESS + return entry # Tests @@ -227,7 +229,11 @@ async def test_bin_sensor(hass, mock_config_entry): return_value=mock_app_instance.run.return_value, ): coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -266,7 +272,11 @@ async def test_raw_json_sensor(hass, mock_config_entry): return_value=mock_app_instance.run.return_value, ): coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_refresh() @@ -307,7 +317,11 @@ async def test_bin_sensor_custom_icon_color(hass, mock_config_entry): ): # Create the coordinator coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) # Perform the first refresh @@ -352,7 +366,11 @@ async def test_bin_sensor_today_collection(hass, freezer, mock_config_entry): ): # Create the coordinator coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) # Perform the first refresh @@ -396,7 +414,11 @@ async def test_bin_sensor_tomorrow_collection(hass, freezer, mock_config_entry): ): # Create the coordinator coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) # Perform the first refresh @@ -441,7 +463,11 @@ async def test_bin_sensor_partial_custom_icon_color(hass, mock_config_entry): ): # Create the coordinator coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) # Perform the first refresh @@ -573,7 +599,11 @@ async def test_coordinator_timeout_error(hass, mock_config_entry): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=1 + hass, + mock_app_instance, + "Test Name", + timeout=1, + config_entry=mock_config_entry, ) # Expect ConfigEntryNotReady instead of UpdateFailed @@ -603,7 +633,11 @@ def side_effect(*args, **kwargs): hass.data = {} coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) # Expect ConfigEntryNotReady instead of UpdateFailed @@ -629,14 +663,19 @@ async def test_coordinator_general_exception(hass, mock_config_entry): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) - # Expect ConfigEntryNotReady instead of UpdateFailed + # Initial setup converts the redacted unexpected-error category into a + # retryable Home Assistant setup error without exposing exception text. with pytest.raises(ConfigEntryNotReady) as exc_info: await coordinator.async_config_entry_first_refresh() - assert "Unexpected error" in str(exc_info.value) + assert str(exc_info.value) == "Unexpected collector error (Exception)." def process_bin_data_duplicate_bin_types(freezer): @@ -725,7 +764,11 @@ async def test_bin_sensor_state_today(hass, mock_config_entry, freezer): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -761,7 +804,11 @@ async def test_bin_sensor_state_tomorrow(hass, mock_config_entry, freezer): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -795,7 +842,11 @@ async def test_bin_sensor_state_in_days(hass, mock_config_entry, freezer): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -829,7 +880,11 @@ async def test_bin_sensor_missing_data(hass, mock_config_entry): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -1000,7 +1055,11 @@ async def test_data_sensor_missing_icon_or_color(hass, mock_config_entry): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -1059,7 +1118,11 @@ async def test_attribute_sensor_with_complete_mappings(hass, mock_config_entry): hass.data = {} coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -1110,6 +1173,7 @@ async def test_data_sensor_color_property_missing_or_none(hass, mock_config_entr mock_app_missing_color_instance, "Test Name", timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -1151,6 +1215,7 @@ async def test_data_sensor_color_property_missing_or_none(hass, mock_config_entr mock_app_none_color_instance, "Test Name", timeout=60, + config_entry=mock_config_entry, ) await coordinator_none_color.async_config_entry_first_refresh() @@ -1229,7 +1294,11 @@ async def test_coordinator_empty_data(hass, mock_config_entry): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -1267,7 +1336,7 @@ async def test_async_setup_entry_missing_required_fields(hass): mock_app_instance.run.return_value = "{}" hass.async_add_executor_job = AsyncMock(return_value="{}") - with pytest.raises(ConfigEntryNotReady) as exc_info: + with pytest.raises(ConfigEntryError) as exc_info: # Call the domain-level function await async_setup_entry_domain(hass, mock_config_entry) @@ -1296,7 +1365,11 @@ async def test_data_sensor_device_info(hass, mock_config_entry): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -1341,7 +1414,11 @@ async def test_data_sensor_default_icon(hass, mock_config_entry): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -1380,7 +1457,11 @@ async def test_manual_refresh_service(hass, mock_config_entry): ) coordinator = HouseholdBinCoordinator( - hass, mock_app_instance, "Test Name", timeout=60 + hass, + mock_app_instance, + "Test Name", + timeout=60, + config_entry=mock_config_entry, ) await coordinator.async_config_entry_first_refresh() @@ -1404,6 +1485,7 @@ async def mock_handle_manual_refresh(call: ServiceCall): ) as mock_refresh: # Construct a mock ServiceCall that includes the entry_id fake_call = ServiceCall( + hass=hass, domain=DOMAIN, service="manual_refresh", data={"entry_id": mock_config_entry.entry_id}, @@ -1423,10 +1505,9 @@ def test_load_icon_color_mapping_invalid_json(): result = load_icon_color_mapping(invalid_json) # The function should return {} assert result == {} - # Note the double space after the prefix – adjust to match the actual log message. mock_warn.assert_called_once_with( - "[UKBinCollection] Invalid icon_color_mapping JSON: " - f"{invalid_json}. Using default settings." + "%s Invalid icon_color_mapping JSON. Using default settings.", + LOG_PREFIX, ) @@ -1454,7 +1535,7 @@ async def test_bin_sensor_missing_bin_type(hass, mock_config_entry): assert sensor.extra_state_attributes["days"] is None assert sensor.available is True mock_debug.assert_called_once_with( - "[UKBinCollection] No upcoming date for bin type 'General Waste'." + "%s No upcoming date for this entity.", LOG_PREFIX ) @@ -1477,7 +1558,7 @@ async def test_attribute_sensor_undefined_attribute_type(hass, mock_config_entry state = sensor.state assert state == "Undefined" mock_warn.assert_called_once_with( - "[UKBinCollection] Undefined attribute type: Bogus Attribute" + "%s Entity has an undefined attribute type.", LOG_PREFIX ) diff --git a/custom_components/uk_bin_collection/translations/en.json b/custom_components/uk_bin_collection/translations/en.json index 5431c9e8db..d65f626c73 100644 --- a/custom_components/uk_bin_collection/translations/en.json +++ b/custom_components/uk_bin_collection/translations/en.json @@ -1,59 +1,130 @@ { - "title": "UK Bin Collection Data", - "config": { - "step": { - "user": { - "title": "Select the council", - "data": { - "name": "Location name", - "council": "Council", - "manual_refresh_only":"Automatically refresh the sensor", - "icon_color_mapping": "JSON to map Bin Type for Colour and Icon (see documentation)" - }, - "description": "Please see the documentation if your council isn't listed" - }, - "council": { - "title": "Provide council details", - "data": { - "url": "URL to fetch bin collection data", - "timeout": "The time in seconds for how long the sensor should wait for data", - "update_interval": "Time in hours between updates", - "uprn": "UPRN (Unique Property Reference Number)", - "postcode": "Postcode of the address", - "number": "House number of the address", - "usrn": "USRN (Unique Street Reference Number)", - "web_driver": "To run on a remote Selenium Server add the Selenium Server URL", - "headless": "Run Selenium in headless mode (recommended)", - "local_browser": "Don't run on remote Selenium server, use local install of Chrome instead", - "submit": "Submit" - }, - "description": "Please refer to your council's wiki entry for details on what to enter.\n{selenium_message}" - }, - "reconfigure_confirm": { - "title": "Update council details", - "data": { - "url": "URL to fetch bin collection data", - "timeout": "The time in seconds for how long the sensor should wait for data", - "update_interval": "Time in hours between updates", - "uprn": "UPRN (Unique Property Reference Number)", - "postcode": "Postcode of the address", - "number": "House number of the address", - "usrn": "USRN (Unique Street Reference Number)", - "web_driver": "To run on a remote Selenium Server add the Selenium Server URL", - "headless": "Run Selenium in headless mode (recommended)", - "local_browser": "Don't run on remote Selenium server, use local install of Chrome instead", - "manual_refresh_only":"Automatically refresh the sensor", - "icon_color_mapping": "JSON to map Bin Type for Colour and Icon (see documentation)", - "submit": "Submit" - }, - "description": "Please refer to your council's wiki entry for details on what to enter." - } + "title": "UK Bin Collection Data", + "config": { + "step": { + "user": { + "title": "Select the council", + "data": { + "name": "Location name", + "council": "Council", + "manual_refresh_only": "Manual refresh only", + "icon_color_mapping": "JSON to map bin type to colour and icon" }, - "error": { - "name": "Please enter a location name", - "council": "Please select a council", - "selenium_unavailable": "Selenium server is not accessible. Please ensure it is running at localhost:4444 or selenium:4444", - "chromium_not_found": "Chromium browser is not installed. Please install Chromium or Google Chrome" - } + "description": "Please see the documentation if your council is not listed." + }, + "council": { + "title": "Provide council details", + "data": { + "url": "URL to fetch bin collection data", + "timeout": "Maximum lookup time in seconds", + "update_interval": "Time in hours between updates", + "uprn": "UPRN (Unique Property Reference Number)", + "postcode": "Postcode of the address", + "number": "House number or name", + "usrn": "USRN (Unique Street Reference Number)", + "web_driver": "Remote Selenium WebDriver URL", + "headless": "Run Selenium in headless mode (recommended)", + "local_browser": "Use a locally installed Chrome browser", + "user_agent": "Optional browser user agent", + "submit": "Submit" + }, + "description": "Please refer to your council's wiki entry for details on what to enter.\n{selenium_message}" + }, + "reconfigure_confirm": { + "title": "Update council details", + "data": { + "name": "Location name", + "council": "Council", + "url": "URL to fetch bin collection data", + "timeout": "Maximum lookup time in seconds", + "update_interval": "Time in hours between updates", + "uprn": "UPRN (Unique Property Reference Number)", + "postcode": "Postcode of the address", + "number": "House number or name", + "usrn": "USRN (Unique Street Reference Number)", + "web_driver": "Remote Selenium WebDriver URL", + "headless": "Run Selenium in headless mode (recommended)", + "local_browser": "Use a locally installed Chrome browser", + "user_agent": "Optional browser user agent", + "manual_refresh_only": "Manual refresh only", + "icon_color_mapping": "JSON to map bin type to colour and icon", + "submit": "Submit" + }, + "description": "Please refer to your council's wiki entry for details on what to enter." + } + }, + "error": { + "name": "Please enter a location name", + "council": "Please select a council", + "selenium_unavailable": "The Selenium server is not accessible", + "chromium_not_found": "Chromium or Google Chrome is not installed", + "required": "This field is required", + "invalid_json": "Enter valid JSON", + "invalid_update_interval": "Enter an update interval of at least one hour", + "browser_required": "Enter a remote Selenium WebDriver URL or select local browser", + "browser_conflict": "Choose either a remote Selenium WebDriver or local browser, not both", + "remote_browser_required": "South Kesteven requires a remote Selenium WebDriver; enter its HTTP(S) URL and turn off local browser", + "invalid_webdriver_url": "Enter a valid HTTP or HTTPS Selenium WebDriver URL with a host", + "council_change_not_supported": "An existing entry cannot change council. Add a new integration entry for a different council", + "duplicate_entry": "An equivalent UK Bin Collection entry already exists" + }, + "abort": { + "council_data_unavailable": "Council metadata is currently unavailable", + "reconfigure_failed": "The integration entry could not be found", + "reconfigure_successful": "The integration entry was updated successfully" + } + }, + "options": { + "step": { + "init": { + "title": "Update UK Bin Collection options", + "data": { + "name": "Location name", + "council": "Council", + "url": "URL to fetch bin collection data", + "timeout": "Maximum lookup time in seconds", + "update_interval": "Time in hours between updates", + "uprn": "UPRN (Unique Property Reference Number)", + "postcode": "Postcode of the address", + "number": "House number or name", + "usrn": "USRN (Unique Street Reference Number)", + "web_driver": "Remote Selenium WebDriver URL", + "headless": "Run Selenium in headless mode (recommended)", + "local_browser": "Use a locally installed Chrome browser", + "user_agent": "Optional browser user agent", + "manual_refresh_only": "Manual refresh only", + "icon_color_mapping": "JSON to map bin type to colour and icon" + }, + "description": "Update scheduling, address, and browser settings for this entry." + } + }, + "error": { + "council": "Please select a council", + "required": "This field is required", + "invalid_json": "Enter valid JSON", + "invalid_update_interval": "Enter an update interval of at least one hour", + "browser_required": "Enter a remote Selenium WebDriver URL or select local browser", + "browser_conflict": "Choose either a remote Selenium WebDriver or local browser, not both", + "remote_browser_required": "South Kesteven requires a remote Selenium WebDriver; enter its HTTP(S) URL and turn off local browser", + "invalid_webdriver_url": "Enter a valid HTTP or HTTPS Selenium WebDriver URL with a host", + "council_change_not_supported": "An existing entry cannot change council. Add a new integration entry for a different council" + }, + "abort": { + "council_data_unavailable": "Council metadata is currently unavailable" + } + }, + "issues": { + "dependency_error": { + "title": "A Python dependency is missing or shadowed", + "description": "UK Bin Collection could not safely load a browser dependency. {details} Check the reported module path; a package placed directly in /config can shadow the installed Python package. Move the conflicting folder out of /config, restart Home Assistant, and reload this entry." + }, + "missing_required_configuration": { + "title": "UK Bin Collection configuration is incomplete", + "description": "This entry is missing: {fields}. Use Reconfigure on the integration entry and supply the missing address/browser values." + }, + "browser_unavailable": { + "title": "The Selenium browser is unavailable", + "description": "UK Bin Collection could not reach or start its configured browser. {details} Check the WebDriver address or local browser, then reload this entry." } + } } diff --git a/docs/south-kesteven-ha-hardening-candidate.md b/docs/south-kesteven-ha-hardening-candidate.md new file mode 100644 index 0000000000..3c493eb430 --- /dev/null +++ b/docs/south-kesteven-ha-hardening-candidate.md @@ -0,0 +1,257 @@ +# South Kesteven and Home Assistant hardening candidate + +> **Candidate status:** This document describes an experimental branch in the +> `Dozi3/UKBinCollectionData` fork, based on upstream `0.170.6` +> (`36d920520e4a4c64fb6278557d357b948bf95e88`). It is not an official +> UKBinCollectionData release, has not been merged into +> `robbrad/UKBinCollectionData`, and does not indicate upstream acceptance. + +## Purpose + +This branch addresses the South Kesteven District Council (SKDC) scraper and +the Home Assistant integration path that runs it. + +The reported Home Assistant failure happened before the SKDC scraper itself +executed. Selenium imported the `websocket` top-level package, but Python found +a misplaced `/config/websocket/__init__.py` ahead of the installed +`websocket-client` distribution. That file was an internal Home Assistant +custom-integration module and attempted a parent-relative import while loaded +as a top-level package: + +```text +ImportError: attempted relative import beyond top-level package +``` + +The candidate makes this class of dependency collision diagnosable and +prevents it from appearing as an unexplained, repeatedly retried coordinator +failure. + +## What changed + +### Core package and import isolation + +- Councils are resolved from validated, fully qualified package names. +- Selenium imports are deferred until a browser-backed council actually needs + them. Non-Selenium councils therefore do not import the browser stack merely + because the package is loaded. +- Before WebDriver creation, the candidate validates that the imported + `websocket` module belongs to the installed `websocket-client` distribution. +- Dependency shadowing, missing optional dependencies, browser + unavailability, address mismatches, upstream denials, and council-site drift + have distinct typed errors. +- Household identifiers and identifier-bearing URLs are redacted from normal + logs. Diagnostic screenshots and HTML are opt-in. +- Shared Selenium type annotations no longer force runtime browser imports. + +Many council files changed only to defer Selenium imports and redact exception +text. Those mechanical changes are intended to preserve each council's +existing scraper behavior while preventing a browser dependency problem from +breaking unrelated councils. + +Across the candidate, 122 council adapters are touched and 83 of them move +Selenium imports out of module scope. The branch adds 155 test functions. This +is deliberately a broad import-isolation change, so package-wide deterministic +validation remains a publication gate even though SKDC is the only council +exercised live. + +- USRN and browser user-agent values now propagate through the supported CLI + and Home Assistant paths. +- WebDriver URLs and remote-command settings are validated before use. +- Partially created browser sessions are cleaned up when startup fails. +- Birmingham no longer relies on an undeclared `yarl` dependency. + +### South Kesteven District Council + +- The standalone `requests.get()` preflight was removed. That preflight could + receive an environment-dependent HTTP 403 even when a real browser session + was permitted to use the service. +- One bounded Selenium session begins at the public `/binday` page and follows + the supported self-service checker in the same browser context. +- Property selection uses exact normalized house-number or house-name + matching. Missing and ambiguous matches fail explicitly instead of silently + choosing the first address. +- Page, element, and total-run timeouts are bounded. +- Page and element operations are bounded to 30 seconds, the SKDC run has a + 90-second deadline, and Home Assistant provides a 125-second outer boundary + so browser cleanup can complete. +- WebDriver cleanup runs in `finally`, including error and timeout paths. +- Browser HTTP 403 responses, selector/form drift, address mismatch, and + WebDriver failure remain distinguishable in Home Assistant diagnostics. + +### Home Assistant integration + +- Config-entry arguments use an explicit typed mapping. Boolean options such + as `skip_get_url`, headless mode, and local-browser mode are emitted as + flags rather than `--flag=True`. +- Postcode, house number/name, URL, WebDriver endpoint, user agent, timeout, + and supported flags are carried end to end into the selected council. +- Config entries use schema version 4 with sequential migration and + normalization of historical field aliases. +- Old SKDC entries missing a postcode, house number/name, or WebDriver + endpoint require reconfiguration rather than guessed values. +- Initial setup, options, and reconfiguration use the same council metadata + and validation rules. +- Dependency, browser, and configuration failures can create actionable Home + Assistant Repairs instead of repeated generic tracebacks. +- Refreshes are serialized per entry, stale-data behavior is explicit, and + the calendar platform no longer performs a duplicate initial scrape. +- Existing collection payloads and sensor/calendar entity identity contracts + are retained. + +### Release and workflow safeguards + +- Release validation checks the core-wheel/component contract. +- Release ordering requires the matching core artifact before the integration + manifest can expose it. +- Deterministic tests are separated from live canaries. +- GitHub Actions used by the changed workflows are pinned immutably. +- Workflow tests cover release ordering, manifest pinning, and rollback + contracts. + +## Commit series + +The runtime candidate tested on Home Assistant is +`9139a7a0bf8199e0755fce0946f3d8f0b97c7940`. It contains these commits after +upstream `0.170.6`: + +| Commit | Purpose | +| --- | --- | +| `183afcbe` | Harden SKDC, shared imports, logging, Home Assistant setup, migrations, diagnostics, and release gates | +| `edb88701` | Align deterministic validation with the exact Home Assistant target | +| `4a862602` | Validate rootless capability drops dynamically | +| `99b00fba` | Remove Birmingham's undeclared URL dependency | +| `d33c6476` | Support Selenium status and session-cleanup validation | +| `790577a9` | Isolate disposable-runner imports | +| `d148e7f7` | Honor Home Assistant Repair persistence timing | +| `7c43de91` | Preserve the complete migration/entity contract | +| `241d7425` | Validate unstarted-canary network intent | +| `da704563` | Bind the Podman canary to the inspected command only | +| `9139a7a0` | Isolate the project test package from installed dependencies | + +The branch-level documentation commit is intentionally separate from the +runtime candidate so the tested code identity remains clear. + +## Validation evidence + +### Deterministic validation + +Before the live trial, the exact runtime commit completed the focused +core/SKDC deterministic selection on all supported Python generations: + +| Runtime | Result | +| --- | ---: | +| Python 3.12 | 145 passed | +| Python 3.13 | 145 passed | +| Python 3.14 | 145 passed | + +These focused results validate the changed import, SKDC, and core behavior. +They are not a claim that every council website was contacted successfully. +Fresh fork CI is required for the complete deterministic matrix before this +candidate is considered ready for an upstream pull request or release. + +### Reproducible live-trial artifact + +The runtime commit was exported from Git rather than from the dirty working +tree. A unique prerelease wheel was built twice and produced byte-identical +output: + +| Item | Value | +| --- | --- | +| Trial version | `0.170.6rc2026071901` | +| Wheel SHA-256 | `7D8B15AC3016F2856AFEEB09DA1A2B6A3323853953CB62AB983F808CF0838B61` | +| Staged manifest SHA-256 | `5B188CD6BA8353D92692F8D113B396F67A97BA386CC9B30932FC60105EF99C3D` | +| Wheel tag | `py3-none-any` | +| Python declaration | `>=3.12,<3.15` | + +The generated trial manifest differed from the committed public manifest only +in `version` and `requirements`. It used a local, SHA-256-pinned direct wheel +reference; that generated manifest is not committed to this branch. + +### User-authorized live Home Assistant trial + +On 19 July 2026, the exact wheel and component were installed in a controlled, +reversible trial on the user's actual Home Assistant host: + +| Component | Observed runtime | +| --- | --- | +| Home Assistant Core | `2026.7.2` | +| Python | `3.14` | +| Home Assistant OS | `18.1` | +| Selenium | `4.46.0`, ARM64 | +| WebDriver route | Remote endpoint ending in `/wd/hub` | + +The live result was: + +- the configuration wizard loaded the hash-pinned local wheel successfully; +- one real SKDC lookup completed using the repository's public test fixture; +- Home Assistant created 10 UK Bin Collection devices and 36 entities; +- sensor values and next-collection dates were populated; +- calendar entities were created; +- no displayed UK Bin Collection entity was unavailable or unknown; +- `attempted relative import beyond top-level package` did not recur; +- no UK Bin Collection unexpected-error entry appeared in the post-run Core + log; +- the genuine HACS integration remained available; +- the WebDriver returned to ready state with zero active sessions; and +- the live wheel and component hashes still matched the staged artifacts. + +No other council was exercised live on that Home Assistant host. Confidence in +other councils comes from deterministic tests and import isolation, not from a +claim that every council's external website was manually tested. + +## Known limitations and publication gates + +### This branch is not yet a self-contained HACS release + +The committed component remains version `0.170.6` and requests: + +```text +uk-bin-collection>=0.170.6 +``` + +The already published PyPI `0.170.6` wheel is not this hardened candidate. +Installing this branch directly through HACS would therefore not guarantee +that Home Assistant installs the hardened core package used in the successful +live trial. + +A durable release must: + +1. assign a new, unique core-package version; +2. publish the exact reviewed core wheel; +3. verify that artifact and its supported Python range; +4. update the custom-component manifest to the exact matching version; and +5. publish the component and core artifact atomically. + +Until that happens, this branch is for source review and controlled testing +only. It must not be tagged, published as a GitHub Release, or described as a +normal HACS installation. + +### Remaining gates + +- Run the full deterministic Python 3.12/3.13/3.14 workflow on this fork + branch. +- Run HACS/HassFest validation on the pushed branch. +- Re-run the exact Home Assistant compatibility matrix for any runtime code + change after `9139a7a0`. +- Synchronize the new form, validation, and Repair strings into the + non-English translation files. Home Assistant can fall back to English, but + the missing Welsh, Irish, Scottish Gaelic, and Portuguese entries are not + upstream-release quality. +- Review CI results before requesting any upstream pull request. +- Obtain separate approval before contacting or submitting anything to the + upstream repository. + +Broader networking/TLS centralization, collection-event modeling, supplier +adapter consolidation, and council-registry caching are outside this focused +candidate. + +## Rollback and support status + +This candidate was trialed only after a full Home Assistant backup and a +verified off-host copy were created. Manual trials should follow the same +practice and preserve a reversible copy of any conflicting path before it is +moved. + +The fork branch is not an upstream-supported release. Official support and +release expectations remain with the upstream project unless and until the +changes are accepted there. diff --git a/poetry.lock b/poetry.lock index b1d6a8fda7..5620616d69 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,191 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. - -[[package]] -name = "aiohttp" -version = "3.9.1" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1f80197f8b0b846a8d5cf7b7ec6084493950d0882cc5537fb7b96a69e3c8590"}, - {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72444d17777865734aa1a4d167794c34b63e5883abb90356a0364a28904e6c0"}, - {file = "aiohttp-3.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b05d5cbe9dafcdc733262c3a99ccf63d2f7ce02543620d2bd8db4d4f7a22f83"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c4fa235d534b3547184831c624c0b7c1e262cd1de847d95085ec94c16fddcd5"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:289ba9ae8e88d0ba16062ecf02dd730b34186ea3b1e7489046fc338bdc3361c4"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bff7e2811814fa2271be95ab6e84c9436d027a0e59665de60edf44e529a42c1f"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b77f868814346662c96ab36b875d7814ebf82340d3284a31681085c051320f"}, - {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b9c7426923bb7bd66d409da46c41e3fb40f5caf679da624439b9eba92043fa6"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8d44e7bf06b0c0a70a20f9100af9fcfd7f6d9d3913e37754c12d424179b4e48f"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22698f01ff5653fe66d16ffb7658f582a0ac084d7da1323e39fd9eab326a1f26"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ca7ca5abfbfe8d39e653870fbe8d7710be7a857f8a8386fc9de1aae2e02ce7e4"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8d7f98fde213f74561be1d6d3fa353656197f75d4edfbb3d94c9eb9b0fc47f5d"}, - {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5216b6082c624b55cfe79af5d538e499cd5f5b976820eac31951fb4325974501"}, - {file = "aiohttp-3.9.1-cp310-cp310-win32.whl", hash = "sha256:0e7ba7ff228c0d9a2cd66194e90f2bca6e0abca810b786901a569c0de082f489"}, - {file = "aiohttp-3.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:c7e939f1ae428a86e4abbb9a7c4732bf4706048818dfd979e5e2839ce0159f23"}, - {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:df9cf74b9bc03d586fc53ba470828d7b77ce51b0582d1d0b5b2fb673c0baa32d"}, - {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecca113f19d5e74048c001934045a2b9368d77b0b17691d905af18bd1c21275e"}, - {file = "aiohttp-3.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8cef8710fb849d97c533f259103f09bac167a008d7131d7b2b0e3a33269185c0"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bea94403a21eb94c93386d559bce297381609153e418a3ffc7d6bf772f59cc35"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91c742ca59045dce7ba76cab6e223e41d2c70d79e82c284a96411f8645e2afff"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c93b7c2e52061f0925c3382d5cb8980e40f91c989563d3d32ca280069fd6a87"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee2527134f95e106cc1653e9ac78846f3a2ec1004cf20ef4e02038035a74544d"}, - {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ff168d752cb41e8492817e10fb4f85828f6a0142b9726a30c27c35a1835f01"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b8c3a67eb87394386847d188996920f33b01b32155f0a94f36ca0e0c635bf3e3"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c7b5d5d64e2a14e35a9240b33b89389e0035e6de8dbb7ffa50d10d8b65c57449"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:69985d50a2b6f709412d944ffb2e97d0be154ea90600b7a921f95a87d6f108a2"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:c9110c06eaaac7e1f5562caf481f18ccf8f6fdf4c3323feab28a93d34cc646bd"}, - {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d737e69d193dac7296365a6dcb73bbbf53bb760ab25a3727716bbd42022e8d7a"}, - {file = "aiohttp-3.9.1-cp311-cp311-win32.whl", hash = "sha256:4ee8caa925aebc1e64e98432d78ea8de67b2272252b0a931d2ac3bd876ad5544"}, - {file = "aiohttp-3.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:a34086c5cc285be878622e0a6ab897a986a6e8bf5b67ecb377015f06ed316587"}, - {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f800164276eec54e0af5c99feb9494c295118fc10a11b997bbb1348ba1a52065"}, - {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:500f1c59906cd142d452074f3811614be04819a38ae2b3239a48b82649c08821"}, - {file = "aiohttp-3.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0b0a6a36ed7e164c6df1e18ee47afbd1990ce47cb428739d6c99aaabfaf1b3af"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69da0f3ed3496808e8cbc5123a866c41c12c15baaaead96d256477edf168eb57"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:176df045597e674fa950bf5ae536be85699e04cea68fa3a616cf75e413737eb5"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b796b44111f0cab6bbf66214186e44734b5baab949cb5fb56154142a92989aeb"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f27fdaadce22f2ef950fc10dcdf8048407c3b42b73779e48a4e76b3c35bca26c"}, - {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb6532b9814ea7c5a6a3299747c49de30e84472fa72821b07f5a9818bce0f66"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:54631fb69a6e44b2ba522f7c22a6fb2667a02fd97d636048478db2fd8c4e98fe"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4b4c452d0190c5a820d3f5c0f3cd8a28ace48c54053e24da9d6041bf81113183"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:cae4c0c2ca800c793cae07ef3d40794625471040a87e1ba392039639ad61ab5b"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:565760d6812b8d78d416c3c7cfdf5362fbe0d0d25b82fed75d0d29e18d7fc30f"}, - {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54311eb54f3a0c45efb9ed0d0a8f43d1bc6060d773f6973efd90037a51cd0a3f"}, - {file = "aiohttp-3.9.1-cp312-cp312-win32.whl", hash = "sha256:85c3e3c9cb1d480e0b9a64c658cd66b3cfb8e721636ab8b0e746e2d79a7a9eed"}, - {file = "aiohttp-3.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:11cb254e397a82efb1805d12561e80124928e04e9c4483587ce7390b3866d213"}, - {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8a22a34bc594d9d24621091d1b91511001a7eea91d6652ea495ce06e27381f70"}, - {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:598db66eaf2e04aa0c8900a63b0101fdc5e6b8a7ddd805c56d86efb54eb66672"}, - {file = "aiohttp-3.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c9376e2b09895c8ca8b95362283365eb5c03bdc8428ade80a864160605715f1"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41473de252e1797c2d2293804e389a6d6986ef37cbb4a25208de537ae32141dd"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c5857612c9813796960c00767645cb5da815af16dafb32d70c72a8390bbf690"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffcd828e37dc219a72c9012ec44ad2e7e3066bec6ff3aaa19e7d435dbf4032ca"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:219a16763dc0294842188ac8a12262b5671817042b35d45e44fd0a697d8c8361"}, - {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f694dc8a6a3112059258a725a4ebe9acac5fe62f11c77ac4dcf896edfa78ca28"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bcc0ea8d5b74a41b621ad4a13d96c36079c81628ccc0b30cfb1603e3dfa3a014"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:90ec72d231169b4b8d6085be13023ece8fa9b1bb495e4398d847e25218e0f431"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cf2a0ac0615842b849f40c4d7f304986a242f1e68286dbf3bd7a835e4f83acfd"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:0e49b08eafa4f5707ecfb321ab9592717a319e37938e301d462f79b4e860c32a"}, - {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c59e0076ea31c08553e868cec02d22191c086f00b44610f8ab7363a11a5d9d8"}, - {file = "aiohttp-3.9.1-cp38-cp38-win32.whl", hash = "sha256:4831df72b053b1eed31eb00a2e1aff6896fb4485301d4ccb208cac264b648db4"}, - {file = "aiohttp-3.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:3135713c5562731ee18f58d3ad1bf41e1d8883eb68b363f2ffde5b2ea4b84cc7"}, - {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfeadf42840c1e870dc2042a232a8748e75a36b52d78968cda6736de55582766"}, - {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70907533db712f7aa791effb38efa96f044ce3d4e850e2d7691abd759f4f0ae0"}, - {file = "aiohttp-3.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdefe289681507187e375a5064c7599f52c40343a8701761c802c1853a504558"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7481f581251bb5558ba9f635db70908819caa221fc79ee52a7f58392778c636"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49f0c1b3c2842556e5de35f122fc0f0b721334ceb6e78c3719693364d4af8499"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d406b01a9f5a7e232d1b0d161b40c05275ffbcbd772dc18c1d5a570961a1ca4"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d8e4450e7fe24d86e86b23cc209e0023177b6d59502e33807b732d2deb6975f"}, - {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c0266cd6f005e99f3f51e583012de2778e65af6b73860038b968a0a8888487a"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab221850108a4a063c5b8a70f00dd7a1975e5a1713f87f4ab26a46e5feac5a0e"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c88a15f272a0ad3d7773cf3a37cc7b7d077cbfc8e331675cf1346e849d97a4e5"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:237533179d9747080bcaad4d02083ce295c0d2eab3e9e8ce103411a4312991a0"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:02ab6006ec3c3463b528374c4cdce86434e7b89ad355e7bf29e2f16b46c7dd6f"}, - {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04fa38875e53eb7e354ece1607b1d2fdee2d175ea4e4d745f6ec9f751fe20c7c"}, - {file = "aiohttp-3.9.1-cp39-cp39-win32.whl", hash = "sha256:82eefaf1a996060602f3cc1112d93ba8b201dbf5d8fd9611227de2003dddb3b7"}, - {file = "aiohttp-3.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:9b05d33ff8e6b269e30a7957bd3244ffbce2a7a35a81b81c382629b80af1a8bf"}, - {file = "aiohttp-3.9.1.tar.gz", hash = "sha256:8fc49a87ac269d4529da45871e2ffb6874e87779c3d0e2ccd813c0899221239d"}, -] - -[package.dependencies] -aiosignal = ">=1.1.2" -attrs = ">=17.3.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -yarl = ">=1.0,<2.0" - -[package.extras] -speedups = ["Brotli", "aiodns", "brotlicffi"] - -[[package]] -name = "aiohttp-cors" -version = "0.7.0" -description = "CORS support for aiohttp" -optional = false -python-versions = "*" -files = [ - {file = "aiohttp-cors-0.7.0.tar.gz", hash = "sha256:4d39c6d7100fd9764ed1caf8cebf0eb01bf5e3f24e2e073fda6234bc48b19f5d"}, - {file = "aiohttp_cors-0.7.0-py3-none-any.whl", hash = "sha256:0451ba59fdf6909d0e2cd21e4c0a43752bc0703d33fc78ae94d9d9321710193e"}, -] - -[package.dependencies] -aiohttp = ">=1.1" - -[[package]] -name = "aiohttp-fast-url-dispatcher" -version = "0.3.0" -description = "A faster URL dispatcher for aiohttp" -optional = false -python-versions = ">=3.8,<4.0" -files = [ - {file = "aiohttp_fast_url_dispatcher-0.3.0-py3-none-any.whl", hash = "sha256:e038458a34b79ef7c276b3257a1cdc73625da92cf4b64c2d0aefc4fe04dcdbbb"}, - {file = "aiohttp_fast_url_dispatcher-0.3.0.tar.gz", hash = "sha256:0fc11c60a4209429340d9d2d07b6b0819a45ebd0d47ceb78bea915dbe042addd"}, -] - -[package.dependencies] -aiohttp = ">=3.8.5,<3.10" - -[[package]] -name = "aiohttp-zlib-ng" -version = "0.1.1" -description = "Enable zlib_ng on aiohttp" -optional = false -python-versions = ">=3.8,<4.0" -files = [ - {file = "aiohttp_zlib_ng-0.1.1-py3-none-any.whl", hash = "sha256:05a4f69b1f02cb9a790c366e1417ed7a95e23a55123acc2216a47b8f9d27226f"}, - {file = "aiohttp_zlib_ng-0.1.1.tar.gz", hash = "sha256:8f3a9a9b4e8bfa9c3cfa7fb619b1268d6e986a66e452de067a81017717bb6962"}, -] - -[package.dependencies] -aiohttp = ">=3.8.5" -zlib-ng = ">=0.2.0" - -[[package]] -name = "aiosignal" -version = "1.3.2" -description = "aiosignal: a list of registered asynchronous callbacks" -optional = false -python-versions = ">=3.9" -files = [ - {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, - {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, -] - -[package.dependencies] -frozenlist = ">=1.1.0" - -[[package]] -name = "anyio" -version = "4.9.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.9" -files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, -] - -[package.dependencies] -idna = ">=2.8" -sniffio = ">=1.1" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] -trio = ["trio (>=0.26.1)"] - -[[package]] -name = "astral" -version = "2.2" -description = "Calculations for the position of the sun and moon." -optional = false -python-versions = ">=3.6" -files = [ - {file = "astral-2.2-py2.py3-none-any.whl", hash = "sha256:b9ef70faf32e81a8ba174d21e8f29dc0b53b409ef035f27e0749ddc13cb5982a"}, - {file = "astral-2.2.tar.gz", hash = "sha256:e41d9967d5c48be421346552f0f4dedad43ff39a83574f5ff2ad32b6627b6fbe"}, -] - -[package.dependencies] -pytz = "*" +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "astroid" @@ -198,80 +11,17 @@ files = [ {file = "astroid-3.3.9.tar.gz", hash = "sha256:622cc8e3048684aa42c820d9d218978021c3c3d174fb03a9f0d615921744f550"}, ] -[[package]] -name = "atomicwrites-homeassistant" -version = "1.4.1" -description = "Atomic file writes." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "atomicwrites-homeassistant-1.4.1.tar.gz", hash = "sha256:256a672106f16745445228d966240b77b55f46a096d20305901a57aa5d1f4c2f"}, - {file = "atomicwrites_homeassistant-1.4.1-py2.py3-none-any.whl", hash = "sha256:01457de800961db7d5b575f3c92e7fb56e435d88512c366afb0873f4f092bb0d"}, -] - [[package]] name = "attrs" -version = "23.1.0" +version = "26.1.0" description = "Classes Without Boilerplate" optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, - {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, -] - -[package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] - -[[package]] -name = "awesomeversion" -version = "23.11.0" -description = "One version package to rule them all, One version package to find them, One version package to bring them all, and in the darkness bind them." -optional = false -python-versions = ">=3.8,<4.0" -files = [ - {file = "awesomeversion-23.11.0-py3-none-any.whl", hash = "sha256:16907305c091637e296966a70731345e70825fef1664fd1a72155ce83f2b6dc7"}, - {file = "awesomeversion-23.11.0.tar.gz", hash = "sha256:9146329196f0f045887de6c195730750f8f7a9302d1c149378db73ab5dc468f0"}, -] - -[[package]] -name = "bcrypt" -version = "4.0.1" -description = "Modern password hashing for your software and your servers" -optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" files = [ - {file = "bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2"}, - {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535"}, - {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e"}, - {file = "bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab"}, - {file = "bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9"}, - {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c"}, - {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b"}, - {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d"}, - {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d"}, - {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215"}, - {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c"}, - {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda"}, - {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665"}, - {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71"}, - {file = "bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd"}, + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] -[package.extras] -tests = ["pytest (>=3.2.1,!=3.3.0)"] -typecheck = ["mypy"] - [[package]] name = "beautifulsoup4" version = "4.13.4" @@ -360,13 +110,13 @@ beautifulsoup4 = "*" [[package]] name = "certifi" -version = "2025.1.31" +version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, + {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, + {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] [[package]] @@ -549,60 +299,6 @@ files = [ {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] -[[package]] -name = "ciso8601" -version = "2.3.0" -description = "Fast ISO8601 date time parser for Python written in C" -optional = false -python-versions = "*" -files = [ - {file = "ciso8601-2.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f884d6a0b7384f8b1c57f740196988dd1229242c1be7c30a75424725590e0b3"}, - {file = "ciso8601-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58517dfe06c30ad65fb1b4e9de66ccb72752d79bc71d7b7d26cbc0d008b7265a"}, - {file = "ciso8601-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c66032757d314ad232904f91a54df4907bd9af41b0d0b4acc19bfde1ab52983b"}, - {file = "ciso8601-2.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6cae7a74d9485a2f191adc5aad2563756af89cc1f3190e7d89f401b2349eb2b"}, - {file = "ciso8601-2.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:47cc66899e5facdccc28f183b978ace9edbebdea6545c013ec1d369fdea3de61"}, - {file = "ciso8601-2.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b4596c9d92719af4f06082c59182ce9de3a73e2bda67304498d9ac78264dd5c"}, - {file = "ciso8601-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:a002a8dc91e63730f7ca8eae0cb1e2832ee057fedf65e5b9bf416aefb1dd8cab"}, - {file = "ciso8601-2.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:87a6f58bdda833cb8d78c6482a179fff663903a8f562755e119bf815b1014f2e"}, - {file = "ciso8601-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7667faf021314315a3c498e4c7c8cf57a7014af0960ddd5b671bcf03b2d0132b"}, - {file = "ciso8601-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa90488666ee44796932850fc419cd55863b320f77b1474991e60f321b5ac7d2"}, - {file = "ciso8601-2.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1aba1f59b6d27ec694128f9ba85e22c1f17e67ffc5b1b0a991628bb402e25e81"}, - {file = "ciso8601-2.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:896dd46c7f2129140fc36dbe9ccf78cec02143b941b5a608e652cd40e39f6064"}, - {file = "ciso8601-2.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2cf6dfa22f21f838b730f977bc7ad057c37646f683bf42a727b4e763f44d47dc"}, - {file = "ciso8601-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:a8c4aa6880fd698075d5478615d4668e70af6424d90b1686c560c1ec3459926a"}, - {file = "ciso8601-2.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b12d314415ba1e4e4bfcfa3db782335949ca1866a2b6fe22c47099fed9c82826"}, - {file = "ciso8601-2.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d115fc2501a316256dd0b961b0b384a12998c626ab1e91cd06164f7792e3908"}, - {file = "ciso8601-2.3.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5817bd895c0d083c161ea38459de8e2b90d798de09769aaba003fe53c1418aba"}, - {file = "ciso8601-2.3.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7d68741fe53cd0134e8e94109ede36d7aeaa65a36682680d53b69f790291d80f"}, - {file = "ciso8601-2.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:74c4b0fe3fd0ce1a0da941f3f50af1a81970d7e4536cbae43f27e041b4ae4d3e"}, - {file = "ciso8601-2.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0136d49f2265bf3d06ffb7bc649a64ed316e921ba6cd05e0fecc477c80fe5097"}, - {file = "ciso8601-2.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2188dd4784d87e4008cc765c80e26a503450c57a98655321de777679c556b133"}, - {file = "ciso8601-2.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4e0fa37c6d58be990c10d537ed286a35c018b5f038039ad796cf2352bc26799e"}, - {file = "ciso8601-2.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:fa1085b47c15df627d6bea783a8f7c89a59268af85e204992a013df174b339aa"}, - {file = "ciso8601-2.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:352809f24dc0fa7e05b85046f8bd34165a20fa5ebb5b43e053668fa69d57e657"}, - {file = "ciso8601-2.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7e8e78f8c7d35e6b43ad7316f652e2d53bf4b8798725d481abff14657852a88c"}, - {file = "ciso8601-2.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4cc04399f79a62338d4f4c19560d2b30f2d257021df1b0e55bae9209d8844c0c"}, - {file = "ciso8601-2.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e4affe0e72debf18c98d2f9e41c24a8ec8421ea65fafba96919f20a8d0f9bf87"}, - {file = "ciso8601-2.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d7d0f84fb0276c031bf606da484e9dc52ebdf121695732609dc49b30e8cf7c"}, - {file = "ciso8601-2.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8b1a217967083ac295d9239f5ba5235c66697fdadc2d5399c7bac53353218201"}, - {file = "ciso8601-2.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2785f374388e48c21420e820295d36a8d0734542e4e7bd3899467dc4d56016da"}, - {file = "ciso8601-2.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:59e6ac990dc31b14a39344a6a0f651658829bc59666cfff13c8deca37e360d86"}, - {file = "ciso8601-2.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3b135cda50be4ed52e44e815794cb19b268baf75d6c2a2a34eb6c2851bbe9423"}, - {file = "ciso8601-2.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b247b4a854119d438d28e0efd0258a5bb710be59ffeba3d2bea5bdab82f90ef3"}, - {file = "ciso8601-2.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:243ffcbee824ed74b21bd1cede72050d36095df5fad8f1704730669d2b0db5be"}, - {file = "ciso8601-2.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39aa3d7148fcd9db1007c258e47c9e0174f383d82f5504b80db834c6215b7e4"}, - {file = "ciso8601-2.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e838b694b009e2d9b3b680008fa4c56e52f83935a31ea86fe4203dfff0086f88"}, - {file = "ciso8601-2.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:aa58f55ed5c8b1e9962b56b2ecbfcca32f056edf8ecdce73b6623c55a2fd11e8"}, - {file = "ciso8601-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:161dc428d1735ed6dee6ce599c4275ef3fe280fe37308e3cc2efd4301781a7ff"}, - {file = "ciso8601-2.3.0-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:374275a329138b9b70c857c9ea460f65dc7f01ed2513f991e57090f39bf01de5"}, - {file = "ciso8601-2.3.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:58910c03b5464d6b766ac5d894c6089ee8279432b85181283571b0e2bf502df4"}, - {file = "ciso8601-2.3.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9f7608a276fa46d28255906c341752a87fe5353d8060932e0ec71745148a4d8"}, - {file = "ciso8601-2.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e20d14155f7b069f2aa2387a3f31de98f93bb94da63ad1b5aae78445b33f0529"}, - {file = "ciso8601-2.3.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3f781561401c8666accae823ed8f2a5d1fa50b3e65eb65c21a2bd0374e14f19"}, - {file = "ciso8601-2.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0f4a649e9693e5a46843b0ebd288de1e45b8852a2cff684e3a6b6f3fd56ec4e"}, - {file = "ciso8601-2.3.0.tar.gz", hash = "sha256:19e3fbd786d8bec3358eac94d8774d365b694b604fd1789244b87083f66c8900"}, -] - [[package]] name = "click" version = "8.1.8" @@ -807,119 +503,6 @@ files = [ [package.dependencies] python-dateutil = ">=2.7" -[[package]] -name = "frozenlist" -version = "1.6.0" -description = "A list-like structure which implements collections.abc.MutableSequence" -optional = false -python-versions = ">=3.9" -files = [ - {file = "frozenlist-1.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e6e558ea1e47fd6fa8ac9ccdad403e5dd5ecc6ed8dda94343056fa4277d5c65e"}, - {file = "frozenlist-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4b3cd7334a4bbc0c472164f3744562cb72d05002cc6fcf58adb104630bbc352"}, - {file = "frozenlist-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9799257237d0479736e2b4c01ff26b5c7f7694ac9692a426cb717f3dc02fff9b"}, - {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a7bb0fe1f7a70fb5c6f497dc32619db7d2cdd53164af30ade2f34673f8b1fc"}, - {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:36d2fc099229f1e4237f563b2a3e0ff7ccebc3999f729067ce4e64a97a7f2869"}, - {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f27a9f9a86dcf00708be82359db8de86b80d029814e6693259befe82bb58a106"}, - {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75ecee69073312951244f11b8627e3700ec2bfe07ed24e3a685a5979f0412d24"}, - {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2c7d5aa19714b1b01a0f515d078a629e445e667b9da869a3cd0e6fe7dec78bd"}, - {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69bbd454f0fb23b51cadc9bdba616c9678e4114b6f9fa372d462ff2ed9323ec8"}, - {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7daa508e75613809c7a57136dec4871a21bca3080b3a8fc347c50b187df4f00c"}, - {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:89ffdb799154fd4d7b85c56d5fa9d9ad48946619e0eb95755723fffa11022d75"}, - {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:920b6bd77d209931e4c263223381d63f76828bec574440f29eb497cf3394c249"}, - {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d3ceb265249fb401702fce3792e6b44c1166b9319737d21495d3611028d95769"}, - {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52021b528f1571f98a7d4258c58aa8d4b1a96d4f01d00d51f1089f2e0323cb02"}, - {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0f2ca7810b809ed0f1917293050163c7654cefc57a49f337d5cd9de717b8fad3"}, - {file = "frozenlist-1.6.0-cp310-cp310-win32.whl", hash = "sha256:0e6f8653acb82e15e5443dba415fb62a8732b68fe09936bb6d388c725b57f812"}, - {file = "frozenlist-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f1a39819a5a3e84304cd286e3dc62a549fe60985415851b3337b6f5cc91907f1"}, - {file = "frozenlist-1.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae8337990e7a45683548ffb2fee1af2f1ed08169284cd829cdd9a7fa7470530d"}, - {file = "frozenlist-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c952f69dd524558694818a461855f35d36cc7f5c0adddce37e962c85d06eac0"}, - {file = "frozenlist-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f5fef13136c4e2dee91bfb9a44e236fff78fc2cd9f838eddfc470c3d7d90afe"}, - {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:716bbba09611b4663ecbb7cd022f640759af8259e12a6ca939c0a6acd49eedba"}, - {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b8c4dc422c1a3ffc550b465090e53b0bf4839047f3e436a34172ac67c45d595"}, - {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b11534872256e1666116f6587a1592ef395a98b54476addb5e8d352925cb5d4a"}, - {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c6eceb88aaf7221f75be6ab498dc622a151f5f88d536661af3ffc486245a626"}, - {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62c828a5b195570eb4b37369fcbbd58e96c905768d53a44d13044355647838ff"}, - {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1c6bd2c6399920c9622362ce95a7d74e7f9af9bfec05fff91b8ce4b9647845a"}, - {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ba23817781e22fcbd45fd9ff2b9b8cdb7b16a42a4851ab8025cae7b22e96d0"}, - {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:431ef6937ae0f853143e2ca67d6da76c083e8b1fe3df0e96f3802fd37626e606"}, - {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9d124b38b3c299ca68433597ee26b7819209cb8a3a9ea761dfe9db3a04bba584"}, - {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:118e97556306402e2b010da1ef21ea70cb6d6122e580da64c056b96f524fbd6a"}, - {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb3b309f1d4086b5533cf7bbcf3f956f0ae6469664522f1bde4feed26fba60f1"}, - {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54dece0d21dce4fdb188a1ffc555926adf1d1c516e493c2914d7c370e454bc9e"}, - {file = "frozenlist-1.6.0-cp311-cp311-win32.whl", hash = "sha256:654e4ba1d0b2154ca2f096bed27461cf6160bc7f504a7f9a9ef447c293caf860"}, - {file = "frozenlist-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e911391bffdb806001002c1f860787542f45916c3baf764264a52765d5a5603"}, - {file = "frozenlist-1.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1"}, - {file = "frozenlist-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29"}, - {file = "frozenlist-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25"}, - {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576"}, - {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8"}, - {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9"}, - {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e"}, - {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590"}, - {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103"}, - {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c"}, - {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821"}, - {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70"}, - {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f"}, - {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046"}, - {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770"}, - {file = "frozenlist-1.6.0-cp312-cp312-win32.whl", hash = "sha256:853ac025092a24bb3bf09ae87f9127de9fe6e0c345614ac92536577cf956dfcc"}, - {file = "frozenlist-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bdfe2d7e6c9281c6e55523acd6c2bf77963cb422fdc7d142fb0cb6621b66878"}, - {file = "frozenlist-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e"}, - {file = "frozenlist-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117"}, - {file = "frozenlist-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4"}, - {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3"}, - {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1"}, - {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c"}, - {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45"}, - {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f"}, - {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85"}, - {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8"}, - {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f"}, - {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f"}, - {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6"}, - {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188"}, - {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e"}, - {file = "frozenlist-1.6.0-cp313-cp313-win32.whl", hash = "sha256:4def87ef6d90429f777c9d9de3961679abf938cb6b7b63d4a7eb8a268babfce4"}, - {file = "frozenlist-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:37a8a52c3dfff01515e9bbbee0e6063181362f9de3db2ccf9bc96189b557cbfd"}, - {file = "frozenlist-1.6.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64"}, - {file = "frozenlist-1.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91"}, - {file = "frozenlist-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd"}, - {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2"}, - {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506"}, - {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0"}, - {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0"}, - {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e"}, - {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c"}, - {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b"}, - {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad"}, - {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215"}, - {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2"}, - {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911"}, - {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497"}, - {file = "frozenlist-1.6.0-cp313-cp313t-win32.whl", hash = "sha256:a4d96dc5bcdbd834ec6b0f91027817214216b5b30316494d2b1aebffb87c534f"}, - {file = "frozenlist-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e18036cb4caa17ea151fd5f3d70be9d354c99eb8cf817a3ccde8a7873b074348"}, - {file = "frozenlist-1.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:536a1236065c29980c15c7229fbb830dedf809708c10e159b8136534233545f0"}, - {file = "frozenlist-1.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ed5e3a4462ff25ca84fb09e0fada8ea267df98a450340ead4c91b44857267d70"}, - {file = "frozenlist-1.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e19c0fc9f4f030fcae43b4cdec9e8ab83ffe30ec10c79a4a43a04d1af6c5e1ad"}, - {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c608f833897501dac548585312d73a7dca028bf3b8688f0d712b7acfaf7fb3"}, - {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0dbae96c225d584f834b8d3cc688825911960f003a85cb0fd20b6e5512468c42"}, - {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:625170a91dd7261a1d1c2a0c1a353c9e55d21cd67d0852185a5fef86587e6f5f"}, - {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1db8b2fc7ee8a940b547a14c10e56560ad3ea6499dc6875c354e2335812f739d"}, - {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4da6fc43048b648275a220e3a61c33b7fff65d11bdd6dcb9d9c145ff708b804c"}, - {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef8e7e8f2f3820c5f175d70fdd199b79e417acf6c72c5d0aa8f63c9f721646f"}, - {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa733d123cc78245e9bb15f29b44ed9e5780dc6867cfc4e544717b91f980af3b"}, - {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ba7f8d97152b61f22d7f59491a781ba9b177dd9f318486c5fbc52cde2db12189"}, - {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:56a0b8dd6d0d3d971c91f1df75e824986667ccce91e20dca2023683814344791"}, - {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5c9e89bf19ca148efcc9e3c44fd4c09d5af85c8a7dd3dbd0da1cb83425ef4983"}, - {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1330f0a4376587face7637dfd245380a57fe21ae8f9d360c1c2ef8746c4195fa"}, - {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2187248203b59625566cac53572ec8c2647a140ee2738b4e36772930377a533c"}, - {file = "frozenlist-1.6.0-cp39-cp39-win32.whl", hash = "sha256:2b8cf4cfea847d6c12af06091561a89740f1f67f331c3fa8623391905e878530"}, - {file = "frozenlist-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:1255d5d64328c5a0d066ecb0f02034d086537925f1f04b50b1ae60d37afbf572"}, - {file = "frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191"}, - {file = "frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68"}, -] - [[package]] name = "geopandas" version = "1.1.2" @@ -979,104 +562,6 @@ files = [ [package.dependencies] python-dateutil = "*" -[[package]] -name = "home-assistant-bluetooth" -version = "1.10.4" -description = "Home Assistant Bluetooth Models and Helpers" -optional = false -python-versions = ">=3.9,<4.0" -files = [ - {file = "home_assistant_bluetooth-1.10.4-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:7c3434bdec5dcfe733d3e7c56d4a24418fcd03718dc2e7707c9133d1e48145a8"}, - {file = "home_assistant_bluetooth-1.10.4.tar.gz", hash = "sha256:21216b6be9d028bc232b9188ac4dce773798c6b4e47482cc3524bfc5f82515e3"}, -] - -[[package]] -name = "homeassistant" -version = "2023.12.4" -description = "Open-source home automation platform running on Python 3." -optional = false -python-versions = ">=3.11.0" -files = [ - {file = "homeassistant-2023.12.4-py3-none-any.whl", hash = "sha256:0a521e712d126a88febe747cd4215838774c0954a518c8c314d0fde5082834be"}, - {file = "homeassistant-2023.12.4.tar.gz", hash = "sha256:75e6b43da702cc25a1321da0c3f915247c180a84fbcc9e76a9341b1e6a9cc14f"}, -] - -[package.dependencies] -aiohttp = "3.9.1" -aiohttp-cors = "0.7.0" -aiohttp-fast-url-dispatcher = "0.3.0" -aiohttp-zlib-ng = "0.1.1" -astral = "2.2" -atomicwrites-homeassistant = "1.4.1" -attrs = "23.1.0" -awesomeversion = "23.11.0" -bcrypt = "4.0.1" -certifi = ">=2021.5.30" -ciso8601 = "2.3.0" -cryptography = "41.0.7" -home-assistant-bluetooth = "1.10.4" -httpx = "0.25.0" -ifaddr = "0.2.0" -Jinja2 = "3.1.2" -lru-dict = "1.2.0" -orjson = "3.9.9" -packaging = ">=23.1" -pip = ">=21.3.1" -PyJWT = "2.8.0" -pyOpenSSL = "23.2.0" -python-slugify = "4.0.1" -PyYAML = "6.0.1" -requests = "2.31.0" -typing-extensions = ">=4.8.0,<5.0" -ulid-transform = "0.9.0" -voluptuous = "0.13.1" -voluptuous-serialize = "2.6.0" -yarl = "1.9.2" - -[[package]] -name = "httpcore" -version = "0.18.0" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpcore-0.18.0-py3-none-any.whl", hash = "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced"}, - {file = "httpcore-0.18.0.tar.gz", hash = "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9"}, -] - -[package.dependencies] -anyio = ">=3.0,<5.0" -certifi = "*" -h11 = ">=0.13,<0.15" -sniffio = "==1.*" - -[package.extras] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] - -[[package]] -name = "httpx" -version = "0.25.0" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpx-0.25.0-py3-none-any.whl", hash = "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100"}, - {file = "httpx-0.25.0.tar.gz", hash = "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875"}, -] - -[package.dependencies] -certifi = "*" -httpcore = ">=0.18.0,<0.19.0" -idna = "*" -sniffio = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] - [[package]] name = "icalendar" version = "6.1.3" @@ -1126,17 +611,6 @@ files = [ [package.extras] all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "ifaddr" -version = "0.2.0" -description = "Cross-platform network interface and IP address enumeration library" -optional = false -python-versions = "*" -files = [ - {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, - {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, -] - [[package]] name = "iniconfig" version = "2.1.0" @@ -1163,23 +637,6 @@ files = [ colors = ["colorama"] plugins = ["setuptools"] -[[package]] -name = "jinja2" -version = "3.1.2" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - [[package]] name = "jsonschema" version = "4.23.0" @@ -1215,100 +672,6 @@ files = [ [package.dependencies] referencing = ">=0.31.0" -[[package]] -name = "lru-dict" -version = "1.2.0" -description = "An Dict like LRU container." -optional = false -python-versions = "*" -files = [ - {file = "lru-dict-1.2.0.tar.gz", hash = "sha256:13c56782f19d68ddf4d8db0170041192859616514c706b126d0df2ec72a11bd7"}, - {file = "lru_dict-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:de906e5486b5c053d15b7731583c25e3c9147c288ac8152a6d1f9bccdec72641"}, - {file = "lru_dict-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604d07c7604b20b3130405d137cae61579578b0e8377daae4125098feebcb970"}, - {file = "lru_dict-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:203b3e78d03d88f491fa134f85a42919020686b6e6f2d09759b2f5517260c651"}, - {file = "lru_dict-1.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:020b93870f8c7195774cbd94f033b96c14f51c57537969965c3af300331724fe"}, - {file = "lru_dict-1.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1184d91cfebd5d1e659d47f17a60185bbf621635ca56dcdc46c6a1745d25df5c"}, - {file = "lru_dict-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc42882b554a86e564e0b662da47b8a4b32fa966920bd165e27bb8079a323bc1"}, - {file = "lru_dict-1.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:18ee88ada65bd2ffd483023be0fa1c0a6a051ef666d1cd89e921dcce134149f2"}, - {file = "lru_dict-1.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:756230c22257597b7557eaef7f90484c489e9ba78e5bb6ab5a5bcfb6b03cb075"}, - {file = "lru_dict-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c4da599af36618881748b5db457d937955bb2b4800db891647d46767d636c408"}, - {file = "lru_dict-1.2.0-cp310-cp310-win32.whl", hash = "sha256:35a142a7d1a4fd5d5799cc4f8ab2fff50a598d8cee1d1c611f50722b3e27874f"}, - {file = "lru_dict-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:6da5b8099766c4da3bf1ed6e7d7f5eff1681aff6b5987d1258a13bd2ed54f0c9"}, - {file = "lru_dict-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b20b7c9beb481e92e07368ebfaa363ed7ef61e65ffe6e0edbdbaceb33e134124"}, - {file = "lru_dict-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22147367b296be31cc858bf167c448af02435cac44806b228c9be8117f1bfce4"}, - {file = "lru_dict-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34a3091abeb95e707f381a8b5b7dc8e4ee016316c659c49b726857b0d6d1bd7a"}, - {file = "lru_dict-1.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:877801a20f05c467126b55338a4e9fa30e2a141eb7b0b740794571b7d619ee11"}, - {file = "lru_dict-1.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d3336e901acec897bcd318c42c2b93d5f1d038e67688f497045fc6bad2c0be7"}, - {file = "lru_dict-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8dafc481d2defb381f19b22cc51837e8a42631e98e34b9e0892245cc96593deb"}, - {file = "lru_dict-1.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:87bbad3f5c3de8897b8c1263a9af73bbb6469fb90e7b57225dad89b8ef62cd8d"}, - {file = "lru_dict-1.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:25f9e0bc2fe8f41c2711ccefd2871f8a5f50a39e6293b68c3dec576112937aad"}, - {file = "lru_dict-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ae301c282a499dc1968dd633cfef8771dd84228ae9d40002a3ea990e4ff0c469"}, - {file = "lru_dict-1.2.0-cp311-cp311-win32.whl", hash = "sha256:c9617583173a29048e11397f165501edc5ae223504a404b2532a212a71ecc9ed"}, - {file = "lru_dict-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6b7a031e47421d4b7aa626b8c91c180a9f037f89e5d0a71c4bb7afcf4036c774"}, - {file = "lru_dict-1.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ea2ac3f7a7a2f32f194c84d82a034e66780057fd908b421becd2f173504d040e"}, - {file = "lru_dict-1.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd46c94966f631a81ffe33eee928db58e9fbee15baba5923d284aeadc0e0fa76"}, - {file = "lru_dict-1.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:086ce993414f0b28530ded7e004c77dc57c5748fa6da488602aa6e7f79e6210e"}, - {file = "lru_dict-1.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df25a426446197488a6702954dcc1de511deee20c9db730499a2aa83fddf0df1"}, - {file = "lru_dict-1.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c53b12b89bd7a6c79f0536ff0d0a84fdf4ab5f6252d94b24b9b753bd9ada2ddf"}, - {file = "lru_dict-1.2.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:f9484016e6765bd295708cccc9def49f708ce07ac003808f69efa386633affb9"}, - {file = "lru_dict-1.2.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d0f7ec902a0097ac39f1922c89be9eaccf00eb87751e28915320b4f72912d057"}, - {file = "lru_dict-1.2.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:981ef3edc82da38d39eb60eae225b88a538d47b90cce2e5808846fd2cf64384b"}, - {file = "lru_dict-1.2.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e25b2e90a032dc248213af7f3f3e975e1934b204f3b16aeeaeaff27a3b65e128"}, - {file = "lru_dict-1.2.0-cp36-cp36m-win32.whl", hash = "sha256:59f3df78e94e07959f17764e7fa7ca6b54e9296953d2626a112eab08e1beb2db"}, - {file = "lru_dict-1.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:de24b47159e07833aeab517d9cb1c3c5c2d6445cc378b1c2f1d8d15fb4841d63"}, - {file = "lru_dict-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d0dd4cd58220351233002f910e35cc01d30337696b55c6578f71318b137770f9"}, - {file = "lru_dict-1.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a87bdc291718bbdf9ea4be12ae7af26cbf0706fa62c2ac332748e3116c5510a7"}, - {file = "lru_dict-1.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05fb8744f91f58479cbe07ed80ada6696ec7df21ea1740891d4107a8dd99a970"}, - {file = "lru_dict-1.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00f6e8a3fc91481b40395316a14c94daa0f0a5de62e7e01a7d589f8d29224052"}, - {file = "lru_dict-1.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b172fce0a0ffc0fa6d282c14256d5a68b5db1e64719c2915e69084c4b6bf555"}, - {file = "lru_dict-1.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:e707d93bae8f0a14e6df1ae8b0f076532b35f00e691995f33132d806a88e5c18"}, - {file = "lru_dict-1.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b9ec7a4a0d6b8297102aa56758434fb1fca276a82ed7362e37817407185c3abb"}, - {file = "lru_dict-1.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:f404dcc8172da1f28da9b1f0087009578e608a4899b96d244925c4f463201f2a"}, - {file = "lru_dict-1.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:1171ad3bff32aa8086778be4a3bdff595cc2692e78685bcce9cb06b96b22dcc2"}, - {file = "lru_dict-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:0c316dfa3897fabaa1fe08aae89352a3b109e5f88b25529bc01e98ac029bf878"}, - {file = "lru_dict-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5919dd04446bc1ee8d6ecda2187deeebfff5903538ae71083e069bc678599446"}, - {file = "lru_dict-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fbf36c5a220a85187cacc1fcb7dd87070e04b5fc28df7a43f6842f7c8224a388"}, - {file = "lru_dict-1.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:712e71b64da181e1c0a2eaa76cd860265980cd15cb0e0498602b8aa35d5db9f8"}, - {file = "lru_dict-1.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f54908bf91280a9b8fa6a8c8f3c2f65850ce6acae2852bbe292391628ebca42f"}, - {file = "lru_dict-1.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3838e33710935da2ade1dd404a8b936d571e29268a70ff4ca5ba758abb3850df"}, - {file = "lru_dict-1.2.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5d5a5f976b39af73324f2b793862859902ccb9542621856d51a5993064f25e4"}, - {file = "lru_dict-1.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8bda3a9afd241ee0181661decaae25e5336ce513ac268ab57da737eacaa7871f"}, - {file = "lru_dict-1.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bd2cd1b998ea4c8c1dad829fc4fa88aeed4dee555b5e03c132fc618e6123f168"}, - {file = "lru_dict-1.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:b55753ee23028ba8644fd22e50de7b8f85fa60b562a0fafaad788701d6131ff8"}, - {file = "lru_dict-1.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7e51fa6a203fa91d415f3b2900e5748ec8e06ad75777c98cc3aeb3983ca416d7"}, - {file = "lru_dict-1.2.0-cp38-cp38-win32.whl", hash = "sha256:cd6806313606559e6c7adfa0dbeb30fc5ab625f00958c3d93f84831e7a32b71e"}, - {file = "lru_dict-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d90a70c53b0566084447c3ef9374cc5a9be886e867b36f89495f211baabd322"}, - {file = "lru_dict-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3ea7571b6bf2090a85ff037e6593bbafe1a8598d5c3b4560eb56187bcccb4dc"}, - {file = "lru_dict-1.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:287c2115a59c1c9ed0d5d8ae7671e594b1206c36ea9df2fca6b17b86c468ff99"}, - {file = "lru_dict-1.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5ccfd2291c93746a286c87c3f895165b697399969d24c54804ec3ec559d4e43"}, - {file = "lru_dict-1.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b710f0f4d7ec4f9fa89dfde7002f80bcd77de8024017e70706b0911ea086e2ef"}, - {file = "lru_dict-1.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5345bf50e127bd2767e9fd42393635bbc0146eac01f6baf6ef12c332d1a6a329"}, - {file = "lru_dict-1.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:291d13f85224551913a78fe695cde04cbca9dcb1d84c540167c443eb913603c9"}, - {file = "lru_dict-1.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d5bb41bc74b321789803d45b124fc2145c1b3353b4ad43296d9d1d242574969b"}, - {file = "lru_dict-1.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0facf49b053bf4926d92d8d5a46fe07eecd2af0441add0182c7432d53d6da667"}, - {file = "lru_dict-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:987b73a06bcf5a95d7dc296241c6b1f9bc6cda42586948c9dabf386dc2bef1cd"}, - {file = "lru_dict-1.2.0-cp39-cp39-win32.whl", hash = "sha256:231d7608f029dda42f9610e5723614a35b1fff035a8060cf7d2be19f1711ace8"}, - {file = "lru_dict-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:71da89e134747e20ed5b8ad5b4ee93fc5b31022c2b71e8176e73c5a44699061b"}, - {file = "lru_dict-1.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:21b3090928c7b6cec509e755cc3ab742154b33660a9b433923bd12c37c448e3e"}, - {file = "lru_dict-1.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaecd7085212d0aa4cd855f38b9d61803d6509731138bf798a9594745953245b"}, - {file = "lru_dict-1.2.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ead83ac59a29d6439ddff46e205ce32f8b7f71a6bd8062347f77e232825e3d0a"}, - {file = "lru_dict-1.2.0-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:312b6b2a30188586fe71358f0f33e4bac882d33f5e5019b26f084363f42f986f"}, - {file = "lru_dict-1.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:b30122e098c80e36d0117810d46459a46313421ce3298709170b687dc1240b02"}, - {file = "lru_dict-1.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f010cfad3ab10676e44dc72a813c968cd586f37b466d27cde73d1f7f1ba158c2"}, - {file = "lru_dict-1.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20f5f411f7751ad9a2c02e80287cedf69ae032edd321fe696e310d32dd30a1f8"}, - {file = "lru_dict-1.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:afdadd73304c9befaed02eb42f5f09fdc16288de0a08b32b8080f0f0f6350aa6"}, - {file = "lru_dict-1.2.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7ab0c10c4fa99dc9e26b04e6b62ac32d2bcaea3aad9b81ec8ce9a7aa32b7b1b"}, - {file = "lru_dict-1.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:edad398d5d402c43d2adada390dd83c74e46e020945ff4df801166047013617e"}, - {file = "lru_dict-1.2.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:91d577a11b84387013815b1ad0bb6e604558d646003b44c92b3ddf886ad0f879"}, - {file = "lru_dict-1.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb12f19cdf9c4f2d9aa259562e19b188ff34afab28dd9509ff32a3f1c2c29326"}, - {file = "lru_dict-1.2.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e4c85aa8844bdca3c8abac3b7f78da1531c74e9f8b3e4890c6e6d86a5a3f6c0"}, - {file = "lru_dict-1.2.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c6acbd097b15bead4de8e83e8a1030bb4d8257723669097eac643a301a952f0"}, - {file = "lru_dict-1.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b6613daa851745dd22b860651de930275be9d3e9373283a2164992abacb75b62"}, -] - -[package.extras] -test = ["pytest"] - [[package]] name = "lxml" version = "6.1.0" @@ -1558,119 +921,6 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] -[[package]] -name = "multidict" -version = "6.4.3" -description = "multidict implementation" -optional = false -python-versions = ">=3.9" -files = [ - {file = "multidict-6.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32a998bd8a64ca48616eac5a8c1cc4fa38fb244a3facf2eeb14abe186e0f6cc5"}, - {file = "multidict-6.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a54ec568f1fc7f3c313c2f3b16e5db346bf3660e1309746e7fccbbfded856188"}, - {file = "multidict-6.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7be07e5df178430621c716a63151165684d3e9958f2bbfcb644246162007ab7"}, - {file = "multidict-6.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b128dbf1c939674a50dd0b28f12c244d90e5015e751a4f339a96c54f7275e291"}, - {file = "multidict-6.4.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b9cb19dfd83d35b6ff24a4022376ea6e45a2beba8ef3f0836b8a4b288b6ad685"}, - {file = "multidict-6.4.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3cf62f8e447ea2c1395afa289b332e49e13d07435369b6f4e41f887db65b40bf"}, - {file = "multidict-6.4.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:909f7d43ff8f13d1adccb6a397094adc369d4da794407f8dd592c51cf0eae4b1"}, - {file = "multidict-6.4.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0bb8f8302fbc7122033df959e25777b0b7659b1fd6bcb9cb6bed76b5de67afef"}, - {file = "multidict-6.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:224b79471b4f21169ea25ebc37ed6f058040c578e50ade532e2066562597b8a9"}, - {file = "multidict-6.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a7bd27f7ab3204f16967a6f899b3e8e9eb3362c0ab91f2ee659e0345445e0078"}, - {file = "multidict-6.4.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:99592bd3162e9c664671fd14e578a33bfdba487ea64bcb41d281286d3c870ad7"}, - {file = "multidict-6.4.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a62d78a1c9072949018cdb05d3c533924ef8ac9bcb06cbf96f6d14772c5cd451"}, - {file = "multidict-6.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3ccdde001578347e877ca4f629450973c510e88e8865d5aefbcb89b852ccc666"}, - {file = "multidict-6.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:eccb67b0e78aa2e38a04c5ecc13bab325a43e5159a181a9d1a6723db913cbb3c"}, - {file = "multidict-6.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b6fcf6054fc4114a27aa865f8840ef3d675f9316e81868e0ad5866184a6cba5"}, - {file = "multidict-6.4.3-cp310-cp310-win32.whl", hash = "sha256:f92c7f62d59373cd93bc9969d2da9b4b21f78283b1379ba012f7ee8127b3152e"}, - {file = "multidict-6.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:b57e28dbc031d13916b946719f213c494a517b442d7b48b29443e79610acd887"}, - {file = "multidict-6.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f6f19170197cc29baccd33ccc5b5d6a331058796485857cf34f7635aa25fb0cd"}, - {file = "multidict-6.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f2882bf27037eb687e49591690e5d491e677272964f9ec7bc2abbe09108bdfb8"}, - {file = "multidict-6.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fbf226ac85f7d6b6b9ba77db4ec0704fde88463dc17717aec78ec3c8546c70ad"}, - {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e329114f82ad4b9dd291bef614ea8971ec119ecd0f54795109976de75c9a852"}, - {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1f4e0334d7a555c63f5c8952c57ab6f1c7b4f8c7f3442df689fc9f03df315c08"}, - {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:740915eb776617b57142ce0bb13b7596933496e2f798d3d15a20614adf30d229"}, - {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255dac25134d2b141c944b59a0d2f7211ca12a6d4779f7586a98b4b03ea80508"}, - {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4e8535bd4d741039b5aad4285ecd9b902ef9e224711f0b6afda6e38d7ac02c7"}, - {file = "multidict-6.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c433a33be000dd968f5750722eaa0991037be0be4a9d453eba121774985bc8"}, - {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4eb33b0bdc50acd538f45041f5f19945a1f32b909b76d7b117c0c25d8063df56"}, - {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:75482f43465edefd8a5d72724887ccdcd0c83778ded8f0cb1e0594bf71736cc0"}, - {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce5b3082e86aee80b3925ab4928198450d8e5b6466e11501fe03ad2191c6d777"}, - {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e413152e3212c4d39f82cf83c6f91be44bec9ddea950ce17af87fbf4e32ca6b2"}, - {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aac2eeff69b71f229a405c0a4b61b54bade8e10163bc7b44fcd257949620618"}, - {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ab583ac203af1d09034be41458feeab7863c0635c650a16f15771e1386abf2d7"}, - {file = "multidict-6.4.3-cp311-cp311-win32.whl", hash = "sha256:1b2019317726f41e81154df636a897de1bfe9228c3724a433894e44cd2512378"}, - {file = "multidict-6.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:43173924fa93c7486402217fab99b60baf78d33806af299c56133a3755f69589"}, - {file = "multidict-6.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f1c2f58f08b36f8475f3ec6f5aeb95270921d418bf18f90dffd6be5c7b0e676"}, - {file = "multidict-6.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:26ae9ad364fc61b936fb7bf4c9d8bd53f3a5b4417142cd0be5c509d6f767e2f1"}, - {file = "multidict-6.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:659318c6c8a85f6ecfc06b4e57529e5a78dfdd697260cc81f683492ad7e9435a"}, - {file = "multidict-6.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1eb72c741fd24d5a28242ce72bb61bc91f8451877131fa3fe930edb195f7054"}, - {file = "multidict-6.4.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cd06d88cb7398252284ee75c8db8e680aa0d321451132d0dba12bc995f0adcc"}, - {file = "multidict-6.4.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4543d8dc6470a82fde92b035a92529317191ce993533c3c0c68f56811164ed07"}, - {file = "multidict-6.4.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30a3ebdc068c27e9d6081fca0e2c33fdf132ecea703a72ea216b81a66860adde"}, - {file = "multidict-6.4.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b038f10e23f277153f86f95c777ba1958bcd5993194fda26a1d06fae98b2f00c"}, - {file = "multidict-6.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c605a2b2dc14282b580454b9b5d14ebe0668381a3a26d0ac39daa0ca115eb2ae"}, - {file = "multidict-6.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bd2b875f4ca2bb527fe23e318ddd509b7df163407b0fb717df229041c6df5d3"}, - {file = "multidict-6.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c2e98c840c9c8e65c0e04b40c6c5066c8632678cd50c8721fdbcd2e09f21a507"}, - {file = "multidict-6.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66eb80dd0ab36dbd559635e62fba3083a48a252633164857a1d1684f14326427"}, - {file = "multidict-6.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c23831bdee0a2a3cf21be057b5e5326292f60472fb6c6f86392bbf0de70ba731"}, - {file = "multidict-6.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1535cec6443bfd80d028052e9d17ba6ff8a5a3534c51d285ba56c18af97e9713"}, - {file = "multidict-6.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b73e7227681f85d19dec46e5b881827cd354aabe46049e1a61d2f9aaa4e285a"}, - {file = "multidict-6.4.3-cp312-cp312-win32.whl", hash = "sha256:8eac0c49df91b88bf91f818e0a24c1c46f3622978e2c27035bfdca98e0e18124"}, - {file = "multidict-6.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:11990b5c757d956cd1db7cb140be50a63216af32cd6506329c2c59d732d802db"}, - {file = "multidict-6.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a76534263d03ae0cfa721fea40fd2b5b9d17a6f85e98025931d41dc49504474"}, - {file = "multidict-6.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:805031c2f599eee62ac579843555ed1ce389ae00c7e9f74c2a1b45e0564a88dd"}, - {file = "multidict-6.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c56c179839d5dcf51d565132185409d1d5dd8e614ba501eb79023a6cab25576b"}, - {file = "multidict-6.4.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c64f4ddb3886dd8ab71b68a7431ad4aa01a8fa5be5b11543b29674f29ca0ba3"}, - {file = "multidict-6.4.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3002a856367c0b41cad6784f5b8d3ab008eda194ed7864aaa58f65312e2abcac"}, - {file = "multidict-6.4.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d75e621e7d887d539d6e1d789f0c64271c250276c333480a9e1de089611f790"}, - {file = "multidict-6.4.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:995015cf4a3c0d72cbf453b10a999b92c5629eaf3a0c3e1efb4b5c1f602253bb"}, - {file = "multidict-6.4.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b0fabae7939d09d7d16a711468c385272fa1b9b7fb0d37e51143585d8e72e0"}, - {file = "multidict-6.4.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61ed4d82f8a1e67eb9eb04f8587970d78fe7cddb4e4d6230b77eda23d27938f9"}, - {file = "multidict-6.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:062428944a8dc69df9fdc5d5fc6279421e5f9c75a9ee3f586f274ba7b05ab3c8"}, - {file = "multidict-6.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b90e27b4674e6c405ad6c64e515a505c6d113b832df52fdacb6b1ffd1fa9a1d1"}, - {file = "multidict-6.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7d50d4abf6729921e9613d98344b74241572b751c6b37feed75fb0c37bd5a817"}, - {file = "multidict-6.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:43fe10524fb0a0514be3954be53258e61d87341008ce4914f8e8b92bee6f875d"}, - {file = "multidict-6.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:236966ca6c472ea4e2d3f02f6673ebfd36ba3f23159c323f5a496869bc8e47c9"}, - {file = "multidict-6.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:422a5ec315018e606473ba1f5431e064cf8b2a7468019233dcf8082fabad64c8"}, - {file = "multidict-6.4.3-cp313-cp313-win32.whl", hash = "sha256:f901a5aace8e8c25d78960dcc24c870c8d356660d3b49b93a78bf38eb682aac3"}, - {file = "multidict-6.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:1c152c49e42277bc9a2f7b78bd5fa10b13e88d1b0328221e7aef89d5c60a99a5"}, - {file = "multidict-6.4.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:be8751869e28b9c0d368d94f5afcb4234db66fe8496144547b4b6d6a0645cfc6"}, - {file = "multidict-6.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d4b31f8a68dccbcd2c0ea04f0e014f1defc6b78f0eb8b35f2265e8716a6df0c"}, - {file = "multidict-6.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:032efeab3049e37eef2ff91271884303becc9e54d740b492a93b7e7266e23756"}, - {file = "multidict-6.4.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e78006af1a7c8a8007e4f56629d7252668344442f66982368ac06522445e375"}, - {file = "multidict-6.4.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:daeac9dd30cda8703c417e4fddccd7c4dc0c73421a0b54a7da2713be125846be"}, - {file = "multidict-6.4.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f6f90700881438953eae443a9c6f8a509808bc3b185246992c4233ccee37fea"}, - {file = "multidict-6.4.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f84627997008390dd15762128dcf73c3365f4ec0106739cde6c20a07ed198ec8"}, - {file = "multidict-6.4.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3307b48cd156153b117c0ea54890a3bdbf858a5b296ddd40dc3852e5f16e9b02"}, - {file = "multidict-6.4.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ead46b0fa1dcf5af503a46e9f1c2e80b5d95c6011526352fa5f42ea201526124"}, - {file = "multidict-6.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1748cb2743bedc339d63eb1bca314061568793acd603a6e37b09a326334c9f44"}, - {file = "multidict-6.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:acc9fa606f76fc111b4569348cc23a771cb52c61516dcc6bcef46d612edb483b"}, - {file = "multidict-6.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:31469d5832b5885adeb70982e531ce86f8c992334edd2f2254a10fa3182ac504"}, - {file = "multidict-6.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ba46b51b6e51b4ef7bfb84b82f5db0dc5e300fb222a8a13b8cd4111898a869cf"}, - {file = "multidict-6.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:389cfefb599edf3fcfd5f64c0410da686f90f5f5e2c4d84e14f6797a5a337af4"}, - {file = "multidict-6.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:64bc2bbc5fba7b9db5c2c8d750824f41c6994e3882e6d73c903c2afa78d091e4"}, - {file = "multidict-6.4.3-cp313-cp313t-win32.whl", hash = "sha256:0ecdc12ea44bab2807d6b4a7e5eef25109ab1c82a8240d86d3c1fc9f3b72efd5"}, - {file = "multidict-6.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7146a8742ea71b5d7d955bffcef58a9e6e04efba704b52a460134fefd10a8208"}, - {file = "multidict-6.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5427a2679e95a642b7f8b0f761e660c845c8e6fe3141cddd6b62005bd133fc21"}, - {file = "multidict-6.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:24a8caa26521b9ad09732972927d7b45b66453e6ebd91a3c6a46d811eeb7349b"}, - {file = "multidict-6.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6b5a272bc7c36a2cd1b56ddc6bff02e9ce499f9f14ee4a45c45434ef083f2459"}, - {file = "multidict-6.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edf74dc5e212b8c75165b435c43eb0d5e81b6b300a938a4eb82827119115e840"}, - {file = "multidict-6.4.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9f35de41aec4b323c71f54b0ca461ebf694fb48bec62f65221f52e0017955b39"}, - {file = "multidict-6.4.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae93e0ff43b6f6892999af64097b18561691ffd835e21a8348a441e256592e1f"}, - {file = "multidict-6.4.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e3929269e9d7eff905d6971d8b8c85e7dbc72c18fb99c8eae6fe0a152f2e343"}, - {file = "multidict-6.4.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6214fe1750adc2a1b801a199d64b5a67671bf76ebf24c730b157846d0e90d2"}, - {file = "multidict-6.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d79cf5c0c6284e90f72123f4a3e4add52d6c6ebb4a9054e88df15b8d08444c6"}, - {file = "multidict-6.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2427370f4a255262928cd14533a70d9738dfacadb7563bc3b7f704cc2360fc4e"}, - {file = "multidict-6.4.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:fbd8d737867912b6c5f99f56782b8cb81f978a97b4437a1c476de90a3e41c9a1"}, - {file = "multidict-6.4.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0ee1bf613c448997f73fc4efb4ecebebb1c02268028dd4f11f011f02300cf1e8"}, - {file = "multidict-6.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:578568c4ba5f2b8abd956baf8b23790dbfdc953e87d5b110bce343b4a54fc9e7"}, - {file = "multidict-6.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a059ad6b80de5b84b9fa02a39400319e62edd39d210b4e4f8c4f1243bdac4752"}, - {file = "multidict-6.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:dd53893675b729a965088aaadd6a1f326a72b83742b056c1065bdd2e2a42b4df"}, - {file = "multidict-6.4.3-cp39-cp39-win32.whl", hash = "sha256:abcfed2c4c139f25c2355e180bcc077a7cae91eefbb8b3927bb3f836c9586f1f"}, - {file = "multidict-6.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:b1b389ae17296dd739015d5ddb222ee99fd66adeae910de21ac950e00979d897"}, - {file = "multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9"}, - {file = "multidict-6.4.3.tar.gz", hash = "sha256:3ada0b058c9f213c5f95ba301f922d402ac234f1111a7d8fd70f1b99f3c281ec"}, -] - [[package]] name = "mypy-extensions" version = "1.0.0" @@ -1746,65 +996,6 @@ files = [ {file = "numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f"}, ] -[[package]] -name = "orjson" -version = "3.9.9" -description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" -optional = false -python-versions = ">=3.8" -files = [ - {file = "orjson-3.9.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f28090060a31f4d11221f9ba48b2273b0d04b702f4dcaa197c38c64ce639cc51"}, - {file = "orjson-3.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8038ba245d0c0a6337cfb6747ea0c51fe18b0cf1a4bc943d530fd66799fae33d"}, - {file = "orjson-3.9.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:543b36df56db195739c70d645ecd43e49b44d5ead5f8f645d2782af118249b37"}, - {file = "orjson-3.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8e7877256b5092f1e4e48fc0f1004728dc6901e7a4ffaa4acb0a9578610aa4ce"}, - {file = "orjson-3.9.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12b83e0d8ba4ca88b894c3e00efc59fe6d53d9ffb5dbbb79d437a466fc1a513d"}, - {file = "orjson-3.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef06431f021453a47a9abb7f7853f04f031d31fbdfe1cc83e3c6aadde502cce"}, - {file = "orjson-3.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0a1a4d9e64597e550428ba091e51a4bcddc7a335c8f9297effbfa67078972b5c"}, - {file = "orjson-3.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:879d2d1f6085c9c0831cec6716c63aaa89e41d8e036cabb19a315498c173fcc6"}, - {file = "orjson-3.9.9-cp310-none-win32.whl", hash = "sha256:d3f56e41bc79d30fdf077073072f2377d2ebf0b946b01f2009ab58b08907bc28"}, - {file = "orjson-3.9.9-cp310-none-win_amd64.whl", hash = "sha256:ab7bae2b8bf17620ed381e4101aeeb64b3ba2a45fc74c7617c633a923cb0f169"}, - {file = "orjson-3.9.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:31d676bc236f6e919d100fb85d0a99812cff1ebffaa58106eaaec9399693e227"}, - {file = "orjson-3.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:678ffb5c0a6b1518b149cc328c610615d70d9297e351e12c01d0beed5d65360f"}, - {file = "orjson-3.9.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a71b0cc21f2c324747bc77c35161e0438e3b5e72db6d3b515310457aba743f7f"}, - {file = "orjson-3.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae72621f216d1d990468291b1ec153e1b46e0ed188a86d54e0941f3dabd09ee8"}, - {file = "orjson-3.9.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:512e5a41af008e76451f5a344941d61f48dddcf7d7ddd3073deb555de64596a6"}, - {file = "orjson-3.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f89dc338a12f4357f5bf1b098d3dea6072fb0b643fd35fec556f4941b31ae27"}, - {file = "orjson-3.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:957a45fb201c61b78bcf655a16afbe8a36c2c27f18a998bd6b5d8a35e358d4ad"}, - {file = "orjson-3.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1c01cf4b8e00c7e98a0a7cf606a30a26c32adf2560be2d7d5d6766d6f474b31"}, - {file = "orjson-3.9.9-cp311-none-win32.whl", hash = "sha256:397a185e5dd7f8ebe88a063fe13e34d61d394ebb8c70a443cee7661b9c89bda7"}, - {file = "orjson-3.9.9-cp311-none-win_amd64.whl", hash = "sha256:24301f2d99d670ded4fb5e2f87643bc7428a54ba49176e38deb2887e42fe82fb"}, - {file = "orjson-3.9.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd55ea5cce3addc03f8fb0705be0cfed63b048acc4f20914ce5e1375b15a293b"}, - {file = "orjson-3.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b28c1a65cd13fff5958ab8b350f0921121691464a7a1752936b06ed25c0c7b6e"}, - {file = "orjson-3.9.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b97a67c47840467ccf116136450c50b6ed4e16a8919c81a4b4faef71e0a2b3f4"}, - {file = "orjson-3.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75b805549cbbcb963e9c9068f1a05abd0ea4c34edc81f8d8ef2edb7e139e5b0f"}, - {file = "orjson-3.9.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5424ecbafe57b2de30d3b5736c5d5835064d522185516a372eea069b92786ba6"}, - {file = "orjson-3.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d2cd6ef4726ef1b8c63e30d8287225a383dbd1de3424d287b37c1906d8d2855"}, - {file = "orjson-3.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c959550e0705dc9f59de8fca1a316da0d9b115991806b217c82931ac81d75f74"}, - {file = "orjson-3.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ece2d8ed4c34903e7f1b64fb1e448a00e919a4cdb104fc713ad34b055b665fca"}, - {file = "orjson-3.9.9-cp312-none-win_amd64.whl", hash = "sha256:f708ca623287186e5876256cb30599308bce9b2757f90d917b7186de54ce6547"}, - {file = "orjson-3.9.9-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:335406231f9247f985df045f0c0c8f6b6d5d6b3ff17b41a57c1e8ef1a31b4d04"}, - {file = "orjson-3.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d9b5440a5d215d9e1cfd4aee35fd4101a8b8ceb8329f549c16e3894ed9f18b5"}, - {file = "orjson-3.9.9-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e98ca450cb4fb176dd572ce28c6623de6923752c70556be4ef79764505320acb"}, - {file = "orjson-3.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3bf6ca6bce22eb89dd0650ef49c77341440def966abcb7a2d01de8453df083a"}, - {file = "orjson-3.9.9-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb50d869b3c97c7c5187eda3759e8eb15deb1271d694bc5d6ba7040db9e29036"}, - {file = "orjson-3.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fcf06c69ccc78e32d9f28aa382ab2ab08bf54b696dbe00ee566808fdf05da7d"}, - {file = "orjson-3.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9a4402e7df1b5c9a4c71c7892e1c8f43f642371d13c73242bda5964be6231f95"}, - {file = "orjson-3.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b20becf50d4aec7114dc902b58d85c6431b3a59b04caa977e6ce67b6fee0e159"}, - {file = "orjson-3.9.9-cp38-none-win32.whl", hash = "sha256:1f352117eccac268a59fedac884b0518347f5e2b55b9f650c2463dd1e732eb61"}, - {file = "orjson-3.9.9-cp38-none-win_amd64.whl", hash = "sha256:c4eb31a8e8a5e1d9af5aa9e247c2a52ad5cf7e968aaa9aaefdff98cfcc7f2e37"}, - {file = "orjson-3.9.9-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4a308aeac326c2bafbca9abbae1e1fcf682b06e78a54dad0347b760525838d85"}, - {file = "orjson-3.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e159b97f5676dcdac0d0f75ec856ef5851707f61d262851eb41a30e8fadad7c9"}, - {file = "orjson-3.9.9-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f692e7aabad92fa0fff5b13a846fb586b02109475652207ec96733a085019d80"}, - {file = "orjson-3.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cffb77cf0cd3cbf20eb603f932e0dde51b45134bdd2d439c9f57924581bb395b"}, - {file = "orjson-3.9.9-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c63eca397127ebf46b59c9c1fb77b30dd7a8fc808ac385e7a58a7e64bae6e106"}, - {file = "orjson-3.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f0c024a75e8ba5d9101facb4fb5a028cdabe3cdfe081534f2a9de0d5062af2"}, - {file = "orjson-3.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8cba20c9815c2a003b8ca4429b0ad4aa87cb6649af41365821249f0fd397148e"}, - {file = "orjson-3.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:906cac73b7818c20cf0f6a7dde5a6f009c52aecc318416c7af5ea37f15ca7e66"}, - {file = "orjson-3.9.9-cp39-none-win32.whl", hash = "sha256:50232572dd300c49f134838c8e7e0917f29a91f97dbd608d23f2895248464b7f"}, - {file = "orjson-3.9.9-cp39-none-win_amd64.whl", hash = "sha256:920814e02e3dd7af12f0262bbc18b9fe353f75a0d0c237f6a67d270da1a1bb44"}, - {file = "orjson-3.9.9.tar.gz", hash = "sha256:02e693843c2959befdd82d1ebae8b05ed12d1cb821605d5f9fe9f98ca5c9fd2b"}, -] - [[package]] name = "outcome" version = "1.3.0.post0" @@ -2102,17 +1293,6 @@ test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] -[[package]] -name = "pip" -version = "26.1.2" -description = "The PyPA recommended tool for installing Python packages." -optional = false -python-versions = ">=3.10" -files = [ - {file = "pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab"}, - {file = "pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605"}, -] - [[package]] name = "platformdirs" version = "4.3.7" @@ -2200,6 +1380,20 @@ files = [ {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, ] +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyhamcrest" version = "2.1.0" @@ -2217,23 +1411,6 @@ docs = ["alabaster (>=0.7,<1.0)", "sphinx (>=4.0,<5.0)"] tests = ["coverage[toml]", "dataclasses", "mypy (!=0.940)", "pytest (>=5.0)", "pytest-mypy-plugins", "pytest-sugar", "pytest-xdist", "pyyaml", "types-dataclasses", "types-mock"] tests-numpy = ["numpy", "pyhamcrest[tests]"] -[[package]] -name = "pyjwt" -version = "2.8.0" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, - {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, -] - -[package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] - [[package]] name = "pylint" version = "3.3.6" @@ -2309,24 +1486,6 @@ dev = ["cython"] geopandas = ["geopandas"] test = ["pytest", "pytest-cov"] -[[package]] -name = "pyopenssl" -version = "23.2.0" -description = "Python wrapper module around the OpenSSL library" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pyOpenSSL-23.2.0-py3-none-any.whl", hash = "sha256:24f0dc5227396b3e831f4c7f602b950a5e9833d292c8e4a2e06b709292806ae2"}, - {file = "pyOpenSSL-23.2.0.tar.gz", hash = "sha256:276f931f55a452e7dea69c7173e984eb2a4407ce413c918aa34b55f82f9b8bac"}, -] - -[package.dependencies] -cryptography = ">=38.0.0,<40.0.0 || >40.0.0,<40.0.1 || >40.0.1,<42" - -[package.extras] -docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] -test = ["flaky", "pretend", "pytest (>=3.0.1)"] - [[package]] name = "pypdfium2" version = "5.9.0" @@ -2417,40 +1576,42 @@ files = [ [[package]] name = "pytest" -version = "8.3.5" +version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, - {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -iniconfig = "*" -packaging = "*" +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" +pygments = ">=2.7.2" [package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "1.3.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, - {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, ] [package.dependencies] -pytest = ">=8.2,<9" +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] @@ -2537,22 +1698,6 @@ files = [ [package.extras] cli = ["click (>=5.0)"] -[[package]] -name = "python-slugify" -version = "4.0.1" -description = "A Python Slugify application that handles Unicode" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "python-slugify-4.0.1.tar.gz", hash = "sha256:69a517766e00c1268e5bbfc0d010a0a8508de0b18d30ad5a1ff357f8ae724270"}, -] - -[package.dependencies] -text-unidecode = ">=1.3" - -[package.extras] -unidecode = ["Unidecode (>=1.1.1)"] - [[package]] name = "pytokens" version = "0.4.1" @@ -2618,66 +1763,6 @@ files = [ {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, ] -[[package]] -name = "pyyaml" -version = "6.0.1" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, -] - [[package]] name = "referencing" version = "0.36.2" @@ -2840,22 +1925,22 @@ files = [ [[package]] name = "selenium" -version = "4.31.0" +version = "4.45.0" description = "Official Python bindings for Selenium WebDriver" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "selenium-4.31.0-py3-none-any.whl", hash = "sha256:7b8b8d5e424d7133cb7aa656263b19ac505ec26d65c0f921a696e7e2c5ccd95b"}, - {file = "selenium-4.31.0.tar.gz", hash = "sha256:441cffc436a2e6659fe3cfb012692435652efd38b0d368d16f661a5db47825f5"}, + {file = "selenium-4.45.0-py3-none-any.whl", hash = "sha256:1fd9d0dc08192b2f8100e264ed720f83b05d2dd3a7feff673df04e0c7580df4b"}, + {file = "selenium-4.45.0.tar.gz", hash = "sha256:563f0c4102f112df1cda30d46ce6d177b2e4a7a3d4b0756902d5dc84d3a8a365"}, ] [package.dependencies] -certifi = ">=2021.10.8" -trio = ">=0.17,<1.0" -trio-websocket = ">=0.9,<1.0" -typing_extensions = ">=4.9,<5.0" -urllib3 = {version = ">=1.26,<3", extras = ["socks"]} -websocket-client = ">=1.8,<2.0" +certifi = ">=2026.2.25" +trio = ">=0.31.0,<1.0" +trio-websocket = ">=0.12.2,<1.0" +typing_extensions = ">=4.15.0,<5.0" +urllib3 = {version = ">=2.6.3,<3.0", extras = ["socks"]} +websocket-client = ">=1.8.0,<2.0" [[package]] name = "setuptools" @@ -2992,17 +2077,6 @@ files = [ [package.extras] widechars = ["wcwidth"] -[[package]] -name = "text-unidecode" -version = "1.3" -description = "The most basic Text::Unidecode port" -optional = false -python-versions = "*" -files = [ - {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, - {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, -] - [[package]] name = "tomlkit" version = "0.13.2" @@ -3016,17 +2090,17 @@ files = [ [[package]] name = "trio" -version = "0.24.0" +version = "0.33.0" description = "A friendly Python library for async concurrency and I/O" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "trio-0.24.0-py3-none-any.whl", hash = "sha256:c3bd3a4e3e3025cd9a2241eae75637c43fe0b9e88b4c97b9161a55b9e54cd72c"}, - {file = "trio-0.24.0.tar.gz", hash = "sha256:ffa09a74a6bf81b84f8613909fb0beaee84757450183a7a2e0b47b455c0cac5d"}, + {file = "trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b"}, + {file = "trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"}, ] [package.dependencies] -attrs = ">=20.1.0" +attrs = ">=23.2.0" cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} idna = "*" outcome = "*" @@ -3051,13 +2125,13 @@ wsproto = ">=0.14" [[package]] name = "typing-extensions" -version = "4.13.2" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, - {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] [[package]] @@ -3071,42 +2145,6 @@ files = [ {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] -[[package]] -name = "ulid-transform" -version = "0.9.0" -description = "Create and transform ULIDs" -optional = false -python-versions = ">=3.10,<4.0" -files = [ - {file = "ulid_transform-0.9.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:4689679e3c42f23147ba9ff404e7fdb935ca1063d57971f8abb87e2c2deabfc5"}, - {file = "ulid_transform-0.9.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6123274882adc412f81f70f668dfee472588016679729a3e77627156d5a363f"}, - {file = "ulid_transform-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d954ebf7ef1c657d3d7e951bbbbf7f813b5514f66e49f9ae9065c23e718dfd48"}, - {file = "ulid_transform-0.9.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:927e5afa53339399e22154692eaba39439afbe984b3a18c3deac36af57caddbe"}, - {file = "ulid_transform-0.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ffbf01a1c6d7af77c76a53e6d7a2e08f0d648e74dbb2da7a1f8946a4c86876a3"}, - {file = "ulid_transform-0.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bcd90245996bf915b46b5d01fd3b0374f865f571d129eed41f0ea9c2ad4c4ba6"}, - {file = "ulid_transform-0.9.0-cp310-cp310-win32.whl", hash = "sha256:58761ba94eb339c1cfce11b8b287e6e7af8999d8e8bca26e40f76f3f8c8a65fc"}, - {file = "ulid_transform-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:0558c4dff9f5e319c9db16278bc0620f9589c896316bb945851e2dbdf8d83237"}, - {file = "ulid_transform-0.9.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:a5b9a22df12ef14838d17d0b336e4c583a74616dbd09ae7113836970e8839d57"}, - {file = "ulid_transform-0.9.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:775e444b4ab00a879278ab0d128cdacf782b860132e1ff46b481e6c0e2dbc67f"}, - {file = "ulid_transform-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b88908f7ea7f14664f214f3ef47cf89fbeb82f89523bd0a77a6b5614f79701d0"}, - {file = "ulid_transform-0.9.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d11c0ba06e56f0112546f53d7db340e922475503d1c98eb1f19b911162c71c16"}, - {file = "ulid_transform-0.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:31c7508a0702ece322002bfaa3a3a762d34f2e3575f1e944f0acb1640563bbb7"}, - {file = "ulid_transform-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a7c2eec7fa3934e2735b437de698dc758a91f7c2b8d320b7b3a402016e058027"}, - {file = "ulid_transform-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:35d33a22bb2fd6bc96f563cb038ed59faadd69a839829117f9ce700d5671dbc7"}, - {file = "ulid_transform-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:cf6a9c9b582df4e6b39095fb1faa7632d1632350b197bb4a5a9e0dd4f77b6946"}, - {file = "ulid_transform-0.9.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30eed6d3f1162130992f64050990852751ead37ea415124a89eec5401306c9d3"}, - {file = "ulid_transform-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc01628e64e8bb862487ee691101af01f9c06b108dc90e1f4812137265dae5f"}, - {file = "ulid_transform-0.9.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6b9c26f274a3f1d198abed31c6710695af06bba3aa836108cac640b545c81f3d"}, - {file = "ulid_transform-0.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9f41f78e3cdd86aa03211a191418133b856f1e9852c01bc8b1a93049b8637882"}, - {file = "ulid_transform-0.9.0-cp312-cp312-win32.whl", hash = "sha256:1294b33c347b23cbb9c80bb3fc0148a6cd2891c0fca3b0eedeed1e183f263080"}, - {file = "ulid_transform-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:6b5e7e2cb081b572b41d3f40b8b4401405b7f4ee1d4774ee9b1a0c6700f4bd1c"}, - {file = "ulid_transform-0.9.0-pp310-pypy310_pp73-macosx_11_0_x86_64.whl", hash = "sha256:492ec0f09af05f816b5d1dfa511d110f972cb37f64458b443f80cf5f67ed0256"}, - {file = "ulid_transform-0.9.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd7bf5157a8c3743984aa438e93e5ba221667bc357eabbe6ef4430eb103384c"}, - {file = "ulid_transform-0.9.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b08811281a66023693b1bbd8208d944f7200f6e5d7e429f7bd766c4734f395c4"}, - {file = "ulid_transform-0.9.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:deaa49436b87684d9a33f558fffe5fe9464de3ca2c41f331058a3c77a04223ec"}, - {file = "ulid_transform-0.9.0.tar.gz", hash = "sha256:e50c78ddb89629d1d7b5e7436b51bb49ac3ddca7a278a78640299d4cd49105bc"}, -] - [[package]] name = "undetected-chromedriver" version = "3.5.5" @@ -3142,31 +2180,6 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0)"] -[[package]] -name = "voluptuous" -version = "0.13.1" -description = "" -optional = false -python-versions = "*" -files = [ - {file = "voluptuous-0.13.1-py3-none-any.whl", hash = "sha256:4b838b185f5951f2d6e8752b68fcf18bd7a9c26ded8f143f92d6d28f3921a3e6"}, - {file = "voluptuous-0.13.1.tar.gz", hash = "sha256:e8d31c20601d6773cb14d4c0f42aee29c6821bbd1018039aac7ac5605b489723"}, -] - -[[package]] -name = "voluptuous-serialize" -version = "2.6.0" -description = "Convert voluptuous schemas to dictionaries" -optional = false -python-versions = "*" -files = [ - {file = "voluptuous-serialize-2.6.0.tar.gz", hash = "sha256:79acdc58239582a393144402d827fa8efd6df0f5350cdc606d9242f6f9bca7c4"}, - {file = "voluptuous_serialize-2.6.0-py3-none-any.whl", hash = "sha256:85a5c8d4d829cb49186c1b5396a8a517413cc5938e1bb0e374350190cd139616"}, -] - -[package.dependencies] -voluptuous = "*" - [[package]] name = "webdriver-manager" version = "4.0.2" @@ -3331,160 +2344,7 @@ files = [ [package.dependencies] h11 = ">=0.9.0,<1" -[[package]] -name = "yarl" -version = "1.9.2" -description = "Yet another URL library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82"}, - {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8"}, - {file = "yarl-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528"}, - {file = "yarl-1.9.2-cp310-cp310-win32.whl", hash = "sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3"}, - {file = "yarl-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde"}, - {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6"}, - {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb"}, - {file = "yarl-1.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a"}, - {file = "yarl-1.9.2-cp311-cp311-win32.whl", hash = "sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8"}, - {file = "yarl-1.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051"}, - {file = "yarl-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582"}, - {file = "yarl-1.9.2-cp37-cp37m-win32.whl", hash = "sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b"}, - {file = "yarl-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368"}, - {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac"}, - {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4"}, - {file = "yarl-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b"}, - {file = "yarl-1.9.2-cp38-cp38-win32.whl", hash = "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7"}, - {file = "yarl-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72"}, - {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9"}, - {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8"}, - {file = "yarl-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80"}, - {file = "yarl-1.9.2-cp39-cp39-win32.whl", hash = "sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623"}, - {file = "yarl-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18"}, - {file = "yarl-1.9.2.tar.gz", hash = "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571"}, -] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" - -[[package]] -name = "zlib-ng" -version = "0.5.1" -description = "Drop-in replacement for zlib and gzip modules using zlib-ng" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zlib_ng-0.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ea0b07ff83e253d83e25113fc81f695b9161882de3a65d547ab96f394cf03f5c"}, - {file = "zlib_ng-0.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1be5a5513876cd0a071bbb0fc333eb00bc9c25399f2b863e329dfe6ac4cf6455"}, - {file = "zlib_ng-0.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcc37f32477747bbf68073ca54f277ef09d320fb50d7634e66db72f7221c9881"}, - {file = "zlib_ng-0.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:030d6cead51bb5a38826fca1bd4bd2cec927bb949c3eefb004aa4fc55af5cfe7"}, - {file = "zlib_ng-0.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6cd0fd5f1a84249cc78c2a7746289c66e1dbbc40c1eded91c1e09a5dc6d8d02b"}, - {file = "zlib_ng-0.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ecab68fefad5ac233e4a0bfa0e401ed9897f5e950bad4dee31dfb53be10fa24"}, - {file = "zlib_ng-0.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:12307a1f69aa983287957b37e0fbf629a0d803e8fca791b27d2ef143e306fda0"}, - {file = "zlib_ng-0.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4178acbe1f6ed313626d7b6463e13f2c32be67fed055ce404d5d4b2ab9b4fc4f"}, - {file = "zlib_ng-0.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b8d32a1c296f72e455784ed594c67c9a55e90bd036b4e2ef6621263ec37a481"}, - {file = "zlib_ng-0.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8082762fd90ee71ccb8afc80f077aa34a5c7d3822a7fa1db9fccc0a0bc0815ba"}, - {file = "zlib_ng-0.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0aa641675f5cd3737c1d9d4ba3e0395308516afb41a097da61a786e4d7a6faa1"}, - {file = "zlib_ng-0.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8a0ccc5cd3c47d85ec1d1f245a608e51ac0bab80f9b24544ef1117126db1c226"}, - {file = "zlib_ng-0.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:19eeddb988f6d76e8031ab8aab1dcc03f13abd308ccc16d79b852d3b8057b5cd"}, - {file = "zlib_ng-0.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:086d8ecbbe596fc2bacd52979548950ee48f61d294a1c8a1ea091afc14927e09"}, - {file = "zlib_ng-0.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c5d5cab84a51f6373a4be4b7d0c8e7b25242820e5a2857da338a84c6616e9b8"}, - {file = "zlib_ng-0.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c347663989f3d3d7bb3a635da893c8a90b20b1f3edaaedb037638de3a50c8ab3"}, - {file = "zlib_ng-0.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a951c202086a004bbc9bbfa000f19a8436a3b064257981b2140c7baa0d5a6c0"}, - {file = "zlib_ng-0.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a10b2da0890afce007d71277fb5429f563b0e1bbbda7bd91d4e156658d79a0b7"}, - {file = "zlib_ng-0.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dccd1134ecffa7b7f33ba54432ea0ef431348fd1493387bb2d06f0fc0be686e4"}, - {file = "zlib_ng-0.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:54e6797933adf61f59d77485c781c30ab16abc7a293642f8563086a9613ee8f1"}, - {file = "zlib_ng-0.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:51474eedebb9a3f173bcaf7c2c05284045be1cf7daf55d8506f2cfadb66366a9"}, - {file = "zlib_ng-0.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:26aa95c53e16dcb24d26f5434627e0edc779aa7857be38058c7d9fbbbf9ca9f6"}, - {file = "zlib_ng-0.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9827b85093066afb1b3f8c3a662e2f6953bd1c07e7ae70a558ea6b8adcc898b9"}, - {file = "zlib_ng-0.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e19469536b5e87bf9e4f11ae1e83024b2a9fa03f251f40e63fb6e4fd4e9f5265"}, - {file = "zlib_ng-0.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace2898396a3bf4773693bc22e4f1659274551cb162335f2cae6df425b397292"}, - {file = "zlib_ng-0.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49119be5d677fe78b6841944e78ab8afbc9b65ac7e2d1d32666f0ce1e4fa39d9"}, - {file = "zlib_ng-0.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f8bc77bbe43745e558d7a868d216826f7d8c64146111067fb7bc039df10f744"}, - {file = "zlib_ng-0.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:677e5894ddc50e5a5ad867992744bd4dd54372afb44c4718c6417924241ddcc5"}, - {file = "zlib_ng-0.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:49f01c225cfee0654273a77b4a1a2f82af8c16b2b5181f82166b10615d003129"}, - {file = "zlib_ng-0.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea5993d1999c4a70b1d4121e8f438cb28338af2afaa52c57d1393b343d15051c"}, - {file = "zlib_ng-0.5.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dbee987bca1f5d6cd612c388cecee5334572b47f6730e90d371863472ab4cc9"}, - {file = "zlib_ng-0.5.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:39b2508b7806e47bbb85a8011b881eebd7d9ea104adb3328caa163dbca1440e5"}, - {file = "zlib_ng-0.5.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:81e9049755e1db834594e7831b0de52c4755cdfc0c223cf6733285a30d0fdc8a"}, - {file = "zlib_ng-0.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:b3c6f83c3069121bb0fefb2ec22ec265ec9d450243ff3033e556459167942e71"}, - {file = "zlib_ng-0.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fdee7dc210ffef59b5237d3c705cef613415e67549f41568e2b4e7e712d17747"}, - {file = "zlib_ng-0.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:808d749ad0b2c6942755760c1f17655c8106f78f6d9e4729eb5486361715fca8"}, - {file = "zlib_ng-0.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0b51e1d2c01755c79aa45c66601adc1d8a0671d2a71ce93ff06bfeeeb8b8493"}, - {file = "zlib_ng-0.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9589f7a5f0a9ca9af57a8a7df088ec9d5535ee4a10507978634ce2a158b7fea"}, - {file = "zlib_ng-0.5.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:698f782cc415e76f95f06c4473b6dac4446dc664dee42d5237ac7018fc07aedc"}, - {file = "zlib_ng-0.5.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cef7d3e5b27de0d82d14e343bfbc8866d7a32bb4565a036247d39a8a2c5e1516"}, - {file = "zlib_ng-0.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:8459b6ef8358e1edc08e3ce2a7ad6771549c4a93967a2ebb6b1138a97a115b81"}, - {file = "zlib_ng-0.5.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4de0cf51d8ce333f8395efb03f5bdb1395657dc79be02391ebbd815fb963ef10"}, - {file = "zlib_ng-0.5.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:4333a177e3818c2eb36aa62ca0c7a34010e2f7fbd28bb2f2cee68ce4f2cfcb2e"}, - {file = "zlib_ng-0.5.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69ed5e4319732988c80d8f85d2171330e14f2f4cbad00f26a191ffcc92a334c3"}, - {file = "zlib_ng-0.5.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1590c93375c001ff36c66bcc7f1bb2179dc3db9e6d0fa94c3afa5e0f0eef682f"}, - {file = "zlib_ng-0.5.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:fa5400b8937630a40252fe0b13bb1a190bef9c5b3db7fca1fc6024cf60c0c3b9"}, - {file = "zlib_ng-0.5.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:521b352372916ab40caa03e655ae49f503a2130e73343c8eb2043c57cdf99e8e"}, - {file = "zlib_ng-0.5.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:91b85730e303ef239c3c361cc02023c61eb2739126be1f0e36f5a1f311d2d4f4"}, - {file = "zlib_ng-0.5.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e16ed25141dd4eaba0c8815cacf9e16cf22221b467c412c2a5302840f1dc2a08"}, - {file = "zlib_ng-0.5.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b362d878d82a8f66856ca5557973758a73e661ee6beb80be5427aa89d9dcab29"}, - {file = "zlib_ng-0.5.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:f707d5c3e22242abff72d155e3fc82927cdd65d9f6a10f29d03706d3ecec8b51"}, - {file = "zlib_ng-0.5.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5c53836db4cf729e0c85173958f81ae87f2d83fdc7fb967e87fefa08492f2d5f"}, - {file = "zlib_ng-0.5.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:cfa63c08af2eef138e6c1403ad9302ff5b3fd30c4b116534bc60c3d6b79bf76a"}, - {file = "zlib_ng-0.5.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de0c57e7bae5ea0cce01e8192362726d8471a35353426483974cb2abb86f4a70"}, - {file = "zlib_ng-0.5.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c7e140744440d23e70719d2a299a61a4c20a179c7e94b42ae833a9e13220afc"}, - {file = "zlib_ng-0.5.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3deacc849310854409fccd0be09f0bd4a9f3a82fb5f03f7d41ae9f7cda8ae92e"}, - {file = "zlib_ng-0.5.1.tar.gz", hash = "sha256:32a46649e8efc21ddd74776a55366a8d8be4e3a95b93dc1f0ffe3880718990d9"}, -] - [metadata] lock-version = "2.0" -python-versions = ">=3.12,<3.14" -content-hash = "a3cadd59f4471fb0ec81ff503f19260ce0a178eb6fe1ad03f71e06a7b503bb58" +python-versions = ">=3.12,<3.15" +content-hash = "001788284dd09ad0837c4e70717e5526faca49a725cf0246746ba23d4d99b50f" diff --git a/pyproject.toml b/pyproject.toml index ccd2a3cb97..5ac0d256d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,15 +30,14 @@ coverage = "*" flake8 = "*" jsonschema = "*" pylint = "*" -pytest = "*" +pytest = "9.0.2" setuptools = "*" pytest-bdd = "*" pytest-xdist = {extras = ["psutil"], version = "*"} pyhamcrest = "*" tabulate = "^0.9.0" -pytest-asyncio = "^0.24.0" -pytest-freezer = "^0.4.8" -homeassistant = "^2023.10" +pytest-asyncio = "^1.3.0" +pytest-freezer = "^0.4.9" geopandas = "^1.1.2" @@ -57,9 +56,9 @@ bs4 = "*" python-dateutil = "*" holidays = "*" pandas = "*" -python = ">=3.12,<3.14" +python = ">=3.12,<3.15" requests = "*" -selenium = "*" +selenium = "4.45.0" lxml = "*" urllib3 = "*" webdriver-manager = "^4.0.1" diff --git a/scripts/pin_release_manifest.py b/scripts/pin_release_manifest.py new file mode 100644 index 0000000000..48aa791a8a --- /dev/null +++ b/scripts/pin_release_manifest.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Prepare the HA manifest for an atomic, exact-version release tag.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PYPROJECT = ROOT / "pyproject.toml" +MANIFEST = ROOT / "custom_components" / "uk_bin_collection" / "manifest.json" +CORE_REQUIREMENT = re.compile(r"^uk-bin-collection(?:\s*[<>=!~].*)?$", re.I) + + +def validate_pin_inputs( + version: str, + *, + pyproject_path: Path = PYPROJECT, + manifest_path: Path = MANIFEST, +) -> tuple[dict, list[str]]: + """Return parsed manifest and fail-closed reasons it cannot be pinned.""" + project = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + errors: list[str] = [] + + if project["tool"]["poetry"]["version"] != version: + errors.append("pyproject version does not match the requested release") + if manifest.get("version") != version: + errors.append("manifest version does not match the requested release") + + requirements = manifest.get("requirements") + if ( + not isinstance(requirements, list) + or len(requirements) != 1 + or not isinstance(requirements[0], str) + or not CORE_REQUIREMENT.fullmatch(requirements[0].strip()) + ): + errors.append( + "manifest requirements must contain only uk-bin-collection before pinning" + ) + return manifest, errors + + +def pin_exact_requirement( + version: str, + *, + pyproject_path: Path = PYPROJECT, + manifest_path: Path = MANIFEST, + check_only: bool = False, +) -> list[str]: + """Pin the matching core package, or only prove the mutation is safe.""" + manifest, errors = validate_pin_inputs( + version, + pyproject_path=pyproject_path, + manifest_path=manifest_path, + ) + if errors or check_only: + return errors + + manifest["requirements"] = [f"uk-bin-collection=={version}"] + manifest_path.write_text( + json.dumps(manifest, indent=4, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return [] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--check", action="store_true") + args = parser.parse_args(argv) + + errors = pin_exact_requirement(args.version, check_only=args.check) + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + action = "can be pinned" if args.check else "pinned" + print(f"HA manifest {action} to uk-bin-collection=={args.version}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_release_contract.py b/scripts/validate_release_contract.py new file mode 100644 index 0000000000..c13c22e847 --- /dev/null +++ b/scripts/validate_release_contract.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Fail closed unless the HA component and core artifact are version-atomic.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import tomllib +import zipfile +from email.parser import BytesParser +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PYPROJECT = ROOT / "pyproject.toml" +MANIFEST = ROOT / "custom_components" / "uk_bin_collection" / "manifest.json" +EXPECTED_DISTRIBUTION = "uk-bin-collection" + + +def _normalise_distribution_name(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def validate_release_metadata( + version: str, + *, + require_exact: bool, + pyproject_path: Path = PYPROJECT, + manifest_path: Path = MANIFEST, +) -> list[str]: + """Return release-contract errors without mutating any release metadata.""" + project = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + errors: list[str] = [] + core_version = project["tool"]["poetry"]["version"] + component_version = manifest.get("version") + expected_requirement = f"{EXPECTED_DISTRIBUTION}=={version}" + requirements = manifest.get("requirements", []) + + if core_version != version: + errors.append(f"pyproject version {core_version!r} does not match {version!r}") + if component_version != version: + errors.append( + f"manifest version {component_version!r} does not match {version!r}" + ) + if require_exact and requirements != [expected_requirement]: + errors.append( + "manifest must contain only the exact matching core requirement " + f"{expected_requirement!r}; found {requirements!r}" + ) + return errors + + +def validate_wheel(wheel_path: Path, version: str) -> list[str]: + """Validate identity and version from the wheel's own METADATA.""" + errors: list[str] = [] + try: + with zipfile.ZipFile(wheel_path) as wheel: + metadata_files = [ + name + for name in wheel.namelist() + if name.endswith(".dist-info/METADATA") + ] + if len(metadata_files) != 1: + return [ + f"wheel must contain exactly one dist-info/METADATA; " + f"found {len(metadata_files)}" + ] + metadata = BytesParser().parsebytes(wheel.read(metadata_files[0])) + except (OSError, zipfile.BadZipFile) as exc: + return [f"cannot read wheel {wheel_path}: {exc}"] + + distribution_name = metadata.get("Name", "") + if _normalise_distribution_name(distribution_name) != EXPECTED_DISTRIBUTION: + errors.append( + f"wheel distribution {distribution_name!r} is not {EXPECTED_DISTRIBUTION!r}" + ) + if metadata.get("Version") != version: + errors.append( + f"wheel version {metadata.get('Version')!r} does not match {version!r}" + ) + if not metadata.get("Requires-Python"): + errors.append("wheel is missing Requires-Python metadata") + return errors + + +def validate_pypi_digest( + wheel_path: Path, pypi_json_path: Path, version: str +) -> list[str]: + """Prove PyPI serves the exact locally built wheel bytes.""" + try: + release = json.loads(pypi_json_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [f"cannot read PyPI release JSON {pypi_json_path}: {exc}"] + + errors: list[str] = [] + if release.get("info", {}).get("version") != version: + errors.append( + "PyPI JSON version " + f"{release.get('info', {}).get('version')!r} does not match {version!r}" + ) + + matching_files = [ + item + for item in release.get("urls", []) + if item.get("filename") == wheel_path.name + ] + if len(matching_files) != 1: + errors.append( + f"PyPI JSON must contain exactly one {wheel_path.name!r}; " + f"found {len(matching_files)}" + ) + return errors + + local_digest = hashlib.sha256(wheel_path.read_bytes()).hexdigest() + remote_digest = matching_files[0].get("digests", {}).get("sha256") + if remote_digest != local_digest: + errors.append( + f"PyPI wheel digest {remote_digest!r} does not match local {local_digest!r}" + ) + return errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--require-exact", action="store_true") + parser.add_argument("--wheel", type=Path) + parser.add_argument("--pypi-json", type=Path) + args = parser.parse_args(argv) + + errors = validate_release_metadata(args.version, require_exact=args.require_exact) + if args.wheel: + errors.extend(validate_wheel(args.wheel, args.version)) + if args.pypi_json: + if not args.wheel: + errors.append("--pypi-json requires --wheel") + else: + errors.extend( + validate_pypi_digest(args.wheel, args.pypi_json, args.version) + ) + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + checks = ["core/component versions"] + if args.require_exact: + checks.append("exact HA core requirement") + if args.wheel: + checks.append("wheel metadata") + if args.pypi_json: + checks.append("published wheel SHA-256") + print(f"Release contract valid for {args.version}: {', '.join(checks)}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..0dad9ce0a7 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +"""Project test package. + +Keeping this directory as an explicit package prevents dependencies that ship a +top-level ``tests`` package from shadowing the disposable validation helpers. +""" diff --git a/tests/disposable_ha/Compatibility.Containerfile b/tests/disposable_ha/Compatibility.Containerfile new file mode 100644 index 0000000000..acb07eed25 --- /dev/null +++ b/tests/disposable_ha/Compatibility.Containerfile @@ -0,0 +1,16 @@ +ARG PYTHON_IMAGE +FROM ${PYTHON_IMAGE} + +COPY . /workspace +WORKDIR /workspace + +# Image construction is the controlled dependency-preparation window. Test +# execution happens later with --network none against this frozen image. +RUN python -m pip install --disable-pip-version-check \ + . \ + pytest==9.0.2 \ + pytest-asyncio==1.3.0 \ + pytest-freezer==0.4.9 + +LABEL org.opencontainers.image.title="UKBCD disposable Python compatibility gate" +LABEL org.opencontainers.image.source="local-only" diff --git a/tests/disposable_ha/Containerfile b/tests/disposable_ha/Containerfile new file mode 100644 index 0000000000..6db25f6ac9 --- /dev/null +++ b/tests/disposable_ha/Containerfile @@ -0,0 +1,34 @@ +ARG HA_IMAGE +FROM ${HA_IMAGE} + +COPY tests/disposable_ha/candidate.whl \ + /tmp/uk_bin_collection-0.170.0-py3-none-any.whl +COPY tests/disposable_ha/pytest_asyncio-1.3.0-py3-none-any.whl \ + /tmp/pytest_asyncio-1.3.0-py3-none-any.whl +COPY tests/disposable_ha/freezegun-1.5.1-py3-none-any.whl \ + /tmp/freezegun-1.5.1-py3-none-any.whl +COPY tests/disposable_ha/pytest_freezer-0.4.9-py3-none-any.whl \ + /tmp/pytest_freezer-0.4.9-py3-none-any.whl +COPY tests/disposable_ha/import-wheelhouse/*.whl /tmp/runtime-wheelhouse/ +RUN PIP_NO_INDEX=1 python -m pip install --no-deps --force-reinstall \ + /tmp/uk_bin_collection-0.170.0-py3-none-any.whl \ + /tmp/pytest_asyncio-1.3.0-py3-none-any.whl \ + /tmp/freezegun-1.5.1-py3-none-any.whl \ + /tmp/pytest_freezer-0.4.9-py3-none-any.whl \ + /tmp/runtime-wheelhouse/*.whl \ + && rm -f /tmp/uk_bin_collection-0.170.0-py3-none-any.whl \ + /tmp/pytest_asyncio-1.3.0-py3-none-any.whl \ + /tmp/freezegun-1.5.1-py3-none-any.whl \ + /tmp/pytest_freezer-0.4.9-py3-none-any.whl \ + && rm -rf /tmp/runtime-wheelhouse + +COPY . /workspace +RUN python /workspace/tests/disposable_ha/verify_runtime_requirements.py \ + --distribution uk-bin-collection +COPY tests/disposable_ha/FixtureCouncil.py \ + /usr/local/lib/python3.14/site-packages/uk_bin_collection/uk_bin_collection/councils/FixtureCouncil.py +COPY tests/disposable_ha/SouthKestevenFixtureCouncil.py \ + /usr/local/lib/python3.14/site-packages/uk_bin_collection/uk_bin_collection/councils/SouthKestevenFixtureCouncil.py + +LABEL org.opencontainers.image.title="UKBCD disposable HA validation image" +LABEL org.opencontainers.image.source="local-only" diff --git a/tests/disposable_ha/FixtureCouncil.py b/tests/disposable_ha/FixtureCouncil.py new file mode 100644 index 0000000000..27cf85f057 --- /dev/null +++ b/tests/disposable_ha/FixtureCouncil.py @@ -0,0 +1,32 @@ +"""Disposable non-Selenium council used only inside the offline test image.""" + +from __future__ import annotations + +import os +from datetime import date, timedelta +from pathlib import Path + +from uk_bin_collection.uk_bin_collection.common import date_format +from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass + + +class CouncilClass(AbstractGetBinDataClass): + """Return one deterministic future collection without importing Selenium.""" + + def parse_data(self, page: str, **kwargs) -> dict: + del page, kwargs + evidence_dir = os.environ.get("UKBCD_TEST_EVIDENCE_DIR") + if evidence_dir: + evidence_path = Path(evidence_dir) / "non_selenium_scrape_calls" + with evidence_path.open("a", encoding="utf-8") as handle: + handle.write("1\n") + return { + "bins": [ + { + "type": "Fixture Bin", + "collectionDate": (date.today() + timedelta(days=7)).strftime( + date_format + ), + } + ] + } diff --git a/tests/disposable_ha/ImportGate.Containerfile b/tests/disposable_ha/ImportGate.Containerfile new file mode 100644 index 0000000000..e48eeab4d1 --- /dev/null +++ b/tests/disposable_ha/ImportGate.Containerfile @@ -0,0 +1,12 @@ +ARG CANDIDATE_IMAGE +FROM ${CANDIDATE_IMAGE} + +# These wheels are prepared from the exact versions in poetry.lock during the +# controlled download window. The import gate itself is built and executed +# without network access. +COPY tests/disposable_ha/import-wheelhouse/*.whl /tmp/import-wheelhouse/ +RUN PIP_NO_INDEX=1 python -m pip install --no-deps /tmp/import-wheelhouse/*.whl \ + && rm -rf /tmp/import-wheelhouse + +LABEL org.opencontainers.image.title="UKBCD disposable import-all gate" +LABEL org.opencontainers.image.source="local-only" diff --git a/tests/disposable_ha/LIVE_CANARY.md b/tests/disposable_ha/LIVE_CANARY.md new file mode 100644 index 0000000000..111cf88579 --- /dev/null +++ b/tests/disposable_ha/LIVE_CANARY.md @@ -0,0 +1,149 @@ +# South Kesteven one-shot live canary + +The remediation plan already authorizes exactly one run of this canary after the +offline acceptance suite passes. No additional approval is needed for that one +run. It is not called by pytest, Make, CI, the release workflow, or the disposable +Home Assistant suite, and it must not run before the offline gate passes. A retry, +second lookup, changed fixture, or expanded destination is outside that approval. + +The runner reads only the committed `SouthKestevenDistrictCouncil` values from +`uk_bin_collection/tests/input.json`. It has no postcode, house number, URL, or +WebDriver command-line override. A fixed environment acknowledgement, a fixed +command acknowledgement, a five-second start delay, an exclusive one-shot lock, +and the proxy's paced 256-connection ceiling prevent accidental repeated runs. +The ceiling allows one browser page flow to load its assets; it does not authorize +more than the runner's single logical address lookup. + +## Network boundary + +Create two new, run-ID-scoped rootless Podman networks: + +- `ukbcd-canary-internal-` is an `--internal` network. Only the runner, + Selenium, and proxy join it. Runner and Selenium have no other network. +- `ukbcd-canary-egress-` is joined only by the proxy. The proxy accepts + only exact `www.southkesteven.gov.uk` and + `selfservice.southkesteven.gov.uk` destinations on ports 80 and 443. + +The allowlist proxy never logs a request path, query, header, payload, postcode, +house number, or denied hostname. Its complete log vocabulary is an allowlisted +host (or the constant `[DENIED]`) and a proxy status code. Redirects to any other +origin fail at the next proxy request. The proxy has a read-only filesystem, +drops every capability, has no mounts, and is the only process with an egress +route. + +Chrome must receive both of these Selenium image environment values: + +```text +SE_BROWSER_ARGS_UKBCD_PROXY=--proxy-server=http://ukbcd-live-proxy:3128 +SE_BROWSER_ARGS_UKBCD_NO_BYPASS=--proxy-bypass-list=<-loopback> +``` + +Chromium normally bypasses proxies for loopback. The special `<-loopback>` token +removes that implicit bypass and adds no replacement bypass host. Background +networking, sync, and default apps are disabled as additional noise controls. + +## Reviewed orchestration shape + +The following is a reference sequence, not an executable script. Substitute the +already-recorded digest-pinned candidate and Selenium image names. Build/pull +images only during the plan's preparation window. Never substitute mutable tags +in the evidence run. + +```bash +set -eu +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" +INTERNAL="ukbcd-canary-internal-${RUN_ID}" +EGRESS="ukbcd-canary-egress-${RUN_ID}" +PROXY="ukbcd-live-proxy-${RUN_ID}" +SELENIUM="ukbcd-live-selenium-${RUN_ID}" +RUNNER="ukbcd-live-runner-${RUN_ID}" +PROXY_IMAGE_REF="LOCAL_LIVE_PROXY_IMAGE@sha256:RECORD_BEFORE_RUN" +SELENIUM_IMAGE_REF="PINNED_SELENIUM_IMAGE@sha256:RECORD_BEFORE_RUN" +RUNNER_IMAGE_REF="LOCAL_LIVE_CANARY_IMAGE@sha256:RECORD_BEFORE_RUN" + +# Record the full local immutable image IDs which container inspect will expose. +PROXY_IMAGE_ID="$(podman image inspect --format '{{.Id}}' "$PROXY_IMAGE_REF")" +SELENIUM_IMAGE_ID="$(podman image inspect --format '{{.Id}}' "$SELENIUM_IMAGE_REF")" +RUNNER_IMAGE_ID="$(podman image inspect --format '{{.Id}}' "$RUNNER_IMAGE_REF")" + +podman network create --internal "$INTERNAL" +podman network create "$EGRESS" + +podman create --name "$PROXY" \ + --network "$INTERNAL:alias=ukbcd-live-proxy" \ + --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=64 --cpus=0.25 --memory=192m \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m \ + "$PROXY_IMAGE_ID" +podman network connect "$EGRESS" "$PROXY" + +podman create --name "$SELENIUM" \ + --network "$INTERNAL:alias=selenium" \ + --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=512 --cpus=3 --memory=3500m --shm-size=2g \ + --tmpfs /tmp:rw,nosuid,nodev,size=512m \ + --tmpfs /home/seluser:rw,nosuid,nodev,size=512m \ + -e SE_BROWSER_ARGS_UKBCD_PROXY=--proxy-server=http://ukbcd-live-proxy:3128 \ + -e 'SE_BROWSER_ARGS_UKBCD_NO_BYPASS=--proxy-bypass-list=<-loopback>' \ + -e SE_BROWSER_ARGS_UKBCD_BACKGROUND=--disable-background-networking \ + -e SE_BROWSER_ARGS_UKBCD_SYNC=--disable-sync \ + -e SE_BROWSER_ARGS_UKBCD_DEFAULT_APPS=--disable-default-apps \ + -e SE_NODE_MAX_SESSIONS=1 -e SE_NODE_OVERRIDE_MAX_SESSIONS=false \ + -e SE_OFFLINE=true -e SE_ENABLE_TRACING=false \ + "$SELENIUM_IMAGE_ID" + +podman create --name "$RUNNER" \ + --network "$INTERNAL" \ + --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=96 --cpus=0.5 --memory=512m \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=32m \ + -e UKBCD_LIVE_CANARY_APPROVED=one-public-fixture-lookup \ + "$RUNNER_IMAGE_ID" + +python tests/disposable_ha/live_canary_safety_check.py \ + --internal-network "$INTERNAL" --egress-network "$EGRESS" \ + --proxy "$PROXY" --selenium "$SELENIUM" --runner "$RUNNER" \ + --expected-proxy-image "$PROXY_IMAGE_ID" \ + --expected-selenium-image "$SELENIUM_IMAGE_ID" \ + --expected-runner-image "$RUNNER_IMAGE_ID" \ + --output "live-canary-safety-${RUN_ID}.json" +``` + +The safety check must pass while all three application containers are still in +`created` state. Podman 4.9 does not expose unstarted endpoints in the container +map returned by `network inspect`, so the validator also inventories every local +container and resolves its planned networks from `container inspect`. That +complete planned membership must be exact; if `network inspect` reports any +members, its runtime view must also agree exactly. This detects an extra +unstarted container without starting any reviewed payload. The check fails if +Podman is not rootless; either application container has egress; the proxy lacks +either network; any host port, mount, device, socket, added capability, writable +root, restart policy, or missing resource limit is found; Chrome has a bypass; +or the total limits exceed 6 CPUs/7 GiB. Capability removal +must be present as `--cap-drop all` in each inspected create command and as the +complete expected drop set in container state; a merely non-empty drop list does +not pass. The full local image IDs must match the three separately recorded +immutable values; mutable image names and short IDs do not pass. Host PID, IPC, +user, UTS, or cgroup namespace modes do not pass where the runtime exposes them. +For a `Cmd`-only image, Podman 4.9 reports the command's first value as `Path` +and repeats the complete command in `Args`; the validator derives and binds that +exact runtime representation from the immutable reviewed image metadata. + +After that check passes, start the proxy and Selenium, wait only for Selenium's +internal `/status` endpoint, and attach to the runner once. Do not use `podman +run`, because that would bypass the inspected `created` containers. Export only: + +- the pre-start safety JSON; +- the runner's one-line JSON summary; +- the proxy's safe host/status log; +- image digests and the candidate wheel/local commit hashes. + +Do not export Selenium logs, browser profiles, page source, screenshots, `/tmp`, +or any container filesystem. The runner suppresses collector stdout/stderr and +prints only a bin-row count or a short redacted error. Review exported text with +the same postcode/number/URL redactor before moving it to Windows. + +Remove only the exact three container and two network names after evidence is +safe. Never use a global prune. A failed safety check is terminal for that run; +do not add host networking, a host port, a bind mount, privilege, or a socket to +make the canary pass. diff --git a/tests/disposable_ha/LiveCanary.Containerfile b/tests/disposable_ha/LiveCanary.Containerfile new file mode 100644 index 0000000000..3d8797151c --- /dev/null +++ b/tests/disposable_ha/LiveCanary.Containerfile @@ -0,0 +1,11 @@ +ARG CANDIDATE_IMAGE +FROM ${CANDIDATE_IMAGE} + +COPY tests/disposable_ha/live_canary_runner.py /opt/ukbcd/live_canary_runner.py + +ENV PYTHONDONTWRITEBYTECODE=1 +USER 1000:1000 +ENTRYPOINT ["python", "/opt/ukbcd/live_canary_runner.py", "--confirm-one-public-fixture-lookup"] + +LABEL org.opencontainers.image.title="UKBCD one-shot South Kesteven canary" +LABEL org.opencontainers.image.source="local-only" diff --git a/tests/disposable_ha/LiveProxy.Containerfile b/tests/disposable_ha/LiveProxy.Containerfile new file mode 100644 index 0000000000..d82c1add11 --- /dev/null +++ b/tests/disposable_ha/LiveProxy.Containerfile @@ -0,0 +1,12 @@ +ARG PYTHON_IMAGE +FROM ${PYTHON_IMAGE} + +COPY tests/disposable_ha/live_allowlist_proxy.py /opt/ukbcd/live_allowlist_proxy.py + +ENV PYTHONDONTWRITEBYTECODE=1 +USER 65532:65532 +ENTRYPOINT ["python3", "/opt/ukbcd/live_allowlist_proxy.py"] +CMD ["--max-requests", "256", "--minimum-interval-ms", "25"] + +LABEL org.opencontainers.image.title="UKBCD South Kesteven allowlist proxy" +LABEL org.opencontainers.image.source="local-only" diff --git a/tests/disposable_ha/SouthKestevenFixtureCouncil.py b/tests/disposable_ha/SouthKestevenFixtureCouncil.py new file mode 100644 index 0000000000..a4e54dbd62 --- /dev/null +++ b/tests/disposable_ha/SouthKestevenFixtureCouncil.py @@ -0,0 +1,26 @@ +"""Disposable loopback adapter around the production South Kesteven flow.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from uk_bin_collection.uk_bin_collection.councils.SouthKestevenDistrictCouncil import ( + CouncilClass as ProductionCouncilClass, +) + + +class CouncilClass(ProductionCouncilClass): + """Use the production flow against the pod's loopback-only fixture service.""" + + BIN_DAY_URL = "http://127.0.0.1:8081/binday" + CHECKER_SCHEME = "http" + CHECKER_HOST = "127.0.0.1" + + def parse_data(self, page: str, **kwargs) -> dict: + evidence_dir = os.environ.get("UKBCD_TEST_EVIDENCE_DIR") + if evidence_dir: + evidence_path = Path(evidence_dir) / "south_kesteven_scrape_calls" + with evidence_path.open("a", encoding="utf-8") as handle: + handle.write("1\n") + return super().parse_data(page, **kwargs) diff --git a/tests/disposable_ha/WheelBuilder.Containerfile b/tests/disposable_ha/WheelBuilder.Containerfile new file mode 100644 index 0000000000..a251262a9b --- /dev/null +++ b/tests/disposable_ha/WheelBuilder.Containerfile @@ -0,0 +1,16 @@ +ARG PYTHON_IMAGE +FROM ${PYTHON_IMAGE} + +COPY tests/disposable_ha/build-1.3.0-py3-none-any.whl /tmp/wheelhouse/ +COPY tests/disposable_ha/packaging-26.2-py3-none-any.whl /tmp/wheelhouse/ +COPY tests/disposable_ha/poetry_core-1.9.1-py3-none-any.whl /tmp/wheelhouse/ +COPY tests/disposable_ha/pyproject_hooks-1.2.0-py3-none-any.whl /tmp/wheelhouse/ +RUN PIP_NO_INDEX=1 python -m pip install --no-deps /tmp/wheelhouse/*.whl \ + && rm -rf /tmp/wheelhouse + +COPY . /workspace +WORKDIR /workspace +RUN python -m build --wheel --no-isolation --outdir /dist + +LABEL org.opencontainers.image.title="UKBCD disposable offline wheel builder" +LABEL org.opencontainers.image.source="local-only" diff --git a/tests/disposable_ha/__init__.py b/tests/disposable_ha/__init__.py new file mode 100644 index 0000000000..0fd535c39e --- /dev/null +++ b/tests/disposable_ha/__init__.py @@ -0,0 +1 @@ +"""Disposable Home Assistant and canary validation helpers.""" diff --git a/tests/disposable_ha/collision_probe.py b/tests/disposable_ha/collision_probe.py new file mode 100644 index 0000000000..95b355955a --- /dev/null +++ b/tests/disposable_ha/collision_probe.py @@ -0,0 +1,88 @@ +"""Reproduce the raw collision, then prove the candidate fails before import.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +from uk_bin_collection.uk_bin_collection.dependency_validation import ( + validate_websocket_client, +) +from uk_bin_collection.uk_bin_collection.exceptions import DependencyShadowingError + + +def main() -> None: + evidence = Path(os.environ.get("UKBCD_TEST_EVIDENCE_DIR", "/evidence")) + evidence.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as temp_dir: + poison_root = Path(temp_dir) + poison = poison_root / "websocket" + poison.mkdir() + execution_marker = poison_root / "poison_executed" + (poison / "__init__.py").write_text( + "from pathlib import Path\n" + f"Path({str(execution_marker)!r}).write_text('executed')\n" + "from ..const import DOMAIN\n", + encoding="utf-8", + ) + + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join( + [str(poison_root), env.get("PYTHONPATH", "")] + ) + raw = subprocess.run( + [ + sys.executable, + "-c", + "from selenium.webdriver.remote.websocket_connection import " + "WebSocketConnection", + ], + env=env, + capture_output=True, + text=True, + check=False, + ) + raw_reproduced = ( + raw.returncode != 0 + and "attempted relative import beyond top-level package" in raw.stderr + and execution_marker.exists() + ) + + execution_marker.unlink(missing_ok=True) + sys.path.insert(0, str(poison_root)) + sys.modules.pop("websocket", None) + try: + validate_websocket_client() + except DependencyShadowingError as exc: + typed_error = type(exc).__name__ + typed_message = str(exc) + else: + typed_error = None + typed_message = None + finally: + sys.path.remove(str(poison_root)) + + candidate_blocked_before_execution = ( + typed_error == "DependencyShadowingError" and not execution_marker.exists() + ) + + report = { + "raw_import_reproduced": raw_reproduced, + "raw_return_code": raw.returncode, + "candidate_error": typed_error, + "candidate_message": typed_message, + "candidate_blocked_before_poison_execution": candidate_blocked_before_execution, + } + (evidence / "collision_probe.json").write_text( + json.dumps(report, indent=2, sort_keys=True), encoding="utf-8" + ) + if not raw_reproduced or not candidate_blocked_before_execution: + raise SystemExit(json.dumps(report, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tests/disposable_ha/containerignore b/tests/disposable_ha/containerignore new file mode 100644 index 0000000000..b298ccccfb --- /dev/null +++ b/tests/disposable_ha/containerignore @@ -0,0 +1,9 @@ +.git +**/__pycache__ +**/*.pyc +.pytest_cache +.coverage +build +dist +test_results_map.png +SwaggerUI.png diff --git a/tests/disposable_ha/fixture_server.py b/tests/disposable_ha/fixture_server.py new file mode 100644 index 0000000000..0d081395c5 --- /dev/null +++ b/tests/disposable_ha/fixture_server.py @@ -0,0 +1,129 @@ +"""Loopback-only deterministic HTML service for the South Kesteven browser flow.""" + +from __future__ import annotations + +import json +import os +from datetime import date, timedelta +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +HOST = "127.0.0.1" +PORT = 8081 +EVIDENCE = Path(os.environ.get("UKBCD_TEST_EVIDENCE_DIR", "/evidence")) + + +def _future_date(days: int) -> str: + return (date.today() + timedelta(days=days)).strftime("%A %d %B, %Y") + + +def _checker_html() -> str: + first_date = _future_date(7) + second_date = _future_date(14) + return f""" +Offline bin checker + + +
+ + + + + + + + +
+ +""" + + +def _is_fixture_browser_user_agent(value: str) -> bool: + """Require the disposable browser marker, not merely a generic browser UA.""" + normalized = value.casefold() + return "mozilla" in normalized and "ukbcd-disposable-fixture" in normalized + + +class Handler(BaseHTTPRequestHandler): + """Serve only the three fixture routes needed by the validation.""" + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + EVIDENCE.mkdir(parents=True, exist_ok=True) + with (EVIDENCE / "fixture_requests.jsonl").open( + "a", encoding="utf-8" + ) as handle: + user_agent = self.headers.get("User-Agent", "").lower() + handle.write( + json.dumps( + { + "path": self.path.split("?", 1)[0], + "browser": "mozilla" in user_agent, + "custom_user_agent": "ukbcd-disposable-fixture" in user_agent, + }, + sort_keys=True, + ) + + "\n" + ) + + if self.path == "/health": + self._send(200, "text/plain", "ok") + return + if self.path == "/binday": + user_agent = self.headers.get("User-Agent", "") + if not _is_fixture_browser_user_agent(user_agent): + self._send(403, "text/html", "

403 Forbidden

") + return + self._send( + 200, + "text/html", + '' + "Postcode bin day checker", + ) + return + if self.path == "/checker": + self._send(200, "text/html", _checker_html()) + return + if self.path == "/static": + self._send(200, "application/json", '{"fixture": true}') + return + self._send(404, "text/plain", "not found") + + def _send(self, status: int, content_type: str, body: str) -> None: + payload = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", f"{content_type}; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format: str, *args) -> None: + del format, args + + +if __name__ == "__main__": + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() diff --git a/tests/disposable_ha/import_all_councils.py b/tests/disposable_ha/import_all_councils.py new file mode 100644 index 0000000000..2f0eb01cb0 --- /dev/null +++ b/tests/disposable_ha/import_all_councils.py @@ -0,0 +1,39 @@ +"""Import every shipped council module without performing network activity.""" + +from __future__ import annotations + +import json + +from uk_bin_collection.uk_bin_collection.collect_data import ( + import_council_module, + registered_council_modules, +) + + +def main() -> None: + failures: dict[str, str] = {} + modules = sorted(registered_council_modules()) + for module_name in modules: + try: + import_council_module(module_name) + except Exception as exc: # Report the complete deterministic import inventory. + missing_name = getattr(exc, "name", None) + failures[module_name] = ( + f"{type(exc).__name__}:{missing_name}" + if missing_name + else type(exc).__name__ + ) + + report = { + "registered_council_count": len(modules), + "imported_council_count": len(modules) - len(failures), + "failure_count": len(failures), + "failures": failures, + } + print(json.dumps(report, indent=2, sort_keys=True)) + if failures: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/disposable_ha/live_allowlist_proxy.py b/tests/disposable_ha/live_allowlist_proxy.py new file mode 100644 index 0000000000..978d8ad34a --- /dev/null +++ b/tests/disposable_ha/live_allowlist_proxy.py @@ -0,0 +1,347 @@ +"""Small fail-closed proxy for the separately approved live SKDC canary. + +The proxy deliberately has no general forwarding mode. It accepts only the two +published South Kesteven origins, never records a request target or payload, and +applies a small global connection delay/budget. It is intended to be the only +container attached to both the canary's internal network and its egress network. +""" + +from __future__ import annotations + +import argparse +import logging +import selectors +import socket +import socketserver +import threading +import time +from dataclasses import dataclass +from urllib.parse import urlsplit + +ALLOWED_HOSTS = frozenset( + { + "www.southkesteven.gov.uk", + "selfservice.southkesteven.gov.uk", + } +) +ALLOWED_PORTS = frozenset({80, 443}) +ALLOWED_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "POST"}) +MAX_HEADER_BYTES = 64 * 1024 +SOCKET_TIMEOUT_SECONDS = 15 +TUNNEL_IDLE_TIMEOUT_SECONDS = 45 + +_LOGGER = logging.getLogger("ukbcd.live_allowlist_proxy") + + +class ProxyRequestDenied(ValueError): + """The request is outside the deliberately tiny egress policy.""" + + +class ProxyRateLimitExceeded(RuntimeError): + """The one-shot canary exhausted its bounded proxy request budget.""" + + +@dataclass(frozen=True) +class ProxyRequest: + """Validated upstream request metadata. + + ``forward_header`` may contain a URL path and must never be logged. + """ + + method: str + host: str + port: int + is_tunnel: bool + forward_header: bytes | None + + +class RequestLimiter: + """Serialize upstream connection starts and enforce a hard request budget.""" + + def __init__( + self, + *, + max_requests: int, + minimum_interval_seconds: float, + clock=time.monotonic, + sleeper=time.sleep, + ) -> None: + if max_requests < 1: + raise ValueError("max_requests must be positive") + if minimum_interval_seconds < 0: + raise ValueError("minimum_interval_seconds cannot be negative") + self._max_requests = max_requests + self._minimum_interval = minimum_interval_seconds + self._clock = clock + self._sleeper = sleeper + self._count = 0 + self._last_started: float | None = None + self._lock = threading.Lock() + + @property + def count(self) -> int: + return self._count + + def acquire(self) -> None: + with self._lock: + if self._count >= self._max_requests: + raise ProxyRateLimitExceeded("proxy request budget exhausted") + + now = self._clock() + if self._last_started is not None: + delay = self._minimum_interval - (now - self._last_started) + if delay > 0: + self._sleeper(delay) + now = self._clock() + + self._count += 1 + self._last_started = now + + +def _parse_authority(authority: str) -> tuple[str, int]: + """Return a validated exact allowlisted host and explicit/default port.""" + if not authority or any(character in authority for character in "/?#"): + raise ProxyRequestDenied("invalid proxy authority") + + parsed = urlsplit(f"//{authority}") + if parsed.username is not None or parsed.password is not None: + raise ProxyRequestDenied("credentials are forbidden in proxy authorities") + if not parsed.hostname: + raise ProxyRequestDenied("proxy authority has no hostname") + + host = parsed.hostname.casefold() + if host not in ALLOWED_HOSTS: + raise ProxyRequestDenied("hostname is not allowlisted") + try: + port = parsed.port + except ValueError as exc: + raise ProxyRequestDenied("invalid proxy port") from exc + port = 443 if port is None else port + if port not in ALLOWED_PORTS: + raise ProxyRequestDenied("port is not allowlisted") + return host, port + + +def parse_proxy_request(header: bytes) -> ProxyRequest: + """Validate a single HTTP proxy header without contacting the network.""" + if not header.endswith(b"\r\n\r\n") or len(header) > MAX_HEADER_BYTES: + raise ProxyRequestDenied("invalid proxy header framing") + try: + text = header.decode("iso-8859-1") + except UnicodeDecodeError as exc: # pragma: no cover - ISO-8859-1 is total + raise ProxyRequestDenied("invalid proxy header encoding") from exc + + lines = text[:-4].split("\r\n") + if not lines: + raise ProxyRequestDenied("missing request line") + request_parts = lines[0].split(" ") + if len(request_parts) != 3: + raise ProxyRequestDenied("invalid request line") + method, target, version = request_parts + method = method.upper() + if version not in {"HTTP/1.0", "HTTP/1.1"}: + raise ProxyRequestDenied("unsupported HTTP version") + if any(line.startswith((" ", "\t")) for line in lines[1:]): + raise ProxyRequestDenied("folded proxy headers are forbidden") + + if method == "CONNECT": + host, port = _parse_authority(target) + return ProxyRequest(method, host, port, True, None) + + if method not in ALLOWED_HTTP_METHODS: + raise ProxyRequestDenied("HTTP method is not allowlisted") + + parsed = urlsplit(target) + if ( + parsed.scheme.casefold() != "http" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + raise ProxyRequestDenied("only absolute allowlisted HTTP targets are accepted") + host = parsed.hostname.casefold() + if host not in ALLOWED_HOSTS: + raise ProxyRequestDenied("hostname is not allowlisted") + try: + port = parsed.port or 80 + except ValueError as exc: + raise ProxyRequestDenied("invalid proxy port") from exc + if port not in ALLOWED_PORTS: + raise ProxyRequestDenied("port is not allowlisted") + + origin_target = parsed.path or "/" + if parsed.query: + origin_target = f"{origin_target}?{parsed.query}" + + forwarded_headers: list[str] = [] + for line in lines[1:]: + if not line or ":" not in line: + if line: + raise ProxyRequestDenied("malformed proxy header") + continue + name, value = line.split(":", 1) + normalized_name = name.strip().casefold() + if normalized_name in { + "connection", + "host", + "proxy-authorization", + "proxy-connection", + }: + continue + forwarded_headers.append(f"{name.strip()}:{value}") + + host_header = host if port == 80 else f"{host}:{port}" + forward_lines = [ + f"{method} {origin_target} {version}", + f"Host: {host_header}", + *forwarded_headers, + "Connection: close", + "", + "", + ] + return ProxyRequest( + method, + host, + port, + False, + "\r\n".join(forward_lines).encode("iso-8859-1"), + ) + + +def safe_log_line(host: str | None, status: int) -> str: + """Return a log line containing no URL, payload, or denied hostname.""" + safe_host = host if host in ALLOWED_HOSTS else "[DENIED]" + return f"host={safe_host} status={int(status)}" + + +def _read_header(connection: socket.socket) -> tuple[bytes, bytes]: + buffer = bytearray() + while b"\r\n\r\n" not in buffer: + chunk = connection.recv(4096) + if not chunk: + raise ProxyRequestDenied("connection ended before proxy header") + buffer.extend(chunk) + if len(buffer) > MAX_HEADER_BYTES: + raise ProxyRequestDenied("proxy header is too large") + marker = buffer.index(b"\r\n\r\n") + 4 + return bytes(buffer[:marker]), bytes(buffer[marker:]) + + +def _relay(left: socket.socket, right: socket.socket) -> None: + selector = selectors.DefaultSelector() + selector.register(left, selectors.EVENT_READ, right) + selector.register(right, selectors.EVENT_READ, left) + try: + while True: + events = selector.select(TUNNEL_IDLE_TIMEOUT_SECONDS) + if not events: + return + for key, _ in events: + source = key.fileobj + destination = key.data + chunk = source.recv(64 * 1024) + if not chunk: + return + destination.sendall(chunk) + finally: + selector.close() + + +class AllowlistProxyHandler(socketserver.BaseRequestHandler): + """Handle one validated tunnel or one close-delimited HTTP exchange.""" + + server: "AllowlistProxyServer" + + def handle(self) -> None: + self.request.settimeout(SOCKET_TIMEOUT_SECONDS) + allowed_host: str | None = None + try: + header, buffered_body = _read_header(self.request) + decision = parse_proxy_request(header) + allowed_host = decision.host + self.server.limiter.acquire() + + with socket.create_connection( + (decision.host, decision.port), timeout=SOCKET_TIMEOUT_SECONDS + ) as upstream: + upstream.settimeout(TUNNEL_IDLE_TIMEOUT_SECONDS) + self.request.settimeout(TUNNEL_IDLE_TIMEOUT_SECONDS) + if decision.is_tunnel: + self.request.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + else: + assert decision.forward_header is not None + upstream.sendall(decision.forward_header) + if buffered_body: + upstream.sendall(buffered_body) + _LOGGER.info(safe_log_line(decision.host, 200)) + _relay(self.request, upstream) + except ProxyRateLimitExceeded: + self._send_error(429) + _LOGGER.warning(safe_log_line(allowed_host, 429)) + except ProxyRequestDenied: + self._send_error(403) + _LOGGER.warning(safe_log_line(None, 403)) + except (OSError, TimeoutError): + self._send_error(502) + _LOGGER.warning(safe_log_line(allowed_host, 502)) + + def _send_error(self, status: int) -> None: + reason = {403: "Forbidden", 429: "Too Many Requests", 502: "Bad Gateway"}[ + status + ] + response = ( + f"HTTP/1.1 {status} {reason}\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n\r\n" + ).encode("ascii") + try: + self.request.sendall(response) + except OSError: + pass + + +class AllowlistProxyServer(socketserver.ThreadingTCPServer): + """Threaded proxy server with a process-wide limiter.""" + + allow_reuse_address = False + daemon_threads = True + + def __init__(self, address, handler, *, limiter: RequestLimiter): + self.limiter = limiter + super().__init__(address, handler) + + def handle_error(self, request, client_address) -> None: + """Suppress default tracebacks so unexpected failures cannot leak data.""" + del request, client_address + _LOGGER.error(safe_log_line(None, 500)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--listen", default="0.0.0.0") + parser.add_argument("--port", type=int, default=3128) + parser.add_argument("--max-requests", type=int, default=256) + parser.add_argument("--minimum-interval-ms", type=int, default=25) + args = parser.parse_args() + + if not 1 <= args.port <= 65535: + parser.error("port must be between 1 and 65535") + if not 1 <= args.max_requests <= 256: + parser.error("max-requests must be between 1 and 256") + if not 25 <= args.minimum_interval_ms <= 10_000: + parser.error("minimum-interval-ms must be between 25 and 10000") + + logging.basicConfig(level=logging.INFO, format="%(message)s") + limiter = RequestLimiter( + max_requests=args.max_requests, + minimum_interval_seconds=args.minimum_interval_ms / 1000, + ) + with AllowlistProxyServer( + (args.listen, args.port), AllowlistProxyHandler, limiter=limiter + ) as server: + server.serve_forever(poll_interval=0.25) + + +if __name__ == "__main__": + main() diff --git a/tests/disposable_ha/live_canary_runner.py b/tests/disposable_ha/live_canary_runner.py new file mode 100644 index 0000000000..22bde24b60 --- /dev/null +++ b/tests/disposable_ha/live_canary_runner.py @@ -0,0 +1,230 @@ +"""One-shot, redaction-safe South Kesteven public-fixture canary runner. + +This module is intentionally not part of the normal test suite's network path. +Running ``main`` requires both a deliberate command-line acknowledgement and a +fixed environment acknowledgement. Those controls record intent for the single +plan-approved run; they are not an additional approval gate. Address values can +only come from the repository's already-public +``uk_bin_collection/tests/input.json`` entry. +""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import os +import re +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable +from urllib.parse import urlsplit + +COUNCIL_NAME = "SouthKestevenDistrictCouncil" +PUBLIC_FIXTURE_PATH = Path("/workspace/uk_bin_collection/tests/input.json") +PUBLIC_BINDAY_URL = "https://www.southkesteven.gov.uk/binday" +SELENIUM_INTERNAL_URL = "http://selenium:4444" +APPROVAL_ENVIRONMENT_VARIABLE = "UKBCD_LIVE_CANARY_APPROVED" +APPROVAL_VALUE = "one-public-fixture-lookup" +LOCK_PATH = Path("/tmp/ukbcd-live-canary-one-shot.lock") +MINIMUM_START_DELAY_SECONDS = 5.0 + +_URL_PATTERN = re.compile(r"(?i)\bhttps?://[^\s\"'<>]+") +_UK_POSTCODE_PATTERN = re.compile( + r"(?i)\b(?:GIR\s?0AA|(?:[A-Z]{1,2}[0-9][0-9A-Z]?)\s?[0-9][A-Z]{2})\b" +) +_HOUSEHOLD_IDENTIFIER_PATTERN = re.compile( + r"(?i)\b((?:uprn|usrn)\s*[:=]?\s*)[0-9]{6,15}\b" +) + + +@dataclass(frozen=True) +class PublicFixture: + """The public South Kesteven canary fields committed to input.json.""" + + postcode: str + house_number: str + url: str + web_driver: str + + +def load_public_fixture(path: Path = PUBLIC_FIXTURE_PATH) -> PublicFixture: + """Load and fail-closed validate the repository's public fixture.""" + raw = json.loads(path.read_text(encoding="utf-8")) + entry = raw.get(COUNCIL_NAME) + if not isinstance(entry, dict): + raise ValueError("public South Kesteven fixture is missing") + + postcode = entry.get("postcode") + house_number = entry.get("house_number") + url = entry.get("url") + web_driver = entry.get("web_driver") + if not all( + isinstance(value, str) and value.strip() + for value in (postcode, house_number, url, web_driver) + ): + raise ValueError("public South Kesteven fixture is incomplete") + if entry.get("skip_get_url") is not True: + raise ValueError( + "public South Kesteven fixture must skip the generic HTTP preflight" + ) + if url != PUBLIC_BINDAY_URL: + raise ValueError("public South Kesteven URL changed from the reviewed origin") + + driver = urlsplit(web_driver) + if ( + driver.scheme != "http" + or driver.hostname != "selenium" + or driver.port != 4444 + or driver.path not in {"", "/"} + or driver.query + or driver.fragment + or driver.username is not None + or driver.password is not None + ): + raise ValueError("public WebDriver fixture is not the isolated Selenium alias") + + return PublicFixture( + postcode=postcode.strip(), + house_number=house_number.strip(), + url=url, + web_driver=SELENIUM_INTERNAL_URL, + ) + + +def redact_output(value: object, fixture: PublicFixture | None = None) -> str: + """Remove household values and all URLs from text intended for output.""" + text = str(value) + text = _URL_PATTERN.sub("[REDACTED_URL]", text) + text = _UK_POSTCODE_PATTERN.sub("[REDACTED_POSTCODE]", text) + text = _HOUSEHOLD_IDENTIFIER_PATTERN.sub(r"\1[REDACTED]", text) + + if fixture is not None: + explicit_values = { + fixture.postcode, + fixture.postcode.replace(" ", ""), + fixture.house_number, + fixture.url, + fixture.web_driver, + } + for sensitive in sorted(explicit_values, key=len, reverse=True): + if not sensitive: + continue + text = re.sub(re.escape(sensitive), "[REDACTED]", text, flags=re.I) + return text + + +def parse_collection_result(result: object) -> dict[str, object]: + """Return a deliberately non-identifying summary of collector JSON.""" + payload = json.loads(result) if isinstance(result, str) else result + if not isinstance(payload, dict) or not isinstance(payload.get("bins"), list): + raise ValueError("collector did not return a bins list") + bins = payload["bins"] + for row in bins: + if not isinstance(row, dict): + raise ValueError("collector returned a malformed bin row") + if not isinstance(row.get("type"), str) or not isinstance( + row.get("collectionDate"), str + ): + raise ValueError("collector returned an incomplete bin row") + return { + "status": "passed", + "council": COUNCIL_NAME, + "logical_lookups": 1, + "bin_count": len(bins), + } + + +def _invoke_collector(fixture: PublicFixture) -> object: + """Invoke the production CLI application once using only public values.""" + from uk_bin_collection.uk_bin_collection.collect_data import UKBinCollectionApp + + app = UKBinCollectionApp() + app.set_args( + [ + COUNCIL_NAME, + fixture.url, + "--postcode", + fixture.postcode, + "--number", + fixture.house_number, + "--skip_get_url", + "--web_driver", + fixture.web_driver, + ] + ) + return app.run() + + +def reserve_one_shot(path: Path = LOCK_PATH) -> None: + """Fail if this immutable canary container already attempted its lookup.""" + descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(descriptor, "w", encoding="ascii") as lock_file: + lock_file.write("reserved\n") + + +def run_canary( + fixture: PublicFixture, + *, + invoke: Callable[[PublicFixture], object] = _invoke_collector, + sleeper: Callable[[float], None] = time.sleep, + minimum_delay_seconds: float = MINIMUM_START_DELAY_SECONDS, +) -> dict[str, object]: + """Perform exactly one deliberately delayed logical lookup.""" + if minimum_delay_seconds < MINIMUM_START_DELAY_SECONDS: + raise ValueError("live canary delay cannot be reduced below five seconds") + sleeper(minimum_delay_seconds) + + # Do not allow third-party or legacy print/log output to escape. Only the + # fixed summary below is emitted by main(). + captured = io.StringIO() + with contextlib.redirect_stdout(captured), contextlib.redirect_stderr(captured): + result = invoke(fixture) + return parse_collection_result(result) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--confirm-one-public-fixture-lookup", + action="store_true", + help="Required acknowledgement for the one plan-approved live canary.", + ) + args = parser.parse_args() + + if ( + not args.confirm_one_public_fixture_lookup + or os.environ.get(APPROVAL_ENVIRONMENT_VARIABLE) != APPROVAL_VALUE + ): + parser.error( + "live canary requires the command acknowledgement and fixed approval environment value" + ) + + fixture: PublicFixture | None = None + try: + reserve_one_shot() + fixture = load_public_fixture() + summary = run_canary(fixture) + print(json.dumps(summary, sort_keys=True)) + return 0 + except Exception as exc: + # Never emit a traceback or arbitrary response content. The class and a + # short redacted message are enough to correlate with separately-redacted + # scraper diagnostics. + message = redact_output(exc, fixture)[:240] + failure = { + "status": "failed", + "council": COUNCIL_NAME, + "logical_lookups": 0 if fixture is None else 1, + "error_type": type(exc).__name__, + "message": message, + } + print(json.dumps(failure, sort_keys=True)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/disposable_ha/live_canary_safety_check.py b/tests/disposable_ha/live_canary_safety_check.py new file mode 100644 index 0000000000..8a382cc61b --- /dev/null +++ b/tests/disposable_ha/live_canary_safety_check.py @@ -0,0 +1,685 @@ +"""Validate the live-canary topology before any container is started. + +Unlike the fully-offline HA pod, this canary uses two rootless Podman networks: +runner/Selenium are attached only to an internal network, while the minimal +allowlist proxy is the sole dual-homed container. The validator is intentionally +strict and writes only topology metadata, never fixture values or container logs. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +from pathlib import Path + +PROXY_SERVER_ARGUMENT = "--proxy-server=http://ukbcd-live-proxy:3128" +# Chromium has an implicit loopback bypass. The special <-loopback> token +# subtracts it, leaving no hosts outside the proxy policy. +PROXY_NO_BYPASS_ARGUMENT = "--proxy-bypass-list=<-loopback>" +APPROVAL_ENVIRONMENT_VARIABLE = "UKBCD_LIVE_CANARY_APPROVED" +APPROVAL_VALUE = "one-public-fixture-lookup" +HOST_NAMESPACE_FIELDS = ( + "PidMode", + "IpcMode", + "UsernsMode", + "UTSMode", + "CgroupnsMode", + "CgroupMode", +) +RUNNER_PROCESS = ( + "python", + ( + "/opt/ukbcd/live_canary_runner.py", + "--confirm-one-public-fixture-lookup", + ), +) +PROXY_PROCESS = ( + "python3", + ( + "/opt/ukbcd/live_allowlist_proxy.py", + "--max-requests", + "256", + "--minimum-interval-ms", + "25", + ), +) +_SHA256_PATTERN = re.compile(r"^(?:sha256:)?([0-9a-fA-F]{64})$") +_CAPABILITY_PATTERN = re.compile(r"^(?:CAP_)?[A-Z][A-Z0-9_]*$") +SENSITIVE_ENVIRONMENT_NAME_MARKERS = frozenset( + { + "token", + "password", + "passwd", + "secret", + "credential", + "postcode", + "house_number", + "housenumber", + "house_name", + "housename", + "paon", + "uprn", + "usrn", + "address", + } +) + + +def _podman_json(*arguments: str): + result = subprocess.run( + ["podman", *arguments], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def _inspect_container(name: str) -> dict: + return _podman_json("inspect", name)[0] + + +def _inspect_image(image_id: str) -> dict: + return _podman_json("image", "inspect", image_id)[0] + + +def _inspect_network(name: str) -> dict: + return _podman_json("network", "inspect", name)[0] + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def _is_internal(network: dict) -> bool: + return bool(network.get("internal", network.get("Internal", False))) + + +def _is_rootless(info: dict) -> bool: + reported_values: list[object] = [] + if "rootless" in info.get("host", {}).get("security", {}): + reported_values.append(info["host"]["security"]["rootless"]) + if "Rootless" in info.get("Host", {}).get("Security", {}): + reported_values.append(info["Host"]["Security"]["Rootless"]) + return bool(reported_values) and all(value is True for value in reported_values) + + +def _attached_networks(container: dict) -> set[str]: + return set(container.get("NetworkSettings", {}).get("Networks", {})) + + +def _environment(container: dict) -> set[str]: + return set(container.get("Config", {}).get("Env") or []) + + +def _environment_mapping(subject: dict) -> dict[str, str]: + environment: dict[str, str] = {} + for raw_value in subject.get("Config", {}).get("Env") or []: + variable_name, separator, value = str(raw_value).partition("=") + if variable_name: + environment[variable_name] = value if separator else "" + return environment + + +def _is_sensitive_environment_name(variable_name: str) -> bool: + normalized_name = variable_name.casefold() + return any( + marker in normalized_name for marker in SENSITIVE_ENVIRONMENT_NAME_MARKERS + ) + + +def _require_no_sensitive_environment_overrides( + name: str, container: dict, image: dict +) -> None: + """Reject sensitive runtime env not identically baked into the reviewed image.""" + container_environment = _environment_mapping(container) + reviewed_environment = _environment_mapping(image) + sensitive_overrides = sorted( + variable_name + for variable_name, value in container_environment.items() + if _is_sensitive_environment_name(variable_name) + and reviewed_environment.get(variable_name) != value + ) + _require( + not sensitive_overrides, + f"{name} receives a sensitive environment override: {sensitive_overrides}", + ) + + +def _network_members(network: dict) -> set[str]: + """Extract actual container names from a Podman network-inspect result.""" + raw_members = network.get("containers", network.get("Containers")) + if isinstance(raw_members, dict): + rows = raw_members.values() + elif isinstance(raw_members, list): + rows = raw_members + else: + return set() + + members: set[str] = set() + for row in rows: + if not isinstance(row, dict): + continue + name = row.get("name", row.get("Name")) + if isinstance(name, str) and name: + members.add(name) + return members + + +def _all_container_names() -> set[str]: + """Return every local container name from Podman's JSON inventory.""" + rows = _podman_json("ps", "--all", "--format", "json") + _require( + isinstance(rows, list), "Podman returned a malformed container inventory" + ) + + names: set[str] = set() + for row in rows: + _require(isinstance(row, dict), "Podman returned a malformed container row") + raw_names = row.get("Names", row.get("names")) + if isinstance(raw_names, str): + row_names = [raw_names] + elif isinstance(raw_names, list) and all( + isinstance(name, str) for name in raw_names + ): + row_names = raw_names + else: + raise RuntimeError("Podman returned malformed container names") + names.update(name for name in row_names if name) + return names + + +def _planned_network_members(network_name: str) -> set[str]: + """Resolve unstarted network intent from every container inspect record. + + Podman 4.9 does not populate ``network inspect``'s container map until a + container starts. Scanning the complete local inventory retains exact + extra-container detection while the reviewed application containers remain + in the required ``created`` state. + """ + return { + name + for name in _all_container_names() + if network_name in _attached_networks(_inspect_container(name)) + } + + +def _validated_network_members( + network_name: str, network: dict, expected: set[str] +) -> tuple[set[str], set[str], str]: + """Validate exact planned membership and reconcile runtime membership.""" + planned = _planned_network_members(network_name) + reported = _network_members(network) + _require_exact_members(network_name, planned, expected) + if reported: + _require_exact_members(f"{network_name} runtime", reported, planned) + source = "network-inspect-and-container-inspect" + else: + source = "all-container-inspect-unstarted-intent" + return planned, reported, source + + +def _require_exact_members(boundary: str, actual: set[str], expected: set[str]) -> None: + missing = expected - actual + extra = actual - expected + _require( + not missing and not extra, + f"{boundary} membership mismatch; missing={sorted(missing)} extra={sorted(extra)}", + ) + + +def _normalise_capability(value: object) -> str: + capability = str(value).strip().upper() + return capability.removeprefix("CAP_") + + +def _parse_capabilities(value: object) -> frozenset[str]: + """Parse one concrete capability universe reported by Podman.""" + if isinstance(value, str): + raw_capabilities = [] if not value.strip() else value.split(",") + elif isinstance(value, list) and all(isinstance(item, str) for item in value): + raw_capabilities = value + else: + raise RuntimeError("Podman reports malformed rootless capabilities") + + capabilities: set[str] = set() + for raw_capability in raw_capabilities: + normalized_input = raw_capability.strip().upper() + _require( + bool(_CAPABILITY_PATTERN.fullmatch(normalized_input)), + "Podman reports malformed rootless capabilities", + ) + capability = _normalise_capability(normalized_input) + _require( + capability != "ALL", + "Podman rootless capability universe must contain concrete names", + ) + capabilities.add(capability) + return frozenset(capabilities) + + +def _rootless_capabilities(info: dict) -> frozenset[str]: + """Return the exact capability universe advertised by the rootless engine.""" + reported: list[frozenset[str]] = [] + lower_security = info.get("host", {}).get("security", {}) + if "capabilities" in lower_security: + reported.append(_parse_capabilities(lower_security["capabilities"])) + upper_security = info.get("Host", {}).get("Security", {}) + if "Capabilities" in upper_security: + reported.append(_parse_capabilities(upper_security["Capabilities"])) + + _require(bool(reported), "Podman does not report rootless capabilities") + _require( + all(value == reported[0] for value in reported[1:]), + "Podman reports conflicting rootless capability universes", + ) + return reported[0] + + +def _create_command_requests_cap_drop_all(container: dict) -> bool: + command = container.get("Config", {}).get("CreateCommand") or [] + if not isinstance(command, list): + return False + tokens = [str(token).casefold() for token in command] + if "--cap-drop=all" in tokens: + return True + return any( + token == "--cap-drop" and index + 1 < len(tokens) and tokens[index + 1] == "all" + for index, token in enumerate(tokens) + ) + + +def _cap_drop_covers_available_set( + container: dict, available_capabilities: frozenset[str] +) -> bool: + host = container.get("HostConfig", {}) + raw_drops = host.get("CapDrop") + _require( + isinstance(raw_drops, list) + and all(isinstance(value, str) for value in raw_drops), + "Podman reports malformed dropped capabilities", + ) + drops: set[str] = set() + for value in raw_drops: + normalized_input = value.strip().upper() + _require( + bool(_CAPABILITY_PATTERN.fullmatch(normalized_input)), + "Podman reports malformed dropped capabilities", + ) + drops.add(_normalise_capability(normalized_input)) + return "ALL" in drops or available_capabilities <= drops + + +def _require_cap_drop_all( + name: str, container: dict, available_capabilities: frozenset[str] +) -> None: + cap_add = container.get("HostConfig", {}).get("CapAdd") + _require( + isinstance(cap_add, list), + f"{name} reports malformed added capabilities", + ) + _require( + _create_command_requests_cap_drop_all(container), + f"{name} was not created with --cap-drop all", + ) + _require( + not cap_add, + f"{name} adds Linux capabilities", + ) + _require( + _cap_drop_covers_available_set(container, available_capabilities), + f"{name} does not report the complete rootless capability drop set", + ) + + +def _host_namespace_modes(container: dict) -> dict[str, str]: + host = container.get("HostConfig", {}) + return { + field: str(host[field]) + for field in HOST_NAMESPACE_FIELDS + if field in host + and str(host[field]).strip().casefold().split(":", 1)[0] == "host" + } + + +def _require_no_host_namespaces(name: str, container: dict) -> None: + host_modes = _host_namespace_modes(container) + _require( + not host_modes, + f"{name} joins host namespaces: {sorted(host_modes)}", + ) + + +def _normalise_image_id(value: object) -> str | None: + text = str(value).strip() + match = _SHA256_PATTERN.fullmatch(text) + if not match: + return None + return f"sha256:{match.group(1).lower()}" + + +def _inspected_image_ids(container: dict) -> set[str]: + identifiers: set[str] = set() + for field in ("Image", "ImageID", "ImageDigest", "Digest"): + if field not in container: + continue + normalized = _normalise_image_id(container[field]) + if normalized is not None: + identifiers.add(normalized) + return identifiers + + +def _require_expected_image(name: str, container: dict, expected: str) -> str: + normalized_expected = _normalise_image_id(expected) + _require( + normalized_expected is not None, + f"{name} expected image must be a full immutable SHA-256 ID or digest", + ) + actual_ids = _inspected_image_ids(container) + _require(bool(actual_ids), f"{name} inspect data has no immutable image ID") + _require( + normalized_expected in actual_ids, + f"{name} immutable image ID does not match the reviewed value", + ) + return normalized_expected + + +def _require_exact_process( + name: str, + container: dict, + expected: tuple[str, tuple[str, ...]], +) -> None: + actual = (str(container.get("Path", "")), tuple(container.get("Args") or [])) + _require(actual == expected, f"{name} entrypoint or arguments differ from policy") + + +def _process_sha256(container: dict) -> str: + payload = { + "path": str(container.get("Path", "")), + "args": [str(value) for value in container.get("Args") or []], + } + serialized = json.dumps( + payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + return f"sha256:{hashlib.sha256(serialized).hexdigest()}" + + +def _require_expected_process(name: str, container: dict, expected: str) -> str: + normalized_expected = _normalise_image_id(expected) + _require( + normalized_expected is not None, + f"{name} expected process must be a full SHA-256 digest", + ) + actual = _process_sha256(container) + _require( + actual == normalized_expected, + f"{name} entrypoint or arguments differ from the reviewed payload", + ) + return actual + + +def _reviewed_image_process_sha256(image: dict) -> str: + config = image.get("Config", {}) + entrypoint = config.get("Entrypoint") or [] + command = config.get("Cmd") or [] + _require( + isinstance(entrypoint, list) + and all(isinstance(value, str) for value in entrypoint) + and isinstance(command, list) + and all(isinstance(value, str) for value in command), + "reviewed Selenium image process metadata is malformed", + ) + if entrypoint: + process = [*entrypoint, *command] + else: + # Podman 4.9 represents a Cmd-only image with the command's first value + # as Path and the complete command (including argv[0]) as Args. Bind to + # that exact inspected representation instead of assuming Docker's + # Path/Args normalization. + process = [command[0], *command] if command else [] + _require( + bool(process) and bool(process[0].strip()), + "reviewed Selenium image has no default process", + ) + return _process_sha256({"Path": process[0], "Args": process[1:]}) + + +def _validate_container( + name: str, + container: dict, + *, + expected_networks: set[str], + available_capabilities: frozenset[str], +) -> dict: + host = container["HostConfig"] + _require(container["State"]["Status"] == "created", f"{name} already started") + _require(not container.get("Pod"), f"{name} unexpectedly belongs to a pod") + _require(not host["Privileged"], f"{name} is privileged") + _require(host["ReadonlyRootfs"], f"{name} root filesystem is writable") + _require(not host.get("PortBindings"), f"{name} publishes a host port") + _require(not host.get("PublishAllPorts"), f"{name} publishes all ports") + _require(not host.get("Devices"), f"{name} receives a host device") + _require_cap_drop_all(name, container, available_capabilities) + _require_no_host_namespaces(name, container) + _require( + "no-new-privileges" in host.get("SecurityOpt", []), + f"{name} permits privilege escalation", + ) + _require( + host.get("RestartPolicy", {}).get("Name") in ("", "no"), + f"{name} has a restart policy", + ) + _require(host.get("PidsLimit", 0) > 0, f"{name} has no PID limit") + _require(host.get("NanoCpus", 0) > 0, f"{name} has no CPU limit") + _require(host.get("Memory", 0) > 0, f"{name} has no memory limit") + _require(not container.get("Mounts"), f"{name} has a mount") + _require( + host.get("NetworkMode") not in {"host", "none"} + and not str(host.get("NetworkMode", "")).startswith("container:"), + f"{name} uses an unexpected network namespace", + ) + + networks = _attached_networks(container) + _require( + networks == expected_networks, + f"{name} networks differ from policy: {sorted(networks)}", + ) + + serialized = json.dumps(container).casefold() + _require("podman.sock" not in serialized, f"{name} receives the Podman socket") + _require("docker.sock" not in serialized, f"{name} receives the Docker socket") + _require("/mnt/" not in serialized, f"{name} references a Windows-drive mount") + + return { + "state": "created", + "read_only_rootfs": True, + "privileged": False, + "networks": sorted(networks), + "published_ports": 0, + "mounts": 0, + "devices": 0, + "capabilities_added": 0, + "capabilities_dropped": "all", + "rootless_capabilities_dropped": sorted(available_capabilities), + "cap_drop_all": True, + "host_namespaces": [], + "no_new_privileges": True, + "pid_limit": host["PidsLimit"], + "cpu_limit": host["NanoCpus"] / 1_000_000_000, + "memory_limit_bytes": host["Memory"], + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--internal-network", required=True) + parser.add_argument("--egress-network", required=True) + parser.add_argument("--runner", required=True) + parser.add_argument("--selenium", required=True) + parser.add_argument("--proxy", required=True) + parser.add_argument("--expected-proxy-image", required=True) + parser.add_argument("--expected-selenium-image", required=True) + parser.add_argument("--expected-runner-image", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + info = _podman_json("info", "--format", "json") + _require(_is_rootless(info), "Podman is not running rootless") + available_capabilities = _rootless_capabilities(info) + + internal = _inspect_network(args.internal_network) + egress = _inspect_network(args.egress_network) + _require(_is_internal(internal), "runner/Selenium network is not internal") + _require(not _is_internal(egress), "proxy egress network is marked internal") + _require( + internal.get("driver", internal.get("Driver")) == "bridge", + "runner/Selenium network is not a rootless bridge", + ) + _require( + egress.get("driver", egress.get("Driver")) == "bridge", + "proxy egress network is not a rootless bridge", + ) + internal_members, internal_reported_members, internal_membership_source = ( + _validated_network_members( + args.internal_network, + internal, + {args.runner, args.selenium, args.proxy}, + ) + ) + egress_members, egress_reported_members, egress_membership_source = ( + _validated_network_members( + args.egress_network, + egress, + {args.proxy}, + ) + ) + + runner = _inspect_container(args.runner) + selenium = _inspect_container(args.selenium) + proxy = _inspect_container(args.proxy) + image_ids = { + args.runner: _require_expected_image( + args.runner, runner, args.expected_runner_image + ), + args.selenium: _require_expected_image( + args.selenium, selenium, args.expected_selenium_image + ), + args.proxy: _require_expected_image( + args.proxy, proxy, args.expected_proxy_image + ), + } + containers = { + args.runner: runner, + args.selenium: selenium, + args.proxy: proxy, + } + images: dict[str, dict] = {} + for name, container in containers.items(): + images[name] = _inspect_image(image_ids[name]) + _require_no_sensitive_environment_overrides(name, container, images[name]) + checks = { + args.runner: _validate_container( + args.runner, + runner, + expected_networks={args.internal_network}, + available_capabilities=available_capabilities, + ), + args.selenium: _validate_container( + args.selenium, + selenium, + expected_networks={args.internal_network}, + available_capabilities=available_capabilities, + ), + args.proxy: _validate_container( + args.proxy, + proxy, + expected_networks={args.internal_network, args.egress_network}, + available_capabilities=available_capabilities, + ), + } + selenium_environment = _environment(selenium) + expected_proxy_environment = { + f"SE_BROWSER_ARGS_UKBCD_PROXY={PROXY_SERVER_ARGUMENT}", + f"SE_BROWSER_ARGS_UKBCD_NO_BYPASS={PROXY_NO_BYPASS_ARGUMENT}", + "SE_BROWSER_ARGS_UKBCD_BACKGROUND=--disable-background-networking", + "SE_BROWSER_ARGS_UKBCD_SYNC=--disable-sync", + "SE_BROWSER_ARGS_UKBCD_DEFAULT_APPS=--disable-default-apps", + } + _require( + expected_proxy_environment <= selenium_environment, + "Selenium does not contain the reviewed Chrome proxy arguments", + ) + bypass_values = { + value + for value in selenium_environment + if "proxy-bypass-list" in value.casefold() + } + _require( + bypass_values + == {f"SE_BROWSER_ARGS_UKBCD_NO_BYPASS={PROXY_NO_BYPASS_ARGUMENT}"}, + "Selenium contains an additional proxy bypass rule", + ) + + runner_environment = _environment(runner) + _require( + f"{APPROVAL_ENVIRONMENT_VARIABLE}={APPROVAL_VALUE}" in runner_environment, + "runner lacks the exact one-shot approval value", + ) + + _require_exact_process(args.runner, runner, RUNNER_PROCESS) + _require_exact_process(args.proxy, proxy, PROXY_PROCESS) + selenium_process_digest = _require_expected_process( + args.selenium, + selenium, + _reviewed_image_process_sha256(images[args.selenium]), + ) + checks[args.selenium]["process_sha256"] = selenium_process_digest + + total_cpu = sum(check["cpu_limit"] for check in checks.values()) + total_memory = sum(check["memory_limit_bytes"] for check in checks.values()) + _require(total_cpu <= 6, f"aggregate CPU limit exceeds 6: {total_cpu}") + _require(total_memory <= 7 * 1024**3, "aggregate memory exceeds 7 GiB") + + report = { + "status": "passed", + "rootless": True, + "rootless_capability_universe": sorted(available_capabilities), + "internal_network": { + "name": args.internal_network, + "internal": True, + "members": sorted(internal_members), + "runtime_reported_members": sorted(internal_reported_members), + "membership_source": internal_membership_source, + }, + "egress_network": { + "name": args.egress_network, + "internal": False, + "members": sorted(egress_members), + "runtime_reported_members": sorted(egress_reported_members), + "membership_source": egress_membership_source, + }, + "allowlisted_origins": [ + "selfservice.southkesteven.gov.uk", + "www.southkesteven.gov.uk", + ], + "application_containers": checks, + "immutable_image_ids": image_ids, + "aggregate_cpu_limit": total_cpu, + "aggregate_memory_limit_bytes": total_memory, + "host_ports": 0, + "host_mounts": 0, + "container_sockets": 0, + "chrome_proxy_bypass_hosts": 0, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(report, indent=2, sort_keys=True), encoding="utf-8" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/disposable_ha/migration_runner.py b/tests/disposable_ha/migration_runner.py new file mode 100644 index 0000000000..087fcc121e --- /dev/null +++ b/tests/disposable_ha/migration_runner.py @@ -0,0 +1,376 @@ +"""Verify actual HA config-entry migration and restart persistence offline.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import os +import re +import sys +import time +from pathlib import Path +from urllib.parse import quote, quote_plus + +CONFIG = Path("/config") +EVIDENCE = Path(os.environ.get("UKBCD_TEST_EVIDENCE_DIR", "/evidence")) +HA_VERSION = "2026.7.2" +ENTRY_IDS = ( + "ddddddddddddddddddddddddddddddd1", + "ddddddddddddddddddddddddddddddd2", + "ddddddddddddddddddddddddddddddd3", +) +EXPECTED_ENTITY_SUFFIXES = ( + "Fixture Bin", + "Fixture Bin_bin_type", + "Fixture Bin_calendar", + "Fixture Bin_colour", + "Fixture Bin_days_until_collection", + "Fixture Bin_next_collection_date", + "Fixture Bin_next_collection_human_readable", + "raw_json", +) +SNAPSHOT_PATH = EVIDENCE / "migration_v4_snapshot.json" +SENSITIVE_SENTINELS = ( + "Synthetic Address V1", + "Synthetic Address V2", + "Synthetic Address V3", + "Ignored legacy house value", + "Ignored legacy PAON value", +) + +COMMON_DATA = { + "council": "FixtureCouncil", + "url": "http://127.0.0.1:8081/static", + "timeout": 75, + "icon_color_mapping": "{}", +} +EXPECTED_DATA = { + ENTRY_IDS[0]: { + **COMMON_DATA, + "name": "Offline migration v1", + "update_interval": 12, + "manual_refresh_only": True, + "number": "Synthetic Address V1", + "web_driver": "http://127.0.0.1:4444", + "headless": True, + "local_browser": False, + "skip_get_url": True, + }, + ENTRY_IDS[1]: { + **COMMON_DATA, + "name": "Offline migration v2", + "update_interval": 6, + "manual_refresh_only": True, + "number": "Synthetic Address V2", + "web_driver": "http://127.0.0.1:4444/wd/hub", + "headless": False, + "local_browser": True, + "skip_get_url": True, + }, + ENTRY_IDS[2]: { + **COMMON_DATA, + "name": "Offline migration v3", + "update_interval": 12, + "manual_refresh_only": True, + "number": "Synthetic Address V3", + "web_driver": "http://127.0.0.1:4444", + "headless": True, + "local_browser": False, + "skip_get_url": True, + }, +} + + +def _wait_for_file(path: Path, timeout: float) -> Path: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.is_file() and path.stat().st_size: + return path + time.sleep(0.5) + raise RuntimeError(f"Timed out waiting for disposable file: {path.name}") + + +def _network_assertions() -> tuple[list[str], int]: + interfaces = sorted(path.name for path in Path("/sys/class/net").iterdir()) + routes = Path("/proc/net/route").read_text(encoding="utf-8") + rows = [line.split() for line in routes.splitlines()[1:] if line.split()] + default_routes = [row for row in rows if len(row) > 1 and row[1] == "00000000"] + if interfaces != ["lo"] or default_routes: + raise RuntimeError( + f"Offline network assertion failed: interfaces={interfaces}, " + f"default_routes={default_routes}" + ) + return interfaces, len(default_routes) + + +def _load_entry_projection() -> dict[str, dict]: + storage = json.loads( + _wait_for_file(CONFIG / ".storage" / "core.config_entries", 15).read_text( + encoding="utf-8" + ) + ) + return { + entry["entry_id"]: {"version": entry["version"], "data": entry["data"]} + for entry in storage["data"]["entries"] + if entry.get("entry_id") in ENTRY_IDS + } + + +def _expected_projection() -> dict[str, dict]: + return { + entry_id: {"version": 4, "data": data} + for entry_id, data in EXPECTED_DATA.items() + } + + +def _wait_for_projection(timeout: float) -> dict[str, dict]: + expected = _expected_projection() + deadline = time.monotonic() + timeout + projection: dict[str, dict] = {} + while time.monotonic() < deadline: + try: + projection = _load_entry_projection() + except (FileNotFoundError, json.JSONDecodeError, KeyError): + pass + if projection == expected: + return projection + time.sleep(0.5) + raise RuntimeError( + "Persisted config entries did not match the exact v4 migration contract: " + f"observed_entry_ids={sorted(projection)}" + ) + + +def _scrape_count() -> int: + path = EVIDENCE / "non_selenium_scrape_calls" + if not path.exists(): + return 0 + return len(path.read_text(encoding="utf-8").splitlines()) + + +def _wait_for_scrape_count(expected: int, timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _scrape_count() >= expected: + time.sleep(2) + observed = _scrape_count() + if observed != expected: + raise RuntimeError( + f"Expected {expected} fixture scrapes, observed {observed}" + ) + return + time.sleep(0.5) + raise RuntimeError( + f"Timed out waiting for {expected} fixture scrapes; observed {_scrape_count()}" + ) + + +def _entry_setup_markers() -> tuple[str, ...]: + return tuple( + f"async_setup_entry finished for entry_id={entry_id}" for entry_id in ENTRY_IDS + ) + + +def _wait_for_first_log(timeout: float) -> bytes: + path = CONFIG / "home-assistant.log" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.exists(): + content = path.read_bytes() + text = content.decode("utf-8", errors="replace") + if all(marker in text for marker in _entry_setup_markers()): + return content + time.sleep(0.5) + raise RuntimeError("Timed out waiting for first-boot HA setup markers") + + +def _wait_for_restart_log(snapshot: dict, timeout: float) -> bytes: + """Return only log bytes written after the first validated boot when possible.""" + path = CONFIG / "home-assistant.log" + prefix_size = snapshot["first_log_size"] + prefix_sha256 = snapshot["first_log_sha256"] + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.exists(): + content = path.read_bytes() + if ( + len(content) >= prefix_size + and hashlib.sha256(content[:prefix_size]).hexdigest() == prefix_sha256 + ): + current_boot = content[prefix_size:] + else: + # HA may rotate or recreate its log on restart. In that case the + # current file is the second boot's log and is safe to inspect whole. + current_boot = content + text = current_boot.decode("utf-8", errors="replace") + if all(marker in text for marker in _entry_setup_markers()): + return current_boot + time.sleep(0.5) + raise RuntimeError("Timed out waiting for second-boot HA setup markers") + + +def _entity_ids() -> set[str | None]: + storage = json.loads( + _wait_for_file(CONFIG / ".storage" / "core.entity_registry", 30).read_text( + encoding="utf-8" + ) + ) + return { + entity.get("unique_id") + for entity in storage["data"].get("entities", []) + if entity.get("platform") == "uk_bin_collection" + and any( + str(entity.get("unique_id", "")).startswith(entry_id) + for entry_id in ENTRY_IDS + ) + } + + +def _assert_entity_contract(timeout: float) -> int: + expected = { + f"{entry_id}_{suffix}" + for entry_id in ENTRY_IDS + for suffix in EXPECTED_ENTITY_SUFFIXES + } + deadline = time.monotonic() + timeout + actual: set[str | None] = set() + while time.monotonic() < deadline: + try: + actual = _entity_ids() + except (FileNotFoundError, json.JSONDecodeError, KeyError): + pass + if actual == expected: + return len(actual) + time.sleep(0.5) + raise RuntimeError( + "Migrated entries did not retain the exact sensor/calendar identity contract" + ) + + +def _redaction_variants() -> set[str]: + variants: set[str] = set() + for value in SENSITIVE_SENTINELS: + variants.update( + (value, "".join(value.split()), quote(value), quote_plus(value)) + ) + return {value for value in variants if value} + + +def _write_redacted_log(log_bytes: bytes, phase: str) -> None: + text = log_bytes.decode("utf-8", errors="replace") + redacted = text + leaked: list[str] = [] + for value in sorted(_redaction_variants(), key=len, reverse=True): + if value.casefold() in text.casefold(): + leaked.append(value) + redacted = re.sub(re.escape(value), "[REDACTED]", redacted, flags=re.IGNORECASE) + (EVIDENCE / f"migration_{phase}.redacted.log").write_text( + redacted, encoding="utf-8" + ) + if leaked: + raise RuntimeError("HA migration log contains a synthetic address sentinel") + + +def _runtime_contract() -> tuple[str, str, str]: + ha_version = importlib.metadata.version("homeassistant") + core_version = importlib.metadata.version("uk-bin-collection") + if ha_version != HA_VERSION or sys.version_info[:2] != (3, 14): + raise RuntimeError("The disposable runtime is not HA 2026.7.2/Python 3.14") + return ha_version, sys.version, core_version + + +def _run(phase: str, timeout: float) -> dict: + interfaces, default_route_count = _network_assertions() + expected_scrapes = 3 if phase == "first" else 6 + _wait_for_scrape_count(expected_scrapes, timeout) + projection = _wait_for_projection(timeout) + projection_sha256 = hashlib.sha256( + json.dumps(projection, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + entity_count = _assert_entity_contract(timeout) + + if phase == "first": + log_bytes = _wait_for_first_log(timeout) + log_text = log_bytes.decode("utf-8", errors="replace") + for entry_id in ENTRY_IDS: + marker = f"Migrated config entry {entry_id} to version 4" + if log_text.count(marker) != 1: + raise RuntimeError( + f"Expected one first-boot migration marker for {entry_id}" + ) + snapshot = { + "projection": projection, + "first_log_size": len(log_bytes), + "first_log_sha256": hashlib.sha256(log_bytes).hexdigest(), + } + SNAPSHOT_PATH.write_text( + json.dumps(snapshot, indent=2, sort_keys=True), encoding="utf-8" + ) + migration_reran = False + else: + snapshot = json.loads( + _wait_for_file(SNAPSHOT_PATH, 10).read_text(encoding="utf-8") + ) + if projection != snapshot["projection"]: + raise RuntimeError("The v4 config-entry projection changed across restart") + log_bytes = _wait_for_restart_log(snapshot, timeout) + log_text = log_bytes.decode("utf-8", errors="replace") + migration_reran = "Migrated config entry" in log_text + if migration_reran: + raise RuntimeError("A persisted v4 entry was migrated again after restart") + + integration_errors = [ + line + for line in log_text.splitlines() + if "ERROR" in line and "[custom_components.uk_bin_collection]" in line + ] + if integration_errors or "Unexpected coordinator error" in log_text: + raise RuntimeError("Home Assistant logged an unexpected migration/setup error") + _write_redacted_log(log_bytes, phase) + ha_version, python_version, core_version = _runtime_contract() + return { + "network_interfaces": interfaces, + "default_route_count": default_route_count, + "home_assistant_version": ha_version, + "python_version": python_version, + "core_package_version": core_version, + "checks": { + "source_versions": [1, 2, 3], + "persisted_target_version": 4, + "persisted_entry_count": len(projection), + "registered_entity_count": entity_count, + "fixture_scrape_calls": expected_scrapes, + "legacy_aliases_absent": True, + "boolean_fields_are_boolean": True, + "persisted_projection_sha256": projection_sha256, + "migration_reran_after_restart": migration_reran, + "projection_preserved_across_restart": phase == "restart", + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--phase", choices=("first", "restart"), required=True) + parser.add_argument("--timeout", type=float, default=180) + args = parser.parse_args() + EVIDENCE.mkdir(parents=True, exist_ok=True) + + report = {"phase": args.phase, "status": "failed"} + try: + report.update(_run(args.phase, args.timeout)) + report["status"] = "passed" + except Exception as exc: + report["failure_type"] = type(exc).__name__ + report["failure_message"] = str(exc) + raise + finally: + (EVIDENCE / f"migration_{args.phase}_report.json").write_text( + json.dumps(report, indent=2, sort_keys=True), encoding="utf-8" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/disposable_ha/offline_runner.py b/tests/disposable_ha/offline_runner.py new file mode 100644 index 0000000000..19b40892b1 --- /dev/null +++ b/tests/disposable_ha/offline_runner.py @@ -0,0 +1,353 @@ +"""Validate an offline HA run from a sibling container in the same pod.""" + +from __future__ import annotations + +import argparse +import importlib.metadata +import json +import os +import re +import sys +import time +from pathlib import Path +from urllib.error import HTTPError +from urllib.parse import quote, quote_plus +from urllib.request import urlopen + +CONFIG = Path("/config") +EVIDENCE = Path(os.environ.get("UKBCD_TEST_EVIDENCE_DIR", "/evidence")) +HA_VERSION = "2026.7.2" +REPAIR_PERSIST_TIMEOUT = 210 +SUCCESS_ENTRY_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +CONTROL_ENTRY_ID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +COLLISION_ENTRY_ID = "cccccccccccccccccccccccccccccccc" +SENSITIVE_VALUES = ("ZZ99 9ZZ", "Codex Test House") + + +def _wait_for_url(url: str, timeout: float) -> dict | str: + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + with urlopen(url, timeout=2) as response: # noqa: S310 - loopback only + body = response.read().decode("utf-8") + if response.status == 200: + try: + return json.loads(body) + except json.JSONDecodeError: + return body + except Exception as exc: # service may still be starting + last_error = exc + time.sleep(0.5) + raise RuntimeError( + f"Timed out waiting for loopback service: {type(last_error).__name__}" + ) + + +def _wait_for_log(markers: tuple[str, ...], timeout: float) -> str: + log_path = CONFIG / "home-assistant.log" + deadline = time.monotonic() + timeout + text = "" + while time.monotonic() < deadline: + if log_path.exists(): + text = log_path.read_text(encoding="utf-8", errors="replace") + if all(marker in text for marker in markers): + return text + time.sleep(1) + raise RuntimeError(f"Missing HA log markers: {markers!r}") + + +def _wait_for_file(path: Path, timeout: float) -> Path: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.is_file() and path.stat().st_size: + return path + time.sleep(0.5) + raise RuntimeError(f"Timed out waiting for disposable storage file: {path.name}") + + +def _line_count(path: Path) -> int: + if not path.exists(): + return 0 + return len(path.read_text(encoding="utf-8").splitlines()) + + +def _read_requests() -> list[dict]: + path = _wait_for_file(EVIDENCE / "fixture_requests.jsonl", 15) + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +def _storage_data(name: str, timeout: float = 30) -> dict: + path = _wait_for_file(CONFIG / ".storage" / name, timeout) + return json.loads(path.read_text(encoding="utf-8"))["data"] + + +def _assert_entity_ids(expected: set[str]) -> int: + deadline = time.monotonic() + 30 + actual: set[str | None] = set() + while time.monotonic() < deadline: + entities = _storage_data("core.entity_registry").get("entities", []) + actual = { + entity.get("unique_id") + for entity in entities + if entity.get("platform") == "uk_bin_collection" + } + if expected <= actual: + return len(actual) + time.sleep(0.5) + missing = expected - actual + raise RuntimeError( + f"Expected sensor/calendar entities were not registered: {sorted(missing)}" + ) + + +def _assert_one_dependency_repair(timeout: float) -> None: + expected_id = f"dependency_{COLLISION_ENTRY_ID}" + # HA intentionally delays registry writes created during startup by 180 + # seconds. Read the persisted registry only after that bounded delay; the + # in-memory issue is already visible to the Repairs UI immediately. + issues = _storage_data("repairs.issue_registry", timeout).get("issues", []) + # Transient HA Repairs deliberately persist only their stable domain and + # issue id; their translation metadata is regenerated in memory at startup. + matches = [ + issue + for issue in issues + if issue.get("domain") == "uk_bin_collection" + and issue.get("issue_id") == expected_id + ] + if len(matches) != 1: + raise RuntimeError( + "Expected exactly one structured dependency Repair, " + f"observed {len(matches)}" + ) + + +def _redaction_variants() -> set[str]: + variants: set[str] = set() + for value in SENSITIVE_VALUES: + variants.update( + { + value, + "".join(value.split()), + quote(value), + quote_plus(value), + } + ) + return {value for value in variants if value} + + +def _write_redacted_ha_log(log_text: str) -> None: + redacted = log_text + leaked: list[str] = [] + for value in sorted(_redaction_variants(), key=len, reverse=True): + if value.casefold() in log_text.casefold(): + leaked.append(value) + redacted = re.sub(re.escape(value), "[REDACTED]", redacted, flags=re.IGNORECASE) + (EVIDENCE / "home-assistant.redacted.log").write_text(redacted, encoding="utf-8") + if leaked: + raise RuntimeError("Home Assistant log contains a synthetic household sentinel") + + +def _assert_fixture_request_contract(mode: str) -> dict[str, int | bool]: + requests = _read_requests() + binday = [request for request in requests if request.get("path") == "/binday"] + browser = [request for request in binday if request.get("browser")] + direct = [request for request in binday if not request.get("browser")] + if len(direct) != 1: + raise RuntimeError( + f"Expected one deliberate direct-HTTP /binday request, observed {len(direct)}" + ) + if mode == "success": + if len(browser) != 1: + raise RuntimeError( + "Expected exactly one browser /binday request; skip_get_url may be broken" + ) + if not browser[0].get("custom_user_agent"): + raise RuntimeError("The configured browser user agent was not propagated") + elif browser: + raise RuntimeError("The collision run created a browser session unexpectedly") + return { + "binday_browser_requests": len(browser), + "binday_direct_requests": len(direct), + "custom_user_agent_observed": bool( + browser and browser[0].get("custom_user_agent") + ), + } + + +def _assert_no_selenium_sessions() -> None: + # Selenium 4.45 removed the legacy GET /sessions route. The supported + # status payload exposes every node slot and its current session, which is + # also a stronger cleanup assertion for a bounded one-node test grid. + status = _wait_for_url("http://127.0.0.1:4444/status", 15) + value = status.get("value") if isinstance(status, dict) else None + nodes = value.get("nodes") if isinstance(value, dict) else None + if not isinstance(nodes, list) or not value.get("ready"): + raise RuntimeError("Selenium returned an invalid status payload") + + active_sessions = 0 + for node in nodes: + slots = node.get("slots") if isinstance(node, dict) else None + if not isinstance(slots, list): + raise RuntimeError("Selenium returned an invalid node-slot payload") + active_sessions += sum( + 1 + for slot in slots + if not isinstance(slot, dict) or slot.get("session") is not None + ) + + if active_sessions: + raise RuntimeError("A Selenium session remained active after the HA lookup") + + +def _network_assertions() -> tuple[list[str], list[list[str]]]: + interfaces = sorted(path.name for path in Path("/sys/class/net").iterdir()) + routes = Path("/proc/net/route").read_text(encoding="utf-8") + route_rows = [line.split() for line in routes.splitlines()[1:] if line.split()] + default_routes = [ + row for row in route_rows if len(row) > 1 and row[1] == "00000000" + ] + if interfaces != ["lo"] or default_routes: + raise RuntimeError( + f"Offline network assertion failed: interfaces={interfaces}, " + f"default_routes={default_routes}" + ) + return interfaces, default_routes + + +def _run(mode: str, timeout: float) -> dict: + interfaces, default_routes = _network_assertions() + fixture_health = _wait_for_url("http://127.0.0.1:8081/health", 30) + selenium_status = _wait_for_url("http://127.0.0.1:4444/status", 90) + if not ( + isinstance(selenium_status, dict) + and selenium_status.get("value", {}).get("ready") + ): + raise RuntimeError("Selenium status did not report ready") + + try: + urlopen("http://127.0.0.1:8081/binday", timeout=3) # noqa: S310 + except HTTPError as exc: + direct_preflight_status = exc.code + else: + direct_preflight_status = 200 + if direct_preflight_status != 403: + raise RuntimeError("The deterministic direct-HTTP denial was not reproduced") + + if mode == "success": + markers = ( + "Initial data fetched successfully", + "Setting up UK Bin Collection Data platform", + "Setting up UK Bin Collection Calendar platform", + "async_setup_entry finished", + ) + _wait_for_log(markers, timeout) + time.sleep(12) + log_text = (CONFIG / "home-assistant.log").read_text( + encoding="utf-8", errors="replace" + ) + scrape_calls = _line_count(EVIDENCE / "south_kesteven_scrape_calls") + if scrape_calls != 1: + raise RuntimeError(f"Expected one initial scrape, observed {scrape_calls}") + integration_errors = [ + line + for line in log_text.splitlines() + if "ERROR" in line and "[custom_components.uk_bin_collection]" in line + ] + if integration_errors or "Unexpected coordinator error" in log_text: + raise RuntimeError("Home Assistant logged an unexpected integration error") + entity_count = _assert_entity_ids( + { + f"{SUCCESS_ENTRY_ID}_Black Bin", + f"{SUCCESS_ENTRY_ID}_Grey Bin", + f"{SUCCESS_ENTRY_ID}_Black Bin_calendar", + f"{SUCCESS_ENTRY_ID}_Grey Bin_calendar", + } + ) + checks = { + "south_kesteven_scrape_calls": scrape_calls, + "registered_entity_count": entity_count, + "sensor_platform_marker": markers[1] in log_text, + "calendar_platform_marker": markers[2] in log_text, + } + else: + _wait_for_log(("A Python dependency is missing or shadowed",), timeout) + time.sleep(12) + log_text = (CONFIG / "home-assistant.log").read_text( + encoding="utf-8", errors="replace" + ) + non_selenium_calls = _line_count(EVIDENCE / "non_selenium_scrape_calls") + collision_calls = _line_count(EVIDENCE / "south_kesteven_scrape_calls") + if non_selenium_calls != 1: + raise RuntimeError("The non-Selenium control entry did not remain isolated") + if collision_calls != 1: + raise RuntimeError("The collision entry retried unexpectedly") + if (EVIDENCE / "ha_poison_executed").exists(): + raise RuntimeError("The top-level shadow package executed inside HA") + _assert_one_dependency_repair(max(timeout, REPAIR_PERSIST_TIMEOUT)) + if "attempted relative import beyond top-level package" in log_text: + raise RuntimeError("The raw relative-import failure escaped into HA") + if "Unexpected coordinator error" in log_text: + raise RuntimeError("The typed dependency error reached the generic handler") + entity_count = _assert_entity_ids( + { + f"{CONTROL_ENTRY_ID}_Fixture Bin", + f"{CONTROL_ENTRY_ID}_Fixture Bin_calendar", + } + ) + checks = { + "non_selenium_scrape_calls": non_selenium_calls, + "collision_entry_calls": collision_calls, + "dependency_repair_persisted": True, + "poison_module_not_executed": True, + "registered_control_entity_count": entity_count, + "raw_import_error_absent": True, + } + + _assert_no_selenium_sessions() + checks.update(_assert_fixture_request_contract(mode)) + _write_redacted_ha_log(log_text) + + home_assistant_version = importlib.metadata.version("homeassistant") + if home_assistant_version != HA_VERSION or sys.version_info[:2] != (3, 14): + raise RuntimeError( + "The disposable runtime is not the accepted HA/Python version" + ) + + return { + "network_interfaces": interfaces, + "default_route_count": len(default_routes), + "fixture_health": fixture_health, + "selenium_ready": True, + "selenium_sessions_after_lookup": 0, + "direct_http_preflight_status": direct_preflight_status, + "home_assistant_version": home_assistant_version, + "python_version": sys.version, + "core_package_version": importlib.metadata.version("uk-bin-collection"), + "checks": checks, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--mode", choices=("success", "collision"), required=True) + parser.add_argument("--timeout", type=float, default=180) + args = parser.parse_args() + EVIDENCE.mkdir(parents=True, exist_ok=True) + + report = {"mode": args.mode, "status": "failed"} + try: + report.update(_run(args.mode, args.timeout)) + report["status"] = "passed" + except Exception as exc: + report["failure_type"] = type(exc).__name__ + report["failure_message"] = str(exc) + raise + finally: + (EVIDENCE / f"offline_{args.mode}_report.json").write_text( + json.dumps(report, indent=2, sort_keys=True), encoding="utf-8" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/disposable_ha/pod_safety_check.py b/tests/disposable_ha/pod_safety_check.py new file mode 100644 index 0000000000..cdf7d1e44d --- /dev/null +++ b/tests/disposable_ha/pod_safety_check.py @@ -0,0 +1,501 @@ +"""Fail closed unless an unstarted disposable Podman pod is tightly contained.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +from pathlib import Path + +HOST_NAMESPACE_FIELDS = ( + "PidMode", + "IpcMode", + "UsernsMode", + "UTSMode", + "CgroupnsMode", + "CgroupMode", +) +_SHA256_PATTERN = re.compile(r"^(?:sha256:)?([0-9a-fA-F]{64})$") +_CAPABILITY_PATTERN = re.compile(r"^(?:CAP_)?[A-Z][A-Z0-9_]*$") +SENSITIVE_ENVIRONMENT_NAME_MARKERS = frozenset( + { + "token", + "password", + "passwd", + "secret", + "credential", + "postcode", + "house_number", + "housenumber", + "house_name", + "housename", + "paon", + "uprn", + "usrn", + "address", + } +) + + +def _podman_json(*arguments: str): + result = subprocess.run( + ["podman", *arguments], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def _inspect(name: str) -> dict: + return _podman_json("inspect", name)[0] + + +def _inspect_image(image_id: str) -> dict: + return _podman_json("image", "inspect", image_id)[0] + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def _is_rootless(info: dict) -> bool: + reported_values: list[object] = [] + if "rootless" in info.get("host", {}).get("security", {}): + reported_values.append(info["host"]["security"]["rootless"]) + if "Rootless" in info.get("Host", {}).get("Security", {}): + reported_values.append(info["Host"]["Security"]["Rootless"]) + return bool(reported_values) and all(value is True for value in reported_values) + + +def _named_members(raw_members) -> set[str]: + """Extract names from Podman pod/network inspect member structures.""" + if isinstance(raw_members, dict): + rows = raw_members.values() + elif isinstance(raw_members, list): + rows = raw_members + else: + return set() + + members: set[str] = set() + for row in rows: + if not isinstance(row, dict): + continue + name = row.get("Name", row.get("name")) + if isinstance(name, str) and name: + members.add(name) + return members + + +def _pod_members(pod: dict) -> set[str]: + return _named_members(pod.get("Containers", pod.get("containers"))) + + +def _require_exact_members(boundary: str, actual: set[str], expected: set[str]) -> None: + missing = expected - actual + extra = actual - expected + _require( + not missing and not extra, + f"{boundary} membership mismatch; missing={sorted(missing)} extra={sorted(extra)}", + ) + + +def _normalise_capability(value: object) -> str: + capability = str(value).strip().upper() + return capability.removeprefix("CAP_") + + +def _parse_capabilities(value: object) -> frozenset[str]: + """Parse one concrete capability universe reported by Podman.""" + if isinstance(value, str): + raw_capabilities = [] if not value.strip() else value.split(",") + elif isinstance(value, list) and all(isinstance(item, str) for item in value): + raw_capabilities = value + else: + raise RuntimeError("Podman reports malformed rootless capabilities") + + capabilities: set[str] = set() + for raw_capability in raw_capabilities: + normalized_input = raw_capability.strip().upper() + _require( + bool(_CAPABILITY_PATTERN.fullmatch(normalized_input)), + "Podman reports malformed rootless capabilities", + ) + capability = _normalise_capability(normalized_input) + _require( + capability != "ALL", + "Podman rootless capability universe must contain concrete names", + ) + capabilities.add(capability) + return frozenset(capabilities) + + +def _rootless_capabilities(info: dict) -> frozenset[str]: + """Return the exact capability universe advertised by the rootless engine.""" + reported: list[frozenset[str]] = [] + lower_security = info.get("host", {}).get("security", {}) + if "capabilities" in lower_security: + reported.append(_parse_capabilities(lower_security["capabilities"])) + upper_security = info.get("Host", {}).get("Security", {}) + if "Capabilities" in upper_security: + reported.append(_parse_capabilities(upper_security["Capabilities"])) + + _require(bool(reported), "Podman does not report rootless capabilities") + _require( + all(value == reported[0] for value in reported[1:]), + "Podman reports conflicting rootless capability universes", + ) + return reported[0] + + +def _create_command_requests_cap_drop_all(container: dict) -> bool: + command = container.get("Config", {}).get("CreateCommand") or [] + if not isinstance(command, list): + return False + tokens = [str(token).casefold() for token in command] + if "--cap-drop=all" in tokens: + return True + return any( + token == "--cap-drop" and index + 1 < len(tokens) and tokens[index + 1] == "all" + for index, token in enumerate(tokens) + ) + + +def _cap_drop_covers_available_set( + container: dict, available_capabilities: frozenset[str] +) -> bool: + host = container.get("HostConfig", {}) + raw_drops = host.get("CapDrop") + _require( + isinstance(raw_drops, list) + and all(isinstance(value, str) for value in raw_drops), + "Podman reports malformed dropped capabilities", + ) + drops: set[str] = set() + for value in raw_drops: + normalized_input = value.strip().upper() + _require( + bool(_CAPABILITY_PATTERN.fullmatch(normalized_input)), + "Podman reports malformed dropped capabilities", + ) + drops.add(_normalise_capability(normalized_input)) + return "ALL" in drops or available_capabilities <= drops + + +def _require_cap_drop_all( + name: str, container: dict, available_capabilities: frozenset[str] +) -> None: + cap_add = container.get("HostConfig", {}).get("CapAdd") + _require( + isinstance(cap_add, list), + f"{name} reports malformed added capabilities", + ) + _require( + _create_command_requests_cap_drop_all(container), + f"{name} was not created with --cap-drop all", + ) + _require( + not cap_add, + f"{name} adds Linux capabilities", + ) + _require( + _cap_drop_covers_available_set(container, available_capabilities), + f"{name} does not report the complete rootless capability drop set", + ) + + +def _host_namespace_modes(container: dict) -> dict[str, str]: + host = container.get("HostConfig", {}) + return { + field: str(host[field]) + for field in HOST_NAMESPACE_FIELDS + if field in host + and str(host[field]).strip().casefold().split(":", 1)[0] == "host" + } + + +def _require_no_host_namespaces(name: str, container: dict) -> None: + host_modes = _host_namespace_modes(container) + _require( + not host_modes, + f"{name} joins host namespaces: {sorted(host_modes)}", + ) + + +def _environment_mapping(subject: dict) -> dict[str, str]: + environment: dict[str, str] = {} + for raw_value in subject.get("Config", {}).get("Env") or []: + variable_name, separator, value = str(raw_value).partition("=") + if variable_name: + environment[variable_name] = value if separator else "" + return environment + + +def _is_sensitive_environment_name(variable_name: str) -> bool: + normalized_name = variable_name.casefold() + return any( + marker in normalized_name for marker in SENSITIVE_ENVIRONMENT_NAME_MARKERS + ) + + +def _require_no_sensitive_environment_overrides( + name: str, container: dict, image: dict +) -> None: + """Reject sensitive runtime env not identically baked into the reviewed image.""" + container_environment = _environment_mapping(container) + reviewed_environment = _environment_mapping(image) + sensitive_overrides = sorted( + variable_name + for variable_name, value in container_environment.items() + if _is_sensitive_environment_name(variable_name) + and reviewed_environment.get(variable_name) != value + ) + _require( + not sensitive_overrides, + f"{name} receives a sensitive environment override: {sensitive_overrides}", + ) + + +def _normalise_sha256(value: object) -> str | None: + match = _SHA256_PATTERN.fullmatch(str(value).strip()) + if not match: + return None + return f"sha256:{match.group(1).lower()}" + + +def _inspected_image_ids(container: dict) -> set[str]: + identifiers: set[str] = set() + for field in ("Image", "ImageID", "ImageDigest", "Digest"): + if field not in container: + continue + normalized = _normalise_sha256(container[field]) + if normalized is not None: + identifiers.add(normalized) + return identifiers + + +def _require_expected_image(name: str, container: dict, expected: str) -> str: + normalized_expected = _normalise_sha256(expected) + _require( + normalized_expected is not None, + f"{name} expected image must be a full immutable SHA-256 ID or digest", + ) + actual_ids = _inspected_image_ids(container) + _require(bool(actual_ids), f"{name} inspect data has no immutable image ID") + _require( + normalized_expected in actual_ids, + f"{name} immutable image ID does not match the reviewed value", + ) + return normalized_expected + + +def _process_sha256(container: dict) -> str: + payload = { + "path": str(container.get("Path", "")), + "args": [str(value) for value in container.get("Args") or []], + } + serialized = json.dumps( + payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + return f"sha256:{hashlib.sha256(serialized).hexdigest()}" + + +def _require_expected_process(name: str, container: dict, expected: str) -> str: + normalized_expected = _normalise_sha256(expected) + _require( + normalized_expected is not None, + f"{name} expected process must be a full SHA-256 digest", + ) + actual = _process_sha256(container) + _require( + actual == normalized_expected, + f"{name} entrypoint or arguments differ from the reviewed payload", + ) + return actual + + +def _parse_name_mapping( + values: list[str], expected_names: set[str], label: str +) -> dict[str, str]: + mapping: dict[str, str] = {} + for value in values: + name, separator, expected = value.partition("=") + _require( + bool(separator and name and expected), + f"{label} values must use NAME=SHA256", + ) + _require(name not in mapping, f"duplicate {label} for {name}") + mapping[name] = expected + _require_exact_members(label, set(mapping), expected_names) + return mapping + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--pod", required=True) + parser.add_argument("--infra", required=True) + parser.add_argument("--container", action="append", default=[], required=True) + parser.add_argument("--expected-image", action="append", default=[], required=True) + parser.add_argument( + "--expected-process-sha256", action="append", default=[], required=True + ) + parser.add_argument("--allowed-volume", action="append", default=[]) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + info = _podman_json("info", "--format", "json") + _require(_is_rootless(info), "Podman is not running rootless") + available_capabilities = _rootless_capabilities(info) + + pod = _inspect(args.pod) + infra = _inspect(args.infra) + infra_id = infra["Id"] + infra_host = infra["HostConfig"] + + pod_members = _pod_members(pod) + expected_pod_members = {args.infra, *args.container} + _require_exact_members(args.pod, pod_members, expected_pod_members) + container_names = set(args.container) + expected_images = _parse_name_mapping( + args.expected_image, container_names, "expected images" + ) + expected_processes = _parse_name_mapping( + args.expected_process_sha256, + container_names, + "expected process digests", + ) + + _require(pod["State"] == "Created", "pod was started before safety validation") + _require(infra["State"]["Status"] == "created", "infra was already started") + _require( + infra["Config"]["Image"].startswith("localhost/podman-pause:"), + "infra image is not Podman's built-in pause image", + ) + _require(infra["Path"] == "/catatonit", "infra runs an unexpected process") + _require_no_host_namespaces(args.infra, infra) + _require(infra_host["NetworkMode"] == "none", "pod has a network route") + _require(not infra_host["Privileged"], "infra is privileged") + _require(not infra_host.get("PortBindings"), "infra publishes a host port") + _require(not infra_host.get("Devices"), "infra has a host device") + _require(not infra.get("Mounts"), "infra has a host or volume mount") + _require( + "no-new-privileges" in infra_host.get("SecurityOpt", []), + "infra permits privilege escalation", + ) + _require( + infra_host.get("RestartPolicy", {}).get("Name") in ("", "no"), + "infra has a restart policy", + ) + + allowed_volumes = set(args.allowed_volume) + total_cpus = 0.0 + total_memory = 0 + checks: dict[str, dict] = {} + for name in args.container: + container = _inspect(name) + host = container["HostConfig"] + image_id = _require_expected_image(name, container, expected_images[name]) + _require_no_sensitive_environment_overrides( + name, container, _inspect_image(image_id) + ) + process_digest = _require_expected_process( + name, container, expected_processes[name] + ) + _require(container["State"]["Status"] == "created", f"{name} already started") + _require(not host["Privileged"], f"{name} is privileged") + _require(host["ReadonlyRootfs"], f"{name} root filesystem is writable") + _require( + host["NetworkMode"] == f"container:{infra_id}", + f"{name} does not share the offline pod namespace", + ) + _require(not host.get("PortBindings"), f"{name} publishes a host port") + _require(not host.get("PublishAllPorts"), f"{name} publishes all ports") + _require(not host.get("Devices"), f"{name} has a host device") + _require_cap_drop_all(name, container, available_capabilities) + _require_no_host_namespaces(name, container) + _require( + "no-new-privileges" in host.get("SecurityOpt", []), + f"{name} permits privilege escalation", + ) + _require( + host.get("RestartPolicy", {}).get("Name") in ("", "no"), + f"{name} has a restart policy", + ) + _require(host.get("PidsLimit", 0) > 0, f"{name} has no PID limit") + _require(host.get("NanoCpus", 0) > 0, f"{name} has no CPU limit") + _require(host.get("Memory", 0) > 0, f"{name} has no memory limit") + + mounts = container.get("Mounts", []) + _require( + all(mount.get("Type") == "volume" for mount in mounts), + f"{name} has a bind or non-volume mount", + ) + mounted_names = {mount.get("Name") for mount in mounts} + _require( + mounted_names <= allowed_volumes, + f"{name} has an unapproved named volume: {sorted(mounted_names)}", + ) + serialized = json.dumps(container).casefold() + _require("podman.sock" not in serialized, f"{name} receives the Podman socket") + _require("docker.sock" not in serialized, f"{name} receives the Docker socket") + + total_cpus += host["NanoCpus"] / 1_000_000_000 + total_memory += host["Memory"] + checks[name] = { + "image_id": image_id, + "process_sha256": process_digest, + "read_only_rootfs": True, + "privileged": False, + "network_namespace": "offline-pod-loopback", + "published_ports": 0, + "devices": 0, + "capabilities_added": 0, + "capabilities_dropped": "all", + "rootless_capabilities_dropped": sorted(available_capabilities), + "cap_drop_all": True, + "host_namespaces": [], + "no_new_privileges": True, + "pid_limit": host["PidsLimit"], + "cpu_limit": host["NanoCpus"] / 1_000_000_000, + "memory_limit_bytes": host["Memory"], + "named_volumes": sorted(mounted_names), + "tmpfs": sorted(host.get("Tmpfs", {})), + } + + _require(total_cpus <= 6, f"aggregate CPU limit exceeds 6: {total_cpus}") + _require(total_memory <= 7 * 1024**3, "aggregate memory limit exceeds 7 GiB") + + report = { + "status": "passed", + "rootless": True, + "rootless_capability_universe": sorted(available_capabilities), + "pod": args.pod, + "pod_members": sorted(pod_members), + "pod_state_before_test": "Created", + "infra": { + "image": infra["Config"]["Image"], + "process": [infra["Path"], *infra["Args"]], + "network_mode": "none", + "published_ports": 0, + "devices": 0, + "mounts": 0, + "privileged": False, + "no_new_privileges": True, + "host_namespaces": [], + "note": "Podman's static namespace-holding pause process has no application payload.", + }, + "application_containers": checks, + "aggregate_cpu_limit": total_cpus, + "aggregate_memory_limit_bytes": total_memory, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(report, indent=2, sort_keys=True), encoding="utf-8" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/disposable_ha/prepare_config.py b/tests/disposable_ha/prepare_config.py new file mode 100644 index 0000000000..4ee17af39f --- /dev/null +++ b/tests/disposable_ha/prepare_config.py @@ -0,0 +1,198 @@ +"""Create a fresh, synthetic Home Assistant config for one offline test mode.""" + +from __future__ import annotations + +import argparse +import json +import shutil +from pathlib import Path + +CONFIG = Path("/config") +COMPONENT_SOURCE = Path("/workspace/custom_components/uk_bin_collection") + + +def _entry(entry_id: str, title: str, data: dict, *, version: int = 4) -> dict: + return { + "entry_id": entry_id, + "version": version, + "minor_version": 1, + "domain": "uk_bin_collection", + "title": title, + "data": data, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": None, + "disabled_by": None, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--mode", choices=("success", "collision", "migration"), required=True + ) + args = parser.parse_args() + + if any(CONFIG.iterdir()): + raise RuntimeError("Refusing to populate a non-empty disposable config volume") + + component_target = CONFIG / "custom_components" / "uk_bin_collection" + component_target.parent.mkdir(parents=True) + shutil.copytree(COMPONENT_SOURCE, component_target) + storage = CONFIG / ".storage" + storage.mkdir(mode=0o700) + + common = { + "manual_refresh_only": True, + "update_interval": 12, + "timeout": 75, + "icon_color_mapping": "{}", + } + if args.mode == "success": + entries = [ + _entry( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "Offline South Kesteven", + { + **common, + "name": "Offline South Kesteven", + "council": "SouthKestevenFixtureCouncil", + "url": "http://127.0.0.1:8081/binday", + "postcode": "ZZ99 9ZZ", + "number": "Codex Test House", + "web_driver": "http://127.0.0.1:4444", + "headless": True, + "skip_get_url": True, + "user_agent": "Mozilla/5.0 UKBCD-Disposable-Fixture", + }, + ) + ] + elif args.mode == "collision": + entries = [ + _entry( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "Offline non-Selenium control", + { + **common, + "name": "Offline non-Selenium control", + "council": "FixtureCouncil", + "url": "http://127.0.0.1:8081/static", + "skip_get_url": True, + }, + ), + _entry( + "cccccccccccccccccccccccccccccccc", + "Offline dependency collision", + { + **common, + "name": "Offline dependency collision", + "council": "SouthKestevenFixtureCouncil", + "url": "http://127.0.0.1:8081/binday", + "postcode": "ZZ99 9ZZ", + "number": "Codex Test House", + "web_driver": "http://127.0.0.1:4444", + "headless": True, + "skip_get_url": True, + }, + ), + ] + poison = CONFIG / "websocket" + poison.mkdir() + (poison / "__init__.py").write_text( + "import os\n" + "from pathlib import Path\n" + "Path(os.environ.get('UKBCD_TEST_EVIDENCE_DIR', '/evidence'))" + ".joinpath('ha_poison_executed').write_text('executed', encoding='utf-8')\n" + "from ..const import DOMAIN\n", + encoding="utf-8", + ) + else: + # These entries deliberately exercise every historical starting version in + # an actual HA storage file. FixtureCouncil never performs network I/O, + # and every value is synthetic or loopback-only. + migration_common = { + "council": "FixtureCouncil", + "url": "http://127.0.0.1:8081/static", + "timeout": 75, + "icon_color_mapping": "{}", + } + entries = [ + _entry( + "ddddddddddddddddddddddddddddddd1", + "Offline migration v1", + { + **migration_common, + "name": "Offline migration v1", + "house_number": "Synthetic Address V1", + "selenium_url": " http://127.0.0.1:4444/ ", + "skip_get_url": "true", + }, + version=1, + ), + _entry( + "ddddddddddddddddddddddddddddddd2", + "Offline migration v2", + { + **migration_common, + "name": "Offline migration v2", + "update_interval": 6, + "paon": "Synthetic Address V2", + "webdriver": " http://127.0.0.1:4444/wd/hub/ ", + "headless": "false", + "local_browser": "yes", + "skip_get_url": "on", + }, + version=2, + ), + _entry( + "ddddddddddddddddddddddddddddddd3", + "Offline migration v3", + { + **migration_common, + "name": "Offline migration v3", + "update_interval": None, + "manual_refresh_only": "true", + "number": "Synthetic Address V3", + "house_number": "Ignored legacy house value", + "paon": "Ignored legacy PAON value", + "web_driver": " http://127.0.0.1:4444/ ", + "selenium_url": "http://127.0.0.1:5555/", + "webdriver": "http://127.0.0.1:6666/", + "headless": True, + "local_browser": "0", + "skip_get_url": "1", + }, + version=3, + ), + ] + + store = { + "version": 1, + "minor_version": 1, + "key": "core.config_entries", + "data": {"entries": entries}, + } + (storage / "core.config_entries").write_text( + json.dumps(store, indent=2, sort_keys=True), encoding="utf-8" + ) + (CONFIG / "configuration.yaml").write_text( + """homeassistant: + name: UKBCD Offline Test + latitude: 0 + longitude: 0 + elevation: 0 + unit_system: metric + time_zone: Europe/London +logger: + default: info + logs: + custom_components.uk_bin_collection: debug +""", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/tests/disposable_ha/test_live_canary_harness.py b/tests/disposable_ha/test_live_canary_harness.py new file mode 100644 index 0000000000..9e9ce68e37 --- /dev/null +++ b/tests/disposable_ha/test_live_canary_harness.py @@ -0,0 +1,261 @@ +"""Offline-only tests for the live-canary policy and redaction helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tests.disposable_ha.fixture_server import _is_fixture_browser_user_agent +from tests.disposable_ha.live_allowlist_proxy import ( + ALLOWED_HOSTS, + ProxyRateLimitExceeded, + ProxyRequestDenied, + RequestLimiter, + parse_proxy_request, + safe_log_line, +) + + +def test_fixture_rejects_legacy_generic_get_user_agent() -> None: + legacy = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 Chrome/108.0.0.0 Safari/537.36" + ) + configured = "Mozilla/5.0 UKBCD-Disposable-Fixture" + + assert _is_fixture_browser_user_agent(legacy) is False + assert _is_fixture_browser_user_agent(configured) is True + + +from tests.disposable_ha.live_canary_runner import ( + COUNCIL_NAME, + MINIMUM_START_DELAY_SECONDS, + PUBLIC_BINDAY_URL, + PublicFixture, + load_public_fixture, + parse_collection_result, + redact_output, + run_canary, +) + + +@pytest.mark.parametrize( + "authority", + [ + "www.southkesteven.gov.uk:443", + "selfservice.southkesteven.gov.uk:443", + "www.southkesteven.gov.uk:80", + "selfservice.southkesteven.gov.uk:80", + ], +) +def test_proxy_allows_only_reviewed_connect_authorities(authority: str) -> None: + request = parse_proxy_request( + f"CONNECT {authority} HTTP/1.1\r\nHost: redacted.invalid\r\n\r\n".encode() + ) + + assert request.is_tunnel is True + assert request.host in ALLOWED_HOSTS + assert request.port in {80, 443} + assert request.forward_header is None + + +@pytest.mark.parametrize( + "target", + [ + "evil.example:443", + "www.southkesteven.gov.uk.evil.example:443", + "127.0.0.1:443", + "www.southkesteven.gov.uk:22", + "user@www.southkesteven.gov.uk:443", + "[::1]:443", + ], +) +def test_proxy_denies_non_allowlisted_connect_targets(target: str) -> None: + with pytest.raises(ProxyRequestDenied): + parse_proxy_request(f"CONNECT {target} HTTP/1.1\r\n\r\n".encode()) + + +def test_proxy_rewrites_allowed_http_but_safe_log_has_no_request_data() -> None: + sensitive_path = "/binday?postcode=NG31%208XG&number=43" + request = parse_proxy_request( + ( + "POST http://www.southkesteven.gov.uk" + f"{sensitive_path} HTTP/1.1\r\n" + "Host: attacker.invalid\r\n" + "Proxy-Authorization: secret\r\n" + "Content-Length: 0\r\n\r\n" + ).encode() + ) + + assert request.is_tunnel is False + assert request.host == "www.southkesteven.gov.uk" + assert sensitive_path.encode() in request.forward_header + assert b"attacker.invalid" not in request.forward_header + assert b"Proxy-Authorization" not in request.forward_header + + log_line = safe_log_line(request.host, 200) + assert log_line == "host=www.southkesteven.gov.uk status=200" + assert "/binday" not in log_line + assert "postcode" not in log_line + assert "NG31" not in log_line + assert "43" not in log_line + + +@pytest.mark.parametrize( + "request_line", + [ + "GET http://evil.example/path HTTP/1.1", + "GET https://www.southkesteven.gov.uk/path HTTP/1.1", + "DELETE http://www.southkesteven.gov.uk/path HTTP/1.1", + "GET /relative HTTP/1.1", + ], +) +def test_proxy_denies_unsafe_http_forms(request_line: str) -> None: + with pytest.raises(ProxyRequestDenied): + parse_proxy_request(f"{request_line}\r\nHost: ignored\r\n\r\n".encode()) + + +def test_denied_proxy_log_never_discloses_denied_hostname() -> None: + assert safe_log_line("metadata.google.internal", 403) == ( + "host=[DENIED] status=403" + ) + + +def test_proxy_limiter_paces_and_bounds_requests() -> None: + now = [10.0] + delays: list[float] = [] + + def clock() -> float: + return now[0] + + def sleeper(delay: float) -> None: + delays.append(delay) + now[0] += delay + + limiter = RequestLimiter( + max_requests=2, + minimum_interval_seconds=0.25, + clock=clock, + sleeper=sleeper, + ) + limiter.acquire() + now[0] += 0.1 + limiter.acquire() + + assert limiter.count == 2 + assert delays == pytest.approx([0.15]) + with pytest.raises(ProxyRateLimitExceeded): + limiter.acquire() + + +def test_loads_only_repository_public_fixture() -> None: + fixture_path = ( + Path(__file__).parents[2] / "uk_bin_collection" / "tests" / "input.json" + ) + fixture = load_public_fixture(fixture_path) + + source = json.loads(fixture_path.read_text(encoding="utf-8"))[COUNCIL_NAME] + assert fixture.postcode == source["postcode"] + assert fixture.house_number == source["house_number"] + assert fixture.url == source["url"] == PUBLIC_BINDAY_URL + assert fixture.web_driver == source["web_driver"] == "http://selenium:4444" + + +def test_fixture_loader_fails_closed_on_webdriver_override(tmp_path: Path) -> None: + fixture_path = tmp_path / "input.json" + fixture_path.write_text( + json.dumps( + { + COUNCIL_NAME: { + "postcode": "NG31 8XG", + "house_number": "43", + "url": PUBLIC_BINDAY_URL, + "web_driver": "http://host.containers.internal:4444", + "skip_get_url": True, + } + } + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="isolated Selenium alias"): + load_public_fixture(fixture_path) + + +def test_runner_redacts_postcode_number_identifiers_and_urls() -> None: + fixture = PublicFixture( + postcode="NG31 8XG", + house_number="43", + url=PUBLIC_BINDAY_URL, + web_driver="http://selenium:4444", + ) + raw = ( + "postcode NG31 8XG / NG318XG number 43 " + "UPRN=123456789012 and https://example.test/path?address=43" + ) + + redacted = redact_output(raw, fixture) + + assert "NG31" not in redacted + assert "NG318XG" not in redacted + assert "123456789012" not in redacted + assert "https://" not in redacted + assert "example.test" not in redacted + assert " 43" not in redacted + + +def test_result_parser_returns_only_non_identifying_counts() -> None: + summary = parse_collection_result( + json.dumps( + { + "bins": [ + {"type": "Grey Bin", "collectionDate": "20/07/2026"}, + {"type": "Food Bin", "collectionDate": "20/07/2026"}, + ] + } + ) + ) + + assert summary == { + "status": "passed", + "council": COUNCIL_NAME, + "logical_lookups": 1, + "bin_count": 2, + } + + +def test_runner_invokes_exactly_once_after_minimum_delay() -> None: + fixture = PublicFixture( + postcode="NG31 8XG", + house_number="43", + url=PUBLIC_BINDAY_URL, + web_driver="http://selenium:4444", + ) + invocations: list[PublicFixture] = [] + delays: list[float] = [] + + def invoke(value: PublicFixture) -> str: + invocations.append(value) + return json.dumps( + {"bins": [{"type": "Grey Bin", "collectionDate": "20/07/2026"}]} + ) + + summary = run_canary(fixture, invoke=invoke, sleeper=delays.append) + + assert invocations == [fixture] + assert delays == [MINIMUM_START_DELAY_SECONDS] + assert summary["logical_lookups"] == 1 + + +def test_runner_refuses_reduced_delay() -> None: + fixture = PublicFixture("NG31 8XG", "43", PUBLIC_BINDAY_URL, "http://selenium:4444") + + with pytest.raises(ValueError, match="cannot be reduced"): + run_canary( + fixture, + invoke=lambda _: None, + sleeper=lambda _: None, + minimum_delay_seconds=0, + ) diff --git a/tests/disposable_ha/test_migration_runner.py b/tests/disposable_ha/test_migration_runner.py new file mode 100644 index 0000000000..a664cc4e13 --- /dev/null +++ b/tests/disposable_ha/test_migration_runner.py @@ -0,0 +1,54 @@ +"""Unit tests for disposable Home Assistant migration validation.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + + +_RUNNER_PATH = Path(__file__).with_name("migration_runner.py") +_RUNNER_SPEC = importlib.util.spec_from_file_location( + "ukbcd_disposable_migration_runner", _RUNNER_PATH +) +assert _RUNNER_SPEC is not None and _RUNNER_SPEC.loader is not None +migration_runner = importlib.util.module_from_spec(_RUNNER_SPEC) +_RUNNER_SPEC.loader.exec_module(migration_runner) + + +def _expected_entity_ids() -> set[str]: + return { + f"{entry_id}_{suffix}" + for entry_id in migration_runner.ENTRY_IDS + for suffix in migration_runner.EXPECTED_ENTITY_SUFFIXES + } + + +def test_entity_contract_accepts_all_existing_sensor_and_calendar_ids( + monkeypatch, +) -> None: + expected = _expected_entity_ids() + monkeypatch.setattr(migration_runner, "_entity_ids", lambda: expected) + + assert migration_runner._assert_entity_contract(0.1) == 24 + + +@pytest.mark.parametrize( + "missing_suffix", + ( + "Fixture Bin", + "Fixture Bin_calendar", + "Fixture Bin_next_collection_date", + "raw_json", + ), +) +def test_entity_contract_rejects_a_missing_existing_identity( + monkeypatch, missing_suffix: str +) -> None: + expected = _expected_entity_ids() + expected.remove(f"{migration_runner.ENTRY_IDS[0]}_{missing_suffix}") + monkeypatch.setattr(migration_runner, "_entity_ids", lambda: expected) + + with pytest.raises(RuntimeError, match="identity contract"): + migration_runner._assert_entity_contract(0) diff --git a/tests/disposable_ha/test_offline_runner.py b/tests/disposable_ha/test_offline_runner.py new file mode 100644 index 0000000000..94aff9783b --- /dev/null +++ b/tests/disposable_ha/test_offline_runner.py @@ -0,0 +1,120 @@ +"""Unit tests for disposable Home Assistant result validation.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + + +# Some Home Assistant dependencies install an unrelated top-level ``tests`` +# package. Load the sibling runner directly so this isolated harness test does +# not depend on site-packages ordering. +_RUNNER_PATH = Path(__file__).with_name("offline_runner.py") +_RUNNER_SPEC = importlib.util.spec_from_file_location( + "ukbcd_disposable_offline_runner", _RUNNER_PATH +) +assert _RUNNER_SPEC is not None and _RUNNER_SPEC.loader is not None +offline_runner = importlib.util.module_from_spec(_RUNNER_SPEC) +_RUNNER_SPEC.loader.exec_module(offline_runner) + + +def test_session_cleanup_accepts_empty_selenium_status_slots(monkeypatch) -> None: + monkeypatch.setattr( + offline_runner, + "_wait_for_url", + lambda url, timeout: { + "value": { + "ready": True, + "nodes": [ + { + "slots": [ + {"session": None}, + {"session": None}, + ] + } + ], + } + }, + ) + + offline_runner._assert_no_selenium_sessions() + + +def test_session_cleanup_rejects_an_active_selenium_slot(monkeypatch) -> None: + monkeypatch.setattr( + offline_runner, + "_wait_for_url", + lambda url, timeout: { + "value": { + "ready": True, + "nodes": [ + {"slots": [{"session": {"sessionId": "synthetic"}}]} + ], + } + }, + ) + + with pytest.raises(RuntimeError, match="remained active"): + offline_runner._assert_no_selenium_sessions() + + +@pytest.mark.parametrize( + "status", + ( + None, + {}, + {"value": {"ready": False, "nodes": []}}, + {"value": {"ready": True, "nodes": [{"slots": None}]}}, + ), +) +def test_session_cleanup_rejects_malformed_status(monkeypatch, status) -> None: + monkeypatch.setattr( + offline_runner, + "_wait_for_url", + lambda url, timeout: status, + ) + + with pytest.raises(RuntimeError, match="invalid"): + offline_runner._assert_no_selenium_sessions() + + +def test_dependency_repair_waits_for_persisted_startup_registry(monkeypatch) -> None: + observed: dict[str, object] = {} + + def storage_data(name: str, timeout: float) -> dict: + observed.update(name=name, timeout=timeout) + return { + "issues": [ + { + "domain": "uk_bin_collection", + "issue_id": ( + f"dependency_{offline_runner.COLLISION_ENTRY_ID}" + ), + "is_persistent": False, + } + ] + } + + monkeypatch.setattr(offline_runner, "_storage_data", storage_data) + + offline_runner._assert_one_dependency_repair(210) + + assert observed == {"name": "repairs.issue_registry", "timeout": 210} + + +def test_dependency_repair_rejects_duplicate_matches(monkeypatch) -> None: + issue = { + "domain": "uk_bin_collection", + "issue_id": f"dependency_{offline_runner.COLLISION_ENTRY_ID}", + "is_persistent": False, + } + monkeypatch.setattr( + offline_runner, + "_storage_data", + lambda name, timeout: {"issues": [issue, issue.copy()]}, + ) + + with pytest.raises(RuntimeError, match="observed 2"): + offline_runner._assert_one_dependency_repair(210) diff --git a/tests/disposable_ha/test_runtime_requirements.py b/tests/disposable_ha/test_runtime_requirements.py new file mode 100644 index 0000000000..dec0cdc7db --- /dev/null +++ b/tests/disposable_ha/test_runtime_requirements.py @@ -0,0 +1,9 @@ +"""Tests for the candidate image's offline dependency-closure verifier.""" + +from tests.disposable_ha.verify_runtime_requirements import dependency_errors + + +def test_runtime_verifier_reports_missing_root_distribution() -> None: + assert dependency_errors("ukbcd-definitely-not-installed-7f930b9a") == [ + "missing distribution: ukbcd-definitely-not-installed-7f930b9a" + ] diff --git a/tests/disposable_ha/test_safety_validators.py b/tests/disposable_ha/test_safety_validators.py new file mode 100644 index 0000000000..5ee6fa702b --- /dev/null +++ b/tests/disposable_ha/test_safety_validators.py @@ -0,0 +1,637 @@ +"""Offline tests for fail-closed Podman safety validator decisions.""" + +from __future__ import annotations + +import pytest + +from tests.disposable_ha import live_canary_safety_check as live +from tests.disposable_ha import pod_safety_check as offline + +VALIDATORS = (offline, live) +ROOTLESS_CAPABILITIES = frozenset( + { + "CHOWN", + "DAC_OVERRIDE", + "FOWNER", + "FSETID", + "KILL", + "NET_BIND_SERVICE", + "SETFCAP", + "SETGID", + "SETPCAP", + "SETUID", + "SYS_CHROOT", + } +) + +SENSITIVE_ENVIRONMENT_NAMES = ( + "HA_TOKEN", + "ADMIN_PASSWORD", + "API_SECRET", + "CLIENT_CREDENTIAL", + "POSTCODE", + "HOUSE_NUMBER", + "HOUSE_NAME", + "PAON", + "UPRN", + "USRN", + "PROPERTY_ADDRESS", +) + + +def _container_with_drops(drops: list[str], command: list[str] | None = None) -> dict: + return { + "Config": { + "CreateCommand": ( + command + if command is not None + else ["podman", "create", "--cap-drop", "all", "image"] + ), + }, + "HostConfig": {"CapAdd": [], "CapDrop": drops}, + } + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_rejects_rootful_podman(validator) -> None: + assert validator._is_rootless({"host": {"security": {"rootless": False}}}) is False + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_accepts_both_rootless_info_schemas(validator) -> None: + assert validator._is_rootless({"host": {"security": {"rootless": True}}}) is True + assert validator._is_rootless({"Host": {"Security": {"Rootless": True}}}) is True + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_rejects_missing_or_conflicting_rootless_status(validator) -> None: + assert validator._is_rootless({}) is False + assert ( + validator._is_rootless( + { + "host": {"security": {"rootless": True}}, + "Host": {"Security": {"Rootless": False}}, + } + ) + is False + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_rejects_partial_capability_drop(validator) -> None: + partial = _container_with_drops(["CAP_CHOWN", "CAP_NET_RAW"]) + + with pytest.raises(RuntimeError, match="complete rootless capability drop set"): + validator._require_cap_drop_all("partial", partial, ROOTLESS_CAPABILITIES) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_requires_create_command_cap_drop_all(validator) -> None: + full_report = _container_with_drops( + ["ALL"], + command=["podman", "create", "--cap-drop", "NET_RAW", "image"], + ) + + with pytest.raises(RuntimeError, match="not created with --cap-drop all"): + validator._require_cap_drop_all( + "wrong-command", full_report, ROOTLESS_CAPABILITIES + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_accepts_all_sentinel_and_expanded_drop_set(validator) -> None: + validator._require_cap_drop_all( + "sentinel", _container_with_drops(["ALL"]), ROOTLESS_CAPABILITIES + ) + + expanded_runtime = ROOTLESS_CAPABILITIES | { + "AUDIT_WRITE", + "MKNOD", + "NET_RAW", + "FUTURE_CONCRETE_CAPABILITY", + } + validator._require_cap_drop_all( + "expanded-runtime", + _container_with_drops([f"CAP_{name}" for name in expanded_runtime]), + expanded_runtime, + ) + expanded = [f"CAP_{name}" for name in ROOTLESS_CAPABILITIES] + validator._require_cap_drop_all( + "expanded", + _container_with_drops( + expanded, + command=["podman", "create", "--cap-drop=ALL", "image"], + ), + ROOTLESS_CAPABILITIES, + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_uses_rootless_engine_capability_universe(validator) -> None: + actual = ",".join(f"CAP_{name}" for name in sorted(ROOTLESS_CAPABILITIES)) + assert ( + validator._rootless_capabilities( + {"host": {"security": {"capabilities": actual}}} + ) + == ROOTLESS_CAPABILITIES + ) + assert ( + validator._rootless_capabilities( + { + "Host": { + "Security": { + "Capabilities": [ + name.casefold() for name in ROOTLESS_CAPABILITIES + ] + } + } + } + ) + == ROOTLESS_CAPABILITIES + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +@pytest.mark.parametrize( + "info", + [ + {}, + {"host": {"security": {"capabilities": 7}}}, + {"host": {"security": {"capabilities": ["CAP_CHOWN", 7]}}}, + {"host": {"security": {"capabilities": "CAP_CHOWN,"}}}, + {"host": {"security": {"capabilities": "ALL"}}}, + {"host": {"security": {"capabilities": "CAP_ALL"}}}, + {"host": {"security": {"capabilities": "CAP_CHOWN;CAP_SETUID"}}}, + ], +) +def test_validator_rejects_missing_or_malformed_capability_universe( + validator, info +) -> None: + with pytest.raises(RuntimeError): + validator._rootless_capabilities(info) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_rejects_conflicting_capability_schemas(validator) -> None: + with pytest.raises(RuntimeError, match="conflicting"): + validator._rootless_capabilities( + { + "host": {"security": {"capabilities": "CAP_CHOWN"}}, + "Host": {"Security": {"Capabilities": "CAP_SETUID"}}, + } + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_rejects_missing_advertised_capability(validator) -> None: + missing_one = ROOTLESS_CAPABILITIES - {"SETUID"} + drops = [f"CAP_{name}" for name in missing_one | {"NET_RAW"}] + with pytest.raises(RuntimeError, match="complete rootless capability drop set"): + validator._require_cap_drop_all( + "missing-setuid", _container_with_drops(drops), ROOTLESS_CAPABILITIES + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_rejects_empty_drop_for_nonempty_universe(validator) -> None: + with pytest.raises(RuntimeError, match="complete rootless capability drop set"): + validator._require_cap_drop_all( + "empty-drop", _container_with_drops([]), ROOTLESS_CAPABILITIES + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_accepts_explicitly_empty_capability_universe(validator) -> None: + info = {"host": {"security": {"capabilities": ""}}} + available = validator._rootless_capabilities(info) + assert available == frozenset() + validator._require_cap_drop_all( + "already-empty", _container_with_drops([]), available + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_rejects_capability_addition(validator) -> None: + container = _container_with_drops(["ALL"]) + container["HostConfig"]["CapAdd"] = ["CAP_NET_ADMIN"] + with pytest.raises(RuntimeError, match="adds Linux capabilities"): + validator._require_cap_drop_all( + "added-capability", container, ROOTLESS_CAPABILITIES + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +@pytest.mark.parametrize("cap_add", [None, "", "CAP_NET_ADMIN"]) +def test_validator_rejects_malformed_capability_additions(validator, cap_add) -> None: + container = _container_with_drops(["ALL"]) + container["HostConfig"]["CapAdd"] = cap_add + with pytest.raises(RuntimeError, match="malformed added capabilities"): + validator._require_cap_drop_all( + "malformed-cap-add", container, ROOTLESS_CAPABILITIES + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +@pytest.mark.parametrize("cap_drop", [None, "ALL", ["CAP_CHOWN", 7], ["CAP_CHOWN,"]]) +def test_validator_rejects_malformed_capability_drops(validator, cap_drop) -> None: + container = _container_with_drops([]) + container["HostConfig"]["CapDrop"] = cap_drop + with pytest.raises(RuntimeError, match="malformed dropped capabilities"): + validator._require_cap_drop_all( + "malformed-cap-drop", container, ROOTLESS_CAPABILITIES + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +@pytest.mark.parametrize( + "field", + [ + "PidMode", + "IpcMode", + "UsernsMode", + "UTSMode", + "CgroupnsMode", + "CgroupMode", + ], +) +def test_validator_rejects_host_namespace(validator, field: str) -> None: + with pytest.raises(RuntimeError, match="joins host namespaces"): + validator._require_no_host_namespaces("unsafe", {"HostConfig": {field: "host"}}) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_allows_private_or_container_namespace(validator) -> None: + validator._require_no_host_namespaces( + "contained", + { + "HostConfig": { + "PidMode": "private", + "IpcMode": "container:infra-id", + "UsernsMode": "keep-id", + "UTSMode": "container:infra-id", + "CgroupnsMode": "private", + } + }, + ) + + +def test_live_validator_rejects_wrong_or_mutable_image_identity() -> None: + actual = "a" * 64 + container = {"Image": f"sha256:{actual}"} + + assert live._require_expected_image("runner", container, actual) == ( + f"sha256:{actual}" + ) + with pytest.raises(RuntimeError, match="does not match"): + live._require_expected_image("runner", container, "b" * 64) + with pytest.raises(RuntimeError, match="full immutable SHA-256"): + live._require_expected_image("runner", container, "candidate:latest") + + +def test_offline_validator_rejects_wrong_or_mutable_image_identity() -> None: + actual = "a" * 64 + container = {"Image": f"sha256:{actual}"} + + assert offline._require_expected_image("ha", container, actual) == ( + f"sha256:{actual}" + ) + with pytest.raises(RuntimeError, match="does not match"): + offline._require_expected_image("ha", container, "b" * 64) + with pytest.raises(RuntimeError, match="full immutable SHA-256"): + offline._require_expected_image("ha", container, "candidate:latest") + + +def test_offline_validator_binds_exact_entrypoint_and_arguments() -> None: + reviewed = { + "Path": "python3", + "Args": ["/workspace/tests/disposable_ha/offline_runner.py", "success"], + } + expected = offline._process_sha256(reviewed) + + assert offline._require_expected_process("runner", reviewed, expected) == expected + with pytest.raises(RuntimeError, match="entrypoint or arguments"): + offline._require_expected_process( + "runner", + {**reviewed, "Args": [*reviewed["Args"], "unexpected"]}, + expected, + ) + with pytest.raises(RuntimeError, match="full SHA-256"): + offline._require_expected_process("runner", reviewed, "reviewed-command") + + +def test_live_validator_binds_selenium_entrypoint_and_arguments_by_digest() -> None: + image = { + "Config": { + "Entrypoint": ["/opt/bin/entry_point.sh"], + "Cmd": ["--selenium-manager", "false"], + } + } + reviewed = { + "Path": "/opt/bin/entry_point.sh", + "Args": ["--selenium-manager", "false"], + } + expected = live._reviewed_image_process_sha256(image) + + assert live._require_expected_process("selenium", reviewed, expected) == expected + with pytest.raises(RuntimeError, match="entrypoint or arguments"): + live._require_expected_process( + "selenium", + {**reviewed, "Args": [*reviewed["Args"], "unexpected"]}, + expected, + ) + with pytest.raises(RuntimeError, match="full SHA-256"): + live._require_expected_process("selenium", reviewed, "reviewed-command") + + +def test_live_validator_normalizes_podman_cmd_only_process_metadata() -> None: + command = "/opt/bin/entry_point.sh" + image = {"Config": {"Entrypoint": None, "Cmd": [command]}} + podman_inspect = {"Path": command, "Args": [command]} + + expected = live._process_sha256(podman_inspect) + + assert live._reviewed_image_process_sha256(image) == expected + assert ( + live._require_expected_process("selenium", podman_inspect, expected) + == expected + ) + + +@pytest.mark.parametrize( + "image", + [ + {"Config": {}}, + {"Config": {"Entrypoint": [""], "Cmd": []}}, + {"Config": {"Entrypoint": "entry-point.sh", "Cmd": []}}, + ], +) +def test_live_validator_rejects_missing_or_malformed_image_process(image) -> None: + with pytest.raises(RuntimeError, match="reviewed Selenium image"): + live._reviewed_image_process_sha256(image) + + +@pytest.mark.parametrize("validator", VALIDATORS) +@pytest.mark.parametrize("environment_name", SENSITIVE_ENVIRONMENT_NAMES) +def test_validator_rejects_added_sensitive_environment_variable( + validator, environment_name: str +) -> None: + container = { + "Config": { + "Env": ["PATH=/usr/bin", f"{environment_name}=must-not-enter-container"] + } + } + image = {"Config": {"Env": ["PATH=/usr/bin"]}} + + with pytest.raises(RuntimeError, match="sensitive environment override"): + validator._require_no_sensitive_environment_overrides( + "application", container, image + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_rejects_changed_sensitive_base_image_environment(validator) -> None: + container = { + "Config": {"Env": ["PATH=/usr/bin", "SE_VNC_PASSWORD=host-supplied-secret"]} + } + image = {"Config": {"Env": ["PATH=/usr/bin", "SE_VNC_PASSWORD=reviewed-default"]}} + + with pytest.raises(RuntimeError, match="SE_VNC_PASSWORD"): + validator._require_no_sensitive_environment_overrides( + "application", container, image + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_rejects_malformed_sensitive_environment_entry(validator) -> None: + container = {"Config": {"Env": ["PATH=/usr/bin", "HA_TOKEN"]}} + image = {"Config": {"Env": ["PATH=/usr/bin"]}} + + with pytest.raises(RuntimeError, match="HA_TOKEN"): + validator._require_no_sensitive_environment_overrides( + "application", container, image + ) + + +@pytest.mark.parametrize("validator", VALIDATORS) +def test_validator_allows_unchanged_reviewed_base_image_environment(validator) -> None: + container = { + "Config": { + "Env": [ + "PATH=/usr/bin", + "SE_VNC_PASSWORD=reviewed-default", + "NORMAL_RUNTIME_SETTING=enabled", + ] + } + } + image = {"Config": {"Env": ["PATH=/usr/bin", "SE_VNC_PASSWORD=reviewed-default"]}} + + validator._require_no_sensitive_environment_overrides( + "application", container, image + ) + + +def test_offline_expected_mappings_require_every_container_exactly_once() -> None: + assert offline._parse_name_mapping( + [f"ha={'a' * 64}", f"runner={'b' * 64}"], + {"ha", "runner"}, + "expected images", + ) == {"ha": "a" * 64, "runner": "b" * 64} + + with pytest.raises(RuntimeError, match="membership mismatch"): + offline._parse_name_mapping( + [f"ha={'a' * 64}"], {"ha", "runner"}, "expected images" + ) + with pytest.raises(RuntimeError, match="duplicate"): + offline._parse_name_mapping( + [f"ha={'a' * 64}", f"ha={'b' * 64}"], + {"ha"}, + "expected images", + ) + + +@pytest.mark.parametrize( + ("name", "container", "expected"), + [ + ( + "runner", + { + "Path": "python3", + "Args": [ + "/opt/ukbcd/live_canary_runner.py", + "--confirm-one-public-fixture-lookup", + ], + }, + live.RUNNER_PROCESS, + ), + ( + "proxy", + { + "Path": "python3", + "Args": [ + "/opt/ukbcd/live_allowlist_proxy.py", + "--max-requests", + "256", + "--minimum-interval-ms", + "0", + ], + }, + live.PROXY_PROCESS, + ), + ], +) +def test_live_validator_rejects_wrong_payload_or_arguments( + name: str, + container: dict, + expected: tuple[str, tuple[str, ...]], +) -> None: + with pytest.raises(RuntimeError, match="entrypoint or arguments"): + live._require_exact_process(name, container, expected) + + +def test_live_validator_accepts_exact_runner_and_proxy_processes() -> None: + live._require_exact_process( + "runner", + {"Path": live.RUNNER_PROCESS[0], "Args": list(live.RUNNER_PROCESS[1])}, + live.RUNNER_PROCESS, + ) + live._require_exact_process( + "proxy", + {"Path": live.PROXY_PROCESS[0], "Args": list(live.PROXY_PROCESS[1])}, + live.PROXY_PROCESS, + ) + + +def test_offline_pod_members_come_from_inspect() -> None: + pod = { + "Containers": [ + {"Id": "infra-id", "Name": "infra"}, + {"Id": "runner-id", "Name": "runner"}, + ] + } + + assert offline._pod_members(pod) == {"infra", "runner"} + offline._require_exact_members( + "offline-pod", offline._pod_members(pod), {"infra", "runner"} + ) + + +@pytest.mark.parametrize( + ("actual", "expected"), + [ + ({"infra", "runner", "unexpected"}, {"infra", "runner"}), + ({"infra"}, {"infra", "runner"}), + ], +) +def test_offline_pod_rejects_extra_or_missing_member( + actual: set[str], expected: set[str] +) -> None: + with pytest.raises(RuntimeError, match="membership mismatch"): + offline._require_exact_members("offline-pod", actual, expected) + + +def test_live_network_members_come_from_network_inspect() -> None: + network = { + "containers": { + "runner-id": {"name": "runner"}, + "selenium-id": {"name": "selenium"}, + "proxy-id": {"name": "proxy"}, + } + } + + actual = live._network_members(network) + assert actual == {"runner", "selenium", "proxy"} + live._require_exact_members( + "internal-network", actual, {"runner", "selenium", "proxy"} + ) + + +def test_live_planned_members_scan_every_unstarted_container(monkeypatch) -> None: + containers = { + "runner": {"NetworkSettings": {"Networks": {"internal": {}}}}, + "selenium": {"NetworkSettings": {"Networks": {"internal": {}}}}, + "proxy": { + "NetworkSettings": {"Networks": {"internal": {}, "egress": {}}} + }, + "unrelated": {"NetworkSettings": {"Networks": {"other": {}}}}, + } + monkeypatch.setattr(live, "_all_container_names", lambda: set(containers)) + monkeypatch.setattr(live, "_inspect_container", containers.__getitem__) + + assert live._planned_network_members("internal") == { + "runner", + "selenium", + "proxy", + } + assert live._planned_network_members("egress") == {"proxy"} + + +def test_live_accepts_empty_runtime_members_for_exact_unstarted_plan( + monkeypatch, +) -> None: + expected = {"runner", "selenium", "proxy"} + monkeypatch.setattr(live, "_planned_network_members", lambda name: expected) + + planned, reported, source = live._validated_network_members( + "internal", {"containers": {}}, expected + ) + + assert planned == expected + assert reported == set() + assert source == "all-container-inspect-unstarted-intent" + + +def test_live_rejects_extra_unstarted_member_when_runtime_map_is_empty( + monkeypatch, +) -> None: + monkeypatch.setattr( + live, + "_planned_network_members", + lambda name: {"runner", "selenium", "proxy", "unexpected"}, + ) + + with pytest.raises(RuntimeError, match="membership mismatch"): + live._validated_network_members( + "internal", + {"containers": {}}, + {"runner", "selenium", "proxy"}, + ) + + +def test_live_rejects_runtime_membership_that_disagrees_with_the_plan( + monkeypatch, +) -> None: + expected = {"runner", "selenium", "proxy"} + monkeypatch.setattr(live, "_planned_network_members", lambda name: expected) + network = { + "containers": { + "runner-id": {"name": "runner"}, + "selenium-id": {"name": "selenium"}, + } + } + + with pytest.raises(RuntimeError, match="membership mismatch"): + live._validated_network_members("internal", network, expected) + + +@pytest.mark.parametrize( + ("network", "expected"), + [ + ( + { + "containers": { + "proxy-id": {"name": "proxy"}, + "unexpected-id": {"name": "unexpected"}, + } + }, + {"proxy"}, + ), + ({"containers": {}}, {"proxy"}), + ], +) +def test_live_network_rejects_extra_or_missing_member( + network: dict, expected: set[str] +) -> None: + with pytest.raises(RuntimeError, match="membership mismatch"): + live._require_exact_members( + "egress-network", live._network_members(network), expected + ) diff --git a/tests/disposable_ha/verify_runtime_requirements.py b/tests/disposable_ha/verify_runtime_requirements.py new file mode 100644 index 0000000000..d00c215c19 --- /dev/null +++ b/tests/disposable_ha/verify_runtime_requirements.py @@ -0,0 +1,69 @@ +"""Fail an offline candidate build if its installed dependency graph is incomplete.""" + +from __future__ import annotations + +import argparse +from collections import deque +from importlib import metadata + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + + +def dependency_errors(distribution_name: str) -> list[str]: + """Return missing or incompatible requirements reachable from a distribution.""" + pending = deque([distribution_name]) + visited: set[str] = set() + errors: list[str] = [] + + while pending: + requested_name = pending.popleft() + canonical_name = canonicalize_name(requested_name) + if canonical_name in visited: + continue + visited.add(canonical_name) + + try: + distribution = metadata.distribution(requested_name) + except metadata.PackageNotFoundError: + errors.append(f"missing distribution: {canonical_name}") + continue + + for raw_requirement in distribution.requires or (): + requirement = Requirement(raw_requirement) + if requirement.marker and not requirement.marker.evaluate({"extra": ""}): + continue + + required_name = canonicalize_name(requirement.name) + try: + installed_version = metadata.version(requirement.name) + except metadata.PackageNotFoundError: + errors.append(f"missing dependency: {required_name}") + continue + + if requirement.specifier and not requirement.specifier.contains( + installed_version, prereleases=True + ): + errors.append( + f"incompatible dependency: {required_name}=={installed_version}" + ) + continue + pending.append(requirement.name) + + return sorted(set(errors)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--distribution", required=True) + args = parser.parse_args() + + errors = dependency_errors(args.distribution) + if errors: + raise SystemExit("\n".join(errors)) + print(f"Installed dependency closure is complete for {args.distribution}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_pin_release_manifest.py b/tests/test_pin_release_manifest.py new file mode 100644 index 0000000000..429b3ccc29 --- /dev/null +++ b/tests/test_pin_release_manifest.py @@ -0,0 +1,192 @@ +"""Tests for fail-closed release-manifest pinning.""" + +from __future__ import annotations + +import json +import runpy +import sys +from pathlib import Path + +import pytest + +from scripts import pin_release_manifest +from scripts.pin_release_manifest import pin_exact_requirement + + +def _write_release_files(tmp_path: Path, requirement: str) -> tuple[Path, Path]: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[tool.poetry]\nname = "uk-bin-collection"\nversion = "1.2.3"\n', + encoding="utf-8", + ) + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + { + "domain": "uk_bin_collection", + "version": "1.2.3", + "requirements": [requirement], + } + ), + encoding="utf-8", + ) + return pyproject, manifest + + +def test_release_manifest_is_pinned_only_to_matching_core(tmp_path: Path) -> None: + pyproject, manifest = _write_release_files(tmp_path, "uk-bin-collection>=1.2.3") + + assert ( + pin_exact_requirement("1.2.3", pyproject_path=pyproject, manifest_path=manifest) + == [] + ) + assert json.loads(manifest.read_text(encoding="utf-8"))["requirements"] == [ + "uk-bin-collection==1.2.3" + ] + + +def test_check_mode_does_not_mutate_manifest(tmp_path: Path) -> None: + pyproject, manifest = _write_release_files(tmp_path, "uk-bin-collection>=1.2.3") + before = manifest.read_bytes() + + assert ( + pin_exact_requirement( + "1.2.3", + pyproject_path=pyproject, + manifest_path=manifest, + check_only=True, + ) + == [] + ) + assert manifest.read_bytes() == before + + +def test_pin_refuses_version_mismatch_or_unrelated_requirements( + tmp_path: Path, +) -> None: + pyproject, manifest = _write_release_files(tmp_path, "other-package==1.2.3") + before = manifest.read_bytes() + + errors = pin_exact_requirement( + "9.9.9", pyproject_path=pyproject, manifest_path=manifest + ) + + assert "pyproject version does not match the requested release" in errors + assert "manifest version does not match the requested release" in errors + assert any("only uk-bin-collection" in error for error in errors) + assert manifest.read_bytes() == before + + +@pytest.mark.parametrize( + "requirements", + [ + None, + "uk-bin-collection>=1.2.3", + [], + ["uk-bin-collection>=1.2.3", "other-package==1.0"], + [123], + ], + ids=[ + "missing", + "not-a-list", + "empty", + "multiple-packages", + "non-string", + ], +) +def test_pin_refuses_malformed_requirement_shapes( + tmp_path: Path, + requirements: object, +) -> None: + pyproject, manifest = _write_release_files(tmp_path, "uk-bin-collection>=1.2.3") + payload = json.loads(manifest.read_text(encoding="utf-8")) + payload["requirements"] = requirements + manifest.write_text(json.dumps(payload), encoding="utf-8") + before = manifest.read_bytes() + + errors = pin_exact_requirement( + "1.2.3", pyproject_path=pyproject, manifest_path=manifest + ) + + assert errors == [ + "manifest requirements must contain only uk-bin-collection before pinning" + ] + assert manifest.read_bytes() == before + + +@pytest.mark.parametrize( + ("argv", "expected_check_only", "expected_action"), + [ + (["--version", "1.2.3"], False, "pinned"), + (["--version", "1.2.3", "--check"], True, "can be pinned"), + ], +) +def test_main_reports_success_without_bypassing_requested_mode( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], + expected_check_only: bool, + expected_action: str, +) -> None: + calls: list[tuple[str, bool]] = [] + + def successful_pin(version: str, *, check_only: bool = False) -> list[str]: + calls.append((version, check_only)) + return [] + + monkeypatch.setattr(pin_release_manifest, "pin_exact_requirement", successful_pin) + + assert pin_release_manifest.main(argv) == 0 + captured = capsys.readouterr() + assert calls == [("1.2.3", expected_check_only)] + assert ( + captured.out == f"HA manifest {expected_action} to uk-bin-collection==1.2.3.\n" + ) + assert captured.err == "" + + +def test_main_reports_every_validation_error( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + errors = ["version mismatch", "unexpected requirement"] + + monkeypatch.setattr( + pin_release_manifest, + "pin_exact_requirement", + lambda version, *, check_only=False: errors, + ) + + assert pin_release_manifest.main(["--version", "1.2.3", "--check"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == ("ERROR: version mismatch\nERROR: unexpected requirement\n") + + +def test_module_entrypoint_runs_repository_check_without_mutating_manifest( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + manifest_before = pin_release_manifest.MANIFEST.read_bytes() + version = json.loads(manifest_before)["version"] + monkeypatch.setattr( + sys, + "argv", + [ + str(pin_release_manifest.__file__), + "--version", + version, + "--check", + ], + ) + + with pytest.raises(SystemExit) as exc_info: + runpy.run_path(str(pin_release_manifest.__file__), run_name="__main__") + + assert exc_info.value.code == 0 + assert pin_release_manifest.MANIFEST.read_bytes() == manifest_before + captured = capsys.readouterr() + assert ( + captured.out == f"HA manifest can be pinned to uk-bin-collection=={version}.\n" + ) + assert captured.err == "" diff --git a/tests/test_release_workflow_order.py b/tests/test_release_workflow_order.py new file mode 100644 index 0000000000..f8afd3da57 --- /dev/null +++ b/tests/test_release_workflow_order.py @@ -0,0 +1,44 @@ +"""Regression checks for safe core/component publication ordering.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_core_is_verified_before_manifest_bearing_branch_is_pushed() -> None: + workflow = (ROOT / ".github" / "workflows" / "bump.yml").read_text(encoding="utf-8") + + publish = workflow.index("name: Publish core to PyPI before repository writes") + verify = workflow.index( + "name: Verify published core bytes before repository writes" + ) + repository_push = workflow.index("git push --atomic origin master") + + assert publish < verify < repository_push + assert "--pypi-json dist/pypi-release.json" in workflow[verify:repository_push] + push_step = workflow[ + workflow.index("name: Push installable release commit and tag") : + ] + assert push_step.count("git push") == 1 + assert "refs/tags/${{ steps.bump.outputs.version }}" in push_step + + +def test_tag_release_cannot_publish_or_load_pypi_credentials() -> None: + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + + assert "poetry publish" not in workflow + assert "PYPI_API_KEY" not in workflow + assert "Verify pre-published exact core artifact from PyPI" in workflow + + +def test_prepublication_and_tag_rebuild_use_same_reproducible_epoch() -> None: + bump = (ROOT / ".github" / "workflows" / "bump.yml").read_text(encoding="utf-8") + release = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + + marker = "SOURCE_DATE_EPOCH=315532800" + assert bump.count(marker) == 1 + assert release.count(marker) == 1 diff --git a/tests/test_validate_release_contract.py b/tests/test_validate_release_contract.py new file mode 100644 index 0000000000..7e8a181daa --- /dev/null +++ b/tests/test_validate_release_contract.py @@ -0,0 +1,308 @@ +"""Tests for the fail-closed release contract.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import runpy +import sys +import zipfile +from pathlib import Path + +import pytest + +SCRIPT = ( + Path(__file__).resolve().parents[1] / "scripts" / "validate_release_contract.py" +) +SPEC = importlib.util.spec_from_file_location("validate_release_contract", SCRIPT) +assert SPEC and SPEC.loader +release_contract = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release_contract) + + +def _write_project( + tmp_path: Path, + *, + requirement: str, + project_version: str = "1.2.3", + manifest_version: str = "1.2.3", +) -> tuple[Path, Path]: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + f'[tool.poetry]\nversion = "{project_version}"\n', encoding="utf-8" + ) + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + { + "version": manifest_version, + "requirements": [requirement], + } + ), + encoding="utf-8", + ) + return pyproject, manifest + + +def _write_wheel(tmp_path: Path, *, version: str = "1.2.3") -> Path: + wheel = tmp_path / f"uk_bin_collection-{version}-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + f"uk_bin_collection-{version}.dist-info/METADATA", + "Metadata-Version: 2.4\n" + "Name: uk-bin-collection\n" + f"Version: {version}\n" + "Requires-Python: >=3.12,<3.15\n", + ) + return wheel + + +def test_broad_requirement_is_allowed_before_release_but_not_at_release( + tmp_path: Path, +) -> None: + pyproject, manifest = _write_project( + tmp_path, requirement="uk-bin-collection>=1.2.3" + ) + assert ( + release_contract.validate_release_metadata( + "1.2.3", + require_exact=False, + pyproject_path=pyproject, + manifest_path=manifest, + ) + == [] + ) + assert ( + "manifest must contain only" + in release_contract.validate_release_metadata( + "1.2.3", + require_exact=True, + pyproject_path=pyproject, + manifest_path=manifest, + )[0] + ) + + +def test_release_metadata_reports_core_and_component_version_mismatches( + tmp_path: Path, +) -> None: + pyproject, manifest = _write_project( + tmp_path, + requirement="uk-bin-collection==1.2.3", + project_version="1.2.2", + manifest_version="1.2.4", + ) + + assert release_contract.validate_release_metadata( + "1.2.3", + require_exact=True, + pyproject_path=pyproject, + manifest_path=manifest, + ) == [ + "pyproject version '1.2.2' does not match '1.2.3'", + "manifest version '1.2.4' does not match '1.2.3'", + ] + + +def test_wheel_metadata_must_match_release_version(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path, version="1.2.4") + errors = release_contract.validate_wheel(wheel, "1.2.3") + assert any("wheel version" in error for error in errors) + + +def test_wheel_must_contain_exactly_one_metadata_file(tmp_path: Path) -> None: + wheel = tmp_path / "missing-metadata.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("uk_bin_collection/__init__.py", "") + assert release_contract.validate_wheel(wheel, "1.2.3") == [ + "wheel must contain exactly one dist-info/METADATA; found 0" + ] + + wheel = tmp_path / "duplicate-metadata.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "one.dist-info/METADATA", + "Name: uk-bin-collection\nVersion: 1.2.3\nRequires-Python: >=3.12\n", + ) + archive.writestr( + "two.dist-info/METADATA", + "Name: uk-bin-collection\nVersion: 1.2.3\nRequires-Python: >=3.12\n", + ) + assert release_contract.validate_wheel(wheel, "1.2.3") == [ + "wheel must contain exactly one dist-info/METADATA; found 2" + ] + + +def test_wheel_must_be_a_readable_zip_archive(tmp_path: Path) -> None: + wheel = tmp_path / "invalid.whl" + wheel.write_text("not a zip archive", encoding="utf-8") + + errors = release_contract.validate_wheel(wheel, "1.2.3") + + assert len(errors) == 1 + assert errors[0].startswith(f"cannot read wheel {wheel}:") + + +def test_wheel_rejects_wrong_distribution_and_missing_python_metadata( + tmp_path: Path, +) -> None: + wheel = tmp_path / "wrong-package.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "wrong_package-1.2.3.dist-info/METADATA", + "Metadata-Version: 2.4\n" "Name: wrong_package\n" "Version: 1.2.3\n", + ) + + assert release_contract.validate_wheel(wheel, "1.2.3") == [ + "wheel distribution 'wrong_package' is not 'uk-bin-collection'", + "wheel is missing Requires-Python metadata", + ] + + +def test_pypi_digest_must_match_local_wheel(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path) + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + release_json = tmp_path / "release.json" + release_json.write_text( + json.dumps( + { + "info": {"version": "1.2.3"}, + "urls": [ + { + "filename": wheel.name, + "digests": {"sha256": digest}, + } + ], + } + ), + encoding="utf-8", + ) + assert release_contract.validate_pypi_digest(wheel, release_json, "1.2.3") == [] + + payload = json.loads(release_json.read_text(encoding="utf-8")) + payload["urls"][0]["digests"]["sha256"] = "0" * 64 + release_json.write_text(json.dumps(payload), encoding="utf-8") + assert ( + "does not match" + in release_contract.validate_pypi_digest(wheel, release_json, "1.2.3")[0] + ) + + +def test_pypi_release_json_must_be_readable(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path) + release_json = tmp_path / "release.json" + release_json.write_text("{", encoding="utf-8") + + errors = release_contract.validate_pypi_digest(wheel, release_json, "1.2.3") + + assert len(errors) == 1 + assert errors[0].startswith(f"cannot read PyPI release JSON {release_json}:") + + +def test_pypi_release_must_match_version_and_contain_the_wheel( + tmp_path: Path, +) -> None: + wheel = _write_wheel(tmp_path) + release_json = tmp_path / "release.json" + release_json.write_text( + json.dumps( + { + "info": {"version": "1.2.4"}, + "urls": [{"filename": "another-file.whl"}], + } + ), + encoding="utf-8", + ) + + assert release_contract.validate_pypi_digest(wheel, release_json, "1.2.3") == [ + "PyPI JSON version '1.2.4' does not match '1.2.3'", + f"PyPI JSON must contain exactly one {wheel.name!r}; found 0", + ] + + +def test_main_runs_all_requested_validators_and_reports_success( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + wheel = tmp_path / "candidate.whl" + release_json = tmp_path / "release.json" + calls: list[tuple[object, ...]] = [] + + def validate_metadata(version: str, *, require_exact: bool) -> list[str]: + calls.append(("metadata", version, require_exact)) + return [] + + def validate_wheel(wheel_path: Path, version: str) -> list[str]: + calls.append(("wheel", wheel_path, version)) + return [] + + def validate_digest( + wheel_path: Path, pypi_json_path: Path, version: str + ) -> list[str]: + calls.append(("pypi", wheel_path, pypi_json_path, version)) + return [] + + monkeypatch.setattr( + release_contract, "validate_release_metadata", validate_metadata + ) + monkeypatch.setattr(release_contract, "validate_wheel", validate_wheel) + monkeypatch.setattr(release_contract, "validate_pypi_digest", validate_digest) + + result = release_contract.main( + [ + "--version", + "1.2.3", + "--require-exact", + "--wheel", + str(wheel), + "--pypi-json", + str(release_json), + ] + ) + + assert result == 0 + assert calls == [ + ("metadata", "1.2.3", True), + ("wheel", wheel, "1.2.3"), + ("pypi", wheel, release_json, "1.2.3"), + ] + assert capsys.readouterr().out == ( + "Release contract valid for 1.2.3: core/component versions, " + "exact HA core requirement, wheel metadata, published wheel SHA-256.\n" + ) + + +def test_main_reports_all_errors_and_requires_a_wheel_for_pypi_json( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + release_json = tmp_path / "release.json" + monkeypatch.setattr( + release_contract, + "validate_release_metadata", + lambda version, *, require_exact: ["metadata mismatch"], + ) + + result = release_contract.main( + ["--version", "1.2.3", "--pypi-json", str(release_json)] + ) + + assert result == 1 + assert capsys.readouterr().err == ( + "ERROR: metadata mismatch\nERROR: --pypi-json requires --wheel\n" + ) + + +def test_script_entrypoint_validates_the_repository_release_contract( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = release_contract.tomllib.loads( + (SCRIPT.parents[1] / "pyproject.toml").read_text(encoding="utf-8") + ) + version = project["tool"]["poetry"]["version"] + monkeypatch.setattr(sys, "argv", [str(SCRIPT), "--version", version]) + + with pytest.raises(SystemExit) as exc_info: + runpy.run_path(str(SCRIPT), run_name="__main__") + + assert exc_info.value.code == 0 + assert capsys.readouterr().out.startswith(f"Release contract valid for {version}:") diff --git a/tests/test_workflow_actions_pinned.py b/tests/test_workflow_actions_pinned.py new file mode 100644 index 0000000000..3ead81ad80 --- /dev/null +++ b/tests/test_workflow_actions_pinned.py @@ -0,0 +1,34 @@ +"""Supply-chain checks for GitHub Actions workflow dependencies.""" + +from pathlib import Path +import re + +WORKFLOWS = Path(__file__).parents[1] / ".github" / "workflows" +USES_PATTERN = re.compile(r"\buses:\s*[\"']?([^\"'\s#]+)") +FULL_COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") + + +def test_external_actions_are_pinned_to_full_commit_shas() -> None: + """Reject mutable tags, branches, and abbreviated commit references.""" + + violations: list[str] = [] + + for workflow in sorted(WORKFLOWS.glob("*.yml")): + for line_number, line in enumerate( + workflow.read_text(encoding="utf-8").splitlines(), start=1 + ): + match = USES_PATTERN.search(line) + if not match: + continue + + action = match.group(1) + if action.startswith("./"): + continue + + _, separator, revision = action.rpartition("@") + if not separator or not FULL_COMMIT_PATTERN.fullmatch(revision): + violations.append(f"{workflow.name}:{line_number}: {action}") + + assert not violations, "External actions must use full commit SHAs:\n" + "\n".join( + violations + ) diff --git a/uk_bin_collection/tests/test_birmingham_city_council.py b/uk_bin_collection/tests/test_birmingham_city_council.py new file mode 100644 index 0000000000..5fe090870d --- /dev/null +++ b/uk_bin_collection/tests/test_birmingham_city_council.py @@ -0,0 +1,64 @@ +"""Regression tests for Birmingham's standalone runtime dependency boundary.""" + +from __future__ import annotations + +import builtins +import importlib +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + + +MODULE_NAME = ( + "uk_bin_collection.uk_bin_collection.councils.BirminghamCityCouncil" +) +COLLECTION_URL = ( + "https://www.birmingham.gov.uk/info/50388/check_your_collection_day" +) + + +def test_birmingham_import_does_not_require_yarl(monkeypatch) -> None: + """The packaged council module must use only declared runtime dependencies.""" + sys.modules.pop(MODULE_NAME, None) + real_import = builtins.__import__ + + def guarded_import(name, *args, **kwargs): + if name == "yarl" or name.startswith("yarl."): + raise ModuleNotFoundError("No module named 'yarl'", name="yarl") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + + module = importlib.import_module(MODULE_NAME) + + assert module.CouncilClass.__name__ == "CouncilClass" + + +def test_birmingham_passes_address_as_requests_query_parameters() -> None: + module = importlib.import_module(MODULE_NAME) + response = SimpleNamespace( + text='
', + raise_for_status=MagicMock(), + ) + postcode = "B1 1AA" + uprn = "100000000001" + + with ( + patch.object(module, "check_uprn"), + patch.object(module, "check_postcode"), + patch.object(module.requests, "get", return_value=response) as get, + ): + result = module.CouncilClass().parse_data( + "", + postcode=postcode, + uprn=uprn, + ) + + assert result == {"bins": []} + response.raise_for_status.assert_called_once_with() + get.assert_called_once_with( + COLLECTION_URL, + params={"postcode": postcode, "uprn": uprn}, + headers=module.HEADERS, + timeout=30, + ) diff --git a/uk_bin_collection/tests/test_collect_data.py b/uk_bin_collection/tests/test_collect_data.py index 086b9ff12f..171894f2fa 100644 --- a/uk_bin_collection/tests/test_collect_data.py +++ b/uk_bin_collection/tests/test_collect_data.py @@ -1,7 +1,37 @@ -from unittest.mock import MagicMock, patch import argparse +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + import pytest -from uk_bin_collection.collect_data import UKBinCollectionApp, import_council_module +from uk_bin_collection.uk_bin_collection import collect_data +from uk_bin_collection.uk_bin_collection.collect_data import ( + UKBinCollectionApp, +) +from uk_bin_collection.uk_bin_collection.exceptions import ( + InvalidCouncilModuleError, + MissingDependencyError, +) + + +def _configure_registered_council( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> Path: + """Configure a trusted synthetic council for loader branch tests.""" + council_file = tmp_path / "ExampleCouncil.py" + council_file.write_text("# synthetic council\n", encoding="utf-8") + monkeypatch.setattr( + collect_data, + "registered_council_modules", + lambda: frozenset({"ExampleCouncil"}), + ) + monkeypatch.setattr( + collect_data, "council_requires_selenium", lambda _module_name: False + ) + monkeypatch.setattr( + collect_data, "_council_file", lambda _module_name: council_file + ) + return council_file # Test UKBinCollectionApp setup_arg_parser @@ -21,7 +51,10 @@ def test_setup_arg_parser(): "number", "skip_get_url", "uprn", + "usrn", "web_driver", + "artifact_dir", + "user_agent", "headless", "local_browser", "dev_mode", @@ -41,6 +74,7 @@ def test_set_args(): assert app.parsed_args.module == "council_module" assert app.parsed_args.URL == "http://example.com" assert app.parsed_args.postcode == "AB1 2CD" + assert app.parsed_args.headless is True # Test UKBinCollectionApp client_code method @@ -56,11 +90,11 @@ def test_client_code(): # Test the run() function with logging setup -@patch("uk_bin_collection.collect_data.setup_logging") # Correct patch path -@patch("uk_bin_collection.collect_data.UKBinCollectionApp.run") # Correct patch path +@patch("uk_bin_collection.uk_bin_collection.collect_data.setup_logging") +@patch("uk_bin_collection.uk_bin_collection.collect_data.UKBinCollectionApp.run") @patch("sys.argv", ["uk_bin_collection.py", "council_module", "http://example.com"]) def test_run_function(mock_app_run, mock_setup_logging): - from uk_bin_collection.collect_data import run + from uk_bin_collection.uk_bin_collection.collect_data import run mock_setup_logging.return_value = MagicMock() mock_app_run.return_value = None @@ -70,3 +104,168 @@ def test_run_function(mock_app_run, mock_setup_logging): # Ensure logging was set up and the app run method was called mock_setup_logging.assert_called_once() mock_app_run.assert_called_once() + + +def test_run_propagates_all_supported_arguments(): + app = UKBinCollectionApp() + app.set_args( + [ + "ExampleCouncil", + "https://example.invalid/collections", + "--postcode=AB1 2CD", + "--number=42A", + "--uprn=100012345", + "--usrn=200012345", + "--skip_get_url", + "--web_driver=http://selenium.invalid:4444/wd/hub", + "--artifact_dir=/tmp/ukbcd-artifacts", + "--user_agent=UKBCD contract test", + "--not-headless", + "--local_browser", + "--dev_mode", + ] + ) + council = MagicMock() + module = MagicMock() + module.CouncilClass.return_value = council + + with patch( + "uk_bin_collection.uk_bin_collection.collect_data.import_council_module", + return_value=module, + ): + app.run() + + council.template_method.assert_called_once_with( + "https://example.invalid/collections", + postcode="AB1 2CD", + paon="42A", + uprn="100012345", + usrn="200012345", + skip_get_url=True, + web_driver="http://selenium.invalid:4444/wd/hub", + artifact_dir="/tmp/ukbcd-artifacts", + user_agent="UKBCD contract test", + headless=False, + local_browser=True, + dev_mode=True, + council_module_str="ExampleCouncil", + ) + + +def test_council_requires_selenium_detects_direct_import( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + council_file = tmp_path / "DirectImportCouncil.py" + council_file.write_text("import selenium.webdriver\n", encoding="utf-8") + monkeypatch.setattr( + collect_data, "_council_file", lambda _module_name: council_file + ) + + collect_data.council_requires_selenium.cache_clear() + try: + assert collect_data.council_requires_selenium("DirectImportCouncil") is True + finally: + collect_data.council_requires_selenium.cache_clear() + + +def test_council_requires_selenium_rejects_uninspectable_source( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + council_file = tmp_path / "BrokenCouncil.py" + council_file.write_text("from selenium import\n", encoding="utf-8") + monkeypatch.setattr( + collect_data, "_council_file", lambda _module_name: council_file + ) + + collect_data.council_requires_selenium.cache_clear() + try: + with pytest.raises( + InvalidCouncilModuleError, match="cannot be inspected safely" + ) as error: + collect_data.council_requires_selenium("BrokenCouncil") + finally: + collect_data.council_requires_selenium.cache_clear() + + assert isinstance(error.value.__cause__, SyntaxError) + + +def test_council_loader_wraps_selenium_resolution_errors( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _configure_registered_council(monkeypatch, tmp_path) + monkeypatch.setattr( + collect_data, "council_requires_selenium", lambda _module_name: True + ) + resolution_error = ValueError("selenium.__spec__ is invalid") + find_spec = MagicMock(side_effect=resolution_error) + import_module = MagicMock() + monkeypatch.setattr(collect_data.import_util, "find_spec", find_spec) + monkeypatch.setattr(collect_data.importlib, "import_module", import_module) + + with pytest.raises(MissingDependencyError, match="cannot safely resolve") as error: + collect_data.import_council_module("ExampleCouncil") + + assert error.value.__cause__ is resolution_error + find_spec.assert_called_once_with("selenium") + import_module.assert_not_called() + + +def test_council_loader_wraps_qualified_module_resolution_errors( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _configure_registered_council(monkeypatch, tmp_path) + resolution_error = ImportError("qualified module cannot be resolved") + find_spec = MagicMock(side_effect=resolution_error) + import_module = MagicMock() + monkeypatch.setattr(collect_data.import_util, "find_spec", find_spec) + monkeypatch.setattr(collect_data.importlib, "import_module", import_module) + + with pytest.raises( + InvalidCouncilModuleError, match="cannot be resolved safely" + ) as error: + collect_data.import_council_module("ExampleCouncil") + + assert error.value.__cause__ is resolution_error + find_spec.assert_called_once_with( + "uk_bin_collection.uk_bin_collection.councils.ExampleCouncil" + ) + import_module.assert_not_called() + + +def test_council_loader_rejects_module_loaded_from_unexpected_location( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + council_file = _configure_registered_council(monkeypatch, tmp_path) + specification = SimpleNamespace(origin=str(council_file)) + loaded_module = SimpleNamespace( + __file__=str(tmp_path / "shadow" / "ExampleCouncil.py"), + CouncilClass=object, + ) + monkeypatch.setattr( + collect_data.import_util, "find_spec", lambda _qualified_name: specification + ) + monkeypatch.setattr( + collect_data.importlib, "import_module", lambda _qualified_name: loaded_module + ) + + with pytest.raises( + InvalidCouncilModuleError, match="loaded from an unexpected location" + ): + collect_data.import_council_module("ExampleCouncil") + + +def test_council_loader_requires_council_class( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + council_file = _configure_registered_council(monkeypatch, tmp_path) + specification = SimpleNamespace(origin=str(council_file)) + loaded_module = SimpleNamespace(__file__=str(council_file)) + monkeypatch.setattr( + collect_data.import_util, "find_spec", lambda _qualified_name: specification + ) + monkeypatch.setattr( + collect_data.importlib, "import_module", lambda _qualified_name: loaded_module + ) + + with pytest.raises(InvalidCouncilModuleError, match="does not expose CouncilClass"): + collect_data.import_council_module("ExampleCouncil") diff --git a/uk_bin_collection/tests/test_common_functions.py b/uk_bin_collection/tests/test_common_functions.py index ba8f279d1d..467d2d00c9 100644 --- a/uk_bin_collection/tests/test_common_functions.py +++ b/uk_bin_collection/tests/test_common_functions.py @@ -5,24 +5,59 @@ import pytest from selenium.common.exceptions import WebDriverException -from uk_bin_collection.common import * -from urllib3.exceptions import MaxRetryError +from uk_bin_collection.uk_bin_collection import common as common_module +from uk_bin_collection.uk_bin_collection.common import * def test_check_postcode_valid(): valid_postcode = "SW1A 1AA" - result = check_postcode(valid_postcode) + response = MagicMock(status_code=200) + with patch( + "uk_bin_collection.uk_bin_collection.common.requests.get", return_value=response + ) as get: + result = check_postcode(valid_postcode) assert result is True + get.assert_called_once_with( + "https://api.postcodes.io/postcodes/SW1A 1AA", timeout=(3.05, 10) + ) def test_check_postcode_invalid(): invalid_postcode = "BADPOSTCODE" - with pytest.raises(ValueError) as exc_info: - result = check_postcode(invalid_postcode) + response = MagicMock(status_code=404) + response.json.return_value = {"error": "Invalid postcode", "status": 404} + with ( + patch( + "uk_bin_collection.uk_bin_collection.common.requests.get", + return_value=response, + ), + pytest.raises(ValueError) as exc_info, + ): + check_postcode(invalid_postcode) assert exc_info._excinfo[1].args[0] == "Exception: Invalid postcode Status: 404" assert exc_info.type == ValueError +def test_check_postcode_handles_non_json_rejection(): + response = MagicMock(status_code=503) + response.json.side_effect = ValueError("not JSON") + + with ( + patch( + "uk_bin_collection.uk_bin_collection.common.requests.get", + return_value=response, + ), + pytest.raises( + ValueError, + match=( + "Exception: Postcode validation service rejected the request " + "Status: 503" + ), + ), + ): + check_postcode("SW1A 1AA") + + def test_check_paon(): valid_house_num = "1" result = check_paon(valid_house_num) @@ -34,7 +69,7 @@ def test_check_paon_invalid(capfd): with pytest.raises(SystemExit) as exc_info: result = check_paon(invalid_house_num) out, err = capfd.readouterr() - assert out.startswith("Exception encountered: Invalid house number") + assert out.startswith("Exception encountered: ValueError") assert exc_info.type == SystemExit assert exc_info.value.code == 1 @@ -222,8 +257,8 @@ def test_get_next_occurrence_from_day_month_true(): assert result == pd.Timestamp("2024-01-01 00:00:00") -@patch("uk_bin_collection.common.load_data", return_value={}) -@patch("uk_bin_collection.common.save_data") +@patch("uk_bin_collection.uk_bin_collection.common.load_data", return_value={}) +@patch("uk_bin_collection.uk_bin_collection.common.save_data") def test_update_input_json(mock_save_data, mock_load_data): update_input_json( "test_council", @@ -248,20 +283,18 @@ def test_update_input_json(mock_save_data, mock_load_data): mock_save_data.assert_called_once_with("path/to/input.json", expected_data) -@patch("uk_bin_collection.common.load_data") -@patch("uk_bin_collection.common.save_data") +@patch("uk_bin_collection.uk_bin_collection.common.load_data") +@patch("uk_bin_collection.uk_bin_collection.common.save_data") def test_update_input_json_ioerror(mock_save_data, mock_load_data): mock_load_data.side_effect = IOError("Unable to access file") with patch("builtins.print") as mock_print: update_input_json("test_council", "TEST_URL", "path/to/input.json") - mock_print.assert_called_once_with( - "Error updating the JSON file: Unable to access file" - ) + mock_print.assert_called_once_with("Error updating the JSON file: OSError") -@patch("uk_bin_collection.common.load_data") -@patch("uk_bin_collection.common.save_data") +@patch("uk_bin_collection.uk_bin_collection.common.load_data") +@patch("uk_bin_collection.uk_bin_collection.common.save_data") def test_update_input_json_jsondecodeerror(mock_save_data, mock_load_data): mock_load_data.side_effect = json.JSONDecodeError("Expecting value", "doc", 0) @@ -295,8 +328,12 @@ def test_load_data_non_existing_file(): def test_load_data_invalid_json(): # Create a mock file with invalid JSON content mock_file_data = '{"key": "value"' - with patch("builtins.open", mock_open(read_data=mock_file_data)), patch( - "json.load", side_effect=json.JSONDecodeError("Expecting ',' delimiter", "", 0) + with ( + patch("builtins.open", mock_open(read_data=mock_file_data)), + patch( + "json.load", + side_effect=json.JSONDecodeError("Expecting ',' delimiter", "", 0), + ), ): data = load_data("path/to/invalid.json") assert data == {} # Modify based on your desired behavior @@ -359,18 +396,69 @@ def test_contains_date_with_mixed_content(): def test_create_webdriver_local(): - result = create_webdriver( - None, headless=True, user_agent="FireFox", session_name="test-session" - ) + mock_webdriver = mock.MagicMock() + mock_options = mock.MagicMock() + mock_webdriver.ChromeOptions.return_value = mock_options + mock_driver = mock.MagicMock(name="local-driver") + mock_driver.name = "chrome" + mock_webdriver.Chrome.return_value = mock_driver + mock_service = mock.MagicMock() + mock_manager = mock.MagicMock() + mock_manager.return_value.install.return_value = "/drivers/chromedriver" + + with ( + mock.patch( + "uk_bin_collection.uk_bin_collection.common._load_selenium_dependencies", + return_value=(mock_webdriver, WebDriverException), + ), + mock.patch( + "uk_bin_collection.uk_bin_collection.common._load_local_chrome_dependencies", + return_value=(mock_service, mock_manager), + ), + ): + result = create_webdriver( + None, headless=True, user_agent="FireFox", session_name="test-session" + ) + assert result.name in ["chrome", "chrome-headless-shell"] + mock_webdriver.Chrome.assert_called_once() def test_create_webdriver_remote_failure(): # Test the scenario where the remote server is not available - with pytest.raises(MaxRetryError) as exc_info: + mock_webdriver = mock.MagicMock() + mock_webdriver.ChromeOptions.return_value = mock.MagicMock() + mock_webdriver.Remote.side_effect = WebDriverException("server unavailable") + with ( + mock.patch( + "uk_bin_collection.uk_bin_collection.common._load_selenium_dependencies", + return_value=(mock_webdriver, WebDriverException), + ), + pytest.raises(BrowserUnavailableError), + ): create_webdriver("http://invalid-url:4444", False) +@pytest.mark.parametrize( + "web_driver", + ["/path/to/webdriver", "selenium:4444", "ftp://selenium:4444", "http://:4444"], +) +def test_create_webdriver_rejects_malformed_remote_url(web_driver): + mock_webdriver = mock.MagicMock() + mock_webdriver.ChromeOptions.return_value = mock.MagicMock() + + with ( + mock.patch( + "uk_bin_collection.uk_bin_collection.common._load_selenium_dependencies", + return_value=(mock_webdriver, WebDriverException), + ), + pytest.raises(BrowserUnavailableError, match="WebDriver URL"), + ): + create_webdriver(web_driver) + + mock_webdriver.Remote.assert_not_called() + + def test_create_webdriver_remote_with_session_name(): # Test creating a remote WebDriver with a session name session_name = "test-session" @@ -378,22 +466,142 @@ def test_create_webdriver_remote_with_session_name(): "http://localhost:4444/wd/hub" # Use a valid remote WebDriver URL for testing ) - # Mock the Remote WebDriver - with mock.patch("uk_bin_collection.common.webdriver.Remote") as mock_remote: + # Mock the lazily loaded Remote WebDriver + mock_webdriver = mock.MagicMock() + mock_options = mock.MagicMock() + mock_options._caps = {} + mock_options.set_capability.side_effect = mock_options._caps.__setitem__ + mock_webdriver.ChromeOptions.return_value = mock_options + mock_service = mock.MagicMock() + mock_manager = mock.MagicMock() + with mock.patch( + "uk_bin_collection.uk_bin_collection.common._load_selenium_dependencies", + return_value=(mock_webdriver, WebDriverException), + ): mock_instance = mock.MagicMock() mock_instance.name = "chrome" - mock_remote.return_value = mock_instance + mock_webdriver.Remote.return_value = mock_instance # Call the function with the test parameters result = create_webdriver(web_driver=web_driver_url, session_name=session_name) # Check if the session name was set in capabilities - args, kwargs = mock_remote.call_args + args, kwargs = mock_webdriver.Remote.call_args options = kwargs["options"] assert options._caps.get("se:name") == session_name assert result.name == "chrome" +def test_create_webdriver_remote_uses_bounded_client_config(): + mock_webdriver = mock.MagicMock() + mock_webdriver.ChromeOptions.return_value = mock.MagicMock() + mock_driver = mock.MagicMock(name="remote-driver") + mock_webdriver.Remote.return_value = mock_driver + client_config = object() + + with ( + mock.patch( + "uk_bin_collection.uk_bin_collection.common._load_selenium_dependencies", + return_value=(mock_webdriver, WebDriverException), + ), + mock.patch( + "uk_bin_collection.uk_bin_collection.common._build_remote_client_config", + return_value=client_config, + ) as build_config, + mock.patch( + "uk_bin_collection.uk_bin_collection.common._load_local_chrome_dependencies", + side_effect=AssertionError( + "remote WebDriver must not require local Chrome dependencies" + ), + ) as local_dependencies, + ): + result = create_webdriver(web_driver="http://selenium:4444", command_timeout=17) + + assert result is mock_driver + local_dependencies.assert_not_called() + build_config.assert_called_once_with("http://selenium:4444", 17) + assert mock_webdriver.Remote.call_args.kwargs["client_config"] is client_config + + +def test_remote_webdriver_url_rejects_invalid_port(): + with pytest.raises(BrowserUnavailableError, match="URL is invalid"): + common_module._validate_remote_webdriver_url("http://selenium:not-a-port") + + +def test_remote_client_config_validates_dependency_and_bounds_timeout(): + client_config_class = MagicMock() + client_config = object() + client_config_class.return_value = client_config + + with ( + patch.object(common_module, "validate_websocket_client") as validate, + patch( + "selenium.webdriver.remote.client_config.ClientConfig", + client_config_class, + ), + ): + result = common_module._build_remote_client_config("http://selenium:4444", 0.25) + + assert result is client_config + validate.assert_called_once_with() + client_config_class.assert_called_once_with( + remote_server_addr="http://selenium:4444", + timeout=1, + websocket_timeout=1.0, + ) + + +def test_selenium_dependency_loader_wraps_resolution_errors(): + resolution_error = ValueError("invalid selenium module metadata") + + with ( + patch.object(common_module, "find_spec", side_effect=resolution_error), + pytest.raises(MissingDependencyError, match="cannot safely resolve") as raised, + ): + common_module._load_selenium_dependencies() + + assert raised.value.__cause__ is resolution_error + + +def test_selenium_dependency_preflight_delegates_to_lazy_loader(): + with patch.object(common_module, "_load_selenium_dependencies") as loader: + common_module.ensure_selenium_dependencies() + + loader.assert_called_once_with() + + +def test_local_chrome_dependency_loader_returns_runtime_classes(): + chrome_service, chrome_driver_manager = ( + common_module._load_local_chrome_dependencies() + ) + + assert chrome_service.__name__ == "Service" + assert chrome_driver_manager.__name__ == "ChromeDriverManager" + + +def test_webdriver_cleanup_failure_preserves_browser_creation_error(): + mock_webdriver = MagicMock() + mock_webdriver.ChromeOptions.return_value = MagicMock() + driver = MagicMock() + creation_error = WebDriverException("window setup failed") + driver.set_window_position.side_effect = creation_error + driver.quit.side_effect = RuntimeError("cleanup failed") + mock_webdriver.Remote.return_value = driver + + with ( + patch.object( + common_module, + "_load_selenium_dependencies", + return_value=(mock_webdriver, WebDriverException), + ), + pytest.raises(BrowserUnavailableError) as raised, + ): + create_webdriver(web_driver="http://selenium:4444") + + assert raised.value.__cause__ is creation_error + driver.quit.assert_called_once_with() + + def test_string_with_numbers(): assert has_numbers("abc123") is True assert has_numbers("1a2b3c") is True @@ -457,7 +665,7 @@ def test_string_with_whitespace_and_numbers(): def test_get_next_day_of_week(today_str, day_name, expected): mock_today = datetime.strptime(today_str, "%Y-%m-%d") with patch( - "uk_bin_collection.common.datetime" + "uk_bin_collection.uk_bin_collection.common.datetime" ) as mock_datetime: # replace 'your_module' with the actual module name mock_datetime.now.return_value = mock_today mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw) @@ -485,7 +693,10 @@ def test_build_retry_session_custom_headers_and_methods(): def test_get_scraper_user_agent_uses_installed_version(): - with patch("uk_bin_collection.common._pkg_version", return_value="1.2.3"): + with patch( + "uk_bin_collection.uk_bin_collection.common._pkg_version", + return_value="1.2.3", + ): result = get_scraper_user_agent() assert result == ( "uk-bin-collection/1.2.3 (+https://github.com/robbrad/UKBinCollectionData)" @@ -494,7 +705,8 @@ def test_get_scraper_user_agent_uses_installed_version(): def test_get_scraper_user_agent_falls_back_when_not_installed(): with patch( - "uk_bin_collection.common._pkg_version", side_effect=PackageNotFoundError + "uk_bin_collection.uk_bin_collection.common._pkg_version", + side_effect=PackageNotFoundError, ): result = get_scraper_user_agent() assert result == ( @@ -504,7 +716,8 @@ def test_get_scraper_user_agent_falls_back_when_not_installed(): def test_get_scraper_user_agent_custom_fallback(): with patch( - "uk_bin_collection.common._pkg_version", side_effect=PackageNotFoundError + "uk_bin_collection.uk_bin_collection.common._pkg_version", + side_effect=PackageNotFoundError, ): result = get_scraper_user_agent(fallback_version="dev") assert result == ( diff --git a/uk_bin_collection/tests/test_core_import_hardening.py b/uk_bin_collection/tests/test_core_import_hardening.py new file mode 100644 index 0000000000..6b87f2c2e4 --- /dev/null +++ b/uk_bin_collection/tests/test_core_import_hardening.py @@ -0,0 +1,447 @@ +"""Focused regression tests for council and Selenium import boundaries.""" + +from __future__ import annotations + +import ast +import builtins +import importlib +import sys +from pathlib import Path, PurePosixPath +from types import ModuleType, SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from uk_bin_collection.uk_bin_collection import common, dependency_validation +from uk_bin_collection.uk_bin_collection import collect_data +from uk_bin_collection.uk_bin_collection.exceptions import ( + DependencyShadowingError, + InvalidCouncilModuleError, + MissingDependencyError, +) + + +class _FakeDistribution: + def __init__(self, root: Path, *, include_websocket: bool = True) -> None: + self.root = root + self.files = ( + [PurePosixPath("websocket/__init__.py")] if include_websocket else [] + ) + + def locate_file(self, relative_path: PurePosixPath) -> Path: + return self.root.joinpath(*relative_path.parts) + + +def test_websocket_client_accepts_file_owned_by_distribution(tmp_path: Path) -> None: + expected = tmp_path / "site-packages" / "websocket" / "__init__.py" + expected.parent.mkdir(parents=True) + expected.touch() + distribution = _FakeDistribution(tmp_path / "site-packages") + + with ( + patch.object( + dependency_validation.metadata, + "distribution", + return_value=distribution, + ), + patch.object( + dependency_validation.util, + "find_spec", + return_value=SimpleNamespace(origin=str(expected)), + ), + ): + result = dependency_validation.validate_websocket_client() + + assert result == expected.resolve() + + +def test_websocket_client_reports_stale_distribution_metadata( + tmp_path: Path, +) -> None: + expected = tmp_path / "site-packages" / "websocket" / "__init__.py" + distribution = _FakeDistribution(tmp_path / "site-packages") + + with ( + patch.object( + dependency_validation.metadata, + "distribution", + return_value=distribution, + ), + patch.object( + dependency_validation.util, + "find_spec", + return_value=SimpleNamespace(origin=str(expected)), + ), + pytest.raises(MissingDependencyError, match="missing"), + ): + dependency_validation.validate_websocket_client() + + +def test_websocket_client_rejects_shadow_package(tmp_path: Path) -> None: + distribution = _FakeDistribution(tmp_path / "site-packages") + shadow = tmp_path / "config" / "websocket" / "__init__.py" + + with ( + patch.object( + dependency_validation.metadata, + "distribution", + return_value=distribution, + ), + patch.object( + dependency_validation.util, + "find_spec", + return_value=SimpleNamespace(origin=str(shadow)), + ), + pytest.raises(DependencyShadowingError, match="shadowed"), + ): + dependency_validation.validate_websocket_client() + + +def test_websocket_client_reports_missing_distribution() -> None: + with ( + patch.object( + dependency_validation.metadata, + "distribution", + side_effect=dependency_validation.metadata.PackageNotFoundError, + ), + pytest.raises(MissingDependencyError, match="websocket-client"), + ): + dependency_validation.validate_websocket_client() + + +def test_websocket_client_rejects_broken_loaded_module_metadata( + tmp_path: Path, +) -> None: + distribution = _FakeDistribution(tmp_path / "site-packages") + with ( + patch.object( + dependency_validation.metadata, + "distribution", + return_value=distribution, + ), + patch.object( + dependency_validation.util, + "find_spec", + side_effect=ValueError("websocket.__spec__ is None"), + ), + pytest.raises(DependencyShadowingError, match="cannot safely resolve"), + ): + dependency_validation.validate_websocket_client() + + +def test_selenium_is_loaded_only_when_browser_is_requested() -> None: + with ( + patch.object(common, "find_spec", return_value=None), + pytest.raises(MissingDependencyError, match="selenium"), + ): + common._load_selenium_dependencies() + + +def test_browser_session_is_closed_when_initialisation_fails() -> None: + webdriver = MagicMock() + options = MagicMock() + webdriver.ChromeOptions.return_value = options + driver = MagicMock() + webdriver.Remote.return_value = driver + + class FakeWebDriverError(Exception): + pass + + driver.set_window_position.side_effect = FakeWebDriverError("window unavailable") + with ( + patch.object( + common, + "_load_selenium_dependencies", + return_value=(webdriver, FakeWebDriverError), + ), + pytest.raises(common.BrowserUnavailableError), + ): + common.create_webdriver("http://selenium:4444") + + driver.quit.assert_called_once_with() + + +@pytest.mark.parametrize( + "module_name", + ["", "../SouthKestevenDistrictCouncil", "os.path", "name-with-dash", 123], +) +def test_council_loader_rejects_non_identifier_names(module_name: object) -> None: + with pytest.raises(InvalidCouncilModuleError): + collect_data.import_council_module(module_name) # type: ignore[arg-type] + + +def test_council_loader_rejects_custom_source_path() -> None: + with pytest.raises(InvalidCouncilModuleError, match="Custom"): + collect_data.import_council_module("SouthKestevenDistrictCouncil", "../tmp") + + +def test_council_loader_imports_only_from_councils_package() -> None: + expected_file = collect_data._council_file("ExampleCouncil") + council_module = SimpleNamespace( + CouncilClass=MagicMock(), __file__=str(expected_file) + ) + with ( + patch.object( + collect_data, + "registered_council_modules", + return_value=frozenset({"ExampleCouncil"}), + ), + patch.object(collect_data, "council_requires_selenium", return_value=False), + patch.object( + collect_data.import_util, + "find_spec", + return_value=SimpleNamespace(origin=str(expected_file)), + ), + patch.object( + collect_data.importlib, + "import_module", + return_value=council_module, + ) as import_module, + ): + result = collect_data.import_council_module("ExampleCouncil") + + assert result is council_module + import_module.assert_called_once_with( + "uk_bin_collection.uk_bin_collection.councils.ExampleCouncil" + ) + + +def test_council_loader_rejects_shadowed_qualified_module(tmp_path: Path) -> None: + shadow = tmp_path / "config" / "ExampleCouncil.py" + with ( + patch.object( + collect_data, + "registered_council_modules", + return_value=frozenset({"ExampleCouncil"}), + ), + patch.object(collect_data, "council_requires_selenium", return_value=False), + patch.object( + collect_data.import_util, + "find_spec", + return_value=SimpleNamespace(origin=str(shadow)), + ), + patch.object(collect_data.importlib, "import_module") as import_module, + pytest.raises(InvalidCouncilModuleError, match="outside"), + ): + collect_data.import_council_module("ExampleCouncil") + + import_module.assert_not_called() + + +def test_selenium_council_validates_websocket_before_module_import() -> None: + with ( + patch.object( + collect_data, + "registered_council_modules", + return_value=frozenset({"ExampleCouncil"}), + ), + patch.object(collect_data, "council_requires_selenium", return_value=True), + patch.object( + collect_data, + "validate_websocket_client", + side_effect=DependencyShadowingError("shadowed"), + ) as validate, + patch.object(collect_data.import_util, "find_spec") as find_spec, + patch.object(collect_data.importlib, "import_module") as import_module, + pytest.raises(DependencyShadowingError, match="shadowed"), + ): + collect_data.import_council_module("ExampleCouncil") + + validate.assert_called_once_with() + find_spec.assert_called_once_with("selenium") + import_module.assert_not_called() + + +def test_selenium_council_reports_missing_optional_dependency_before_import() -> None: + with ( + patch.object( + collect_data, + "registered_council_modules", + return_value=frozenset({"ExampleCouncil"}), + ), + patch.object(collect_data, "council_requires_selenium", return_value=True), + patch.object(collect_data.import_util, "find_spec", return_value=None), + patch.object(collect_data, "validate_websocket_client") as validate, + patch.object(collect_data.importlib, "import_module") as import_module, + pytest.raises(MissingDependencyError, match="selenium"), + ): + collect_data.import_council_module("ExampleCouncil") + + validate.assert_not_called() + import_module.assert_not_called() + + +def test_selenium_preflight_detects_eager_and_lazy_imports( + tmp_path: Path, +) -> None: + eager_source = tmp_path / "EagerCouncil.py" + eager_source.write_text("from selenium import webdriver\n", encoding="utf-8") + lazy_source = tmp_path / "LazyCouncil.py" + lazy_source.write_text( + "def load_browser():\n from selenium import webdriver\n", + encoding="utf-8", + ) + + collect_data.council_requires_selenium.cache_clear() + with patch.object(collect_data, "_council_file", return_value=eager_source): + assert collect_data.council_requires_selenium("EagerCouncil") is True + + collect_data.council_requires_selenium.cache_clear() + with patch.object(collect_data, "_council_file", return_value=lazy_source): + assert collect_data.council_requires_selenium("LazyCouncil") is True + + collect_data.council_requires_selenium.cache_clear() + + +def test_isle_of_wight_lazy_selenium_import_is_preflighted() -> None: + """A real lazy-import council cannot bypass dependency ownership checks.""" + collect_data.council_requires_selenium.cache_clear() + assert collect_data.council_requires_selenium("IsleOfWightCouncil") is True + collect_data.council_requires_selenium.cache_clear() + + +def test_council_loader_rejects_identifier_not_in_registry() -> None: + with ( + patch.object( + collect_data, + "registered_council_modules", + return_value=frozenset({"KnownCouncil"}), + ), + pytest.raises(InvalidCouncilModuleError, match="installed registry"), + ): + collect_data.import_council_module("UnknownCouncil") + + +def test_non_selenium_council_imports_with_shadow_websocket(monkeypatch) -> None: + """An unrelated websocket package must not break an HTTP-only council import.""" + fake_websocket = ModuleType("websocket") + fake_websocket.__file__ = "/config/websocket/__init__.py" + monkeypatch.setitem(sys.modules, "websocket", fake_websocket) + + real_import = builtins.__import__ + + def guarded_import(name, *args, **kwargs): + if name == "selenium" or name.startswith("selenium."): + raise AssertionError("HTTP-only council unexpectedly imported Selenium") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + importlib.reload(common) + sys.modules.pop( + "uk_bin_collection.uk_bin_collection.councils.AberdeenCityCouncil", None + ) + original_import_path = list(sys.path) + + module = collect_data.import_council_module("AberdeenCityCouncil") + + assert module.CouncilClass.__name__ == "CouncilClass" + assert sys.path == original_import_path + + +def test_all_council_selenium_imports_are_lazy_and_prevalidated() -> None: + council_root = ( + Path(__file__).resolve().parents[1] / "uk_bin_collection" / "councils" + ) + + for path in sorted(council_root.glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + selenium_imports = [ + node + for node in ast.walk(tree) + if ( + isinstance(node, ast.Import) + and any( + alias.name == "selenium" or alias.name.startswith("selenium.") + for alias in node.names + ) + ) + or ( + isinstance(node, ast.ImportFrom) + and node.module + and (node.module == "selenium" or node.module.startswith("selenium.")) + ) + ] + if not selenium_imports: + continue + + assert not any(node in tree.body for node in selenium_imports), path.name + council_class = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "CouncilClass" + ) + parse_data = next( + node + for node in council_class.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "parse_data" + ) + parse_imports = { + node for node in ast.walk(parse_data) if node in selenium_imports + } + if parse_imports != set(selenium_imports): + support_loader = next( + ( + node + for node in council_class.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "_load_selenium_support" + ), + None, + ) + assert support_loader is not None, path.name + assert { + node for node in ast.walk(support_loader) if node in selenium_imports + } == set(selenium_imports), path.name + validation_calls = [ + node + for node in ast.walk(support_loader) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "validate_websocket_client" + ] + assert len(validation_calls) == 1, path.name + assert validation_calls[0].lineno < min( + node.lineno for node in selenium_imports + ), path.name + loader_calls = [ + node + for node in ast.walk(parse_data) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "_load_selenium_support" + ] + assert len(loader_calls) == 1, path.name + continue + + validation_calls = [ + node + for node in ast.walk(parse_data) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "ensure_selenium_dependencies" + ] + assert len(validation_calls) == 1, path.name + assert validation_calls[0].lineno < min( + node.lineno for node in selenium_imports + ), path.name + + +def test_browser_only_optional_dependencies_are_not_imported_at_module_scope() -> None: + council_root = ( + Path(__file__).resolve().parents[1] / "uk_bin_collection" / "councils" + ) + browser_packages = {"selenium", "undetected_chromedriver", "webdriver_manager"} + + for path in sorted(council_root.glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in tree.body: + if isinstance(node, ast.Import): + imported = { + alias.name.split(".", maxsplit=1)[0] for alias in node.names + } + elif isinstance(node, ast.ImportFrom) and node.module: + imported = {node.module.split(".", maxsplit=1)[0]} + else: + continue + assert imported.isdisjoint(browser_packages), path.name diff --git a/uk_bin_collection/tests/test_council_log_redaction.py b/uk_bin_collection/tests/test_council_log_redaction.py new file mode 100644 index 0000000000..8aff87aa12 --- /dev/null +++ b/uk_bin_collection/tests/test_council_log_redaction.py @@ -0,0 +1,332 @@ +"""Package-wide regression checks for sensitive diagnostic output.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +CORE_DIRECTORY = Path(__file__).resolve().parents[1] / "uk_bin_collection" +COUNCILS_DIRECTORY = CORE_DIRECTORY / "councils" +HOME_ASSISTANT_DIRECTORY = ( + Path(__file__).resolve().parents[2] / "custom_components" / "uk_bin_collection" +) +AUDITED_FILES = ( + tuple(sorted(COUNCILS_DIRECTORY.glob("*.py"))) + + tuple(sorted(CORE_DIRECTORY.glob("*.py"))) + + tuple(sorted(HOME_ASSISTANT_DIRECTORY.glob("*.py"))) +) +LOG_METHODS = {"critical", "debug", "error", "exception", "info", "warning"} +SAFE_STATE_SUFFIXES = ( + "_count", + "_found", + "_length", + "_matched", + "_present", + "_status", + "_status_code", + "_total", +) +SAFE_STATE_NAMES = { + "council_key", + "http_status", + "manual_refresh", + "response_status", + "status", + "status_code", + "timeout", + "update_interval", + "update_interval_hours", +} +SENSITIVE_NAME_PARTS = ( + "address", + "bin", + "collection", + "data", + "date", + "html", + "markup", + "mapping", + "month", + "option", + "output", + "page_source", + "paon", + "postcode", + "result", + "raw", + "response", + "service", + "tag", + "text", + "uprn", + "usrn", + "waste", + "web_driver", +) +SENSITIVE_EXACT_NAMES = { + "body", + "item", + "key", + "page", + "payload", + "row", + "soup", + "url", + "value", +} +SENSITIVE_ATTRIBUTES = { + "_attribute_type", + "_bin_type", + "content", + "data", + "page_source", + "url", +} + + +def _diagnostic_sinks(tree: ast.AST): + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Name) and node.func.id == "print": + yield node + elif isinstance(node.func, ast.Attribute) and node.func.attr in LOG_METHODS: + yield node + + +def _exception_names(tree: ast.AST) -> set[str]: + return { + node.name + for node in ast.walk(tree) + if isinstance(node, ast.ExceptHandler) and isinstance(node.name, str) + } + + +def _root_name(node: ast.AST) -> str | None: + while isinstance(node, (ast.Attribute, ast.Call, ast.Subscript)): + if isinstance(node, ast.Attribute): + node = node.value + elif isinstance(node, ast.Call): + node = node.func + else: + node = node.value + return node.id if isinstance(node, ast.Name) else None + + +def _is_sensitive_name(name: str) -> bool: + lowered = name.lower() + if lowered in SAFE_STATE_NAMES or lowered.endswith(SAFE_STATE_SUFFIXES): + return False + if lowered in SENSITIVE_EXACT_NAMES: + return True + if lowered.startswith("url_") or lowered.endswith("_url"): + return True + return any(part in lowered for part in SENSITIVE_NAME_PARTS) + + +def _is_safe_summary(node: ast.AST, exception_names: set[str]) -> bool: + if ( + isinstance(node, ast.Attribute) + and node.attr == "__name__" + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and node.value.func.id == "type" + and len(node.value.args) == 1 + and isinstance(node.value.args[0], ast.Name) + and node.value.args[0].id in exception_names + ): + return True + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "len" + and len(node.args) == 1 + ): + return True + return isinstance(node, ast.Attribute) and node.attr == "status_code" + + +def _expression_findings( + node: ast.AST, + exception_names: set[str], + tainted_names: set[str], +) -> set[str]: + if _is_safe_summary(node, exception_names): + return set() + + findings = set() + if isinstance(node, ast.Name): + if node.id.lower() in SAFE_STATE_NAMES or node.id.lower().endswith( + SAFE_STATE_SUFFIXES + ): + return findings + if node.id in exception_names: + findings.add(f"raw exception '{node.id}'") + if node.id in tainted_names or _is_sensitive_name(node.id): + findings.add(f"sensitive value '{node.id}'") + return findings + + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Attribute): + findings.update( + _expression_findings(node.func.value, exception_names, tainted_names) + ) + for argument in node.args: + findings.update( + _expression_findings(argument, exception_names, tainted_names) + ) + for keyword in node.keywords: + findings.update( + _expression_findings(keyword.value, exception_names, tainted_names) + ) + return findings + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if "http://" in node.value.lower() or "https://" in node.value.lower(): + findings.add("literal URL") + return findings + + if isinstance(node, ast.Attribute): + root = (_root_name(node) or "").lower() + if node.attr in SENSITIVE_ATTRIBUTES: + findings.add(f"sensitive attribute '.{node.attr}'") + elif node.attr in {"name", "summary"} and any( + marker in root for marker in ("calendar", "entity", "event") + ): + findings.add(f"sensitive attribute '.{node.attr}'") + elif node.attr == "text" and any( + marker in root + for marker in ("address", "dropdown", "html", "option", "page", "response") + ): + findings.add("sensitive attribute '.text'") + + for child in ast.iter_child_nodes(node): + findings.update(_expression_findings(child, exception_names, tainted_names)) + return findings + + +def _assignment_value(node: ast.AST) -> ast.AST | None: + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): + return node.value + return None + + +def _assignment_targets(node: ast.AST): + targets = [] + if isinstance(node, ast.Assign): + targets = node.targets + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)): + targets = [node.target] + pending = list(targets) + while pending: + target = pending.pop() + if isinstance(target, ast.Name): + yield target.id + elif isinstance(target, (ast.List, ast.Tuple)): + pending.extend(target.elts) + elif isinstance(target, ast.Starred): + pending.append(target.value) + + +def _tainted_names(tree: ast.AST, exception_names: set[str]) -> set[str]: + tainted = { + node.id + for node in ast.walk(tree) + if isinstance(node, ast.Name) and _is_sensitive_name(node.id) + } + assignments = [ + node for node in ast.walk(tree) if _assignment_value(node) is not None + ] + changed = True + while changed: + changed = False + for assignment in assignments: + value = _assignment_value(assignment) + if value is None or not _expression_findings( + value, exception_names, tainted + ): + continue + for target in _assignment_targets(assignment): + if ( + target.lower() not in SAFE_STATE_NAMES + and not target.lower().endswith(SAFE_STATE_SUFFIXES) + and target not in tainted + ): + tainted.add(target) + changed = True + return tainted + + +def _tree_violations(tree: ast.AST, source_name: str): + exception_names = _exception_names(tree) + tainted_names = _tainted_names(tree, exception_names) + violations = [] + for sink in _diagnostic_sinks(tree): + findings = _expression_findings(sink, exception_names, tainted_names) + if any( + keyword.arg in {"exc_info", "stack_info"} + and not ( + isinstance(keyword.value, ast.Constant) + and keyword.value.value in {False, None} + ) + for keyword in sink.keywords + ) or ( + isinstance(sink.func, ast.Attribute) + and sink.func.attr == "exception" + and (_root_name(sink.func.value) or "").lower() + in {"logger", "logging", "_logger"} + ): + findings.add("raw traceback output") + if findings: + violations.append((source_name, sink.lineno, sorted(findings))) + return violations + + +def test_sensitive_values_do_not_flow_to_diagnostics(): + """Normal print and logger diagnostics must contain summaries only.""" + violations = [] + for source_path in AUDITED_FILES: + tree = ast.parse( + source_path.read_text(encoding="utf-8"), filename=str(source_path) + ) + violations.extend(_tree_violations(tree, source_path.name)) + + assert violations == [], violations + + +def test_gate_detects_sensitive_and_allows_summary_diagnostics(): + """Exercise each protected source class and each supported safe summary.""" + unsafe_samples = ( + "try:\n pass\nexcept Exception as exc:\n print(f'{exc}')\n", + "print(driver.page_source)\n", + "print(html)\n", + "logger.error('URL: %s', response.url)\n", + "print('https://council.invalid/private')\n", + "print(postcode, paon, uprn, usrn)\n", + "selected = address_options[0].text\nlogger.info('%s', selected)\n", + "print(dict_data, bin_data, collection, raw_text)\n", + "logger.debug('artifact: %s', json.dumps(data))\n", + "print(bin_type, collection_date, waste_service)\n", + "logger.warning('%s', icon_color_mapping)\n", + "logger.debug('%s', config_entry.data)\n", + "logger.info('%s', event.summary)\n", + "print(raw_name, month_txt, start_tag, key, value, payload, row)\n", + "logger.info('%s', entity._bin_type)\n", + "logger.exception('collection failed')\n", + "try:\n pass\nexcept Exception:\n logger.error('failed', exc_info=True)\n", + ) + safe_samples = ( + "try:\n pass\nexcept Exception as exc:\n print(type(exc).__name__)\n", + "print(f'Found {len(address_options)} address options')\n", + "logger.info('HTTP %s', response.status_code)\n", + "print('Address selected successfully')\n", + "logger.error('failed', exc_info=False)\n", + ) + + for index, source in enumerate(unsafe_samples): + tree = ast.parse(source, filename=f"unsafe_{index}.py") + assert _tree_violations(tree, f"unsafe_{index}.py"), source + + for index, source in enumerate(safe_samples): + tree = ast.parse(source, filename=f"safe_{index}.py") + assert _tree_violations(tree, f"safe_{index}.py") == [], source diff --git a/uk_bin_collection/uk_bin_collection/collect_data.py b/uk_bin_collection/uk_bin_collection/collect_data.py index 29c3649691..27f9a4e5a1 100755 --- a/uk_bin_collection/uk_bin_collection/collect_data.py +++ b/uk_bin_collection/uk_bin_collection/collect_data.py @@ -1,22 +1,148 @@ import argparse +import ast import importlib +import logging import os +import re import sys -import logging +from functools import lru_cache +from importlib import util as import_util +from pathlib import Path + from uk_bin_collection.uk_bin_collection.get_bin_data import ( setup_logging, LOGGING_CONFIG, ) +from uk_bin_collection.uk_bin_collection.exceptions import ( + InvalidCouncilModuleError, + MissingDependencyError, +) +from uk_bin_collection.uk_bin_collection.dependency_validation import ( + validate_websocket_client, +) _LOGGER = logging.getLogger(__name__) +_COUNCIL_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$") +_COUNCILS_PACKAGE = "uk_bin_collection.uk_bin_collection.councils" +_COUNCILS_DIRECTORY = Path(__file__).resolve().parent / "councils" + + +def _normalised_path(path: os.PathLike[str] | str) -> str: + """Return a real, case-normalised path for import-origin comparisons.""" + return os.path.normcase(os.path.realpath(os.fspath(path))) + + +def _council_file(module_name: str) -> Path: + """Return the trusted source location for a registered council.""" + return _COUNCILS_DIRECTORY / f"{module_name}.py" + + +@lru_cache(maxsize=None) +def council_requires_selenium(module_name: str) -> bool: + """Return whether a trusted council imports Selenium at any runtime scope. + + Older adapters use both eager and function-local imports. Inspecting the full + trusted source before import lets the loader validate Selenium's + collision-prone ``websocket`` dependency before either form can execute. + """ + council_file = _council_file(module_name) + try: + syntax_tree = ast.parse( + council_file.read_text(encoding="utf-8"), filename=str(council_file) + ) + except (OSError, SyntaxError, UnicodeError) as exc: + raise InvalidCouncilModuleError( + f"Council module {module_name!r} cannot be inspected safely." + ) from exc + + for node in ast.walk(syntax_tree): + if isinstance(node, ast.Import) and any( + imported.name == "selenium" or imported.name.startswith("selenium.") + for imported in node.names + ): + return True + if ( + isinstance(node, ast.ImportFrom) + and node.module + and (node.module == "selenium" or node.module.startswith("selenium.")) + ): + return True + return False + + +@lru_cache(maxsize=1) +def registered_council_modules() -> frozenset[str]: + """Return council module names shipped in the installed core package.""" + return frozenset( + council_file.stem + for council_file in _COUNCILS_DIRECTORY.iterdir() + if council_file.is_file() + and council_file.suffix == ".py" + and _COUNCIL_NAME_PATTERN.fullmatch(council_file.stem) + ) + -def import_council_module(module_name, src_path="councils"): - """Dynamically import the council processor module.""" - module_path = os.path.realpath(os.path.join(os.path.dirname(__file__), src_path)) - if module_path not in sys.path: - sys.path.append(module_path) - return importlib.import_module(module_name) +def import_council_module(module_name: str, src_path: str = "councils"): + """Import an allowlisted council by its fully qualified package name.""" + if src_path != "councils": + raise InvalidCouncilModuleError( + "Custom council import paths are not supported." + ) + if not isinstance(module_name, str) or not _COUNCIL_NAME_PATTERN.fullmatch( + module_name + ): + raise InvalidCouncilModuleError( + "Council names must be simple Python identifiers." + ) + + if module_name not in registered_council_modules(): + raise InvalidCouncilModuleError( + f"Council module {module_name!r} is not present in the installed registry." + ) + + if council_requires_selenium(module_name): + try: + selenium_spec = import_util.find_spec("selenium") + except (AttributeError, ImportError, ValueError) as exc: + raise MissingDependencyError( + "Python cannot safely resolve the optional 'selenium' dependency." + ) from exc + if selenium_spec is None: + raise MissingDependencyError( + "The optional dependency 'selenium' is required for this council." + ) + validate_websocket_client() + + qualified_name = f"{_COUNCILS_PACKAGE}.{module_name}" + expected_file = _normalised_path(_council_file(module_name)) + try: + specification = import_util.find_spec(qualified_name) + except (AttributeError, ImportError, ValueError) as exc: + raise InvalidCouncilModuleError( + f"Council module {module_name!r} cannot be resolved safely." + ) from exc + + if ( + specification is None + or specification.origin is None + or _normalised_path(specification.origin) != expected_file + ): + raise InvalidCouncilModuleError( + f"Council module {module_name!r} resolves outside the installed registry." + ) + + council_module = importlib.import_module(qualified_name) + module_file = getattr(council_module, "__file__", None) + if module_file is None or _normalised_path(module_file) != expected_file: + raise InvalidCouncilModuleError( + f"Council module {module_name!r} loaded from an unexpected location." + ) + if not hasattr(council_module, "CouncilClass"): + raise InvalidCouncilModuleError( + f"Council module {module_name!r} does not expose CouncilClass." + ) + return council_module class UKBinCollectionApp: @@ -55,6 +181,9 @@ def setup_arg_parser(self): self.parser.add_argument( "-u", "--uprn", type=str, help="UPRN to parse", required=False ) + self.parser.add_argument( + "-us", "--usrn", type=str, help="USRN to parse", required=False + ) self.parser.add_argument( "-w", "--web_driver", @@ -70,6 +199,14 @@ def setup_arg_parser(self): help="Directory for council-specific debug artifacts when a live scrape fails", required=False, ) + self.parser.add_argument( + "--user-agent", + "--user_agent", + dest="user_agent", + type=str, + help="Optional HTTP/browser user agent for council requests", + required=False, + ) self.parser.add_argument( "--headless", dest="headless", @@ -112,9 +249,11 @@ def run(self): postcode=self.parsed_args.postcode, paon=self.parsed_args.number, uprn=self.parsed_args.uprn, + usrn=self.parsed_args.usrn, skip_get_url=self.parsed_args.skip_get_url, web_driver=self.parsed_args.web_driver, artifact_dir=self.parsed_args.artifact_dir, + user_agent=self.parsed_args.user_agent, headless=self.parsed_args.headless, local_browser=self.parsed_args.local_browser, dev_mode=self.parsed_args.dev_mode, diff --git a/uk_bin_collection/uk_bin_collection/common.py b/uk_bin_collection/uk_bin_collection/common.py index c6eb844596..ccbcca244b 100644 --- a/uk_bin_collection/uk_bin_collection/common.py +++ b/uk_bin_collection/uk_bin_collection/common.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import calendar import json import os @@ -5,17 +7,28 @@ from datetime import datetime, timedelta from enum import Enum from importlib.metadata import PackageNotFoundError, version as _pkg_version +from importlib.util import find_spec +from typing import TYPE_CHECKING, Any +from urllib.parse import urlsplit import holidays import pandas as pd import requests from dateutil.parser import parse from requests.adapters import HTTPAdapter -from selenium import webdriver -from selenium.webdriver.chrome.service import Service as ChromeService from urllib3.exceptions import MaxRetryError from urllib3.util.retry import Retry -from webdriver_manager.chrome import ChromeDriverManager + +from uk_bin_collection.uk_bin_collection.dependency_validation import ( + validate_websocket_client, +) +from uk_bin_collection.uk_bin_collection.exceptions import ( + BrowserUnavailableError, + MissingDependencyError, +) + +if TYPE_CHECKING: + from selenium.webdriver.remote.webdriver import WebDriver date_format = "%d/%m/%Y" days_of_week = { @@ -42,13 +55,20 @@ def check_postcode(postcode: str): :param postcode: Postcode to parse """ postcode_api_url = "https://api.postcodes.io/postcodes/" - postcode_api_response = requests.get(f"{postcode_api_url}{postcode}") + postcode_api_response = requests.get( + f"{postcode_api_url}{postcode}", timeout=(3.05, 10) + ) if postcode_api_response.status_code != 200: - val_error = json.loads(postcode_api_response.text) - raise ValueError( - f"Exception: {val_error['error']} Status: {val_error['status']}" + try: + val_error = postcode_api_response.json() + except (ValueError, TypeError): + val_error = {} + error = val_error.get( + "error", "Postcode validation service rejected the request" ) + status = val_error.get("status", postcode_api_response.status_code) + raise ValueError(f"Exception: {error} Status: {status}") return True @@ -62,7 +82,7 @@ def check_paon(paon: str): raise ValueError("Invalid house number") return True except Exception as ex: - print(f"Exception encountered: {ex}") + print(f"Exception encountered: {type(ex).__name__}") print("Please check the provided house number.") exit(1) @@ -77,7 +97,7 @@ def check_uprn(uprn: str): raise ValueError("Invalid UPRN") return True except Exception as ex: - print(f"Exception encountered: {ex}") + print(f"Exception encountered: {type(ex).__name__}") print("Please check the provided UPRN.") @@ -91,7 +111,7 @@ def check_usrn(usrn: str): raise ValueError("Invalid USRN") return True except Exception as ex: - print(f"Exception encountered: {ex}") + print(f"Exception encountered: {type(ex).__name__}") print("Please check the provided USRN.") @@ -271,7 +291,7 @@ def update_input_json(council: str, url: str, input_file_path: str, **kwargs): save_data(input_file_path, data) except IOError as e: - print(f"Error updating the JSON file: {e}") + print(f"Error updating the JSON file: {type(e).__name__}") except json.JSONDecodeError: print("Failed to decode JSON, check the integrity of the input file.") @@ -366,7 +386,8 @@ def create_webdriver( headless: bool = True, user_agent: str = None, session_name: str = None, -) -> webdriver.Chrome: + command_timeout: float | None = None, +) -> WebDriver: """ Create and return a Chrome WebDriver configured for optional headless operation. @@ -374,9 +395,14 @@ def create_webdriver( :param headless: Whether to run the browser in headless mode. :param user_agent: Optional custom user agent string. :param session_name: Optional custom session name string. + :param command_timeout: Optional bound for remote WebDriver HTTP/WebSocket calls. :return: An instance of a Chrome WebDriver. - :raises WebDriverException: If the WebDriver cannot be created. + :raises DependencyError: If Selenium's dependencies cannot be imported safely. + :raises BrowserUnavailableError: If the WebDriver cannot be created. """ + webdriver, WebDriverException = _load_selenium_dependencies() + if web_driver: + web_driver = _validate_remote_webdriver_url(web_driver) options = webdriver.ChromeOptions() if headless: options.add_argument("--headless") @@ -391,10 +417,20 @@ def create_webdriver( if session_name and web_driver: options.set_capability("se:name", session_name) + driver = None try: if web_driver: - driver = webdriver.Remote(command_executor=web_driver, options=options) + remote_kwargs = { + "command_executor": web_driver, + "options": options, + } + if command_timeout is not None: + remote_kwargs["client_config"] = _build_remote_client_config( + web_driver, command_timeout + ) + driver = webdriver.Remote(**remote_kwargs) else: + ChromeService, ChromeDriverManager = _load_local_chrome_dependencies() driver = webdriver.Chrome( service=ChromeService(ChromeDriverManager().install()), options=options ) @@ -403,6 +439,110 @@ def create_webdriver( driver.set_window_position(0, 0) return driver - except MaxRetryError as e: - print(f"Failed to create WebDriver: {e}") - raise + except ( + MaxRetryError, + WebDriverException, + requests.exceptions.RequestException, + OSError, + ) as exc: + if driver is not None: + try: + driver.quit() + except Exception: + # Preserve the browser-creation failure as the actionable cause. + pass + raise BrowserUnavailableError( + "Unable to create or reach the configured Selenium WebDriver." + ) from exc + + +def _validate_remote_webdriver_url(web_driver: str) -> str: + """Return a normalized HTTP(S) WebDriver URL or fail with a typed error.""" + normalized = str(web_driver).strip().rstrip("/") + try: + parsed = urlsplit(normalized) + # Accessing hostname/port performs urllib's bracket and port validation. + hostname = parsed.hostname + parsed.port + except (TypeError, ValueError) as exc: + raise BrowserUnavailableError( + "The configured Selenium WebDriver URL is invalid." + ) from exc + + if ( + parsed.scheme.casefold() not in {"http", "https"} + or not hostname + or any(character.isspace() for character in normalized) + ): + raise BrowserUnavailableError( + "The configured Selenium WebDriver URL must use HTTP or HTTPS and " + "include a host." + ) + return normalized + + +def _build_remote_client_config(web_driver: str, timeout: float): + """Build Selenium's bounded remote connection config lazily.""" + validate_websocket_client() + try: + from selenium.webdriver.remote.client_config import ClientConfig + except ImportError as exc: + raise MissingDependencyError( + "Selenium's remote client configuration support is unavailable." + ) from exc + + timeout_seconds = max(1, int(timeout)) + return ClientConfig( + remote_server_addr=web_driver, + timeout=timeout_seconds, + websocket_timeout=float(timeout_seconds), + ) + + +def _load_selenium_dependencies() -> tuple[Any, type[Exception]]: + """Import Selenium only when a caller actually needs a browser.""" + try: + selenium_spec = find_spec("selenium") + except (AttributeError, ImportError, ValueError) as exc: + raise MissingDependencyError( + "Python cannot safely resolve the optional 'selenium' dependency." + ) from exc + + if selenium_spec is None: + raise MissingDependencyError( + "The optional dependency 'selenium' is required for this council." + ) + + # Selenium's remote driver imports ``websocket``. Validate its ownership before + # allowing that import to execute potentially conflicting top-level code. + validate_websocket_client() + + try: + from selenium import webdriver + from selenium.common.exceptions import WebDriverException + except ImportError as exc: + # Recheck the most security-sensitive transitive dependency in case import + # state changed between validation and Selenium's own import. + validate_websocket_client() + raise MissingDependencyError("Selenium is installed incompletely.") from exc + + return webdriver, WebDriverException + + +def ensure_selenium_dependencies() -> None: + """Validate and import Selenium before a council performs lazy imports.""" + _load_selenium_dependencies() + + +def _load_local_chrome_dependencies() -> tuple[Any, Any]: + """Import dependencies needed only when launching a local Chrome driver.""" + try: + from selenium.webdriver.chrome.service import Service as ChromeService + from webdriver_manager.chrome import ChromeDriverManager + except ImportError as exc: + raise MissingDependencyError( + "The optional dependency 'webdriver-manager' is required for a local " + "browser, but is not required for a remote WebDriver." + ) from exc + + return ChromeService, ChromeDriverManager diff --git a/uk_bin_collection/uk_bin_collection/councils/AdurAndWorthingCouncils.py b/uk_bin_collection/uk_bin_collection/councils/AdurAndWorthingCouncils.py index 37616efe45..ed752cb27e 100644 --- a/uk_bin_collection/uk_bin_collection/councils/AdurAndWorthingCouncils.py +++ b/uk_bin_collection/uk_bin_collection/councils/AdurAndWorthingCouncils.py @@ -78,13 +78,12 @@ def parse_data(self, page: str, **kwargs) -> dict: bin_date = bin_date.replace(year=current_year + 1) collections.append((bin_type, bin_date)) - print( - f"Successfully parsed date for {bin_type}: {bin_date}" - ) + print("Collection date parsed successfully") except ValueError as e: print( - f"Failed to parse date '{date_str}' for {bin_type}: {e}" + "Failed to parse a collection date:", + type(e).__name__, ) continue @@ -92,7 +91,7 @@ def parse_data(self, page: str, **kwargs) -> dict: break except Exception as e: - print(f"Error processing bin row: {e}") + print(f"Error processing bin row: {type(e).__name__}") continue if not collections: diff --git a/uk_bin_collection/uk_bin_collection/councils/AngusCouncil.py b/uk_bin_collection/uk_bin_collection/councils/AngusCouncil.py index 95d1135523..dd49ee5e94 100644 --- a/uk_bin_collection/uk_bin_collection/councils/AngusCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/AngusCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import re from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait -from selenium.common.exceptions import TimeoutException, NoSuchElementException from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -13,6 +11,17 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, NoSuchElementException, Select, TimeoutException, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + from selenium.common.exceptions import TimeoutException, NoSuchElementException + driver = None try: user_postcode = kwargs.get("postcode") @@ -59,7 +68,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except NoSuchElementException: # Print page source for debugging - print("Page source:", driver.page_source[:1000]) + print("Page source omitted from diagnostics.") raise ValueError("Could not find postcode input field") postcode_input.clear() postcode_input.send_keys(user_postcode) @@ -170,7 +179,7 @@ def parse_data(self, page: str, **kwargs) -> dict: return bin_data except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/ArgyllandButeCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ArgyllandButeCouncil.py index 0ae0d16bd8..f5b7d14935 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ArgyllandButeCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ArgyllandButeCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,7 +19,7 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Fetch upcoming bin collection types and dates for an Argyll and Bute address. - + Parameters: page (str): Unused; the function always targets the Argyll and Bute bin collection page. **kwargs: @@ -28,12 +27,22 @@ def parse_data(self, page: str, **kwargs) -> dict: postcode (str): The postcode to search. web_driver: Optional webdriver configuration or path passed to create_webdriver. headless (bool): Whether to run the browser in headless mode. - + Returns: dict: A dictionary with a "bins" key containing a list of collections. Each collection is a dict with: - "type" (str): Human-readable bin type (e.g., "General waste"). - "collectionDate" (str): Collection date formatted according to the module's `date_format`. """ + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: page = "https://www.argyll-bute.gov.uk/rubbish-and-recycling/household-waste/bin-collection" @@ -146,11 +155,11 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: # This block ensures that the driver is closed regardless of an exception if driver: driver.quit() - return bin_data \ No newline at end of file + return bin_data diff --git a/uk_bin_collection/uk_bin_collection/councils/ArunCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ArunCouncil.py index eabc5fd06f..7e81acf892 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ArunCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ArunCouncil.py @@ -1,10 +1,8 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +17,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait, webdriver + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + try: # Make a BS4 object data = {"bins": []} @@ -88,7 +97,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/AshfieldDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/AshfieldDistrictCouncil.py index 3e668ca263..7c725d4297 100644 --- a/uk_bin_collection/uk_bin_collection/councils/AshfieldDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/AshfieldDistrictCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +17,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait, webdriver + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: # Get and check UPRN @@ -105,7 +114,7 @@ def parse_data(self, page: str, **kwargs) -> dict: bindata["bins"].append(dict_data) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/AshfordBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/AshfordBoroughCouncil.py index 6af1164536..b2afd3cdb9 100644 --- a/uk_bin_collection/uk_bin_collection/councils/AshfordBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/AshfordBoroughCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + from datetime import datetime import requests from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +17,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: # Get and check UPRN @@ -133,7 +142,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/BaberghDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BaberghDistrictCouncil.py index 7f79634f06..412dd17591 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BaberghDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BaberghDistrictCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import re from datetime import datetime from bs4 import BeautifulSoup -from selenium.common.exceptions import TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -39,6 +37,17 @@ def parse_data(self, page: str, **kwargs) -> dict: Returns: A dict with a ``bins`` list of collection type/date entries. """ + global By, EC, Select, TimeoutException, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.common.exceptions import TimeoutException + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: web_driver = kwargs.get("web_driver") @@ -195,7 +204,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/BarkingDagenham.py b/uk_bin_collection/uk_bin_collection/councils/BarkingDagenham.py index 0a6caeb990..ab2c3e04f7 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BarkingDagenham.py +++ b/uk_bin_collection/uk_bin_collection/councils/BarkingDagenham.py @@ -1,15 +1,11 @@ +from __future__ import annotations + # This script pulls bin collection data from Barking and Dagenham Council # Example URL: https://www.lbbd.gov.uk/rubbish-recycling/household-bin-collection/check-your-bin-collection-days import time from bs4 import BeautifulSoup from dateutil.parser import parse -from selenium.common.exceptions import NoSuchElementException, TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -18,6 +14,19 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, NoSuchElementException, Select, TimeoutException, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.common.exceptions import NoSuchElementException, TimeoutException + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -28,16 +37,12 @@ def parse_data(self, page: str, **kwargs) -> dict: headless = kwargs.get("headless") url = kwargs.get("url") - print( - f"Starting parse_data with parameters: postcode={postcode}, paon={user_paon}" - ) - print( - f"Creating webdriver with: web_driver={web_driver}, headless={headless}" - ) + print("Starting parse_data with configured address parameters") + print(f"Creating configured webdriver (headless={headless})") user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" driver = create_webdriver(web_driver, headless, user_agent, __name__) - print(f"Navigating to URL: {url}") + print("Navigating to the configured council page") driver.get(url) print("Successfully loaded the page") @@ -82,7 +87,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) post_code_input.clear() post_code_input.send_keys(postcode) - print(f"Entered postcode: {postcode}") + print("Postcode entered") driver.switch_to.active_element.send_keys(Keys.TAB + Keys.ENTER) print("Pressed ENTER on Search button") @@ -152,13 +157,13 @@ def parse_data(self, page: str, **kwargs) -> dict: "collectionDate": bin_date, } data["bins"].append(dict_data) - print(f"Successfully added collection: {dict_data}") + print("Collection added successfully") except Exception as e: - print(f"Error processing item: {e}") + print(f"Error processing item ({type(e).__name__})") continue except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred ({type(e).__name__})") raise finally: print("Cleaning up webdriver...") diff --git a/uk_bin_collection/uk_bin_collection/councils/BarnetCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BarnetCouncil.py index 311acc726c..2058f549a5 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BarnetCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BarnetCouncil.py @@ -1,11 +1,10 @@ +from __future__ import annotations + import re import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +18,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: user_postcode = kwargs.get("postcode") @@ -132,9 +141,7 @@ def parse_data(self, page: str, **kwargs) -> dict: date_text = date_el.get_text(strip=True).strip() # Remove ordinal suffixes (1st, 2nd, 3rd, 4th, etc.) - date_text = re.sub( - r"(\d+)(st|nd|rd|th)", r"\1", date_text - ) + date_text = re.sub(r"(\d+)(st|nd|rd|th)", r"\1", date_text) # Parse "Monday, 6 April" try: @@ -172,7 +179,7 @@ def parse_data(self, page: str, **kwargs) -> dict: return bin_data except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/BasildonCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BasildonCouncil.py index fb78cafd00..999564cd5f 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BasildonCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BasildonCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import requests import json from datetime import datetime from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC from uk_bin_collection.uk_bin_collection.common import ( check_uprn, date_format as DATE_FORMAT, @@ -19,30 +17,43 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait, webdriver + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait + from selenium.webdriver.support import expected_conditions as EC + uprn = kwargs.get("uprn") check_uprn(uprn) - + # Try API first try: return self._try_api_method(uprn) except Exception: # Fallback to Selenium method return self._try_selenium_method(uprn, **kwargs) - + def _try_api_method(self, uprn: str) -> dict: - url_base = "https://basildonportal.azurewebsites.net/api/getPropertyRefuseInformation" + url_base = ( + "https://basildonportal.azurewebsites.net/api/getPropertyRefuseInformation" + ) payload = {"uprn": uprn} headers = {"Content-Type": "application/json"} - + response = requests.post(url_base, data=json.dumps(payload), headers=headers) - + if response.status_code != 200: raise Exception(f"API failed with status {response.status_code}") - + data = response.json() bins = [] available_services = data.get("refuse", {}).get("available_services", {}) - + for service_name, service_data in available_services.items(): match service_data["container"]: case "Green Wheelie Bin": @@ -56,55 +67,69 @@ def _try_api_method(self, uprn: str) -> dict: type_descr = service_data.get("name", "Unknown Service") case _: type_descr = service_data.get("container", "Unknown Container") - + date_str = service_data.get("current_collection_date") if date_str: try: date_obj = datetime.strptime(date_str, "%Y-%m-%d") formatted_date = date_obj.strftime(DATE_FORMAT) - bins.append({ - "type": type_descr, - "collectionDate": formatted_date, - }) + bins.append( + { + "type": type_descr, + "collectionDate": formatted_date, + } + ) except ValueError: pass # Skip bins with invalid dates - + return {"bins": bins} - + def _try_selenium_method(self, uprn: str, **kwargs) -> dict: driver = kwargs.get("web_driver") if not driver: raise Exception("Selenium driver required for new portal") - + driver.get("https://mybasildon.powerappsportals.com/check/where_i_live/") - + # Wait for and find postcode input wait = WebDriverWait(driver, 10) postcode_input = wait.until( EC.element_to_be_clickable((By.CSS_SELECTOR, "input[type='text']")) ) - + # Get postcode from UPRN lookup (simplified - would need actual lookup) postcode_input.send_keys("SS14 1EY") # Default postcode for testing - + # Submit form - submit_btn = driver.find_element(By.CSS_SELECTOR, "button[type='submit'], input[type='submit']") + submit_btn = driver.find_element( + By.CSS_SELECTOR, "button[type='submit'], input[type='submit']" + ) submit_btn.click() - + # Wait for results and parse - wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".collection-info, .bin-info"))) - + wait.until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, ".collection-info, .bin-info") + ) + ) + bins = [] # Parse the results from the new portal - collection_elements = driver.find_elements(By.CSS_SELECTOR, ".collection-info, .bin-info") - + collection_elements = driver.find_elements( + By.CSS_SELECTOR, ".collection-info, .bin-info" + ) + for element in collection_elements: bin_type = element.find_element(By.CSS_SELECTOR, ".bin-type").text - collection_date = element.find_element(By.CSS_SELECTOR, ".collection-date").text - - bins.append({ - "type": bin_type, - "collectionDate": collection_date, - }) - + collection_date = element.find_element( + By.CSS_SELECTOR, ".collection-date" + ).text + + bins.append( + { + "type": bin_type, + "collectionDate": collection_date, + } + ) + return {"bins": bins} diff --git a/uk_bin_collection/uk_bin_collection/councils/BexleyCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BexleyCouncil.py index f7355296c0..3e8fffa1ee 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BexleyCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BexleyCouncil.py @@ -71,10 +71,15 @@ def parse_data(self, page: str, **kwargs) -> dict: next_collection = dd.get_text(strip=True) try: - if "collected today" in next_collection.lower() or "could not be collected today" in next_collection.lower(): + if ( + "collected today" in next_collection.lower() + or "could not be collected today" in next_collection.lower() + ): parsed_date = datetime.now() else: - cleaned = remove_ordinal_indicator_from_date_string(next_collection) + cleaned = remove_ordinal_indicator_from_date_string( + next_collection + ) try: parsed_date = datetime.strptime(cleaned, "%A, %d %B %Y") except ValueError: @@ -87,7 +92,7 @@ def parse_data(self, page: str, **kwargs) -> dict: } ) except ValueError as e: - print(f"Error parsing date for {bin_type}: {e}") + print(f"Collection date parsing failed ({type(e).__name__})") break return data diff --git a/uk_bin_collection/uk_bin_collection/councils/BirminghamCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BirminghamCityCouncil.py index a535a992ff..a07782dac1 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BirminghamCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BirminghamCityCouncil.py @@ -4,7 +4,6 @@ from datetime import datetime from dateutil.parser import parse as dateutil_parse from dateutil.parser import ParserError -from yarl import URL from uk_bin_collection.uk_bin_collection.common import ( check_uprn, @@ -89,10 +88,13 @@ def parse_data(self, page: str, **kwargs: Any) -> Dict[str, List[Dict[str, str]] "postcode": postcode, "uprn": uprn, } - url = URL( - "https://www.birmingham.gov.uk/info/50388/check_your_collection_day" - ).with_query(query_string) - response = requests.get(url, headers=HEADERS, timeout=30) + url = "https://www.birmingham.gov.uk/info/50388/check_your_collection_day" + response = requests.get( + url, + params=query_string, + headers=HEADERS, + timeout=30, + ) response.raise_for_status() soup = BeautifulSoup(response.text, "html.parser") diff --git a/uk_bin_collection/uk_bin_collection/councils/BlaenauGwentCountyBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BlaenauGwentCountyBoroughCouncil.py index 119fef279f..a62e4a6c0a 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BlaenauGwentCountyBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BlaenauGwentCountyBoroughCouncil.py @@ -1,8 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait import re import time @@ -12,6 +10,17 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -25,10 +34,10 @@ def parse_data(self, page: str, **kwargs) -> dict: # Create Selenium webdriver user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" driver = create_webdriver(web_driver, headless, user_agent, __name__) - + # Navigate to the main page first driver.get("https://www.blaenau-gwent.gov.uk/en/resident/waste-recycling/") - + # Handle cookie overlay if present try: # Wait a moment for any overlays to appear @@ -40,7 +49,7 @@ def parse_data(self, page: str, **kwargs) -> dict: "//button[contains(text(), 'Accept')]", "//button[contains(text(), 'OK')]", "//button[@id='ccc-recommended-settings']", - "//button[contains(@class, 'cookie')]" + "//button[contains(@class, 'cookie')]", ] for button_xpath in cookie_buttons: try: @@ -52,13 +61,15 @@ def parse_data(self, page: str, **kwargs) -> dict: continue except: pass # No cookie overlay found - + # Find and extract the collection day URL find_collection_link = WebDriverWait(driver, 10).until( - EC.presence_of_element_located((By.XPATH, "//a[contains(text(), 'Find Your Collection Day')]")) + EC.presence_of_element_located( + (By.XPATH, "//a[contains(text(), 'Find Your Collection Day')]") + ) ) collection_url = find_collection_link.get_attribute("href") - + # Navigate to the collection portal driver.get(collection_url) @@ -70,7 +81,9 @@ def parse_data(self, page: str, **kwargs) -> dict: # Click Find button find_button = WebDriverWait(driver, 10).until( - EC.element_to_be_clickable((By.XPATH, "//button[contains(text(), 'Find')]")) + EC.element_to_be_clickable( + (By.XPATH, "//button[contains(text(), 'Find')]") + ) ) find_button.click() @@ -83,42 +96,47 @@ def parse_data(self, page: str, **kwargs) -> dict: # Wait for collection data to load time.sleep(3) # Give JavaScript time to process the selection - + # Wait for the actual collection data to appear WebDriverWait(driver, 20).until( - lambda d: "Your next collections" in d.page_source and ("Recycling" in d.page_source or "Refuse" in d.page_source) + lambda d: "Your next collections" in d.page_source + and ("Recycling" in d.page_source or "Refuse" in d.page_source) ) soup = BeautifulSoup(driver.page_source, features="html.parser") page_text = soup.get_text() - + # Find the collections section in the text if "Your next collections" in page_text: # Extract the section after "Your next collections" collections_section = page_text.split("Your next collections")[1] - collections_section = collections_section.split("Related content")[0] # Stop at Related content - + collections_section = collections_section.split("Related content")[ + 0 + ] # Stop at Related content + # Use regex to find collection patterns # Pattern to match: "Collection Type" followed by "Day Date Month" (stopping before 'followed') - pattern = r'(Recycling collection|Refuse Bin)([A-Za-z]+ \d+ [A-Za-z]+)(?=followed|$|[A-Z])' + pattern = r"(Recycling collection|Refuse Bin)([A-Za-z]+ \d+ [A-Za-z]+)(?=followed|$|[A-Z])" matches = re.findall(pattern, collections_section) - + for bin_type, date_text in matches: try: # Clean up the date text date_text = date_text.strip() if "followed by" in date_text: date_text = date_text.split("followed by")[0].strip() - + # Parse the date collection_date = datetime.strptime(date_text, "%A %d %B") - + # Set the correct year current_year = datetime.now().year current_month = datetime.now().month - + if (current_month > 10) and (collection_date.month < 3): - collection_date = collection_date.replace(year=(current_year + 1)) + collection_date = collection_date.replace( + year=(current_year + 1) + ) else: collection_date = collection_date.replace(year=current_year) @@ -131,9 +149,9 @@ def parse_data(self, page: str, **kwargs) -> dict: pass # Skip if date parsing fails except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: driver.quit() - return data \ No newline at end of file + return data diff --git a/uk_bin_collection/uk_bin_collection/councils/BoltonCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BoltonCouncil.py index 6f617dcde5..0c8a8ade7d 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BoltonCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BoltonCouncil.py @@ -114,7 +114,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise diff --git a/uk_bin_collection/uk_bin_collection/councils/BostonBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BostonBoroughCouncil.py index fdc09dffe0..ffed100924 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BostonBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BostonBoroughCouncil.py @@ -1,14 +1,8 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium.common.exceptions import ( - ElementClickInterceptedException, - NoSuchElementException, - TimeoutException, -) -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -24,19 +18,34 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Retrieve bin collection types and upcoming collection dates for the given address. - + Parameters: page (str): Unused by this implementation (kept for interface compatibility). paon (str, in kwargs): Property/PAON text used to select the correct address option. postcode (str, in kwargs): Postcode to search for addresses. web_driver (optional, in kwargs): Selenium WebDriver instance or web driver identifier to use when creating the driver. headless (bool, optional, in kwargs): Whether to run the browser in headless mode. - + Returns: data (dict): Dictionary with a single key "bins" whose value is a list of dictionaries. Each entry contains: - "type" (str): The bin/collection type name. - "collectionDate" (str): The next collection date formatted according to the module's date_format. """ + global By, EC, ElementClickInterceptedException, NoSuchElementException, TimeoutException, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.common.exceptions import ( + ElementClickInterceptedException, + NoSuchElementException, + TimeoutException, + ) + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -98,9 +107,11 @@ def parse_data(self, page: str, **kwargs) -> dict: # pattern as a fallback in case the council re-renames it again. WebDriverWait(driver, 15).until( EC.presence_of_element_located( - (By.XPATH, - "//select[contains(@id, 'ADDRESSUPRN') or contains(@id, 'ADDRESSSELECTION')]" - " | //div[contains(@id, 'ADDRESSUPRN_chosen') or contains(@id, 'ADDRESSSELECTION_chosen') or contains(@class, 'chosen-container')]") + ( + By.XPATH, + "//select[contains(@id, 'ADDRESSUPRN') or contains(@id, 'ADDRESSSELECTION')]" + " | //div[contains(@id, 'ADDRESSUPRN_chosen') or contains(@id, 'ADDRESSSELECTION_chosen') or contains(@class, 'chosen-container')]", + ) ) ) @@ -110,11 +121,13 @@ def parse_data(self, page: str, **kwargs) -> dict: dropdown_containers = driver.find_elements( By.XPATH, "//div[contains(@id, 'ADDRESSUPRN_chosen') or contains(@id, 'ADDRESSSELECTION_chosen')]" - " | //div[contains(@class, 'chosen-container')]" + " | //div[contains(@class, 'chosen-container')]", ) if dropdown_containers: dropdown = dropdown_containers[0] - driver.execute_script("arguments[0].scrollIntoView({block:'center'});", dropdown) + driver.execute_script( + "arguments[0].scrollIntoView({block:'center'});", dropdown + ) try: dropdown.click() except ElementClickInterceptedException: @@ -125,7 +138,7 @@ def parse_data(self, page: str, **kwargs) -> dict: try: search_input = driver.find_element( By.XPATH, - "//div[contains(@class, 'chosen-container')]//input[contains(@class, 'chosen-search-input') or @type='text']" + "//div[contains(@class, 'chosen-container')]//input[contains(@class, 'chosen-search-input') or @type='text']", ) search_input.clear() search_input.send_keys(user_paon) @@ -138,10 +151,12 @@ def parse_data(self, page: str, **kwargs) -> dict: ) desired_option = WebDriverWait(driver, 10).until( - EC.element_to_be_clickable(( - By.XPATH, - f"//li[contains(@class, 'active-result') and contains(., '{user_paon}')]" - )) + EC.element_to_be_clickable( + ( + By.XPATH, + f"//li[contains(@class, 'active-result') and contains(., '{user_paon}')]", + ) + ) ) desired_option.click() else: @@ -164,7 +179,10 @@ def parse_data(self, page: str, **kwargs) -> dict: # Click the next button to proceed next_button = WebDriverWait(driver, 10).until( EC.presence_of_element_located( - (By.XPATH, "//button[contains(@id, 'NEXT') and contains(@id, 'BBCWASTECOLLECTIONSV2')]") + ( + By.XPATH, + "//button[contains(@id, 'NEXT') and contains(@id, 'BBCWASTECOLLECTIONSV2')]", + ) ) ) next_button.click() @@ -172,7 +190,10 @@ def parse_data(self, page: str, **kwargs) -> dict: # Wait for the collections information to appear WebDriverWait(driver, 10).until( EC.presence_of_element_located( - (By.XPATH, "//div[contains(@class, 'item__title') or contains(@class, 'grid__cell--listitem')]") + ( + By.XPATH, + "//div[contains(@class, 'item__title') or contains(@class, 'grid__cell--listitem')]", + ) ) ) @@ -192,18 +213,18 @@ def parse_data(self, page: str, **kwargs) -> dict: bin_type_elem = bin_div.find("h2", class_="item__title") if not bin_type_elem: continue - + bin_type = bin_type_elem.text.strip() # Find the next collection date content_div = bin_div.find("div", class_="item__content") if not content_div: continue - + date_div = content_div.find("div") if not date_div: continue - + next_collection = date_div.text.strip().replace("Next: ", "") next_collection = datetime.strptime( @@ -224,7 +245,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/BrentCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BrentCouncil.py index 50fe942a57..8ceed738ee 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BrentCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BrentCouncil.py @@ -27,9 +27,11 @@ def parse_data(self, page: str, **kwargs) -> dict: payload = {"postcode": user_postcode} s = requests.Session() - s.headers.update({ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" - }) + s.headers.update( + { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" + } + ) # Make the POST request response = s.post(URI, data=payload) @@ -125,8 +127,11 @@ def parse_data(self, page: str, **kwargs) -> dict: ), } data["bins"].append(dict_data) - print(dict_data) + print("Collection added successfully") except ValueError as e: - print(f"Error parsing date {next_collection}: {e}") + print( + "Collection date parsing failed " + f"({type(e).__name__})" + ) return data diff --git a/uk_bin_collection/uk_bin_collection/councils/BrightonandHoveCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BrightonandHoveCityCouncil.py index df4e588ec5..434e262ee4 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BrightonandHoveCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BrightonandHoveCityCouncil.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # This script pulls (in one hit) the data from Bromley Council Bins Data import datetime import re @@ -6,11 +8,6 @@ import requests from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -27,9 +24,9 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Fetch and parse bin collection data for a given address from Brighton & Hove's collections page. - + This function drives a Selenium browser to the fixed Brighton & Hove collections URL, submits the provided postcode, selects the matching PAON (primary addressable object name) from the resulting address dropdown, submits the selection, and parses the resulting list view into structured bin collection entries. - + Parameters: page (str): Unused; included for compatibility with caller signature. uprn (str, optional): Unique Property Reference Number for the address (passed via kwargs). @@ -37,15 +34,27 @@ def parse_data(self, page: str, **kwargs) -> dict: postcode (str, optional): Postcode to search on the council site (passed via kwargs). web_driver (str or WebDriver, optional): Specification or instance used by create_webdriver to start the browser (passed via kwargs). headless (bool, optional): Whether to run the browser in headless mode (passed via kwargs). - + Returns: dict: A dictionary with a single key "bins" whose value is a list of objects each containing: - "type": bin type string - "collectionDate": collection date string formatted according to the module's date_format - + Raises: Exception: If no dropdown option matching `paon` is found or any other error occurs during navigation or parsing. """ + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -137,11 +146,11 @@ def parse_data(self, page: str, **kwargs) -> dict: data["bins"].append(dict_data) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: # This block ensures that the driver is closed regardless of an exception if driver: driver.quit() - return data \ No newline at end of file + return data diff --git a/uk_bin_collection/uk_bin_collection/councils/BroadlandDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BroadlandDistrictCouncil.py index bd3fe772d6..d20c2f2935 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BroadlandDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BroadlandDistrictCouncil.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # This script pulls (in one hit) the data from Broadland District Council Bins Data # Working command line: # python collect_data.py BroadlandDistrictCouncil "https://area.southnorfolkandbroadland.gov.uk/FindAddress" -p "NR10 3FD" -n "1 Park View, Horsford, Norfolk, NR10 3FD" @@ -7,11 +9,6 @@ from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +17,18 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -32,16 +41,12 @@ def parse_data(self, page: str, **kwargs) -> dict: headless = kwargs.get("headless") url = kwargs.get("url") - print( - f"Starting parse_data with parameters: postcode={postcode}, paon={user_paon}" - ) - print( - f"Creating webdriver with: web_driver={web_driver}, headless={headless}" - ) + print("Starting parse_data with configured address parameters") + print(f"Creating configured webdriver (headless={headless})") user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" driver = create_webdriver(web_driver, headless, user_agent, __name__) - print(f"Navigating to URL: {url}") + print("Navigating to the configured council page") driver.get(url) print("Successfully loaded the page") @@ -86,7 +91,7 @@ def parse_data(self, page: str, **kwargs) -> dict: dropdown_select = Select(address_dropdown) - print(f"Looking for address containing: {user_paon}") + print("Looking for the configured address") found = False user_paon_clean = user_paon.lower().strip() @@ -95,12 +100,15 @@ def parse_data(self, page: str, **kwargs) -> dict: option_text_clean = option.text.lower().strip() if ( - option_text_clean == user_paon_clean # Exact match if full address given - or option_text_clean.startswith(f"{user_paon_clean} ") # Startswith match if just a number + option_text_clean + == user_paon_clean # Exact match if full address given + or option_text_clean.startswith( + f"{user_paon_clean} " + ) # Startswith match if just a number ): option.click() found = True - print(f"Selected address: {option.text.strip()}") + print("Address selected successfully") break if not found: @@ -169,7 +177,7 @@ def parse_data(self, page: str, **kwargs) -> dict: # Extract the full text and remove the bin type to get the date part full_text = text_container.get_text(strip=True) date_text = full_text.replace(bin_type, "").strip() - print(f"Unparsed collection date: {date_text}") + print("Collection date value found") # Parse the date # First, remove any ordinal indicators (1st, 2nd, 3rd, etc.) @@ -189,9 +197,9 @@ def parse_data(self, page: str, **kwargs) -> dict: "collectionDate": bin_date, } data["bins"].append(dict_data) - print(f"Added bin data: {dict_data}") + print("Collection added successfully") except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred ({type(e).__name__})") raise finally: print("Cleaning up webdriver...") diff --git a/uk_bin_collection/uk_bin_collection/councils/BromleyBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BromleyBoroughCouncil.py index 11b054efae..93569c34f0 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BromleyBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BromleyBoroughCouncil.py @@ -1,12 +1,11 @@ +from __future__ import annotations + # This script pulls (in one hit) the data from Bromley Council Bins Data import datetime from datetime import datetime from bs4 import BeautifulSoup from dateutil.relativedelta import relativedelta -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -21,6 +20,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + driver = None try: bin_data_dict = {"bins": []} @@ -97,7 +106,7 @@ def parse_data(self, page: str, **kwargs) -> dict: data["bins"].append(dict_data) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/BroxbourneCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BroxbourneCouncil.py index 3b9c717ec7..8117d431c7 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BroxbourneCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BroxbourneCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + from datetime import datetime import time from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -13,49 +11,66 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + user_uprn = kwargs.get("uprn") user_postcode = kwargs.get("postcode") web_driver = kwargs.get("web_driver") headless = kwargs.get("headless") - + check_uprn(user_uprn) check_postcode(user_postcode) - + bindata = {"bins": []} # Use a realistic user agent to help bypass Cloudflare user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" driver = create_webdriver(web_driver, headless, user_agent, __name__) - + try: driver.get("https://www.broxbourne.gov.uk/bin-collection-date") - + # Wait for Cloudflare challenge to complete print("Waiting for page to load (Cloudflare check)...") try: WebDriverWait(driver, 45).until( - lambda d: "Just a moment" not in d.title and d.title != "" and len(d.find_elements(By.TAG_NAME, "input")) > 0 + lambda d: "Just a moment" not in d.title + and d.title != "" + and len(d.find_elements(By.TAG_NAME, "input")) > 0 ) - print(f"Page loaded: {driver.title}") + print("Page loaded successfully.") except: - print(f"Timeout waiting for page load. Current title: {driver.title}") + print("Timeout waiting for page load.") # Try to continue anyway pass - + time.sleep(8) - + # Handle cookie banner with multiple attempts try: cookie_btn = WebDriverWait(driver, 15).until( - EC.element_to_be_clickable((By.XPATH, "//button[contains(text(), 'Allow all')]")) + EC.element_to_be_clickable( + (By.XPATH, "//button[contains(text(), 'Allow all')]") + ) ) cookie_btn.click() except: pass - + # Find postcode input postcode_input = WebDriverWait(driver, 20).until( - EC.element_to_be_clickable((By.XPATH, "//input[@autocomplete='postal-code']")) + EC.element_to_be_clickable( + (By.XPATH, "//input[@autocomplete='postal-code']") + ) ) postcode_input.clear() @@ -74,52 +89,76 @@ def parse_data(self, page: str, **kwargs) -> dict: ) ) Select(address_select).select_by_value(user_uprn) - + # Click Next button next_btn = WebDriverWait(driver, 15).until( - EC.element_to_be_clickable((By.XPATH, "//button[contains(text(), 'Next')]")) + EC.element_to_be_clickable( + (By.XPATH, "//button[contains(text(), 'Next')]") + ) ) next_btn.click() - + # Get results WebDriverWait(driver, 15).until( - EC.presence_of_element_located((By.XPATH, "//h1[contains(text(), 'When is my bin collection date?')]")) + EC.presence_of_element_located( + ( + By.XPATH, + "//h1[contains(text(), 'When is my bin collection date?')]", + ) + ) ) - + table = WebDriverWait(driver, 15).until( - EC.presence_of_element_located((By.XPATH, "//h1[contains(text(), 'When is my bin collection date?')]/following::table[1]")) + EC.presence_of_element_located( + ( + By.XPATH, + "//h1[contains(text(), 'When is my bin collection date?')]/following::table[1]", + ) + ) ) - - soup = BeautifulSoup(table.get_attribute('outerHTML'), 'html.parser') - rows = soup.find_all('tr') - + + soup = BeautifulSoup(table.get_attribute("outerHTML"), "html.parser") + rows = soup.find_all("tr") + current_year = datetime.now().year current_month = datetime.now().month - + for row in rows[1:]: - columns = row.find_all('td') + columns = row.find_all("td") if len(columns) >= 2: collection_date_text = columns[0].get_text().strip() service = columns[1].get_text().strip() - + if collection_date_text: try: - collection_date = datetime.strptime(collection_date_text, "%a %d %b") + collection_date = datetime.strptime( + collection_date_text, "%a %d %b" + ) if collection_date.month == 1 and current_month != 1: - collection_date = collection_date.replace(year=current_year + 1) + collection_date = collection_date.replace( + year=current_year + 1 + ) else: - collection_date = collection_date.replace(year=current_year) - - bindata["bins"].append({ - "type": service, - "collectionDate": collection_date.strftime("%d/%m/%Y") - }) + collection_date = collection_date.replace( + year=current_year + ) + + bindata["bins"].append( + { + "type": service, + "collectionDate": collection_date.strftime( + "%d/%m/%Y" + ), + } + ) except ValueError: continue - - bindata["bins"].sort(key=lambda x: datetime.strptime(x["collectionDate"], "%d/%m/%Y")) - + + bindata["bins"].sort( + key=lambda x: datetime.strptime(x["collectionDate"], "%d/%m/%Y") + ) + finally: driver.quit() - - return bindata \ No newline at end of file + + return bindata diff --git a/uk_bin_collection/uk_bin_collection/councils/BroxtoweBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BroxtoweBoroughCouncil.py index c5f98c39d7..26a48929a2 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BroxtoweBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BroxtoweBoroughCouncil.py @@ -1,8 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -17,6 +15,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = "https://selfservice.broxtowe.gov.uk/renderform.aspx?t=217&k=9D2EF214E144EE796430597FB475C3892C43C528" @@ -70,7 +79,14 @@ def parse_data(self, page: str, **kwargs) -> dict: paon_lower = user_paon.strip().lower() for option in dropdownSelect.options: text = option.text.strip().lower() - if text and paon_lower and (text.startswith(paon_lower + " ") or text.startswith(paon_lower + ",")): + if ( + text + and paon_lower + and ( + text.startswith(paon_lower + " ") + or text.startswith(paon_lower + ",") + ) + ): option.click() matched = True break @@ -126,7 +142,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/BuckinghamshireCouncil.py b/uk_bin_collection/uk_bin_collection/councils/BuckinghamshireCouncil.py index 2bb9908113..d7bae960ee 100644 --- a/uk_bin_collection/uk_bin_collection/councils/BuckinghamshireCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/BuckinghamshireCouncil.py @@ -111,7 +111,7 @@ def parse_data(self, _: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise return data diff --git a/uk_bin_collection/uk_bin_collection/councils/CanterburyCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/CanterburyCityCouncil.py index 00f34a02fd..49f911d5f2 100644 --- a/uk_bin_collection/uk_bin_collection/councils/CanterburyCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/CanterburyCityCouncil.py @@ -45,7 +45,7 @@ def parse_data(self, page: str, **kwargs) -> dict: } # Loop through each collection in bin_collection for collection in collections: - print(collection) + print("Processing collection entry") if len(collections[collection]) <= 0: continue diff --git a/uk_bin_collection/uk_bin_collection/councils/CardiffCouncil.py b/uk_bin_collection/uk_bin_collection/councils/CardiffCouncil.py index cca28a3a12..b12e5f0c22 100644 --- a/uk_bin_collection/uk_bin_collection/councils/CardiffCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/CardiffCouncil.py @@ -79,7 +79,7 @@ def get_jwt() -> str: raise ValueError("Invalid server response code getting JWT!") except Exception as ex: - print(f"Exception encountered: {ex}") + print(f"Exception encountered: {type(ex).__name__}") exit(1) token = parse_token(response.text) options.close() @@ -144,7 +144,7 @@ def parse_data(self, page: str, **kwargs) -> dict: raise ValueError("Invalid server response code finding UPRN!") except Exception as ex: - print(f"Exception encountered: {ex}") + print(f"Exception encountered: {type(ex).__name__}") exit(1) result = json.loads(response.text) diff --git a/uk_bin_collection/uk_bin_collection/councils/CastlepointDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/CastlepointDistrictCouncil.py index 4766d304ac..a8d8f455f9 100644 --- a/uk_bin_collection/uk_bin_collection/councils/CastlepointDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/CastlepointDistrictCouncil.py @@ -74,7 +74,7 @@ def parse_data(self, page: str, **kwargs) -> dict: continue month_txt = container.find("tr", class_="calendar").get_text(strip=True) month = datetime.strptime(month_txt, "%B").strftime("%m") - print(month_txt) + print("Collection month parsed") pink_days = [ td.get_text(strip=True) diff --git a/uk_bin_collection/uk_bin_collection/councils/CeredigionCountyCouncil.py b/uk_bin_collection/uk_bin_collection/councils/CeredigionCountyCouncil.py index 7ca1fab762..414cbf6485 100644 --- a/uk_bin_collection/uk_bin_collection/councils/CeredigionCountyCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/CeredigionCountyCouncil.py @@ -1,10 +1,8 @@ +from __future__ import annotations + from time import sleep from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.common.exceptions import NoSuchElementException -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +17,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, NoSuchElementException, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.common.exceptions import NoSuchElementException + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: house_number = kwargs.get("paon") @@ -178,14 +187,14 @@ def parse_data(self, page: str, **kwargs) -> dict: } data["bins"].append(dict_data) except Exception as e: - print(f"Skipping one panel due to: {e}") + print(f"Skipping one panel due to: {type(e).__name__}") data["bins"].sort( key=lambda x: datetime.strptime(x.get("collectionDate"), date_format) ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/ChelmsfordCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ChelmsfordCityCouncil.py index 77e79a2caa..a427b2a8b5 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ChelmsfordCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ChelmsfordCityCouncil.py @@ -37,9 +37,7 @@ def parse_data(self, page: str, **kwargs) -> dict: postcode = kwargs.get("postcode") user_paon = kwargs.get("paon") - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" - } + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} session = requests.Session() base_url = "https://www.chelmsford.gov.uk/bins-and-recycling/check-your-collection-day/" @@ -142,7 +140,7 @@ def parse_data(self, page: str, **kwargs) -> dict: } ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise return data diff --git a/uk_bin_collection/uk_bin_collection/councils/ChesterfieldBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ChesterfieldBoroughCouncil.py index 97277c8369..ba3e8de27e 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ChesterfieldBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ChesterfieldBoroughCouncil.py @@ -9,7 +9,6 @@ from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass import urllib3 - # Suppress only the single warning from urllib3 needed. urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -159,7 +158,10 @@ def parse_data(self, page: str, **kwargs) -> dict: dt_local = dt_utc.astimezone(None) collection_date = dt_local.date() except (IndexError, KeyError, ValueError) as e: - _LOGGER.warning(f"Failed to parse date for {waste_type}: {e}") + _LOGGER.warning( + "Failed to parse a collection date (%s).", + type(e).__name__, + ) continue # Append to bin_schedule @@ -179,10 +181,10 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except requests.RequestException as e: - _LOGGER.error(f"Network error occurred: {e}") + _LOGGER.error(f"Network error occurred: {type(e).__name__}") except json.JSONDecodeError as e: - _LOGGER.error(f"JSON decoding failed: {e}") + _LOGGER.error(f"JSON decoding failed: {type(e).__name__}") except Exception as e: - _LOGGER.error(f"An unexpected error occurred: {e}") + _LOGGER.error(f"An unexpected error occurred: {type(e).__name__}") return bindata diff --git a/uk_bin_collection/uk_bin_collection/councils/ChichesterDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ChichesterDistrictCouncil.py index c133e12b90..83ab20a21c 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ChichesterDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ChichesterDistrictCouncil.py @@ -1,16 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support.ui import WebDriverWait, Select -from selenium.webdriver.support import expected_conditions as EC -from selenium.common.exceptions import ( - StaleElementReferenceException, - TimeoutException, - NoSuchElementException, -) from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -36,6 +29,22 @@ class CouncilClass(AbstractGetBinDataClass): DROPDOWN_ID = "WASTECOLLECTIONCALENDARV7_CALENDAR_ADDRESSLOOKUPADDRESS" def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, NoSuchElementException, Select, StaleElementReferenceException, TimeoutException, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support.ui import WebDriverWait, Select + from selenium.webdriver.support import expected_conditions as EC + from selenium.common.exceptions import ( + StaleElementReferenceException, + TimeoutException, + NoSuchElementException, + ) + driver = None try: start_url = "https://www.chichester.gov.uk/checkyourbinday" @@ -171,9 +180,7 @@ def dropdown_has_addresses(d): sel_element = driver.find_element(By.ID, self.DROPDOWN_ID) select = Select(sel_element) - raise Exception( - f"Failed to select '{target_text}' after retries" - ) + raise Exception(f"Failed to select '{target_text}' after retries") @staticmethod def _pick_option(options, house_number: str): @@ -182,8 +189,10 @@ def _pick_option(options, house_number: str): for opt in options: text = opt.text.strip() low = text.lower() - if low == target or low.startswith(f"{target},") or low.startswith( - f"{target} " + if ( + low == target + or low.startswith(f"{target},") + or low.startswith(f"{target} ") ): return text # Fuzzy: substring match diff --git a/uk_bin_collection/uk_bin_collection/councils/ColchesterCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ColchesterCityCouncil.py index 346b74946f..b04cb814b5 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ColchesterCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ColchesterCityCouncil.py @@ -1,7 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -16,6 +15,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -94,7 +103,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/CotswoldDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/CotswoldDistrictCouncil.py index ed8c7ff70f..c62809cad0 100644 --- a/uk_bin_collection/uk_bin_collection/councils/CotswoldDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/CotswoldDistrictCouncil.py @@ -1,24 +1,40 @@ +from __future__ import annotations + import time import re from datetime import datetime, timedelta from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass _BIN_TYPES = { - "180 litre refuse", "black recycling box", "blue bag", "white bag", - "outdoor food caddy", "indoor food caddy", "garden waste", - "240 litre refuse", "recycling box", "food caddy", + "180 litre refuse", + "black recycling box", + "blue bag", + "white bag", + "outdoor food caddy", + "indoor food caddy", + "garden waste", + "240 litre refuse", + "recycling box", + "food caddy", } class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + driver = None try: page = "https://community.cotswold.gov.uk/s/waste-collection-enquiry" @@ -26,7 +42,11 @@ def parse_data(self, page: str, **kwargs) -> dict: house_number = kwargs.get("paon") postcode = kwargs.get("postcode") - if house_number and postcode and postcode.upper() not in house_number.upper(): + if ( + house_number + and postcode + and postcode.upper() not in house_number.upper() + ): full_address = f"{house_number}, {postcode}" else: full_address = house_number or postcode or "" @@ -41,9 +61,7 @@ def parse_data(self, page: str, **kwargs) -> dict: time.sleep(8) address_entry_field = wait.until( - EC.presence_of_element_located( - (By.XPATH, "//input[@role='combobox']") - ) + EC.presence_of_element_located((By.XPATH, "//input[@role='combobox']")) ) address_entry_field.click() @@ -52,9 +70,7 @@ def parse_data(self, page: str, **kwargs) -> dict: time.sleep(4) wait.until( - EC.element_to_be_clickable( - (By.XPATH, "//li[@role='presentation']") - ) + EC.element_to_be_clickable((By.XPATH, "//li[@role='presentation']")) ) all_opts = driver.find_elements(By.XPATH, "//li[@role='presentation']") if not all_opts: @@ -78,7 +94,9 @@ def parse_data(self, page: str, **kwargs) -> dict: for _ in range(8): time.sleep(5) - if driver.find_elements(By.XPATH, "//*[contains(text(), 'Collection day')]"): + if driver.find_elements( + By.XPATH, "//*[contains(text(), 'Collection day')]" + ): break soup = BeautifulSoup(driver.page_source, features="html.parser") @@ -92,23 +110,23 @@ def parse_data(self, page: str, **kwargs) -> dict: td = row.find("td") if not th or not td: continue - container_type = ( - th.get("data-cell-value", "").strip() - or th.get_text(strip=True) - ) - raw_date = ( - td.get("data-cell-value", "").strip() - or td.get_text(strip=True) + container_type = th.get( + "data-cell-value", "" + ).strip() or th.get_text(strip=True) + raw_date = td.get("data-cell-value", "").strip() or td.get_text( + strip=True ) if container_type and raw_date: try: parsed_date = self._parse_date(raw_date, current_year) except (ValueError, AttributeError): continue - data["bins"].append({ - "type": container_type, - "collectionDate": parsed_date, - }) + data["bins"].append( + { + "type": container_type, + "collectionDate": parsed_date, + } + ) except (ValueError, AttributeError): continue else: @@ -122,13 +140,15 @@ def parse_data(self, page: str, **kwargs) -> dict: parsed_date = self._parse_date(raw_date, current_year) except (ValueError, AttributeError): continue - data["bins"].append({ - "type": line, - "collectionDate": parsed_date, - }) + data["bins"].append( + { + "type": line, + "collectionDate": parsed_date, + } + ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: @@ -138,7 +158,9 @@ def parse_data(self, page: str, **kwargs) -> dict: @staticmethod def _looks_like_date(text): t = text.lower().strip() - return t in ("today", "tomorrow") or bool(re.match(r"^(mon|tue|wed|thu|fri|sat|sun)", t)) + return t in ("today", "tomorrow") or bool( + re.match(r"^(mon|tue|wed|thu|fri|sat|sun)", t) + ) @staticmethod def _parse_date(raw_date, current_year): diff --git a/uk_bin_collection/uk_bin_collection/councils/CroydonCouncil.py b/uk_bin_collection/uk_bin_collection/councils/CroydonCouncil.py index df642a45fe..ffa6cde199 100644 --- a/uk_bin_collection/uk_bin_collection/councils/CroydonCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/CroydonCouncil.py @@ -1,11 +1,8 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +16,18 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait, webdriver + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: user_postcode = kwargs.get("postcode") @@ -120,14 +129,17 @@ def parse_data(self, page: str, **kwargs) -> dict: } bin_data["bins"].append(bin_info) except ValueError as e: - print(f"Error parsing date '{collection_date_string}': {e}") + print( + "Collection date parsing failed " + f"({type(e).__name__})" + ) if not bin_data["bins"]: raise ValueError("No bin collection data found") except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/DacorumBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/DacorumBoroughCouncil.py index 089c7462a5..c683cfac66 100644 --- a/uk_bin_collection/uk_bin_collection/councils/DacorumBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/DacorumBoroughCouncil.py @@ -1,7 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -16,6 +15,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -81,17 +90,30 @@ def parse_data(self, page: str, **kwargs) -> dict: if strong_element: BinType = strong_element.text.strip() # Skip if this is not a bin type (e.g., informational text) - if BinType and not any(skip_text in BinType.lower() for skip_text in - ["please note", "we may collect", "bank holiday", "different day"]): - date_cells = Collection.find_all("div", {"style": "display:table-cell;"}) + if BinType and not any( + skip_text in BinType.lower() + for skip_text in [ + "please note", + "we may collect", + "bank holiday", + "different day", + ] + ): + date_cells = Collection.find_all( + "div", {"style": "display:table-cell;"} + ) if len(date_cells) > 1: date_text = date_cells[1].get_text().strip() if date_text: try: - CollectionDate = datetime.strptime(date_text, "%a, %d %b %Y") + CollectionDate = datetime.strptime( + date_text, "%a, %d %b %Y" + ) dict_data = { "type": BinType, - "collectionDate": CollectionDate.strftime("%d/%m/%Y"), + "collectionDate": CollectionDate.strftime( + "%d/%m/%Y" + ), } # Check for duplicates before adding if dict_data not in data["bins"]: @@ -102,7 +124,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/DerbyshireDalesDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/DerbyshireDalesDistrictCouncil.py index 8cbf19b5ad..2795160bd6 100644 --- a/uk_bin_collection/uk_bin_collection/councils/DerbyshireDalesDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/DerbyshireDalesDistrictCouncil.py @@ -94,7 +94,7 @@ def get_hidden_value(soup, name): ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/DurhamCouncil.py b/uk_bin_collection/uk_bin_collection/councils/DurhamCouncil.py index 810b4c7f10..64e060463d 100644 --- a/uk_bin_collection/uk_bin_collection/councils/DurhamCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/DurhamCouncil.py @@ -78,9 +78,7 @@ def parse_data(self, page: str, **kwargs) -> dict: # volume suffix. Whitespace is flexible throughout. fullmatch() is # used so unexpected leading/trailing content surfaces as a warning # rather than being silently absorbed. - name_pattern = re.compile( - r"(?:Empty\s+Bin\s+)?(.+?)(?:\s+\d+\s*[Ll])?" - ) + name_pattern = re.compile(r"(?:Empty\s+Bin\s+)?(.+?)(?:\s+\d+\s*[Ll])?") next_dates = {} for job in soup.find_all("job"): @@ -92,7 +90,7 @@ def parse_data(self, page: str, **kwargs) -> dict: raw_name = name_tag.get_text(strip=True) match = name_pattern.fullmatch(raw_name) if not match: - _LOGGER.warning("Could not parse bin name %r", raw_name) + _LOGGER.warning("Could not parse a collection type") continue label = match.group(1).strip() @@ -101,11 +99,7 @@ def parse_data(self, page: str, **kwargs) -> dict: start_tag.get_text(strip=True)[:10], "%Y-%m-%d" ) except ValueError: - _LOGGER.warning( - "Could not parse scheduled date %r for bin %r", - start_tag.get_text(strip=True), - raw_name, - ) + _LOGGER.warning("Could not parse a scheduled collection date") continue if scheduled < today: @@ -115,9 +109,11 @@ def parse_data(self, page: str, **kwargs) -> dict: data = {"bins": []} for bin_type, collection_date in next_dates.items(): - data["bins"].append({ - "type": bin_type, - "collectionDate": collection_date.strftime(date_format), - }) + data["bins"].append( + { + "type": bin_type, + "collectionDate": collection_date.strftime(date_format), + } + ) return data diff --git a/uk_bin_collection/uk_bin_collection/councils/EastLindseyDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/EastLindseyDistrictCouncil.py index 82a57e3b7a..ce0577754c 100644 --- a/uk_bin_collection/uk_bin_collection/councils/EastLindseyDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/EastLindseyDistrictCouncil.py @@ -1,7 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -16,6 +15,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -101,7 +110,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/EastRenfrewshireCouncil.py b/uk_bin_collection/uk_bin_collection/councils/EastRenfrewshireCouncil.py index 6dcfc2ce03..fd789fdf4d 100644 --- a/uk_bin_collection/uk_bin_collection/councils/EastRenfrewshireCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/EastRenfrewshireCouncil.py @@ -1,8 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait -from selenium.webdriver.support.ui import Select from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -16,6 +14,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + from selenium.webdriver.support.ui import Select + driver = None try: data = {"bins": []} @@ -41,9 +50,7 @@ def parse_data(self, page: str, **kwargs) -> dict: # Click search button search_button = WebDriverWait(driver, 10).until( - EC.element_to_be_clickable( - (By.XPATH, "//button[text()='Search']") - ) + EC.element_to_be_clickable((By.XPATH, "//button[text()='Search']")) ) search_button.click() @@ -53,7 +60,7 @@ def parse_data(self, page: str, **kwargs) -> dict: (By.XPATH, "//label[text()='Addresses']/following-sibling::select") ) ) - + # Select the appropriate address based on UPRN or house number select = Select(addresses_select) if user_uprn: @@ -79,31 +86,31 @@ def parse_data(self, page: str, **kwargs) -> dict: # Wait for the results table to appear WebDriverWait(driver, 10).until( - EC.presence_of_element_located( - (By.XPATH, "//th[text()='Bin Type']") - ) + EC.presence_of_element_located((By.XPATH, "//th[text()='Bin Type']")) ) soup = BeautifulSoup(driver.page_source, features="html.parser") - + # Find the table with bin collection data table = soup.find("th", string="Bin Type").find_parent("table") rows = table.find_all("tr")[1:] # Skip header row - + for row in rows: cells = row.find_all("td") if len(cells) >= 3: date_cell = cells[0].get_text().strip() bin_type_cell = cells[2] - + # Only process rows that have a date if date_cell: # Get all text content including line breaks - bin_type_text = bin_type_cell.get_text(separator='\n').strip() - + bin_type_text = bin_type_cell.get_text(separator="\n").strip() + # Split multiple bin types that appear on separate lines - bin_types = [bt.strip() for bt in bin_type_text.split('\n') if bt.strip()] - + bin_types = [ + bt.strip() for bt in bin_type_text.split("\n") if bt.strip() + ] + for bin_type in bin_types: dict_data = { "type": bin_type, @@ -115,7 +122,7 @@ def parse_data(self, page: str, **kwargs) -> dict: key=lambda x: datetime.strptime(x.get("collectionDate"), "%d/%m/%Y") ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/EastStaffordshireBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/EastStaffordshireBoroughCouncil.py index 8926bbbca2..4412c4def3 100644 --- a/uk_bin_collection/uk_bin_collection/councils/EastStaffordshireBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/EastStaffordshireBoroughCouncil.py @@ -1,9 +1,7 @@ +from __future__ import annotations + import requests from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +17,17 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + bindata = {"bins": []} soup = BeautifulSoup(page.text, features="html.parser") diff --git a/uk_bin_collection/uk_bin_collection/councils/EastSuffolkCouncil.py b/uk_bin_collection/uk_bin_collection/councils/EastSuffolkCouncil.py index 082cceac31..d93465c653 100644 --- a/uk_bin_collection/uk_bin_collection/councils/EastSuffolkCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/EastSuffolkCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + from time import sleep from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +19,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: user_uprn = kwargs.get("uprn") @@ -121,7 +130,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py index 9b6c46b29d..8f95881f63 100644 --- a/uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/EastleighBoroughCouncil.py @@ -1,7 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -16,6 +15,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + try: uprn = kwargs.get("uprn") # Check the UPRN is valid @@ -81,7 +90,7 @@ def parse_data(self, page: str, **kwargs) -> dict: import traceback error_message = f"Error fetching/parsing data for Eastleigh: {str(e)}\n{traceback.format_exc()}" - print(error_message) + print(f"Eastleigh data retrieval failed ({type(e).__name__}).") # Use the correct date format for the error fallback today = datetime.now().strftime("%d/%m/%Y") return {"bins": [{"type": "Error", "collectionDate": today}]} diff --git a/uk_bin_collection/uk_bin_collection/councils/EnfieldCouncil.py b/uk_bin_collection/uk_bin_collection/councils/EnfieldCouncil.py index f91d7707a2..2b073a96f0 100644 --- a/uk_bin_collection/uk_bin_collection/councils/EnfieldCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/EnfieldCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait import pdb from uk_bin_collection.uk_bin_collection.common import * @@ -19,6 +18,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: user_postcode = kwargs.get("postcode") @@ -42,18 +51,20 @@ def parse_data(self, page: str, **kwargs) -> dict: for attempt in range(max_attempts): try: WebDriverWait(driver, 60).until( - lambda d: "Just a moment" not in d.title and d.title != "" and len(d.find_elements(By.TAG_NAME, "input")) > 1 + lambda d: "Just a moment" not in d.title + and d.title != "" + and len(d.find_elements(By.TAG_NAME, "input")) > 1 ) - print(f"Page loaded: {driver.title}") + print("Page loaded successfully.") break except: - print(f"Attempt {attempt + 1}: Timeout waiting for page load. Current title: {driver.title}") + print(f"Attempt {attempt + 1}: timeout waiting for page load.") if attempt < max_attempts - 1: time.sleep(10) driver.refresh() else: print("Failed to bypass Cloudflare after multiple attempts") - + time.sleep(8) try: @@ -70,27 +81,30 @@ def parse_data(self, page: str, **kwargs) -> dict: # Check for multiple iframes and find the correct one try: iframes = driver.find_elements(By.TAG_NAME, "iframe") - + # Try each iframe to find the one with the bin collection form for i, iframe in enumerate(iframes): try: driver.switch_to.frame(iframe) - + # Check if this iframe has the postcode input time.sleep(2) inputs = driver.find_elements(By.TAG_NAME, "input") - + # Look for address-related inputs for inp in inputs: - aria_label = inp.get_attribute('aria-label') or '' - placeholder = inp.get_attribute('placeholder') or '' - if 'address' in aria_label.lower() or 'postcode' in placeholder.lower(): + aria_label = inp.get_attribute("aria-label") or "" + placeholder = inp.get_attribute("placeholder") or "" + if ( + "address" in aria_label.lower() + or "postcode" in placeholder.lower() + ): break else: # This iframe doesn't have the form, try the next one driver.switch_to.default_content() continue - + # Found the right iframe, break out of the loop break except Exception as e: @@ -108,9 +122,9 @@ def parse_data(self, page: str, **kwargs) -> dict: '[aria-label="Enter your address"]', 'input[placeholder*="postcode"]', 'input[placeholder*="address"]', - 'input[type="text"]' + 'input[type="text"]', ] - + for selector in selectors: try: postcode_input = WebDriverWait(driver, 5).until( @@ -119,7 +133,7 @@ def parse_data(self, page: str, **kwargs) -> dict: break except: continue - + if not postcode_input: raise ValueError("Could not find postcode input field") @@ -214,7 +228,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/EpsomandEwellBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/EpsomandEwellBoroughCouncil.py index 4de73ac35b..8dbe4e5e4c 100644 --- a/uk_bin_collection/uk_bin_collection/councils/EpsomandEwellBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/EpsomandEwellBoroughCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import re from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +17,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.ui import WebDriverWait + user_uprn = kwargs.get("uprn") user_postcode = kwargs.get("postcode") check_uprn(user_uprn) @@ -31,18 +40,18 @@ def parse_data(self, page: str, **kwargs) -> dict: kwargs.get("web_driver"), kwargs.get("headless", True), user_agent, - __name__ + __name__, ) - + # Navigate to the iTouchVision portal portal_url = "https://iportal.itouchvision.com/icollectionday/collection-day/?uuid=8E7DCC4BD90D8405D154BE053147018A8C0B5F09" driver.get(portal_url) - + # Wait for postcode input to be present postcode_input = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "postcodeSearch")) ) - + # Enter postcode using JavaScript to trigger React events if user_postcode: postcode = user_postcode @@ -50,7 +59,7 @@ def parse_data(self, page: str, **kwargs) -> dict: # If no postcode provided, we need to derive it from UPRN # For now, raise an error raise ValueError("Postcode is required for EpsomandEwellBoroughCouncil") - + driver.execute_script(f""" const input = document.getElementById('postcodeSearch'); const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; @@ -58,94 +67,101 @@ def parse_data(self, page: str, **kwargs) -> dict: input.dispatchEvent(new Event('input', {{ bubbles: true }})); input.dispatchEvent(new Event('change', {{ bubbles: true }})); """) - + # Click the Find button find_button = driver.find_element(By.CSS_SELECTOR, ".govuk-button") find_button.click() - + # Wait for address dropdown to appear and be populated address_select = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "addressSelect")) ) - + # Wait a bit for options to populate import time + time.sleep(2) - + # Select the address by UPRN value using JavaScript driver.execute_script(f""" const select = document.getElementById('addressSelect'); select.value = '{user_uprn}'; select.dispatchEvent(new Event('change', {{ bubbles: true }})); """) - + # Wait for collection data to load (look for h3 elements with bin types) WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "h3")) ) - + # Wait a bit more for all data to render time.sleep(3) - + # Get the page source and parse with BeautifulSoup soup = BeautifulSoup(driver.page_source, "html.parser") - + # Find all h3 elements (these contain bin types) h3_elements = soup.find_all("h3") - + for h3 in h3_elements: bin_type = h3.text.strip() - + # Skip if empty if not bin_type: continue - + # Get the next sibling element which should contain the date next_elem = h3.find_next_sibling() if not next_elem: continue - + date_text = next_elem.text.strip() - + # Parse date in format "Thursday 11 December" # Need to add current year try: # Extract day and month from "Thursday 11 December" format - match = re.search(r'(\w+)\s+(\d{1,2})\s+(\w+)', date_text) + match = re.search(r"(\w+)\s+(\d{1,2})\s+(\w+)", date_text) if match: day = match.group(2) month = match.group(3) - + # Determine the year (if month is in the past, use next year) current_date = datetime.now() current_year = current_date.year - + # Try parsing with current year try: - date_obj = datetime.strptime(f"{day} {month} {current_year}", "%d %B %Y") + date_obj = datetime.strptime( + f"{day} {month} {current_year}", "%d %B %Y" + ) # If the date is more than 30 days in the past, assume it's next year if (current_date - date_obj).days > 30: - date_obj = datetime.strptime(f"{day} {month} {current_year + 1}", "%d %B %Y") + date_obj = datetime.strptime( + f"{day} {month} {current_year + 1}", "%d %B %Y" + ) except ValueError: # Try with next year - date_obj = datetime.strptime(f"{day} {month} {current_year + 1}", "%d %B %Y") - + date_obj = datetime.strptime( + f"{day} {month} {current_year + 1}", "%d %B %Y" + ) + collection_date = date_obj.strftime(date_format) - + dict_data = { "type": bin_type, "collectionDate": collection_date, } bindata["bins"].append(dict_data) except Exception as e: - print(f"Error parsing date '{date_text}': {e}") + print("Collection date parsing failed " f"({type(e).__name__})") continue - + # Sort by collection date bindata["bins"].sort( key=lambda x: datetime.strptime(x.get("collectionDate"), "%d/%m/%Y") ) - + finally: if driver: driver.quit() diff --git a/uk_bin_collection/uk_bin_collection/councils/FalkirkCouncil.py b/uk_bin_collection/uk_bin_collection/councils/FalkirkCouncil.py index 977d7ae2b9..5e2f263529 100644 --- a/uk_bin_collection/uk_bin_collection/councils/FalkirkCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/FalkirkCouncil.py @@ -35,8 +35,8 @@ def parse_data(self, page: str, **kwargs) -> dict: # Loop through the dates for each collection type for date in collection_dates: - print(f"Bin Type: {bin_type}") - print(f"Collection Date: {date}") + print("Collection type parsed") + print("Collection date parsed") dict_data = { "type": bin_type, diff --git a/uk_bin_collection/uk_bin_collection/councils/FifeCouncil.py b/uk_bin_collection/uk_bin_collection/councils/FifeCouncil.py index 6fdd028f1e..38d7d35ad5 100644 --- a/uk_bin_collection/uk_bin_collection/councils/FifeCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/FifeCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -21,22 +19,33 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Parse bin collection data for a given postcode and house identifier by driving the council bin-calendar web UI and returning structured collection entries. - + Parameters: page (str): Unused; kept for API compatibility. postcode (str, in kwargs): The postcode to search for. paon (str, in kwargs): The property identifier (house number or name) used to pick the best address option from the dropdown. web_driver (str, optional, in kwargs): WebDriver backend identifier passed to the webdriver factory. headless (bool, optional, in kwargs): Whether to run the browser in headless mode. - + Returns: dict: A dictionary with a "bins" key containing a list of entries. Each entry is a dict with: - "type": the collection colour/name extracted from the image alt text (or None if missing). - "collectionDate": the collection date string formatted according to the module's date_format. - + Raises: ValueError: If the provided paon cannot be matched to any dropdown address or if the collections table cannot be found. """ + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: # Get and check UPRN @@ -92,9 +101,9 @@ def _best_option(): # Prefer exact contains on visible text; fallback to casefold contains """ Selects the first dropdown option whose visible text contains the normalized PAON. - + Performs a case-insensitive containment check using the precomputed `paon_norm` against each option's visible text and returns the first match. - + Returns: `WebElement` of the first matching option if found, `None` otherwise. """ @@ -159,11 +168,11 @@ def _best_option(): except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: # This block ensures that the driver is closed regardless of an exception if driver: driver.quit() - return bindata \ No newline at end of file + return bindata diff --git a/uk_bin_collection/uk_bin_collection/councils/ForestOfDeanDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ForestOfDeanDistrictCouncil.py index bec7e856b2..ba37e7dae3 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ForestOfDeanDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ForestOfDeanDistrictCouncil.py @@ -1,24 +1,40 @@ +from __future__ import annotations + import time import re from datetime import datetime, timedelta from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass _BIN_TYPES = { - "180 litre refuse", "black recycling box", "blue bag", "white bag", - "outdoor food caddy", "indoor food caddy", "garden waste", - "240 litre refuse", "recycling box", "food caddy", + "180 litre refuse", + "black recycling box", + "blue bag", + "white bag", + "outdoor food caddy", + "indoor food caddy", + "garden waste", + "240 litre refuse", + "recycling box", + "food caddy", } class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + driver = None try: page = "https://community.fdean.gov.uk/s/waste-collection-enquiry" @@ -26,7 +42,11 @@ def parse_data(self, page: str, **kwargs) -> dict: house_number = kwargs.get("paon") postcode = kwargs.get("postcode") - if house_number and postcode and postcode.upper() not in house_number.upper(): + if ( + house_number + and postcode + and postcode.upper() not in house_number.upper() + ): full_address = f"{house_number}, {postcode}" else: full_address = house_number or postcode or "" @@ -41,9 +61,7 @@ def parse_data(self, page: str, **kwargs) -> dict: time.sleep(8) address_entry_field = wait.until( - EC.presence_of_element_located( - (By.XPATH, "//input[@role='combobox']") - ) + EC.presence_of_element_located((By.XPATH, "//input[@role='combobox']")) ) address_entry_field.click() @@ -52,9 +70,7 @@ def parse_data(self, page: str, **kwargs) -> dict: time.sleep(4) wait.until( - EC.element_to_be_clickable( - (By.XPATH, "//li[@role='presentation']") - ) + EC.element_to_be_clickable((By.XPATH, "//li[@role='presentation']")) ) all_opts = driver.find_elements(By.XPATH, "//li[@role='presentation']") if len(all_opts) > 1: @@ -72,7 +88,9 @@ def parse_data(self, page: str, **kwargs) -> dict: for _ in range(8): time.sleep(5) - if driver.find_elements(By.XPATH, "//*[contains(text(), 'Collection day')]"): + if driver.find_elements( + By.XPATH, "//*[contains(text(), 'Collection day')]" + ): break soup = BeautifulSoup(driver.page_source, features="html.parser") @@ -87,19 +105,21 @@ def parse_data(self, page: str, **kwargs) -> dict: td = row.find("td") if not th or not td: continue - container_type = ( - th.get("data-cell-value", "").strip() - or th.get_text(strip=True) - ) - raw_date = ( - td.get("data-cell-value", "").strip() - or td.get_text(strip=True) + container_type = th.get( + "data-cell-value", "" + ).strip() or th.get_text(strip=True) + raw_date = td.get("data-cell-value", "").strip() or td.get_text( + strip=True ) if container_type and raw_date: - data["bins"].append({ - "type": container_type, - "collectionDate": self._parse_date(raw_date, current_year), - }) + data["bins"].append( + { + "type": container_type, + "collectionDate": self._parse_date( + raw_date, current_year + ), + } + ) except (ValueError, AttributeError): continue else: @@ -109,13 +129,17 @@ def parse_data(self, page: str, **kwargs) -> dict: if line.lower() in _BIN_TYPES and i + 1 < len(lines): raw_date = lines[i + 1] if self._looks_like_date(raw_date): - data["bins"].append({ - "type": line, - "collectionDate": self._parse_date(raw_date, current_year), - }) + data["bins"].append( + { + "type": line, + "collectionDate": self._parse_date( + raw_date, current_year + ), + } + ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: @@ -125,7 +149,9 @@ def parse_data(self, page: str, **kwargs) -> dict: @staticmethod def _looks_like_date(text): t = text.lower().strip() - return t in ("today", "tomorrow") or bool(re.match(r"^(mon|tue|wed|thu|fri|sat|sun)", t)) + return t in ("today", "tomorrow") or bool( + re.match(r"^(mon|tue|wed|thu|fri|sat|sun)", t) + ) @staticmethod def _parse_date(raw_date, current_year): diff --git a/uk_bin_collection/uk_bin_collection/councils/GatesheadCouncil.py b/uk_bin_collection/uk_bin_collection/councils/GatesheadCouncil.py index 2b20e38e89..702fd268a7 100644 --- a/uk_bin_collection/uk_bin_collection/councils/GatesheadCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/GatesheadCouncil.py @@ -1,8 +1,7 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -17,6 +16,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -177,7 +186,7 @@ def parse_data(self, page: str, **kwargs) -> dict: } data["bins"].append(dict_data) except Exception as e: - print(f"Error parsing date for row: {e}") + print(f"Error parsing date for row: {type(e).__name__}") continue data["bins"].sort( @@ -185,7 +194,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/GlasgowCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/GlasgowCityCouncil.py index 1737719dba..eaad30c1eb 100644 --- a/uk_bin_collection/uk_bin_collection/councils/GlasgowCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/GlasgowCityCouncil.py @@ -55,7 +55,7 @@ def parse_data(self, page: str, **kwargs) -> dict: collection_date = (datetime.today() + timedelta(days=1)).strftime( date_format ) - print(collection_date) + print("Collection date parsed") else: collection_date = datetime.strptime( collection_date, diff --git a/uk_bin_collection/uk_bin_collection/councils/GloucesterCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/GloucesterCityCouncil.py index 2825e3e928..0363038de9 100644 --- a/uk_bin_collection/uk_bin_collection/councils/GloucesterCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/GloucesterCityCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -21,6 +19,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = "https://gloucester-self.achieveservice.com/service/Bins___Check_your_bin_day" @@ -112,7 +121,7 @@ def is_a_collection_date(t): except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/GreatYarmouthBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/GreatYarmouthBoroughCouncil.py index ed4f655d88..8829e691e5 100644 --- a/uk_bin_collection/uk_bin_collection/councils/GreatYarmouthBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/GreatYarmouthBoroughCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +18,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: user_uprn = kwargs.get("uprn") @@ -106,7 +115,9 @@ def parse_data(self, page: str, **kwargs) -> dict: try: # Parse the date text - date_obj = datetime.strptime(date_text + " " + str(datetime.today().year), "%A %d %B %Y") + date_obj = datetime.strptime( + date_text + " " + str(datetime.today().year), "%A %d %B %Y" + ) if date_obj.date() < datetime.today().date(): continue # Skip past dates except ValueError: @@ -131,21 +142,19 @@ def parse_data(self, page: str, **kwargs) -> dict: ) ): next_collections[name] = bin_entry - print( - f"Found next collection for {name}: {formatted_date}" - ) # Debug output + print("Next collection parsed successfully") break # Add the next collections to the bin_data bin_data["bins"] = list(next_collections.values()) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: driver.quit() print("\nFinal bin data:") - print(bin_data) # Debug output + print(f"Parsed {len(bin_data.get('bins', []))} collections") return bin_data diff --git a/uk_bin_collection/uk_bin_collection/councils/GuildfordCouncil.py b/uk_bin_collection/uk_bin_collection/councils/GuildfordCouncil.py index a2ada06efc..1d906d3bdc 100644 --- a/uk_bin_collection/uk_bin_collection/councils/GuildfordCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/GuildfordCouncil.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # This script pulls (in one hit) the data from Bromley Council Bins Data import datetime import re @@ -6,11 +8,6 @@ import requests from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -25,6 +22,18 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: postcode = kwargs.get("postcode") @@ -140,7 +149,7 @@ def parse_data(self, page: str, **kwargs) -> dict: data["bins"].append(dict_data) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/HaltonBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/HaltonBoroughCouncil.py index 792ba23fa2..e19ff48dfa 100644 --- a/uk_bin_collection/uk_bin_collection/councils/HaltonBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/HaltonBoroughCouncil.py @@ -1,12 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -24,20 +21,32 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Retrieve bin collection dates for a property from Halton Council's waste service. - + This method loads the council's waste service page, submits the provided property identifier and postcode, parses the resulting collection schedule, and returns structured bin collection entries. - + Parameters: paon (str, via kwargs): Property identifier — house number or property name. postcode (str, via kwargs): Property postcode. web_driver (str or selenium.webdriver, via kwargs): Optional webdriver backend identifier or instance passed to create_webdriver. headless (bool, via kwargs): If True, the browser is created in headless mode. - + Returns: dict: A dictionary with a single key "bins" containing a list of collection entries. Each entry is a dict with: - "type" (str): Waste type name (capitalized). - "collectionDate" (str): Collection date formatted as "DD/MM/YYYY". """ + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -155,11 +164,11 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: # This block ensures that the driver is closed regardless of an exception if driver: driver.quit() - return data \ No newline at end of file + return data diff --git a/uk_bin_collection/uk_bin_collection/councils/HerefordshireCouncil.py b/uk_bin_collection/uk_bin_collection/councils/HerefordshireCouncil.py index da561c9815..4b1e7c9021 100644 --- a/uk_bin_collection/uk_bin_collection/councils/HerefordshireCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/HerefordshireCouncil.py @@ -48,7 +48,9 @@ def parse_data(self, page: str, **kwargs) -> dict: soup = BeautifulSoup(page.text, "html.parser") soup.prettify - checkValid = any("Your next collection days" in h2.get_text() for h2 in soup.find_all("h2")) + checkValid = any( + "Your next collection days" in h2.get_text() for h2 in soup.find_all("h2") + ) if not checkValid: raise ValueError("Address/UPRN not found") @@ -74,12 +76,14 @@ def parse_data(self, page: str, **kwargs) -> dict: next_date = li.get_text(strip=True).replace(" (next collection)", "") - logging.info(f"Bin type: {bin_type} - Collection date: {next_date}") + logging.info("Collection entry parsed successfully") data["bins"].append( { "type": bin_type, - "collectionDate": datetime.strptime(next_date, "%A %d %B %Y").strftime(date_format), + "collectionDate": datetime.strptime( + next_date, "%A %d %B %Y" + ).strftime(date_format), } ) diff --git a/uk_bin_collection/uk_bin_collection/councils/HertsmereBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/HertsmereBoroughCouncil.py index 2e1b55d796..35cb19a8c8 100644 --- a/uk_bin_collection/uk_bin_collection/councils/HertsmereBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/HertsmereBoroughCouncil.py @@ -1,12 +1,10 @@ +from __future__ import annotations + import re import time import requests from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -21,6 +19,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: user_paon = kwargs.get("paon") @@ -51,11 +60,9 @@ def parse_data(self, page: str, **kwargs) -> dict: # Wait for results to appear WebDriverWait(driver, 10).until( - EC.presence_of_element_located( - (By.CSS_SELECTOR, "ul.result_list li") - ) + EC.presence_of_element_located((By.CSS_SELECTOR, "ul.result_list li")) ) - + # Use JavaScript to click the correct address # Add space after house number to match exactly (e.g., "1 " not "10", "11", etc.) driver.execute_script(f""" @@ -96,16 +103,14 @@ def parse_data(self, page: str, **kwargs) -> dict: table_data = [] for row in table.find("tbody").find_all("tr"): # Extract cell data from each tag - row_data = [ - cell.get_text(strip=True) for cell in row.find_all("td") - ] + row_data = [cell.get_text(strip=True) for cell in row.find_all("td")] table_data.append(row_data) # The table structure is: [Bin Type, Collection Day, Round Code] # All bins are collected on the same day (e.g., "Thursday") if not table_data or len(table_data[0]) < 2: raise Exception("Unable to parse collection schedule from table.") - + collection_day = table_data[0][1] # e.g., "Thursday" # Extract all bin types @@ -116,7 +121,7 @@ def parse_data(self, page: str, **kwargs) -> dict: # Calculate next collection dates based on the collection day from datetime import datetime, timedelta - + days_of_week = [ "Monday", "Tuesday", @@ -138,7 +143,9 @@ def parse_data(self, page: str, **kwargs) -> dict: next_day = today + timedelta(days=days_until_target) # Generate collection dates for the next 12 weeks (all bins collected weekly) - all_dates = get_dates_every_x_days(next_day, 7, 12) # 12 collections, every 7 days + all_dates = get_dates_every_x_days( + next_day, 7, 12 + ) # 12 collections, every 7 days # Assign all bin types to each collection date for date in all_dates: @@ -155,7 +162,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/HighPeakCouncil.py b/uk_bin_collection/uk_bin_collection/councils/HighPeakCouncil.py index 32e356751d..dcf8d0b666 100644 --- a/uk_bin_collection/uk_bin_collection/councils/HighPeakCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/HighPeakCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + import re from datetime import datetime -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +19,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + driver = None try: user_postcode = kwargs.get("postcode") @@ -98,7 +107,7 @@ def parse_data(self, page: str, **kwargs) -> dict: key=lambda x: datetime.strptime(x["collectionDate"], date_format) ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/Hillingdon.py b/uk_bin_collection/uk_bin_collection/councils/Hillingdon.py index 09b8d3d410..6240bbf1d3 100644 --- a/uk_bin_collection/uk_bin_collection/councils/Hillingdon.py +++ b/uk_bin_collection/uk_bin_collection/councils/Hillingdon.py @@ -1,19 +1,11 @@ +from __future__ import annotations + import json from datetime import datetime, timedelta from typing import Any, Dict from bs4 import BeautifulSoup from dateutil.parser import parse -from selenium.common.exceptions import ( - NoSuchElementException, - StaleElementReferenceException, - TimeoutException, -) -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.remote.webdriver import WebDriver -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -86,17 +78,37 @@ def get_bank_holiday_changes(driver: WebDriver) -> Dict[str, str]: ) changes[normal_date] = revised_date except Exception as e: - print(f"Error parsing dates: {e}") + print(f"Error parsing dates: {type(e).__name__}") continue except Exception as e: - print(f"An error occurred while fetching bank holiday changes: {e}") + print( + "An error occurred while fetching bank holiday changes " + f"({type(e).__name__})." + ) return changes class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs: Any) -> Dict[str, Any]: + global By, EC, Keys, NoSuchElementException, StaleElementReferenceException, TimeoutException, WebDriver, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.common.exceptions import ( + NoSuchElementException, + StaleElementReferenceException, + TimeoutException, + ) + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.remote.webdriver import WebDriver + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data: Dict[str, Any] = {"bins": []} @@ -264,7 +276,7 @@ def parse_data(self, page: str, **kwargs: Any) -> Dict[str, Any]: } ) except Exception as e: - print(f"Error processing item: {e}") + print(f"Error processing item: {type(e).__name__}") continue # Get bank holiday changes @@ -276,13 +288,11 @@ def parse_data(self, page: str, **kwargs: Any) -> Dict[str, Any]: original_date = bin_data["collectionDate"] if original_date in bank_holiday_changes: new_date = bank_holiday_changes[original_date] - print( - f"Bank holiday change: {bin_data['type']} collection moved from {original_date} to {new_date}" - ) + print("Applied a bank-holiday collection-date adjustment.") bin_data["collectionDate"] = new_date except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: @@ -290,6 +300,6 @@ def parse_data(self, page: str, **kwargs: Any) -> Dict[str, Any]: # Print the final data dictionary for debugging print("\nFinal data dictionary:") - print(json.dumps(data, indent=2)) + print(f"Parsed {len(data.get('bins', []))} collections") return data diff --git a/uk_bin_collection/uk_bin_collection/councils/HyndburnBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/HyndburnBoroughCouncil.py index 4fbed58d2f..901d0c984c 100644 --- a/uk_bin_collection/uk_bin_collection/councils/HyndburnBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/HyndburnBoroughCouncil.py @@ -1,10 +1,8 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +17,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: user_postcode = kwargs.get("postcode") @@ -123,19 +132,19 @@ def parse_data(self, page: str, **kwargs) -> dict: bin_info = {"type": bin_type, "collectionDate": formatted_date} bin_data["bins"].append(bin_info) except ValueError as e: - print(f"Error parsing date {collection_date_string}: {e}") + print("Collection date parsing failed " f"({type(e).__name__})") continue if not bin_data["bins"]: raise ValueError("No collection data found") - print(bin_data) + print(f"Parsed {len(bin_data.get('bins', []))} collections") return bin_data except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/IsleOfAngleseyCouncil.py b/uk_bin_collection/uk_bin_collection/councils/IsleOfAngleseyCouncil.py index 9a97c9ec62..cf54cb59a0 100644 --- a/uk_bin_collection/uk_bin_collection/councils/IsleOfAngleseyCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/IsleOfAngleseyCouncil.py @@ -27,7 +27,7 @@ class CouncilClass(AbstractGetBinDataClass): def __init__(self): """ Initialize the CouncilClass instance. - + Calls the superclass initializer, creates a requests.Session assigned to self._session, and sets self._have_session to False to indicate that an authenticated session has not yet been established. """ super().__init__() @@ -37,9 +37,9 @@ def __init__(self): def _initialise_session(self) -> None: """ Establish an authenticated session with the remote service and mark the instance as having a valid session. - + Performs an HTTP GET to the configured session endpoint and verifies the JSON response contains an "auth-session" indicator. On success sets the instance flag that a session is available. - + Raises: requests.HTTPError: If the session request returned an HTTP error status. ValueError: If the response JSON cannot be decoded or does not contain an "auth-session" key. @@ -58,14 +58,14 @@ def _initialise_session(self) -> None: def _run_lookup(self, lookup_id: str, payload: dict) -> dict: """ Run a lookup request and return the lookup's transformed rows data. - + Parameters: lookup_id (str): Lookup identifier appended as the `id` query parameter to the lookup endpoint. payload (dict): JSON body sent with the POST request. - + Returns: The `rows_data` value extracted from the response's `integration.transformed` object. - + Raises: ValueError: If the response is not valid JSON or does not contain the expected `integration.transformed.rows_data` structure. """ @@ -83,20 +83,20 @@ def _run_lookup(self, lookup_id: str, payload: dict) -> dict: except requests.exceptions.JSONDecodeError as e: raise ValueError("Failed to decode lookup response as JSON") from e except KeyError as e: - logger.debug(f"Lookup response content: {response.text}") + logger.debug("Lookup response content omitted after schema mismatch.") raise ValueError("Unexpected response structure from lookup") from e def _get_uprn_from_postcode_and_paon(self, postcode: str, paon: str) -> str: """ Return the UPRN for the address at the given postcode matching the provided PAON. - + Parameters: postcode (str): Postcode to search. paon (str): Primary Addressable Object Name — house number or name to match. - + Returns: str: The matching UPRN. - + Raises: ValueError: If no addresses are found for the postcode or no address matches the PAON. """ @@ -135,7 +135,7 @@ def _get_uprn_from_postcode_and_paon(self, postcode: str, paon: str) -> str: def parse_data(self, page: str, **kwargs) -> dict: """ Obtain the bin collection schedule for a property identified by UPRN or by postcode and house number/name. - + Parameters: page (str): Unused but required by the interface. **kwargs: Identification parameters — provide either: @@ -143,10 +143,10 @@ def parse_data(self, page: str, **kwargs) -> dict: OR postcode (str): The property's postcode. paon (str) or number (str): The property's primary addressable object name/number (required with postcode). - + Returns: dict: A dictionary with a "bins" key mapping to a list of collection entries, each containing `type` and `collectionDate`. - + Raises: ValueError: If required identification parameters are missing, input validation fails, or the remote lookup cannot resolve the property. """ @@ -191,16 +191,16 @@ def parse_data(self, page: str, **kwargs) -> dict: def _extract_bin_data(schedule: dict) -> dict: """ Convert a schedule response into the standardized bins list. - + Parameters: schedule (dict): Mapping of schedule rows returned by the API where each value is a dict containing at least the keys "Service" and "Date". - + Returns: dict: {"bins": [ {"type": , "collectionDate": } , ... ]} - + Raises: ValueError: If `schedule` is empty. - + Notes: - Rows missing required fields or containing unparsable dates are skipped and a warning is logged. """ @@ -232,6 +232,6 @@ def _extract_bin_data(schedule: dict) -> dict: } ) except (KeyError, ValueError) as e: - logger.warning(f"Skipping invalid row: {e}", exc_info=True) + logger.warning("Skipping invalid row (%s).", type(e).__name__) - return {"bins": bins} \ No newline at end of file + return {"bins": bins} diff --git a/uk_bin_collection/uk_bin_collection/councils/IsleOfWightCouncil.py b/uk_bin_collection/uk_bin_collection/councils/IsleOfWightCouncil.py index e45e131177..6ccc8e95e5 100644 --- a/uk_bin_collection/uk_bin_collection/councils/IsleOfWightCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/IsleOfWightCouncil.py @@ -13,22 +13,32 @@ from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass RECYCLING_COLORS = [ - (0.5, 0.0, 1.0, 0.0), # CMYK purple (2024-25 PDF) - (0.584, 0.757, 0.12), # RGB green (2025-26 PDF) + (0.5, 0.0, 1.0, 0.0), # CMYK purple (2024-25 PDF) + (0.584, 0.757, 0.12), # RGB green (2025-26 PDF) ] NON_RECYCLABLE_COLORS = [ - (0.0, 0.0, 0.0, 0.383), # CMYK grey (2024-25 PDF) - (0.713, 0.713, 0.712), # RGB grey (2025-26 PDF) + (0.0, 0.0, 0.0, 0.383), # CMYK grey (2024-25 PDF) + (0.713, 0.713, 0.712), # RGB grey (2025-26 PDF) ] HEADER_BG_COLORS = [ (0.527, 0.323, 0.0, 0.0), # CMYK brown (2024-25 PDF) - (0.525, 0.628, 0.828), # RGB blue (2025-26 PDF) + (0.525, 0.628, 0.828), # RGB blue (2025-26 PDF) ] COLOR_TOLERANCE = 0.08 MONTHS = [ - "JANUARY", "FEBRUARY", "MARCH", "APRIL", "MAY", "JUNE", - "JULY", "AUGUST", "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER", + "JANUARY", + "FEBRUARY", + "MARCH", + "APRIL", + "MAY", + "JUNE", + "JULY", + "AUGUST", + "SEPTEMBER", + "OCTOBER", + "NOVEMBER", + "DECEMBER", ] PDF_CACHE_DIR = "/tmp/iow-pdf-cache" @@ -76,12 +86,14 @@ def _parse_calendar_pdf(pdf_path, collection_day): for w in words: upper = w["text"].upper() if upper in MONTHS: - month_headers.append({ - "month": MONTHS.index(upper) + 1, - "x0": w["x0"], - "top": w["top"], - "bottom": w.get("bottom", w["top"] + 12), - }) + month_headers.append( + { + "month": MONTHS.index(upper) + 1, + "x0": w["x0"], + "top": w["top"], + "bottom": w.get("bottom", w["top"] + 12), + } + ) if not month_headers: raise ValueError("No month headers found in PDF calendar") @@ -91,8 +103,13 @@ def _parse_calendar_pdf(pdf_path, collection_day): mh["year"] = start_year if mh["month"] >= first_month else start_year + 1 day_map = { - "MONDAY": 0, "TUESDAY": 1, "WEDNESDAY": 2, - "THURSDAY": 3, "FRIDAY": 4, "SATURDAY": 5, "SUNDAY": 6, + "MONDAY": 0, + "TUESDAY": 1, + "WEDNESDAY": 2, + "THURSDAY": 3, + "FRIDAY": 4, + "SATURDAY": 5, + "SUNDAY": 6, } target_weekday = day_map.get(collection_day.upper()) if target_weekday is None: @@ -226,9 +243,9 @@ def _select_address(options_text, user_paon): if user_paon: paon_lower = user_paon.lower().strip() for label in options_text: - if label.lower().startswith( - paon_lower + "," - ) or label.lower().startswith(paon_lower + " "): + if label.lower().startswith(paon_lower + ",") or label.lower().startswith( + paon_lower + " " + ): return label for label in options_text: @@ -245,9 +262,7 @@ def _extract_collection_info(html): collection_day_el = soup.find("strong", string=re.compile(r"Collection Day:")) if collection_day_el: collection_day = ( - collection_day_el.parent.get_text() - .replace("Collection Day:", "") - .strip() + collection_day_el.parent.get_text().replace("Collection Day:", "").strip() ) else: raise ValueError("Could not find collection day in page") @@ -270,10 +285,12 @@ def _build_bins(collection_dates): for dt, bin_type in collection_dates: if dt >= today: - data["bins"].append({ - "type": bin_type, - "collectionDate": dt.strftime(date_format), - }) + data["bins"].append( + { + "type": bin_type, + "collectionDate": dt.strftime(date_format), + } + ) return data @@ -286,6 +303,7 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + ensure_selenium_dependencies() from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select, WebDriverWait @@ -355,14 +373,12 @@ def parse_data(self, page: str, **kwargs) -> dict: "Referer": "https://digitalservices.iow.gov.uk/wasteday", } - pdf_path = _download_pdf_cached( - pdf_url, cookies=cookies, headers=headers - ) + pdf_path = _download_pdf_cached(pdf_url, cookies=cookies, headers=headers) collection_dates = _parse_calendar_pdf(pdf_path, collection_day) return _build_bins(collection_dates) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/KingstonUponThamesCouncil.py b/uk_bin_collection/uk_bin_collection/councils/KingstonUponThamesCouncil.py index 2807caabae..26c01a430e 100644 --- a/uk_bin_collection/uk_bin_collection/councils/KingstonUponThamesCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/KingstonUponThamesCouncil.py @@ -1,6 +1,8 @@ +from __future__ import annotations + # alternative implementation for retrieving bin data from Kingston Upon Thames Council # principal URL is https://waste-services.kingston.gov.uk/waste/[uprn] -# https://www.kingston.gov.uk/bins-and-recycling/collections/check-your-bin-collection-day +# https://www.kingston.gov.uk/bins-and-recycling/collections/check-your-bin-collection-day # switched to using Selenium as the htmx elements are not rendered reliably with requests # updated Jan 2026 due to small website formatting changes @@ -8,10 +10,6 @@ import re from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -27,6 +25,17 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait, webdriver + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + driver = None try: @@ -65,7 +74,9 @@ def parse_data(self, page: str, **kwargs) -> dict: f"Kingston parser: missing dl.govuk-summary-list for {service_name}" ) - rows = summary_list.find_all("div", {"class": "govuk-summary-list__row"}) + rows = summary_list.find_all( + "div", {"class": "govuk-summary-list__row"} + ) for row in rows: dt = row.find("dt") if dt and dt.get_text().strip().lower() == "next collection": @@ -74,9 +85,11 @@ def parse_data(self, page: str, **kwargs) -> dict: raise ValueError( f"Kingston parser: missing dd element for 'next collection' in {service_name}" ) - collection_date = remove_ordinal_indicator_from_date_string( - dd.get_text() - ).strip().replace(" (In progress)", "") + collection_date = ( + remove_ordinal_indicator_from_date_string(dd.get_text()) + .strip() + .replace(" (In progress)", "") + ) # strip out any text inside of the date string collection_date = re.sub( r"\n\s*\(this.*?\)", "", collection_date @@ -100,7 +113,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/KnowsleyMBCouncil.py b/uk_bin_collection/uk_bin_collection/councils/KnowsleyMBCouncil.py index 1473d2fe30..c9b96c5754 100644 --- a/uk_bin_collection/uk_bin_collection/councils/KnowsleyMBCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/KnowsleyMBCouncil.py @@ -1,10 +1,8 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup from datetime import datetime -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -12,6 +10,17 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + driver = None try: bindata = {"bins": []} @@ -135,7 +144,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/LeedsCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/LeedsCityCouncil.py index 8237b18bee..fd217e138a 100644 --- a/uk_bin_collection/uk_bin_collection/councils/LeedsCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/LeedsCityCouncil.py @@ -40,7 +40,7 @@ def parse_data(self, page: str, **kwargs) -> dict: # Send GET request response = requests.get(URI, params=params, headers=headers) - print(response.content) + print(f"Council response received (HTTP {response.status_code}).") collections = json.loads(response.content) @@ -59,7 +59,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/LondonBoroughHarrow.py b/uk_bin_collection/uk_bin_collection/councils/LondonBoroughHarrow.py index 66c8268145..6ae270270c 100644 --- a/uk_bin_collection/uk_bin_collection/councils/LondonBoroughHarrow.py +++ b/uk_bin_collection/uk_bin_collection/councils/LondonBoroughHarrow.py @@ -15,13 +15,12 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: - """ Fetch bin collection data for a property and return normalized bin types with formatted dates. - + Parameters: uprn (str): Unique Property Reference Number used to query the council's bin collections endpoint (passed via kwargs). - + Returns: dict: A dictionary with a "bins" key mapping to a list of collection records. Each record is a dict with: - "type" (str): Bin type. @@ -45,7 +44,7 @@ def parse_data(self, page: str, **kwargs) -> dict: for collection in bin_collection["results"]["collections"]["all"]: CollectTime = (collection["eventTime"]).split("T")[0] - print(CollectTime) + print("Collection schedule parsed") dict_data = { "type": collection["binType"], @@ -55,4 +54,4 @@ def parse_data(self, page: str, **kwargs) -> dict: } bindata["bins"].append(dict_data) - return bindata \ No newline at end of file + return bindata diff --git a/uk_bin_collection/uk_bin_collection/councils/LondonBoroughRedbridge.py b/uk_bin_collection/uk_bin_collection/councils/LondonBoroughRedbridge.py index 1534115b35..2541268cfb 100644 --- a/uk_bin_collection/uk_bin_collection/councils/LondonBoroughRedbridge.py +++ b/uk_bin_collection/uk_bin_collection/councils/LondonBoroughRedbridge.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # This script pulls (in one hit) the data from Bromley Council Bins Data import datetime import re @@ -6,11 +8,6 @@ import requests from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -25,6 +22,18 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -96,7 +105,9 @@ def extract_collection_data(collection_div, collection_type, class_prefix): ) # e.g., "February 2026 " # Parse the date string (format: "04 February 2026 ") - date_string = f"{collection_date} {collection_month.strip()}" + date_string = ( + f"{collection_date} {collection_month.strip()}" + ) try: # Convert the date string to a datetime object @@ -121,13 +132,12 @@ def extract_collection_data(collection_div, collection_type, class_prefix): except ValueError as e: # Handle the case where the date format is invalid print( - f"Error parsing date '{date_string}' for {collection_type}: {e}" + "Collection date parsing failed " + f"({type(e).__name__})" ) # Extract Refuse collection data - refuse_div = soup.find( - "div", class_="container-fluid RegularCollectionDay" - ) + refuse_div = soup.find("div", class_="container-fluid RegularCollectionDay") if refuse_div and refuse_div.find(class_="refuse-container"): extract_collection_data(refuse_div, "Refuse", "refuse") @@ -150,7 +160,7 @@ def extract_collection_data(collection_div, collection_type, class_prefix): # Print the extracted data except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/MaidstoneBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/MaidstoneBoroughCouncil.py index ede74bde7a..af8dae7292 100644 --- a/uk_bin_collection/uk_bin_collection/councils/MaidstoneBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/MaidstoneBoroughCouncil.py @@ -1,12 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +17,18 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = "https://my.maidstone.gov.uk/service/Find-your-bin-day" @@ -97,11 +106,11 @@ def parse_data(self, page: str, **kwargs) -> dict: } ) except Exception as inner_e: - print(f"Skipping one panel due to error: {inner_e}") + print(f"Skipping one panel due to error: {type(inner_e).__name__}") except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/ManchesterCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ManchesterCityCouncil.py index dd5fa7104b..082cbde233 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ManchesterCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ManchesterCityCouncil.py @@ -64,12 +64,12 @@ def parse_data(self, page: str, **kwargs) -> dict: r.raise_for_status() result = r.json() - print(result["data"]) + print("Council response decoded successfully.") for key, value in result["data"].items(): if key.startswith("ahtm_dates_"): - print(key) - print(value) + print("Collection field parsed") + print("Collection field value parsed") dates_list = [ datetime.strptime(date.strip(), "%d/%m/%Y %H:%M:%S").date() diff --git a/uk_bin_collection/uk_bin_collection/councils/MeltonBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/MeltonBoroughCouncil.py index 9aed04a3a8..a6a060ce9d 100644 --- a/uk_bin_collection/uk_bin_collection/councils/MeltonBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/MeltonBoroughCouncil.py @@ -77,6 +77,6 @@ def parse_data(self, page: str, **kwargs) -> dict: } data["bins"].append(dict_data) - print(json.dumps(data, indent=2)) + print(f"Parsed {len(data.get('bins', []))} collections") return data diff --git a/uk_bin_collection/uk_bin_collection/councils/MidSuffolkDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/MidSuffolkDistrictCouncil.py index 2e357b2759..501c4dd1f9 100644 --- a/uk_bin_collection/uk_bin_collection/councils/MidSuffolkDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/MidSuffolkDistrictCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +19,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: web_driver = kwargs.get("web_driver") @@ -152,7 +161,7 @@ def _populated_select(d): ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/MidSussexDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/MidSussexDistrictCouncil.py index ccfa4cfbb7..14f4938727 100644 --- a/uk_bin_collection/uk_bin_collection/councils/MidSussexDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/MidSussexDistrictCouncil.py @@ -49,8 +49,7 @@ def parse_data(self, page: str, **kwargs) -> dict: alink = soup.find( "a", - string=lambda s: s - and "View my collections" in s, + string=lambda s: s and "View my collections" in s, ) if alink is None: @@ -114,5 +113,5 @@ def parse_data(self, page: str, **kwargs) -> dict: return bindata except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error(f"An error occurred: {type(e).__name__}") raise diff --git a/uk_bin_collection/uk_bin_collection/councils/MidUlsterDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/MidUlsterDistrictCouncil.py index 88f9237f4e..69debd91ac 100644 --- a/uk_bin_collection/uk_bin_collection/councils/MidUlsterDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/MidUlsterDistrictCouncil.py @@ -1,12 +1,10 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait # import selenium keys -from selenium.webdriver.common.keys import Keys from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -21,6 +19,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + from selenium.webdriver.common.keys import Keys + driver = None try: user_postcode = kwargs.get("postcode") @@ -106,7 +115,7 @@ def parse_data(self, page: str, **kwargs) -> dict: else: collection_date = None except Exception as e: - print(f"Failed to parse date: {e}") + print(f"Failed to parse date: {type(e).__name__}") collection_date = None # 2. Extract bin types @@ -130,7 +139,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/MiddlesbroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/MiddlesbroughCouncil.py index bd4bc6e02b..c471aad043 100644 --- a/uk_bin_collection/uk_bin_collection/councils/MiddlesbroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/MiddlesbroughCouncil.py @@ -12,14 +12,14 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Retrieve upcoming bin collection types and the next collection date for the provided address (paon) within the Middlesbrough service. - + Queries the Recollect API to resolve the provided address to a place_id, fetches the place calendar payload, extracts the "Next Collection" date and associated bin types, and returns them in a normalized structure. If the address cannot be resolved to a place_id, the function returns None. - + Parameters: page (str): Page or URL context (not used by this implementation). kwargs: paon (str): The primary addressable object name/number (e.g., house number or name). Required to look up the address. - + Returns: dict: A dictionary with the key "bins" mapping to a list of collection entries: { @@ -137,5 +137,5 @@ def extract_next_collection(payload: dict): return data except Exception as e: - print(f"An error occurred: {e}") - raise \ No newline at end of file + print(f"An error occurred: {type(e).__name__}") + raise diff --git a/uk_bin_collection/uk_bin_collection/councils/MidlothianCouncil.py b/uk_bin_collection/uk_bin_collection/councils/MidlothianCouncil.py index 837ad1ad3e..e81577fb05 100644 --- a/uk_bin_collection/uk_bin_collection/councils/MidlothianCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/MidlothianCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + import time from datetime import datetime -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import Select, WebDriverWait -from selenium.webdriver.support import expected_conditions as EC from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -34,6 +33,16 @@ class CouncilClass(AbstractGetBinDataClass): } def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import Select, WebDriverWait + from selenium.webdriver.support import expected_conditions as EC + house_identifier = (kwargs.get("paon") or kwargs.get("number") or "").strip() user_postcode = kwargs.get("postcode") web_driver = kwargs.get("web_driver") @@ -51,11 +60,13 @@ def parse_data(self, page: str, **kwargs) -> dict: # undetected_chromedriver in non-headless mode via the Xvfb display # available on the VPS. import os + driver = None try: if os.environ.get("DISPLAY") and web_driver is None: try: import undetected_chromedriver as uc + uc_opts = uc.ChromeOptions() uc_opts.add_argument("--no-sandbox") uc_opts.add_argument("--disable-dev-shm-usage") @@ -126,9 +137,7 @@ def dropdown_populated(d): } ) - bins.sort( - key=lambda x: datetime.strptime(x["collectionDate"], date_format) - ) + bins.sort(key=lambda x: datetime.strptime(x["collectionDate"], date_format)) return {"bins": bins} finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/NeathPortTalbotCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NeathPortTalbotCouncil.py index c358b1aa9d..1d72cb19c5 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NeathPortTalbotCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NeathPortTalbotCouncil.py @@ -1,10 +1,8 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +17,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -112,8 +121,7 @@ def parse_data(self, page: str, **kwargs) -> dict: if date_text != "Bank Holidays": try: bin_date = datetime.strptime( - date_text - .removesuffix("(Today)") + date_text.removesuffix("(Today)") .removesuffix("(Tomorrow)") .replace(" ", " ") + " " @@ -128,7 +136,9 @@ def parse_data(self, page: str, **kwargs) -> dict: }, ): if bin_date and bin_type_wrapper: - bin_type = bin_type_wrapper.find("a").get_text(strip=True) + bin_type = bin_type_wrapper.find("a").get_text( + strip=True + ) bin_type += ( " (" + bin_type_wrapper.find("span").get_text(strip=True) @@ -148,7 +158,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/NewForestCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NewForestCouncil.py index b88ba8033f..35e40244b0 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NewForestCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NewForestCouncil.py @@ -1,14 +1,9 @@ +from __future__ import annotations + import logging import time from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.common.exceptions import NoSuchElementException -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -42,7 +37,7 @@ def get_legacy_bins(self, page: str) -> []: "collectionDate": next_collection, } ) - logging.info(f"Rubbish and Recycling: {str(next_collection)}") + logging.info("Rubbish and recycling date parsed.") # Glass collection glass_collection = soup.find("span", class_="CTID-78-_ eb-78-textControl") @@ -54,7 +49,7 @@ def get_legacy_bins(self, page: str) -> []: legacy_bins.append( {"type": "Glass collection", "collectionDate": match.group(1)} ) - logging.info(f"Glass: {str(match.group(1))}") + logging.info("Glass collection date parsed.") # Garden waste garden_waste = soup.find("div", class_="eb-2HIpCnWC-Override-EditorInput") @@ -64,12 +59,26 @@ def get_legacy_bins(self, page: str) -> []: legacy_bins.append( {"type": "Garden waste", "collectionDate": match.group(1)} ) - logging.info(f"Garden: {str(match.group(1))}") + logging.info("Garden-waste collection date parsed.") # return bins return legacy_bins def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, NoSuchElementException, Select, WebDriverWait, webdriver + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium import webdriver + from selenium.common.exceptions import NoSuchElementException + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: bins = [] @@ -106,7 +115,7 @@ def parse_data(self, page: str, **kwargs) -> dict: "arguments[0].scrollIntoView();", input_element_postcode ) - logging.info(f"Entering postcode '{str(user_postcode)}'") + logging.info("Entering the configured postcode.") # Force the value through the DOM cos send_keys just don't work for some reason :( driver.execute_script( f"arguments[0].value='{str(user_postcode)}'", input_element_postcode @@ -193,7 +202,7 @@ def parse_data(self, page: str, **kwargs) -> dict: return {"bins": bins} except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error(f"An error occurred: {type(e).__name__}") raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/NewportCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NewportCityCouncil.py index d90a66bd97..1c8469bdab 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NewportCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NewportCityCouncil.py @@ -32,10 +32,10 @@ class CouncilClass(AbstractGetBinDataClass): def encode_body(self, newport_input: NewportInput): """ Encrypt a NewportInput dataclass using AES-CBC and encode the resulting ciphertext as a hex string. - + Parameters: newport_input (NewportInput): Dataclass instance to serialize to JSON and encrypt. The instance is converted to a dict via `asdict()` before serialization. - + Returns: str: Hex-encoded AES-CBC ciphertext of the JSON-serialized input. Encryption uses the module-level `key_hex` and `iv_hex` values and applies PKCS#7 padding. """ @@ -56,13 +56,12 @@ def encode_body(self, newport_input: NewportInput): return ciphertext.hex() def decode_response(self, hex_input: str): - """ Decrypts a hex-encoded AES-CBC ciphertext and returns the parsed JSON payload. - + Parameters: hex_input (str): Hex-encoded AES-CBC ciphertext to decrypt. - + Returns: The Python object produced by JSON decoding the decrypted UTF-8 plaintext (typically a dict). """ @@ -84,13 +83,13 @@ def decode_response(self, hex_input: str): def parse_data(self, _: str, **kwargs) -> dict: """ Fetch collection-day information for a given UPRN and return it as a normalized bins dictionary. - + Parameters: _: str Unused placeholder parameter kept for signature compatibility. kwargs: uprn (str): Unique Property Reference Number to query; this value is validated before use. - + Returns: dict: A dictionary with a "bins" key containing a list of mappings: - "type": the bin type string from the service response. @@ -116,10 +115,12 @@ def parse_data(self, _: str, **kwargs) -> dict: ) output = response.text - + # Check if API returned HTML error page instead of encrypted data - if output.strip().startswith('<'): - raise ValueError(f"API returned HTML error page instead of encrypted data. Status: {response.status_code}") + if output.strip().startswith("<"): + raise ValueError( + f"API returned HTML error page instead of encrypted data. Status: {response.status_code}" + ) decoded_bins = self.decode_response(output) data: dict[str, list[dict[str, str]]] = {} @@ -135,7 +136,7 @@ def parse_data(self, _: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise - return data \ No newline at end of file + return data diff --git a/uk_bin_collection/uk_bin_collection/councils/NorthDevonCountyCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NorthDevonCountyCouncil.py index 992353c682..952692ae41 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NorthDevonCountyCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NorthDevonCountyCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + from time import sleep from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -18,6 +17,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: user_uprn = kwargs.get("uprn") @@ -143,14 +152,17 @@ def parse_data(self, page: str, **kwargs) -> dict: } data["bins"].append(dict_data) except Exception as e: - print(f"Skipping invalid date '{full_date}': {e}") + print( + "Skipping an invalid collection date " + f"({type(e).__name__})" + ) data["bins"].sort( key=lambda x: datetime.strptime(x.get("collectionDate"), date_format) ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/NorthEastDerbyshireDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NorthEastDerbyshireDistrictCouncil.py index 4980010bad..bde83ad7cd 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NorthEastDerbyshireDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NorthEastDerbyshireDistrictCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + from datetime import datetime from time import sleep from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +17,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = ( @@ -97,7 +106,7 @@ def parse_data(self, page: str, **kwargs) -> dict: bin_types = bin_label.get_text().strip() if bin_label else None if collection_date and bin_types: - print(f"Found collection: {collection_date} - {bin_types}") + print("Collection entry parsed") # Parse date once formatted_date = datetime.strptime( @@ -115,10 +124,10 @@ def parse_data(self, page: str, **kwargs) -> dict: ) print(f"Found {len(data['bins'])} collections") - print(f"Final data: {data}") + print("Collection data parsing complete") except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/NorthHertfordshireDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NorthHertfordshireDistrictCouncil.py index 35cbd52344..4255602c06 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NorthHertfordshireDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NorthHertfordshireDistrictCouncil.py @@ -1,17 +1,14 @@ +from __future__ import annotations + import re import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass - _ORDINAL_RE = re.compile(r"(\d+)(?:st|nd|rd|th)", re.IGNORECASE) @@ -35,6 +32,17 @@ def parse_data(self, page: str, **kwargs) -> dict: against the typeahead list item text. web_driver, headless: passed through to create_webdriver. """ + global By, EC, Keys, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: user_postcode = kwargs.get("postcode") @@ -57,10 +65,12 @@ def parse_data(self, page: str, **kwargs) -> dict: # Typeahead input — Liberty's visible field uses this class search_input = wait.until( - EC.element_to_be_clickable(( - By.CSS_SELECTOR, - "input.relation_path_type_ahead_search", - )) + EC.element_to_be_clickable( + ( + By.CSS_SELECTOR, + "input.relation_path_type_ahead_search", + ) + ) ) search_input.click() search_input.clear() @@ -101,23 +111,25 @@ def parse_data(self, page: str, **kwargs) -> dict: time.sleep(0.5) submit_btn = wait.until( - EC.element_to_be_clickable(( - By.CSS_SELECTOR, - 'input[type="submit"][aria-label="Select address and continue"]', - )) + EC.element_to_be_clickable( + ( + By.CSS_SELECTOR, + 'input[type="submit"][aria-label="Select address and continue"]', + ) + ) ) submit_btn.click() # Wait for the result page — URL pattern ends in "show-details" - WebDriverWait(driver, 30).until( - lambda d: "show-details" in d.current_url - ) + WebDriverWait(driver, 30).until(lambda d: "show-details" in d.current_url) # Wait for the bin content to render WebDriverWait(driver, 30).until( - EC.presence_of_element_located(( - By.XPATH, - "//strong[normalize-space()='Next collection']", - )) + EC.presence_of_element_located( + ( + By.XPATH, + "//strong[normalize-space()='Next collection']", + ) + ) ) soup = BeautifulSoup(driver.page_source, "html.parser") @@ -173,10 +185,12 @@ def parse_data(self, page: str, **kwargs) -> dict: parsed = datetime.strptime(raw, "%A %d %B %Y") except ValueError: continue - data["bins"].append({ - "type": current_type, - "collectionDate": parsed.strftime(date_format), - }) + data["bins"].append( + { + "type": current_type, + "collectionDate": parsed.strftime(date_format), + } + ) # Dedupe while preserving order seen = set() diff --git a/uk_bin_collection/uk_bin_collection/councils/NorthNorfolkDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NorthNorfolkDistrictCouncil.py index cc0b9e2f8b..0cef48e28b 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NorthNorfolkDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NorthNorfolkDistrictCouncil.py @@ -1,12 +1,10 @@ +from __future__ import annotations + import re import time from datetime import timedelta from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +18,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -79,9 +88,7 @@ def parse_data(self, page: str, **kwargs) -> dict: # Select matching address address_select = Select(address_dropdown) matching_options = [ - o - for o in address_select.options - if user_paon.lower() in o.text.lower() + o for o in address_select.options if user_paon.lower() in o.text.lower() ] if not matching_options: raise ValueError( @@ -96,9 +103,7 @@ def parse_data(self, page: str, **kwargs) -> dict: time.sleep(2) # Click Next to get bin results - next_btn2 = wait.until( - EC.element_to_be_clickable((By.ID, "NextButton")) - ) + next_btn2 = wait.until(EC.element_to_be_clickable((By.ID, "NextButton"))) next_btn2.click() # Wait for results page @@ -146,7 +151,7 @@ def parse_data(self, page: str, **kwargs) -> dict: raise ValueError("No bin collection data found on the results page.") except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/NorthTynesideCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NorthTynesideCouncil.py index 3b05938a04..008217df54 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NorthTynesideCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NorthTynesideCouncil.py @@ -55,14 +55,14 @@ def parse_data(self, page: str, **kwargs) -> dict: if getattr(response, "raise_for_status", None): response.raise_for_status() - # Parse form page and get the day of week and week offsets soup = BeautifulSoup(response.text, features="html.parser") schedule = soup.find("div", {"class": "waste-collection__schedule"}) if schedule is None: - raise ValueError("No waste-collection schedule found. The page structure may have changed.") - + raise ValueError( + "No waste-collection schedule found. The page structure may have changed." + ) # Find days of form: # @@ -94,13 +94,17 @@ def parse_data(self, page: str, **kwargs) -> dict: type_span = day.find("span", {"class": "waste-collection__day--type"}) # Direct text only (exclude nested spans, e.g., bank-holiday note) - bin_type_text = type_span.find(text=True, recursive=False) if type_span else None + bin_type_text = ( + type_span.find(text=True, recursive=False) if type_span else None + ) if not bin_type_text: logger.warning("Skipping day: missing type") continue bin_type = bin_type_text.strip() - colour_span = day.find("span", {"class": "waste-collection__day--colour"}) + colour_span = day.find( + "span", {"class": "waste-collection__day--colour"} + ) if not colour_span: logger.warning("Skipping day: missing colour") continue @@ -108,7 +112,7 @@ def parse_data(self, page: str, **kwargs) -> dict: collections.append((f"{bin_type} ({bin_colour})", collection_date)) except (AttributeError, KeyError, TypeError, ValueError) as e: - logger.warning(f"Skipping unparsable day node: {e}") + logger.warning(f"Skipping unparsable day node: {type(e).__name__}") continue return { diff --git a/uk_bin_collection/uk_bin_collection/councils/NorthumberlandCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NorthumberlandCouncil.py index fd0a22a59a..0822b12856 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NorthumberlandCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NorthumberlandCouncil.py @@ -1,13 +1,10 @@ +from __future__ import annotations + import datetime import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.common.exceptions import TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -23,10 +20,10 @@ class CouncilClass(AbstractGetBinDataClass): def extract_styles(self, style_str: str) -> dict: """ Parse an inline CSS style string into a dictionary of property-value pairs. - + Parameters: style_str (str): Inline CSS style text with semicolon-separated declarations (e.g. "color: red; margin: 0;"). - + Returns: dict: Mapping of CSS property names to their values, with surrounding whitespace removed from both keys and values. """ @@ -40,7 +37,7 @@ def extract_styles(self, style_str: str) -> dict: def parse_data(self, page: str, **kwargs) -> dict: """ Fetches bin collection dates from the Northumberland council postcode lookup and returns them as structured entries. - + Parameters: page (str): Ignored; the method uses the council postcode lookup URL. **kwargs: @@ -48,12 +45,24 @@ def parse_data(self, page: str, **kwargs) -> dict: uprn (str|int): Property UPRN; will be padded to 12 digits before use. web_driver: Optional Selenium WebDriver factory or identifier passed to create_webdriver. headless (bool): Optional flag controlling headless browser creation. - + Returns: dict: A dictionary with a "bins" key mapping to a list of entries. Each entry is a dict with: - "type" (str): The bin type (e.g., "General waste", "Recycling", "Garden waste"). - "collectionDate" (str): The collection date formatted according to the module's date_format. """ + global By, EC, Keys, Select, TimeoutException, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.common.exceptions import TimeoutException + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: page = "https://bincollection.northumberland.gov.uk/postcode" @@ -166,9 +175,9 @@ def parse_data(self, page: str, **kwargs) -> dict: } ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: driver.quit() - return data \ No newline at end of file + return data diff --git a/uk_bin_collection/uk_bin_collection/councils/NuneatonBedworthBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/NuneatonBedworthBoroughCouncil.py index a579b72666..4ecf0c2787 100644 --- a/uk_bin_collection/uk_bin_collection/councils/NuneatonBedworthBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/NuneatonBedworthBoroughCouncil.py @@ -937,7 +937,7 @@ def get_bin_data(self, url) -> dict: output = bin_data[filename] else: - print(bin_day_response.content) + print("Bin data download link was not found.") Exception("Bin data Download link not found.") else: diff --git a/uk_bin_collection/uk_bin_collection/councils/PembrokeshireCountyCouncil.py b/uk_bin_collection/uk_bin_collection/councils/PembrokeshireCountyCouncil.py index fa2718a709..8c0004845f 100644 --- a/uk_bin_collection/uk_bin_collection/councils/PembrokeshireCountyCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/PembrokeshireCountyCouncil.py @@ -91,6 +91,6 @@ def parse_data(self, page: str, **kwargs) -> dict: key=lambda x: datetime.strptime(x["collectionDate"], "%d/%m/%Y") ) - print(data) + print(f"Parsed {len(data.get('bins', []))} collections") return data diff --git a/uk_bin_collection/uk_bin_collection/councils/PeterboroughCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/PeterboroughCityCouncil.py index 2f3c2d3c6d..f225bb4c85 100644 --- a/uk_bin_collection/uk_bin_collection/councils/PeterboroughCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/PeterboroughCityCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -18,6 +17,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: user_poan = kwargs.get("paon") @@ -135,7 +144,7 @@ def parse_data(self, page: str, **kwargs) -> dict: if not next_collection: continue - print(f"Found next collection for {bin_type}: '{next_collection}'") + print("Next collection parsed successfully") parsed_date = datetime.strptime(next_collection, input_date_format) formatted_date = parsed_date.strftime(output_date_format) @@ -148,9 +157,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: - print( - f"Error processing panel for bin '{bin_type if 'bin_type' in locals() else 'unknown'}': {e}" - ) + print(f"Error processing a collection panel ({type(e).__name__}).") # Sort the data data["bins"].sort( @@ -158,7 +165,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/PortsmouthCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/PortsmouthCityCouncil.py index c88f591093..25b922306e 100644 --- a/uk_bin_collection/uk_bin_collection/councils/PortsmouthCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/PortsmouthCityCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -21,6 +19,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = "https://my.portsmouth.gov.uk/en/AchieveForms/?form_uri=sandbox-publish://AF-Process-26e27e70-f771-47b1-a34d-af276075cede/AF-Stage-cd7cc291-2e59-42cc-8c3f-1f93e132a2c9/definition.json&redirectlink=%2F&cancelRedirectLink=%2F" @@ -122,7 +131,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/PrestonCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/PrestonCityCouncil.py index 36a8693beb..12ed1ab695 100644 --- a/uk_bin_collection/uk_bin_collection/councils/PrestonCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/PrestonCityCouncil.py @@ -1,11 +1,8 @@ +from __future__ import annotations + from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -21,6 +18,18 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = "https://selfservice.preston.gov.uk/service/Forms/FindMyNearest.aspx?Service=bins" @@ -88,7 +97,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/ReigateAndBansteadBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ReigateAndBansteadBoroughCouncil.py index 73c90934d7..7ce854ccd8 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ReigateAndBansteadBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ReigateAndBansteadBoroughCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -17,6 +16,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + driver = None try: user_uprn = kwargs.get("uprn") @@ -100,7 +109,7 @@ def parse_data(self, page: str, **kwargs) -> dict: key=lambda x: datetime.strptime(x.get("collectionDate"), date_format) ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/RotherhamCouncil.py b/uk_bin_collection/uk_bin_collection/councils/RotherhamCouncil.py index 18ab217120..71bdead0a9 100644 --- a/uk_bin_collection/uk_bin_collection/councils/RotherhamCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/RotherhamCouncil.py @@ -46,9 +46,7 @@ def _resolve_premise(self, postcode: str, paon: str) -> str: target = str(paon).strip().lower() if not target: if not rows: - raise ValueError( - f"No addresses found for postcode {postcode}" - ) + raise ValueError(f"No addresses found for postcode {postcode}") return str(rows[0].get("PremiseID")) # Match against Address2 (house number/name) first, then Street. @@ -64,15 +62,12 @@ def _resolve_premise(self, postcode: str, paon: str) -> str: # against a paon of "22A". for row in rows: blob = " ".join( - str(row.get(k, "")).strip() - for k in ("Address1", "Address2", "Street") + str(row.get(k, "")).strip() for k in ("Address1", "Address2", "Street") ).lower() if target and target in blob: return str(row.get("PremiseID")) - raise ValueError( - f"No address matching '{paon}' for postcode {postcode}" - ) + raise ValueError(f"No address matching '{paon}' for postcode {postcode}") def parse_data(self, page: str, **kwargs) -> dict: premises = kwargs.get("premisesid") @@ -104,14 +99,11 @@ def parse_data(self, page: str, **kwargs) -> dict: timeout=15, ) except Exception as exc: - print(f"Error contacting Rotherham API: {exc}") + print(f"Error contacting Rotherham API ({type(exc).__name__}).") return {"bins": []} if resp.status_code != 200: - print( - f"Rotherham API request failed ({resp.status_code}). " - f"URL: {resp.url}" - ) + print(f"Rotherham API request failed ({resp.status_code}).") return {"bins": []} try: @@ -123,12 +115,8 @@ def parse_data(self, page: str, **kwargs) -> dict: data = {"bins": []} seen = set() for item in collections: - bin_type = ( - item.get("BinType") or item.get("bintype") or "Unknown" - ) - date_str = ( - item.get("CollectionDate") or item.get("collectionDate") - ) + bin_type = item.get("BinType") or item.get("bintype") or "Unknown" + date_str = item.get("CollectionDate") or item.get("collectionDate") if not date_str: continue try: @@ -141,13 +129,9 @@ def parse_data(self, page: str, **kwargs) -> dict: if key in seen: continue seen.add(key) - data["bins"].append( - {"type": bin_type, "collectionDate": formatted} - ) + data["bins"].append({"type": bin_type, "collectionDate": formatted}) data["bins"].sort( - key=lambda x: datetime.strptime( - x["collectionDate"], date_format - ) + key=lambda x: datetime.strptime(x["collectionDate"], date_format) ) return data diff --git a/uk_bin_collection/uk_bin_collection/councils/RoyalBoroughofGreenwich.py b/uk_bin_collection/uk_bin_collection/councils/RoyalBoroughofGreenwich.py index ff5b224164..fb471ada92 100644 --- a/uk_bin_collection/uk_bin_collection/councils/RoyalBoroughofGreenwich.py +++ b/uk_bin_collection/uk_bin_collection/councils/RoyalBoroughofGreenwich.py @@ -112,7 +112,9 @@ def parse_data(self, page: str, **kwargs) -> dict: original_collection_date, "%A %d %B" ) - original_collection_date = self._set_year_from_month(original_collection_date) + original_collection_date = self._set_year_from_month( + original_collection_date + ) new_collection_date = row.find_all("td")[1].get_text(strip=True) new_collection_date = new_collection_date.replace( @@ -131,7 +133,7 @@ def parse_data(self, page: str, **kwargs) -> dict: IndexError, ValueError, ) as e: - logger.warning(f"Failed to scrape bank holiday dates: {e}") + logger.warning(f"Failed to scrape bank holiday dates: {type(e).__name__}") greenstartDate = datetime(2025, 12, 29) bluestartDate = datetime(2025, 12, 29) diff --git a/uk_bin_collection/uk_bin_collection/councils/RushcliffeBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/RushcliffeBoroughCouncil.py index 9770167487..07a13f5922 100644 --- a/uk_bin_collection/uk_bin_collection/councils/RushcliffeBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/RushcliffeBoroughCouncil.py @@ -1,8 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -17,6 +15,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = "https://selfservice.rushcliffe.gov.uk/renderform.aspx?t=1242&k=86BDCD8DE8D868B9E23D10842A7A4FE0F1023CCA" @@ -110,7 +119,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/SandwellBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/SandwellBoroughCouncil.py index 714788c5bd..050793c433 100644 --- a/uk_bin_collection/uk_bin_collection/councils/SandwellBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/SandwellBoroughCouncil.py @@ -94,14 +94,14 @@ def parse_data(self, page: str, **kwargs) -> dict: rows_data = result["integration"]["transformed"]["rows_data"] if not isinstance(rows_data, dict): - logger.warning("Unexpected rows_data format: %s", rows_data) + logger.warning("Unexpected rows_data format.") continue for row in rows_data.values(): date = row.get(date_key) if not date: logger.warning( - "Date key '%s' missing in row: %s", date_key, row + "Required collection date field missing from parsed row" ) continue @@ -111,11 +111,11 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except requests.RequestException as e: - logger.error("API request failed: %s", e) + logger.error("API request failed: %s", type(e).__name__) continue except (KeyError, ValueError, TypeError) as e: - logger.warning("Unexpected structure in response: %s", e) + logger.warning("Unexpected structure in response: %s", type(e).__name__) continue - logger.info("Parsed bins: %s", bindata["bins"]) + logger.info("Parsed %s bins", len(bindata["bins"])) return bindata diff --git a/uk_bin_collection/uk_bin_collection/councils/SevenoaksDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/SevenoaksDistrictCouncil.py index 55cc96e11f..0e4e92203b 100644 --- a/uk_bin_collection/uk_bin_collection/councils/SevenoaksDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/SevenoaksDistrictCouncil.py @@ -1,12 +1,9 @@ +from __future__ import annotations + import time from typing import Any from dateutil.parser import parse -from selenium.common.exceptions import NoSuchElementException, TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import create_webdriver, date_format from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -21,6 +18,18 @@ def wait_for_element_conditions(self, driver, conditions, timeout: int = 5): raise def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, NoSuchElementException, Select, TimeoutException, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.common.exceptions import NoSuchElementException, TimeoutException + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: web_driver = kwargs.get("web_driver") @@ -83,7 +92,10 @@ def parse_data(self, page: str, **kwargs) -> dict: )[1].text # Skip if the message indicates service suspension - if "suspended" in raw_next_collection_date.lower() or "restarting" in raw_next_collection_date.lower(): + if ( + "suspended" in raw_next_collection_date.lower() + or "restarting" in raw_next_collection_date.lower() + ): continue parsed_bin_date = parse( @@ -101,7 +113,7 @@ def parse_data(self, page: str, **kwargs) -> dict: print("Error finding element for bin") except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/SomersetCouncil.py b/uk_bin_collection/uk_bin_collection/councils/SomersetCouncil.py index 7fd94e8867..14a10cc819 100644 --- a/uk_bin_collection/uk_bin_collection/councils/SomersetCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/SomersetCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -18,6 +17,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -127,7 +136,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/SouthGloucestershireCouncil.py b/uk_bin_collection/uk_bin_collection/councils/SouthGloucestershireCouncil.py index 06825b1de0..90ea3788d8 100644 --- a/uk_bin_collection/uk_bin_collection/councils/SouthGloucestershireCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/SouthGloucestershireCouncil.py @@ -7,9 +7,9 @@ def format_bin_data(key: str, date: datetime): formatted_date = date.strftime(date_format) servicename = key.get("hso_servicename") - print(servicename) + print("Processing collection service") if re.match(r"^Recycl", servicename) is not None: - return [ ("Recycling", formatted_date) ] + return [("Recycling", formatted_date)] elif re.match(r"^Refuse", servicename) is not None: return [("General Waste (Black Bin)", formatted_date)] elif re.match(r"^Garden", servicename) is not None: @@ -24,16 +24,16 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Parse waste collection data for the given UPRN and return upcoming bin collections within the next eight weeks. - + Parameters: page (str): Raw page content (unused by this implementation; included for signature compatibility). uprn (str, keyword): Unique Property Reference Number used to query the South Gloucestershire collection API. - + Returns: dict: A mapping with a "bins" key containing a list of collection entries. Each entry is a dict with: - "type" (str): Human-friendly bin type (e.g., "Recycling", "General Waste (Black Bin)"). - "collectionDate" (str): Formatted collection date string. - + Raises: ValueError: If the API returns no collection data for the provided UPRN. """ @@ -53,15 +53,15 @@ def parse_data(self, page: str, **kwargs) -> dict: if not json_response: raise ValueError("No collection data found for provided UPRN.") - collection_data = json_response.get('value') + collection_data = json_response.get("value") today = datetime.today() eight_weeks = datetime.today() + timedelta(days=8 * 7) data = {"bins": []} collection_tuple = [] for collection in collection_data: - print(collection) - item = collection.get('hso_nextcollection') + print("Processing collection entry") + item = collection.get("hso_nextcollection") if not item: continue @@ -82,4 +82,4 @@ def parse_data(self, page: str, **kwargs) -> dict: } data["bins"].append(dict_data) - return data \ No newline at end of file + return data diff --git a/uk_bin_collection/uk_bin_collection/councils/SouthKestevenDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/SouthKestevenDistrictCouncil.py index 78dca587b2..3bacffde75 100644 --- a/uk_bin_collection/uk_bin_collection/councils/SouthKestevenDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/SouthKestevenDistrictCouncil.py @@ -1,24 +1,58 @@ +"""South Kesteven District Council collection data via its live browser flow.""" + +from __future__ import annotations + import json +import logging +import re +import unicodedata +import uuid from datetime import datetime from pathlib import Path -from urllib.parse import urljoin +from time import monotonic +from types import SimpleNamespace +from urllib.parse import quote, quote_plus, urljoin, urlsplit, urlunsplit from bs4 import BeautifulSoup -import requests -from requests import RequestException -from selenium.common.exceptions import NoSuchElementException, TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import create_webdriver, date_format +from uk_bin_collection.uk_bin_collection.dependency_validation import ( + validate_websocket_client, +) +from uk_bin_collection.uk_bin_collection.exceptions import ( + AddressMismatchError, + BrowserUnavailableError, + ConfigurationError, + MissingDependencyError, + SiteChanged, + UKBinCollectionError, + UpstreamAccessDenied, +) from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass +_LOGGER = logging.getLogger(__name__) + class CouncilClass(AbstractGetBinDataClass): - """South Kesteven District Council bin collections via the live binday flow.""" + """Collect South Kesteven bin dates from the supported postcode checker.""" + # This adapter owns the complete browser navigation flow. The opt-out is + # intrinsic so callers cannot accidentally reinstate the direct HTTP + # preflight that South Kesteven rejects in some environments. + skip_generic_get = True BIN_DAY_URL = "https://www.southkesteven.gov.uk/binday" CHECKER_LINK_TEXT = "Postcode bin day checker" + CHECKER_SCHEME = "https" + CHECKER_HOST = "selfservice.southkesteven.gov.uk" + PAGE_LOAD_TIMEOUT_SECONDS = 30 + ELEMENT_TIMEOUT_SECONDS = 30 + TOTAL_RUN_TIMEOUT_SECONDS = 90 + ACCESS_DENIED_MARKERS = ( + "403 forbidden", + "error 403", + "access denied", + "request blocked", + ) BIN_TYPE_MAP = { "240 Litre Recycling": "Grey Bin", "23lt Food Caddy": "Food Bin", @@ -34,179 +68,339 @@ class CouncilClass(AbstractGetBinDataClass): SUBMIT_BUTTON_ID = "submit-button" BODY_CONTENT_ID = "body-content" CURRENT_SECTION_ID = "current-section-id" - DEFAULT_ARTIFACT_ROOT = "artifacts/SouthKestevenDistrictCouncil" + CHECKER_ARTIFACT_PATHS = frozenset( + { + "/renderform", + "/renderform.aspx", + "/renderform/Form", + } + ) + SENSITIVE_OPAQUE_FIELD_PREFIXES = ("ff5265",) def parse_data(self, page: str, **kwargs) -> dict: - binday_url = kwargs.get("url") or self.BIN_DAY_URL + """Run the complete lookup in one bounded Selenium browser session.""" + del page # The generic HTTP response is intentionally never used. + + # This adapter has one reviewed entry point. Do not let standalone/API + # callers redirect its privileged browser session to another origin. + binday_url = self.BIN_DAY_URL postcode = kwargs.get("postcode") paon = kwargs.get("paon") web_driver = kwargs.get("web_driver") user_agent = kwargs.get("user_agent") headless = kwargs.get("headless", True) artifact_root = self._get_artifact_root(kwargs.get("artifact_dir")) + deadline = monotonic() + self.TOTAL_RUN_TIMEOUT_SECONDS if headless is None: headless = True - if not postcode: - raise ValueError("Postcode is required for South Kesteven.") + raise ConfigurationError("Postcode is required for South Kesteven.") if not paon: - raise ValueError( - "Property number or name (paon) is required for South Kesteven." + raise ConfigurationError( + "Property number or name is required for South Kesteven." ) + if not str(web_driver or "").strip(): + raise ConfigurationError( + "A remote Selenium WebDriver URL is required for South Kesteven." + ) + web_driver = str(web_driver).strip() - checker_url = self._resolve_checker_url( - binday_url, page=page, user_agent=user_agent - ) - - driver = create_webdriver(web_driver, headless, user_agent, __name__) + driver = None + support = None + stage = "create_browser" try: - wait = WebDriverWait(driver, 30) + # create_webdriver validates websocket-client ownership before importing + # Selenium, so a /config/websocket collision is reported safely. + driver = create_webdriver( + web_driver, + headless, + user_agent, + __name__, + command_timeout=min( + self.PAGE_LOAD_TIMEOUT_SECONDS, + self._remaining_seconds(deadline), + ), + ) + support = self._load_selenium_support() + + stage = "open_binday" + self._set_page_load_timeout(driver, deadline) + driver.get(binday_url) + self._raise_if_access_denied(driver.page_source) + + stage = "resolve_checker" + checker_url = self._wait_for_checker_url( + self._new_wait(driver, support, deadline), support, binday_url + ) + + stage = "open_checker" + self._set_page_load_timeout(driver, deadline) driver.get(checker_url) + self._raise_if_access_denied(driver.page_source) + stage = "enter_postcode" postcode_input = self._wait_for_clickable( - wait, - (By.ID, self.POSTCODE_INPUT_ID), - "Unable to find the postcode input on the South Kesteven checker.", + self._new_wait(driver, support, deadline), + support, + (support.By.ID, self.POSTCODE_INPUT_ID), + "The postcode input is missing from the South Kesteven checker.", ) postcode_input.clear() postcode_input.send_keys(postcode) - initial_section_id = self._get_current_section_id(driver) - initial_body_markup = self._get_body_markup(driver) + initial_section_id = self._get_current_section_id(driver, support) + initial_body_markup = self._get_body_markup(driver, support) search_button = self._wait_for_clickable( - wait, - (By.ID, self.SEARCH_BUTTON_ID), - "Unable to find the search button after entering the postcode.", + self._new_wait(driver, support, deadline), + support, + (support.By.ID, self.SEARCH_BUTTON_ID), + "The postcode search button is missing from the checker.", ) search_button.click() + self._raise_if_driver_access_denied(driver) - address_select = self._wait_for_address_options(wait) - self._select_address(address_select, paon) - self._wait_for_address_confirmation(wait) + stage = "select_address" + address_select = self._wait_for_address_options( + self._new_wait(driver, support, deadline), support + ) + self._raise_if_driver_access_denied(driver) + self._select_address(address_select, paon, support) + self._wait_for_address_confirmation( + self._new_wait(driver, support, deadline), support + ) + self._raise_if_driver_access_denied(driver) + stage = "submit_address" submit_button = self._wait_for_clickable( - wait, - (By.ID, self.SUBMIT_BUTTON_ID), - "Unable to find the submit button after selecting the address.", + self._new_wait(driver, support, deadline), + support, + (support.By.ID, self.SUBMIT_BUTTON_ID), + "The address submit button is missing from the checker.", ) submit_button.click() + self._raise_if_driver_access_denied(driver) self._wait_for_results_container( - wait, + self._new_wait(driver, support, deadline), + support, initial_section_id, initial_body_markup, ) + self._raise_if_driver_access_denied(driver) + stage = "parse_results" + self._remaining_seconds(deadline) return {"bins": self._parse_collection_rows(driver.page_source)} + except UKBinCollectionError as exc: + artifact_path = self._capture_debug_artifacts( + driver, + artifact_root, + stage=stage, + redactions=(str(postcode), str(paon)), + ) + if artifact_path is not None and hasattr(exc, "add_note"): + exc.add_note(f"Redacted debug artifacts: {artifact_path}") + raise except Exception as exc: artifact_path = self._capture_debug_artifacts( driver, artifact_root, - {"postcode": postcode, "paon": paon, "binday_url": binday_url}, + stage=stage, + redactions=(str(postcode), str(paon)), ) - raise RuntimeError( - self._with_artifact_hint(str(exc), artifact_path) - ) from exc + try: + self._raise_if_driver_access_denied(driver) + except UpstreamAccessDenied as translated: + if artifact_path is not None and hasattr(translated, "add_note"): + translated.add_note(f"Redacted debug artifacts: {artifact_path}") + raise translated from exc + if support is not None and isinstance(exc, support.WebDriverException): + translated: UKBinCollectionError = BrowserUnavailableError( + "The configured Selenium browser stopped responding." + ) + else: + translated = SiteChanged( + "The South Kesteven checker did not complete the expected flow." + ) + if artifact_path is not None and hasattr(translated, "add_note"): + translated.add_note(f"Redacted debug artifacts: {artifact_path}") + raise translated from exc finally: - if driver: - driver.quit() - - def _wait_for_clickable(self, wait, locator, error_message): + if driver is not None: + try: + driver.quit() + except Exception: + _LOGGER.warning("South Kesteven WebDriver cleanup failed.") + + @staticmethod + def _load_selenium_support() -> SimpleNamespace: + """Import Selenium support classes only after dependency validation.""" + validate_websocket_client() try: - return wait.until(EC.element_to_be_clickable(locator)) - except TimeoutException as exc: - raise RuntimeError(error_message) from exc + from selenium.common.exceptions import ( + NoSuchElementException, + TimeoutException, + UnexpectedTagNameException, + WebDriverException, + ) + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + except ImportError as exc: + raise MissingDependencyError( + "Selenium support modules are not installed completely." + ) from exc + + return SimpleNamespace( + By=By, + EC=EC, + Select=Select, + WebDriverWait=WebDriverWait, + NoSuchElementException=NoSuchElementException, + TimeoutException=TimeoutException, + UnexpectedTagNameException=UnexpectedTagNameException, + WebDriverException=WebDriverException, + ) + + def _remaining_seconds(self, deadline: float) -> float: + remaining = deadline - monotonic() + if remaining <= 0: + raise SiteChanged( + "The South Kesteven browser flow exceeded its total timeout." + ) + return remaining - def _wait_for_presence(self, wait, locator, error_message): + def _new_wait(self, driver, support, deadline: float): + timeout = min( + self.ELEMENT_TIMEOUT_SECONDS, + self._remaining_seconds(deadline), + ) + return support.WebDriverWait(driver, timeout) + + def _set_page_load_timeout(self, driver, deadline: float) -> None: + if not hasattr(driver, "set_page_load_timeout"): + return + timeout = min( + self.PAGE_LOAD_TIMEOUT_SECONDS, + self._remaining_seconds(deadline), + ) + driver.set_page_load_timeout(timeout) + + def _wait_for_checker_url(self, wait, support, binday_url: str) -> str: try: - return wait.until(EC.presence_of_element_located(locator)) - except TimeoutException as exc: - raise RuntimeError(error_message) from exc + return wait.until( + lambda driver: self._find_checker_url( + driver.page_source, + binday_url, + ) + ) + except support.TimeoutException as exc: + driver = getattr(wait, "_driver", None) + self._raise_if_driver_access_denied(driver) + raise SiteChanged( + "The supported postcode checker link is missing from the binday page." + ) from exc - def _resolve_checker_url( - self, - binday_url: str, - page: str | object | None = None, - user_agent: str | None = None, - ) -> str: - html = self._get_binday_html(binday_url, page=page, user_agent=user_agent) - soup = BeautifulSoup(html, "html.parser") + def _find_checker_url(self, html: str, binday_url: str) -> str | bool: + if not html or not html.strip(): + return False + soup = BeautifulSoup(html, "html.parser") for link in soup.find_all("a", href=True): - link_text = link.get_text(" ", strip=True).lower() - if self.CHECKER_LINK_TEXT.lower() in link_text: - return urljoin(binday_url, link["href"]) + link_text = link.get_text(" ", strip=True).casefold() + if self.CHECKER_LINK_TEXT.casefold() not in link_text: + continue + checker_url = urljoin(binday_url, link["href"]) + parsed = urlsplit(checker_url) + if ( + parsed.scheme != self.CHECKER_SCHEME + or parsed.hostname != self.CHECKER_HOST + ): + raise SiteChanged( + "The postcode checker link points outside the supported service." + ) + return checker_url + return False + + def _raise_if_access_denied(self, html: str) -> None: + text = BeautifulSoup(html or "", "html.parser").get_text(" ", strip=True) + normalized = self._normalize_text(text) + if any(marker in normalized for marker in self.ACCESS_DENIED_MARKERS): + raise UpstreamAccessDenied( + "South Kesteven denied browser access to the bin checker." + ) - raise RuntimeError( - "Unable to find the postcode bin day checker link on the binday page." - ) + def _raise_if_driver_access_denied(self, driver) -> None: + """Classify a browser denial without trusting arbitrary driver values.""" + if driver is None: + return + try: + page_source = getattr(driver, "page_source", "") + except Exception: + return + if not isinstance(page_source, str): + return + self._raise_if_access_denied(page_source) - def _get_binday_html( - self, - binday_url: str, - page: str | object | None = None, - user_agent: str | None = None, - ) -> str: - if hasattr(page, "text"): - html = str(page.text) - elif isinstance(page, str) and page.strip(): - html = page - else: - headers = {} - if user_agent: - headers["User-Agent"] = user_agent - try: - response = requests.get(binday_url, headers=headers, timeout=30) - response.raise_for_status() - except RequestException as exc: - raise RuntimeError( - f"Unable to load the South Kesteven binday page at {binday_url}." - ) from exc - html = response.text - - if not html.strip(): - raise RuntimeError( - f"Unable to load the South Kesteven binday page at {binday_url}." - ) + def _raise_if_wait_access_denied(self, wait) -> None: + self._raise_if_driver_access_denied(getattr(wait, "_driver", None)) - return html + def _wait_for_clickable(self, wait, support, locator, error_message): + try: + return wait.until(support.EC.element_to_be_clickable(locator)) + except support.TimeoutException as exc: + self._raise_if_wait_access_denied(wait) + raise SiteChanged(error_message) from exc - def _wait_for_address_options(self, wait): + def _wait_for_address_options(self, wait, support): try: - return wait.until(self._address_options_ready) - except TimeoutException as exc: - raise RuntimeError( - "Unable to find the address dropdown after searching for the postcode." + return wait.until( + lambda driver: self._address_options_ready(driver, support) + ) + except support.TimeoutException as exc: + self._raise_if_wait_access_denied(wait) + raise SiteChanged( + "The address dropdown did not appear after the postcode search." ) from exc - def _address_options_ready(self, driver): + def _address_options_ready(self, driver, support): try: - address_select = driver.find_element(By.ID, self.ADDRESS_SELECT_ID) - except NoSuchElementException: + address_select = driver.find_element(support.By.ID, self.ADDRESS_SELECT_ID) + except support.NoSuchElementException: return False if not address_select.is_displayed(): return False - options = [ - option for option in Select(address_select).options if option.text.strip() - ] + try: + options = [ + option + for option in support.Select(address_select).options + if option.text.strip() + ] + except support.UnexpectedTagNameException as exc: + raise SiteChanged( + "The South Kesteven address control is no longer a selection list." + ) from exc return address_select if options else False - def _wait_for_address_confirmation(self, wait): + def _wait_for_address_confirmation(self, wait, support): try: - return wait.until(self._address_confirmation_ready) - except TimeoutException as exc: - raise RuntimeError( - "Unable to confirm the selected address before submitting the lookup." + return wait.until( + lambda driver: self._address_confirmation_ready(driver, support) + ) + except support.TimeoutException as exc: + self._raise_if_wait_access_denied(wait) + raise SiteChanged( + "The selected address was not confirmed by the checker." ) from exc - def _address_confirmation_ready(self, driver): + def _address_confirmation_ready(self, driver, support): try: - address_value = driver.find_element(By.ID, self.ADDRESS_VALUE_ID) - change_button = driver.find_element(By.ID, self.CHANGE_BUTTON_ID) - except NoSuchElementException: + address_value = driver.find_element(support.By.ID, self.ADDRESS_VALUE_ID) + change_button = driver.find_element(support.By.ID, self.CHANGE_BUTTON_ID) + except support.NoSuchElementException: return False selected_value = (address_value.get_attribute("value") or "").strip() @@ -214,128 +408,312 @@ def _address_confirmation_ready(self, driver): return True try: - display_name = driver.find_element(By.ID, self.ADDRESS_DISPLAY_ID) - except NoSuchElementException: + display_name = driver.find_element(support.By.ID, self.ADDRESS_DISPLAY_ID) + except support.NoSuchElementException: return False return bool((display_name.text or "").strip()) and change_button.is_displayed() def _wait_for_results_container( - self, wait, initial_section_id: str | None, initial_body_markup: str + self, + wait, + support, + initial_section_id: str | None, + initial_body_markup: str, ): try: return wait.until( lambda driver: self._results_container_ready( driver, + support, initial_section_id, initial_body_markup, ) ) - except TimeoutException as exc: - raise RuntimeError( - "Unable to load the collection results after submitting the address." + except support.TimeoutException as exc: + self._raise_if_wait_access_denied(wait) + raise SiteChanged( + "Collection results did not load after the address was submitted." ) from exc def _results_container_ready( - self, driver, initial_section_id: str | None, initial_body_markup: str + self, + driver, + support, + initial_section_id: str | None, + initial_body_markup: str, ): try: - body_markup = self._get_body_markup(driver) - except RuntimeError: + body_markup = self._get_body_markup(driver, support) + except SiteChanged: return False if not body_markup or body_markup == initial_body_markup: return False - current_section_id = self._get_current_section_id(driver) + current_section_id = self._get_current_section_id(driver, support) section_changed = bool( initial_section_id and current_section_id and current_section_id != initial_section_id ) address_form_gone = self.POSTCODE_INPUT_ID not in body_markup + normalized_markup = body_markup.casefold() has_collection_markup = ( - "alloy-table" in body_markup.lower() - or "your collections" in body_markup.lower() + "alloy-table" in normalized_markup + or "your collections" in normalized_markup ) - return address_form_gone and (section_changed or has_collection_markup) - def _select_address(self, address_select, paon: str) -> None: - target = str(paon).strip().lower() - select = Select(address_select) + def _select_address(self, address_select, paon: str, support) -> None: + target = self._normalize_text(str(paon)) + try: + select = support.Select(address_select) + except support.UnexpectedTagNameException as exc: + raise SiteChanged( + "The South Kesteven address control is no longer a selection list." + ) from exc + matches = [ + option + for option in select.options + if self._address_component(option.text, target) == target + ] + + if not matches: + raise AddressMismatchError( + "The configured property was not found in the address results." + ) + if len(matches) > 1: + raise AddressMismatchError( + "The configured property matched more than one address." + ) + select.select_by_visible_text(matches[0].text) - for option in select.options: - option_text = option.text.strip().lower() - if target in option_text: - select.select_by_visible_text(option.text) - return + @classmethod + def _address_component(cls, address: str, target: str) -> str: + normalized_address = cls._normalize_text(address) + if re.fullmatch(r"[0-9]+[a-z]?", target): + return re.split(r"[\s,]", normalized_address, maxsplit=1)[0] + return normalized_address.split(",", maxsplit=1)[0].strip() - raise RuntimeError( - f"Unable to find the property '{paon}' in the address dropdown." - ) + @staticmethod + def _normalize_text(value: str) -> str: + normalized = unicodedata.normalize("NFKC", value or "") + return " ".join(normalized.split()).casefold() - def _get_current_section_id(self, driver) -> str | None: + def _get_current_section_id(self, driver, support) -> str | None: try: - return driver.find_element(By.ID, self.CURRENT_SECTION_ID).get_attribute( - "value" - ) - except NoSuchElementException: + return driver.find_element( + support.By.ID, self.CURRENT_SECTION_ID + ).get_attribute("value") + except support.NoSuchElementException: return None - def _get_body_markup(self, driver) -> str: + def _get_body_markup(self, driver, support) -> str: try: - body_content = driver.find_element(By.ID, self.BODY_CONTENT_ID) - except NoSuchElementException as exc: - raise RuntimeError( - "Unable to find the body-content container on the checker page." - ) from exc - + body_content = driver.find_element(support.By.ID, self.BODY_CONTENT_ID) + except support.NoSuchElementException as exc: + raise SiteChanged("The checker body container is missing.") from exc return body_content.get_attribute("innerHTML") or "" - def _get_artifact_root(self, artifact_dir: str | None) -> Path: - if artifact_dir: - return Path(artifact_dir) - return Path.cwd() / self.DEFAULT_ARTIFACT_ROOT + @staticmethod + def _get_artifact_root(artifact_dir: str | None) -> Path | None: + if not artifact_dir or not str(artifact_dir).strip(): + return None + return Path(artifact_dir) def _capture_debug_artifacts( - self, driver, artifact_root: Path, context: dict[str, str] + self, + driver, + artifact_root: Path | None, + *, + stage: str, + redactions: tuple[str, ...], ) -> Path | None: - if not driver: + """Write opt-in HTML/metadata without screenshots or household values.""" + if driver is None or artifact_root is None: return None - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - artifact_path = artifact_root / timestamp - artifact_path.mkdir(parents=True, exist_ok=True) + try: + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + artifact_path = artifact_root / f"{timestamp}-{uuid.uuid4().hex[:8]}" + artifact_path.mkdir(parents=True, exist_ok=False) - metadata = { - "current_url": str(getattr(driver, "current_url", "")) or None, - **context, + html = self._redact_html( + str(getattr(driver, "page_source", "") or ""), redactions + ) + + metadata = { + "stage": stage, + "current_url": self._sanitize_url( + str(getattr(driver, "current_url", "") or "") + ), + } + (artifact_path / "page.html").write_text(html, encoding="utf-8") + (artifact_path / "metadata.json").write_text( + json.dumps(metadata, indent=2, sort_keys=True), + encoding="utf-8", + ) + return artifact_path.resolve() + except Exception: + _LOGGER.warning("Unable to write redacted South Kesteven debug artifacts.") + return None + + @classmethod + def _redact_html(cls, html: str, redactions: tuple[str, ...]) -> str: + """Remove configured and form-derived household values from HTML. + + Form state is default-deny: values and option/display text are removed + even when the upstream service uses opaque field names. Selector names + and structural markup remain available for diagnosing site drift. + """ + redacted = html + for value in redactions: + if not value or value == "None": + continue + variants = { + value, + re.sub(r"\s+", "", value), + quote(value), + quote_plus(value), + } + for variant in sorted(variants, key=len, reverse=True): + if variant: + redacted = re.sub( + re.escape(variant), + "[REDACTED]", + redacted, + flags=re.IGNORECASE, + ) + + soup = BeautifulSoup(redacted, "html.parser") + for script in soup.find_all("script"): + script.clear() + script.append("[REDACTED SCRIPT]") + + sensitive_markers = ( + "address", + "displayname", + "postcode", + "property", + "paon", + "uprn", + "usrn", + ) + form_control_tags = { + "button", + "input", + "option", + "output", + "select", + "textarea", + } + value_attributes = { + "aria-label", + "aria-valuetext", + "placeholder", + "title", + "value", } - screenshot_path = artifact_path / "page.png" - html_path = artifact_path / "page.html" - metadata_path = artifact_path / "metadata.json" + for field in soup.find_all(True): + descriptor = " ".join( + str(field.get(attribute, "")) + for attribute in ( + "id", + "name", + "for", + "aria-label", + "autocomplete", + ) + ).casefold() + field_id = str(field.get("id", "")).casefold() + opaque_sensitive = any( + field_id.startswith(prefix) + for prefix in cls.SENSITIVE_OPAQUE_FIELD_PREFIXES + ) + semantic_sensitive = any( + marker in descriptor for marker in sensitive_markers + ) + style = str(field.get("style", "")).casefold().replace(" ", "") + hidden = ( + field.has_attr("hidden") + or str(field.get("aria-hidden", "")).casefold() == "true" + or ( + field.name == "input" + and str(field.get("type", "")).casefold() == "hidden" + ) + or "display:none" in style + or "visibility:hidden" in style + ) + form_control = field.name in form_control_tags + + if form_control or opaque_sensitive or semantic_sensitive or hidden: + for attribute in list(field.attrs): + normalized_attribute = attribute.casefold() + if ( + normalized_attribute in value_attributes + or normalized_attribute.startswith("data-") + ): + field[attribute] = "[REDACTED]" + + if field.name == "option": + field.attrs.pop("selected", None) + + redact_text = ( + field.name in {"option", "output", "textarea"} + or opaque_sensitive + or semantic_sensitive + or hidden + ) + if redact_text: + for text_node in list(field.find_all(string=True)): + if text_node.strip(): + text_node.replace_with("[REDACTED]") + + redacted = str(soup) + return re.sub( + r"(?i)((?:uprn|usrn)[^0-9]{0,20})[0-9]{6,15}", + r"\1[REDACTED]", + redacted, + ) + @classmethod + def _sanitize_url(cls, value: str) -> str | None: + """Retain only an allowlisted origin and a known non-identifier route.""" + if not value: + return None + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return None try: - html_path.write_text(driver.page_source, encoding="utf-8") - except Exception as exc: - metadata["page_html_error"] = str(exc) + parsed_port = parsed.port + except ValueError: + return None - try: - metadata["screenshot_saved"] = bool( - driver.save_screenshot(str(screenshot_path)) - ) - except Exception as exc: - metadata["screenshot_error"] = str(exc) + default_port = 443 if parsed.scheme == "https" else 80 + if parsed_port not in {None, default_port}: + return None - metadata_path.write_text(json.dumps(metadata, indent=4), encoding="utf-8") - return artifact_path.resolve() + binday = urlsplit(cls.BIN_DAY_URL) + origin = (parsed.scheme.casefold(), parsed.hostname.casefold()) + binday_origin = (binday.scheme.casefold(), (binday.hostname or "").casefold()) + checker_origin = (cls.CHECKER_SCHEME.casefold(), cls.CHECKER_HOST.casefold()) + + safe_path = "" + if origin == binday_origin: + if parsed.path == binday.path: + safe_path = binday.path + elif origin == checker_origin: + if parsed.path in cls.CHECKER_ARTIFACT_PATHS: + safe_path = parsed.path + else: + return None - def _with_artifact_hint(self, message: str, artifact_path: Path | None) -> str: - if artifact_path is None: - return message - return f"{message} Debug artifacts saved to: {artifact_path}" + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + return urlunsplit((parsed.scheme.casefold(), host, safe_path, "", "")) def _normalize_bin_type(self, raw_bin_type: str) -> str: return self.BIN_TYPE_MAP.get(raw_bin_type, raw_bin_type) @@ -353,7 +731,6 @@ def _parse_collection_rows(self, page_source: str) -> list[dict]: raw_date = cols[0].get_text(" ", strip=True).replace(",", "") raw_bin_type = cols[1].get_text(" ", strip=True) - try: collection_date = datetime.strptime( raw_date, "%A %d %B %Y" @@ -369,8 +746,7 @@ def _parse_collection_rows(self, page_source: str) -> list[dict]: ) if not bins: - raise RuntimeError( - "Unable to find any collection rows on the South Kesteven results page." + raise SiteChanged( + "No collection rows were found on the South Kesteven results page." ) - return bins diff --git a/uk_bin_collection/uk_bin_collection/councils/SouthLanarkshireCouncil.py b/uk_bin_collection/uk_bin_collection/councils/SouthLanarkshireCouncil.py index a53a4811a9..c9e3f31926 100644 --- a/uk_bin_collection/uk_bin_collection/councils/SouthLanarkshireCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/SouthLanarkshireCouncil.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) + # import the wonderful Beautiful Soup and the URL grabber class CouncilClass(AbstractGetBinDataClass): """ @@ -76,7 +77,9 @@ def parse_data(self, page: str, **kwargs) -> dict: td_cell = row.find("td") if th_cell is None or td_cell is None: - logger.warning("Skipping schedule row with missing th or td cell") + logger.warning( + "Skipping schedule row with missing th or td cell" + ) continue schedule_type = th_cell.get_text().strip() @@ -91,8 +94,7 @@ def parse_data(self, page: str, **kwargs) -> dict: if " " not in td_text: logger.warning( - "Skipping schedule cadence parsing for unexpected schedule text: %s", - td_text, + "Skipping schedule cadence parsing for unexpected schedule text" ) schedule_cadence = "" else: diff --git a/uk_bin_collection/uk_bin_collection/councils/SouthTynesideCouncil.py b/uk_bin_collection/uk_bin_collection/councils/SouthTynesideCouncil.py index e5a9e090ba..10f6fdd5b8 100644 --- a/uk_bin_collection/uk_bin_collection/councils/SouthTynesideCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/SouthTynesideCouncil.py @@ -59,7 +59,7 @@ def parse_data(self, page: str, **kwargs) -> dict: if user_paon is None: raise ValueError("Invalid house number") except Exception as ex: - print(f"Exception encountered: {ex}") + print(f"Exception encountered: {type(ex).__name__}") print( "Please check the provided house number. If this error continues, please first trying setting the " "house number manually on line 25 before raising an issue." diff --git a/uk_bin_collection/uk_bin_collection/councils/StHelensBC.py b/uk_bin_collection/uk_bin_collection/councils/StHelensBC.py index f88ea53135..d618e4eaa9 100644 --- a/uk_bin_collection/uk_bin_collection/councils/StHelensBC.py +++ b/uk_bin_collection/uk_bin_collection/councils/StHelensBC.py @@ -1,8 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -17,6 +15,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -113,7 +122,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/StaffordshireMoorlandsDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/StaffordshireMoorlandsDistrictCouncil.py index 79c8281fcb..3fbaf48a79 100644 --- a/uk_bin_collection/uk_bin_collection/councils/StaffordshireMoorlandsDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/StaffordshireMoorlandsDistrictCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + import re from datetime import datetime -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -22,6 +21,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + driver = None try: user_postcode = kwargs.get("postcode") @@ -99,7 +108,7 @@ def parse_data(self, page: str, **kwargs) -> dict: key=lambda x: datetime.strptime(x["collectionDate"], date_format) ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/StocktonOnTeesCouncil.py b/uk_bin_collection/uk_bin_collection/councils/StocktonOnTeesCouncil.py index 2e828c9560..23980b20e3 100644 --- a/uk_bin_collection/uk_bin_collection/councils/StocktonOnTeesCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/StocktonOnTeesCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup from dateutil.relativedelta import relativedelta -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +18,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -163,7 +172,7 @@ def parse_data(self, page: str, **kwargs) -> dict: print() except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/StroudDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/StroudDistrictCouncil.py index 6d580311a2..e334798362 100644 --- a/uk_bin_collection/uk_bin_collection/councils/StroudDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/StroudDistrictCouncil.py @@ -67,7 +67,7 @@ def parse_data(self, page: Any, **kwargs: Any) -> Dict[str, Any]: date = item.find_all("strong")[1].text.strip() extracted_data[key] = date - print("Extracted data:", extracted_data) + print(f"Extracted {len(extracted_data)} collection entries") # Transform the data to the required schema bin_data = {"bins": []} diff --git a/uk_bin_collection/uk_bin_collection/councils/SwaleBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/SwaleBoroughCouncil.py index 62c3ad0c82..93aec1152a 100644 --- a/uk_bin_collection/uk_bin_collection/councils/SwaleBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/SwaleBoroughCouncil.py @@ -1,7 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -34,6 +33,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: # Get postcode and UPRN from kwargs @@ -128,7 +137,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/TeignbridgeCouncil.py b/uk_bin_collection/uk_bin_collection/councils/TeignbridgeCouncil.py index c4b7c87024..a3d6604417 100644 --- a/uk_bin_collection/uk_bin_collection/councils/TeignbridgeCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/TeignbridgeCouncil.py @@ -1,7 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -16,6 +15,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: user_uprn = kwargs.get("uprn") @@ -59,7 +68,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/TendringDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/TendringDistrictCouncil.py index aab8e1e7b4..1367f7aeeb 100644 --- a/uk_bin_collection/uk_bin_collection/councils/TendringDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/TendringDistrictCouncil.py @@ -1,12 +1,10 @@ +from __future__ import annotations + import re import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.common.exceptions import TimeoutException -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import ( check_postcode, @@ -28,19 +26,30 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Scrape Tendring District Council's rubbish and recycling collection days for a given address and return upcoming collections. - + This navigates the council's canonical service page, enters the supplied postcode, selects the address by UPRN, parses the resulting waste collection table, and returns a list of future collection entries. Entries with collection dates on or before today are excluded. - + Parameters: page (str): Ignored; the method always uses the canonical Tendring service URL. uprn (int | str, via kwargs["uprn"]): Unique Property Reference Number used to select the address. postcode (str, via kwargs["postcode"]): Postcode to populate the address search field. web_driver (optional, via kwargs["web_driver"]): Selenium driver configuration or remote endpoint; if omitted a local driver is created. headless (bool, via kwargs["headless"]): Whether to run the browser headlessly; defaults to True when not provided. - + Returns: dict: {"bins": [{"type": , "collectionDate": }, ...]} where each entry describes a waste type and its upcoming collection date. `collectionDate` is formatted using the module's configured `date_format`. """ + global By, EC, Select, TimeoutException, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.common.exceptions import TimeoutException + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None bin_data: dict[str, list[dict]] = {"bins": []} @@ -101,16 +110,16 @@ def parse_data(self, page: str, **kwargs) -> dict: input_postcode.clear() input_postcode.send_keys(user_postcode) # Wait for dropdown to appear and be populated - dropdown = wait.until(EC.element_to_be_clickable((By.NAME, "selectAddress"))) + dropdown = wait.until( + EC.element_to_be_clickable((By.NAME, "selectAddress")) + ) wait.until(lambda _: len(Select(dropdown).options) > 1) # Select address by UPRN Select(dropdown).select_by_value(str(user_uprn)) # Wait for results table - wait.until( - EC.presence_of_element_located((By.CLASS_NAME, "wasteTable")) - ) + wait.until(EC.presence_of_element_located((By.CLASS_NAME, "wasteTable"))) # Parse HTML soup = BeautifulSoup(driver.page_source, "html.parser") @@ -145,9 +154,7 @@ def parse_data(self, page: str, **kwargs) -> dict: continue # Normalise bin type (strip parentheses) - bin_type = re.sub( - r"\([^)]*\)", "", cols[type_idx].get_text(strip=True) - ) + bin_type = re.sub(r"\([^)]*\)", "", cols[type_idx].get_text(strip=True)) # Extract a dd/mm/YYYY from the 'Next collection' cell cell_txt = cols[next_idx].get_text(" ", strip=True) diff --git a/uk_bin_collection/uk_bin_collection/councils/TestValleyBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/TestValleyBoroughCouncil.py index 2563c2e061..b6b766c0ee 100644 --- a/uk_bin_collection/uk_bin_collection/councils/TestValleyBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/TestValleyBoroughCouncil.py @@ -1,9 +1,8 @@ +from __future__ import annotations + import datetime as dt from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -11,6 +10,16 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -49,6 +58,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ).click() import time + time.sleep(8) soup = BeautifulSoup(driver.page_source, features="html.parser") @@ -68,10 +78,12 @@ def parse_data(self, page: str, **kwargs) -> dict: next_date = self._parse_date(next_collection_text) if next_date: - data["bins"].append({ - "type": bin_type, - "collectionDate": next_date.strftime(date_format), - }) + data["bins"].append( + { + "type": bin_type, + "collectionDate": next_date.strftime(date_format), + } + ) followed_div = collection.find( lambda t: ( @@ -82,16 +94,20 @@ def parse_data(self, page: str, **kwargs) -> dict: if followed_div: following_text = followed_div.get_text(strip=True) following_date = self._parse_date( - following_text.replace("followed by ", "").replace("Followed by ", "") + following_text.replace("followed by ", "").replace( + "Followed by ", "" + ) ) if following_date: - data["bins"].append({ - "type": bin_type, - "collectionDate": following_date.strftime(date_format), - }) + data["bins"].append( + { + "type": bin_type, + "collectionDate": following_date.strftime(date_format), + } + ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: @@ -101,6 +117,7 @@ def parse_data(self, page: str, **kwargs) -> dict: @staticmethod def _parse_date(text: str): import re + text = re.sub(r"(st|nd|rd|th)", "", text).strip() try: parsed = dt.datetime.strptime(text, "%A %d %B").date() diff --git a/uk_bin_collection/uk_bin_collection/councils/TewkesburyBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/TewkesburyBoroughCouncil.py index ac06158f82..c133ca893c 100644 --- a/uk_bin_collection/uk_bin_collection/councils/TewkesburyBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/TewkesburyBoroughCouncil.py @@ -10,7 +10,9 @@ def parse_data(self, page: str, **kwargs) -> dict: user_uprn = kwargs.get("uprn") check_uprn(user_uprn) - url = f"https://api-2.tewkesbury.gov.uk/incab/rounds/{user_uprn}/next-collection" + url = ( + f"https://api-2.tewkesbury.gov.uk/incab/rounds/{user_uprn}/next-collection" + ) response = requests.get(url, timeout=30) response.raise_for_status() @@ -19,17 +21,23 @@ def parse_data(self, page: str, **kwargs) -> dict: data = {"bins": []} # Legacy format: {"status":"OK","body":[{"collectionType":"...","NextCollection":"YYYY-MM-DD"}]} - if isinstance(json_data, dict) and json_data.get("status") == "OK" and "body" in json_data: + if ( + isinstance(json_data, dict) + and json_data.get("status") == "OK" + and "body" in json_data + ): for entry in json_data["body"]: bin_type = entry.get("collectionType") date_str = entry.get("NextCollection") if bin_type and date_str: try: collection_date = datetime.strptime(date_str, "%Y-%m-%d") - data["bins"].append({ - "type": bin_type, - "collectionDate": collection_date.strftime(date_format) - }) + data["bins"].append( + { + "type": bin_type, + "collectionDate": collection_date.strftime(date_format), + } + ) except ValueError: continue # Current format: {"food":{"nextCollectionDate":"...Z"},"garden":{...},"recycling":{...},"refuse":{...}} @@ -50,10 +58,12 @@ def parse_data(self, page: str, **kwargs) -> dict: try: # "2026-04-23T01:00:00.000Z" collection_date = datetime.strptime(date_str[:10], "%Y-%m-%d") - data["bins"].append({ - "type": display_name, - "collectionDate": collection_date.strftime(date_format) - }) + data["bins"].append( + { + "type": display_name, + "collectionDate": collection_date.strftime(date_format), + } + ) except ValueError: continue @@ -61,5 +71,5 @@ def parse_data(self, page: str, **kwargs) -> dict: key=lambda x: datetime.strptime(x["collectionDate"], date_format) ) - print(json.dumps(data, indent=2)) + print(f"Parsed {len(data.get('bins', []))} collections") return data diff --git a/uk_bin_collection/uk_bin_collection/councils/ThanetDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ThanetDistrictCouncil.py index 5f4e7afe61..990776f6ce 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ThanetDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ThanetDistrictCouncil.py @@ -1,11 +1,10 @@ +from __future__ import annotations + import json import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -19,6 +18,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + user_uprn = kwargs.get("uprn") check_uprn(user_uprn) bindata = {"bins": []} @@ -32,7 +41,7 @@ def parse_data(self, page: str, **kwargs) -> dict: driver = create_webdriver(web_driver, headless, user_agent, __name__) try: - print(f"Navigating to URL: {url}") + print("Navigating to the configured council page") driver.get(url) # Wait for Cloudflare to complete its check @@ -67,10 +76,10 @@ def parse_data(self, page: str, **kwargs) -> dict: bindata["bins"].sort( key=lambda x: datetime.strptime(x.get("collectionDate"), "%d/%m/%Y") ) - print(bindata) + print(f"Parsed {len(bindata.get('bins', []))} collections") except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: print("Cleaning up WebDriver...") diff --git a/uk_bin_collection/uk_bin_collection/councils/ThreeRiversDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/ThreeRiversDistrictCouncil.py index eccde4bfae..9e2cfbe4da 100644 --- a/uk_bin_collection/uk_bin_collection/councils/ThreeRiversDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/ThreeRiversDistrictCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import logging import time from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -18,6 +16,17 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -133,7 +142,7 @@ def click_element(by, value): return bin_data except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error(f"An error occurred: {type(e).__name__}") raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/TorbayCouncil.py b/uk_bin_collection/uk_bin_collection/councils/TorbayCouncil.py index dd27724d1d..ac668f8927 100644 --- a/uk_bin_collection/uk_bin_collection/councils/TorbayCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/TorbayCouncil.py @@ -1,13 +1,9 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup from dateutil.parser import parse -from selenium.common.exceptions import NoSuchElementException, TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -16,6 +12,19 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, NoSuchElementException, Select, TimeoutException, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.common.exceptions import NoSuchElementException, TimeoutException + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -28,16 +37,12 @@ def parse_data(self, page: str, **kwargs) -> dict: check_postcode(user_postcode) - print( - f"Starting parse_data with parameters: postcode={user_postcode}, uprn={user_uprn}" - ) - print( - f"Creating webdriver with: web_driver={web_driver}, headless={headless}" - ) + print("Starting parse_data with configured address parameters") + print(f"Creating configured webdriver (headless={headless})") user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" driver = create_webdriver(web_driver, headless, user_agent, __name__) - print(f"Navigating to URL: {url}") + print("Navigating to the configured council page") driver.get("https://www.torbay.gov.uk/recycling/bin-collections/") print("Successfully loaded the page") @@ -96,7 +101,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) post_code_input.clear() post_code_input.send_keys(user_postcode) - print(f"Entered postcode: {user_postcode}") + print("Postcode entered") post_code_input.send_keys(Keys.TAB + Keys.ENTER) # driver.switch_to.active_element.send_keys(Keys.TAB + Keys.ENTER) @@ -116,28 +121,28 @@ def parse_data(self, page: str, **kwargs) -> dict: options = address_select.find_elements(By.TAG_NAME, "option") print(f"Found {len(options)} options in dropdown") - # Print all options first for debugging - print("\nAvailable options:") + # Inspect all options without writing their household data to diagnostics. + print("Inspecting available address options") for opt in options: value = opt.get_attribute("value") text = opt.text - print(f"Value: '{value}', Text: '{text}'") + print("Address option inspected") # Try to find our specific UPRN target_uprn = f"U{user_uprn}|" - print(f"\nLooking for UPRN pattern: {target_uprn}") + print("Looking for the configured property identifier") found = False for option in options: value = option.get_attribute("value") if value and target_uprn in value: - print(f"Found matching address with value: {value}") + print("Found a matching address") option.click() found = True break if not found: - print(f"No matching address found for UPRN: {user_uprn}") + print("No matching address found") return data print("Address selected successfully") @@ -209,18 +214,18 @@ def parse_data(self, page: str, **kwargs) -> dict: "collectionDate": bin_date, } data["bins"].append(dict_data) - print(f"Successfully added collection: {dict_data}") + print("Collection added successfully") except Exception as e: - print(f"Error processing collection row: {e}") + print(f"Error processing collection row ({type(e).__name__})") continue # Debug: Print the complete dict_data print("\nFinal bin collection data:") - print(data) + print(f"Parsed {len(data.get('bins', []))} collections") except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred ({type(e).__name__})") raise finally: print("Cleaning up webdriver...") diff --git a/uk_bin_collection/uk_bin_collection/councils/TorfaenCouncil.py b/uk_bin_collection/uk_bin_collection/councils/TorfaenCouncil.py index 1f8cb7075b..df1dad727d 100644 --- a/uk_bin_collection/uk_bin_collection/councils/TorfaenCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/TorfaenCouncil.py @@ -1,12 +1,10 @@ +from __future__ import annotations + import re import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -28,13 +26,22 @@ def _parse_date(text): return parsed except ValueError: continue - raise ValueError( - f"Could not parse date '{text}' with any known format" - ) + raise ValueError(f"Could not parse date '{text}' with any known format") class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: data = {"bins": []} @@ -89,7 +96,9 @@ def parse_data(self, page: str, **kwargs) -> dict: soup = BeautifulSoup(driver.page_source, "html.parser") - collections_div = soup.find("h2", string=re.compile(r"Your next collections", re.I)) + collections_div = soup.find( + "h2", string=re.compile(r"Your next collections", re.I) + ) if not collections_div: raise ValueError("Collection results not found on page") @@ -100,7 +109,9 @@ def parse_data(self, page: str, **kwargs) -> dict: cards = parent.find_all("h3") for card_heading in cards: bin_type = card_heading.get_text(strip=True) - card = card_heading.find_parent("div", class_=re.compile(r"ant-col|col")) + card = card_heading.find_parent( + "div", class_=re.compile(r"ant-col|col") + ) if not card: card = card_heading.find_parent("div") @@ -108,7 +119,8 @@ def parse_data(self, page: str, **kwargs) -> dict: date_matches = re.findall( r"(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\s+\d{1,2}\s+\w+", - card_text, re.I + card_text, + re.I, ) seen = set() @@ -121,15 +133,15 @@ def parse_data(self, page: str, **kwargs) -> dict: key = (bin_type, cd) if key not in seen: seen.add(key) - data["bins"].append({ - "type": bin_type, - "collectionDate": cd, - }) + data["bins"].append( + { + "type": bin_type, + "collectionDate": cd, + } + ) if not data["bins"]: - raise ValueError( - "No bin collection data found for this address" - ) + raise ValueError("No bin collection data found for this address") data["bins"].sort( key=lambda x: datetime.strptime(x.get("collectionDate"), date_format) @@ -138,7 +150,7 @@ def parse_data(self, page: str, **kwargs) -> dict: return data except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/TowerHamletsCouncil.py b/uk_bin_collection/uk_bin_collection/councils/TowerHamletsCouncil.py index 155e3139e6..13733aae81 100644 --- a/uk_bin_collection/uk_bin_collection/councils/TowerHamletsCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/TowerHamletsCouncil.py @@ -1,12 +1,10 @@ +from __future__ import annotations + import re import time from datetime import datetime, timedelta from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +18,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = ( @@ -126,9 +135,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) if not selected: - raise ValueError( - f"No addresses found for postcode {user_postcode}" - ) + raise ValueError(f"No addresses found for postcode {user_postcode}") time.sleep(8) @@ -190,7 +197,7 @@ def _field(name): return data except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/UttlesfordDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/UttlesfordDistrictCouncil.py index c293eac3cc..4d4b6f7217 100644 --- a/uk_bin_collection/uk_bin_collection/councils/UttlesfordDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/UttlesfordDistrictCouncil.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import pickle import time @@ -5,12 +7,6 @@ import requests from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -24,6 +20,19 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait, webdriver + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: user_paon = kwargs.get("paon") @@ -72,15 +81,21 @@ def parse_data(self, page: str, **kwargs) -> dict: paon_lower = str(user_paon).strip().lower() for option in drop_down_values.options: text = option.text.strip().lower() - if text and paon_lower and (text.startswith(paon_lower + " ") or text.startswith(paon_lower + ",") or text == paon_lower): + if ( + text + and paon_lower + and ( + text.startswith(paon_lower + " ") + or text.startswith(paon_lower + ",") + or text == paon_lower + ) + ): option.click() matched = True break if not matched: - raise ValueError( - f"Address '{user_paon}' not found in dropdown" - ) + raise ValueError(f"Address '{user_paon}' not found in dropdown") input_element_address_btn = wait.until( EC.element_to_be_clickable( @@ -135,7 +150,7 @@ def parse_data(self, page: str, **kwargs) -> dict: return bin_data except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error(f"An error occurred: {type(e).__name__}") raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/WakefieldCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WakefieldCityCouncil.py index 7ee5d2f52a..43d90cdec6 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WakefieldCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WakefieldCityCouncil.py @@ -68,7 +68,7 @@ def parse_data(self, page: str, **kwargs) -> dict: data["bins"].append(dict_data) except ValueError: # Skip if the date isn't a valid date - print(f"Skipping invalid date: {date_text}") + print("Skipping an invalid collection date") # Get future collections future_collections_section = row.find("ul", class_="u-mt-4") @@ -98,7 +98,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except ValueError: # Skip if the future collection date isn't valid print( - f"Skipping invalid future date: {future_date_text}" + "Skipping an invalid future collection date" ) # Sort the collections by date @@ -106,7 +106,7 @@ def parse_data(self, page: str, **kwargs) -> dict: key=lambda x: datetime.strptime(x.get("collectionDate"), date_format) ) except Exception as e: - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") raise finally: if driver: diff --git a/uk_bin_collection/uk_bin_collection/councils/WalsallCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WalsallCouncil.py index 9a7cf8e4ea..bd68a0253f 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WalsallCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WalsallCouncil.py @@ -40,9 +40,7 @@ def parse_data(self, page: str, **kwargs) -> dict: bin_url = "https://cag.walsall.gov.uk" + item.contents[1]["href"] r = requests.get(bin_url, headers=headers) if r.status_code != 200: - print( - f"Collection details for {bin_colour.lower()} bin could not be retrieved." - ) + print("Collection details could not be retrieved") break soup = BeautifulSoup(r.text, "html.parser") table = soup.findAll("tr") diff --git a/uk_bin_collection/uk_bin_collection/councils/WalthamForest.py b/uk_bin_collection/uk_bin_collection/councils/WalthamForest.py index 1582eef726..d8789fe6ba 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WalthamForest.py +++ b/uk_bin_collection/uk_bin_collection/councils/WalthamForest.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +18,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = "https://portal.walthamforest.gov.uk/AchieveForms/?mode=fill&consentMessage=yes&form_uri=sandbox-publish://AF-Process-d62ccdd2-3de9-48eb-a229-8e20cbdd6393/AF-Stage-8bf39bf9-5391-4c24-857f-0dc2025c67f4/definition.json&process=1&process_uri=sandbox-processes://AF-Process-d62ccdd2-3de9-48eb-a229-8e20cbdd6393&process_id=AF-Process-d62ccdd2-3de9-48eb-a229-8e20cbdd6393" @@ -117,7 +126,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/WestBerkshireCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WestBerkshireCouncil.py index 08c77e0558..ca62d70d52 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WestBerkshireCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WestBerkshireCouncil.py @@ -1,11 +1,9 @@ +from __future__ import annotations + import time from bs4 import BeautifulSoup from dateutil.relativedelta import relativedelta -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +18,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -136,7 +145,7 @@ def parse_data(self, page: str, **kwargs) -> dict: print() except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/WestDevonBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WestDevonBoroughCouncil.py index eac75e5230..ddd7902d43 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WestDevonBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WestDevonBoroughCouncil.py @@ -1,12 +1,10 @@ +from __future__ import annotations + import json import time from datetime import datetime from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -20,6 +18,17 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait, webdriver + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import WebDriverWait + user_uprn = kwargs.get("uprn") user_postcode = kwargs.get("postcode") user_paon = kwargs.get("paon") @@ -34,9 +43,7 @@ def parse_data(self, page: str, **kwargs) -> dict: driver.get(base_url) WebDriverWait(driver, 15).until( - EC.presence_of_element_located( - (By.CSS_SELECTOR, "input[type='text']") - ) + EC.presence_of_element_located((By.CSS_SELECTOR, "input[type='text']")) ) fcc_token = None @@ -65,9 +72,7 @@ def parse_data(self, page: str, **kwargs) -> dict: addresses = result.get("addresses", {}) if not addresses: - raise ValueError( - f"No addresses found for postcode {user_postcode}" - ) + raise ValueError(f"No addresses found for postcode {user_postcode}") paon_lower = user_paon.lower().strip() matched_uprn = None @@ -123,9 +128,9 @@ def parse_data(self, page: str, **kwargs) -> dict: for coll in collections: service_name = coll.find("h3").text.strip() - det_wrap = coll.find( - "div", class_="wdshDetWrap" - ) or coll.find("div", class_="detWrap") + det_wrap = coll.find("div", class_="wdshDetWrap") or coll.find( + "div", class_="detWrap" + ) if not det_wrap: continue @@ -141,9 +146,7 @@ def parse_data(self, page: str, **kwargs) -> dict: if next_collection.startswith("today"): next_collection = next_collection.split("today, ")[1] elif next_collection.startswith("tomorrow"): - next_collection = next_collection.split( - "tomorrow, " - )[1] + next_collection = next_collection.split("tomorrow, ")[1] collection_date = datetime.strptime( next_collection, "%A, %d %B %Y" @@ -161,9 +164,7 @@ def parse_data(self, page: str, **kwargs) -> dict: bindata["bins"].append(dict_data) bindata["bins"].sort( - key=lambda x: datetime.strptime( - x.get("collectionDate"), "%d/%m/%Y" - ) + key=lambda x: datetime.strptime(x.get("collectionDate"), "%d/%m/%Y") ) finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/WestLothianCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WestLothianCouncil.py index cad85b83c1..09d2dfe576 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WestLothianCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WestLothianCouncil.py @@ -1,7 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -16,6 +15,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -94,7 +103,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/WestOxfordshireDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WestOxfordshireDistrictCouncil.py index 7138ed69ee..bed4c3c93c 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WestOxfordshireDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WestOxfordshireDistrictCouncil.py @@ -1,12 +1,9 @@ +from __future__ import annotations + import time from datetime import datetime from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -24,19 +21,31 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Query West Oxfordshire's waste collection site for a property's bin types and upcoming collection dates. - + Parameters: page (str): Ignored; the function always queries the West Oxfordshire waste collection enquiry page. paon (str, in kwargs): Property house number or name. postcode (str, in kwargs): Property postcode. web_driver (str or WebDriver, in kwargs): WebDriver identifier or instance used to create the Selenium driver. headless (bool, in kwargs): Whether to run the browser in headless mode. - + Returns: dict: A dictionary with a "bins" key mapping to a list of objects, each containing: - "type" (str): Bin/container type (e.g., "General waste", "Recycling"). - "collectionDate" (str): Next collection date formatted as "DD/MM/YYYY". """ + global By, EC, Keys, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: page = "https://community.westoxon.gov.uk/s/waste-collection-enquiry" @@ -53,7 +62,7 @@ def parse_data(self, page: str, **kwargs) -> dict: user_agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" driver = create_webdriver(web_driver, headless, user_agent, __name__) driver.get(page) - + # Scroll to top-left to ensure components are visible driver.execute_script("window.scrollTo(0, 0);") @@ -67,7 +76,7 @@ def parse_data(self, page: str, **kwargs) -> dict: ) address_entry_field.click() address_entry_field.send_keys(str(full_address)) - + # Trigger dropdown by sending backspace and re-typing last character time.sleep(1) # Give the search a moment to process address_entry_field.send_keys(Keys.BACKSPACE) @@ -77,7 +86,7 @@ def parse_data(self, page: str, **kwargs) -> dict: # The second item contains the actual address (first is "Show more results") first_found_address = wait.until( EC.element_to_be_clickable( - (By.XPATH, '(//lightning-base-combobox-item)[2]') + (By.XPATH, "(//lightning-base-combobox-item)[2]") ) ) first_found_address.click() @@ -129,11 +138,11 @@ def parse_data(self, page: str, **kwargs) -> dict: ) except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: # This block ensures that the driver is closed regardless of an exception if driver: driver.quit() - return data \ No newline at end of file + return data diff --git a/uk_bin_collection/uk_bin_collection/councils/WinchesterCityCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WinchesterCityCouncil.py index 41fd2a3f65..4873dd8582 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WinchesterCityCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WinchesterCityCouncil.py @@ -1,7 +1,6 @@ +from __future__ import annotations + from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass @@ -18,7 +17,7 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: """ Parse Winchester council bin calendar and extract upcoming collection types and dates. - + Parameters: page (str): Unused by this implementation; kept for interface compatibility. **kwargs: @@ -26,15 +25,25 @@ def parse_data(self, page: str, **kwargs) -> dict: postcode (str): Postcode to search for addresses. web_driver: Optional identifier or configuration for the Selenium webdriver. headless (bool): Whether to run the webdriver in headless mode. - + Returns: dict: A dictionary with a single key "bins" whose value is a list of dictionaries, each containing: - "type" (str): The bin type/name. - "collectionDate" (str): Collection date formatted as "dd/mm/YYYY". - + Raises: ValueError: If the page does not contain the expected collections container. """ + global By, EC, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -92,7 +101,9 @@ def parse_data(self, page: str, **kwargs) -> dict: # Find the main container and then each card. Use class contains so small CSS changes don't break parsing. recyclingcalendar = soup.find( "div", - class_=lambda c: c and "ant-row" in c and "justify-content-between" in c, + class_=lambda c: c + and "ant-row" in c + and "justify-content-between" in c, ) if not recyclingcalendar: @@ -135,11 +146,11 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: # This block ensures that the driver is closed regardless of an exception if driver: driver.quit() - return data \ No newline at end of file + return data diff --git a/uk_bin_collection/uk_bin_collection/councils/WirralCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WirralCouncil.py index 485bb2d1cf..02d27dfb79 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WirralCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WirralCouncil.py @@ -154,7 +154,11 @@ def parse_data(self, page: str, **kwargs) -> dict: continue try: parsed_month = datetime.strptime(month_name, "%B").month - year = current_year + 1 if parsed_month < current_month else current_year + year = ( + current_year + 1 + if parsed_month < current_month + else current_year + ) collection_date = datetime( year, parsed_month, @@ -169,7 +173,10 @@ def parse_data(self, page: str, **kwargs) -> dict: } ) except ValueError as e: - logging.warning(f"Failed to parse date {day} {month_name}: {e}") + logging.warning( + "Failed to parse a primary date (%s).", + type(e).__name__, + ) j += 1 else: i += 1 @@ -182,15 +189,17 @@ def parse_data(self, page: str, **kwargs) -> dict: if parent: text = parent.get_text(separator="\n", strip=True) # Parse "Friday 15 May\nNon-recyclable waste" pattern - date_match = re.search( - r"(\w+day)\s+(\d{1,2})\s+(\w+)", text - ) + date_match = re.search(r"(\w+day)\s+(\d{1,2})\s+(\w+)", text) if date_match: day = int(date_match.group(2)) month = date_match.group(3) try: parsed_month = datetime.strptime(month, "%B").month - year = current_year + 1 if parsed_month < current_month else current_year + year = ( + current_year + 1 + if parsed_month < current_month + else current_year + ) collection_date = datetime.strptime( f"{day} {month} {year}", "%d %B %Y" ) @@ -210,7 +219,10 @@ def parse_data(self, page: str, **kwargs) -> dict: } ) except ValueError as e: - logging.warning(f"Failed to parse fallback date {day} {month}: {e}") + logging.warning( + "Failed to parse a fallback date (%s).", + type(e).__name__, + ) if not data["bins"]: raise ValueError("No collection data found on page") diff --git a/uk_bin_collection/uk_bin_collection/councils/WrexhamCountyBoroughCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WrexhamCountyBoroughCouncil.py index fb707e5459..69da97d092 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WrexhamCountyBoroughCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WrexhamCountyBoroughCouncil.py @@ -1,14 +1,12 @@ +from __future__ import annotations + from time import sleep from bs4 import BeautifulSoup -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select, WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.get_bin_data import AbstractGetBinDataClass - # import the wonderful Beautiful Soup and the URL grabber @@ -20,6 +18,16 @@ class CouncilClass(AbstractGetBinDataClass): """ def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Select, WebDriverWait + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select, WebDriverWait + driver = None try: page = "https://www.wrexham.gov.uk/service/when-are-my-bins-collected" @@ -114,7 +122,7 @@ def parse_data(self, page: str, **kwargs) -> dict: except Exception as e: # Here you can log the exception if needed - print(f"An error occurred: {e}") + print(f"An error occurred: {type(e).__name__}") # Optionally, re-raise the exception if you want it to propagate raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/WychavonDistrictCouncil.py b/uk_bin_collection/uk_bin_collection/councils/WychavonDistrictCouncil.py index 15bf8b7eb8..70ec2588d2 100644 --- a/uk_bin_collection/uk_bin_collection/councils/WychavonDistrictCouncil.py +++ b/uk_bin_collection/uk_bin_collection/councils/WychavonDistrictCouncil.py @@ -1,15 +1,11 @@ +from __future__ import annotations + import logging import pickle import time import requests from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from selenium.webdriver.support.wait import WebDriverWait from uk_bin_collection.uk_bin_collection.common import * from uk_bin_collection.uk_bin_collection.common import * @@ -24,6 +20,19 @@ class CouncilClass(AbstractGetBinDataClass): def parse_data(self, page: str, **kwargs) -> dict: + global By, EC, Keys, Select, WebDriverWait, webdriver + from uk_bin_collection.uk_bin_collection.common import ( + ensure_selenium_dependencies, + ) + + ensure_selenium_dependencies() + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.support.ui import Select + from selenium.webdriver.support.wait import WebDriverWait + driver = None try: data = {"bins": []} @@ -148,7 +157,7 @@ def parse_data(self, page: str, **kwargs) -> dict: return bin_data except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error(f"An error occurred: {type(e).__name__}") raise finally: diff --git a/uk_bin_collection/uk_bin_collection/councils/tests/test_south_kesteven_district_council.py b/uk_bin_collection/uk_bin_collection/councils/tests/test_south_kesteven_district_council.py index 221a2993ed..2224e36d15 100644 --- a/uk_bin_collection/uk_bin_collection/councils/tests/test_south_kesteven_district_council.py +++ b/uk_bin_collection/uk_bin_collection/councils/tests/test_south_kesteven_district_council.py @@ -1,74 +1,81 @@ -"""Unit tests for the South Kesteven District Council live checker flow.""" +"""Deterministic tests for the South Kesteven browser-only checker flow.""" + +from __future__ import annotations import json -from unittest.mock import ANY, MagicMock, patch +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch import pytest -from selenium.webdriver.common.by import By +from uk_bin_collection.uk_bin_collection.collect_data import UKBinCollectionApp from uk_bin_collection.uk_bin_collection.councils.SouthKestevenDistrictCouncil import ( CouncilClass, ) +from uk_bin_collection.uk_bin_collection.exceptions import ( + AddressMismatchError, + BrowserUnavailableError, + ConfigurationError, + SiteChanged, + UpstreamAccessDenied, +) MODULE_PATH = ( "uk_bin_collection.uk_bin_collection.councils.SouthKestevenDistrictCouncil" ) CHECKER_URL = ( - "https://selfservice.southkesteven.gov.uk/renderform?" - "t=213&k=2074C945A63DDC0D18F1EB74DA230AC3122958B1" + "https://selfservice.southkesteven.gov.uk/renderform?" "t=213&k=public-test-token" ) BINDAY_HTML = f""" - - - - Postcode bin day checker - - - + + Postcode bin day checker + """ RESULTS_HTML = """ - -
-

Your Collections

- - - - - - - - - - - - - - - - - -
Thursday 16 April, 2026240 Litre Refuse
Thursday 23 April, 2026240 Litre Recycling
Thursday 30 April, 202623lt Food Caddy
Thursday 07 May, 2026240 Litre Paper and Card
-
- +

Your Collections

+ + + + + +
Thursday 16 April, 2026240 Litre Refuse
Thursday 23 April, 2026240 Litre Recycling
Thursday 30 April, 202623lt Food Caddy
Thursday 07 May, 2026240 Litre Paper and Card
+
""" UNKNOWN_RESULTS_HTML = """ - -
-

Your Collections

- - - - - -
Thursday 30 April, 2026Glass Box Collection
-
- +

Your Collections

+ + +
Thursday 30 April, 2026Glass Box Collection
+
""" -@pytest.fixture -def council(): - return CouncilClass() +class FakeTimeout(Exception): + """Stand-in for Selenium's timeout exception.""" + + +class FakeWebDriverError(Exception): + """Stand-in for Selenium's WebDriver exception.""" + + +class FakePageLoadTimeout(FakeWebDriverError): + """Stand-in for Selenium's page-load timeout exception.""" + + +class FakeNoSuchElement(Exception): + """Stand-in for Selenium's missing-element exception.""" + + +class FakeUnexpectedTagName(Exception): + """Stand-in for Selenium's unexpected-element-type exception.""" + + +class FakeExpectedConditions: + """Return a callable marker without importing Selenium in this test module.""" + + @staticmethod + def element_to_be_clickable(locator): + return lambda _driver: locator def make_option(text: str) -> MagicMock: @@ -77,246 +84,697 @@ def make_option(text: str) -> MagicMock: return option +def make_support(select=None): + wait_factory = MagicMock( + side_effect=lambda driver, timeout: SimpleNamespace( + _driver=driver, timeout=timeout + ) + ) + select_factory = MagicMock(return_value=select or MagicMock(options=[])) + return SimpleNamespace( + By=SimpleNamespace(ID="id"), + EC=FakeExpectedConditions, + Select=select_factory, + WebDriverWait=wait_factory, + NoSuchElementException=FakeNoSuchElement, + TimeoutException=FakeTimeout, + UnexpectedTagNameException=FakeUnexpectedTagName, + WebDriverException=FakeWebDriverError, + ) + + +@pytest.fixture +def council(): + return CouncilClass() + + def test_parse_data_requires_postcode(council): - with pytest.raises(ValueError, match="Postcode is required for South Kesteven."): + with pytest.raises(ConfigurationError, match="Postcode is required"): council.parse_data("", paon="43") def test_parse_data_requires_paon(council): - with pytest.raises( - ValueError, - match="Property number or name \\(paon\\) is required for South Kesteven.", - ): + with pytest.raises(ConfigurationError, match="Property number or name is required"): council.parse_data("", postcode="NG31 8XG") -def test_resolve_checker_url_extracts_live_cta_link(council): - checker_url = council._resolve_checker_url(council.BIN_DAY_URL, page=BINDAY_HTML) +def test_parse_data_requires_remote_webdriver_before_browser_creation(council): + with patch(f"{MODULE_PATH}.create_webdriver") as create: + with pytest.raises(ConfigurationError, match="remote Selenium WebDriver"): + council.parse_data("", postcode="NG31 8XG", paon="43") - assert checker_url == CHECKER_URL + create.assert_not_called() -def test_address_options_ready_requires_visible_populated_dropdown(council): - mock_driver = MagicMock() - hidden_select = MagicMock() - hidden_select.is_displayed.return_value = False - mock_driver.find_element.return_value = hidden_select +def test_find_checker_url_extracts_only_supported_https_service(council): + assert council._find_checker_url(BINDAY_HTML, council.BIN_DAY_URL) == CHECKER_URL - assert council._address_options_ready(mock_driver) is False + malicious = BINDAY_HTML.replace( + CHECKER_URL, "https://example.invalid/collect?address=secret" + ) + with pytest.raises(SiteChanged, match="outside the supported service"): + council._find_checker_url(malicious, council.BIN_DAY_URL) - visible_select = MagicMock() - visible_select.is_displayed.return_value = True - mock_driver.find_element.return_value = visible_select - with patch(f"{MODULE_PATH}.Select") as mock_select_cls: - mock_select_cls.return_value.options = [ - make_option("43 Pembroke Avenue, Grantham") +def test_address_selection_uses_exact_normalized_paon(council): + matching = make_option("4 Pembroke Avenue, Grantham, NG31 8XG") + select = MagicMock( + options=[ + make_option("43 Pembroke Avenue, Grantham, NG31 8XG"), + matching, ] + ) + support = make_support(select) + + council._select_address(MagicMock(), " 4 ", support) - assert council._address_options_ready(mock_driver) is visible_select + select.select_by_visible_text.assert_called_once_with(matching.text) -def test_select_address_raises_when_property_not_found(council): - mock_select = MagicMock() - mock_select.options = [ - make_option("41 Pembroke Avenue, Grantham, NG31 8XG"), - make_option("45 Pembroke Avenue, Grantham, NG31 8XG"), +@pytest.mark.parametrize( + ("options", "expected"), + [ + ( + [make_option("41 High Street, Grantham")], + "was not found", + ), + ( + [ + make_option("The Cottage, High Street, Grantham"), + make_option(" THE COTTAGE , Low Street, Grantham"), + ], + "matched more than one", + ), + ], +) +def test_address_selection_rejects_missing_or_ambiguous_matches( + council, options, expected +): + support = make_support(MagicMock(options=options)) + + with pytest.raises(AddressMismatchError, match=expected): + council._select_address(MagicMock(), "The Cottage", support) + + +@pytest.mark.parametrize("operation", ["wait", "select"]) +def test_address_control_tag_drift_is_typed_as_site_changed(council, operation): + support = make_support() + support.Select.side_effect = FakeUnexpectedTagName("Select only works on ' + with patch.object( + council, "_get_body_markup", return_value=still_address_form + ), patch.object(council, "_get_current_section_id", return_value="489"): + assert ( + council._results_container_ready( + driver, support, "488", "
initial
" + ) + is False + ) + with patch.object( + council, "_get_body_markup", return_value="
new section
" + ), patch.object(council, "_get_current_section_id", return_value="489"): + assert council._results_container_ready( + driver, support, "488", "
initial
" + ) -def test_parse_data_uses_live_checker_url_and_form_ids(council): - mock_driver = MagicMock() - mock_driver.page_source = RESULTS_HTML + with patch.object( + council, + "_get_body_markup", + return_value="
", + ), patch.object(council, "_get_current_section_id", return_value=None): + assert council._results_container_ready( + driver, support, None, "
initial
" + ) + + +def test_checker_wait_session_loss_is_typed_as_browser_unavailable(council): + driver = MagicMock() + driver.page_source = BINDAY_HTML + wait = MagicMock(_driver=driver) + wait.until.side_effect = FakeWebDriverError("lost session") + support = make_support() + + with patch(f"{MODULE_PATH}.create_webdriver", return_value=driver), patch.object( + council, "_load_selenium_support", return_value=support + ), patch.object(council, "_new_wait", return_value=wait): + with pytest.raises(BrowserUnavailableError, match="stopped responding"): + council.parse_data( + "", + postcode="NG31 8XG", + paon="43", + web_driver="http://selenium:4444", + ) - current_section = MagicMock() - current_section.get_attribute.return_value = "488" - body_content = MagicMock() - body_content.get_attribute.return_value = "
Collection Address
" + driver.quit.assert_called_once_with() - def find_element_side_effect(by, value): - if (by, value) == (By.ID, council.CURRENT_SECTION_ID): - return current_section - if (by, value) == (By.ID, council.BODY_CONTENT_ID): - return body_content - raise AssertionError(f"Unexpected find_element lookup: {(by, value)}") - mock_driver.find_element.side_effect = find_element_side_effect +def test_app_run_intrinsically_skips_generic_get_for_south_kesteven(): + """The CLI/API execution path must not rely on callers supplying a flag.""" + app = UKBinCollectionApp() + app.set_args( + [ + "SouthKestevenDistrictCouncil", + CouncilClass.BIN_DAY_URL, + "--postcode=NG31 8XG", + "--number=43", + "--web_driver=http://selenium:4444", + ] + ) + with patch( + "uk_bin_collection.uk_bin_collection.collect_data.import_council_module", + return_value=SimpleNamespace(CouncilClass=CouncilClass), + ), patch.object( + CouncilClass, + "get_data", + side_effect=AssertionError("generic HTTP preflight must not run"), + ) as generic_get, patch.object( + CouncilClass, + "parse_data", + autospec=True, + return_value={"bins": []}, + ) as parse_data: + result = json.loads(app.run()) + + assert result == {"bins": []} + generic_get.assert_not_called() + assert parse_data.call_args.args[1] == "" + assert parse_data.call_args.kwargs["skip_get_url"] is False + assert parse_data.call_args.kwargs["url"] == CouncilClass.BIN_DAY_URL + + +def test_parse_data_uses_one_browser_session_and_never_calls_http_preflight(council): + driver = MagicMock() + driver.page_source = RESULTS_HTML postcode_input = MagicMock() search_button = MagicMock() submit_button = MagicMock() address_select = MagicMock() + matching = make_option("43 Pembroke Avenue, Grantham, NG31 8XG") + select = MagicMock(options=[matching]) + support = make_support(select) - matched_option = make_option("43 Pembroke Avenue, Grantham, NG31 8XG") - mock_select = MagicMock() - mock_select.options = [ - make_option("41 Pembroke Avenue, Grantham, NG31 8XG"), - matched_option, - ] - - with patch(f"{MODULE_PATH}.create_webdriver", return_value=mock_driver), patch( - f"{MODULE_PATH}.WebDriverWait", return_value=MagicMock() + with patch(f"{MODULE_PATH}.create_webdriver", return_value=driver) as create, patch( + "requests.get", side_effect=AssertionError("HTTP preflight must not run") + ), patch.object( + council, "_load_selenium_support", return_value=support ), patch.object( - council, "_resolve_checker_url", return_value=CHECKER_URL + council, "_wait_for_checker_url", return_value=CHECKER_URL ), patch.object( council, "_wait_for_clickable", side_effect=[postcode_input, search_button, submit_button], - ) as wait_clickable, patch.object( + ), patch.object( council, "_wait_for_address_options", return_value=address_select - ) as wait_for_address, patch.object( + ), patch.object( council, "_wait_for_address_confirmation" - ) as wait_for_confirmation, patch.object( + ), patch.object( council, "_wait_for_results_container" - ) as wait_for_results, patch( - f"{MODULE_PATH}.Select", return_value=mock_select + ), patch.object( + council, "_get_current_section_id", return_value="488" + ), patch.object( + council, "_get_body_markup", return_value="
Address form
" ): - result = council.parse_data("", postcode="NG31 8XG", paon="43") - - assert result["bins"][0]["type"] == "Black Bin" - assert result["bins"][0]["collectionDate"] == "16/04/2026" - mock_driver.get.assert_called_once_with(CHECKER_URL) - postcode_input.clear.assert_called_once() - postcode_input.send_keys.assert_called_once_with("NG31 8XG") - search_button.click.assert_called_once() - submit_button.click.assert_called_once() - wait_clickable.assert_any_call( - ANY, - (By.ID, council.POSTCODE_INPUT_ID), - "Unable to find the postcode input on the South Kesteven checker.", - ) - wait_clickable.assert_any_call( - ANY, - (By.ID, council.SUBMIT_BUTTON_ID), - "Unable to find the submit button after selecting the address.", - ) - wait_for_address.assert_called_once() - wait_for_confirmation.assert_called_once() - wait_for_results.assert_called_once_with( - ANY, - "488", - "
Collection Address
", + result = council.parse_data( + "ignored preflight body", + url="https://unreviewed.invalid/redirect", + postcode="NG31 8XG", + paon="43", + web_driver="http://selenium:4444", + user_agent="UKBCD deterministic test", + ) + + assert result["bins"][0] == { + "type": "Black Bin", + "collectionDate": "16/04/2026", + } + assert driver.get.call_args_list == [ + call(council.BIN_DAY_URL), + call(CHECKER_URL), + ] + create.assert_called_once() + assert create.call_args.args == ( + "http://selenium:4444", + True, + "UKBCD deterministic test", + council.__class__.__module__, ) - mock_select.select_by_visible_text.assert_called_once_with(matched_option.text) - mock_driver.quit.assert_called_once() + assert 0 < create.call_args.kwargs["command_timeout"] <= 30 + postcode_input.clear.assert_called_once_with() + postcode_input.send_keys.assert_called_once_with("NG31 8XG") + search_button.click.assert_called_once_with() + submit_button.click.assert_called_once_with() + select.select_by_visible_text.assert_called_once_with(matching.text) + driver.quit.assert_called_once_with() -def test_parse_data_appends_artifact_path_on_failure(council, tmp_path): - mock_driver = MagicMock() - mock_driver.current_url = CHECKER_URL - mock_driver.page_source = "lookup failed" - mock_driver.save_screenshot.return_value = True +def test_access_denial_after_postcode_search_is_typed_and_session_is_closed(council): + driver = MagicMock() + driver.page_source = BINDAY_HTML + postcode_input = MagicMock() + search_button = MagicMock() + search_button.click.side_effect = lambda: setattr( + driver, + "page_source", + "

403 Forbidden

Access denied", + ) + support = make_support() - current_section = MagicMock() - current_section.get_attribute.return_value = "488" - body_content = MagicMock() - body_content.get_attribute.return_value = "
Collection Address
" + with patch(f"{MODULE_PATH}.create_webdriver", return_value=driver), patch.object( + council, "_load_selenium_support", return_value=support + ), patch.object( + council, "_wait_for_checker_url", return_value=CHECKER_URL + ), patch.object( + council, + "_wait_for_clickable", + side_effect=[postcode_input, search_button], + ), patch.object( + council, "_get_current_section_id", return_value="488" + ), patch.object( + council, "_get_body_markup", return_value="
Address form
" + ), patch.object( + council, "_wait_for_address_options" + ) as wait_for_addresses: + with pytest.raises(UpstreamAccessDenied, match="denied browser access"): + council.parse_data( + "", + postcode="NG31 8XG", + paon="43", + web_driver="http://selenium:4444", + ) - def find_element_side_effect(by, value): - if (by, value) == (By.ID, council.CURRENT_SECTION_ID): - return current_section - if (by, value) == (By.ID, council.BODY_CONTENT_ID): - return body_content - raise AssertionError(f"Unexpected find_element lookup: {(by, value)}") + wait_for_addresses.assert_not_called() + driver.quit.assert_called_once_with() - mock_driver.find_element.side_effect = find_element_side_effect +def test_access_denial_after_submit_is_typed_and_session_is_closed(council): + driver = MagicMock() + driver.page_source = BINDAY_HTML postcode_input = MagicMock() search_button = MagicMock() + submit_button = MagicMock() + submit_button.click.side_effect = lambda: setattr( + driver, + "page_source", + "

403 Forbidden

Access denied", + ) + address_select = MagicMock() + matching = make_option("43 Pembroke Avenue, Grantham, NG31 8XG") + support = make_support(MagicMock(options=[matching])) - with patch(f"{MODULE_PATH}.create_webdriver", return_value=mock_driver), patch( - f"{MODULE_PATH}.WebDriverWait", return_value=MagicMock() + with patch(f"{MODULE_PATH}.create_webdriver", return_value=driver), patch.object( + council, "_load_selenium_support", return_value=support ), patch.object( - council, "_resolve_checker_url", return_value=CHECKER_URL + council, "_wait_for_checker_url", return_value=CHECKER_URL ), patch.object( council, "_wait_for_clickable", - side_effect=[postcode_input, search_button], + side_effect=[postcode_input, search_button, submit_button], ), patch.object( - council, - "_wait_for_address_options", - side_effect=RuntimeError( - "Unable to find the address dropdown after searching for the postcode." - ), + council, "_wait_for_address_options", return_value=address_select + ), patch.object( + council, "_wait_for_address_confirmation" + ), patch.object( + council, "_wait_for_results_container" + ) as wait_for_results, patch.object( + council, "_get_current_section_id", return_value="488" + ), patch.object( + council, "_get_body_markup", return_value="
Address form
" + ): + with pytest.raises(UpstreamAccessDenied, match="denied browser access"): + council.parse_data( + "", + postcode="NG31 8XG", + paon="43", + web_driver="http://selenium:4444", + ) + + wait_for_results.assert_not_called() + driver.quit.assert_called_once_with() + + +def test_browser_403_is_typed_and_session_is_closed(council): + driver = MagicMock() + driver.page_source = "403 ForbiddenAccess denied" + support = make_support() + + with patch(f"{MODULE_PATH}.create_webdriver", return_value=driver), patch.object( + council, "_load_selenium_support", return_value=support + ): + with pytest.raises(UpstreamAccessDenied, match="denied browser access"): + council.parse_data( + "", + postcode="NG31 8XG", + paon="43", + web_driver="http://selenium:4444", + ) + + driver.quit.assert_called_once_with() + + +def test_page_load_timeout_with_denial_preserves_access_denied(council): + driver = MagicMock() + driver.page_source = "

403 Forbidden

Access denied" + driver.get.side_effect = FakePageLoadTimeout("page load timed out") + support = make_support() + + with patch(f"{MODULE_PATH}.create_webdriver", return_value=driver), patch.object( + council, "_load_selenium_support", return_value=support + ): + with pytest.raises(UpstreamAccessDenied, match="denied browser access"): + council.parse_data( + "", + postcode="NG31 8XG", + paon="43", + web_driver="http://selenium:4444", + ) + + driver.quit.assert_called_once_with() + + +def test_webdriver_creation_failure_is_typed(council): + with patch( + f"{MODULE_PATH}.create_webdriver", + side_effect=BrowserUnavailableError("offline"), ): + with pytest.raises(BrowserUnavailableError, match="offline"): + council.parse_data( + "", + postcode="NG31 8XG", + paon="43", + web_driver="http://selenium:4444", + ) + + +def test_unexpected_flow_error_is_typed_and_cleanup_failure_is_contained(council): + driver = MagicMock() + driver.quit.side_effect = RuntimeError("cleanup failed") + + with patch(f"{MODULE_PATH}.create_webdriver", return_value=driver), patch.object( + council, + "_load_selenium_support", + side_effect=RuntimeError("unexpected support failure"), + ), patch(f"{MODULE_PATH}._LOGGER.warning") as warning: with pytest.raises( - RuntimeError, - match="Unable to find the address dropdown after searching for the postcode.", - ) as exc_info: + SiteChanged, match="did not complete the expected flow" + ) as raised: + council.parse_data( + "", + postcode="NG31 8XG", + paon="43", + web_driver="http://selenium:4444", + ) + + assert isinstance(raised.value.__cause__, RuntimeError) + driver.quit.assert_called_once_with() + warning.assert_called_once_with("South Kesteven WebDriver cleanup failed.") + + +def test_total_deadline_is_enforced_and_session_is_closed(council): + driver = MagicMock() + support = make_support() + + with patch(f"{MODULE_PATH}.monotonic", side_effect=[0.0, 1.0, 91.0]), patch( + f"{MODULE_PATH}.create_webdriver", return_value=driver + ) as create, patch.object(council, "_load_selenium_support", return_value=support): + with pytest.raises(SiteChanged, match="exceeded its total timeout"): + council.parse_data( + "", + postcode="NG31 8XG", + paon="43", + web_driver="http://selenium:4444", + ) + + create.assert_called_once() + driver.get.assert_not_called() + driver.quit.assert_called_once_with() + + +def test_opt_in_artifacts_are_redacted_and_never_capture_screenshots(council, tmp_path): + driver = MagicMock() + driver.current_url = ( + "https://selfservice.southkesteven.gov.uk/" + "renderform/100012345678?postcode=NG31%208XG&paon=43" + ) + driver.page_source = ( + "Postcode NG31 8XG / NG318XG / NG31%208XG; property 43; " + '' + '" + '43 Codex Avenue' + '' + '' + '' + "safe fixture content" + ) + + artifact_path = council._capture_debug_artifacts( + driver, + tmp_path, + stage="select_address", + redactions=("NG31 8XG", "43"), + ) + + assert artifact_path is not None + page = (artifact_path / "page.html").read_text(encoding="utf-8") + metadata_text = (artifact_path / "metadata.json").read_text(encoding="utf-8") + metadata = json.loads(metadata_text) + combined = f"{page}\n{metadata_text}" + assert "NG31 8XG" not in combined + assert "NG318XG" not in combined + assert "NG31%208XG" not in combined + assert "100012345678" not in combined + assert "Secret Road" not in combined + assert "Codex Avenue" not in combined + assert "opaque-household-token" not in combined + assert "Hidden Secret Lane" not in combined + assert "Script Secret Road" not in combined + assert "selected" not in page + assert "paon=" not in combined + assert "safe fixture content" in page + assert metadata == { + "current_url": "https://selfservice.southkesteven.gov.uk", + "stage": "select_address", + } + driver.save_screenshot.assert_not_called() + + +def test_artifact_url_preserves_only_allowlisted_origin_or_static_route(council): + assert council._sanitize_url(CHECKER_URL) == ( + "https://selfservice.southkesteven.gov.uk/renderform" + ) + assert ( + council._sanitize_url( + "https://selfservice.southkesteven.gov.uk/address/100012345678" + ) + == "https://selfservice.southkesteven.gov.uk" + ) + assert ( + council._sanitize_url("https://example.invalid/renderform/100012345678") is None + ) + + +def test_artifact_write_failure_does_not_replace_original_error(council, tmp_path): + not_a_directory = tmp_path / "artifact-root" + not_a_directory.write_text("file", encoding="utf-8") + driver = MagicMock() + driver.page_source = "403 Forbidden" + support = make_support() + + with patch(f"{MODULE_PATH}.create_webdriver", return_value=driver), patch.object( + council, "_load_selenium_support", return_value=support + ): + with pytest.raises(UpstreamAccessDenied): council.parse_data( "", postcode="NG31 8XG", paon="43", - artifact_dir=str(tmp_path), + web_driver="http://selenium:4444", + artifact_dir=str(not_a_directory), ) - assert "Debug artifacts saved to:" in str(exc_info.value) - created_artifacts = list(tmp_path.iterdir()) - assert created_artifacts - artifact_run_dir = next(path for path in created_artifacts if path.is_dir()) - assert (artifact_run_dir / "page.html").exists() - assert (artifact_run_dir / "metadata.json").exists() - mock_driver.quit.assert_called_once() + driver.quit.assert_called_once_with() diff --git a/uk_bin_collection/uk_bin_collection/councils/tests/test_south_kesteven_integration.py b/uk_bin_collection/uk_bin_collection/councils/tests/test_south_kesteven_integration.py index caa204cab1..9109a4fdc0 100644 --- a/uk_bin_collection/uk_bin_collection/councils/tests/test_south_kesteven_integration.py +++ b/uk_bin_collection/uk_bin_collection/councils/tests/test_south_kesteven_integration.py @@ -1,12 +1,14 @@ """Integration tests for the South Kesteven binday Selenium flow.""" import pytest -from selenium.common.exceptions import WebDriverException -from urllib3.exceptions import MaxRetryError from uk_bin_collection.uk_bin_collection.councils.SouthKestevenDistrictCouncil import ( CouncilClass, ) +from uk_bin_collection.uk_bin_collection.exceptions import ( + AddressMismatchError, + BrowserUnavailableError, +) class TestSouthKestevenIntegration: @@ -37,7 +39,7 @@ def test_real_binday_lookup( headless=test_headless, artifact_dir=str(tmp_path), ) - except (MaxRetryError, WebDriverException) as exc: + except BrowserUnavailableError as exc: pytest.skip(f"Selenium unavailable for integration test: {exc}") assert "bins" in result @@ -62,7 +64,8 @@ def test_unknown_property_is_reported( ): try: with pytest.raises( - RuntimeError, match="Unable to find the property 'NOT_A_REAL_PROPERTY'" + AddressMismatchError, + match="configured property was not found", ): self.council.parse_data( "", @@ -73,5 +76,5 @@ def test_unknown_property_is_reported( headless=test_headless, artifact_dir=str(tmp_path), ) - except (MaxRetryError, WebDriverException) as exc: + except BrowserUnavailableError as exc: pytest.skip(f"Selenium unavailable for integration test: {exc}") diff --git a/uk_bin_collection/uk_bin_collection/dependency_validation.py b/uk_bin_collection/uk_bin_collection/dependency_validation.py new file mode 100644 index 0000000000..d4f982ddc6 --- /dev/null +++ b/uk_bin_collection/uk_bin_collection/dependency_validation.py @@ -0,0 +1,92 @@ +"""Validation for optional dependencies that are imported at runtime.""" + +from __future__ import annotations + +import os +from importlib import metadata, util +from pathlib import Path + +from uk_bin_collection.uk_bin_collection.exceptions import ( + DependencyShadowingError, + MissingDependencyError, +) + +WEBSOCKET_DISTRIBUTION = "websocket-client" +WEBSOCKET_IMPORT_NAME = "websocket" +_WEBSOCKET_INIT = "websocket/__init__.py" + + +def _normalised_path(path: os.PathLike[str] | str) -> str: + """Return a real, case-normalised path suitable for identity comparison.""" + return os.path.normcase(os.path.realpath(os.fspath(path))) + + +def _owned_websocket_init_files(distribution: metadata.Distribution) -> set[str]: + """Return websocket package entry points owned by ``websocket-client``.""" + owned_files: set[str] = set() + for relative_path in distribution.files or (): + portable_path = str(relative_path).replace("\\", "/") + if portable_path == _WEBSOCKET_INIT: + owned_files.add(_normalised_path(distribution.locate_file(relative_path))) + return owned_files + + +def validate_websocket_client() -> Path: + """Verify that ``websocket`` resolves to the ``websocket-client`` package. + + Selenium imports the top-level module named ``websocket``. Home Assistant's + configuration directory is importable, so an unrelated ``/config/websocket`` + package can otherwise win normal import resolution. Comparing the resolved + module file with the files recorded in installed distribution metadata catches + that collision without modifying ``sys.path`` or importing the suspect module. + + Returns: + The verified path to ``websocket/__init__.py``. + + Raises: + MissingDependencyError: ``websocket-client`` is absent or incomplete. + DependencyShadowingError: ``websocket`` resolves outside that distribution. + """ + try: + distribution = metadata.distribution(WEBSOCKET_DISTRIBUTION) + except metadata.PackageNotFoundError as exc: + raise MissingDependencyError( + "The optional dependency 'websocket-client' is required for Selenium." + ) from exc + + owned_files = _owned_websocket_init_files(distribution) + if not owned_files: + raise MissingDependencyError( + "The installed 'websocket-client' distribution does not contain " + "websocket/__init__.py. Reinstall the dependency before using Selenium." + ) + + try: + specification = util.find_spec(WEBSOCKET_IMPORT_NAME) + except (AttributeError, ImportError, ValueError) as exc: + raise DependencyShadowingError( + "Python cannot safely resolve the 'websocket' module required by " + "'websocket-client'. Remove or rename the conflicting top-level package." + ) from exc + + if specification is None or specification.origin is None: + raise MissingDependencyError( + "The 'websocket-client' distribution is installed but its 'websocket' " + "module cannot be resolved." + ) + + resolved_path = _normalised_path(specification.origin) + if resolved_path not in owned_files: + expected_parent = Path(next(iter(owned_files))).parent + raise DependencyShadowingError( + "The top-level 'websocket' import is shadowed by an unrelated package " + f"at {specification.origin!s}. It must resolve from {expected_parent!s}." + ) + + if not Path(resolved_path).is_file(): + raise MissingDependencyError( + "The 'websocket-client' distribution metadata points to a missing " + "websocket/__init__.py file. Reinstall the dependency." + ) + + return Path(resolved_path) diff --git a/uk_bin_collection/uk_bin_collection/exceptions.py b/uk_bin_collection/uk_bin_collection/exceptions.py new file mode 100644 index 0000000000..a43a1e98e0 --- /dev/null +++ b/uk_bin_collection/uk_bin_collection/exceptions.py @@ -0,0 +1,46 @@ +"""Domain exceptions raised by the UK Bin Collection core library. + +The library deliberately raises these exceptions without importing Home Assistant, +CLI, or API concerns. Each application boundary can therefore translate the same +failure into an appropriate user-facing result. +""" + + +class UKBinCollectionError(Exception): + """Base class for expected UK Bin Collection failures.""" + + +class ConfigurationError(UKBinCollectionError): + """The supplied collector configuration is invalid or incomplete.""" + + +class InvalidCouncilModuleError(ConfigurationError): + """The requested council is not present in the installed council registry.""" + + +class DependencyError(UKBinCollectionError): + """A required runtime dependency cannot be used safely.""" + + +class MissingDependencyError(DependencyError): + """A required runtime dependency is not installed or is incomplete.""" + + +class DependencyShadowingError(DependencyError): + """An import resolves outside the files owned by its expected distribution.""" + + +class BrowserUnavailableError(UKBinCollectionError): + """A Selenium browser session could not be created or reached.""" + + +class AddressMismatchError(UKBinCollectionError): + """No unique upstream address matches the configured property.""" + + +class UpstreamAccessDenied(UKBinCollectionError): + """The upstream collection service denied access to the request.""" + + +class SiteChanged(UKBinCollectionError): + """The upstream site no longer has the structure expected by its adapter.""" diff --git a/uk_bin_collection/uk_bin_collection/get_bin_data.py b/uk_bin_collection/uk_bin_collection/get_bin_data.py index 6941eba4d4..f5dd09db20 100644 --- a/uk_bin_collection/uk_bin_collection/get_bin_data.py +++ b/uk_bin_collection/uk_bin_collection/get_bin_data.py @@ -50,6 +50,12 @@ class AbstractGetBinDataClass(ABC): Keyword arguments: None """ + # Councils whose complete flow is browser/API driven can opt out of the + # template's legacy HTTP preflight. Keeping this on the adapter makes the + # behaviour consistent for the CLI, API and Home Assistant callers even if + # they do not know to pass ``skip_get_url``. + skip_generic_get = False + def template_method(self, address_url: str, **kwargs) -> None: # pragma: no cover """The main template method that is constructed @@ -80,7 +86,7 @@ def get_and_parse_data(self, address_url, **kwargs): Keyword arguments: address_url -- the URL to get the data from """ - if not kwargs.get("skip_get_url"): + if not (kwargs.get("skip_get_url") or self.skip_generic_get): page = self.get_data(address_url) bin_data_dict = self.parse_data(page, url=address_url, **kwargs) else: @@ -128,7 +134,7 @@ def get_data(cls, url) -> str: full_page = session.get(url, verify=False, timeout=120) return full_page except requests.exceptions.RequestException as err: - _LOGGER.error(f"Request Error: {err}") + _LOGGER.error("Council request failed (%s).", type(err).__name__) raise @abstractmethod