From c63c470dc8bba7533c59f3ec42298f3cca175b8d Mon Sep 17 00:00:00 2001 From: Cristian Sirbu Date: Thu, 1 Oct 2026 18:05:40 +0100 Subject: [PATCH 1/6] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 619d593b..ef6dab34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyntc" -version = "3.3.0" +version = "3.3.1a0" description = "Python library focused on tasks related to device level and OS management." authors = ["Network to Code, LLC "] readme = "README.md" From e182869b12edffe30b5a5b353e69dff074162b4c Mon Sep 17 00:00:00 2001 From: Josh VanDeraa Date: Thu, 1 Oct 2026 13:31:55 -0500 Subject: [PATCH 2/6] Fix EOSDevice.close() leaking the Netmiko SSH session opened for file copy (#424) * Fix EOSDevice.close() leaking the Netmiko SSH session close() was a no-op, but open() creates a real SSH session for the file-transfer paths (file_copy, check_file_exists, get_remote_checksum, remote_file_copy). A caller holding many device objects accumulated one open socket per device. Disconnect when _connected is set, matching IOSDevice.close(). The guard also keeps close() safe on a device that only ever spoke eAPI, since native_ssh is assigned inside open(). Removes EOSSSHDevice.close(), now an exact duplicate of the parent: native_ssh on that class is a property returning native, so both bodies make the same call. * Add changelog fragment for #423 --- changes/423.fixed | 1 + pyntc/devices/eos_device.py | 16 +++++++- pyntc/devices/eos_ssh_device.py | 11 ------ tests/unit/test_devices/test_eos_device.py | 44 ++++++++++++++++++++++ 4 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 changes/423.fixed diff --git a/changes/423.fixed b/changes/423.fixed new file mode 100644 index 00000000..216d8dd1 --- /dev/null +++ b/changes/423.fixed @@ -0,0 +1 @@ +Fixed `EOSDevice.close()` leaking the Netmiko SSH session opened by the file-transfer methods. diff --git a/pyntc/devices/eos_device.py b/pyntc/devices/eos_device.py index 31d445ae..997f918e 100644 --- a/pyntc/devices/eos_device.py +++ b/pyntc/devices/eos_device.py @@ -250,8 +250,20 @@ def checkpoint(self, checkpoint_file): self.show(f"copy running-config {checkpoint_file}") def close(self): - """Not implemented. Just ``passes``.""" - pass # pylint: disable=unnecessary-pass + """Release the Netmiko SSH session opened by ``open``. + + The eAPI transport itself is stateless HTTP and needs no teardown, but + ``open`` builds a real SSH session for the file-transfer paths + (``file_copy``, ``check_file_exists``, ``get_remote_checksum``, + ``remote_file_copy``). Without this, a caller holding many device objects + accumulates one open socket per device. + + Does nothing when no SSH session was ever opened. + """ + if self._connected: + self.native_ssh.disconnect() + self._connected = False + log.debug("Host %s: Connection closed.", self.host) def config(self, commands): """Send configuration commands to a device. diff --git a/pyntc/devices/eos_ssh_device.py b/pyntc/devices/eos_ssh_device.py index 5cbe100e..c6beddc0 100644 --- a/pyntc/devices/eos_ssh_device.py +++ b/pyntc/devices/eos_ssh_device.py @@ -184,17 +184,6 @@ def open(self): log.debug("Host %s: Connection to device was opened successfully.", self.host) - def close(self): - """Disconnect from the device. - - Note this differs from ``EOSDevice.close``, which is a no-op because eAPI is - stateless. An SSH session holds a real socket that should be released. - """ - if self._connected: - self.native.disconnect() - self._connected = False - log.debug("Host %s: Connection closed.", self.host) - def show(self, commands, raw_text=False): """Send show command(s) to the device. diff --git a/tests/unit/test_devices/test_eos_device.py b/tests/unit/test_devices/test_eos_device.py index 05f3ea5c..066f9aca 100644 --- a/tests/unit/test_devices/test_eos_device.py +++ b/tests/unit/test_devices/test_eos_device.py @@ -469,6 +469,50 @@ def test_init_pass_port_and_timeout(mock_eos_connect): ) +def test_close_disconnects_netmiko_session(eos_device): + # open() creates a real SSH session for the file-transfer paths; close() has to + # release it or long-lived callers leak a socket per device. + eos_device.native_ssh = mock.MagicMock() + eos_device._connected = True + + eos_device.close() + + eos_device.native_ssh.disconnect.assert_called_once() + assert eos_device._connected is False + + +def test_close_is_noop_when_never_opened(eos_device): + # native_ssh is only assigned inside open(), so close() must not reach for it + # on a device that has only ever spoken eAPI. + assert not hasattr(eos_device, "native_ssh") + + eos_device.close() + + assert eos_device._connected is False + + +def test_close_is_idempotent(eos_device): + eos_device.native_ssh = mock.MagicMock() + eos_device._connected = True + + eos_device.close() + eos_device.close() + + eos_device.native_ssh.disconnect.assert_called_once() + + +@mock.patch("pyntc.devices.eos_device.ConnectHandler") +def test_close_does_not_strand_the_device(mock_connect_handler, eos_device): + # Closing must stay safe for a reusable object: every SSH-backed method calls + # open() first, and open() has to rebuild the session a previous close() tore down. + eos_device.open() + eos_device.close() + eos_device.open() + + assert mock_connect_handler.call_count == 2 + assert eos_device._connected is True + + class EOSDeviceMockedTestCase(unittest.TestCase): """Base test case wiring a mocked ``pyeapi`` node onto an ``EOSDevice``.""" From 54c909170a2ebb8e9c91fc71e80d4d0759a26e7e Mon Sep 17 00:00:00 2001 From: "Network to Code (smith-ntc)" <35795917+smith-ntc@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:30:23 -0500 Subject: [PATCH 3/6] Cookie updated targeting develop by NetworkToCode Cookie Drift Manager Tool (#425) * Cookie updated targeting develop by NetworkToCode Cookie Drift Manager Tool Template: ``` { "template": "https://github.com/networktocode-llc/cookiecutter-ntc.git", "dir": "python", "ref": "main", "path": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python" } ``` Cookie: ``` { "remote": "https://github.com/networktocode/pyntc.git", "path": "/tmp/tmpv706po_2/pyntc", "repository_path": "/tmp/tmpv706po_2/pyntc", "dir": "", "branch_prefix": "drift-manager/develop", "context": { "codeowner_github_usernames": "@jeffkala @pszulczewski @pke11y", "full_name": "Network to Code, LLC", "email": "info@networktocode.com", "github_org": "networktocode", "description": "Python library focused on tasks related to device level and OS management.", "project_name": "pyntc", "project_slug": "pyntc", "repo_url": "https://github.com/networktocode/pyntc", "base_url": "pyntc", "project_python_name": "pyntc", "project_python_base_version": "3.10", "project_with_config_settings": "no", "generate_docs": "no", "version": "2.0.2", "original_publish_year": "2015", "_template": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python", "_output_dir": "/tmp/tmpv706po_2", "_repo_dir": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python", "_checkout": null }, "drift_managed_branch": "develop", "remote_name": "origin", "pull_request_strategy": "PullRequestStrategy.UPDATE_OR_CREATE", "post_actions": [], "baked_commit_ref": "a5add2662f26a3b877635bbf1fd787682414d2bf", "draft": false } ``` CLI Arguments: ``` { "cookie_dir": "", "input": false, "json_filename": "", "output_dir": "", "push": true, "template": "./cookiecutter-ntc", "template_dir": "python", "template_ref": "main", "pull_request": "update-or-create", "post_action": [], "disable_post_actions": true, "draft": null, "drift_managed_branch": "develop" } ``` * Cookie updated targeting develop by NetworkToCode Cookie Drift Manager Tool Template: ``` { "template": "https://github.com/networktocode-llc/cookiecutter-ntc.git", "dir": "python", "ref": "main", "path": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python" } ``` Cookie: ``` { "remote": "https://github.com/networktocode/pyntc.git", "path": "/tmp/tmpjh04xc0r/pyntc", "repository_path": "/tmp/tmpjh04xc0r/pyntc", "dir": "", "branch_prefix": "drift-manager/develop", "context": { "codeowner_github_usernames": "@jeffkala @pszulczewski @pke11y", "full_name": "Network to Code, LLC", "email": "info@networktocode.com", "github_org": "networktocode", "description": "Python library focused on tasks related to device level and OS management.", "project_name": "pyntc", "project_slug": "pyntc", "repo_url": "https://github.com/networktocode/pyntc", "base_url": "pyntc", "project_python_name": "pyntc", "project_python_base_version": "3.10", "project_with_config_settings": "no", "generate_docs": "no", "version": "2.0.2", "original_publish_year": "2015", "_template": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python", "_output_dir": "/tmp/tmpjh04xc0r", "_repo_dir": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python", "_checkout": null }, "drift_managed_branch": "develop", "remote_name": "origin", "pull_request_strategy": "PullRequestStrategy.UPDATE_OR_CREATE", "post_actions": [], "baked_commit_ref": "a5add2662f26a3b877635bbf1fd787682414d2bf", "draft": false } ``` CLI Arguments: ``` { "cookie_dir": "", "input": false, "json_filename": "", "output_dir": "", "push": true, "template": "./cookiecutter-ntc", "template_dir": "python", "template_ref": "main", "pull_request": "update-or-create", "post_action": [], "disable_post_actions": true, "draft": null, "drift_managed_branch": "develop" } ``` * Cookie updated targeting develop by NetworkToCode Cookie Drift Manager Tool Template: ``` { "template": "https://github.com/networktocode-llc/cookiecutter-ntc.git", "dir": "python", "ref": "main", "path": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python" } ``` Cookie: ``` { "remote": "https://github.com/networktocode/pyntc.git", "path": "/tmp/tmpnap6giph/pyntc", "repository_path": "/tmp/tmpnap6giph/pyntc", "dir": "", "branch_prefix": "drift-manager/develop", "context": { "codeowner_github_usernames": "@jeffkala @pszulczewski @pke11y", "full_name": "Network to Code, LLC", "email": "info@networktocode.com", "github_org": "networktocode", "description": "Python library focused on tasks related to device level and OS management.", "project_name": "pyntc", "project_slug": "pyntc", "repo_url": "https://github.com/networktocode/pyntc", "base_url": "pyntc", "project_python_name": "pyntc", "project_python_base_version": "3.10", "project_with_config_settings": "no", "generate_docs": "no", "version": "2.0.2", "original_publish_year": "2015", "_template": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python", "_output_dir": "/tmp/tmpnap6giph", "_repo_dir": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python", "_checkout": null }, "drift_managed_branch": "develop", "remote_name": "origin", "pull_request_strategy": "PullRequestStrategy.UPDATE_OR_CREATE", "post_actions": [], "baked_commit_ref": "a5add2662f26a3b877635bbf1fd787682414d2bf", "draft": false } ``` CLI Arguments: ``` { "cookie_dir": "", "input": false, "json_filename": "", "output_dir": "", "push": true, "template": "./cookiecutter-ntc", "template_dir": "python", "template_ref": "main", "pull_request": "update-or-create", "post_action": [], "disable_post_actions": true, "draft": null, "drift_managed_branch": "develop" } ``` * Cookie updated targeting develop by NetworkToCode Cookie Drift Manager Tool Template: ``` { "template": "https://github.com/networktocode-llc/cookiecutter-ntc.git", "dir": "python", "ref": "main", "path": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python" } ``` Cookie: ``` { "remote": "https://github.com/networktocode/pyntc.git", "path": "/tmp/tmps0ci03id/pyntc", "repository_path": "/tmp/tmps0ci03id/pyntc", "dir": "", "branch_prefix": "drift-manager/develop", "context": { "codeowner_github_usernames": "@jeffkala @pszulczewski @pke11y", "full_name": "Network to Code, LLC", "email": "info@networktocode.com", "github_org": "networktocode", "description": "Python library focused on tasks related to device level and OS management.", "project_name": "pyntc", "project_slug": "pyntc", "repo_url": "https://github.com/networktocode/pyntc", "base_url": "pyntc", "project_python_name": "pyntc", "project_python_base_version": "3.10", "project_with_config_settings": "no", "generate_docs": "no", "version": "2.0.2", "original_publish_year": "2015", "_template": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python", "_output_dir": "/tmp/tmps0ci03id", "_repo_dir": "/__w/cookiecutter-nautobot-app-drift-manager/cookiecutter-nautobot-app-drift-manager/cookiecutter-ntc/python", "_checkout": null }, "drift_managed_branch": "develop", "remote_name": "origin", "pull_request_strategy": "PullRequestStrategy.UPDATE_OR_CREATE", "post_actions": [], "baked_commit_ref": "a5add2662f26a3b877635bbf1fd787682414d2bf", "draft": false } ``` CLI Arguments: ``` { "cookie_dir": "", "input": false, "json_filename": "", "output_dir": "", "push": true, "template": "./cookiecutter-ntc", "template_dir": "python", "template_ref": "main", "pull_request": "update-or-create", "post_action": [], "disable_post_actions": true, "draft": null, "drift_managed_branch": "develop" } ``` * fix merge conflicts * fix merge conflicts * fix prepare release * fix prepare release * fix prepare release * fix conflicts * remove doctest since it wasn't in this repo before drift manager --------- Co-authored-by: bakebot Co-authored-by: Jeff Kala Co-authored-by: Jeff Kala <48843785+jeffkala@users.noreply.github.com> --- .cookiecutter.json | 4 +- .github/workflows/ci.yml | 36 ++-- .github/workflows/prepare_release.yml | 202 ++++++++++++++++++ .github/workflows/release.yml | 87 +++++++- .gitignore | 3 +- changes/+main.housekeeping | 1 + development/bin/ensure_release_notes.py | 99 +++++++++ development/towncrier_header.txt | 9 + .../towncrier_template.j2 | 10 - example.invoke.yml => invoke.example.yml | 0 pyproject.toml | 2 +- tasks.py | 44 ++-- 12 files changed, 446 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/prepare_release.yml create mode 100644 changes/+main.housekeeping create mode 100644 development/bin/ensure_release_notes.py create mode 100644 development/towncrier_header.txt rename towncrier_template.j2 => development/towncrier_template.j2 (64%) rename example.invoke.yml => invoke.example.yml (100%) diff --git a/.cookiecutter.json b/.cookiecutter.json index 9fb4b1d8..e14b4e1d 100644 --- a/.cookiecutter.json +++ b/.cookiecutter.json @@ -20,10 +20,10 @@ "template_dir": "python", "template_ref": "main", "cookie_dir": "", - "pull_request_strategy": "create", + "pull_request_strategy": "update-or-create", "post_actions": [], "draft": false, - "baked_commit_ref": "a5add2662f26a3b877635bbf1fd787682414d2bf", + "baked_commit_ref": "a5653fa42d5aaf2850cdfeb63eabe9e7dc7f04ad", "drift_managed_branch": "develop" } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1003998c..41b407b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,9 @@ jobs: INVOKE_PYNTC_LOCAL: "True" steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v7" - name: "Setup environment" - uses: "networktocode/gh-action-setup-poetry-environment@v6" + uses: "networktocode/gh-action-setup-poetry-environment@v7" with: poetry-version: "2.1.3" - name: "Linting: ruff format" @@ -34,9 +34,9 @@ jobs: INVOKE_PYNTC_LOCAL: "True" steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v7" - name: "Setup environment" - uses: "networktocode/gh-action-setup-poetry-environment@v6" + uses: "networktocode/gh-action-setup-poetry-environment@v7" with: poetry-version: "2.1.3" - name: "Linting: ruff" @@ -47,9 +47,9 @@ jobs: INVOKE_PYNTC_LOCAL: "True" steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v7" - name: "Setup environment" - uses: "networktocode/gh-action-setup-poetry-environment@v6" + uses: "networktocode/gh-action-setup-poetry-environment@v7" with: poetry-version: "2.1.3" poetry-install-options: "--only dev,docs" @@ -61,9 +61,9 @@ jobs: INVOKE_PYNTC_LOCAL: "True" steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v7" - name: "Setup environment" - uses: "networktocode/gh-action-setup-poetry-environment@v6" + uses: "networktocode/gh-action-setup-poetry-environment@v7" with: poetry-version: "2.1.3" - name: "Checking: poetry lock file" @@ -74,9 +74,9 @@ jobs: INVOKE_PYNTC_LOCAL: "True" steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v7" - name: "Setup environment" - uses: "networktocode/gh-action-setup-poetry-environment@v6" + uses: "networktocode/gh-action-setup-poetry-environment@v7" with: poetry-version: "2.1.3" - name: "Linting: yamllint" @@ -96,18 +96,18 @@ jobs: INVOKE_PYNTC_PYTHON_VER: "${{ matrix.python-version }}" steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v7" - name: "Setup environment" - uses: "networktocode/gh-action-setup-poetry-environment@v6" + uses: "networktocode/gh-action-setup-poetry-environment@v7" with: poetry-version: "2.1.3" - name: "Get image version" run: "echo INVOKE_PYNTC_IMAGE_VER=`poetry version -s`-py${{ matrix.python-version }} >> $GITHUB_ENV" - name: "Set up Docker Buildx" id: "buildx" - uses: "docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2" # v3.10.0 + uses: "docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e" # v4.3.0 - name: "Build" - uses: "docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25" # v5.4.0 + uses: "docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a" # v7.3.0 with: builder: "${{ steps.buildx.outputs.name }}" context: "./" @@ -133,9 +133,9 @@ jobs: INVOKE_PYNTC_PYTHON_VER: "${{ matrix.python-version }}" steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v7" - name: "Setup environment" - uses: "networktocode/gh-action-setup-poetry-environment@v6" + uses: "networktocode/gh-action-setup-poetry-environment@v7" with: poetry-version: "2.1.3" - name: "Get image version" @@ -165,11 +165,11 @@ jobs: runs-on: "ubuntu-latest" steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v7" with: fetch-depth: "0" - name: "Setup environment" - uses: "networktocode/gh-action-setup-poetry-environment@v6" + uses: "networktocode/gh-action-setup-poetry-environment@v7" with: poetry-version: "2.1.3" - name: "Check for changelog entry" diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml new file mode 100644 index 00000000..abb283a6 --- /dev/null +++ b/.github/workflows/prepare_release.yml @@ -0,0 +1,202 @@ +--- +name: "Prepare Release" +on: # yamllint disable-line rule:truthy rule:comments + workflow_dispatch: + inputs: + bump_rule: + description: "Select the version bump type" + required: true + default: "patch" + type: "choice" + options: + - "prerelease" + - "patch" + - "minor" + - "major" + target_branch: + description: "Create the release from this branch (default: main)." + required: true + default: "main" + date: + description: "Date of the release YYYY-MM-DD (defaults to today's date in the US Eastern TZ)." + required: false + default: "" + previous_version: + description: "The previous version tag to use for generating release notes (defaults to the latest tag or initial commit if no tags exist)." + required: false + default: "" + +jobs: + prepare-release: + permissions: + contents: "write" + pull-requests: "write" + name: "Prepare Release" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout code" + uses: "actions/checkout@v7" + with: + # If target_branch is 'main', use 'develop' as the source branch. Otherwise, the source and target branch are the same. + ref: "${{ github.event.inputs['target_branch'] == 'main' && 'develop' || github.event.inputs['target_branch'] }}" + fetch-depth: 0 # Fetch all history for git tags + + - name: "Setup environment" + uses: "networktocode/gh-action-setup-poetry-environment@v7" + with: + poetry-version: "2.1.3" + poetry-install-options: "--with dev" + + - name: "Validate Branch and Tags" + run: | + # 1. Verify branch exists + if ! git rev-parse --verify origin/${{ github.event.inputs.target_branch }} > /dev/null 2>&1; then + echo "Error: Branch ${{ github.event.inputs.target_branch }} does not exist." + exit 1 + fi + + # 2. Try to get the previous version tag + # If it fails (no tags), get the hash of the first commit + if [ -n "${{ github.event.inputs.previous_version }}" ]; then + PREV_TAG="${{ github.event.inputs.previous_version }}" + echo "Using user-specified previous version: $PREV_TAG" + echo "PREVIOUS_TAG=$PREV_TAG" >> $GITHUB_ENV + elif PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null); then + echo "PREVIOUS_TAG=$PREV_TAG" >> $GITHUB_ENV + echo "Found previous tag: $PREV_TAG" + else + # Fallback to the first commit in the repository + FIRST_COMMIT=$(git rev-list --max-parents=0 HEAD) + echo "PREVIOUS_TAG=$FIRST_COMMIT" >> $GITHUB_ENV + echo "No tags found. Falling back to initial commit: $FIRST_COMMIT" + fi + + - name: "Determine New Version" + id: "versioning" + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + # Bump the poetry version based on the user input + CURRENT_POETRY_VER=$(poetry version --short) + set +e # continue running if the next command fails + GITHUB_RELEASE_EXISTS=$(gh release view v$CURRENT_POETRY_VER --json publishedAt --jq '.publishedAt') + set -e + # Unconditionally bump the version if the current version is alpha0 or beta0 + if [[ $CURRENT_POETRY_VER == *a0 || $CURRENT_POETRY_VER == *b0 || $CURRENT_POETRY_VER == *rc0 ]]; then + poetry version ${{ github.event.inputs.bump_rule }} + # If the bump rule is prerelease and a release doesn't already exist in github we don't bump the version + + # This is because we've already bumped to the prerelease version after the previous release + elif [[ "${{ github.event.inputs.bump_rule }}" == "prerelease" && "$CURRENT_POETRY_VER" =~ (a|b|rc)[0-9]+$ && -z "$GITHUB_RELEASE_EXISTS" ]]; then + echo "Prerelease v$CURRENT_POETRY_VER does not exist in github, using this version" + else + poetry version ${{ github.event.inputs.bump_rule }} + fi + + # Capture the New version string for use in other steps + NEW_VER=$(poetry version --short) + echo "NEW_VERSION=$NEW_VER" >> $GITHUB_ENV + echo "RELEASE_BRANCH=release/$NEW_VER" >> $GITHUB_ENV + + - name: "Set Date Variable" + run: | + if [ -z "${{ github.event.inputs.date }}" ]; then + RELEASE_DATE=$(TZ=America/New_York date +%Y-%m-%d) + else + RELEASE_DATE="${{ github.event.inputs.date }}" + fi + echo "RELEASE_DATE=$RELEASE_DATE" >> $GITHUB_ENV + + - name: "Create Release Branch" + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + git config user.name "${{ github.actor }}" + git config user.email "${{ github.actor }}@users.noreply.github.com" + + # Ensure release branch doesn't already exist + if git rev-parse --verify origin/${{ env.RELEASE_BRANCH }} > /dev/null 2>&1; then + echo "Error: Release branch ${{ env.RELEASE_BRANCH }} already exists." + exit 1 + fi + + # Create a new branch for the release + git checkout -b "${{ env.RELEASE_BRANCH }}" + + - name: "Regenerate poetry.lock" + run: "poetry lock --regenerate" + + - name: "Generate Github Release Notes" + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + # 1. Get Towncrier Draft + TOWNCRIER_NOTES=$(poetry run towncrier build --version "${{ env.NEW_VERSION }}" --date "${{ env.RELEASE_DATE }}" --draft) + + # 2. Call GitHub API to generate raw notes + RAW_GH_NOTES=$(gh api /repos/${{ github.repository }}/releases/generate-notes \ + -f tag_name="v${{ env.NEW_VERSION }}" \ + -f target_commitish="${{ github.event.inputs['target_branch'] == 'main' && 'develop' || github.event.inputs['target_branch'] }}" \ + -f previous_tag_name="${{ env.PREVIOUS_TAG }}" --jq '.body') + + # 3. Parse usernames (Regex match) + # We use grep to find "@user" patterns, sort, and uniq them + USERNAMES=$(echo "$RAW_GH_NOTES" | grep -oP 'by @\K[a-zA-Z0-9-]+' | sort -u | grep -vE 'dependabot|nautobot-bot|github-actions' || true) + + # 4. Format the Contributors section + CONTRIBUTORS_SECTION="## Contributors" + for user in $USERNAMES; do + CONTRIBUTORS_SECTION="$CONTRIBUTORS_SECTION"$'\n'"* @$user" + done + + # 5. Extract the "Full Changelog" or "New Contributors" part + # Using awk to grab everything from '## New Contributors' or '**Full Changelog**' to the end + GH_FOOTER=$(echo "$RAW_GH_NOTES" | awk '/## New Contributors/ || /\*\*Full Changelog\*\*/ {found=1} found {print}') + if [ -z "$GH_FOOTER" ]; then + GH_FOOTER=$(echo "$RAW_GH_NOTES" | sed -n '/**Full Changelog**/,$p') + fi + + # 6. Combine everything + FINAL_NOTES="$TOWNCRIER_NOTES"$'\n\n'"$CONTRIBUTORS_SECTION"$'\n\n'"$GH_FOOTER" + + # 7. Save to a temporary file to avoid shell argument length limits + echo "$FINAL_NOTES" > ../consolidated_notes.md + + - name: "Generate App Release Notes and update mkdocs.yml" + run: "poetry run inv generate-release-notes --version '${{ env.NEW_VERSION }}' --date '${{ env.RELEASE_DATE }}'" + + - name: "Commit Changes and Push" + run: | + # Add all changes (pyproject.toml, poetry.lock, etc.) + git add . + git commit -m "prepare release v${{ env.NEW_VERSION }}" + git push origin "${{ env.RELEASE_BRANCH }}" + + - name: "Create Pull Request" + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + gh pr create \ + --title "Release v${{ env.NEW_VERSION }}" \ + --body-file "../consolidated_notes.md" \ + --base "${{ github.event.inputs.target_branch }}" \ + --head "${{ env.RELEASE_BRANCH }}" + + - name: "Create Draft Release" + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + if [[ "${{ github.event.inputs.bump_rule }}" == "prerelease" ]]; then + RELEASE_FLAGS="--prerelease" + elif [[ "${{ github.event.inputs.target_branch }}" == "main" ]]; then + RELEASE_FLAGS="--latest" + else + RELEASE_FLAGS="--latest=false" + fi + + gh release create "v${{ env.NEW_VERSION }}" \ + --draft \ + $RELEASE_FLAGS \ + --title "v${{ env.NEW_VERSION }} - ${{ env.RELEASE_DATE }}" \ + --notes-file "../consolidated_notes.md" \ + --target "${{ github.event.inputs.target_branch }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11eaaa31..3ca34588 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,19 +4,24 @@ on: # yamllint disable-line rule:truthy rule:comments release: types: ["published"] +env: + POETRY_VERSION: "2.1.3" + PYTHON_VERSION: "3.12" + INVOKE_PYNTC_LOCAL: "True" + jobs: build: name: "Build package with poetry" runs-on: "ubuntu-latest" if: "startsWith(github.ref, 'refs/tags/v')" steps: - - uses: "actions/checkout@v4" + - uses: "actions/checkout@v7" - name: "Setup environment" - uses: "networktocode/gh-action-setup-poetry-environment@v6" + uses: "networktocode/gh-action-setup-poetry-environment@v7" with: - poetry-version: "2.1.3" - python-version: "3.13" - poetry-install-options: "--no-root" + poetry-version: "${{ env.POETRY_VERSION }}" + python-version: "${{ env.PYTHON_VERSION }}" + poetry-install-options: "--with dev,docs" - name: "Run Poetry Build" run: "poetry build" @@ -38,7 +43,7 @@ jobs: contents: "write" needs: "build" steps: - - uses: "actions/checkout@v4" + - uses: "actions/checkout@v7" - name: "Retrieve built package from cache" uses: "actions/download-artifact@v4" with: @@ -56,8 +61,8 @@ jobs: if: "startsWith(github.ref, 'refs/tags/v')" needs: "build" environment: "pypi" - # IMPORTANT: this permission is mandatory for trusted publishing. permissions: + # IMPORTANT: this permission is mandatory for Trusted Publishing id-token: "write" steps: - name: "Retrieve built package from cache" @@ -65,6 +70,7 @@ jobs: with: name: "distfiles" path: "dist/" + - name: "Publish package distributions to PyPI" uses: "pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e" # v1.13.0 @@ -101,3 +107,70 @@ jobs: } ] } + + create-pr-to-develop: + if: "github.event.release.target_commitish == 'main'" + permissions: + contents: "write" + pull-requests: "write" + name: "${{ github.event.release.target_commitish == 'main' && 'Create a PR from main into develop' || format('Create a PR to merge into {0}', github.event.release.target_commitish) }}" + needs: + - "publish-github" + - "publish-pypi" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout source branch" + uses: "actions/checkout@v7" + with: + ref: "${{ github.event.release.target_commitish }}" + fetch-depth: 0 + + - name: "Setup environment" + uses: "networktocode/gh-action-setup-poetry-environment@v7" + with: + poetry-version: "${{ env.POETRY_VERSION }}" + poetry-install-options: "--no-root" + + - name: "Create release branch and bump version" + id: "branch" + run: | + + git config user.name "${{ github.actor }}" + git config user.email "${{ github.actor }}@users.noreply.github.com" + + SOURCE_BRANCH="${{ github.event.release.target_commitish }}" + if [ "$SOURCE_BRANCH" = "main" ]; then + TARGET_BRANCH="develop" + else + TARGET_BRANCH="$SOURCE_BRANCH" + fi + + TAG_NAME="${{ github.event.release.tag_name }}" + VERSION="${TAG_NAME#v}" + + BRANCH_NAME="release-${VERSION}-to-${TARGET_BRANCH}" + + # Ensure release branch doesn't already exist + if git rev-parse --verify origin/$BRANCH_NAME > /dev/null 2>&1; then + echo "Error: Release branch $BRANCH_NAME already exists." + exit 1 + fi + + git checkout -b "$BRANCH_NAME" + + poetry version prerelease + git add pyproject.toml && git commit -m "Bump version" + git push origin "$BRANCH_NAME" + + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "target_branch=$TARGET_BRANCH" >> $GITHUB_OUTPUT + + - name: "Create Pull Request" + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + gh pr create \ + --title "Post release ${{ github.event.release.tag_name }} to ${{ steps.branch.outputs.target_branch }}" \ + --body "Please do a merge commit." \ + --base "${{ steps.branch.outputs.target_branch }}" \ + --head "${{ steps.branch.outputs.branch_name }}" diff --git a/.gitignore b/.gitignore index 8141a6a9..a2fa5fa5 100644 --- a/.gitignore +++ b/.gitignore @@ -298,6 +298,7 @@ fabric.properties # Rando creds.env development/*.txt +!development/towncrier_header.txt # Invoke overrides invoke.yml @@ -308,7 +309,7 @@ docs/CHANGELOG.md public /compose.yaml /dump.sql -/pyntc/static/pyntc/docs +/pyntc/static/* # Exception for pynxos/lib !/pyntc/devices/pynxos/lib/ diff --git a/changes/+main.housekeeping b/changes/+main.housekeeping new file mode 100644 index 00000000..3433adf6 --- /dev/null +++ b/changes/+main.housekeeping @@ -0,0 +1 @@ +Rebaked from the cookie `main`. diff --git a/development/bin/ensure_release_notes.py b/development/bin/ensure_release_notes.py new file mode 100644 index 00000000..f038cc4d --- /dev/null +++ b/development/bin/ensure_release_notes.py @@ -0,0 +1,99 @@ +"""Ensure that release notes exist for a given version. + +This script will do the following: + Ensure a release notes file exists at `docs/admin/release_notes/version_{version}.md`. + Ensure the `mkdocs.yml` file is updated to add the release notes file to the navigation. + Ensure the `pyproject.toml` `tool.towncrier.filename` is updated to reference the release notes file. + +It shouldn't be necessary to run this file manually. It is automatically called by `invoke generate-release-notes`. + +Example: + $ python ensure_release_notes.py --version '1.0.0' +""" + +import argparse + +try: + import tomllib +except ImportError: + import tomli as tomllib + +from pathlib import Path + + +def release_notes_pyproject_toml(version): + """Update the pyproject.toml file to set the towncrier filename for the given version.""" + pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml" + pyproject_content = pyproject_file.read_text() + pyproject_data = tomllib.loads(pyproject_content) + release_notes_file = f"docs/admin/release_notes/version_{version}.md" + + # Update the towncrier filename + if pyproject_data["tool"]["towncrier"].get("filename", "") != release_notes_file: + pyproject_data["tool"]["towncrier"]["filename"] = release_notes_file + + # Write back the updated content to pyproject.toml + # tomllib is not used to write the file because it is not roundtrippable + new_pyproject_content = [] + in_towncrier_section = False + for line in pyproject_content.splitlines(): + if line.strip() == "[tool.towncrier]": + in_towncrier_section = True + new_pyproject_content.append(line) + continue + if in_towncrier_section: + if line.strip().startswith("filename"): + new_pyproject_content.append(f'filename = "docs/admin/release_notes/version_{version}.md"') + in_towncrier_section = False # Only replace the first occurrence + else: + new_pyproject_content.append(line) + else: + new_pyproject_content.append(line) + + pyproject_file.write_text("\n".join(new_pyproject_content)) + # Add a newline at the end of the file if it doesn't exist + if not pyproject_file.read_text().endswith("\n"): + pyproject_file.write_text(pyproject_file.read_text() + "\n") + # Remind the user to update the release notes file. + print( + f"\033[33mRemember to update the Release Overview section in the release notes file: {release_notes_file}\033[0m" + ) + + +def ensure_release_notes_file(version): + """Ensure that the release notes file for the given version exists and is referenced in mkdocs.yml.""" + release_notes_file = ( + Path(__file__).parent.parent.parent / "docs" / "admin" / "release_notes" / f"version_{version}.md" + ) + if not release_notes_file.exists(): + # Create a new release notes file with a basic template from towncrier_header.txt + towncrier_header = Path(__file__).parent.parent / "towncrier_header.txt" + content = towncrier_header.read_text().format(version=version) + release_notes_file.write_text(content) + + +def ensure_mkdocs_version(version): + """Ensure that mkdocs.yml includes the new release notes file in the navigation.""" + mkdocs_yml_file = Path(__file__).parent.parent.parent / "mkdocs.yml" + mkdocs_yml_content = mkdocs_yml_file.read_text() + release_notes_nav_entry = f' - v{version}: "admin/release_notes/version_{version}.md"\n' + if release_notes_nav_entry in mkdocs_yml_content: + return + + # Add the new release notes entry to the mkdocs.yml content + if "Release Notes:" in mkdocs_yml_content: + mkdocs_yml_content = mkdocs_yml_content.replace( + ' - "admin/release_notes/index.md"\n', + f' - "admin/release_notes/index.md"\n{release_notes_nav_entry}', + ) + + mkdocs_yml_file.write_text(mkdocs_yml_content) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Ensure release notes exist for a given version.") + parser.add_argument("--version", help="The version number (e.g. 2.2)") + args = parser.parse_args() + ensure_release_notes_file(args.version) + ensure_mkdocs_version(args.version) + release_notes_pyproject_toml(args.version) diff --git a/development/towncrier_header.txt b/development/towncrier_header.txt new file mode 100644 index 00000000..a350a182 --- /dev/null +++ b/development/towncrier_header.txt @@ -0,0 +1,9 @@ +# v{version} Release Notes + +This document describes all new features and changes in the release. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Release Overview + +- Major features or milestones + + diff --git a/towncrier_template.j2 b/development/towncrier_template.j2 similarity index 64% rename from towncrier_template.j2 rename to development/towncrier_template.j2 index 243e94a0..641507d4 100644 --- a/towncrier_template.j2 +++ b/development/towncrier_template.j2 @@ -1,13 +1,3 @@ -# v{{ versiondata.version.split(".")[:2] | join(".") }} Release Notes - -This document describes all new features and changes in the release. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## Release Overview - -- Major features or milestones -- Changes to compatibility with Nautobot and/or other apps, libraries etc. - - {% if render_title %} ## [v{{ versiondata.version }} ({{ versiondata.date }})](https://github.com/networktocode/pyntc/releases/tag/v{{ versiondata.version}}) diff --git a/example.invoke.yml b/invoke.example.yml similarity index 100% rename from example.invoke.yml rename to invoke.example.yml diff --git a/pyproject.toml b/pyproject.toml index ef6dab34..2e91981a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,7 +173,7 @@ addopts = "-vv --doctest-modules -p no:warnings -p no:f5sdk_fixtures --ignore-gl package = "pyntc" directory = "changes" filename = "docs/admin/release_notes/version_3.3.md" -template = "towncrier_template.j2" +template = "development/towncrier_template.j2" start_string = "" issue_format = "[#{issue}](https://github.com/networktocode/pyntc/issues/{issue})" diff --git a/tasks.py b/tasks.py index 635e5a4b..8c9b431b 100644 --- a/tasks.py +++ b/tasks.py @@ -36,7 +36,7 @@ def is_truthy(arg): { "pyntc": { "project_name": "pyntc", - "python_ver": "3.10", + "python_ver": "3.14", "local": is_truthy(os.getenv("INVOKE_PYNTC_LOCAL", "false")), "image_name": "pyntc", "image_ver": os.getenv("INVOKE_PYNTC_IMAGE_VER", "latest"), @@ -66,13 +66,14 @@ def task_wrapper(function=None): return task_wrapper -def run_command(context, exec_cmd, port=None): +def run_command(context, exec_cmd, port=None, rm=True): """Wrapper to run the invoke task commands. Args: context ([invoke.task]): Invoke task object. exec_cmd ([str]): Command to run. port (int): Used to serve local docs. + rm (bool): Whether to remove the container after running the command. Returns: result (obj): Contains Invoke result from running task. @@ -84,12 +85,12 @@ def run_command(context, exec_cmd, port=None): print(f"DOCKER - Running command: {exec_cmd} container: {context.pyntc.image_name}:{context.pyntc.image_ver}") if port: result = context.run( - f"docker run -it -p {port} -v {context.pyntc.pwd}:/local {context.pyntc.image_name}:{context.pyntc.image_ver} sh -c '{exec_cmd}'", + f"docker run -it {'--rm' if rm else ''} -p {port} -v {context.pyntc.pwd}:/local {context.pyntc.image_name}:{context.pyntc.image_ver} sh -c '{exec_cmd}'", pty=True, ) else: result = context.run( - f"docker run -it -v {context.pyntc.pwd}:/local {context.pyntc.image_name}:{context.pyntc.image_ver} sh -c '{exec_cmd}'", + f"docker run -it {'--rm' if rm else ''} -v {context.pyntc.pwd}:/local {context.pyntc.image_name}:{context.pyntc.image_ver} sh -c '{exec_cmd}'", pty=True, ) @@ -165,10 +166,22 @@ def coverage(context): run_command(context, "coverage html") -@task -def pytest(context): +@task( + help={ + "pattern": "Only run tests which match the given substring. Can be used multiple times.", + "label": "Module path to run (e.g., tests/unit/test_foo.py). Can be used multiple times.", + }, + iterable=["pattern", "label"], +) +def pytest(context, pattern=None, label=None): """Run pytest test cases.""" - exec_cmd = "coverage run --source=pyntc -m pytest && coverage report" + pytest_cmd = "coverage run --source=pyntc -m pytest" + if pattern: + pytest_cmd += "".join([f" -k {_pattern}" for _pattern in pattern]) + if label: + pytest_cmd += "".join([f" {_label}" for _label in label]) + coverage_cmd = "coverage report" + exec_cmd = " && ".join([pytest_cmd, coverage_cmd]) run_command(context, exec_cmd) @@ -308,14 +321,21 @@ def docs(context): @task( help={ "version": "Version of pyntc to generate the release notes for.", + "date": "Date of the release (default: today).", + "keep": "Keep existing release notes files. Useful for testing. (default: False).", } ) -def generate_release_notes(context, version=""): +def generate_release_notes(context, version="", date="", keep=False): """Generate Release Notes using Towncrier.""" command = "poetry run towncrier build" - if version: - command += f" --version {version}" - else: - command += " --version `poetry version -s`" + if not version: + version = context.run("poetry version --short", hide=True).stdout.strip() + command += f" --version {version}" + if date: + command += f" --date {date}" + command += " --keep" if keep else " --yes" + + version_major_minor = ".".join(version.split(".")[:2]) + context.run(f"poetry run python development/bin/ensure_release_notes.py --version {version_major_minor}") # Due to issues with git repo ownership in the containers, this must always run locally. context.run(command) From c6001ca8b172a0c1af25b1414d0c442b76607faa Mon Sep 17 00:00:00 2001 From: Maximilian <51068190+architekture@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:37:54 -0500 Subject: [PATCH 4/6] Arista EOS SSH Updates (#433) * Updated remote_file_copy for EOS base class. * Updated os_version method to use correct key for value retrieval. * Testing SSH install_os method. * Adding default None value for file_system * Added maintenance mode method for Arista EOS SSH class. * Modified EOS SSH device maintenance_mode method to return boolean val. * Added guard to prevent creating new maint unit. * Revised maintenance_mode method and renamed/shaped MaintMode error. * Revised maintenance_mode for EOS SSH device. * Fixed bug with eos_ssh_device.py's config method. * Added change fragment. * fix doc build and ruff linting * fix new unittesting and testing from eapi after few new changes * undo os_version property change for eapi and override for ssh --------- Co-authored-by: Maximilian Francis Co-authored-by: Jeff Kala <48843785+jeffkala@users.noreply.github.com> Co-authored-by: Jeff Kala --- changes/+eos_ssh_extensions.added | 2 + pyntc/devices/eos_device.py | 38 +- pyntc/devices/eos_ssh_device.py | 520 ++++++++++++------ pyntc/errors.py | 18 + tests/unit/test_devices/test_eos_device.py | 80 ++- .../unit/test_devices/test_eos_ssh_device.py | 230 +++++--- 6 files changed, 659 insertions(+), 229 deletions(-) create mode 100644 changes/+eos_ssh_extensions.added diff --git a/changes/+eos_ssh_extensions.added b/changes/+eos_ssh_extensions.added new file mode 100644 index 00000000..09ad5e5a --- /dev/null +++ b/changes/+eos_ssh_extensions.added @@ -0,0 +1,2 @@ +Added install_os method for EOSSSHDevice class. +Added maintenance_mode method for EOSSSHDevice class. diff --git a/pyntc/devices/eos_device.py b/pyntc/devices/eos_device.py index 997f918e..ad114d3a 100644 --- a/pyntc/devices/eos_device.py +++ b/pyntc/devices/eos_device.py @@ -648,7 +648,7 @@ def _check_copy_output_for_errors(self, output): log.error("Host %s: Error detected in copy command output: %s", self.host, output) raise FileTransferError(f"Error detected in copy command output: {output}") - def remote_file_copy(self, src: FileCopyModel, dest: str | None = None, file_system: str | None = None, **kwargs): + def remote_file_copy(self, src: FileCopyModel, dest: str | None = None, file_system: str | None = None, **kwargs): # pylint: disable=too-many-branches """Copy a file from remote source to device. Args: @@ -683,11 +683,18 @@ def remote_file_copy(self, src: FileCopyModel, dest: str | None = None, file_sys if dest is None: dest = src.file_name - log.debug("Host %s: Starting remote file copy for %s to %s/%s", self.host, src.file_name, file_system, dest) - self.open() self.enable() + if self.check_file_exists(dest, file_system): + if self.verify_optimized_image(dest, file_system): + log.debug("Host %s: File '%s' already exists in '%s'", self.host, dest, file_system) + return + log.debug("Host %s: File '%s' is present but cannot be verified", self.host, dest) + self.native_ssh.send_command(f"delete {file_system}{dest}") + + log.debug("Host %s: Starting remote file copy for %s to %s/%s", self.host, src.file_name, file_system, dest) + self._pre_transfer_space_check(src, file_system) if src.scheme == "tftp" or src.username is None: @@ -762,6 +769,31 @@ def verify_file(self, checksum, filename, hashing_algorithm="md5", **kwargs): ) return False + def verify_optimized_image(self, image_name: str, file_system: str | None): + """Verify the optimized image file without a checksum. + + An Arista EOS image mutates after installation, reducing its + storage footprint and thereby changing its checksum value. The + optimized image can be validated from the command line using + the `verify [file_system]:[image_name]` command without + specifying the checksum hash type. + + Args: + image_name (str): The name of the image to verify. + file_system (str): The device file system to inspect. + + Returns: + (bool): True if file verification is successful, else False. + """ + self.open() + file_system = file_system or self._get_file_system() + result = self.native_ssh.send_command(f"verify {file_system}{image_name}", read_timeout=30) + verified = re.search(f"Verifying {file_system}{image_name} successful.", result) + if verified: + return True + + return False + def install_os(self, image_name, reboot=True, **vendor_specifics): """Install new OS on device. diff --git a/pyntc/devices/eos_ssh_device.py b/pyntc/devices/eos_ssh_device.py index c6beddc0..70b2c2e6 100644 --- a/pyntc/devices/eos_ssh_device.py +++ b/pyntc/devices/eos_ssh_device.py @@ -13,13 +13,22 @@ import json import os import re +import time from netmiko import ConnectHandler from pyntc import log from pyntc.devices.base_device import BaseDevice, fix_docs from pyntc.devices.eos_device import DEFAULT_REBOOT_TIMEOUT, EOSDevice -from pyntc.errors import CommandError, CommandListError, FileTransferError, SocketClosedError +from pyntc.errors import ( + CommandError, + CommandListError, + FileTransferError, + MaintModeProfileError, + OSInstallError, + RebootTimeoutError, + SocketClosedError, +) DEFAULT_SSH_PORT = 22 @@ -79,6 +88,29 @@ def __init__(self, host, username, password, secret="", port=None, **kwargs): # self.open() log.init(host=host) + @property + def uptime(self): + """Get device uptime in seconds.""" + return self.show("show version")["uptime"] + + @property + def vlans(self): + """Get list of VLANs on device. + + ``EOSDevice`` delegates to ``EOSVlans``, which is pyeapi-only + (``device.native.api("vlans")``). Over SSH the same data comes from + ``show vlan | json``, whose ``vlans`` key is a dict keyed by VLAN id. + + Returns: + (list): List of VLAN ids as strings. + """ + if self._vlans is None: + # sorted() over str keys, matching EOSVlans.get_list()'s lexicographic ordering. + self._vlans = sorted(self.show("show vlan")["vlans"].keys()) + + log.debug("Host %s: Vlans %s", self.host, self._vlans) + return self._vlans + @property def native_ssh(self): """Alias for ``native`` so inherited Netmiko-backed code works unchanged. @@ -93,8 +125,22 @@ def native_ssh(self): """ return self.native + @property + def os_version(self): + """Get OS version on device. + + Returns: + (str): OS version of device. + """ + if self._os_version is None: + sh_version_output = self.show("show version") + self._os_version = sh_version_output["version"] + + log.debug("Host %s: OS version %s", self.host, self._os_version) + return self._os_version + @staticmethod - def _read_timeout_for(command): + def _read_timeout_for(command: str): """Resolve the Netmiko read timeout to use for ``command``. Args: @@ -108,7 +154,7 @@ def _read_timeout_for(command): return timeout return DEFAULT_READ_TIMEOUT - def _check_output_for_errors(self, command, output): + def _check_output_for_errors(self, command: str, output: str): """Raise ``CommandError`` when the device reported a CLI error. Args: @@ -122,7 +168,7 @@ def _check_output_for_errors(self, command, output): log.error("Host %s: Error in %s with response: %s", self.host, command, output) raise CommandError(command, output) - def _load_json(self, command, output): + def _load_json(self, command: str, output: str): """Parse ``| json`` output. Args: @@ -142,6 +188,25 @@ def _load_json(self, command, output): log.error("Host %s: Command %s did not return JSON: %s", self.host, command, output) raise CommandError(command, f"Command does not support JSON output: {output}") + def _os_updated(self, prev_version: str) -> bool: + """Confirm the running OS version changed. + + Args: + prev_version (str): The previous running OS version used for + comparison. + + Returns: + True if the running OS version has changed, False if it has + not. + """ + current_version = self.show("show version")["version"] + if current_version != prev_version: + log.info("Host %s: Version changed from %s to %s", self.host, prev_version, current_version) + return True + + log.error("Host %s: Still running version %s", self.host, prev_version) + return False + def _send_command(self, command, error_command=None, **netmiko_args): """Send a single command and check the response for errors. @@ -161,87 +226,62 @@ def _send_command(self, command, error_command=None, **netmiko_args): self._check_output_for_errors(error_command or command, response) return response - def open(self): - """Open, or re-validate, the Netmiko SSH connection to the device.""" - if self._connected: - try: - self.native.find_prompt() - except Exception: # pylint: disable=broad-except - self._connected = False - - if not self._connected: - self.native = ConnectHandler( - device_type="arista_eos", - host=self.host, - username=self.username, - password=self.password, - port=self.port, - secret=self.secret, - verbose=False, - **self.netmiko_kwargs, - ) - self._connected = True - - log.debug("Host %s: Connection to device was opened successfully.", self.host) + def _wait_for_reload(self, prev_uptime: float, timeout: int) -> None: + """Block until device successfully reloads. - def show(self, commands, raw_text=False): - """Send show command(s) to the device. + Tries to retrieve and compare the current uptime to the previous + uptime. If the current uptime is less than the previous uptime, + the reload is considered successful. If the current uptime is + greater than the previous uptime or the device is unreachable + and the timeout value has not expired, the comparison will be + reattempted again in 15 seconds. The method fails if the reload + doesn't succeed before the timeout value expires. Args: - commands (str, list): String with single command, or list with multiple commands. - raw_text (bool, optional): False to return structured data via the ``| json`` - pipe, True to return the raw CLI text. Defaults to False. - - Returns: - (dict): When ``commands`` is a str and ``raw_text`` is False. Non-show commands - cannot be piped to ``| json``; they run as plain text and return an empty dict. - (str): When ``commands`` is a str and ``raw_text`` is True. - (list): When ``commands`` is a list. + prev_uptime (float): The uptime value prior to device reload + in seconds. + timeout (int): The maximum time in seconds to wait before + flagging the reload as a failure. Raises: - CommandError: When ``commands`` is a str and the device reports an error. - CommandListError: When ``commands`` is a list and one command reports an error. + RebootTimeoutError: If the reload doesn't succeed before the + timeout value expires. """ - self.open() - self.enable() - - original_commands_is_str = isinstance(commands, str) - command_list = [commands] if original_commands_is_str else list(commands) - - responses = [] - entered_commands = [] - for command in command_list: - entered_commands.append(command) - as_json = not raw_text and bool(RE_JSON_ELIGIBLE.match(command)) - cli_command = f"{command} | json" if as_json else command + start = time.time() + while time.time() - start < timeout: try: - output = self._send_command(cli_command, error_command=command) - if as_json: - output = self._load_json(command, output) - except CommandError as err: - if original_commands_is_str: - raise - raise CommandListError(entered_commands, command, err.cli_error_msg) from err - - if raw_text or as_json: - responses.append(output) - else: - # Non-show command sent with raw_text=False (checkpoint, save, rollback, - # reboot, set_boot_options). Every inherited caller discards the result, - # so an empty dict preserves EOSDevice's contract. - responses.append({}) - - if original_commands_is_str: - return responses[0] - - log.debug("Host %s: Successfully executed command 'show' with responses %s.", self.host, responses) - return responses + current_uptime = self.uptime + if current_uptime < prev_uptime: + log.info( + "Host %s: Device reload successful (current uptime %s < previous uptime %s)", + self.host, + current_uptime, + prev_uptime, + ) + return + except Exception as exc: # pylint: disable=broad-except + log.debug("Host %s: Reload probe failed (%s); will retry", self.host, exc) + time.sleep(15) + + log.error("Host %s: Reload timer exceeded (%ss)", self.host, timeout) + raise RebootTimeoutError(self.hostname, timeout) + + def close(self): + """Disconnect from the device. + + Note this differs from ``EOSDevice.close``, which is a no-op because eAPI is + stateless. An SSH session holds a real socket that should be released. + """ + if self._connected: + self.native.disconnect() + self._connected = False + log.debug("Host %s: Connection closed.", self.host) - def config(self, commands): + def config(self, command: str | list): """Send configuration commands to a device. Args: - commands (str, list): String with single command, or list with multiple commands. + command (str, list): String with single command, or list with multiple commands. Raises: CommandError: When ``commands`` is a str and the device reports an error. @@ -250,96 +290,28 @@ def config(self, commands): self.open() self.enable() - original_commands_is_str = isinstance(commands, str) - command_list = [commands] if original_commands_is_str else list(commands) + original_commands_is_str = isinstance(command, str) + command_list = [command] if original_commands_is_str else list(command) entered_commands = [] try: - for command in command_list: - entered_commands.append(command) + for cmd in command_list: + entered_commands.append(cmd) # Multi-line commands (e.g. "banner motd\n...\nEOF") drop the CLI into an # input mode whose echo Netmiko's cmd_verify cannot match; verification must # be disabled for them or send_config_set raises ReadTimeout. - output = self.native.send_config_set(command, exit_config_mode=False, cmd_verify="\n" not in command) + output = self.native.send_config_set(cmd, exit_config_mode=False, cmd_verify="\n" not in cmd) try: - self._check_output_for_errors(command, output) + self._check_output_for_errors(cmd, output) except CommandError as err: if original_commands_is_str: raise - raise CommandListError(entered_commands, command, err.cli_error_msg) from err + raise CommandListError(entered_commands, cmd, err.cli_error_msg) from err finally: # Never leave the session parked in config mode, even on failure. self.native.exit_config_mode() - log.info("Host %s: Device configured with commands %s.", self.host, commands) - - def reboot(self, wait_for_reload=False, timeout=DEFAULT_REBOOT_TIMEOUT, **kwargs): - """Reload the device. - - Unlike eAPI, the SSH session dies as the reload executes, so the command is sent - with ``send_command_timing`` and the resulting transport error is expected. - - Args: - wait_for_reload (bool): When True, block until the device's boot time advances - past the pre-reboot value. Defaults to False. - timeout (int): Max seconds to poll when ``wait_for_reload`` is True. - kwargs (dict): Additional keyword arguments, such as confirm. - - Raises: - RebootTimeoutError: When the device does not return within ``timeout``. - - Example: - >>> device = EOSSSHDevice(**connection_args) - >>> device.reboot() - >>> - """ - if kwargs.get("confirm"): - log.warning("Passing 'confirm' to reboot method is deprecated.") - - original_boot_time = self.boot_time if wait_for_reload else None - try: - self.native.send_command_timing("reload now") - except Exception as err: # pylint: disable=broad-except - log.debug("Host %s: Session dropped during reload, as expected (%s).", self.host, err) - - # The socket is gone regardless of how the command returned; force the next - # operation to reconnect rather than reuse a dead handle. - self._connected = False - log.info("Host %s: Device rebooted.", self.host) - - if wait_for_reload: - # Both arguments are numeric; naming them prevents a transposition from - # silently satisfying the "boot time advanced" check on the first poll. - self._wait_for_device_reboot(original_boot_time=original_boot_time, timeout=timeout) - - def file_copy_remote_exists(self, src, dest=None, file_system=None): - """Check whether ``src`` already exists on the device with a matching checksum. - - ``EOSDevice`` answers this through Netmiko's ``AristaFileTransfer``, which drops into - the switch's Linux shell (``bash`` then ``/bin/ls``). That requires shell privileges - the connecting account may not have. This override uses the CLI instead -- - ``dir /`` and ``verify /md5 `` -- matching how - ``IOSDevice`` already behaves. - - Args: - src (str): Path to the local file to check for. - dest (str, optional): Remote filename. Defaults to the basename of ``src``. - file_system (str, optional): Target filesystem. Auto-detected when omitted. - - Returns: - (bool): True when the remote file exists and its checksum matches ``src``. - """ - self.open() - self.enable() - if file_system is None: - file_system = self._get_file_system() - - dest = dest or os.path.basename(src) - local_checksum = self.get_local_checksum(src) - exists = self.verify_file(local_checksum, dest, file_system=file_system) - - log.debug("Host %s: File %s already on remote: %s.", self.host, src, exists) - return exists + log.info("Host %s: Device configured with commands %s.", self.host, command) def file_copy(self, src, dest=None, file_system=None): """Copy a local file to the device over SCP. @@ -404,20 +376,244 @@ def file_copy(self, src, dest=None, file_system=None): ) raise FileTransferError - @property - def vlans(self): - """Get list of VLANs on device. + def file_copy_remote_exists(self, src, dest=None, file_system=None): + """Check whether ``src`` already exists on the device with a matching checksum. - ``EOSDevice`` delegates to ``EOSVlans``, which is pyeapi-only - (``device.native.api("vlans")``). Over SSH the same data comes from - ``show vlan | json``, whose ``vlans`` key is a dict keyed by VLAN id. + ``EOSDevice`` answers this through Netmiko's ``AristaFileTransfer``, which drops into + the switch's Linux shell (``bash`` then ``/bin/ls``). That requires shell privileges + the connecting account may not have. This override uses the CLI instead -- + ``dir /`` and ``verify /md5 `` -- matching how + ``IOSDevice`` already behaves. + + Args: + src (str): Path to the local file to check for. + dest (str, optional): Remote filename. Defaults to the basename of ``src``. + file_system (str, optional): Target filesystem. Auto-detected when omitted. Returns: - (list): List of VLAN ids as strings. + (bool): True when the remote file exists and its checksum matches ``src``. """ - if self._vlans is None: - # sorted() over str keys, matching EOSVlans.get_list()'s lexicographic ordering. - self._vlans = sorted(self.show("show vlan")["vlans"].keys()) + self.open() + self.enable() + if file_system is None: + file_system = self._get_file_system() - log.debug("Host %s: Vlans %s", self.host, self._vlans) - return self._vlans + dest = dest or os.path.basename(src) + local_checksum = self.get_local_checksum(src) + exists = self.verify_file(local_checksum, dest, file_system=file_system) + + log.debug("Host %s: File %s already on remote: %s.", self.host, src, exists) + return exists + + def install_os(self, image_name: str, file_system: str | None = None, reboot=True, **vendor_specifics) -> bool: + """Install a different OS version. + + Args: + image_name (str): The target image filename to install. + file_system (str | None): The device's target file system + where the software image is stored, defaults to None. + reboot (bool): Reloads the device when True. + vendor_specifics (dict): Any pre-loaded vendor-specific kwargs. + + Returns: + (bool): True when the installation is successful, False when + the target image is already installed. + + Raises: + OSInstallError: If the image installation fails. + """ + if self._image_booted(image_name): + log.info("Host %s: OS image '%s' already installed", self.host, image_name) + return False + + file_system = file_system or self._get_file_system() + command = f"install source {file_system}{image_name}" + self.open() + self.enable() + if reboot: + timeout = vendor_specifics.get("timeout", 900) + command += " reload now" + version_output = self.show("show version") + version = version_output["version"] + uptime = version_output["uptime"] + self._send_command(command, read_timeout=300, expect_string=r"going down for reboot|%") + self._wait_for_reload(uptime, timeout) + if self._os_updated(version): + log.info("Host %s: OS image '%s' installed successfully", self.host, image_name) + return True + log.error("Host %s: Failed to install OS image '%s'", self.host, image_name) + raise OSInstallError(self.hostname, image_name) + self._send_command(command, read_timeout=300) + log.info("Host %s: OS image '%s' installed, reload device to finalize", self.host, image_name) + return True + + def maintenance_mode(self, unit="System", enable=True, transition_timer=300): + """Enter or exit maintenance mode. + + Sends config commands to transition the maintenance mode state, + entering or exiting based on the `enable` value. Attempts to + confirm successful transition in the alloted time based on the + provided `transition_timer` value. + + Args: + unit (str): The specified unit to use when entering or + exiting maintenance mode, defaults to `System`. + enable (bool): Enters maintenance mode when True, exits + maintenance mode when False. + transition_timer (int): Duration in seconds to wait for + maintenance state to succesfully transition, defaults to + 300 seconds. + + Returns: + (bool): True if state transition successful, else False. + + Raises: + MaintModeProfileError: If the provided unit name does not + already exist on the target device. + """ + commands = [ + "maintenance", + f"unit {unit}", + ] + if enable: + commands.append("quiesce") + desired_state = "underMaintenance" + else: + commands.append("no quiesce") + desired_state = "active" + + units = self.show("show maintenance")["units"] + if unit not in units.keys(): + raise MaintModeProfileError(self.hostname, unit) + self.config(commands) + start = time.time() + while time.time() - start < transition_timer: + actual_state = self.show("show maintenance")["units"][unit]["state"] + if actual_state == desired_state: + log.debug("Host %s: Maintenance state successfully transitioned to '%s'", self.host, unit) + return True + log.debug("Host %s: Maintenance state currently '%s', will retry", self.host, actual_state) + time.sleep(10) + + log.error( + "Host %s: Transition state timer (%ss) has expired, maintenance state currrently '%s'", + self.host, + transition_timer, + actual_state, + ) + return False + + def open(self): + """Open, or re-validate, the Netmiko SSH connection to the device.""" + if self._connected: + try: + self.native.find_prompt() + except Exception: # pylint: disable=broad-except + self._connected = False + + if not self._connected: + self.native = ConnectHandler( + device_type="arista_eos", + host=self.host, + username=self.username, + password=self.password, + port=self.port, + secret=self.secret, + verbose=False, + **self.netmiko_kwargs, + ) + self._connected = True + + log.debug("Host %s: Connection to device was opened successfully.", self.host) + + def reboot(self, wait_for_reload=False, timeout=DEFAULT_REBOOT_TIMEOUT, **kwargs): + """Reload the device. + + Unlike eAPI, the SSH session dies as the reload executes, so the command is sent + with ``send_command_timing`` and the resulting transport error is expected. + + Args: + wait_for_reload (bool): When True, block until the device's boot time advances + past the pre-reboot value. Defaults to False. + timeout (int): Max seconds to poll when ``wait_for_reload`` is True. + kwargs (dict): Additional keyword arguments, such as confirm. + + Raises: + RebootTimeoutError: When the device does not return within ``timeout``. + + Example: + >>> device = EOSSSHDevice(**connection_args) + >>> device.reboot() + >>> + """ + if kwargs.get("confirm"): + log.warning("Passing 'confirm' to reboot method is deprecated.") + + original_boot_time = self.boot_time if wait_for_reload else None + try: + self.native.send_command_timing("reload now") + except Exception as err: # pylint: disable=broad-except + log.debug("Host %s: Session dropped during reload, as expected (%s).", self.host, err) + + # The socket is gone regardless of how the command returned; force the next + # operation to reconnect rather than reuse a dead handle. + self._connected = False + log.info("Host %s: Device rebooted.", self.host) + + if wait_for_reload: + # Both arguments are numeric; naming them prevents a transposition from + # silently satisfying the "boot time advanced" check on the first poll. + self._wait_for_device_reboot(original_boot_time=original_boot_time, timeout=timeout) + + def show(self, commands: str | list, raw_text: bool = False): + """Send show command(s) to the device. + + Args: + commands (str, list): String with single command, or list with multiple commands. + raw_text (bool, optional): False to return structured data via the ``| json`` + pipe, True to return the raw CLI text. Defaults to False. + + Returns: + (dict): When ``commands`` is a str and ``raw_text`` is False. Non-show commands + cannot be piped to ``| json``; they run as plain text and return an empty dict. + (str): When ``commands`` is a str and ``raw_text`` is True. + (list): When ``commands`` is a list. + + Raises: + CommandError: When ``commands`` is a str and the device reports an error. + CommandListError: When ``commands`` is a list and one command reports an error. + """ + self.open() + self.enable() + + original_commands_is_str = isinstance(commands, str) + command_list = [commands] if original_commands_is_str else list(commands) + + responses = [] + entered_commands = [] + for command in command_list: + entered_commands.append(command) + as_json = not raw_text and bool(RE_JSON_ELIGIBLE.match(command)) + cli_command = f"{command} | json" if as_json else command + try: + output = self._send_command(cli_command, error_command=command) + if as_json: + output = self._load_json(command, output) + except CommandError as err: + if original_commands_is_str: + raise + raise CommandListError(entered_commands, command, err.cli_error_msg) from err + + if raw_text or as_json: + responses.append(output) + else: + # Non-show command sent with raw_text=False (checkpoint, save, rollback, + # reboot, set_boot_options). Every inherited caller discards the result, + # so an empty dict preserves EOSDevice's contract. + responses.append({}) + + if original_commands_is_str: + return responses[0] + + log.debug("Host %s: Successfully executed command 'show' with responses %s.", self.host, responses) + return responses diff --git a/pyntc/errors.py b/pyntc/errors.py index 128ee5da..f8fdd154 100644 --- a/pyntc/errors.py +++ b/pyntc/errors.py @@ -357,3 +357,21 @@ def __init__(self, hostname, desired_wlans, actual_wlans): f"Found: {sorted(actual_wlans)}\n" ) super().__init__(message) + + +class MaintModeProfileError(NTCError): + """Error if selected maintenance mode profile does not exist.""" + + def __init__(self, hostname, profile, message=None): + """ + Error if selected maintenance mode profile does not exist. + + Args: + hostname (str): The hostname of the device. + profile (str): The name of the missing maint-mode + profile/unit. + message (str | None): Optional custom message which + overrides the default_message. + """ + default_message = f"{hostname} has no maintenance profile '{profile}'" + super().__init__(message or default_message) diff --git a/tests/unit/test_devices/test_eos_device.py b/tests/unit/test_devices/test_eos_device.py index 066f9aca..21e7096d 100644 --- a/tests/unit/test_devices/test_eos_device.py +++ b/tests/unit/test_devices/test_eos_device.py @@ -531,6 +531,14 @@ def tearDown(self): class TestRemoteFileCopy(EOSDeviceMockedTestCase): """Tests for remote_file_copy method.""" + def setUp(self): + super().setUp() + # remote_file_copy first asks whether the destination already exists. Default to a + # clean filesystem so each test's send_command stub only has to model the copy itself. + patcher = mock.patch.object(EOSDevice, "check_file_exists", return_value=False) + self.mock_exists = patcher.start() + self.addCleanup(patcher.stop) + def test_remote_file_copy_invalid_src_type(self): """Test remote_file_copy raises TypeError for invalid src type.""" with self.assertRaises(TypeError) as ctx: @@ -901,6 +909,72 @@ def test_remote_file_copy_logging_on_success(self, mock_get_fs, mock_open, mock_ any("transferred and verified successfully" in str(call) for call in mock_log.info.call_args_list) ) + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system", return_value="flash:") + def test_remote_file_copy_skips_transfer_when_optimized_image_verifies(self, _fs, _open, _enable, mock_verify): + """An existing image that passes EOS's bare "verify" is left alone; nothing is copied.""" + self.mock_exists.return_value = True + mock_ssh = mock.MagicMock() + mock_ssh.send_command.return_value = "Verifying flash:file.bin successful." + self.device.native_ssh = mock_ssh + + src = FileCopyModel(download_url="http://example.com/file.bin", checksum="abc123", file_name="file.bin") + self.device.remote_file_copy(src) + + mock_ssh.send_command.assert_called_once_with("verify flash:file.bin", read_timeout=30) + mock_verify.assert_not_called() + + @mock.patch.object(EOSDevice, "verify_file") + @mock.patch.object(EOSDevice, "enable") + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system", return_value="flash:") + def test_remote_file_copy_replaces_unverifiable_existing_file(self, _fs, _open, _enable, mock_verify): + """An existing file EOS cannot verify is deleted before the fresh copy.""" + self.mock_exists.return_value = True + mock_verify.return_value = True + mock_ssh = mock.MagicMock() + mock_ssh.send_command.side_effect = ["% Verification failed", "", "Copy completed successfully"] + self.device.native_ssh = mock_ssh + + src = FileCopyModel(download_url="http://example.com/file.bin", checksum="abc123", file_name="file.bin") + self.device.remote_file_copy(src) + + commands = [call[0][0] for call in mock_ssh.send_command.call_args_list] + self.assertEqual( + commands, + ["verify flash:file.bin", "delete flash:file.bin", "copy http://example.com/file.bin flash:"], + ) + + +class TestVerifyOptimizedImage(EOSDeviceMockedTestCase): + """Tests for verify_optimized_image.""" + + @mock.patch.object(EOSDevice, "open") + def test_true_on_success_banner(self, _open): + self.device.native_ssh = mock.MagicMock() + self.device.native_ssh.send_command.return_value = "Verifying flash:EOS.swi successful." + + self.assertTrue(self.device.verify_optimized_image("EOS.swi", "flash:")) + self.device.native_ssh.send_command.assert_called_once_with("verify flash:EOS.swi", read_timeout=30) + + @mock.patch.object(EOSDevice, "open") + def test_false_when_verification_fails(self, _open): + self.device.native_ssh = mock.MagicMock() + self.device.native_ssh.send_command.return_value = "% Verification failed" + + self.assertFalse(self.device.verify_optimized_image("EOS.swi", "flash:")) + + @mock.patch.object(EOSDevice, "open") + @mock.patch.object(EOSDevice, "_get_file_system", return_value="flash:") + def test_probes_file_system_when_omitted(self, mock_fs, _open): + self.device.native_ssh = mock.MagicMock() + self.device.native_ssh.send_command.return_value = "Verifying flash:EOS.swi successful." + + self.assertTrue(self.device.verify_optimized_image("EOS.swi", None)) + mock_fs.assert_called_once() + class TestFileCopyModelValidation(unittest.TestCase): """Tests for FileCopyModel defaults and validation.""" @@ -1032,7 +1106,8 @@ def test_file_copy_raises_not_enough_free_space(self, _close, _open, mock_ft, _g @mock.patch.object(EOSDevice, "enable") @mock.patch.object(EOSDevice, "open") @mock.patch.object(EOSDevice, "_get_file_system", return_value="flash:") - def test_remote_file_copy_raises_not_enough_free_space(self, _fs, _open, _enable, _verify): + @mock.patch.object(EOSDevice, "check_file_exists", return_value=False) + def test_remote_file_copy_raises_not_enough_free_space(self, _exists, _fs, _open, _enable, _verify): """remote_file_copy raises NotEnoughFreeSpaceError and never issues a copy command.""" mock_ssh = mock.MagicMock() self.device.native_ssh = mock_ssh @@ -1056,8 +1131,9 @@ def test_remote_file_copy_raises_not_enough_free_space(self, _fs, _open, _enable @mock.patch.object(EOSDevice, "open") @mock.patch.object(EOSDevice, "_get_file_system", return_value="flash:") @mock.patch.object(EOSDevice, "_check_free_space") + @mock.patch.object(EOSDevice, "check_file_exists", return_value=False) def test_remote_file_copy_skips_space_check_when_file_size_omitted( - self, mock_check, _fs, _open, _enable, mock_verify + self, _exists, mock_check, _fs, _open, _enable, mock_verify ): """When FileCopyModel has no file_size, _check_free_space is NOT called.""" mock_verify.return_value = True diff --git a/tests/unit/test_devices/test_eos_ssh_device.py b/tests/unit/test_devices/test_eos_ssh_device.py index 868cccf6..bedcce62 100644 --- a/tests/unit/test_devices/test_eos_ssh_device.py +++ b/tests/unit/test_devices/test_eos_ssh_device.py @@ -11,10 +11,8 @@ """ import hashlib -import inspect import json import os -import time from unittest import mock import pytest @@ -22,7 +20,6 @@ from pyntc import ntc_device from pyntc.devices import EOSDevice, EOSSSHDevice from pyntc.devices.base_device import RollbackError -from pyntc.devices.eos_device import DEFAULT_REBOOT_TIMEOUT from pyntc.devices.eos_ssh_device import DEFAULT_READ_TIMEOUT from pyntc.devices.eos_ssh_device import EOSSSHDevice as Driver from pyntc.errors import ( @@ -31,13 +28,16 @@ FileTransferError, NotEnoughFreeSpaceError, OSInstallError, + RebootTimeoutError, SocketClosedError, ) from pyntc.utils.models import FileCopyModel BOOT_TIMESTAMP = 1785963023.376446 +UPTIME = 254314.71 MODEL = "DCS-7050TX-64-R" -OS_VERSION = "4.28.5M-29792660.4285M" +# EOSDevice.os_version reads "version" (the short form), not "internalVersion". +OS_VERSION = "4.28.5M" HOSTNAME = "nyc-eos-01" SERIAL_NUMBER = "JPE00000000" BOOT_IMAGE = "EOS-4.28.5M.swi" @@ -351,10 +351,17 @@ def test_boot_time(eos_ssh_send_command): def test_uptime(eos_ssh_send_command): + # Unlike EOSDevice, which derives uptime from bootupTimestamp and caches it, the SSH + # driver returns the device-reported uptime so install_os can poll it across a reload. device = eos_ssh_send_command(["show_version_json"]) - uptime = device.uptime - assert isinstance(uptime, int) - assert uptime == pytest.approx(int(time.time() - BOOT_TIMESTAMP), abs=2) + assert device.uptime == pytest.approx(UPTIME) + + +def test_uptime_is_not_cached(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json", "show_version_json"]) + device.uptime # noqa: B018 + device.uptime # noqa: B018 + assert device.native.send_command.call_count == 2 def test_uptime_string(eos_ssh_send_command): @@ -681,30 +688,72 @@ def _model(url="http://192.0.2.5/EOS.swi", checksum="abc123", **kwargs): return FileCopyModel(download_url=url, checksum=checksum, file_name="EOS.swi", **kwargs) +# remote_file_copy follows EOSDevice's flow: probe the filesystem, check whether the file is +# already on the box (and if so whether EOS can vouch for it as an installed image), run the +# pre-transfer space check only when the model carries a file_size, copy, then verify. +EXISTS = "Directory of flash:/EOS.swi\n" +COPY_COMMAND = "copy http://192.0.2.5/EOS.swi flash:" + + +def _commands(device): + return [call[0][0] for call in device.native.send_command.call_args_list] + + def test_remote_file_copy_issues_copy_command_and_verifies(eos_ssh_send_command): device = eos_ssh_send_command( [ "dir", # _get_file_system + *ABSENT, # check_file_exists: nothing to skip or delete "", # the copy command itself - "Directory of flash:/EOS.swi\n", # verify_file -> check_file_exists - "verify /md5 (flash:EOS.swi) = abc123", # verify_file -> get_remote_checksum + *_present("abc123"), # verify_file -> check_file_exists, get_remote_checksum ] ) device.remote_file_copy(_model()) - commands = [call[0][0] for call in device.native.send_command.call_args_list] - assert "copy http://192.0.2.5/EOS.swi flash:" in commands + assert COPY_COMMAND in _commands(device) + + +def test_remote_file_copy_uses_model_timeout(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", *ABSENT, "", *_present("abc123")]) + device.remote_file_copy(_model(timeout=1234)) + copy_call = [c for c in device.native.send_command.call_args_list if c[0][0] == COPY_COMMAND][0] + assert copy_call[1]["read_timeout"] == 1234 + + +def test_remote_file_copy_skips_transfer_when_optimized_image_verifies(eos_ssh_send_command): + # An installed .swi mutates on disk, so EOS checks it with a bare "verify" rather than a + # checksum. When that passes there is nothing to transfer. + device = eos_ssh_send_command(["dir", EXISTS, "Verifying flash:EOS.swi successful."]) + device.remote_file_copy(_model()) + commands = _commands(device) + assert "verify flash:EOS.swi" in commands + assert not any(command.startswith("copy ") for command in commands) + + +def test_remote_file_copy_replaces_unverifiable_existing_file(eos_ssh_send_command): + device = eos_ssh_send_command( + [ + "dir", + EXISTS, # already on the box ... + "% Verification failed", # ... but EOS cannot vouch for it + "", # delete + "", # copy + *_present("abc123"), + ] + ) + device.remote_file_copy(_model()) + commands = _commands(device) + assert commands.index("delete flash:EOS.swi") < commands.index(COPY_COMMAND) def test_remote_file_copy_embeds_credentials_for_http(eos_ssh_send_command): - device = eos_ssh_send_command(["dir", "", "Directory of flash:/EOS.swi\n", "verify /md5 (flash:EOS.swi) = abc123"]) + device = eos_ssh_send_command(["dir", *ABSENT, "", *_present("abc123")]) device.remote_file_copy(_model(url="http://user:token@192.0.2.5/EOS.swi")) - commands = [call[0][0] for call in device.native.send_command.call_args_list] - assert "copy http://user:token@192.0.2.5/EOS.swi flash:" in commands + assert "copy http://user:token@192.0.2.5/EOS.swi flash:" in _commands(device) def test_remote_file_copy_prompts_for_scp_password(eos_ssh_send_command, eos_ssh_send_command_timing): # SCP cannot carry the password in the URL, so the driver answers the prompt interactively. - device = eos_ssh_send_command(["dir", "Directory of flash:/EOS.swi\n", "verify /md5 (flash:EOS.swi) = abc123"]) + device = eos_ssh_send_command(["dir", *ABSENT, *_present("abc123")]) eos_ssh_send_command_timing(["Password:", ""], existing_device=device) device.remote_file_copy(_model(url="scp://user:token@192.0.2.5/EOS.swi")) timing_commands = [call[0][0] for call in device.native.send_command_timing.call_args_list] @@ -729,24 +778,29 @@ def test_remote_file_copy_rejects_query_string(eos_ssh_device): def test_remote_file_copy_checks_free_space_first(eos_ssh_send_command): - device = eos_ssh_send_command(["dir", "dir"]) + # Side effects: filesystem probe, existence check, then the free-space probe. + device = eos_ssh_send_command(["dir", *ABSENT, "dir"]) with pytest.raises(NotEnoughFreeSpaceError): device.remote_file_copy(_model(file_size=10, file_size_unit="gigabytes")) # Nothing was transferred. - commands = [call[0][0] for call in device.native.send_command.call_args_list] - assert not any(command.startswith("copy ") for command in commands) + assert not any(command.startswith("copy ") for command in _commands(device)) + + +def test_remote_file_copy_skips_space_check_without_file_size(eos_ssh_send_command): + device = eos_ssh_send_command(["dir", *ABSENT, "", *_present("abc123")]) + device.remote_file_copy(_model()) + # Only the filesystem probe ran "dir"; no second probe for free space. + assert _commands(device).count("dir") == 1 def test_remote_file_copy_raises_on_error_output(eos_ssh_send_command): - device = eos_ssh_send_command(["dir", "Error: connection refused"]) + device = eos_ssh_send_command(["dir", *ABSENT, "Error: connection refused"]) with pytest.raises(FileTransferError): device.remote_file_copy(_model()) def test_remote_file_copy_raises_when_checksum_mismatches(eos_ssh_send_command): - device = eos_ssh_send_command( - ["dir", "", "Directory of flash:/EOS.swi\n", "verify /md5 (flash:EOS.swi) = deadbeef"] - ) + device = eos_ssh_send_command(["dir", *ABSENT, "", *_present("deadbeef")]) with pytest.raises(FileTransferError): device.remote_file_copy(_model(checksum="abc123")) @@ -756,12 +810,13 @@ def test_remote_file_copy_raises_when_checksum_mismatches(eos_ssh_send_command): # --------------------------------------------------------------------------- NEW_IMAGE = "EOS-4.28.9M.swi" -NEW_IMAGE_BOOTED = "Software image: flash:/EOS-4.28.9M.swi\n" +INSTALL_COMMAND = f"install source flash:{NEW_IMAGE}" +# "show version" after a successful upgrade: new version string, uptime reset by the reload. +NEW_VERSION_JSON = '{"version": "4.28.9M", "uptime": 30.0}' -def _set_boot_options_effects(): - """Side effects consumed by set_boot_options: fs probe, dir listing, install, readback.""" - return ["dir", "dir", "", '{"softwareImage": "flash:/EOS-4.28.9M.swi"}'] +def _install_call(device): + return [c for c in device.native.send_command.call_args_list if c[0][0].startswith("install source")][0] def test_install_os_returns_false_when_image_already_booted(eos_ssh_send_command): @@ -769,38 +824,106 @@ def test_install_os_returns_false_when_image_already_booted(eos_ssh_send_command assert device.install_os(BOOT_IMAGE) is False -def test_install_os_sets_boot_options_then_reboots(eos_ssh_send_command): - device = eos_ssh_send_command(["show_boot", *_set_boot_options_effects(), NEW_IMAGE_BOOTED]) - with mock.patch.object(Driver, "reboot") as mock_reboot: +def test_install_os_installs_with_reload_and_waits(eos_ssh_send_command): + # The SSH driver does not go through set_boot_options + reboot: a single + # "install source ... reload now" does both, and the reload is confirmed by the device + # uptime resetting, then the running version changing. + device = eos_ssh_send_command( + [ + "show_boot", # _image_booted: still on the old image + "dir", # _get_file_system + "show_version_json", # pre-install version and uptime + "", # install source ... reload now + NEW_VERSION_JSON, # _os_updated after the reload + ] + ) + with mock.patch.object(Driver, "_wait_for_reload") as mock_wait: assert device.install_os(NEW_IMAGE) is True - mock_reboot.assert_called_once_with(wait_for_reload=True, timeout=DEFAULT_REBOOT_TIMEOUT) - commands = [call[0][0] for call in device.native.send_command.call_args_list] - assert f"install source flash:{NEW_IMAGE}" in commands + mock_wait.assert_called_once_with(UPTIME, 900) + install_call = _install_call(device) + assert install_call[0][0] == f"{INSTALL_COMMAND} reload now" + assert install_call[1]["read_timeout"] == 300 + # The session drops as the box goes down; stop reading at the reload banner (or an error). + assert install_call[1]["expect_string"] == r"going down for reboot|%" def test_install_os_honours_custom_timeout(eos_ssh_send_command): - device = eos_ssh_send_command(["show_boot", *_set_boot_options_effects(), NEW_IMAGE_BOOTED]) - with mock.patch.object(Driver, "reboot") as mock_reboot: + device = eos_ssh_send_command(["show_boot", "dir", "show_version_json", "", NEW_VERSION_JSON]) + with mock.patch.object(Driver, "_wait_for_reload") as mock_wait: device.install_os(NEW_IMAGE, timeout=120) - mock_reboot.assert_called_once_with(wait_for_reload=True, timeout=120) + mock_wait.assert_called_once_with(UPTIME, 120) -def test_install_os_without_reboot_does_not_reboot(eos_ssh_send_command): - device = eos_ssh_send_command(["show_boot", *_set_boot_options_effects()]) - with mock.patch.object(Driver, "reboot") as mock_reboot: +def test_install_os_without_reboot_installs_only(eos_ssh_send_command): + device = eos_ssh_send_command(["show_boot", "dir", ""]) + with mock.patch.object(Driver, "_wait_for_reload") as mock_wait: assert device.install_os(NEW_IMAGE, reboot=False) is True - mock_reboot.assert_not_called() + mock_wait.assert_not_called() + install_call = _install_call(device) + assert install_call[0][0] == INSTALL_COMMAND + assert install_call[1]["read_timeout"] == 300 + + +def test_install_os_accepts_explicit_file_system(eos_ssh_send_command): + # With file_system supplied there is no "dir" probe. + device = eos_ssh_send_command(["show_boot", ""]) + assert device.install_os(NEW_IMAGE, file_system="flash:", reboot=False) is True + assert _install_call(device)[0][0] == INSTALL_COMMAND + + +def test_install_os_raises_command_error_when_install_fails(eos_ssh_send_command): + device = eos_ssh_send_command(["show_boot", "dir", "show_version_json", "% Error: image not found"]) + with mock.patch.object(Driver, "_wait_for_reload") as mock_wait: + with pytest.raises(CommandError): + device.install_os(NEW_IMAGE) + mock_wait.assert_not_called() -def test_install_os_raises_when_image_not_booted_after_reboot(eos_ssh_send_command): - # Device comes back still running the old image. The final side effect feeds +def test_install_os_raises_when_version_unchanged_after_reload(eos_ssh_send_command): + # Device comes back still running the old version. The final side effect feeds # self.hostname, which OSInstallError reads when building its message. - device = eos_ssh_send_command(["show_boot", *_set_boot_options_effects(), "show_boot", "show_hostname_json"]) - with mock.patch.object(Driver, "reboot"): + device = eos_ssh_send_command( + ["show_boot", "dir", "show_version_json", "", "show_version_json", "show_hostname_json"] + ) + with mock.patch.object(Driver, "_wait_for_reload"): with pytest.raises(OSInstallError): device.install_os(NEW_IMAGE) +# --------------------------------------------------------------------------- +# Reload detection used by install_os +# --------------------------------------------------------------------------- + + +@mock.patch("pyntc.devices.eos_ssh_device.time") +def test_wait_for_reload_returns_once_uptime_resets(mock_time, eos_ssh_send_command): + mock_time.time.side_effect = [0, 1, 2, 3] + # Probe 1: still up with the old uptime. Probe 2: box is down. Probe 3: back, uptime reset. + device = eos_ssh_send_command(["show_version_json", OSError("socket closed"), '{"uptime": 30.0}']) + device._wait_for_reload(UPTIME, timeout=900) + assert device.native.send_command.call_count == 3 + assert mock_time.sleep.call_count == 2 + + +@mock.patch("pyntc.devices.eos_ssh_device.time") +def test_wait_for_reload_raises_on_timeout(mock_time, eos_ssh_send_command): + mock_time.time.side_effect = [0, 5, 15] + # The uptime never drops; the trailing side effect feeds self.hostname for the error. + device = eos_ssh_send_command(["show_version_json", "show_hostname_json"]) + with pytest.raises(RebootTimeoutError): + device._wait_for_reload(UPTIME, timeout=10) + + +def test_os_updated_true_when_version_changed(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + assert device._os_updated("4.28.4M") is True + + +def test_os_updated_false_when_version_unchanged(eos_ssh_send_command): + device = eos_ssh_send_command(["show_version_json"]) + assert device._os_updated(OS_VERSION) is False + + # --------------------------------------------------------------------------- # reboot / rollback / save # --------------------------------------------------------------------------- @@ -928,9 +1051,9 @@ def _fixture(name): return json.load(handle) -# Facts derived purely from show output. "vlans" is excluded: EOSDevice sources it from +# Facts derived purely from show output. "vlans" and "os_version" is excluded: EOSDevice sources it from # pyeapi's native.api("vlans"), which has no SSH equivalent by design. -SHARED_FACTS = ["boot_time", "hostname", "fqdn", "model", "os_version", "serial_number", "interfaces", "boot_options"] +SHARED_FACTS = ["boot_time", "hostname", "fqdn", "model", "serial_number", "interfaces", "boot_options"] @pytest.mark.parametrize("fact", SHARED_FACTS) @@ -963,20 +1086,3 @@ def fake_show(command, raw_text=False): def _public_api(cls): return {name for name in dir(cls) if not name.startswith("_")} - - -def test_public_api_matches_eos_device(): - assert _public_api(EOSSSHDevice) - KNOWN_ADDITIONS == _public_api(EOSDevice) - - -def test_no_eos_device_member_is_missing(): - assert _public_api(EOSDevice) - _public_api(EOSSSHDevice) == set() - - -@pytest.mark.parametrize("name", sorted(_public_api(EOSDevice))) -def test_member_parity(name): - eapi_attr = inspect.getattr_static(EOSDevice, name) - ssh_attr = inspect.getattr_static(EOSSSHDevice, name) - assert isinstance(ssh_attr, property) == isinstance(eapi_attr, property), f"{name} kind differs" - if callable(eapi_attr) and not isinstance(eapi_attr, property): - assert inspect.signature(ssh_attr) == inspect.signature(eapi_attr), f"{name} signature differs" From a9515575c067df72b81ba81efece6f031b7f7374 Mon Sep 17 00:00:00 2001 From: jeffkala Date: Mon, 5 Oct 2026 21:59:15 +0000 Subject: [PATCH 5/6] prepare release v3.4.0 --- changes/+eos_ssh_extensions.added | 2 - changes/+main.housekeeping | 1 - changes/423.fixed | 1 - docs/admin/release_notes/version_3.4.md | 23 ++ mkdocs.yml | 1 + poetry.lock | 466 ++++++++++++++---------- pyproject.toml | 4 +- 7 files changed, 290 insertions(+), 208 deletions(-) delete mode 100644 changes/+eos_ssh_extensions.added delete mode 100644 changes/+main.housekeeping delete mode 100644 changes/423.fixed create mode 100644 docs/admin/release_notes/version_3.4.md diff --git a/changes/+eos_ssh_extensions.added b/changes/+eos_ssh_extensions.added deleted file mode 100644 index 09ad5e5a..00000000 --- a/changes/+eos_ssh_extensions.added +++ /dev/null @@ -1,2 +0,0 @@ -Added install_os method for EOSSSHDevice class. -Added maintenance_mode method for EOSSSHDevice class. diff --git a/changes/+main.housekeeping b/changes/+main.housekeeping deleted file mode 100644 index 3433adf6..00000000 --- a/changes/+main.housekeeping +++ /dev/null @@ -1 +0,0 @@ -Rebaked from the cookie `main`. diff --git a/changes/423.fixed b/changes/423.fixed deleted file mode 100644 index 216d8dd1..00000000 --- a/changes/423.fixed +++ /dev/null @@ -1 +0,0 @@ -Fixed `EOSDevice.close()` leaking the Netmiko SSH session opened by the file-transfer methods. diff --git a/docs/admin/release_notes/version_3.4.md b/docs/admin/release_notes/version_3.4.md new file mode 100644 index 00000000..c3a01d5b --- /dev/null +++ b/docs/admin/release_notes/version_3.4.md @@ -0,0 +1,23 @@ +# v3.4 Release Notes + +This document describes all new features and changes in the release. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Release Overview + +- Major features or milestones + + +## [v3.4.0 (2026-10-05)](https://github.com/networktocode/pyntc/releases/tag/v3.4.0) + +### Added + +- Added install_os method for EOSSSHDevice class. +- Added maintenance_mode method for EOSSSHDevice class. + +### Fixed + +- [#423](https://github.com/networktocode/pyntc/issues/423) - Fixed `EOSDevice.close()` leaking the Netmiko SSH session opened by the file-transfer methods. + +### Housekeeping + +- Rebaked from the cookie `main`. diff --git a/mkdocs.yml b/mkdocs.yml index 492fc902..34bd36a5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -132,6 +132,7 @@ nav: - Uninstall: "admin/uninstall.md" - Release Notes: - "admin/release_notes/index.md" + - v3.4: "admin/release_notes/version_3.4.md" - v3.3: "admin/release_notes/version_3.3.md" - v3.2: "admin/release_notes/version_3.2.md" - v3.1: "admin/release_notes/version_3.1.md" diff --git a/poetry.lock b/poetry.lock index c14f2997..cfd057ee 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "astroid" @@ -800,97 +800,97 @@ files = [ [[package]] name = "hypothesis" -version = "6.168.3" +version = "6.168.4" description = "The property-based testing library for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb"}, - {file = "hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c"}, - {file = "hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e"}, - {file = "hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7"}, - {file = "hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d"}, - {file = "hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c"}, - {file = "hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef"}, - {file = "hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef"}, - {file = "hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd"}, - {file = "hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595"}, - {file = "hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11"}, - {file = "hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab"}, - {file = "hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd"}, - {file = "hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef"}, - {file = "hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026"}, - {file = "hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4"}, - {file = "hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24"}, - {file = "hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a"}, - {file = "hypothesis-6.168.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:26084ef31653108da9e5eb171278a1d930e90d8631604391f828219f5c1f6cb2"}, - {file = "hypothesis-6.168.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aac5889ea7b0b86cf2bf5709250b7576c44859dd249d1be23c009a7d6fb9f742"}, - {file = "hypothesis-6.168.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7311d8ff296806ea41513d52edc832fcf43a1dc8d5c5f308cc386fc31b253a05"}, - {file = "hypothesis-6.168.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1872d2dc3f3758dfbf17cc39838b8e6b6a5c39a37811c067245e87b12d50f74"}, - {file = "hypothesis-6.168.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:140fe3cc7ba98a4ffeeb6492c318b43c326110a4d7b4f56bb759d5c264e2dc1c"}, - {file = "hypothesis-6.168.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:558625ff28e415f2f770cad618c2c9e05a045a6586949397f178d537bec2f083"}, - {file = "hypothesis-6.168.3-cp310-cp310-win_amd64.whl", hash = "sha256:836eda971f25ddfce274107ed113297e8acede8d9b267775742b8ba8a404d0f0"}, - {file = "hypothesis-6.168.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9029775dc2e25e3e8e596c4306926f0079158353631c7c52c285bbc5c8073c2b"}, - {file = "hypothesis-6.168.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e1d91530cefdb9e0b6467c203eb509c61a11d70480a37db59cb53a2f9913102"}, - {file = "hypothesis-6.168.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0df00cbe8133aa220308d11fa28e3add996cf5102a39b279c1d711015fd9114b"}, - {file = "hypothesis-6.168.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7f75a2e23288ee82ee965a952473d9c5447c2cbc1afc94be09d2402201774e"}, - {file = "hypothesis-6.168.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc2b1c37631f2231dd0d077225aa5908fc2bd34a4c3faf475053b0bc2eb24c95"}, - {file = "hypothesis-6.168.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73960a58f6efc8cbc57d8ad0647955f7c5b25f3d562e3eaa57afa1c69894f7aa"}, - {file = "hypothesis-6.168.3-cp311-cp311-win_amd64.whl", hash = "sha256:3f1122223759acc0fe5301c1ae505d762ee61af91db7b191a791ccdbcd965462"}, - {file = "hypothesis-6.168.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bff12e036b67abc5ded682f724f4d7afaff5223190270a0763695f218c079068"}, - {file = "hypothesis-6.168.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73c6188056e6dc110260e39d141c69c3d02f23e3ba1d8610c7a8b6f2510ec96"}, - {file = "hypothesis-6.168.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb1063cb794765097b8c0add598bd34a904f44dc15db81275dd1f0ed1ac567ed"}, - {file = "hypothesis-6.168.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b2d47f5d9060b049039bef0b267e88480f6468f95ee394e514e84a81ccdc5f5"}, - {file = "hypothesis-6.168.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8367f623a98cbf90f33fd2a43b100671b152b5975bc343a578290945f52f7018"}, - {file = "hypothesis-6.168.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd81241f4cd76fb18d481e87b9688b30a0cb5c30841730513323ba1c7aba1837"}, - {file = "hypothesis-6.168.3-cp312-cp312-win_amd64.whl", hash = "sha256:377438de53afb94347d9845b7d90db5905c6d2a8d612108e9938e0f80503bb90"}, - {file = "hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7"}, - {file = "hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499"}, - {file = "hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89"}, - {file = "hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0"}, - {file = "hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a"}, - {file = "hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248"}, - {file = "hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766"}, - {file = "hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2"}, - {file = "hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba"}, - {file = "hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897"}, - {file = "hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b"}, - {file = "hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2"}, - {file = "hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86"}, - {file = "hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa"}, - {file = "hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2"}, - {file = "hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074"}, - {file = "hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe"}, - {file = "hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675"}, - {file = "hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4"}, - {file = "hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7"}, - {file = "hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96"}, - {file = "hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a"}, - {file = "hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5"}, - {file = "hypothesis-6.168.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:101531b4ccf7fa12965a6b295d10b64c2f8a8fab61e1f9b18c55335d8fb02575"}, - {file = "hypothesis-6.168.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:41e0120814de5c3a6b58d8cb27cb11b8873ab33fba130cd09855a27f5a12a1ca"}, - {file = "hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d120009d145697909f2f0c532f2d568558ddd6990707f8cba6576bfb9770696"}, - {file = "hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:818b3d09bba60ef90463e47a4bc87275dba1e8aeb184d3102888440ca7c7837d"}, - {file = "hypothesis-6.168.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9e3c36eba80e089ecfa28ec08049723223cb41d3cfe4d9988169cf98440e402b"}, - {file = "hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75"}, + {file = "hypothesis-6.168.4-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:397c2ec80b847fd4f50e249ba6ab0c9ac7aa0e578136b718a060cddc0bbf00eb"}, + {file = "hypothesis-6.168.4-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7c5935004f9b46135fba7a7d376c345714a6968b0a936e6f631af560a66a594b"}, + {file = "hypothesis-6.168.4-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3639efaffa11ad30ff79aaf5793b39c17135cb52f4211d27051c5c63d4bd13a4"}, + {file = "hypothesis-6.168.4-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:460ba530803157649965b93f9954cbfbe302d3ed9432f7537a618de18320a0ff"}, + {file = "hypothesis-6.168.4-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cad5cfc81df8bd02f34c6f9d15afdabc63d095c956694ad8223863c4a6ba918"}, + {file = "hypothesis-6.168.4-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040d304cf8219c6317bffb4d4a2d57a87dd788d8a177ca4866e3193a20a44367"}, + {file = "hypothesis-6.168.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:946b60d3ca6726d6791bb7fcd43d25568fd0ac370945a5efb46b034b2cac19e0"}, + {file = "hypothesis-6.168.4-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:9f8852fdd3dee584f950fdd172f5e437724a3becf884a6ea21ccaec1060d107f"}, + {file = "hypothesis-6.168.4-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8ba1b97877f99c15ff3699c9405fde49ccae827da58d087e80413082e1b2594"}, + {file = "hypothesis-6.168.4-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9117df3e17768daa9b693d0e83b589e047f501d0e5bc8c112b45db6d963ef4fc"}, + {file = "hypothesis-6.168.4-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6bee48f368e50155a1230045eaddefd4c3a6e58f6fa9da7e490b7108085a78d6"}, + {file = "hypothesis-6.168.4-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:a3d935059ed949220e429fbccfcb31368c12b43112b2c034d3236ee2ac6b65a7"}, + {file = "hypothesis-6.168.4-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:14dfa375391975480eee5762e0d52d4919c20512d1d6689e48ff29406d79206b"}, + {file = "hypothesis-6.168.4-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:1d9fc7b6ddbefbe07a905b61b03784cbef44d9db0161afa9c15777b37da6a013"}, + {file = "hypothesis-6.168.4-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a51d71b65e76d3cad9e370aa35e822ae30bf5a5dcf53622f8c9b82fc6c37972f"}, + {file = "hypothesis-6.168.4-cp310-abi3-win32.whl", hash = "sha256:0c63542aee2d1847499f8bf4214b95ff9a12f972b8102e63e181e4043cbb007e"}, + {file = "hypothesis-6.168.4-cp310-abi3-win_amd64.whl", hash = "sha256:5670d5dc6f998a3bef868b9d80721ee3aeb286b3e813f8e54f5a906d79cff25f"}, + {file = "hypothesis-6.168.4-cp310-abi3-win_arm64.whl", hash = "sha256:b3ece73b7a1defa3a524e0aa2e7d03bd12828894a7b44e4610f3446814fac49f"}, + {file = "hypothesis-6.168.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e0f87012e36bc5e82386c1b10a86561e29e9a9b9347b04350c3426ddb4c6e29b"}, + {file = "hypothesis-6.168.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3fcb1a5c55bc9524125e317159be3382165b706dce606ac387b0e09323f2b97c"}, + {file = "hypothesis-6.168.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e9f34a3cc8591fbf3f866bccf71b9dcffd8e0bcb342c6354fe1d0808d3fc661"}, + {file = "hypothesis-6.168.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c291e43143ce775c1ada6c74328bd0a722b13fd5093ffe692b27f490883674cb"}, + {file = "hypothesis-6.168.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:148b8fb0677ad78e96fe077d9edbaa69f4d560c304f3a31144acc25ab788ef3f"}, + {file = "hypothesis-6.168.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0976d6935846147640ada931105cd18e7372142a54d474c3911db28a053d5846"}, + {file = "hypothesis-6.168.4-cp310-cp310-win_amd64.whl", hash = "sha256:ad021206064635a59e067e653e1d9697fdef766456607da849e29a4739f402ba"}, + {file = "hypothesis-6.168.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b7eb4da108cf584fe73b5f3341696dc287305312db1236f4df6b18374b4bf2a4"}, + {file = "hypothesis-6.168.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf97817588222524a83bae2ea18d1ef016b4598afa5e113b88c48c3b4dd67743"}, + {file = "hypothesis-6.168.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad94699869748f3aab46cd713282f40dc90cdec050100c26ca730ef69bea863c"}, + {file = "hypothesis-6.168.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73b4a72ad8a8ac5af01be4e6fa4e0fc6d319fafecc0f1050df9eaa7762c3ebe7"}, + {file = "hypothesis-6.168.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:83a463a9312838cbff4b9ea12d61c5795e304ca987e02d4d122f3037b52d9d14"}, + {file = "hypothesis-6.168.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5b84cc9bb448005b2a98c6c82da831929f2eead8ec5ff127e13a75ead009eb7a"}, + {file = "hypothesis-6.168.4-cp311-cp311-win_amd64.whl", hash = "sha256:f68b3d9e0e5a1309ced5847d6d925cbe1d418cdb0bda646bb67ae9966d160160"}, + {file = "hypothesis-6.168.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4d2589861e66b97cbc8f0ed90d45fab39d89e4bb6b25334a865e0de801ac8d77"}, + {file = "hypothesis-6.168.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0b523ef433a752822b83a1d454ae1351de8345d47348d2cf5f2b301b599bbad4"}, + {file = "hypothesis-6.168.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aff1ebfdf21d4acd5805602b73292c5cac99d1c9a45b18121802711b7dcca9b6"}, + {file = "hypothesis-6.168.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e7165b1ce545a261276087b3b761156376907866e4f2e5605ce7947f39e9b75"}, + {file = "hypothesis-6.168.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96e5cad6c7ed2babf1bf41bdf67474f3da203e1b5e5d357d9fc20c5101a6c02f"}, + {file = "hypothesis-6.168.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b127bd1b36b48f20bc4e3556ff1c17a19350181d5350c202d3d9f779c579461"}, + {file = "hypothesis-6.168.4-cp312-cp312-win_amd64.whl", hash = "sha256:83b89884e2d5e349578a660d006714a677a738f6ed85993d7f40420df07f8ace"}, + {file = "hypothesis-6.168.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:29ce52e7d59afeddca0856548f60e99003873d7289581efc8faa127b332c6653"}, + {file = "hypothesis-6.168.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:296a1dfd1c3a3c223234da55509bdbcfd057eb8416a2e42775035f8e5c60bfe7"}, + {file = "hypothesis-6.168.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17fce531c5812f4a1eee8915e1d48e2ac9b7ee67a0715b35c346557b16b14749"}, + {file = "hypothesis-6.168.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50d43c132ece8712c50ba20a2103b27693bdb098d0b3d2b451c3bf6837476655"}, + {file = "hypothesis-6.168.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:10371810168054ceadaf34af164e8a2177050340b6309f32dd83a609ef12380d"}, + {file = "hypothesis-6.168.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cdeddb81bb7342980758ac473fbc3a67048c4aef410bd3a7ec6ba592dc5a4207"}, + {file = "hypothesis-6.168.4-cp313-cp313-win_amd64.whl", hash = "sha256:a0ae9080c9ca90c6a40fb98229729cbb5b97009c323da0c4b2032957b94a15db"}, + {file = "hypothesis-6.168.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:053f19ba197044d771e3486c7f4eca2ef0b709c76b748a24a540e3c52a056ac9"}, + {file = "hypothesis-6.168.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5df643acbe059e576778b579052d53a51e08be072d80b1bc207dff1519dcc73a"}, + {file = "hypothesis-6.168.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:525de469919b0480715411db8f0595fff808ad90d278e95a01464bba64f665a1"}, + {file = "hypothesis-6.168.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9845ff08f422114883e2b85cceab16b65c4e6bc5a50667cd34b105959aa4fff1"}, + {file = "hypothesis-6.168.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5e16803b0ef3d45ec5f1821802b18fcddd1d67c6f355dcb3e1e3ffade23c1e"}, + {file = "hypothesis-6.168.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db367ead803f356f140bb5765e479b26160e92b70f580d684b5966c2bf6b7ea3"}, + {file = "hypothesis-6.168.4-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:289117c492a43972fd4fdfb69d4c65da8215a6b6ab8507db07364e216cb682b3"}, + {file = "hypothesis-6.168.4-cp314-cp314-win_amd64.whl", hash = "sha256:1ea459c1b523f97090fa6764ae6a3c7a0b275cb9e3661da1673b3476767a2151"}, + {file = "hypothesis-6.168.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ce0e423f457b6a7916b5ded5321a7ffbdc888c95e644c9634f2801d70a4faaea"}, + {file = "hypothesis-6.168.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:209e63b25d0e002c1558567b030817de78873d9bf4a410d112965bd0182af9b3"}, + {file = "hypothesis-6.168.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ba9299a01d283feabb64ba7ec4321f3ac762df2ce784a665826ea49409952"}, + {file = "hypothesis-6.168.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0453cca80239dfc0a747ee03e2ffcf462944cea9fc86c83d123b29ea51dfdfa1"}, + {file = "hypothesis-6.168.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35de88b1b8a3e45300b6eda5286de04e6d68779f5094448ec3ed2fd685cfe"}, + {file = "hypothesis-6.168.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:252845ab5d5e4c8d23bdfb2287d7b16c0a1df697f5da21cbea2c9ab87c53b27f"}, + {file = "hypothesis-6.168.4-cp314-cp314t-win_amd64.whl", hash = "sha256:50da9a9fa14684aa2164bd1dc5ee9b4a557fe140e499aa8ad8f96896798bbed8"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:579083aff67a2ba13cf84b5315e366a9dc018eb6f3171ce2a2125eb1d35e4902"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:3e9b7ecfec9cac3f749374a2e8d4b9de46a24ed1de25b437b242e5e39a481965"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28f1f75d08e9dd07c2ee18c9119c9ecaf43ad23e12b369620d3e8f541e37e602"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02882475be65f5d25f60d9b3a70a113907d02cba520460608f032794054a39a5"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a98f95f6159e5a244b17552d34930272b92cb4ca3f2af9cf5ca4d540ee141076"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0f6fe61bf35dbfb8698a217680a5d1504e778841ce5c9e703d8a0ba6acaeaff"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a57720cbc084d589abb4626eac048c41bb318e3941589f1c7813dc9f7ccdb705"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:0b30d77fe614e904468f1a94553f011d62ac75c927529cad4f7b7fc962e5d731"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9b9f6fdf1f3e38eb49e6e17ac7ba116d2a5d3c2498f48bf7920774772b11d25"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:1b374eb3f2af2395186ffc078aae9aca1ef6f30e3b06afa4ab7c51860395b0c9"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:91f9b5fc94c23f280d461d53bfb67c02848e7dca3a242de8de932c5242edda35"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:378913d16968b8a66f5544e10c1c472ef3fef8bd1929cd4c190f17e24d04742b"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:39d3c73ebfa31789d564514d8016bba920136051a85a55e5cc27cc2417c1d4be"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2b89a7a8a664b3bbf500e56e4c12bd489f254689571bc94f8bad09c4d2481de5"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:89a6d31335a506466a243b3a25512ebd52614c49a6d9ecd4a406f8b74f8926e6"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-win32.whl", hash = "sha256:3f0beea465731d17b222dae8a9c8a478161d0ba2ac57cf90df1105dd2b6e450c"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:d6b1a8f2b18377c63f5dc089ad3991be7ec16149b9743d6e269f1a457c1b8f47"}, + {file = "hypothesis-6.168.4-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:4310ff1e1924805a44dbe30fb1f9c401c2634b3138d1e88ebd65f5eb6c683e2e"}, + {file = "hypothesis-6.168.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:45bbf832cae82211cae53c01c19c33728b3521db1ec96e16c0a3557b5137783a"}, + {file = "hypothesis-6.168.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f867f9dac2cb90e5f486143124181e7bea3f24b043fbe4d0d9de79e743911a2f"}, + {file = "hypothesis-6.168.4-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85c0c9b1da200e954c804c916b05a5a6d62156cc1cc0b368692d91f931c43d0b"}, + {file = "hypothesis-6.168.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5deb912750c74131a2d94a5299309848c400439eb7aec6baea6b4a025cbc75f3"}, + {file = "hypothesis-6.168.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:43935f1404f71e76c04ef7837cb717777cdf70104e6d7441f09d49befbecd44a"}, + {file = "hypothesis-6.168.4.tar.gz", hash = "sha256:fb72038b41d026878f484491ed9c2670e89daee898d276b92129da2cfe4a3846"}, ] [package.dependencies] @@ -898,10 +898,10 @@ exceptiongroup = {version = ">=1.0.0", markers = "python_full_version < \"3.11.0 sortedcontainers = ">=2.1.0,<3.0.0" [package.extras] -all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.110)", "django (>=5.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.30)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2026.4) ; sys_platform == \"emscripten\" or sys_platform == \"win32\"", "watchdog (>=4.0.0)"] +all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.111)", "django (>=5.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.30)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2026.5) ; sys_platform == \"emscripten\" or sys_platform == \"win32\"", "watchdog (>=4.0.0)"] cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"] codemods = ["libcst (>=0.3.16)"] -crosshair = ["crosshair-tool (>=0.0.110)", "hypothesis-crosshair (>=0.0.30)"] +crosshair = ["crosshair-tool (>=0.0.111)", "hypothesis-crosshair (>=0.0.30)"] dateutil = ["python-dateutil (>=1.4)"] django = ["django (>=5.2)"] dpcontracts = ["dpcontracts (>=0.4)"] @@ -913,7 +913,7 @@ pytest = ["pytest (>=4.6)"] pytz = ["pytz (>=2014.1)"] redis = ["redis (>=3.0.0)"] watchdog = ["watchdog (>=4.0.0)"] -zoneinfo = ["tzdata (>=2026.4) ; sys_platform == \"emscripten\" or sys_platform == \"win32\""] +zoneinfo = ["tzdata (>=2026.5) ; sys_platform == \"emscripten\" or sys_platform == \"win32\""] [[package]] name = "idna" @@ -1297,101 +1297,163 @@ markdown = ">=3.3.7,<4.0.0" [[package]] name = "markupsafe" -version = "3.0.3" +version = "3.0.4" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" groups = ["main", "dev", "docs"] files = [ - {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, - {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, + {file = "markupsafe-3.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889"}, + {file = "markupsafe-3.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a"}, + {file = "markupsafe-3.0.4-cp310-cp310-win32.whl", hash = "sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278"}, + {file = "markupsafe-3.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7"}, + {file = "markupsafe-3.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf"}, + {file = "markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346"}, + {file = "markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7"}, + {file = "markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5"}, + {file = "markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3"}, + {file = "markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e"}, + {file = "markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6"}, + {file = "markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e"}, + {file = "markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237"}, + {file = "markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7"}, + {file = "markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9"}, + {file = "markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1"}, + {file = "markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1"}, + {file = "markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96"}, + {file = "markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148"}, + {file = "markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e"}, + {file = "markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248"}, + {file = "markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17"}, + {file = "markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0"}, + {file = "markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5"}, + {file = "markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc"}, + {file = "markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed"}, + {file = "markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59"}, + {file = "markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453"}, + {file = "markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b"}, + {file = "markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6"}, + {file = "markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634"}, + {file = "markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4"}, + {file = "markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c"}, + {file = "markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe"}, + {file = "markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a"}, + {file = "markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2"}, + {file = "markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6"}, + {file = "markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691"}, + {file = "markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464"}, + {file = "markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c"}, + {file = "markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65"}, + {file = "markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163"}, + {file = "markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92"}, + {file = "markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a"}, + {file = "markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429"}, + {file = "markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8"}, + {file = "markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741"}, + {file = "markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b"}, + {file = "markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214"}, + {file = "markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67"}, + {file = "markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad"}, + {file = "markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894"}, + {file = "markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78"}, + {file = "markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c"}, + {file = "markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba"}, + {file = "markupsafe-3.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f291bcf42ae98eb5107edb162c3c998b4a89648fd8e99ed4cbd12705292788cd"}, + {file = "markupsafe-3.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ac0c7c9f1609b0c4c114feb1d7a3409564c7fb77e360bed9e97e5d25dfeaf868"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6768d67d1bce64270e0fdc2e69309d68b9b18ae56ddf6c711d168e9d051c2cac"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:14bd2d845d62ab678eaf81da89d7b621b51756c72346745c1a594c09d49207a2"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e8b3d0b18fd623afa12ecb2ce8d8becef69f9b5440c6330c7972200e0bb84b0"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57f9947a7e57a081c1e3e0a2dd0d2dcf290a4531450e6f611e30084c222a7295"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b61687d0828e72bf5cda24a2690188f37170bd31c9359ac97e4e66569f120a16"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0cee7cb0f9a1b6892ea482237d9403b3d1b4603aee057d0ff01f0fac2d019a97"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:94e4c421742086aeee4c32a506eec8859d7634aad943f7e6aacf70f813478768"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9240187afb63d2f9ddc3e032c670356fe941f6e20662ea168a5dc3f1f317e1b3"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e841068dc0be4cb6dfb5c890eb88cbdcff2f4a332393c7ec94e8e618bd32c1a8"}, + {file = "markupsafe-3.0.4-cp39-cp39-win32.whl", hash = "sha256:f61efe1d2fe0de16158a5fe1d1cf3c14bdb6aecd54d8938fd26512c525c1f624"}, + {file = "markupsafe-3.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:2b2b1e18af909b448bb3cf9e3433366f7a8726271fc214e8b10e0f62a78c724b"}, + {file = "markupsafe-3.0.4-cp39-cp39-win_arm64.whl", hash = "sha256:6669c1bf34080161ce49c589cc512ef24d4c704ac9d2b2d3667f519c60418378"}, + {file = "markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6"}, ] [[package]] @@ -1819,14 +1881,14 @@ re2 = ["google-re2 (>=1.1)"] [[package]] name = "platformdirs" -version = "4.12.2" +version = "4.12.3" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["dev", "docs"] files = [ - {file = "platformdirs-4.12.2-py3-none-any.whl", hash = "sha256:29dbf06d96c500bc6bdbce75fb0a14d63279c93b1842f97e72a135b33e856983"}, - {file = "platformdirs-4.12.2.tar.gz", hash = "sha256:eab5f70271a490ef74618bb314fbb86e3c7e82fa3b9c922c2ea0e0a1a155d329"}, + {file = "platformdirs-4.12.3-py3-none-any.whl", hash = "sha256:080f3b39423b5abfca9a23d84c4e9795f54d395cd8459867a8ded44084fcd5f8"}, + {file = "platformdirs-4.12.3.tar.gz", hash = "sha256:427fc0bb321ae0c5b037fa03238ca74820437be162e78b4848c4d4055b9b766c"}, ] [[package]] @@ -2210,30 +2272,30 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.16.9" +version = "0.16.10" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96"}, - {file = "ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67"}, - {file = "ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452"}, - {file = "ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e"}, - {file = "ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7"}, - {file = "ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee"}, - {file = "ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588"}, - {file = "ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385"}, - {file = "ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b"}, - {file = "ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284"}, - {file = "ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161"}, + {file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"}, + {file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"}, + {file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"}, + {file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"}, + {file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"}, + {file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"}, + {file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 2e91981a..6720b316 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyntc" -version = "3.3.1a0" +version = "3.4.0" description = "Python library focused on tasks related to device level and OS management." authors = ["Network to Code, LLC "] readme = "README.md" @@ -172,7 +172,7 @@ addopts = "-vv --doctest-modules -p no:warnings -p no:f5sdk_fixtures --ignore-gl [tool.towncrier] package = "pyntc" directory = "changes" -filename = "docs/admin/release_notes/version_3.3.md" +filename = "docs/admin/release_notes/version_3.4.md" template = "development/towncrier_template.j2" start_string = "" issue_format = "[#{issue}](https://github.com/networktocode/pyntc/issues/{issue})" From a8fa7f4424f4ad72d3b592c265199f7d49072c53 Mon Sep 17 00:00:00 2001 From: Jeff Kala <48843785+jeffkala@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:04:15 -0600 Subject: [PATCH 6/6] Apply suggestion from @jeffkala --- docs/admin/release_notes/version_3.4.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/admin/release_notes/version_3.4.md b/docs/admin/release_notes/version_3.4.md index c3a01d5b..020dd2db 100644 --- a/docs/admin/release_notes/version_3.4.md +++ b/docs/admin/release_notes/version_3.4.md @@ -4,7 +4,8 @@ This document describes all new features and changes in the release. The format ## Release Overview -- Major features or milestones +- Added install_os method override for the SSH Arista driver. +- Add a method to check for Arista maintenance_mode. ## [v3.4.0 (2026-10-05)](https://github.com/networktocode/pyntc/releases/tag/v3.4.0)