mirror of
https://github.com/home-assistant/core.git
synced 2026-05-22 08:45:16 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 748a9842af | |||
| 55786dbdfc | |||
| e88c03a437 | |||
| dbc0dc1ea6 | |||
| 31271876bf | |||
| d5c31332b5 | |||
| 3f0c93c26c | |||
| 07ed913ba2 | |||
| b7905b163f | |||
| c712b07da3 |
@@ -0,0 +1,52 @@
|
||||
name: Cache and install APT packages
|
||||
description: >-
|
||||
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
|
||||
needs. Removes the conflicting Microsoft apt source before any apt run, and
|
||||
points the dynamic linker at the host's multiarch lib subdirectories so
|
||||
shared libraries that rely on update-alternatives or postinst-managed paths
|
||||
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
|
||||
action does not execute postinst scripts on cache restore.
|
||||
|
||||
inputs:
|
||||
packages:
|
||||
description: Space-delimited list of apt packages to install.
|
||||
required: true
|
||||
version:
|
||||
description: Cache version. Bump to invalidate the cache.
|
||||
required: false
|
||||
default: "1"
|
||||
execute_install_scripts:
|
||||
description: >-
|
||||
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
|
||||
actually cached by the upstream action, so this is largely a no-op today.
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Remove conflicting Microsoft apt source
|
||||
shell: bash
|
||||
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
|
||||
- name: Install apt packages via cache
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
|
||||
with:
|
||||
packages: ${{ inputs.packages }}
|
||||
version: ${{ inputs.version }}
|
||||
execute_install_scripts: ${{ inputs.execute_install_scripts }}
|
||||
- name: Refresh dynamic linker cache
|
||||
shell: bash
|
||||
run: |
|
||||
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
|
||||
# cache restore, so update-alternatives symlinks (eg the one libblas
|
||||
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
|
||||
# Add every /usr/lib/<multiarch> subdirectory that holds shared
|
||||
# libraries to the ldconfig search path so the dynamic linker still
|
||||
# finds them. Use dpkg-architecture to derive the host's multiarch
|
||||
# tuple so this works on non-x86_64 runners too.
|
||||
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
|
||||
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
|
||||
-name '*.so.*' -printf '%h\n' \
|
||||
| sort -u \
|
||||
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
|
||||
sudo ldconfig
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Set up uv and managed Python
|
||||
description: >-
|
||||
Pins uv (avoids the raw.githubusercontent.com manifest fetch on cache miss)
|
||||
and proactively installs the requested Python so cached venvs created with
|
||||
`uv venv` resolve their interpreter symlinks in jobs that only restore the
|
||||
venv. setup-uv alone only sets UV_PYTHON, it does not actually fetch the
|
||||
interpreter until uv first uses it, so jobs that just activate the venv
|
||||
blow up with broken symlinks on cache hit.
|
||||
|
||||
inputs:
|
||||
python-version:
|
||||
description: The Python version uv should install and use.
|
||||
required: true
|
||||
uv-version:
|
||||
description: The uv version setup-uv should install.
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
python-version:
|
||||
description: The Python version uv reports as installed.
|
||||
value: ${{ steps.uv.outputs.python-version }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up uv
|
||||
id: uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: ${{ inputs.uv-version }}
|
||||
python-version: ${{ inputs.python-version }}
|
||||
# Persist astral's managed Python across jobs so 'uv venv' below is
|
||||
# fast on the second job onwards.
|
||||
cache-python: true
|
||||
# Lint-only and codegen jobs touch no Python deps, so the post-step
|
||||
# cache save would otherwise abort the job.
|
||||
ignore-nothing-to-cache: true
|
||||
- name: Install Python interpreter
|
||||
shell: bash
|
||||
env:
|
||||
PYTHON_VERSION: ${{ inputs.python-version }}
|
||||
run: uv python install "${PYTHON_VERSION}"
|
||||
@@ -25,7 +25,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
|
||||
+133
-252
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
CACHE_VERSION: 4
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.6"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
@@ -60,9 +60,7 @@ env:
|
||||
# - 15.2 is the latest (as of 9 Feb 2023)
|
||||
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
||||
UV_CACHE_DIR: /tmp/uv-cache
|
||||
APT_CACHE_BASE: /home/runner/work/apt
|
||||
APT_CACHE_DIR: /home/runner/work/apt/cache
|
||||
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
|
||||
APT_CACHE_VERSION: 1
|
||||
SQLALCHEMY_WARN_20: 1
|
||||
PYTHONASYNCIODEBUG: 1
|
||||
HASS_CI: 1
|
||||
@@ -86,12 +84,13 @@ jobs:
|
||||
core: ${{ steps.core.outputs.changes }}
|
||||
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
||||
integrations: ${{ steps.integrations.outputs.changes }}
|
||||
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
|
||||
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
|
||||
requirements: ${{ steps.core.outputs.requirements }}
|
||||
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
||||
postgresql_groups: ${{ steps.info.outputs.postgresql_groups }}
|
||||
python_versions: ${{ steps.info.outputs.python_versions }}
|
||||
default_python: ${{ steps.info.outputs.default_python }}
|
||||
uv_version: ${{ steps.info.outputs.uv_version }}
|
||||
test_full_suite: ${{ steps.info.outputs.test_full_suite }}
|
||||
test_group_count: ${{ steps.info.outputs.test_group_count }}
|
||||
test_groups: ${{ steps.info.outputs.test_groups }}
|
||||
@@ -116,10 +115,6 @@ jobs:
|
||||
# Include HA_SHORT_VERSION to force the immediate creation
|
||||
# of a new uv cache entry after a version bump.
|
||||
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
|
||||
- name: Generate partial apt restore key
|
||||
id: generate_apt_cache_key
|
||||
run: |
|
||||
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
|
||||
- name: Filter for core changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: core
|
||||
@@ -242,6 +237,11 @@ jobs:
|
||||
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
|
||||
echo "python_versions: ${all_python_versions}"
|
||||
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
|
||||
echo "default_python: ${default_python}"
|
||||
echo "default_python=${default_python}" >> $GITHUB_OUTPUT
|
||||
uv_version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)
|
||||
echo "uv_version: ${uv_version}"
|
||||
echo "uv_version=${uv_version}" >> $GITHUB_OUTPUT
|
||||
echo "test_full_suite: ${test_full_suite}"
|
||||
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
|
||||
echo "integrations_glob: ${integrations_glob}"
|
||||
@@ -351,12 +351,12 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
- name: Set up uv and Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -384,80 +384,41 @@ jobs:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: ${{ steps.generate-uv-key.outputs.full_key }}
|
||||
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
if: |
|
||||
steps.cache-venv.outputs.cache-hit != 'true'
|
||||
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
id: install-os-deps
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
||||
mkdir -p ${APT_CACHE_DIR}
|
||||
mkdir -p ${APT_LIST_CACHE_DIR}
|
||||
fi
|
||||
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils \
|
||||
libavcodec-dev \
|
||||
libavdevice-dev \
|
||||
libavfilter-dev \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libswresample-dev \
|
||||
libswscale-dev \
|
||||
libudev-dev
|
||||
|
||||
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
|
||||
sudo chmod -R 755 ${APT_CACHE_BASE}
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: |
|
||||
always()
|
||||
&& steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
&& steps.install-os-deps.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
libavcodec-dev
|
||||
libavdevice-dev
|
||||
libavfilter-dev
|
||||
libavformat-dev
|
||||
libavutil-dev
|
||||
libswresample-dev
|
||||
libswscale-dev
|
||||
libudev-dev
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: create-venv
|
||||
env:
|
||||
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
|
||||
run: |
|
||||
python -m venv venv
|
||||
uv venv venv --python "${PYTHON_VERSION}"
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install -U "pip>=25.2"
|
||||
uv pip install -r requirements.txt
|
||||
uv pip install -r requirements_all.txt -r requirements_test.txt
|
||||
uv pip install -e . --config-settings editable_mode=compat
|
||||
- name: Dump pip freeze
|
||||
run: |
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
@@ -506,36 +467,22 @@ jobs:
|
||||
&& github.event.inputs.mypy-only != 'true'
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
libturbojpeg
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -569,10 +516,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -605,10 +552,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Run gen_copilot_instructions.py
|
||||
run: |
|
||||
python -m script.gen_copilot_instructions validate
|
||||
@@ -660,10 +607,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -711,10 +658,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -764,10 +711,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -815,10 +762,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Generate partial mypy restore key
|
||||
id: generate-mypy-key
|
||||
run: |
|
||||
@@ -876,38 +823,26 @@ jobs:
|
||||
- info
|
||||
- base
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
check-latest: true
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ needs.info.outputs.default_python }}
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -917,23 +852,12 @@ jobs:
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore pytest test counts cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: pytest_test_counts.json
|
||||
key: >-
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
steps.python.outputs.python-version }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-
|
||||
- name: Run split_tests.py
|
||||
env:
|
||||
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -m script.split_tests \
|
||||
--cache pytest_test_counts.json \
|
||||
${TEST_GROUP_COUNT} tests
|
||||
python -m script.split_tests ${TEST_GROUP_COUNT} tests
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -963,39 +887,27 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -1116,40 +1028,28 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libmariadb-dev-compat \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libmariadb-dev-compat
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -1277,42 +1177,35 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
sudo apt-get -y install \
|
||||
postgresql-server-dev-14
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up PostgreSQL apt repository
|
||||
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
- name: Cache PostgreSQL development headers
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: postgresql-server-dev-14
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -1460,39 +1353,27 @@ jobs:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
sudo apt-get update \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
|
||||
sudo apt-get -y install \
|
||||
-o Dir::Cache=${APT_CACHE_DIR} \
|
||||
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install additional OS dependencies
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/cache-apt-packages
|
||||
with:
|
||||
packages: >-
|
||||
bluez
|
||||
ffmpeg
|
||||
libturbojpeg
|
||||
libxml2-utils
|
||||
version: ${{ env.APT_CACHE_VERSION }}
|
||||
execute_install_scripts: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: ./.github/actions/setup-uv-python
|
||||
with:
|
||||
uv-version: ${{ needs.info.outputs.uv_version }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
|
||||
@@ -15,7 +15,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.29.11",
|
||||
"dbus-fast==5.0.3",
|
||||
"habluetooth==6.2.0"
|
||||
"dbus-fast==5.0.0",
|
||||
"habluetooth==6.1.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ from homeassistant.components.climate import (
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_LOCKED, ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DeconzConfigEntry
|
||||
from .const import ATTR_OFFSET, ATTR_VALVE
|
||||
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
|
||||
from .entity import DeconzDevice
|
||||
from .hub import DeconzHub
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
ATTR_DARK = "dark"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LOCKED = "locked"
|
||||
ATTR_OFFSET = "offset"
|
||||
ATTR_ON = "on"
|
||||
ATTR_VALVE = "valve"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.1",
|
||||
"aiodiscover==3.2.3",
|
||||
"aiodiscover==3.2.0",
|
||||
"cached-ipaddress==1.0.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
|
||||
import aiodns
|
||||
from aiodns.error import DNSError
|
||||
from pycares import AresError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PORT
|
||||
@@ -77,7 +78,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
|
||||
) from err
|
||||
|
||||
errors = [
|
||||
result for result in results if isinstance(result, (TimeoutError, DNSError))
|
||||
result
|
||||
for result in results
|
||||
if isinstance(
|
||||
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
|
||||
)
|
||||
]
|
||||
if errors and len(errors) == len(results):
|
||||
await _close_resolvers()
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["guntamatic==1.9.0"]
|
||||
"requirements": ["guntamatic==1.8.0"]
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@ from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, llm
|
||||
|
||||
from .application_credentials import authorization_server_context
|
||||
from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
|
||||
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
|
||||
from .coordinator import ModelContextProtocolCoordinator, TokenManager
|
||||
from .types import ModelContextProtocolConfigEntry
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from yarl import URL
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -24,7 +24,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
|
||||
from . import async_get_config_entry_implementation
|
||||
from .application_credentials import authorization_server_context
|
||||
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_AUTHORIZATION_URL,
|
||||
CONF_SCOPE,
|
||||
CONF_TOKEN_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import TokenManager, mcp_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
DOMAIN = "mcp"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ACCESS_TOKEN = "access_token"
|
||||
CONF_AUTHORIZATION_URL = "authorization_url"
|
||||
CONF_TOKEN_URL = "token_url"
|
||||
CONF_SCOPE = "scope"
|
||||
|
||||
@@ -41,7 +41,7 @@ from mcp.shared.message import SessionMessage
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
@@ -56,6 +56,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
STREAMABLE_API = "/api/mcp"
|
||||
TIMEOUT = 60 # Seconds
|
||||
|
||||
# Content types
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONTENT_TYPE_JSON = "application/json"
|
||||
|
||||
# Legacy SSE endpoint
|
||||
SSE_API = f"/{DOMAIN}/sse"
|
||||
MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["opendisplay"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["py-opendisplay==7.2.3"]
|
||||
"requirements": ["py-opendisplay==5.9.0"]
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ async def _async_upload_image(call: ServiceCall) -> None:
|
||||
pil_image,
|
||||
refresh_mode=refresh_mode,
|
||||
dither_mode=dither_mode,
|
||||
tone=tone_compression,
|
||||
tone_compression=tone_compression,
|
||||
fit=fit_mode,
|
||||
rotate=rotation,
|
||||
)
|
||||
|
||||
@@ -118,9 +118,6 @@
|
||||
"services": {
|
||||
"prune_images": {
|
||||
"service": "mdi:delete-sweep"
|
||||
},
|
||||
"recreate_container": {
|
||||
"service": "mdi:restart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ from .coordinator import PortainerConfigEntry
|
||||
|
||||
ATTR_DATE_UNTIL = "until"
|
||||
ATTR_DANGLING = "dangling"
|
||||
ATTR_TIMEOUT = "timeout"
|
||||
ATTR_PULL_IMAGE = "pull_image"
|
||||
ATTR_CONTAINER_DEVICE_ID = "container_device_id"
|
||||
|
||||
SERVICE_PRUNE_IMAGES = "prune_images"
|
||||
SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
|
||||
@@ -35,17 +32,6 @@ SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
SERVICE_RECREATE_CONTAINER = "recreate_container"
|
||||
SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONTAINER_DEVICE_ID): cv.string,
|
||||
vol.Optional(ATTR_TIMEOUT): vol.All(
|
||||
cv.time_period, vol.Range(min=timedelta(minutes=1))
|
||||
),
|
||||
vol.Optional(ATTR_PULL_IMAGE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry:
|
||||
"""Extract config entry from the service call."""
|
||||
@@ -89,45 +75,6 @@ async def _get_endpoint_id(
|
||||
return endpoint_data.endpoint.id
|
||||
|
||||
|
||||
async def _get_container_and_endpoint_ids(
|
||||
call: ServiceCall,
|
||||
) -> tuple[PortainerConfigEntry, int, str]:
|
||||
"""Get config entry, endpoint ID and container ID from the container device ID."""
|
||||
device_reg = dr.async_get(call.hass)
|
||||
device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
config_entry: PortainerConfigEntry | None = None
|
||||
for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
if loaded_entry.entry_id in device.config_entries:
|
||||
config_entry = loaded_entry
|
||||
break
|
||||
|
||||
if config_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
for data in coordinator.data.values():
|
||||
for container_name, container_data in data.containers.items():
|
||||
if (
|
||||
DOMAIN,
|
||||
f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}",
|
||||
) in device.identifiers:
|
||||
return config_entry, data.endpoint.id, container_data.container.id
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
|
||||
async def prune_images(call: ServiceCall) -> None:
|
||||
"""Prune unused images in Portainer, with more controls."""
|
||||
config_entry = await _extract_config_entry(call)
|
||||
@@ -157,40 +104,6 @@ async def prune_images(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
async def recreate_container(call: ServiceCall) -> None:
|
||||
"""Recreate a container in Portainer, with more controls."""
|
||||
config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids(
|
||||
call
|
||||
)
|
||||
coordinator = config_entry.runtime_data
|
||||
timeout: timedelta | None = call.data.get(ATTR_TIMEOUT)
|
||||
|
||||
try:
|
||||
await coordinator.portainer.container_recreate(
|
||||
endpoint_id=endpoint_id,
|
||||
container_id=container_id,
|
||||
**({"timeout": timeout} if timeout is not None else {}),
|
||||
pull_image=call.data.get(ATTR_PULL_IMAGE, False),
|
||||
)
|
||||
except PortainerAuthenticationError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth_no_details",
|
||||
) from err
|
||||
except PortainerConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_no_details",
|
||||
) from err
|
||||
except PortainerTimeoutError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect_no_details",
|
||||
) from err
|
||||
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
|
||||
@@ -200,10 +113,3 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
prune_images,
|
||||
SERVICE_PRUNE_IMAGES_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
recreate_container,
|
||||
SERVICE_RECREATE_CONTAINER_SCHEMA,
|
||||
)
|
||||
|
||||
@@ -16,20 +16,3 @@ prune_images:
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
recreate_container:
|
||||
fields:
|
||||
container_device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: portainer
|
||||
model: Container
|
||||
timeout:
|
||||
required: false
|
||||
selector:
|
||||
duration:
|
||||
pull_image:
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
@@ -235,24 +235,6 @@
|
||||
}
|
||||
},
|
||||
"name": "Prune unused images"
|
||||
},
|
||||
"recreate_container": {
|
||||
"description": "Recreates a container on a Portainer endpoint. This is more disruptive than a restart as the container will be stopped, removed, and then re-created with the same configuration. Use with caution.",
|
||||
"fields": {
|
||||
"container_device_id": {
|
||||
"description": "The container to recreate.",
|
||||
"name": "Container"
|
||||
},
|
||||
"pull_image": {
|
||||
"description": "Whether to pull the image before recreating the container. This can be used to update the container to the latest version of the image.",
|
||||
"name": "Pull image"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "The time to wait for the container to stop before killing it. If not provided, a default of 5 minutes will be used.",
|
||||
"name": "Timeout"
|
||||
}
|
||||
},
|
||||
"name": "Recreate container"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Define constants for the SleepIQ component."""
|
||||
|
||||
from homeassistant.const import PRESSURE
|
||||
|
||||
DATA_SLEEPIQ = "data_sleepiq"
|
||||
DOMAIN = "sleepiq"
|
||||
|
||||
@@ -13,6 +11,8 @@ FIRMNESS = "firmness"
|
||||
ICON_EMPTY = "mdi:bed-empty"
|
||||
ICON_OCCUPIED = "mdi:bed"
|
||||
IS_IN_BED = "is_in_bed"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
PRESSURE = "pressure"
|
||||
SLEEP_NUMBER = "sleep_number"
|
||||
FOOT_WARMING_TIMER = "foot_warming_timer"
|
||||
FOOT_WARMER = "foot_warmer"
|
||||
|
||||
@@ -11,13 +11,14 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PRESSURE, UnitOfTime
|
||||
from homeassistant.const import UnitOfTime
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
HEART_RATE,
|
||||
HRV,
|
||||
PRESSURE,
|
||||
RESPIRATORY_RATE,
|
||||
SLEEP_DURATION,
|
||||
SLEEP_NUMBER,
|
||||
|
||||
@@ -7,7 +7,6 @@ import smarttub
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -20,6 +19,8 @@ from .entity import SmartTubOnboardSensorBase
|
||||
# the desired duration, in hours, of the cycle
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_CYCLE_LAST_UPDATED = "cycle_last_updated"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODE = "mode"
|
||||
# the hour of the day at which to start the cycle (0-23)
|
||||
ATTR_START_HOUR = "start_hour"
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ from surepy.enums import Location
|
||||
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_LOCATION, Platform
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
ATTR_FLAP_ID,
|
||||
ATTR_LOCATION,
|
||||
ATTR_LOCK_STATE,
|
||||
ATTR_PET_NAME,
|
||||
DOMAIN,
|
||||
|
||||
@@ -18,5 +18,7 @@ SURE_BATT_VOLTAGE_DIFF = SURE_BATT_VOLTAGE_FULL - SURE_BATT_VOLTAGE_LOW
|
||||
SERVICE_SET_LOCK_STATE = "set_lock_state"
|
||||
SERVICE_SET_PET_LOCATION = "set_pet_location"
|
||||
ATTR_FLAP_ID = "flap_id"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LOCATION = "location"
|
||||
ATTR_LOCK_STATE = "lock_state"
|
||||
ATTR_PET_NAME = "pet_name"
|
||||
|
||||
@@ -8,7 +8,7 @@ from surepy.enums import EntityType, Location, LockState
|
||||
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_LOCATION, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -16,6 +16,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import (
|
||||
ATTR_FLAP_ID,
|
||||
ATTR_LOCATION,
|
||||
ATTR_LOCK_STATE,
|
||||
ATTR_PET_NAME,
|
||||
DOMAIN,
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==10.5.0"]
|
||||
"requirements": ["uiprotect==10.4.1"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Automatically generated by gen_requirements_all.py, do not edit
|
||||
|
||||
aiodhcpwatcher==1.2.1
|
||||
aiodiscover==3.2.3
|
||||
aiodiscover==3.2.0
|
||||
aiodns==4.0.4
|
||||
aiogithubapi==26.0.0
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
@@ -30,12 +30,12 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==48.0.0
|
||||
dbus-fast==5.0.3
|
||||
dbus-fast==5.0.0
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.2
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==6.2.0
|
||||
habluetooth==6.1.0
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
@@ -133,7 +133,7 @@ multidict>=6.0.2
|
||||
Brotli>=1.2.0
|
||||
|
||||
# ensure pydantic version does not float since it might have breaking changes
|
||||
pydantic==2.13.4
|
||||
pydantic==2.13.2
|
||||
|
||||
# Required for Python 3.14.0 compatibility (#119223).
|
||||
mashumaro>=3.17.0
|
||||
|
||||
Generated
+6
-6
@@ -233,7 +233,7 @@ aiocomelit==2.0.3
|
||||
aiodhcpwatcher==1.2.1
|
||||
|
||||
# homeassistant.components.dhcp
|
||||
aiodiscover==3.2.3
|
||||
aiodiscover==3.2.0
|
||||
|
||||
# homeassistant.components.dnsip
|
||||
aiodns==4.0.4
|
||||
@@ -794,7 +794,7 @@ datadog==0.52.0
|
||||
datapoint==0.12.1
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
dbus-fast==5.0.3
|
||||
dbus-fast==5.0.0
|
||||
|
||||
# homeassistant.components.debugpy
|
||||
debugpy==1.8.17
|
||||
@@ -1183,7 +1183,7 @@ growattServer==2.1.0
|
||||
gspread==5.5.0
|
||||
|
||||
# homeassistant.components.guntamatic
|
||||
guntamatic==1.9.0
|
||||
guntamatic==1.8.0
|
||||
|
||||
# homeassistant.components.profiler
|
||||
guppy3==3.1.6
|
||||
@@ -1210,7 +1210,7 @@ ha-xthings-cloud==1.0.5
|
||||
habiticalib==0.4.7
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==6.2.0
|
||||
habluetooth==6.1.0
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.7
|
||||
@@ -1923,7 +1923,7 @@ py-nightscout==1.2.2
|
||||
py-nymta==0.4.0
|
||||
|
||||
# homeassistant.components.opendisplay
|
||||
py-opendisplay==7.2.3
|
||||
py-opendisplay==5.9.0
|
||||
|
||||
# homeassistant.components.schluter
|
||||
py-schluter==0.1.7
|
||||
@@ -3224,7 +3224,7 @@ uasiren==0.0.1
|
||||
uhooapi==1.2.8
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==10.5.0
|
||||
uiprotect==10.4.1
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
|
||||
@@ -18,7 +18,7 @@ license-expression==30.4.3
|
||||
mock-open==1.4.0
|
||||
mypy==2.1.0
|
||||
prek==0.2.28
|
||||
pydantic==2.13.4
|
||||
pydantic==2.13.2
|
||||
pylint==4.0.5
|
||||
pylint-per-file-ignores==3.2.1
|
||||
pipdeptree==2.26.1
|
||||
|
||||
@@ -117,7 +117,7 @@ multidict>=6.0.2
|
||||
Brotli>=1.2.0
|
||||
|
||||
# ensure pydantic version does not float since it might have breaking changes
|
||||
pydantic==2.13.4
|
||||
pydantic==2.13.2
|
||||
|
||||
# Required for Python 3.14.0 compatibility (#119223).
|
||||
mashumaro>=3.17.0
|
||||
|
||||
+19
-327
@@ -2,30 +2,20 @@
|
||||
"""Helper script to split test into n buckets."""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import json
|
||||
from math import ceil
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Final
|
||||
|
||||
# tests/components has ~1000 sub-directories, which makes it the natural
|
||||
# place to subdivide to keep each pytest invocation roughly equal in size.
|
||||
_FAN_OUT_DIRS: Final = frozenset({"components"})
|
||||
|
||||
# Cache file format version; bump on any incompatible schema change so old
|
||||
# caches are ignored rather than misread.
|
||||
_CACHE_VERSION: Final = 1
|
||||
|
||||
|
||||
class Bucket:
|
||||
"""Class to hold bucket."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
):
|
||||
"""Initialize bucket."""
|
||||
self.total_tests = 0
|
||||
self._paths: list[str] = []
|
||||
@@ -87,7 +77,7 @@ class BucketHolder:
|
||||
|
||||
def create_ouput_file(self) -> None:
|
||||
"""Create output file."""
|
||||
with Path("pytest_buckets.txt").open("w", encoding="utf-8") as file:
|
||||
with Path("pytest_buckets.txt").open("w") as file:
|
||||
for idx, bucket in enumerate(self._buckets):
|
||||
print(f"Bucket {idx + 1} has {bucket.total_tests} tests")
|
||||
file.write(bucket.get_paths_line())
|
||||
@@ -174,329 +164,37 @@ class TestFolder:
|
||||
return result
|
||||
|
||||
|
||||
def _collect_batch(paths: list[Path]) -> tuple[str, str, int]:
|
||||
"""Run pytest --collect-only on a batch of paths."""
|
||||
def collect_tests(path: Path) -> TestFolder:
|
||||
"""Collect all tests."""
|
||||
result = subprocess.run(
|
||||
["pytest", "--collect-only", "-qq", "-p", "no:warnings", *map(str, paths)],
|
||||
["pytest", "--collect-only", "-qq", "-p", "no:warnings", path],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout, result.stderr, result.returncode
|
||||
|
||||
if result.returncode != 0:
|
||||
print("Failed to collect tests:")
|
||||
print(result.stderr)
|
||||
print(result.stdout)
|
||||
sys.exit(1)
|
||||
|
||||
def _iter_eligible_children(path: Path) -> list[Path]:
|
||||
"""Return immediate children of ``path`` that pytest should collect.
|
||||
folder = TestFolder(path)
|
||||
|
||||
Filters out hidden/dunder entries, non-``test_*.py`` files (so helper
|
||||
modules like ``conftest.py`` and ``common.py`` are not passed as
|
||||
explicit collection targets), and pycache-style directories.
|
||||
"""
|
||||
children: list[Path] = []
|
||||
for entry in sorted(path.iterdir()):
|
||||
if entry.name.startswith((".", "_")):
|
||||
continue
|
||||
if entry.is_dir() or (entry.suffix == ".py" and entry.name.startswith("test_")):
|
||||
children.append(entry)
|
||||
return children
|
||||
|
||||
|
||||
def _enumerate_batch_paths(path: Path) -> list[Path]:
|
||||
"""Return the child paths to run pytest --collect-only over.
|
||||
|
||||
Files are returned as-is. Directories are expanded one level deep, with
|
||||
a second level of expansion for entries named in ``_FAN_OUT_DIRS`` so the
|
||||
enormous ``tests/components`` tree fans out into per-integration paths.
|
||||
"""
|
||||
if path.is_file():
|
||||
return [path]
|
||||
|
||||
paths: list[Path] = []
|
||||
for entry in _iter_eligible_children(path):
|
||||
if entry.is_dir() and entry.name in _FAN_OUT_DIRS:
|
||||
paths.extend(_iter_eligible_children(entry))
|
||||
else:
|
||||
paths.append(entry)
|
||||
return paths
|
||||
|
||||
|
||||
def _hash_file(path: Path) -> str:
|
||||
"""Return a short content hash for ``path``."""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _walk_test_tree(root: Path) -> tuple[list[Path], list[Path]]:
|
||||
"""Walk ``root`` once and return (test files, conftest files).
|
||||
|
||||
Uses ``os.walk`` rather than ``Path.rglob`` because it's ~2x faster on
|
||||
a 5000-file tree, and we prune hidden/dunder subdirectories instead of
|
||||
visiting them. Doing both walks in one pass keeps total tree I/O down.
|
||||
"""
|
||||
if root.is_file():
|
||||
if root.name.startswith("test_") and root.suffix == ".py":
|
||||
return [root], []
|
||||
return [], []
|
||||
|
||||
test_files: list[Path] = []
|
||||
conftests: list[Path] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith((".", "_"))]
|
||||
base = Path(dirpath)
|
||||
for name in filenames:
|
||||
if name == "conftest.py":
|
||||
conftests.append(base / name)
|
||||
elif name.startswith("test_") and name.endswith(".py"):
|
||||
test_files.append(base / name)
|
||||
test_files.sort()
|
||||
conftests.sort()
|
||||
return test_files, conftests
|
||||
|
||||
|
||||
def _compute_conftest_hash(root: Path, conftests: list[Path]) -> str:
|
||||
"""Return a hash that changes whenever any conftest.py under ``root`` changes.
|
||||
|
||||
Any change to a conftest invalidates the entire test-count cache. This is
|
||||
coarse but safe: conftests can change fixture parametrization in ways the
|
||||
cache cannot otherwise detect, so we just re-collect everything.
|
||||
"""
|
||||
digest = hashlib.sha256()
|
||||
for conftest in conftests:
|
||||
digest.update(str(conftest.relative_to(root)).encode())
|
||||
digest.update(b"\0")
|
||||
digest.update(conftest.read_bytes())
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
"""Cached test count for a single file."""
|
||||
|
||||
hash: str
|
||||
count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Cache:
|
||||
"""Mapping of test file path → cached entry, plus invalidation key."""
|
||||
|
||||
conftest_hash: str
|
||||
entries: dict[str, _CacheEntry]
|
||||
|
||||
@classmethod
|
||||
def empty(cls, conftest_hash: str = "") -> _Cache:
|
||||
"""Return a new empty cache."""
|
||||
return cls(conftest_hash=conftest_hash, entries={})
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path, current_conftest_hash: str) -> _Cache:
|
||||
"""Load cache from ``path`` and invalidate it on schema/conftest drift.
|
||||
|
||||
Any failure (missing file, bad JSON, version drift, conftest drift)
|
||||
returns an empty cache so the script just falls back to a full
|
||||
collection. This is the self-healing path.
|
||||
"""
|
||||
try:
|
||||
raw = json.loads(path.read_bytes())
|
||||
except OSError, ValueError:
|
||||
return cls.empty(current_conftest_hash)
|
||||
if not isinstance(raw, dict) or raw.get("version") != _CACHE_VERSION:
|
||||
return cls.empty(current_conftest_hash)
|
||||
if raw.get("conftest_hash") != current_conftest_hash:
|
||||
return cls.empty(current_conftest_hash)
|
||||
files = raw.get("files")
|
||||
if not isinstance(files, dict):
|
||||
return cls.empty(current_conftest_hash)
|
||||
entries: dict[str, _CacheEntry] = {}
|
||||
for key, value in files.items():
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or not isinstance(value.get("hash"), str)
|
||||
or not isinstance(value.get("count"), int)
|
||||
):
|
||||
# Skip malformed entries instead of discarding the whole cache.
|
||||
continue
|
||||
entries[key] = _CacheEntry(hash=value["hash"], count=value["count"])
|
||||
return cls(conftest_hash=current_conftest_hash, entries=entries)
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""Write the cache to ``path``."""
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": _CACHE_VERSION,
|
||||
"conftest_hash": self.conftest_hash,
|
||||
"files": {
|
||||
key: {"hash": entry.hash, "count": entry.count}
|
||||
for key, entry in sorted(self.entries.items())
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_from_cache(
|
||||
test_files: list[Path],
|
||||
cache: _Cache,
|
||||
root: Path,
|
||||
) -> tuple[dict[Path, int], list[Path]]:
|
||||
"""Split ``test_files`` into ``(cached_counts, needs_collection)``.
|
||||
|
||||
A file is served from cache when its content hash matches what we
|
||||
previously stored; otherwise it is queued for re-collection.
|
||||
"""
|
||||
cached: dict[Path, int] = {}
|
||||
misses: list[Path] = []
|
||||
for file in test_files:
|
||||
key = str(file.relative_to(root))
|
||||
entry = cache.entries.get(key)
|
||||
if entry is None:
|
||||
misses.append(file)
|
||||
continue
|
||||
if entry.hash != _hash_file(file):
|
||||
misses.append(file)
|
||||
continue
|
||||
cached[file] = entry.count
|
||||
return cached, misses
|
||||
|
||||
|
||||
def _run_collect_batches(paths: list[Path]) -> list[tuple[str, str, int]]:
|
||||
"""Run pytest --collect-only across ``paths`` using a process pool."""
|
||||
workers = min(len(paths), os.cpu_count() or 1) or 1
|
||||
batches = [paths[i::workers] for i in range(workers)]
|
||||
if workers == 1:
|
||||
return [_collect_batch(batches[0])]
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
return list(executor.map(_collect_batch, batches))
|
||||
|
||||
|
||||
def _parse_collect_output(stdout: str) -> dict[Path, int]:
|
||||
"""Parse ``pytest --collect-only -qq`` output into ``{path: count}``."""
|
||||
counts: dict[Path, int] = {}
|
||||
for line in stdout.splitlines():
|
||||
for line in result.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
file_path, _, total_tests = line.partition(": ")
|
||||
if not file_path or not total_tests:
|
||||
raise ValueError(f"Unexpected line: {line}")
|
||||
counts[Path(file_path)] = int(total_tests)
|
||||
return counts
|
||||
|
||||
|
||||
def _run_pytest_collect(paths: list[Path]) -> dict[Path, int]:
|
||||
"""Run pytest --collect-only across ``paths`` and parse the output."""
|
||||
counts: dict[Path, int] = {}
|
||||
for stdout, stderr, returncode in _run_collect_batches(paths):
|
||||
if returncode != 0:
|
||||
print("Failed to collect tests:")
|
||||
print(stderr)
|
||||
print(stdout)
|
||||
if not path or not total_tests:
|
||||
print(f"Unexpected line: {line}")
|
||||
sys.exit(1)
|
||||
try:
|
||||
counts.update(_parse_collect_output(stdout))
|
||||
except ValueError as err:
|
||||
print(err)
|
||||
sys.exit(1)
|
||||
return counts
|
||||
|
||||
|
||||
def _collect_tests_uncached(path: Path) -> TestFolder:
|
||||
"""Collect tests by handing pytest the top-level directories.
|
||||
|
||||
Skips the tree walk and per-file hashing; used when no cache file is
|
||||
requested so the script behaves like the pre-cache implementation.
|
||||
"""
|
||||
batch_paths = _enumerate_batch_paths(path)
|
||||
if not batch_paths:
|
||||
print(f"No eligible test paths found under {path}")
|
||||
sys.exit(1)
|
||||
|
||||
folder = TestFolder(path)
|
||||
for file_path, total_tests in _run_pytest_collect(batch_paths).items():
|
||||
folder.add_test_file(TestFile(total_tests, file_path))
|
||||
return folder
|
||||
|
||||
|
||||
def _collect_tests_cached(path: Path, cache_path: Path) -> TestFolder:
|
||||
"""Collect tests using an on-disk cache for incremental updates."""
|
||||
all_test_files, conftests = _walk_test_tree(path)
|
||||
if not all_test_files:
|
||||
print(f"No eligible test paths found under {path}")
|
||||
sys.exit(1)
|
||||
|
||||
conftest_hash = _compute_conftest_hash(path, conftests)
|
||||
cache = _Cache.load(cache_path, conftest_hash)
|
||||
|
||||
cached_counts, missing = _resolve_from_cache(all_test_files, cache, path)
|
||||
print(
|
||||
f"Cache: {len(cached_counts)} hits / {len(missing)} misses"
|
||||
f" / {len(all_test_files)} total"
|
||||
)
|
||||
|
||||
new_counts: dict[Path, int] = {}
|
||||
if missing:
|
||||
# On a full cold-cache run, hand pytest the top-level directories
|
||||
# instead of 5000+ individual file paths: pytest walks dirs much
|
||||
# faster than it resolves each file argument. Once any cache hits
|
||||
# exist, use file-level collection so we only re-collect the diff.
|
||||
if not cached_counts:
|
||||
collect_paths = _enumerate_batch_paths(path)
|
||||
else:
|
||||
collect_paths = missing
|
||||
new_counts = _run_pytest_collect(collect_paths)
|
||||
|
||||
counts: dict[Path, int] = {**cached_counts, **new_counts}
|
||||
|
||||
folder = TestFolder(path)
|
||||
for file_path, total_tests in counts.items():
|
||||
if total_tests == 0:
|
||||
# Files with no collected tests (eg helper modules named
|
||||
# test_init.py with no test functions) shouldn't enter
|
||||
# bucketing, but we still cache them below as count=0 so
|
||||
# they don't get re-collected next run.
|
||||
continue
|
||||
folder.add_test_file(TestFile(total_tests, file_path))
|
||||
|
||||
# Rebuild the cache from scratch on every run so deleted files are
|
||||
# dropped and re-collected files get a refreshed hash.
|
||||
missing_set = set(missing)
|
||||
updated_entries: dict[str, _CacheEntry] = {}
|
||||
for file in all_test_files:
|
||||
if file in counts:
|
||||
count = counts[file]
|
||||
elif file in missing_set:
|
||||
# We asked pytest about this file and got no count back,
|
||||
# so it has no collectible tests; cache it as 0 to avoid
|
||||
# repeating the work next run.
|
||||
count = 0
|
||||
else:
|
||||
continue
|
||||
updated_entries[str(file.relative_to(path))] = _CacheEntry(
|
||||
hash=_hash_file(file), count=count
|
||||
)
|
||||
_Cache(conftest_hash=conftest_hash, entries=updated_entries).save(cache_path)
|
||||
file = TestFile(int(total_tests), Path(file_path))
|
||||
folder.add_test_file(file)
|
||||
|
||||
return folder
|
||||
|
||||
|
||||
def collect_tests(path: Path, cache_path: Path | None = None) -> TestFolder:
|
||||
"""Collect all tests, using an on-disk cache when ``cache_path`` is set."""
|
||||
if cache_path is None:
|
||||
return _collect_tests_uncached(path)
|
||||
if path.is_file():
|
||||
# The cache keys on conftest_hash, but a single file root has no
|
||||
# ancestor conftests to walk and the hash would always be empty,
|
||||
# which would let stale counts survive conftest edits. Skip the
|
||||
# cache for the file-root case rather than silently mis-caching.
|
||||
print(f"--cache ignored: {path} is a single file")
|
||||
return _collect_tests_uncached(path)
|
||||
return _collect_tests_cached(path, cache_path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Execute script."""
|
||||
parser = argparse.ArgumentParser(description="Split tests into n buckets.")
|
||||
@@ -519,17 +217,11 @@ def main() -> None:
|
||||
help="Path to the test files to split into buckets",
|
||||
type=Path,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache",
|
||||
help="Path to a JSON file used to cache per-file test counts",
|
||||
type=Path,
|
||||
default=None,
|
||||
)
|
||||
|
||||
arguments = parser.parse_args()
|
||||
|
||||
print("Collecting tests...")
|
||||
tests = collect_tests(arguments.path, arguments.cache)
|
||||
tests = collect_tests(arguments.path)
|
||||
tests_per_bucket = ceil(tests.total_tests / arguments.bucket_count)
|
||||
|
||||
bucket_holder = BucketHolder(tests_per_bucket, arguments.bucket_count)
|
||||
|
||||
@@ -182,13 +182,8 @@ async def test_diagnostics(
|
||||
"scanners": [
|
||||
{
|
||||
"adapter": "hci0",
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"discovered_devices_and_advertisement_data": [],
|
||||
"last_connect_completed_time": 0.0,
|
||||
"last_detection": ANY,
|
||||
"monotonic_time": ANY,
|
||||
"name": "hci0 (00:00:00:00:00:01)",
|
||||
@@ -207,11 +202,6 @@ async def test_diagnostics(
|
||||
},
|
||||
{
|
||||
"adapter": "hci1",
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"last_connect_completed_time": 0.0,
|
||||
"discovered_devices_and_advertisement_data": [
|
||||
{
|
||||
"address": "44:44:33:11:23:45",
|
||||
@@ -407,12 +397,7 @@ async def test_diagnostics_macos(
|
||||
"scanners": [
|
||||
{
|
||||
"adapter": "Core Bluetooth",
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"last_connect_completed_time": 0.0,
|
||||
"discovered_devices_and_advertisement_data": [
|
||||
{
|
||||
"address": "44:44:33:11:23:45",
|
||||
@@ -617,13 +602,8 @@ async def test_diagnostics_remote_adapter(
|
||||
"scanners": [
|
||||
{
|
||||
"adapter": "hci0",
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"discovered_devices_and_advertisement_data": [],
|
||||
"last_connect_completed_time": 0.0,
|
||||
"last_detection": ANY,
|
||||
"monotonic_time": ANY,
|
||||
"name": "hci0 (00:00:00:00:00:01)",
|
||||
@@ -641,14 +621,9 @@ async def test_diagnostics_remote_adapter(
|
||||
},
|
||||
},
|
||||
{
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"current_mode": None,
|
||||
"requested_mode": None,
|
||||
"last_connect_completed_time": 0.0,
|
||||
"discovered_device_timestamps": {"44:44:33:11:23:45": ANY},
|
||||
"discovered_devices_and_advertisement_data": [
|
||||
{
|
||||
|
||||
@@ -138,10 +138,8 @@ async def test_setup_and_stop_passive(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert init_kwargs == {
|
||||
"bluez": {
|
||||
**scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member
|
||||
"adapter": "hci0",
|
||||
},
|
||||
"adapter": "hci0",
|
||||
"bluez": scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member
|
||||
"scanning_mode": "passive",
|
||||
}
|
||||
|
||||
@@ -190,7 +188,7 @@ async def test_setup_and_stop_old_bluez(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert init_kwargs == {
|
||||
"bluez": {"adapter": "hci0"},
|
||||
"adapter": "hci0",
|
||||
"scanning_mode": "active",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Test for DNS IP integration Init."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiodns.error import DNSError
|
||||
from pycares import AresError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.dnsip.const import (
|
||||
@@ -178,6 +180,8 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None:
|
||||
[
|
||||
TimeoutError(),
|
||||
DNSError(),
|
||||
AresError(),
|
||||
asyncio.CancelledError(),
|
||||
],
|
||||
)
|
||||
async def test_setup_dns_error(hass: HomeAssistant, error: Exception) -> None:
|
||||
|
||||
@@ -81,16 +81,11 @@ async def test_diagnostics_with_bluetooth(
|
||||
"connections_free": 0,
|
||||
"connections_limit": 0,
|
||||
"scanner": {
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"current_mode": None,
|
||||
"requested_mode": None,
|
||||
"discovered_device_timestamps": {},
|
||||
"discovered_devices_and_advertisement_data": [],
|
||||
"last_connect_completed_time": 0.0,
|
||||
"last_detection": ANY,
|
||||
"monotonic_time": ANY,
|
||||
"name": "test (AA:BB:CC:DD:EE:FC)",
|
||||
|
||||
@@ -13,12 +13,13 @@ from homeassistant.components.application_credentials import (
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.mcp.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_AUTHORIZATION_URL,
|
||||
CONF_SCOPE,
|
||||
CONF_TOKEN_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@@ -31,8 +31,6 @@ MOCK_TEST_CONFIG = {
|
||||
|
||||
TEST_ENTRY = "portainer_test_entry_123"
|
||||
TEST_INSTANCE_ID = "299ab403-70a8-4c05-92f7-bf7a994d50df"
|
||||
TEST_CONTAINER_NAME = "practical_morse"
|
||||
TEST_CONTAINER_ID = "ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -95,7 +93,6 @@ def mock_portainer_client() -> Generator[AsyncMock]:
|
||||
client.stop_container = AsyncMock(return_value=None)
|
||||
client.start_stack = AsyncMock(return_value=None)
|
||||
client.stop_stack = AsyncMock(return_value=None)
|
||||
client.container_recreate = AsyncMock(return_value=None)
|
||||
|
||||
yield client
|
||||
|
||||
|
||||
@@ -13,13 +13,9 @@ from voluptuous import MultipleInvalid
|
||||
|
||||
from homeassistant.components.portainer.const import DOMAIN
|
||||
from homeassistant.components.portainer.services import (
|
||||
ATTR_CONTAINER_DEVICE_ID,
|
||||
ATTR_DANGLING,
|
||||
ATTR_DATE_UNTIL,
|
||||
ATTR_PULL_IMAGE,
|
||||
ATTR_TIMEOUT,
|
||||
SERVICE_PRUNE_IMAGES,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
)
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -27,17 +23,13 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.device_registry import DeviceRegistry
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import TEST_CONTAINER_ID, TEST_CONTAINER_NAME, TEST_ENTRY
|
||||
from .conftest import TEST_ENTRY
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_ENDPOINT_ID = 1
|
||||
TEST_DEVICE_IDENTIFIER = f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}"
|
||||
|
||||
TEST_CONTAINER_DEVICE_IDENTIFIER = (
|
||||
f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}_{TEST_CONTAINER_NAME}"
|
||||
)
|
||||
|
||||
|
||||
async def test_services(
|
||||
hass: HomeAssistant,
|
||||
@@ -110,99 +102,6 @@ async def test_service_prune_images(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("call_arguments", "extra_expected_kwargs"),
|
||||
[
|
||||
({}, {"pull_image": False}),
|
||||
(
|
||||
{ATTR_TIMEOUT: timedelta(minutes=10)},
|
||||
{"pull_image": False, "timeout": timedelta(minutes=10)},
|
||||
),
|
||||
(
|
||||
{ATTR_TIMEOUT: timedelta(minutes=12), ATTR_PULL_IMAGE: True},
|
||||
{"pull_image": True, "timeout": timedelta(minutes=12)},
|
||||
),
|
||||
],
|
||||
ids=["no optional", "with duration", "with duration and pull_image"],
|
||||
)
|
||||
async def test_service_recreate_container(
|
||||
hass: HomeAssistant,
|
||||
device_registry: DeviceRegistry,
|
||||
mock_portainer_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
call_arguments: dict,
|
||||
extra_expected_kwargs: dict,
|
||||
) -> None:
|
||||
"""Test recreate container service with the variants."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
container = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
|
||||
)
|
||||
assert container is not None
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{
|
||||
ATTR_CONTAINER_DEVICE_ID: container.id,
|
||||
**call_arguments,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_portainer_client.container_recreate.assert_called_once_with(
|
||||
endpoint_id=TEST_ENDPOINT_ID,
|
||||
container_id=TEST_CONTAINER_ID,
|
||||
**extra_expected_kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "translation_key"),
|
||||
[
|
||||
(
|
||||
PortainerAuthenticationError("auth"),
|
||||
"invalid_auth_no_details",
|
||||
),
|
||||
(
|
||||
PortainerConnectionError("conn"),
|
||||
"cannot_connect_no_details",
|
||||
),
|
||||
(
|
||||
PortainerTimeoutError("timeout"),
|
||||
"timeout_connect_no_details",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_service_recreate_container_portainer_exceptions(
|
||||
hass: HomeAssistant,
|
||||
device_registry: DeviceRegistry,
|
||||
mock_portainer_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
exception: PortainerAuthenticationError
|
||||
| PortainerConnectionError
|
||||
| PortainerTimeoutError,
|
||||
translation_key: str,
|
||||
) -> None:
|
||||
"""Test recreate container service handles Portainer exceptions."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
container = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
|
||||
)
|
||||
assert container is not None
|
||||
|
||||
mock_portainer_client.container_recreate.side_effect = exception
|
||||
with pytest.raises(HomeAssistantError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{ATTR_CONTAINER_DEVICE_ID: container.id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert err.value.translation_key == translation_key
|
||||
mock_portainer_client.container_recreate.assert_called_once()
|
||||
|
||||
|
||||
async def test_service_validation_errors(
|
||||
hass: HomeAssistant,
|
||||
device_registry: DeviceRegistry,
|
||||
@@ -216,11 +115,8 @@ async def test_service_validation_errors(
|
||||
identifiers={(DOMAIN, TEST_DEVICE_IDENTIFIER)}
|
||||
)
|
||||
assert device is not None
|
||||
container = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
|
||||
)
|
||||
assert container is not None
|
||||
|
||||
# Test missing device_id
|
||||
with pytest.raises(MultipleInvalid, match="required key not provided"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
@@ -230,6 +126,7 @@ async def test_service_validation_errors(
|
||||
)
|
||||
mock_portainer_client.images_prune.assert_not_called()
|
||||
|
||||
# Test invalid until (too short, needs to be at least 1 minute)
|
||||
with pytest.raises(MultipleInvalid, match="value must be at least"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
@@ -239,6 +136,7 @@ async def test_service_validation_errors(
|
||||
)
|
||||
mock_portainer_client.images_prune.assert_not_called()
|
||||
|
||||
# Test invalid device
|
||||
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
@@ -248,39 +146,6 @@ async def test_service_validation_errors(
|
||||
)
|
||||
mock_portainer_client.images_prune.assert_not_called()
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{ATTR_CONTAINER_DEVICE_ID: "invalid_device_id"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_portainer_client.container_recreate.assert_not_called()
|
||||
|
||||
other_entry = MockConfigEntry(domain="well_no_portainer_for_sure")
|
||||
other_entry.add_to_hass(hass)
|
||||
non_portainer_device = device_registry.async_get_or_create(
|
||||
config_entry_id=other_entry.entry_id,
|
||||
identifiers={("well_no_portainer_for_sure", "some_identifier")},
|
||||
)
|
||||
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{ATTR_CONTAINER_DEVICE_ID: non_portainer_device.id},
|
||||
blocking=True,
|
||||
)
|
||||
mock_portainer_client.container_recreate.assert_not_called()
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{ATTR_CONTAINER_DEVICE_ID: device.id},
|
||||
blocking=True,
|
||||
)
|
||||
mock_portainer_client.container_recreate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "message"),
|
||||
|
||||
@@ -113,10 +113,6 @@ async def test_rpc_config_entry_diagnostics(
|
||||
"entry": entry_dict | {"discovery_keys": {}},
|
||||
"bluetooth": {
|
||||
"scanner": {
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": False,
|
||||
"current_mode": {
|
||||
"__type": "<enum 'BluetoothScanningMode'>",
|
||||
@@ -126,7 +122,6 @@ async def test_rpc_config_entry_diagnostics(
|
||||
"__type": "<enum 'BluetoothScanningMode'>",
|
||||
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
|
||||
},
|
||||
"last_connect_completed_time": 0.0,
|
||||
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY},
|
||||
"discovered_devices_and_advertisement_data": [
|
||||
{
|
||||
|
||||
@@ -6,10 +6,15 @@ from asyncsleepiq import (
|
||||
SleepIQTimeoutException,
|
||||
)
|
||||
|
||||
from homeassistant.components.sleepiq.const import DOMAIN, IS_IN_BED, SLEEP_NUMBER
|
||||
from homeassistant.components.sleepiq.const import (
|
||||
DOMAIN,
|
||||
IS_IN_BED,
|
||||
PRESSURE,
|
||||
SLEEP_NUMBER,
|
||||
)
|
||||
from homeassistant.components.sleepiq.coordinator import UPDATE_INTERVAL
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_USERNAME, PRESSURE
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
"""Tests for the split_tests cache logic."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from script import split_tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(tmp_path: Path) -> Path:
|
||||
"""Build a small test tree on disk.
|
||||
|
||||
Returns the root path containing one conftest, two integrations,
|
||||
and an unrelated helper module that the splitter should ignore.
|
||||
"""
|
||||
(tmp_path / "conftest.py").write_text("# tests/conftest.py\n")
|
||||
(tmp_path / "common.py").write_text("# helper module\n")
|
||||
|
||||
alpha_dir = tmp_path / "components" / "alpha"
|
||||
alpha_dir.mkdir(parents=True)
|
||||
(alpha_dir / "conftest.py").write_text("# alpha conftest\n")
|
||||
(alpha_dir / "test_one.py").write_text("def test_a():\n pass\n")
|
||||
(alpha_dir / "test_two.py").write_text("def test_b():\n pass\n")
|
||||
|
||||
beta_dir = tmp_path / "components" / "beta"
|
||||
beta_dir.mkdir()
|
||||
(beta_dir / "test_x.py").write_text("def test_x():\n pass\n")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_iter_eligible_children_filters_helpers(tree: Path) -> None:
|
||||
"""Helper files like conftest.py and common.py are not collection targets."""
|
||||
children = split_tests._iter_eligible_children(tree)
|
||||
names = {p.name for p in children}
|
||||
assert "common.py" not in names
|
||||
assert "conftest.py" not in names
|
||||
# components/ is a dir, gets included.
|
||||
assert "components" in names
|
||||
|
||||
|
||||
def test_enumerate_batch_paths_fans_out_components(tree: Path) -> None:
|
||||
"""tests/components fans out one level deeper into per-integration paths."""
|
||||
paths = split_tests._enumerate_batch_paths(tree)
|
||||
rel = {p.relative_to(tree).as_posix() for p in paths}
|
||||
assert rel == {"components/beta", "components/alpha"}
|
||||
|
||||
|
||||
def test_enumerate_batch_paths_for_single_file(tmp_path: Path) -> None:
|
||||
"""A test file passed directly is returned as-is."""
|
||||
file = tmp_path / "test_solo.py"
|
||||
file.write_text("def test_x(): pass\n")
|
||||
assert split_tests._enumerate_batch_paths(file) == [file]
|
||||
|
||||
|
||||
def _conftest_hash_for(tree: Path) -> str:
|
||||
"""Compute the conftest hash for ``tree`` (helper for the tests below)."""
|
||||
_, conftests = split_tests._walk_test_tree(tree)
|
||||
return split_tests._compute_conftest_hash(tree, conftests)
|
||||
|
||||
|
||||
def test_compute_conftest_hash_changes_when_conftest_changes(tree: Path) -> None:
|
||||
"""Editing any conftest changes the global cache key."""
|
||||
before = _conftest_hash_for(tree)
|
||||
(tree / "components" / "alpha" / "conftest.py").write_text("# changed\n")
|
||||
after = _conftest_hash_for(tree)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_compute_conftest_hash_stable_for_non_conftest_changes(tree: Path) -> None:
|
||||
"""Test-file edits do not invalidate the global cache key."""
|
||||
before = _conftest_hash_for(tree)
|
||||
(tree / "components" / "alpha" / "test_one.py").write_text(
|
||||
"def test_a():\n pass\n\ndef test_c():\n pass\n"
|
||||
)
|
||||
after = _conftest_hash_for(tree)
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_walk_test_tree_finds_tests_and_conftests(tree: Path) -> None:
|
||||
"""The walker returns test files and conftest files but no helpers."""
|
||||
test_files, conftests = split_tests._walk_test_tree(tree)
|
||||
test_names = {p.name for p in test_files}
|
||||
conftest_paths = {p.relative_to(tree).as_posix() for p in conftests}
|
||||
assert test_names == {"test_one.py", "test_two.py", "test_x.py"}
|
||||
assert conftest_paths == {"conftest.py", "components/alpha/conftest.py"}
|
||||
|
||||
|
||||
def test_walk_test_tree_skips_hidden_and_dunder_dirs(tmp_path: Path) -> None:
|
||||
"""Hidden/dunder directories are pruned from the walk."""
|
||||
(tmp_path / "__pycache__").mkdir()
|
||||
(tmp_path / "__pycache__" / "test_ghost.py").write_text("def test_g(): pass\n")
|
||||
(tmp_path / ".hidden").mkdir()
|
||||
(tmp_path / ".hidden" / "test_invisible.py").write_text("def test_h(): pass\n")
|
||||
(tmp_path / "test_real.py").write_text("def test_r(): pass\n")
|
||||
|
||||
test_files, _ = split_tests._walk_test_tree(tmp_path)
|
||||
assert {p.name for p in test_files} == {"test_real.py"}
|
||||
|
||||
|
||||
def test_walk_test_tree_handles_single_file(tmp_path: Path) -> None:
|
||||
"""Passing a single test file returns just that file."""
|
||||
file = tmp_path / "test_solo.py"
|
||||
file.write_text("def test_x(): pass\n")
|
||||
assert split_tests._walk_test_tree(file) == ([file], [])
|
||||
|
||||
|
||||
def test_collect_tests_skips_cache_for_single_file_root(tmp_path: Path) -> None:
|
||||
"""A single-file root cannot validate conftest drift, so caching is disabled.
|
||||
|
||||
_walk_test_tree returns no conftests for a file root, which would make
|
||||
the conftest_hash a constant — letting a stale entry survive a real
|
||||
conftest change. Better to bypass the cache than mis-cache silently.
|
||||
"""
|
||||
cache_path = tmp_path / "cache.json"
|
||||
file = tmp_path / "test_solo.py"
|
||||
file.write_text("def test_x(): pass\n")
|
||||
|
||||
with (
|
||||
patch.object(split_tests, "_collect_tests_uncached") as uncached,
|
||||
patch.object(split_tests, "_collect_tests_cached") as cached,
|
||||
):
|
||||
split_tests.collect_tests(file, cache_path)
|
||||
|
||||
uncached.assert_called_once_with(file)
|
||||
cached.assert_not_called()
|
||||
assert not cache_path.exists()
|
||||
|
||||
|
||||
def test_cache_roundtrip(tmp_path: Path) -> None:
|
||||
"""A cache survives save → load when the conftest hash matches."""
|
||||
cache_path = tmp_path / "cache.json"
|
||||
cache = split_tests._Cache(
|
||||
conftest_hash="abc",
|
||||
entries={"tests/alpha/test_a.py": split_tests._CacheEntry(hash="h1", count=5)},
|
||||
)
|
||||
cache.save(cache_path)
|
||||
loaded = split_tests._Cache.load(cache_path, "abc")
|
||||
assert loaded.entries == cache.entries
|
||||
assert loaded.conftest_hash == "abc"
|
||||
|
||||
|
||||
def test_cache_load_missing_returns_empty(tmp_path: Path) -> None:
|
||||
"""A missing cache file degrades gracefully to an empty cache."""
|
||||
cache = split_tests._Cache.load(tmp_path / "missing.json", "abc")
|
||||
assert cache.entries == {}
|
||||
assert cache.conftest_hash == "abc"
|
||||
|
||||
|
||||
def test_cache_load_invalid_json_returns_empty(tmp_path: Path) -> None:
|
||||
"""Corrupt JSON is treated as a cache miss instead of crashing."""
|
||||
path = tmp_path / "broken.json"
|
||||
path.write_text("{not json")
|
||||
cache = split_tests._Cache.load(path, "abc")
|
||||
assert cache.entries == {}
|
||||
|
||||
|
||||
def test_cache_load_wrong_version_returns_empty(tmp_path: Path) -> None:
|
||||
"""An older cache schema is discarded rather than misread."""
|
||||
path = tmp_path / "old.json"
|
||||
path.write_text(json.dumps({"version": 0, "conftest_hash": "abc", "files": {}}))
|
||||
cache = split_tests._Cache.load(path, "abc")
|
||||
assert cache.entries == {}
|
||||
|
||||
|
||||
def test_cache_load_conftest_drift_returns_empty(tmp_path: Path) -> None:
|
||||
"""A conftest change invalidates the entire cached set."""
|
||||
path = tmp_path / "cache.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": split_tests._CACHE_VERSION,
|
||||
"conftest_hash": "old",
|
||||
"files": {"test_a.py": {"hash": "h1", "count": 3}},
|
||||
}
|
||||
)
|
||||
)
|
||||
cache = split_tests._Cache.load(path, "new")
|
||||
assert cache.entries == {}
|
||||
|
||||
|
||||
def test_cache_load_drops_malformed_entries(tmp_path: Path) -> None:
|
||||
"""Malformed per-file entries are skipped, valid ones are kept."""
|
||||
path = tmp_path / "cache.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": split_tests._CACHE_VERSION,
|
||||
"conftest_hash": "abc",
|
||||
"files": {
|
||||
"good.py": {"hash": "h1", "count": 3},
|
||||
"bad_count.py": {"hash": "h2", "count": "three"},
|
||||
"missing_hash.py": {"count": 4},
|
||||
"not_dict.py": 5,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
cache = split_tests._Cache.load(path, "abc")
|
||||
assert set(cache.entries) == {"good.py"}
|
||||
|
||||
|
||||
def test_resolve_from_cache_hits_and_misses(tree: Path) -> None:
|
||||
"""Files with matching hashes are hits; edited or new files are misses."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
|
||||
cache = split_tests._Cache(
|
||||
conftest_hash="dummy",
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_one), count=1
|
||||
),
|
||||
str(alpha_two.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash="stale", count=99
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
cached, missing = split_tests._resolve_from_cache(
|
||||
[alpha_one, alpha_two, beta_x], cache, tree
|
||||
)
|
||||
assert cached == {alpha_one: 1}
|
||||
assert set(missing) == {alpha_two, beta_x}
|
||||
|
||||
|
||||
def test_collect_tests_warm_cache_skips_pytest(tree: Path) -> None:
|
||||
"""A warm cache with no diffs should skip the pytest subprocess entirely."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
split_tests._Cache(
|
||||
conftest_hash=_conftest_hash_for(tree),
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_one), count=1
|
||||
),
|
||||
str(alpha_two.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_two), count=2
|
||||
),
|
||||
str(beta_x.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(beta_x), count=3
|
||||
),
|
||||
},
|
||||
).save(cache_path)
|
||||
|
||||
with patch.object(split_tests, "_run_collect_batches") as run_batches:
|
||||
folder = split_tests.collect_tests(tree, cache_path)
|
||||
run_batches.assert_not_called()
|
||||
assert folder.total_tests == 6
|
||||
|
||||
|
||||
def test_collect_tests_cold_cache_collects_only_missing(tree: Path) -> None:
|
||||
"""A partial cache should only re-collect the files that changed."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
|
||||
split_tests._Cache(
|
||||
conftest_hash=_conftest_hash_for(tree),
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_one), count=1
|
||||
),
|
||||
},
|
||||
).save(cache_path)
|
||||
|
||||
def fake_run_batches(paths: list[Path]) -> list[tuple[str, str, int]]:
|
||||
# Re-collected files emit one fake test each so we can verify which
|
||||
# ones the batched runner was asked for.
|
||||
return [
|
||||
(
|
||||
"\n".join(f"{p}: 1" for p in paths) + "\n",
|
||||
"",
|
||||
0,
|
||||
)
|
||||
]
|
||||
|
||||
with patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=fake_run_batches
|
||||
) as run_batches:
|
||||
folder = split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
assert run_batches.call_count == 1
|
||||
requested = set(run_batches.call_args.args[0])
|
||||
assert requested == {alpha_two, beta_x}
|
||||
assert folder.total_tests == 3
|
||||
|
||||
# Cache should now contain entries for every test file.
|
||||
saved = json.loads(cache_path.read_text())
|
||||
assert set(saved["files"]) == {
|
||||
str(alpha_one.relative_to(tree)),
|
||||
str(alpha_two.relative_to(tree)),
|
||||
str(beta_x.relative_to(tree)),
|
||||
}
|
||||
|
||||
|
||||
def test_collect_tests_caches_files_with_no_collected_tests(tree: Path) -> None:
|
||||
"""Files pytest returns nothing for are cached as 0 so we stop re-collecting them.
|
||||
|
||||
Helper modules named test_*.py with no actual test functions look like
|
||||
test files to the walker but pytest reports no tests for them. We
|
||||
want the cache to remember that and skip them on subsequent runs.
|
||||
"""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
|
||||
# Prime the cache with one hit so collect_tests takes the file-level
|
||||
# diff path; the cold-cache path hands pytest top-level directories
|
||||
# rather than individual file paths.
|
||||
split_tests._Cache(
|
||||
conftest_hash=_conftest_hash_for(tree),
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_one), count=1
|
||||
),
|
||||
},
|
||||
).save(cache_path)
|
||||
|
||||
def fake_run_batches(paths: list[Path]) -> list[tuple[str, str, int]]:
|
||||
# Pretend pytest didn't see alpha_two at all.
|
||||
emitted = [p for p in paths if p != alpha_two]
|
||||
return [("\n".join(f"{p}: 1" for p in emitted) + "\n", "", 0)]
|
||||
|
||||
with patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=fake_run_batches
|
||||
):
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
saved = json.loads(cache_path.read_text())
|
||||
assert saved["files"][str(alpha_two.relative_to(tree))]["count"] == 0
|
||||
assert saved["files"][str(alpha_one.relative_to(tree))]["count"] == 1
|
||||
assert saved["files"][str(beta_x.relative_to(tree))]["count"] == 1
|
||||
|
||||
# Re-running with the same content should now be a full cache hit
|
||||
# even though alpha_two has no tests.
|
||||
with patch.object(split_tests, "_run_collect_batches") as run_batches:
|
||||
folder = split_tests.collect_tests(tree, cache_path)
|
||||
run_batches.assert_not_called()
|
||||
# alpha_two contributes 0, only alpha_one + beta_x count.
|
||||
assert folder.total_tests == 2
|
||||
|
||||
|
||||
def test_collect_tests_drops_deleted_files_from_cache(tree: Path) -> None:
|
||||
"""Files that disappear from disk are dropped from the saved cache."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
ghost_rel = "components/alpha/test_ghost.py"
|
||||
|
||||
split_tests._Cache(
|
||||
conftest_hash=_conftest_hash_for(tree),
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_one), count=1
|
||||
),
|
||||
ghost_rel: split_tests._CacheEntry(hash="dead", count=42),
|
||||
},
|
||||
).save(cache_path)
|
||||
|
||||
def fake_run_batches(paths: list[Path]) -> list[tuple[str, str, int]]:
|
||||
return [
|
||||
(
|
||||
"\n".join(f"{p}: 1" for p in paths) + "\n",
|
||||
"",
|
||||
0,
|
||||
)
|
||||
]
|
||||
|
||||
with patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=fake_run_batches
|
||||
):
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
saved = json.loads(cache_path.read_text())
|
||||
assert ghost_rel not in saved["files"]
|
||||
Reference in New Issue
Block a user