diff --git a/.coverage b/.coverage
deleted file mode 100644
index 73bb5ae1..00000000
Binary files a/.coverage and /dev/null differ
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 616f347a..deac9726 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -3,4 +3,26 @@ updates:
- package-ecosystem: "uv"
directory: "/"
schedule:
- interval: "daily"
\ No newline at end of file
+ interval: "daily"
+ groups:
+ all-deps:
+ patterns:
+ - "*"
+ - package-ecosystem: "uv"
+ directory: "/"
+ target-branch: "beta"
+ schedule:
+ interval: "daily"
+ groups:
+ all-deps:
+ patterns:
+ - "*"
+ - package-ecosystem: "uv"
+ directory: "/"
+ target-branch: "httpx"
+ schedule:
+ interval: "daily"
+ groups:
+ all-deps:
+ patterns:
+ - "*"
\ No newline at end of file
diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
new file mode 100644
index 00000000..26c511b1
--- /dev/null
+++ b/.github/workflows/create-release.yml
@@ -0,0 +1,104 @@
+name: Create GitHub Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: 'Branch to release from'
+ required: true
+ default: 'beta'
+ workflow_run:
+ workflows: ['Test PR']
+ types: [completed]
+ branches: ['main', 'beta', 'httpx']
+
+# The release is created with a GitHub App token. This workflow no longer
+# commits anything back to the branch (the changelog is rendered in the
+# regeneration PR), so the default token only needs read access for checkout.
+permissions:
+ contents: read
+
+jobs:
+ create-release:
+ if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.branch || github.event.workflow_run.head_branch }}
+
+ - name: Generate app token
+ id: app-token
+ uses: actions/create-github-app-token@v3
+ with:
+ app-id: ${{ secrets.RELEASE_APP_ID }}
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+
+ - name: Read version
+ id: version
+ run: |
+ VERSION=$(grep '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ if [[ "$VERSION" == *"b"* || "$VERSION" == *"rc"* || "$VERSION" == *"a"* ]]; then
+ echo "prerelease=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "prerelease=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Check if release already exists
+ id: check-release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ if gh release view "$VERSION" > /dev/null 2>&1; then
+ echo "Release $VERSION already exists, skipping."
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Validate version matches branch
+ if: steps.check-release.outputs.exists == 'false'
+ run: |
+ BRANCH="${{ inputs.branch || github.event.workflow_run.head_branch }}"
+ VERSION="${{ steps.version.outputs.version }}"
+ PRERELEASE="${{ steps.version.outputs.prerelease }}"
+
+ if [[ "$BRANCH" == "beta" && "$PRERELEASE" == "false" ]]; then
+ echo "::error::Beta branch must have a prerelease version (e.g. 3.1.0b0), got '$VERSION'"
+ exit 1
+ fi
+
+ if [[ "$BRANCH" == "httpx" && "$PRERELEASE" == "false" ]]; then
+ echo "::error::httpx branch must have a prerelease version (e.g. 4.0.0b1), got '$VERSION'"
+ exit 1
+ fi
+
+ if [[ "$BRANCH" == "main" && "$PRERELEASE" == "true" ]]; then
+ echo "::error::Main branch must not have a prerelease version, got '$VERSION'"
+ exit 1
+ fi
+
+ # CHANGELOG.md is rendered from changelog.d/ fragments inside the
+ # regeneration PR (see regenerate-library.yml), so it is already on the
+ # branch by the time the release is cut. Committing/pushing here would
+ # bypass the branch's required status checks and be rejected by the
+ # ruleset, so no git write happens in this workflow. --generate-notes
+ # below produces the GitHub release body.
+ - name: Create release
+ if: steps.check-release.outputs.exists == 'false'
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ FLAGS=""
+ if [ "${{ steps.version.outputs.prerelease }}" = "true" ]; then
+ FLAGS="--prerelease"
+ fi
+
+ gh release create "${{ steps.version.outputs.version }}" \
+ --target "${{ inputs.branch || github.event.workflow_run.head_branch }}" \
+ --title "${{ steps.version.outputs.version }}" \
+ --generate-notes \
+ $FLAGS
diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml
new file mode 100644
index 00000000..f9154948
--- /dev/null
+++ b/.github/workflows/dependabot-auto-merge.yml
@@ -0,0 +1,28 @@
+name: Auto-merge Dependabot PRs
+on: pull_request_target
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ auto-merge:
+ if: github.actor == 'dependabot[bot]'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Fetch Dependabot metadata
+ id: meta
+ uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ # Only auto-merge patch/minor bumps and dev-dependency updates. Major
+ # bumps and direct production-dependency changes still require human review.
+ - name: Enable auto-merge for safe updates
+ if: >-
+ steps.meta.outputs.update-type == 'version-update:semver-patch' ||
+ steps.meta.outputs.update-type == 'version-update:semver-minor' ||
+ steps.meta.outputs.dependency-type == 'direct:development'
+ run: gh pr merge --squash --auto "$PR_URL"
+ env:
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/enable-early-access.yml b/.github/workflows/enable-early-access.yml
new file mode 100644
index 00000000..d5fc84c6
--- /dev/null
+++ b/.github/workflows/enable-early-access.yml
@@ -0,0 +1,36 @@
+name: Enable early access features
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '0 18 * * 3'
+
+jobs:
+ enable-early-access:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+
+ - name: Set up Python
+ run: uv python install 3.13
+
+ - name: Install dependencies
+ run: uv sync --python 3.13
+
+ - name: Enable all early access features
+ env:
+ MERAKI_DASHBOARD_API_KEY: ${{ secrets.TEST_ORG_API_KEY }}
+ EA_ORG_0: ${{ secrets.TEST_ORG_BETA_00_ID }}
+ EA_ORG_1: ${{ secrets.TEST_ORG_BETA_01_ID }}
+ EA_ORG_2: ${{ secrets.TEST_ORG_BETA_02_ID }}
+ EA_ORG_3: ${{ secrets.TEST_ORG_BETA_03_ID }}
+ EA_ORG_4: ${{ secrets.TEST_ORG_DEV_00_ID }}
+ EA_ORG_5: ${{ secrets.TEST_ORG_DEV_01_ID }}
+ EA_ORG_6: ${{ secrets.TEST_ORG_DEV_02_ID }}
+ EA_ORG_7: ${{ secrets.TEST_ORG_DEV_03_ID }}
+ run: uv run python examples/ci/enable_all_early_access.py
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index ea06e101..fabf50c7 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -2,6 +2,11 @@ name: Publish release to PyPI
on:
release:
types: [created]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Release tag to publish (e.g. 3.1.0b0)'
+ required: true
permissions:
id-token: write
@@ -11,9 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.tag || github.event.release.tag_name }}
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v7
with:
enable-cache: true
diff --git a/.github/workflows/regenerate-library.yml b/.github/workflows/regenerate-library.yml
index 7a88dddb..09cf332d 100644
--- a/.github/workflows/regenerate-library.yml
+++ b/.github/workflows/regenerate-library.yml
@@ -8,32 +8,95 @@ on:
api_version:
description: 'The corresponding version of the API'
required: true
+ release_stage:
+ description: 'Release stage'
+ required: true
+ type: choice
+ options:
+ - ga
+ - beta
+ - dev
+
+# Writes (commit, PR, auto-merge) are performed with a GitHub App token, not
+# GITHUB_TOKEN, so the default token only needs read access for checkout.
+permissions:
+ contents: read
jobs:
- build:
+ regenerate:
runs-on: ubuntu-latest
steps:
+ - name: Resolve branches
+ id: branches
+ run: |
+ case "${{ github.event.inputs.release_stage }}" in
+ dev)
+ echo "checkout_branch=httpx" >> "$GITHUB_OUTPUT"
+ echo "release_branch=httpx-release" >> "$GITHUB_OUTPUT"
+ echo "pr_base=httpx" >> "$GITHUB_OUTPUT"
+ ;;
+ beta)
+ echo "checkout_branch=beta" >> "$GITHUB_OUTPUT"
+ echo "release_branch=beta-release" >> "$GITHUB_OUTPUT"
+ echo "pr_base=beta" >> "$GITHUB_OUTPUT"
+ ;;
+ ga)
+ echo "checkout_branch=main" >> "$GITHUB_OUTPUT"
+ echo "release_branch=release" >> "$GITHUB_OUTPUT"
+ echo "pr_base=main" >> "$GITHUB_OUTPUT"
+ ;;
+ esac
+
+ # Generate the App token before checkout so checkout persists *it* (not the
+ # read-only GITHUB_TOKEN) as the git credential used for the later push.
+ - name: Generate app token
+ id: app-token
+ uses: actions/create-github-app-token@v3
+ with:
+ app-id: ${{ secrets.RELEASE_APP_ID }}
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+
- uses: actions/checkout@v6
+ with:
+ ref: ${{ steps.branches.outputs.checkout_branch }}
+ # Persist the App token (write access) so EndBug/add-and-commit can push.
+ # add-and-commit does NOT set up push auth from its github_token input —
+ # it relies on whatever credential checkout wrote to git config.
+ token: ${{ steps.app-token.outputs.token }}
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
- run: uv python install 3.12
+ run: uv python install 3.13
- name: Install dependencies
run: uv sync --group generator
- - name: Delete folder
- run: rm -rf meraki
+ # Preserve the checked-out non-generated sources, then wipe meraki/ so the
+ # generator only regenerates the API modules. The generator reads the
+ # preserved copy from disk (MERAKI_SOURCE_DIR) instead of fetching them
+ # from raw.githubusercontent, which rate-limits (429s) the shared runner IP.
+ - name: Stash non-generated sources
+ run: |
+ cp -r meraki "$RUNNER_TEMP/meraki-src"
+ rm -rf meraki
- name: Regenerate Python Library
env:
LIBRARY_VERSION: ${{ github.event.inputs.library_version }}
API_VERSION: ${{ github.event.inputs.api_version }}
- run: uv run python generator/generate_library.py -g true -v "$LIBRARY_VERSION" -a "$API_VERSION"
+ MERAKI_SOURCE_DIR: ${{ runner.temp }}/meraki-src
+ MERAKI_DASHBOARD_API_KEY: ${{ (github.event.inputs.release_stage == 'beta' || github.event.inputs.release_stage == 'dev') && secrets.TEST_ORG_API_KEY || '' }}
+ BETA_ORG_ID: ${{ (github.event.inputs.release_stage == 'beta' || github.event.inputs.release_stage == 'dev') && secrets.TEST_ORG_BETA_00_ID || '' }}
+ run: |
+ ARGS="-g true -l -v $LIBRARY_VERSION -a $API_VERSION"
+ if [ -n "$BETA_ORG_ID" ]; then
+ ARGS="$ARGS -o $BETA_ORG_ID"
+ fi
+ uv run python generator/generate_library.py $ARGS
- name: Set new version
env:
@@ -42,10 +105,41 @@ jobs:
sed -i "s/^version = \".*\"/version = \"$LIBRARY_VERSION\"/" pyproject.toml
uv lock
+ # Render changelog.d/ fragments into CHANGELOG.md here so the update rides
+ # the regeneration PR (and its required checks) instead of being pushed
+ # straight to a protected branch later. towncrier is in the dev group, so
+ # `uv sync --group generator` (default-groups = dev) already installed it.
+ - name: Build changelog
+ env:
+ LIBRARY_VERSION: ${{ github.event.inputs.library_version }}
+ run: |
+ if ls changelog.d/*.md >/dev/null 2>&1; then
+ uv run towncrier build --yes --version "$LIBRARY_VERSION"
+ else
+ echo "No changelog fragments; skipping towncrier build."
+ fi
+
- name: Commit changes to new branch
uses: EndBug/add-and-commit@v10
with:
author_name: GitHub Action
author_email: support@meraki.com
message: Auto-generated library v${{ github.event.inputs.library_version }} for API v${{ github.event.inputs.api_version }}.
- new_branch: release
+ new_branch: ${{ steps.branches.outputs.release_branch }}
+
+ - name: Create pull request
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ gh pr create \
+ --base "${{ steps.branches.outputs.pr_base }}" \
+ --head "${{ steps.branches.outputs.release_branch }}" \
+ --title "Release v${{ github.event.inputs.library_version }} (API v${{ github.event.inputs.api_version }})" \
+ --body "Auto-generated library v${{ github.event.inputs.library_version }} for API v${{ github.event.inputs.api_version }}."
+
+ - name: Enable auto-merge
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ gh pr merge "${{ steps.branches.outputs.release_branch }}" \
+ --auto --squash
diff --git a/.github/workflows/test-library-generator.yml b/.github/workflows/test-library-generator.yml
index 3fb47443..43242b38 100644
--- a/.github/workflows/test-library-generator.yml
+++ b/.github/workflows/test-library-generator.yml
@@ -17,6 +17,9 @@ on:
- 'uv.lock'
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
lint:
runs-on: ubuntu-latest
@@ -25,7 +28,7 @@ jobs:
- uses: actions/checkout@v6
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v7
with:
enable-cache: true
@@ -35,10 +38,10 @@ jobs:
- name: Install dependencies
run: uv sync --python 3.13
- - name: Lint with flake8
+ - name: Lint with ruff
run: |
- uv run flake8 ./generator/generate_library.py --count --select=E9,F63,F7,F82 --ignore=F405,W391,W291,C901,E501,E303,W293 --show-source --statistics
- uv run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+ uv run ruff check generator/
+ uv run ruff format --check generator/
test:
runs-on: ubuntu-latest
@@ -47,7 +50,7 @@ jobs:
- uses: actions/checkout@v6
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v7
with:
enable-cache: true
diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml
index abefc06a..0b5a0e97 100644
--- a/.github/workflows/test-library.yml
+++ b/.github/workflows/test-library.yml
@@ -1,24 +1,12 @@
name: Test Python library
on:
- push:
- branches: ['main', 'release', 'dev_rest_session']
- paths:
- - 'meraki/**'
- - 'tests/unit/**'
- - 'tests/integration/**'
- - 'pyproject.toml'
- - 'uv.lock'
- pull_request:
- branches: ['main', 'release', 'dev_rest_session']
- paths:
- - 'meraki/**'
- - 'tests/unit/**'
- - 'tests/integration/**'
- - 'pyproject.toml'
- - 'uv.lock'
+ workflow_call:
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
lint:
runs-on: ubuntu-latest
@@ -26,7 +14,7 @@ jobs:
- uses: actions/checkout@v6
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v7
with:
enable-cache: true
@@ -36,15 +24,15 @@ jobs:
- name: Install dependencies
run: uv sync --python 3.13
- - name: Lint with flake8
+ - name: Lint with ruff
run: |
- uv run flake8 . --count --select=E9,F63,F7,F82 --ignore=F405,W391,W291,C901,E501,E303,W293 --exclude=examples,generator,.venv --show-source --statistics
- uv run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --exclude=examples,generator,.venv --statistics
+ uv run ruff check meraki/ tests/unit/ tests/integration/ tests/benchmarks/
+ uv run ruff format --check meraki/ tests/unit/ tests/integration/ tests/benchmarks/
unit-test:
runs-on: ubuntu-latest
strategy:
- fail-fast: false
+ fail-fast: true
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
@@ -52,7 +40,7 @@ jobs:
- uses: actions/checkout@v6
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v7
with:
enable-cache: true
@@ -65,11 +53,29 @@ jobs:
- name: Unit tests with coverage
run: uv run pytest tests/unit --cov --cov-report=term-missing --cov-fail-under=90
+ assign-orgs:
+ runs-on: ubuntu-latest
+ outputs:
+ assignments: ${{ steps.shuffle.outputs.assignments }}
+ steps:
+ - name: Shuffle org assignments
+ id: shuffle
+ run: |
+ python3 -c "
+ import json, random
+ versions = ['3.11', '3.12', '3.13', '3.14']
+ indices = list(range(4))
+ random.shuffle(indices)
+ assignments = dict(zip(versions, indices))
+ print(f'assignments={json.dumps(assignments)}')
+ " >> "$GITHUB_OUTPUT"
+
integration-test:
+ needs: [assign-orgs]
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
strategy:
- fail-fast: false
+ fail-fast: true
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
@@ -77,7 +83,7 @@ jobs:
- uses: actions/checkout@v6
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v7
with:
enable-cache: true
@@ -88,4 +94,117 @@ jobs:
run: uv sync --python ${{ matrix.python-version }}
- name: Integration tests
- run: uv run pytest tests/integration --apikey ${{ secrets.TEST_ORG_API_KEY }} --o ${{ secrets.TEST_ORG_ID }}
+ env:
+ ORG_GA_0: ${{ secrets.TEST_ORG_GA_00_ID }}
+ ORG_GA_1: ${{ secrets.TEST_ORG_GA_01_ID }}
+ ORG_GA_2: ${{ secrets.TEST_ORG_GA_02_ID }}
+ ORG_GA_3: ${{ secrets.TEST_ORG_GA_03_ID }}
+ ORG_BETA_0: ${{ secrets.TEST_ORG_BETA_00_ID }}
+ ORG_BETA_1: ${{ secrets.TEST_ORG_BETA_01_ID }}
+ ORG_BETA_2: ${{ secrets.TEST_ORG_BETA_02_ID }}
+ ORG_BETA_3: ${{ secrets.TEST_ORG_BETA_03_ID }}
+ ORG_DEV_0: ${{ secrets.TEST_ORG_DEV_00_ID }}
+ ORG_DEV_1: ${{ secrets.TEST_ORG_DEV_01_ID }}
+ ORG_DEV_2: ${{ secrets.TEST_ORG_DEV_02_ID }}
+ ORG_DEV_3: ${{ secrets.TEST_ORG_DEV_03_ID }}
+ run: |
+ BRANCH="${{ github.head_ref || github.ref_name }}"
+ BASE="${{ github.base_ref }}"
+ case "$BRANCH" in
+ main|release) PREFIX="GA" ;;
+ beta|beta-release) PREFIX="BETA" ;;
+ httpx|httpx-release) PREFIX="DEV" ;;
+ *)
+ case "$BASE" in
+ main) PREFIX="GA" ;;
+ beta) PREFIX="BETA" ;;
+ httpx) PREFIX="DEV" ;;
+ *) echo "Unknown branch: $BRANCH (base: $BASE)" && exit 1 ;;
+ esac
+ ;;
+ esac
+ INDEX=$(echo '${{ needs.assign-orgs.outputs.assignments }}' | jq -r '.["${{ matrix.python-version }}"]')
+ VAR_NAME="ORG_${PREFIX}_${INDEX}"
+ ORG_ID="${!VAR_NAME}"
+ uv run pytest tests/integration -v --tb=short --apikey ${{ secrets.TEST_ORG_API_KEY }} --o "$ORG_ID"
+
+ - name: Validate against baseline
+ env:
+ ORG_GA_0: ${{ secrets.TEST_ORG_GA_00_ID }}
+ ORG_GA_1: ${{ secrets.TEST_ORG_GA_01_ID }}
+ ORG_GA_2: ${{ secrets.TEST_ORG_GA_02_ID }}
+ ORG_GA_3: ${{ secrets.TEST_ORG_GA_03_ID }}
+ ORG_BETA_0: ${{ secrets.TEST_ORG_BETA_00_ID }}
+ ORG_BETA_1: ${{ secrets.TEST_ORG_BETA_01_ID }}
+ ORG_BETA_2: ${{ secrets.TEST_ORG_BETA_02_ID }}
+ ORG_BETA_3: ${{ secrets.TEST_ORG_BETA_03_ID }}
+ ORG_DEV_0: ${{ secrets.TEST_ORG_DEV_00_ID }}
+ ORG_DEV_1: ${{ secrets.TEST_ORG_DEV_01_ID }}
+ ORG_DEV_2: ${{ secrets.TEST_ORG_DEV_02_ID }}
+ ORG_DEV_3: ${{ secrets.TEST_ORG_DEV_03_ID }}
+ run: |
+ BRANCH="${{ github.head_ref || github.ref_name }}"
+ BASE="${{ github.base_ref }}"
+ case "$BRANCH" in
+ main|release) PREFIX="GA" ;;
+ beta|beta-release) PREFIX="BETA" ;;
+ httpx|httpx-release) PREFIX="DEV" ;;
+ *)
+ case "$BASE" in
+ main) PREFIX="GA" ;;
+ beta) PREFIX="BETA" ;;
+ httpx) PREFIX="DEV" ;;
+ *) echo "Unknown branch: $BRANCH (base: $BASE)" && exit 1 ;;
+ esac
+ ;;
+ esac
+ INDEX=$(echo '${{ needs.assign-orgs.outputs.assignments }}' | jq -r '.["${{ matrix.python-version }}"]')
+ VAR_NAME="ORG_${PREFIX}_${INDEX}"
+ ORG_ID="${!VAR_NAME}"
+ if [ -z "$ORG_ID" ]; then
+ echo "::error::No org id resolved for ${VAR_NAME}; cannot validate baseline"
+ exit 1
+ fi
+ EXPECTED_TOTAL=32
+ ACTUAL=$(uv run pytest tests/integration --co -q --apikey ${{ secrets.TEST_ORG_API_KEY }} --o "$ORG_ID" 2>/dev/null | grep -oP '^\d+(?= tests? collected)' | tail -1)
+ ACTUAL="${ACTUAL:-0}"
+ if ! [[ "$ACTUAL" =~ ^[0-9]+$ ]]; then
+ echo "::error::Could not parse collected test count (got '$ACTUAL')"
+ exit 1
+ fi
+ echo "Collected tests: $ACTUAL (baseline: $EXPECTED_TOTAL)"
+ if [ "$ACTUAL" -lt "$EXPECTED_TOTAL" ]; then
+ echo "::error::Regression: collected $ACTUAL tests, baseline expects $EXPECTED_TOTAL"
+ exit 1
+ fi
+
+ benchmark:
+ runs-on: ubuntu-latest
+ needs: [unit-test]
+ strategy:
+ fail-fast: true
+ matrix:
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+
+ - name: Set up Python ${{ matrix.python-version }}
+ run: uv python install ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: uv sync --python ${{ matrix.python-version }}
+
+ - name: Run benchmarks
+ run: uv run pytest tests/benchmarks --benchmark-json=benchmark-${{ matrix.python-version }}.json
+
+ - name: Upload benchmark results
+ uses: actions/upload-artifact@v7
+ with:
+ name: benchmark-py${{ matrix.python-version }}
+ path: benchmark-${{ matrix.python-version }}.json
diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml
new file mode 100644
index 00000000..1783e1b9
--- /dev/null
+++ b/.github/workflows/test-pr.yml
@@ -0,0 +1,27 @@
+name: Test PR
+
+on:
+ push:
+ branches: ['main', 'beta', 'httpx']
+ paths:
+ - 'meraki/**'
+ - 'tests/unit/**'
+ - 'tests/integration/**'
+ - 'pyproject.toml'
+ - 'uv.lock'
+ pull_request:
+ branches: ['main', 'beta', 'httpx']
+ paths:
+ - 'meraki/**'
+ - 'tests/unit/**'
+ - 'tests/integration/**'
+ - 'pyproject.toml'
+ - 'uv.lock'
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ uses: ./.github/workflows/test-library.yml
+ secrets: inherit
diff --git a/.github/workflows/v3-drift-detection.yml b/.github/workflows/v3-drift-detection.yml
deleted file mode 100644
index d780e02f..00000000
--- a/.github/workflows/v3-drift-detection.yml
+++ /dev/null
@@ -1,62 +0,0 @@
-name: V3 Drift Detection
-
-on:
- push:
- branches: ['main', 'release']
- paths:
- - 'generator/**'
- - 'scripts/semantic_diff_v2_v3.py'
- pull_request:
- branches: ['main', 'release']
- paths:
- - 'generator/**'
- - 'scripts/semantic_diff_v2_v3.py'
- workflow_dispatch:
- schedule:
- - cron: '0 6 * * 1' # Weekly Monday 6am UTC
-
-jobs:
- drift-check:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
-
- - name: Install uv
- uses: astral-sh/setup-uv@v5
- with:
- enable-cache: true
-
- - name: Set up Python
- run: uv python install 3.13
-
- - name: Install dependencies
- run: uv sync --python 3.13 --group generator
-
- - name: Run semantic diff (live spec)
- run: uv run python scripts/semantic_diff_v2_v3.py --live --json > drift-report.json
- env:
- PYTHONPATH: generator
-
- - name: Check for critical drift
- run: |
- python -c "
- import json, sys
- with open('drift-report.json') as f:
- drifts = json.load(f)
- critical_types = ('MISSING_IN_V3', 'BODY_WIRING', 'QUERY_WIRING')
- critical = [d for d in drifts if d['type'] in critical_types]
- if critical:
- print(f'CRITICAL: {len(critical)} issues found')
- for d in critical[:20]:
- print(f' [{d[\"type\"]}] {d[\"scope\"]}.{d[\"method\"]}: {d.get(\"detail\", \"\")}')
- sys.exit(1)
- print(f'OK: {len(drifts)} expected differences, 0 critical')
- "
-
- - name: Upload drift report
- if: always()
- uses: actions/upload-artifact@v4
- with:
- name: drift-report
- path: drift-report.json
- retention-days: 30
diff --git a/.github/workflows/watch-openapi-release.yml b/.github/workflows/watch-openapi-release.yml
new file mode 100644
index 00000000..70ffd7d6
--- /dev/null
+++ b/.github/workflows/watch-openapi-release.yml
@@ -0,0 +1,292 @@
+name: Watch OpenAPI Releases
+
+on:
+ schedule:
+ - cron: '0 14 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ check-ga:
+ runs-on: ubuntu-latest
+ outputs:
+ has_new_release: ${{ steps.check.outputs.has_new_release }}
+ api_version: ${{ steps.check.outputs.api_version }}
+ new_sdk_version: ${{ steps.version.outputs.new_sdk_version }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Get latest GA release
+ id: check
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # Retry: api.github.com occasionally 401s a job's freshly-minted token.
+ for attempt in 1 2 3 4 5; do
+ if LATEST=$(gh release list --repo meraki/openapi --exclude-pre-releases --limit 1 --json tagName -q '.[0].tagName'); then
+ break
+ fi
+ if [ "$attempt" = 5 ]; then
+ echo "::error::gh release list failed after 5 attempts"
+ exit 1
+ fi
+ echo "::warning::gh release list failed (attempt $attempt/5); retrying in $((attempt * 3))s"
+ sleep $((attempt * 3))
+ done
+ API_VERSION="${LATEST#v}"
+ # Reject anything that isn't a clean semver tag before it flows into
+ # downstream run: steps / workflow_dispatch inputs (script injection guard).
+ if ! [[ "$API_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
+ echo "::error::Unexpected OpenAPI release tag: '$LATEST'"
+ exit 1
+ fi
+ echo "api_version=$API_VERSION" >> "$GITHUB_OUTPUT"
+
+ LAST_PROCESSED="${{ vars.LAST_OPENAPI_GA_RELEASE }}"
+ if [ "$LATEST" = "$LAST_PROCESSED" ]; then
+ echo "has_new_release=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_new_release=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Compute next SDK version
+ if: steps.check.outputs.has_new_release == 'true'
+ id: version
+ run: |
+ CURRENT=$(git show "origin/main:pyproject.toml" | grep '^version' | sed 's/version = "\(.*\)"/\1/')
+ BASE=$(echo "$CURRENT" | sed 's/\(a\|b\|rc\).*//')
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
+ NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
+ echo "new_sdk_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
+
+ check-beta:
+ runs-on: ubuntu-latest
+ outputs:
+ has_new_release: ${{ steps.check.outputs.has_new_release }}
+ api_version: ${{ steps.check.outputs.api_version }}
+ new_sdk_version: ${{ steps.version.outputs.new_sdk_version }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Get latest beta release
+ id: check
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # Retry: api.github.com occasionally 401s a job's freshly-minted token.
+ for attempt in 1 2 3 4 5; do
+ if LATEST=$(gh release list --repo meraki/openapi --limit 20 --json tagName,isPrerelease -q '[.[] | select(.isPrerelease)] | .[0].tagName'); then
+ break
+ fi
+ if [ "$attempt" = 5 ]; then
+ echo "::error::gh release list failed after 5 attempts"
+ exit 1
+ fi
+ echo "::warning::gh release list failed (attempt $attempt/5); retrying in $((attempt * 3))s"
+ sleep $((attempt * 3))
+ done
+ if [ -z "$LATEST" ]; then
+ echo "has_new_release=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ API_VERSION="${LATEST#v}"
+ # Reject anything that isn't a clean semver tag before it flows into
+ # downstream run: steps / workflow_dispatch inputs (script injection guard).
+ if ! [[ "$API_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
+ echo "::error::Unexpected OpenAPI release tag: '$LATEST'"
+ exit 1
+ fi
+ echo "api_version=$API_VERSION" >> "$GITHUB_OUTPUT"
+
+ LAST_PROCESSED="${{ vars.LAST_OPENAPI_BETA_RELEASE }}"
+ if [ "$LATEST" = "$LAST_PROCESSED" ]; then
+ echo "has_new_release=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_new_release=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Compute next SDK version
+ if: steps.check.outputs.has_new_release == 'true'
+ id: version
+ env:
+ API_VERSION: ${{ steps.check.outputs.api_version }}
+ run: |
+ CURRENT=$(git show "origin/beta:pyproject.toml" | grep '^version' | sed 's/version = "\(.*\)"/\1/')
+
+ # Parse API version: e.g. "1.81.2-beta.3" -> minor=81, patch=2, beta=3
+ API_MINOR=$(echo "$API_VERSION" | cut -d. -f2)
+ API_PATCH=$(echo "$API_VERSION" | cut -d. -f3 | sed 's/-.*//')
+ API_BETA=$(echo "$API_VERSION" | sed 's/.*beta\.\?//')
+
+ LAST_TAG="${{ vars.LAST_OPENAPI_BETA_RELEASE }}"
+ LAST_API_MINOR=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f2)
+
+ BASE=$(echo "$CURRENT" | sed 's/\(a\|b\|rc\).*//')
+ IFS='.' read -r MAJOR LIB_MINOR LIB_PATCH <<< "$BASE"
+
+ if [ "$API_MINOR" != "$LAST_API_MINOR" ]; then
+ LIB_MINOR=$((LIB_MINOR + 1))
+ fi
+
+ NEW_VERSION="${MAJOR}.${LIB_MINOR}.${API_PATCH}b${API_BETA}"
+ echo "new_sdk_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
+
+ check-dev:
+ runs-on: ubuntu-latest
+ outputs:
+ has_new_release: ${{ steps.check.outputs.has_new_release }}
+ api_version: ${{ steps.check.outputs.api_version }}
+ new_sdk_version: ${{ steps.version.outputs.new_sdk_version }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Get latest beta release
+ id: check
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # Retry: api.github.com occasionally 401s a job's freshly-minted token.
+ for attempt in 1 2 3 4 5; do
+ if LATEST=$(gh release list --repo meraki/openapi --limit 20 --json tagName,isPrerelease -q '[.[] | select(.isPrerelease)] | .[0].tagName'); then
+ break
+ fi
+ if [ "$attempt" = 5 ]; then
+ echo "::error::gh release list failed after 5 attempts"
+ exit 1
+ fi
+ echo "::warning::gh release list failed (attempt $attempt/5); retrying in $((attempt * 3))s"
+ sleep $((attempt * 3))
+ done
+ if [ -z "$LATEST" ]; then
+ echo "has_new_release=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ API_VERSION="${LATEST#v}"
+ # Reject anything that isn't a clean semver tag before it flows into
+ # downstream run: steps / workflow_dispatch inputs (script injection guard).
+ if ! [[ "$API_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
+ echo "::error::Unexpected OpenAPI release tag: '$LATEST'"
+ exit 1
+ fi
+ echo "api_version=$API_VERSION" >> "$GITHUB_OUTPUT"
+
+ LAST_PROCESSED="${{ vars.LAST_OPENAPI_DEV_RELEASE }}"
+ if [ "$LATEST" = "$LAST_PROCESSED" ]; then
+ echo "has_new_release=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_new_release=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Compute next SDK version
+ if: steps.check.outputs.has_new_release == 'true'
+ id: version
+ env:
+ API_VERSION: ${{ steps.check.outputs.api_version }}
+ run: |
+ CURRENT=$(git show "origin/httpx:pyproject.toml" | grep '^version' | sed 's/version = "\(.*\)"/\1/')
+
+ API_MINOR=$(echo "$API_VERSION" | cut -d. -f2)
+ API_PATCH=$(echo "$API_VERSION" | cut -d. -f3 | sed 's/-.*//')
+ API_BETA=$(echo "$API_VERSION" | sed 's/.*beta\.\?//')
+
+ LAST_TAG="${{ vars.LAST_OPENAPI_DEV_RELEASE }}"
+ LAST_API_MINOR=$(echo "$LAST_TAG" | sed 's/^v//' | cut -d. -f2)
+
+ BASE=$(echo "$CURRENT" | sed 's/\(a\|b\|rc\).*//')
+ IFS='.' read -r MAJOR LIB_MINOR LIB_PATCH <<< "$BASE"
+
+ if [ "$API_MINOR" != "$LAST_API_MINOR" ]; then
+ LIB_MINOR=$((LIB_MINOR + 1))
+ fi
+
+ NEW_VERSION="${MAJOR}.${LIB_MINOR}.${API_PATCH}b${API_BETA}"
+ echo "new_sdk_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
+
+ trigger:
+ needs: [check-ga, check-beta, check-dev]
+ if: needs.check-ga.outputs.has_new_release == 'true' || needs.check-beta.outputs.has_new_release == 'true' || needs.check-dev.outputs.has_new_release == 'true'
+ runs-on: ubuntu-latest
+ env:
+ GH_REPO: ${{ github.repository }}
+ steps:
+ - name: Generate app token
+ id: app-token
+ uses: actions/create-github-app-token@v3
+ with:
+ app-id: ${{ secrets.RELEASE_APP_ID }}
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
+
+ - name: Record processed releases
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ if [ "${{ needs.check-ga.outputs.has_new_release }}" = "true" ]; then
+ gh variable set LAST_OPENAPI_GA_RELEASE --body "v${{ needs.check-ga.outputs.api_version }}"
+ fi
+
+ if [ "${{ needs.check-beta.outputs.has_new_release }}" = "true" ]; then
+ gh variable set LAST_OPENAPI_BETA_RELEASE --body "v${{ needs.check-beta.outputs.api_version }}"
+ fi
+
+ if [ "${{ needs.check-dev.outputs.has_new_release }}" = "true" ]; then
+ gh variable set LAST_OPENAPI_DEV_RELEASE --body "v${{ needs.check-dev.outputs.api_version }}"
+ fi
+
+ - name: Enable early access
+ if: needs.check-beta.outputs.has_new_release == 'true'
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ BEFORE_ID=$(gh run list --workflow=enable-early-access.yml --limit=1 --json databaseId -q '.[0].databaseId')
+ gh workflow run enable-early-access.yml
+
+ for i in $(seq 1 30); do
+ sleep 10
+ NEW_ID=$(gh run list --workflow=enable-early-access.yml --limit=1 --json databaseId -q '.[0].databaseId')
+ if [ "$NEW_ID" != "$BEFORE_ID" ]; then
+ gh run watch "$NEW_ID"
+ exit 0
+ fi
+ done
+ echo "Timed out waiting for enable-early-access run to appear"
+ exit 1
+
+ - name: Trigger GA regeneration
+ if: needs.check-ga.outputs.has_new_release == 'true'
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ gh workflow run regenerate-library.yml \
+ -f library_version="${{ needs.check-ga.outputs.new_sdk_version }}" \
+ -f api_version="${{ needs.check-ga.outputs.api_version }}" \
+ -f release_stage="ga"
+
+ - name: Trigger beta regeneration
+ if: needs.check-beta.outputs.has_new_release == 'true'
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ gh workflow run regenerate-library.yml \
+ -f library_version="${{ needs.check-beta.outputs.new_sdk_version }}" \
+ -f api_version="${{ needs.check-beta.outputs.api_version }}" \
+ -f release_stage="beta"
+
+ - name: Trigger dev regeneration
+ if: needs.check-dev.outputs.has_new_release == 'true'
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ gh workflow run regenerate-library.yml \
+ -f library_version="${{ needs.check-dev.outputs.new_sdk_version }}" \
+ -f api_version="${{ needs.check-dev.outputs.api_version }}" \
+ -f release_stage="dev"
diff --git a/.gitignore b/.gitignore
index 4377d847..9a530e89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,26 @@
-.DS_Store
-# No PyCharm config files
-.idea/
-.vscode/
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+dist/
+
+# Virtual environments
venv/
.venv/
-__pycache__
-/.pytest_cache/
-dist/
-*.egg-info/
+
+# Testing
+.pytest_cache/
+.coverage
+
+# IDEs
+.idea/
+.vscode/
+
+# OS
+.DS_Store
+
+# Project
+.scratch/
+
+# Git worktrees
+.worktrees/
\ No newline at end of file
diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md
deleted file mode 100644
index 19f7ac17..00000000
--- a/.planning/MILESTONES.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Milestones
-
-## v1.0 OASv3 Generator (Shipped: 2026-04-30)
-
-**Phases completed:** 5 phases, 11 plans, 11 tasks
-
-**Key accomplishments:**
-
-- One-liner:
-- One-liner:
-- One-liner:
-- RED:
-- Commit:
-- Semantic diff script (scripts/semantic_diff_v2_v3.py):
-
----
diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
deleted file mode 100644
index 101a74e5..00000000
--- a/.planning/PROJECT.md
+++ /dev/null
@@ -1,102 +0,0 @@
-# Meraki Dashboard API Python SDK
-
-## What This Is
-
-A Python SDK wrapping the Meraki Dashboard API, auto-generated from the OpenAPI spec. Provides both synchronous and async interfaces with pagination, retry logic, rate limiting, and batch action support.
-
-## Core Value
-
-Developers can interact with every Meraki Dashboard API endpoint through a well-typed, well-documented Python client that stays current with the live API spec.
-
-## Current Milestone: v1.1 Deprecation Cycle
-
-**Goal:** Promote v3 generator to default, deprecate and remove v2 generator and abandoned v3 attempt.
-
-**Target features:**
-- Rename v2 generator to `generate_library_oasv2.py` with deprecation warning
-- Promote v3 generator to `generate_library.py` (new default)
-- Remove abandoned `generate_library_oasv3.py`
-- Remove v2 generator after confirming no rollbacks needed
-
-## Previous State (v1.0)
-
-Modular OASv3 generator built and tested. Produces sync, async, and batch modules with explicit param construction, .pyi type stubs, and CI drift detection against live spec. 124 tests passing.
-
-## Requirements
-
-### Validated
-
-- Generator produces full SDK from OASv2 spec (production, working)
-- Sync and async interfaces with identical API surface
-- Pagination, retry, rate limiting, batch actions
-- kwarg validation with optional logging
-- ✓ OASv3 generator with modular architecture - v1.0
-- ✓ `$ref` resolution with cycle protection - v1.0
-- ✓ `requestBody` parsing (JSON, multipart, octet-stream) - v1.0
-- ✓ `oneOf` query param handling - v1.0
-- ✓ `nullable` type annotations - v1.0
-- ✓ Path-level parameter inheritance - v1.0
-- ✓ Replace `locals()` antipattern with explicit param construction - v1.0
-- ✓ Type stub generation (`.pyi` files) - v1.0
-- ✓ Golden-file test suite for v3 generator - v1.0
-- ✓ CI drift detection between v2 and v3 output - v1.0
-
-### Active
-
-- [ ] Rename v2 generator to `generate_library_oasv2.py` with deprecation warning
-- [ ] Promote v3 generator to `generate_library.py` (new default)
-- [ ] Remove abandoned `generate_library_oasv3.py`
-- [ ] Remove v2 generator after one minor version cycle
-
-### Out of Scope
-
-- Modifying the v2 generator internals (rename/remove is in scope for v1.1)
-- Changing the runtime SDK behavior (rest_session, pagination, etc.)
-- Supporting OpenAPI 3.1 (`type: [string, null]` syntax)
-- Rewriting Jinja2 templates from scratch (reuse existing, extend as needed)
-
-## Context
-
-- Live v3 spec at `https://api.meraki.com/api/v1/openapiSpec?version=3` (OpenAPI 3.0.1)
-- Existing v2 generator: `generator/generate_library.py` (production)
-- Abandoned v3 attempt: `generator/generate_library_oasv3.py` (monolithic, incomplete)
-- Shared utilities: `generator/common.py`, Jinja2 templates in `generator/`
-- v3 spec has features not in v2: `requestBody`, `$ref`, `oneOf` query params, `nullable`, `components/schemas`
-- 298 `x-batchable-actions` entries present in v3 spec
-
-## Constraints
-
-- **Compatibility**: Output must be structurally identical to v2, with enhanced features (like object query params) and docstrings from v3-only features
-- **Architecture**: Follow v2's modular structure; reuse `common.py` and templates
-- **Testing**: Golden-file tests must validate v3-specific output, not match v2 byte-for-byte
-- **Deprecation**: v2 generator retained until parity gate passes for 2+ consecutive API releases
-
-## Key Decisions
-
-| Decision | Rationale | Outcome |
-|----------|-----------|---------|
-| Replace abandoned oasv3 file entirely | Cleaner modular structure vs patching monolith | ✓ Good |
-| Resolve `$ref` at parse time with caching | Downstream code gets normalized dicts, no template changes | ✓ Good |
-| `oneOf` reported as "string or object" | Accurate type representation without lying | ✓ Good |
-| Thread `spec` through all functions | Needed for `$ref` resolution anywhere in tree | ✓ Good |
-| Explicit param construction over `locals()` | Type-safe, static-analysis friendly | ✓ Good |
-
-## Evolution
-
-This document evolves at phase transitions and milestone boundaries.
-
-**After each phase transition** (via `/gsd-transition`):
-1. Requirements invalidated? -> Move to Out of Scope with reason
-2. Requirements validated? -> Move to Validated with phase reference
-3. New requirements emerged? -> Add to Active
-4. Decisions to log? -> Add to Key Decisions
-5. "What This Is" still accurate? -> Update if drifted
-
-**After each milestone** (via `/gsd-complete-milestone`):
-1. Full review of all sections
-2. Core Value check, still the right priority?
-3. Audit Out of Scope, reasons still valid?
-4. Update Context with current state
-
----
-*Last updated: 2026-04-30 after v1.1 milestone start*
diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
deleted file mode 100644
index c0709a8f..00000000
--- a/.planning/REQUIREMENTS.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# Requirements: Meraki Dashboard API Python SDK
-
-**Defined:** 2026-04-30
-**Core Value:** Developers can interact with every Meraki Dashboard API endpoint through a well-typed, well-documented Python client that stays current with the live API spec.
-
-## v1.1 Requirements
-
-Requirements for Deprecation Cycle milestone. Promotes v3 generator to default, removes legacy code.
-
-### Deprecation
-
-- [ ] **DEP-01**: Rename v2 generator to `generate_library_oasv2.py` with deprecation warning on import
-- [ ] **DEP-02**: Promote v3 generator to `generate_library.py` (new default entry point)
-- [ ] **DEP-03**: Remove abandoned `generate_library_oasv3.py` (dead code cleanup)
-- [ ] **DEP-04**: Update all imports, CI workflows, and documentation referencing old filenames
-
-## Future Requirements
-
-None planned beyond this milestone.
-
-## Out of Scope
-
-| Feature | Reason |
-|---------|--------|
-| Changing runtime SDK behavior | rest_session, pagination, etc. are stable |
-| OpenAPI 3.1 support | Only 3.0.1 style supported |
-| Rewriting Jinja2 templates | Reuse existing, extend as needed |
-| Removing v2 generator immediately | One version cycle buffer required |
-
-## Traceability
-
-| Requirement | Phase | Status |
-|-------------|-------|--------|
-| DEP-01 | Phase 6 | Pending |
-| DEP-02 | Phase 6 | Pending |
-| DEP-03 | Phase 7 | Pending |
-| DEP-04 | Phase 7 | Pending |
-
-**Coverage:**
-- v1.1 requirements: 4 total
-- Mapped to phases: 4
-- Unmapped: 0
-
----
-*Requirements updated: 2026-04-30 (traceability mapped to phases 6-7)*
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
deleted file mode 100644
index 435c096e..00000000
--- a/.planning/ROADMAP.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Roadmap: Meraki Dashboard API Python SDK
-
-## Milestones
-
-- ✅ **v1.0 OASv3 Generator** — Phases 1-5 (shipped 2026-04-30)
-- 🚧 **v1.1 Deprecation Cycle** — Phases 6-7 (active)
-
-## Phases
-
-
-✅ v1.0 OASv3 Generator (Phases 1-5) — SHIPPED 2026-04-30
-
-- [x] Phase 1: Parser Foundation (2/2 plans) — completed 2026-04-30
-- [x] Phase 2: Unified Parameter Parser (2/2 plans) — completed 2026-04-30
-- [x] Phase 3: Generation Integration (2/2 plans) — completed 2026-04-30
-- [x] Phase 4: Type Stubs (2/2 plans) — completed 2026-04-30
-- [x] Phase 5: Testing & CI (3/3 plans) — completed 2026-04-30
-
-
-
-### v1.1 Deprecation Cycle (Phases 6-7)
-
-- [ ] **Phase 6: Generator Swap** - Rename v2 generator with deprecation warning, promote v3 to default
-- [ ] **Phase 7: Legacy Cleanup** - Remove abandoned v3 attempt, update all references
-
-## Phase Details
-
-### Phase 6: Generator Swap
-**Goal**: v3 generator becomes default entry point, v2 generator deprecated but retained
-**Depends on**: Nothing (first phase of milestone)
-**Requirements**: DEP-01, DEP-02
-**Success Criteria** (what must be TRUE):
- 1. `generate_library.py` imports and runs v3 generator code
- 2. `generate_library_oasv2.py` imports and runs v2 generator code with deprecation warning
- 3. Running `python generator/generate_library.py` produces SDK using v3 parser
- 4. Running `python generator/generate_library_oasv2.py` logs deprecation warning but works
-**Plans**: 1 plan
-
-Plans:
-- [x] 06-01-PLAN.md — Swap generator files, add deprecation warning, update test imports
-
-### Phase 7: Legacy Cleanup
-**Goal**: Abandoned v3 attempt removed, all imports and CI workflows updated
-**Depends on**: Phase 6
-**Requirements**: DEP-03, DEP-04
-**Success Criteria** (what must be TRUE):
- 1. `generate_library_oasv3.py` file no longer exists in repository
- 2. CI workflows reference `generate_library.py` (not old filenames)
- 3. Documentation references `generate_library.py` and `generate_library_oasv2.py` (deprecated)
- 4. All internal imports use correct generator filenames
-**Plans**: TBD
-
-## Progress
-
-| Phase | Milestone | Plans Complete | Status | Completed |
-|-------|-----------|----------------|--------|-----------|
-| 1. Parser Foundation | v1.0 | 2/2 | Complete | 2026-04-30 |
-| 2. Unified Parameter Parser | v1.0 | 2/2 | Complete | 2026-04-30 |
-| 3. Generation Integration | v1.0 | 2/2 | Complete | 2026-04-30 |
-| 4. Type Stubs | v1.0 | 2/2 | Complete | 2026-04-30 |
-| 5. Testing & CI | v1.0 | 3/3 | Complete | 2026-04-30 |
-| 6. Generator Swap | v1.1 | 0/1 | Not started | - |
-| 7. Legacy Cleanup | v1.1 | 0/0 | Not started | - |
-
----
-*Roadmap updated: 2026-04-30 (Phase 6 plan created)*
diff --git a/.planning/STATE.md b/.planning/STATE.md
deleted file mode 100644
index 2a668d44..00000000
--- a/.planning/STATE.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-gsd_state_version: 1.0
-milestone: v1.1
-milestone_name: Deprecation Cycle
-status: executing
-last_updated: "2026-04-30T12:09:25.434Z"
-last_activity: 2026-04-30
-progress:
- total_phases: 2
- completed_phases: 1
- total_plans: 1
- completed_plans: 1
- percent: 100
----
-
-# Project State
-
-## Project Reference
-
-See: .planning/PROJECT.md (updated 2026-04-30)
-
-**Core value:** Developers can interact with every Meraki Dashboard API endpoint through a well-typed, well-documented Python client that stays current with the live API spec.
-**Current focus:** Phase 06 — generator-swap
-
-## Current Position
-
-Phase: 07
-Plan: Not started
-Status: Executing Phase 06
-Last activity: 2026-04-30
-
-```
-[░░░░░░░░░░░░░░░░░░░░] 0% (0/2 phases)
-```
-
-## Accumulated Context
-
-### Decisions
-
-- v1.0: All key decisions validated (see PROJECT.md Key Decisions table)
-- v1.1: Parity confirmed against live spec; ready to promote v3
-
-### Pending Todos
-
-None.
-
-### Blockers/Concerns
-
-None.
diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md
deleted file mode 100644
index 72230347..00000000
--- a/.planning/codebase/ARCHITECTURE.md
+++ /dev/null
@@ -1,196 +0,0 @@
-# Architecture
-
-**Analysis Date:** 2026-04-29
-
-## Pattern Overview
-
-**Overall:** Multi-layer façade pattern wrapping the Meraki Dashboard API with configurable HTTP session management. The SDK provides both synchronous (requests-based) and asynchronous (aiohttp-based) interfaces, each exposing identical API scopes through separate entry points.
-
-**Key Characteristics:**
-- Code generated from OpenAPI specification v1 (auto-generated from Meraki OpenAPI spec)
-- Dual API access patterns (synchronous DashboardAPI and async AsyncDashboardAPI)
-- Centralized HTTP session handling with retry logic, pagination, and rate limiting
-- Scoped API endpoints organized by resource type (organizations, networks, devices, etc.)
-- Batch action support through separate Batch helper classes
-- Configurable logging, timeouts, retries, and kwarg validation
-
-## Layers
-
-**Entry Point Layer:**
-- Purpose: Public API initialization and scope access
-- Location: `meraki/__init__.py` (sync), `meraki/aio/__init__.py` (async)
-- Contains: DashboardAPI and AsyncDashboardAPI classes with scope properties
-- Depends on: RestSession/AsyncRestSession, all API scope classes
-- Used by: End users instantiating the client
-
-**Scope/Resource Layer:**
-- Purpose: Group operations by Meraki resource type (Organizations, Networks, Devices, etc.)
-- Location: `meraki/api/{scope}.py` (sync), `meraki/aio/api/{scope}.py` (async)
-- Contains: Classes like Organizations, Networks, Devices, Appliance, Camera, etc. (17 total scopes)
-- Depends on: RestSession.get/post/put/delete methods
-- Used by: Users calling operations like `dashboard.organizations.getOrganizations()`
-
-**Batch Helper Layer:**
-- Purpose: Specialized classes for batch operations (Action Batches)
-- Location: `meraki/api/batch/{scope}.py`
-- Contains: ActionBatchOrganizations, ActionBatchNetworks, etc.
-- Depends on: Batch class aggregation
-- Used by: Users batching multiple API calls for concurrent execution
-
-**HTTP Session Layer:**
-- Purpose: Handle all HTTP communication, retry logic, rate limiting, pagination
-- Location: `meraki/rest_session.py` (sync), `meraki/aio/rest_session.py` (async)
-- Contains: RestSession and AsyncRestSession classes
-- Depends on: requests (sync), aiohttp (async), response_handler, common utilities
-- Used by: All scope classes making actual API calls
-
-**Configuration Layer:**
-- Purpose: Define default constants and environment variable names
-- Location: `meraki/config.py`
-- Contains: API_KEY_ENVIRONMENT_VARIABLE, DEFAULT_BASE_URL, timeouts, retry policies, logging options
-- Depends on: Nothing (config only)
-- Used by: DashboardAPI, AsyncDashboardAPI, RestSession, AsyncRestSession initialization
-
-**Utility Layer:**
-- Purpose: Shared helpers for validation, logging, parameter encoding
-- Location: `meraki/common.py`, `meraki/response_handler.py`
-- Contains: Python version check, base URL validation, iterator logic, user agent formatting
-- Depends on: Nothing (utilities only)
-- Used by: RestSession, common initialization
-
-**Error Handling Layer:**
-- Purpose: Exception types for API and validation errors
-- Location: `meraki/exceptions.py`
-- Contains: APIKeyError, APIError, AsyncAPIError, PythonVersionError, SessionInputError
-- Depends on: Nothing (exceptions only)
-- Used by: RestSession, AsyncRestSession, DashboardAPI initialization
-
-## Data Flow
-
-**Synchronous Request Flow:**
-
-1. User calls `dashboard.organizations.getOrganizations()`
-2. Organizations instance method builds metadata dict with operation name and tags
-3. Method constructs resource path and extracts valid kwargs into params/payload dicts
-4. Organizations calls `self._session.get(metadata, resource, params)`
-5. RestSession.get() calls request() with metadata, resource, params
-6. request() performs retry loop with rate limit/4xx error handling
-7. requests library makes actual HTTP call with Bearer token auth header
-8. Response is logged and returned to Organizations method
-9. Organizations method returns data to caller
-
-**Asynchronous Request Flow:**
-
-1. User calls `await aiomeraki.organizations.getOrganizations()`
-2. Same scope method structure, but AsyncOrganizations calls `await self._session.get()`
-3. AsyncRestSession.get() calls request() coroutine with same parameters
-4. request() performs retry loop with rate limit/4xx error handling using aiohttp
-5. aiohttp client makes actual HTTP call
-6. Response awaited and returned through scope method
-7. Caller receives awaitable result
-
-**Pagination Flow:**
-
-1. User calls listOperation with total_pages parameter
-2. get_pages() is called (iterator or legacy mode)
-3. Iterator mode: returns generator yielding individual items across pages
-4. Legacy mode: yields complete page lists based on total_pages/perPage
-5. Handles startingAfter/endingBefore tokens in Link headers for pagination
-
-**State Management:**
-
-- Session state: Stored in RestSession/AsyncRestSession instance (headers, config, retry policy)
-- Per-request state: Metadata dict carries operation metadata for logging/error handling
-- User agent state: Dynamically constructed with caller identifier and SDK version
-- Pagination state: Tracked via response Link headers, not stored between calls
-
-## Key Abstractions
-
-**Session Abstraction (RestSession/AsyncRestSession):**
-- Purpose: Shields scope classes from HTTP implementation details
-- Examples: `meraki/rest_session.py`, `meraki/aio/rest_session.py`
-- Pattern: Both implement identical interface (get, post, put, delete, get_pages) with different transport
-- Allows scope code to be generated once, shared between sync/async via templates
-
-**Scope Operation Pattern:**
-- Purpose: Standardized method structure for all API operations
-- Examples: `meraki/api/organizations.py`, `meraki/api/networks.py`
-- Pattern: kwargs.update(locals()), metadata dict, resource path building, param extraction, session call
-- Allows generated code to consistently handle pagination, kwarg validation, URL encoding
-
-**Parameter Encoding Abstraction:**
-- Purpose: Support complex query parameter types (array of objects)
-- Examples: `meraki/rest_session.py` encode_params() monkey patch
-- Pattern: Custom requests library override for handling {"param": [{"key_1":"value_1"}]} => ?param[]key_1=value_1
-- Necessary for Meraki API's complex query parameter requirements
-
-**Batch Helper Pattern:**
-- Purpose: Provide staging for Action Batch requests without session dependency
-- Examples: `meraki/api/batch/organizations.py`
-- Pattern: Stateless helper objects building batch payloads, not executing requests
-- Separate from session because batches are composed then submitted separately
-
-**Metadata Dictionary:**
-- Purpose: Carry operation context through request lifecycle for logging/error reporting
-- Structure: {"tags": ["scope_name", "read/configure"], "operation": "operationName"}
-- Usage: Extracted from exception handlers and logged with requests for audit trail
-
-## Entry Points
-
-**DashboardAPI (Sync):**
-- Location: `meraki/__init__.py`
-- Triggers: `api_instance = meraki.DashboardAPI(api_key="...", ...)`
-- Responsibilities: Initialize RestSession with config, instantiate all scope properties, manage logger setup
-- Configuration: 17 parameters controlling auth, retries, logging, simulation, pagination mode
-
-**AsyncDashboardAPI (Async):**
-- Location: `meraki/aio/__init__.py`
-- Triggers: `async with meraki.aio.AsyncDashboardAPI() as api_instance:`
-- Responsibilities: Same as sync but with AsyncRestSession, manages async context manager for session cleanup
-- Configuration: Identical 17 parameters plus AIO_MAXIMUM_CONCURRENT_REQUESTS
-
-**Module import:**
-- Location: `meraki/__init__.py`
-- Triggers: `import meraki`
-- Responsibilities: Exports DashboardAPI, config constants, version string
-- No initialization required
-
-## Error Handling
-
-**Strategy:** Exceptions caught at session layer, re-raised with context as APIError/AsyncAPIError. User handles try/except at caller level.
-
-**Patterns:**
-
-- **Rate Limiting (429):** Caught in RestSession.request() retry loop, waits NGINX_429_RETRY_WAIT_TIME before retry
-- **4XX Errors:** Caught in handle_4xx_errors(), optionally retried if RETRY_4XX_ERROR is true
-- **5XX Errors:** Caught in request() retry loop, retried up to MAXIMUM_RETRIES times
-- **API Key Missing:** Caught in DashboardAPI.__init__() before session creation, raises APIKeyError
-- **Python Version:** Caught in RestSession.__init__(), raises PythonVersionError
-- **Invalid Configuration:** Caught in common validation functions, raises SessionInputError with doc link
-- **Response Parsing:** Caught in AsyncAPIError/APIError constructors, falls back to raw content if JSON parse fails
-
-## Cross-Cutting Concerns
-
-**Logging:**
-- Implementation: Standard logging module with file/console handlers configured in DashboardAPI.__init__()
-- Per-call: RestSession logs request params before each call, logs response status after
-- Redaction: API key masked to last 4 chars in log output
-- Control: suppress_logging=True disables all logging; inherit_logging_config=True uses external logger
-
-**Validation:**
-- User agent format: validate_user_agent() regex check on MERAKI_PYTHON_SDK_CALLER format
-- Base URL: reject_v0_base_url() prevents v0 API access, validate_base_url() whitelists domains
-- Python version: check_python_version() enforces Python 3.10+
-- Kwargs: Optional validate_kwargs mode logs warnings when unrecognized kwargs passed to operations
-
-**Authentication:**
-- Method: Bearer token in Authorization header, token from API_KEY_ENVIRONMENT_VARIABLE or parameter
-- Per-call: RestSession sets header once at init, reused for all requests
-- Security: Requires explicit API key provision or environment variable, fails fast if missing
-
-**Retry Policy:**
-- Rate limits: Waits NGINX_429_RETRY_WAIT_TIME (default 60s) before retry
-- Action batch conflicts: Waits ACTION_BATCH_RETRY_WAIT_TIME (default 60s) before retry
-- Network deletion conflicts: Waits NETWORK_DELETE_RETRY_WAIT_TIME (default 240s) before retry
-- Other 4XX: Optionally retried if RETRY_4XX_ERROR enabled, waits RETRY_4XX_ERROR_WAIT_TIME
-- 5XX: Retried up to MAXIMUM_RETRIES times with exponential backoff (implementation in RestSession.request())
diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md
deleted file mode 100644
index 5b92298f..00000000
--- a/.planning/codebase/CONCERNS.md
+++ /dev/null
@@ -1,175 +0,0 @@
-# Codebase Concerns
-
-**Analysis Date:** 2026-04-29
-
-## Tech Debt
-
-**High Cyclomatic Complexity in Request Handlers:**
-- Issue: `AsyncRestSession._request()` has complexity of 42 (async mirror at `meraki/aio/rest_session.py:135`); sync version `RestSession.request()` at `meraki/rest_session.py:207` is somewhat lower but still elevated. The async method is monolithic with deeply nested status-code matching and error handling.
-- Files: `meraki/aio/rest_session.py:135`, `meraki/rest_session.py:207`
-- Impact: Difficult to test individual error paths, maintain, and add new status handlers. Makes async retry logic hard to follow.
-- Fix approach: Extract status-code handlers (429, 5xx, 4xx patterns) into discrete methods matching the sync version's `handle_4xx_errors` pattern already implemented in `meraki/rest_session.py:324`. Reduce nesting depth.
-
-**Pagination Logic Complexity:**
-- Issue: `_get_pages_legacy()` has complexity of 24 (sync at `meraki/rest_session.py:470`) and 19 (async at `meraki/aio/rest_session.py:392`). The method handles three unrelated result types (list, dict with "items", event log dict) in one monolithic function with operation-specific branching (getNetworkEvents special cases).
-- Files: `meraki/rest_session.py:470`, `meraki/aio/rest_session.py:392`
-- Impact: Adding new endpoint pagination logic requires modifying existing complex code. Event log handling is tightly coupled with generic pagination.
-- Fix approach: Extract event-log-specific pagination into a strategy or helper method. Split result-type handlers (lines 540-561 in sync version) into separate methods. Do this after Stream 1 complexity reduction.
-
-**Sync/Async Code Duplication:**
-- Issue: `rest_session.py` (601 lines) and `aio/rest_session.py` (495 lines) share ~80% identical logic. Both manage retries, rate limiting, error handling, pagination with nearly identical structure but incompatible async/await syntax.
-- Files: `meraki/rest_session.py`, `meraki/aio/rest_session.py`
-- Impact: Bug fixes and feature additions must be applied twice. Inconsistencies can accumulate (already exists: e.g., async uses `response.reason if response.reason else None` while sync uses `response.reason if response.reason else ""`). Maintenance burden increases with each version.
-- Fix approach: Consider shared base class or code-generation approach. The generator already exists (`generator/generate_library.py` and upcoming OASv3 generator). Defer until after complexity reduction (Stream 1) so clean code is deduplicated, not spaghetti. Alternatively, migrate to `httpx` (breaking change for next major version) which provides both sync and async from one client.
-
-**No Type Annotations in Core Modules:**
-- Issue: `rest_session.py`, `aio/rest_session.py`, `__init__.py`, `exceptions.py` contain zero type hints. Functions accept `**kwargs` with no indication of expected structure.
-- Files: `meraki/rest_session.py`, `meraki/aio/rest_session.py`, `meraki/__init__.py`, `meraki/exceptions.py`
-- Impact: IDE autocompletion is limited. Downstream type-checking (mypy --strict, pyright) not possible. Consumers can't validate API usage at development time.
-- Fix approach: Add type annotations to all public and internal methods. Add `py.typed` marker in package root after annotations exist. Enable `mypy --strict` or `pyright` in CI.
-
----
-
-## Known Bugs
-
-**Event Log Pagination Edge Case:**
-- Symptoms: `getNetworkEvents` pagination in `_get_pages_legacy()` has special reverse-sorting logic and time-window boundary detection (lines 502-522 in sync, similar in async). If `pageStartAt` or `pageEndAt` is missing from response JSON, a `KeyError` is caught and logged at line 551-552 (`rest_session.py`) but execution continues, potentially merging incomplete results.
-- Files: `meraki/rest_session.py:547-561`, `meraki/aio/rest_session.py:443-457` (approx)
-- Trigger: Call `get_pages()` with high page count on `getNetworkEvents` endpoint; if Meraki API returns a response missing `pageStartAt` key, the warning logs but continues with incomplete data.
-- Workaround: None; bug is silent except for log entry. Downstream code receives truncated event sequence without indication of loss.
-
-**Bare `except Exception` in Async Request Handler:**
-- Symptoms: Line 187 in `meraki/aio/rest_session.py` catches all exceptions with bare `except Exception`, including `asyncio.CancelledError` and `KeyboardInterrupt` subclasses (in Python 3.8+ these inherit from BaseException, but other unexpected exceptions may be masked).
-- Files: `meraki/aio/rest_session.py:187`
-- Trigger: Any unexpected exception in the request (connection error, DNS failure, etc.) is logged generically and retried without distinguishing between retryable and non-retryable errors.
-- Workaround: Catch only `aiohttp` exceptions explicitly (as done in line 206-208 for JSON parse errors). Sync version (`rest_session.py:240`) is more specific (`requests.exceptions.RequestException`).
-
----
-
-## Security Considerations
-
-**Certificate Path Validation:**
-- Risk: `certificate_path` parameter (line 23 in `meraki/aio/rest_session.py`) is passed to `ssl.create_default_context()` and `load_verify_locations()` without existence check. If path is invalid, SSL context creation fails but error is raised at request time (first async call), not at session init.
-- Files: `meraki/aio/rest_session.py:97-98`, `meraki/rest_session.py` (similar pattern ~line 180)
-- Current mitigation: File must exist for SSL to work; error is caught and propagated.
-- Recommendations: Validate certificate path at session init time and raise early. Document that invalid cert paths will be caught during first request.
-
-**Proxy Configuration:**
-- Risk: `requests_proxy` parameter accepts raw URL string. No validation of proxy format or TLS verification for proxy connection. If proxy is HTTP (not HTTPS), man-in-the-middle attacks on proxy itself are possible.
-- Files: `meraki/rest_session.py:321`, `meraki/aio/rest_session.py:143`
-- Current mitigation: None explicit; relies on underlying libraries (requests, aiohttp) to handle proxy security.
-- Recommendations: Document proxy security considerations. Warn users to use HTTPS proxies in production. Consider adding proxy URL validation.
-
----
-
-## Performance Bottlenecks
-
-**Pagination Buffer in Memory:**
-- Problem: `_get_pages_legacy()` collects all pages into memory before returning. For endpoints with thousands of pages (e.g., large device event logs), this consumes unbounded memory.
-- Files: `meraki/rest_session.py:470`, `meraki/aio/rest_session.py:392`
-- Cause: Results are appended to a single list/dict in a while loop (lines 540-561 in `rest_session.py`). No streaming or generator pattern.
-- Improvement path: Implement generator-based pagination (`_get_pages_iterator` at `meraki/rest_session.py:390` and async version already exist but are not default; `use_iterator_for_get_pages` property controls switch). Document iterator approach as best practice for large result sets. Make iterator the default in next major version.
-
----
-
-## Fragile Areas
-
-**Event Log Pagination State Machine:**
-- Files: `meraki/rest_session.py:502-522`, `meraki/aio/rest_session.py:398-418` (approx)
-- Why fragile: The logic depends on timestamp ordering and specific response structure (pageStartAt, pageEndAt, events array). If Meraki API changes timestamp format or pagination cursor format, code breaks silently. Current time comparison assumes UTC; timezone handling is implicit.
-- Safe modification: Test against live API before deploying changes. Add explicit timezone checks. Verify pagination cursor format is documented in spec. Consider adding integration tests against real API (currently integration tests use mocked responses).
-- Test coverage: Event log pagination is tested in `tests/unit/test_rest_session.py` and `tests/integration/` but mocked. Live API testing is not part of CI (would require API key, rate limits, etc.).
-
-**Response JSON Validation:**
-- Files: `meraki/rest_session.py:273-285`, `meraki/aio/rest_session.py:201-211`
-- Why fragile: Code assumes `response.json()` or `response.content.strip()` will work for all 2xx responses. For GET requests with 204 (No Content), `response.content.strip()` is empty, bypassing JSON parse. If a 2xx endpoint returns empty body unexpectedly, code raises `JSONDecodeError` and retries (which may mask real issues like endpoint returning wrong format).
-- Safe modification: Check Content-Type header before attempting JSON parse. For 204, return None rather than attempting parse.
-- Test coverage: 204 handling is tested explicitly for `getOrganizationClientSearch` (line 496 in `rest_session.py`). Other 2xx no-content responses not explicitly covered.
-
-**Generated API Module Dependencies:**
-- Files: All 60+ files in `meraki/api/`, `meraki/aio/api/`, `meraki/api/batch/`
-- Why fragile: Generated code depends on `rest_session.py` and `aio/rest_session.py` internals. If core request/pagination logic changes, all generated methods can break. Generated methods have no defensive checks.
-- Safe modification: When changing core request logic, regenerate library using `generator/generate_library.py`. Ensure golden tests in `tests/generator/test_generate_library_golden.py` pass before commit.
-- Test coverage: Generator tests compare output against golden files; any signature or template changes are caught.
-
----
-
-## Scaling Limits
-
-**Concurrent Request Semaphore (Async Only):**
-- Current capacity: `AIO_MAXIMUM_CONCURRENT_REQUESTS = 10` (default, defined in `meraki/config.py`)
-- Limit: Hardcoded to 10. For large-scale deployments processing thousands of devices/networks, this is a bottleneck.
-- Files: `meraki/aio/rest_session.py:79` (semaphore init)
-- Scaling path: Make `maximum_concurrent_requests` a user-configurable parameter in `AsyncDashboardAPI` constructor (already is at line 58 of `meraki/aio/__init__.py`). Document recommended values for typical workloads. Consider adaptive tuning based on API response times (not implemented).
-
-**Pagination Window for Event Logs:**
-- Current capacity: Time-based pagination can fetch up to all events matching a time range, but no per-request limit on page count.
-- Limit: If `total_pages` is -1 (fetch all), and endpoint returns many pages, request completes but memory usage grows unbounded. Default behavior in `_get_pages_legacy()` without explicit page limit.
-- Files: `meraki/rest_session.py:470-490`
-- Scaling path: Implement auto-scaling page limit based on available memory. Or enforce hard ceiling on pages per request (e.g., max 100 pages). Document memory usage expectations for large result sets. Recommend iterator-based approach for production workloads.
-
-**Test Suite Growth:**
-- Current capacity: 211 unit tests + integration tests. Test suite runs in ~30 seconds locally.
-- Limit: As generator output and API surface grow, test suite will grow linearly. No known limit yet.
-- Scaling path: Pytest parallelization (use `pytest-xdist`) to reduce wall-clock time. CI already runs tests; no changes needed if runtime stays <2 minutes.
-
----
-
-## Dependencies at Risk
-
-**OASv3 Migration In Progress:**
-- Risk: `OASV3-MIGRATION.md` documents upcoming migration from OASv2 generator to OASv3. Current generator is OASv2-only. OASv3 spec has new features (requestBody, $ref, oneOf parameters) not handled by current generator.
-- Impact: If OASv3 migration is incomplete, generated API stubs will diverge from actual Meraki API spec, causing runtime errors in production code using new v3 features.
-- Files: `OASV3-MIGRATION.md`, `generator/generate_library.py` (current), `generator/generate_library_oasv3.py` (under development)
-- Migration plan: Follow deprecation plan in `OASV3-MIGRATION.md` section "Deprecation Plan". OASv3 generator must achieve parity with v2 (zero semantic differences on live spec for 2+ releases) before being set as default. Until then, continue using OASv2 generator for production releases.
-
-**Requests Library Pinned to <3:**
-- Risk: `requests>=2.33.1,<3` is pinned to major version 2. When requests 3.0 is released, dependency must be updated.
-- Impact: If requests 3.0 makes breaking changes (API changes, different retry behavior, etc.), this library may not work without code changes.
-- Files: `pyproject.toml:13`
-- Alternative: `httpx` (mentioned in TODO.md as potential replacement for both sync and async) is stable and maintained. Could unify sync/async code if migrated (breaking change for major version only).
-
-**aiohttp Version Constraint:**
-- Risk: `aiohttp>=3.13.5,<4` is pinned to major version 3. aiohttp 4.0 may introduce breaking changes.
-- Impact: Unknown until aiohttp 4.0 release.
-- Files: `pyproject.toml:14`
-
----
-
-## Test Coverage Gaps
-
-**Missing Sync/Async Error Path Coverage:**
-- What's not tested: Several error handling branches in `rest_session.py` and `aio/rest_session.py` are not covered. Lines 146-151 in `meraki/__init__.py` (logging edge case) have no test.
-- Files: `meraki/rest_session.py` (~25 lines uncovered, mostly logger-guarded branches), `meraki/aio/rest_session.py` (~6 lines uncovered), `meraki/__init__.py:146-151`
-- Risk: Log-guarded branches may have silent failures if logger state changes or env var is modified. Exception edge cases (e.g., API returns malformed JSON during retry) are not exercised.
-- Priority: Medium. Coverage target is 95.8% (currently met); these 36 lines are logger/simulate path branches that are rarely triggered in production but should be tested for completeness.
-- Note: TODO.md indicates "wait until after complexity refactors so you're not writing tests for code that's about to move."
-
-**Generator Golden Tests (OASv3):**
-- What's not tested: OASv3 generator output is not verified against golden files (unlike OASv2 which has `tests/generator/test_generate_library_golden.py`).
-- Files: Upcoming in `tests/generator/test_generate_library_oasv3_golden.py`
-- Risk: OASv3 generator bugs will not be caught until live API is tested.
-- Priority: Blocking. Must be implemented before OASv3 generator is promoted to default (see OASV3-MIGRATION.md step 1: "Parity gate").
-
----
-
-## Missing Critical Features
-
-**Type Checking in CI:**
-- Problem: No static type checking (mypy, pyright) in CI. `--no-strict` mode only catches obvious errors.
-- Blocks: Type-aware IDE features for downstream users. Early detection of type mismatches at build time.
-- Files: Relevant to all core modules listed under "No Type Annotations in Core Modules" tech debt section.
-
-**Adaptive Retry Strategy:**
-- Problem: All retries use fixed delays (1 second) or random backoff. No exponential backoff or observability into retry behavior.
-- Blocks: Large-scale deployments cannot tune retry strategy per endpoint or backoff strategy based on API health.
-- Files: `meraki/rest_session.py:296`, `meraki/aio/rest_session.py:212`
-
-**Request Cancellation and Timeout Context:**
-- Problem: No mechanism to cancel in-flight requests except relying on `single_request_timeout`. No request context for tracing or cancellation propagation in async.
-- Blocks: Integrations with observability tools (OpenTelemetry, etc.). Safe cancellation in long-running batches.
-- Files: `meraki/aio/rest_session.py`, `meraki/__init__.py`, `meraki/aio/__init__.py`
-
----
-
-*Concerns audit: 2026-04-29*
diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md
deleted file mode 100644
index 413cc3c7..00000000
--- a/.planning/codebase/CONVENTIONS.md
+++ /dev/null
@@ -1,167 +0,0 @@
-# Coding Conventions
-
-**Analysis Date:** 2026-04-29
-
-## Naming Patterns
-
-**Files:**
-- Snake_case for module files: `rest_session.py`, `response_handler.py`
-- Exception classes in `exceptions.py`
-- Generated API endpoint files use CamelCase where tied to service names: `devices.py`, `networks.py`, `cellularGateway.py`, `wirelessController.py`
-- Batch operation files: `meraki/api/batch/` directory with matching service names
-
-**Functions:**
-- camelCase for all function names: `check_python_version()`, `validate_user_agent()`, `getDevice()`, `updateDevice()`
-- Special methods use Python conventions: `__init__()`, `__repr__()`, `__str__()`
-- Private functions: no underscore prefix in generated code; prefix with `_` for internal helpers
-
-**Variables:**
-- camelCase for parameters: `be_geo_id`, `single_request_timeout`, `certificate_path`
-- snake_case for local variables: `user_agent`, `allowed_format_in_regex`
-- Dicts and collections use descriptive names: `metadata`, `payload`, `body_params`, `kwargs`
-- Metadata dicts follow pattern: `metadata = {"tags": [...], "operation": "..."}`
-
-**Types:**
-- Type hints used in function signatures: `def getDevice(self, serial: str):` (file `meraki/api/devices.py:9`)
-- Type hints for parameters but not always return types
-- Exception classes are all UPPERCASE suffixed with `Error`: `APIKeyError`, `APIResponseError`, `APIError`, `AsyncAPIError`, `PythonVersionError`, `SessionInputError` (file `meraki/exceptions.py`)
-
-**Classes:**
-- CamelCase: `DashboardAPI`, `RestSession`, `AsyncRestSession`, `Devices`, `Organizations` (file `meraki/__init__.py:58`)
-- Class names match API endpoint groups or functionality areas
-- Parent class inherits from `object`: `class DashboardAPI(object):` (file `meraki/__init__.py:58`)
-
-## Code Style
-
-**Formatting:**
-- Line length: 127 characters (configured in `pyproject.toml`)
-- Tool: ruff (formatter and linter)
-- Configuration: `tool.ruff` section in `pyproject.toml`
-
-**Linting:**
-- Tool: ruff with flake8 compatibility
-- Config file: `pyproject.toml` under `[tool.ruff]`
-- Pre-commit hooks run: `ruff-format` and `ruff --fix` (file `.pre-commit-config.yaml`)
-- Exclusions from formatting: generated API code in `meraki/(aio/api|api/batch|api)/`, notebooks, code generation snippets
-
-**Formatting examples from codebase:**
-```python
-# Long parameter lists continue on new lines
-def __init__(
- self,
- api_key=None,
- base_url=DEFAULT_BASE_URL,
- single_request_timeout=SINGLE_REQUEST_TIMEOUT,
- certificate_path=CERTIFICATE_PATH,
- # ... continues
-):
-```
-(file `meraki/__init__.py:87-112`)
-
-## Import Organization
-
-**Order:**
-1. Standard library imports: `import logging`, `import os`, `import platform`, `import re`, `import sys`, `import urllib.parse`
-2. Third-party imports: `import requests`, `import aiohttp`
-3. Local application imports: `from meraki.api.administered import Administered`
-
-**Path Aliases:**
-- No aliases used; direct relative imports within the package
-- Imports from `meraki.config` for constants (file `meraki/__init__.py:25-49`)
-
-**Import patterns:**
-```python
-import logging
-import os
-
-from meraki.api.administered import Administered
-from meraki.config import (
- API_KEY_ENVIRONMENT_VARIABLE,
- DEFAULT_BASE_URL,
- # ... multiline from imports
-)
-```
-(file `meraki/__init__.py:1-49`)
-
-## Error Handling
-
-**Patterns:**
-- Raise custom exceptions defined in `meraki/exceptions.py`: `APIError`, `AsyncAPIError`, `APIKeyError`, `APIResponseError`, `PythonVersionError`, `SessionInputError`
-- Check conditions and raise before proceeding: `if not api_key: raise APIKeyError()` (file `meraki/__init__.py:115-116`)
-- Exception __init__ stores attributes for later access: `self.status`, `self.reason`, `self.message` (file `meraki/exceptions.py:37-48`)
-- Exception __str__ and __repr__ methods format error output: `def exc_message(self):` returns formatted string (file `meraki/exceptions.py:22-26`)
-- Try/except for JSON decode failures: wrap `response.json()` and fallback to `response.content` (file `meraki/exceptions.py:43-46`)
-- 404 status codes append contextual help: "please wait a minute if the key or org was just newly created." (file `meraki/exceptions.py:47-48`)
-
-## Logging
-
-**Framework:** Python's built-in `logging` module
-
-**Patterns:**
-- Logger instance created in `__init__`: `self._logger = logging.getLogger(__name__)` (file `meraki/__init__.py:129`)
-- Logger can be inherited or created fresh based on `inherit_logging_config` flag
-- Suppress logging entirely with `suppress_logging=True` (file `meraki/__init__.py:128`)
-- Standard formatter: `"%(asctime)s %(name)12s: %(levelname)8s > %(message)s"` (file `meraki/__init__.py:135`)
-- Handlers: console handler (StreamHandler) and optional file handler (FileHandler)
-- Log level: DEBUG
-- Conditional logging based on `print_console` and `output_log` parameters (file `meraki/__init__.py:150-154`)
-
-## Comments
-
-**When to Comment:**
-- Docstrings for API methods show endpoint URL and parameters: `"""**Update the attributes of a device** https://developer.cisco.com/meraki/api-v1/..."""` (file `meraki/api/devices.py:28-41`)
-- TODO/FIXME comments exist in codebase (check `TODO.md` for list)
-- Inline comments explain algorithm details, especially in parameter encoding: `"""Encode parameters in a piece of data..."""` (file `meraki/rest_session.py:41-57`)
-
-**Docstring Format:**
-- Triple-quoted strings for docstrings
-- API endpoint methods include: operation description, link to documentation, parameter list with types
-- Example: `"""**Return a single device** https://developer.cisco.com/meraki/api-v1/#!get-device\n\n- serial (string): Serial"""` (file `meraki/api/devices.py:10-14`)
-
-## Function Design
-
-**Size:** Functions generally stay under 50 lines; generated API methods average 10-30 lines
-
-**Parameters:**
-- Use `**kwargs` to capture optional parameters: `def updateDevice(self, serial: str, **kwargs):` (file `meraki/api/devices.py:26`)
-- Extract known parameters from kwargs: `payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}` (file `meraki/api/devices.py:63`)
-- Validate user input early: check Python version at session init, validate API key before creating session
-
-**Return Values:**
-- API methods return result of session call: `return self._session.get(metadata, resource)` (file `meraki/api/devices.py:24`)
-- Exceptions raised on errors, no error codes or None returns for failure
-- Session methods handle retries, redirects, and error conversion internally
-
-## Module Design
-
-**Exports:**
-- Main public interface in `meraki/__init__.py`: imports all top-level API classes and `DashboardAPI`
-- Specific imports exposed: `from meraki.exceptions import APIKeyError` (file `meraki/__init__.py:51`)
-- Version exposed: `from meraki._version import __version__` (file `meraki/__init__.py:52`)
-
-**Barrel Files:**
-- `meraki/__init__.py` re-exports: all API endpoint classes (`Administered`, `Appliance`, `Camera`, etc.)
-- API batch operations under `meraki/api/batch/__init__.py` exports `Batch` class
-- Async variants under `meraki/aio/__init__.py` and `meraki/aio/api/`
-
-**Session Dependency Injection:**
-- All API classes receive session in __init__: `def __init__(self, session): self._session = session` (file `meraki/api/devices.py:5-6`)
-- Session methods (`get`, `post`, `put`, `delete`) handle all HTTP communication
-- Metadata dict passed with operation name and tags to session for logging and error handling
-
-## Configuration
-
-**Environment Variables:**
-- `MERAKI_DASHBOARD_API_KEY`: API key (defined as `API_KEY_ENVIRONMENT_VARIABLE`)
-- `BE_GEO_ID`: Partner identifier (deprecated)
-- `MERAKI_PYTHON_SDK_CALLER`: API caller tracking identifier
-- Default configuration constants in `meraki/config.py`
-
-**Constants Pattern:**
-- All config constants defined in `meraki/config.py` with uppercase names: `SINGLE_REQUEST_TIMEOUT`, `MAXIMUM_RETRIES`, `WAIT_ON_RATE_LIMIT`
-- Imported into `__init__.py` and passed to RestSession
-- Can be overridden at DashboardAPI instantiation time
-
----
-
-*Convention analysis: 2026-04-29*
diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md
deleted file mode 100644
index 49c7ba59..00000000
--- a/.planning/codebase/INTEGRATIONS.md
+++ /dev/null
@@ -1,149 +0,0 @@
-# External Integrations
-
-**Analysis Date:** 2026-04-29
-
-## APIs & External Services
-
-**Cisco Meraki Dashboard API:**
-- REST API for network, device, and organization management
- - Endpoint: `https://api.meraki.com/api/v1` (default) with regional alternatives
- - SDK/Client: requests (sync), aiohttp (async)
- - Auth: Bearer token via `Authorization: Bearer [API_KEY]` header
- - OpenAPI Spec: Available at `https://api.meraki.com/api/v1/openapiSpec`
- - Purpose: Core SDK interfaces all Meraki cloud-managed platform features via 16+ scoped API classes
- - Location: `meraki/api/` and `meraki/aio/api/` contain generated endpoint implementations
-
-**API Scopes/Modules:**
-The SDK exposes the following API scope classes, each wrapping a category of Dashboard API endpoints:
-- Organizations - Org management (`meraki/api/organizations.py`)
-- Networks - Network configuration (`meraki/api/networks.py`)
-- Devices - Device management (`meraki/api/devices.py`)
-- Wireless - Wireless networks (`meraki/api/wireless.py`)
-- Switch - Switch configuration (`meraki/api/switch.py`)
-- Appliance - Security appliance (`meraki/api/appliance.py`)
-- Camera - Camera settings (`meraki/api/camera.py`)
-- Sensor - Environmental sensors (`meraki/api/sensor.py`)
-- Licensing - License management (`meraki/api/licensing.py`)
-- Insight - Network analytics (`meraki/api/insight.py`)
-- SM - System Manager (`meraki/api/sm.py`)
-- CellularGateway - Cellular gateway settings (`meraki/api/cellularGateway.py`)
-- CampusGateway - Campus gateway settings (`meraki/api/campusGateway.py`)
-- WirelessController - Wireless controller (`meraki/api/wirelessController.py`)
-- Spaces - Workspace management (`meraki/api/spaces.py`)
-- Administered - Administered identities (`meraki/api/administered.py`)
-
-## Data Storage
-
-**Databases:**
-- None - This is a client library, not an application with persistent storage
-- All data originates from Cisco Meraki Dashboard cloud platform
-
-**File Storage:**
-- Local filesystem only - Logging output stored to disk if enabled
- - Default log location: Current working directory
- - Log file format: `meraki_api_[YYYY-MM-DD_HH-MM-SS].log`
- - Configurable via `log_path` and `log_file_prefix` parameters
-
-**Caching:**
-- None - Requests library handles HTTP caching per standard HTTP headers
-- Application developers can implement caching layers on top of SDK calls
-
-## Authentication & Identity
-
-**Auth Provider:**
-- Custom (API Key-based)
-- Implementation: HTTP Bearer token via `Authorization` header
-- API Key source: Environment variable `MERAKI_DASHBOARD_API_KEY` or constructor parameter
-- Key format: Long hexadecimal string issued via Meraki Dashboard UI
-- No OAuth, no session management
-- Location: `meraki/rest_session.py` (line 6-96 in sync), `meraki/aio/rest_session.py` (async equivalent)
-
-## Monitoring & Observability
-
-**Error Tracking:**
-- None - Library provides exception objects for caller to handle
-- Custom exception classes: `APIKeyError`, `APIError`, `AsyncAPIError`, `SessionInputError`
-- Location: `meraki/exceptions.py`
-
-**Logs:**
-- Python logging framework (stdlib)
-- Default: Logs to both console and rotating file if enabled
-- Can inherit external logger instance via `inherit_logging_config=True`
-- Log levels: DEBUG for full request/response, INFO for console output
-- Configurable via:
- - `suppress_logging=False` to enable logging
- - `output_log=True` to write to file
- - `print_console=True` for console output
- - `log_path` and `log_file_prefix` for custom locations
-- Location: Configured in `meraki/__init__.py` (lines 127-156)
-
-## CI/CD & Deployment
-
-**Hosting:**
-- PyPI (Python Package Index) - Distribution point for pip/uv installations
-- GitHub (source code and issue tracking)
-- GitHub Actions - CI pipeline for test execution
-
-**CI Pipeline:**
-- pytest runs on PR and main branch commits via GitHub Actions
-- Coverage requirement: 90% minimum (enforced in pyproject.toml)
-- Linting: flake8 and ruff applied via pre-commit hooks
-- Integration tests available in `tests/integration/` (separate from unit tests)
-
-## Environment Configuration
-
-**Required env vars:**
-- `MERAKI_DASHBOARD_API_KEY` - Meraki Dashboard API authentication key (required)
-
-**Optional env vars:**
-- `BE_GEO_ID` - Legacy partner identifier for API tracking (deprecated, use MERAKI_PYTHON_SDK_CALLER)
-- `MERAKI_PYTHON_SDK_CALLER` - Application identifier for API usage tracking (format: "AppName/Version VendorName")
-
-**Secrets location:**
-- Secrets NOT stored in repository (`.env*` in `.gitignore`)
-- Users manage API keys outside the codebase
-- No embedded credentials or configuration defaults for real environments
-
-## Webhooks & Callbacks
-
-**Incoming:**
-- None - SDK does not accept incoming webhooks
-- Users can configure webhooks in Meraki Dashboard to receive events (external to SDK)
-
-**Outgoing:**
-- API supports callback configuration for device events
-- Parameters: `callback` object with either `httpServerId` OR `url` and `sharedSecret`
-- Used in endpoints like `updateDeviceLiveToolsPing`, `createDeviceLiveToolsArpTable`, etc.
-- Location: Callback parameter support visible in `meraki/api/devices.py` and `meraki/aio/api/devices.py`
-- SDK passes callback configuration as JSON to Dashboard API; does not handle callback execution
-
-## Request/Response Patterns
-
-**Rate Limiting:**
-- Dashboard API rate limits via HTTP 429 responses
-- SDK automatically retries 429 errors with exponential backoff
-- Retry wait time: `nginx_429_retry_wait_time` (default 60s)
-- Maximum retries: `maximum_retries` (default 2)
-- Configurable via `wait_on_rate_limit=True/False`
-- `Retry-After` header parsed from 429 responses
-
-**Pagination:**
-- Built-in support for paginated results
-- Query parameters: `perPage`, `startingAfter`, `endingBefore`
-- Methods can return iterators or complete lists via `use_iterator_for_get_pages`
-- Link header parsing for next/prev pages
-
-**Error Handling:**
-- HTTP status codes >= 400 raise APIError with metadata
-- 4XX retries optional via `retry_4xx_error` (default False)
-- SSL/TLS certificate validation customizable via `certificate_path`
-- Proxy support via `requests_proxy` parameter
-
-**Simulation Mode:**
-- `simulate=True` parameter prevents POST/PUT/DELETE from executing
-- GET requests still execute normally
-- Useful for testing workflows without making actual changes
-
----
-
-*Integration audit: 2026-04-29*
diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md
deleted file mode 100644
index a576eadd..00000000
--- a/.planning/codebase/STACK.md
+++ /dev/null
@@ -1,100 +0,0 @@
-# Technology Stack
-
-**Analysis Date:** 2026-04-29
-
-## Languages
-
-**Primary:**
-- Python 3.11+ - Main language for SDK and all client implementations
-
-**Supporting:**
-- Jinja2 (templating) - Used in code generator for API endpoint generation
-
-## Runtime
-
-**Environment:**
-- CPython 3.11+ required per `pyproject.toml`
-
-**Package Manager:**
-- uv (modern Python package manager)
-- Lockfile: `uv.lock` present
-- Build system: Hatchling via `hatchling` backend
-
-## Frameworks
-
-**Core HTTP:**
-- requests 2.33.1-2.99.x - Synchronous HTTP client for REST API calls (`meraki/rest_session.py`)
-- aiohttp 3.13.5-3.99.x - Asynchronous HTTP client for concurrent API calls (`meraki/aio/rest_session.py`)
-
-**Testing:**
-- pytest 8.3.5-9.x - Test runner
-- pytest-asyncio 1.0-1.x - AsyncIO support for tests
-- pytest-cov 7.1.0-7.x - Code coverage tracking
-
-**Code Quality:**
-- flake8 7.0-7.x - Linting
-- ruff 0.15.12+ - Fast linter and formatter
-
-**Development:**
-- pre-commit 4.6.0+ - Git hook framework
-- responses 0.25-0.x - HTTP mocking for tests
-
-**Generator:**
-- Jinja2 3.1.6 - Template rendering for code generation
-
-## Key Dependencies
-
-**Critical:**
-- requests - Handles all synchronous HTTP communication with Meraki Dashboard API (`https://api.meraki.com/api/v1`)
-- aiohttp - Enables concurrent API requests via AsyncIO for performance-critical applications
-
-**Build/Runtime:**
-- hatchling - Build backend for wheel and sdist generation
-
-**Testing/Development:**
-- responses - Mocks HTTP responses in unit tests without hitting real API
-- pytest-asyncio - Manages async test execution and event loop lifecycle
-
-## Configuration
-
-**Environment:**
-- API key: `MERAKI_DASHBOARD_API_KEY` environment variable (fallback to constructor parameter)
-- Optional partner ID: `BE_GEO_ID` environment variable (deprecated but supported)
-- Optional caller identifier: `MERAKI_PYTHON_SDK_CALLER` environment variable for API usage tracking
-- Configuration file: `meraki/config.py` contains default values for all session parameters
-
-**Build:**
-- `pyproject.toml` - Project metadata, dependencies, tool configuration
-- `[tool.ruff]` - Line length set to 127
-- `[tool.pytest.ini_options]` - Test paths: `tests/unit`, test mode: `auto` for asyncio
-- `[tool.coverage.run]` - Coverage source: `meraki/` module, excludes auto-generated API endpoints
-
-**Dependency Groups:**
-- `dev` - Testing and linting tools (pytest, flake8, ruff, pre-commit, responses)
-- `generator` - Code generation dependencies (jinja2)
-
-## Platform Requirements
-
-**Development:**
-- Python 3.11+
-- uv package manager installed
-- Pre-commit hooks configured via `.pre-commit-config.yaml`
-
-**Production:**
-- Python 3.11+
-- requests library installed
-- aiohttp library installed (only if using AsyncIO API)
-- Network connectivity to `https://api.meraki.com/api/v1` (default endpoint)
-- Alternate regional endpoints supported:
- - Canada: `https://api.meraki.ca/api/v1`
- - China: `https://api.meraki.cn/api/v1`
- - India: `https://api.meraki.in/api/v1`
- - US Federal: `https://api.gov-meraki.com/api/v1`
-
-## Version
-
-**Current:** 3.0.0 (aligns with Dashboard API v1.69.0)
-
----
-
-*Stack analysis: 2026-04-29*
diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md
deleted file mode 100644
index 45d35748..00000000
--- a/.planning/codebase/STRUCTURE.md
+++ /dev/null
@@ -1,292 +0,0 @@
-# Codebase Structure
-
-**Analysis Date:** 2026-04-29
-
-## Directory Layout
-
-```
-meraki/
-├── __init__.py # DashboardAPI entry point (sync)
-├── _version.py # Version string
-├── config.py # Configuration constants and defaults
-├── exceptions.py # Exception classes (APIError, APIKeyError, etc.)
-├── common.py # Utility functions (validation, version checks)
-├── rest_session.py # RestSession HTTP layer (sync)
-├── response_handler.py # Response processing utilities
-├── aio/
-│ ├── __init__.py # AsyncDashboardAPI entry point (async)
-│ ├── rest_session.py # AsyncRestSession HTTP layer (async)
-│ └── api/
-│ ├── administered.py # AsyncAdministered scope
-│ ├── organizations.py # AsyncOrganizations scope
-│ ├── networks.py # AsyncNetworks scope
-│ ├── devices.py # AsyncDevices scope
-│ ├── [+ 13 more scope files]
-│ └── ...
-└── api/
- ├── __init__.py # API module init
- ├── administered.py # Administered scope
- ├── organizations.py # Organizations scope
- ├── networks.py # Networks scope
- ├── devices.py # Devices scope
- ├── appliance.py # Appliance scope
- ├── camera.py # Camera scope
- ├── cellularGateway.py # CellularGateway scope
- ├── campusGateway.py # CampusGateway scope
- ├── insight.py # Insight scope
- ├── licensing.py # Licensing scope
- ├── sensor.py # Sensor scope
- ├── sm.py # Systems Manager scope
- ├── spaces.py # Spaces scope
- ├── switch.py # Switch scope
- ├── wireless.py # Wireless scope
- ├── wirelessController.py # WirelessController scope
- └── batch/
- ├── __init__.py # Batch class aggregator
- ├── organizations.py # ActionBatchOrganizations
- ├── networks.py # ActionBatchNetworks
- ├── devices.py # ActionBatchDevices
- ├── [+ more batch scopes]
- └── ...
-
-tests/
-├── unit/
-│ ├── test_dashboard_api_init.py # DashboardAPI initialization tests
-│ ├── test_aio_rest_session.py # AsyncRestSession tests
-│ ├── test_common.py # common.py utility tests
-│ ├── test_mock_integration.py # Mock integration tests
-│ └── test_exceptions.py # Exception tests
-├── integration/
-│ ├── test_dashboard_api_python_library.py # Live API tests (requires key)
-│ ├── test_async_dashboard_api.py # Async integration tests
-│ └── conftest.py # Integration test fixtures
-└── generator/
- ├── test_generate_library_golden.py # Golden file comparison tests
- ├── test_pure_functions.py # Generator utility tests
- ├── conftest.py # Generator test fixtures
- └── golden/
- ├── meraki/api/networks.py # Expected generated output (sync)
- ├── meraki/aio/api/networks.py # Expected generated output (async)
- └── meraki/api/batch/networks.py # Expected generated output (batch)
-
-generator/
-├── generate_library.py # Main OpenAPI v2 library generator
-├── generate_library_oasv3.py # OpenAPI v3 library generator
-├── generate_snippets.py # Code snippet generator
-├── function_template.jinja2 # Template for sync operation methods
-├── async_function_template.jinja2 # Template for async operation methods
-├── batch_function_template.jinja2 # Template for batch operation methods
-├── class_template.jinja2 # Template for scope class wrapper
-├── async_class_template.jinja2 # Template for async scope class wrapper
-├── batch_class_template.jinja2 # Template for batch scope class wrapper
-├── common.py # Generator utility functions
-└── README.md # Generator documentation
-
-examples/
-├── org_wide_clients.py # Example: list all clients in org
-├── aio_org_wide_clients.py # Example: async client listing
-├── aio_ips2firewall.py # Example: async L7 firewall automation
-└── merakiApplianceVlanToL3SwitchInterfaceMigrator/
- └── ... # Example: network migration tool
-
-notebooks/
-└── Uplink preference backup and restore/
- └── ... # Jupyter notebook examples
-```
-
-## Directory Purposes
-
-**meraki/:**
-- Purpose: Main package containing synchronous SDK
-- Contains: Entry points, HTTP session, configuration, exceptions, utility functions
-- Key files: `__init__.py` (DashboardAPI), `rest_session.py` (HTTP layer), `config.py` (defaults)
-
-**meraki/api/:**
-- Purpose: Scope classes for synchronous API operations
-- Contains: 17 resource type classes (Organizations, Networks, Devices, etc.) each exposing operations from OpenAPI spec
-- Key files: `organizations.py`, `networks.py`, `devices.py` (primary scopes)
-
-**meraki/api/batch/:**
-- Purpose: Batch operation helper classes for Action Batch support
-- Contains: Specialized classes for staging batch requests without immediate execution
-- Key files: `__init__.py` (Batch aggregator), scope files mirror api/ structure
-
-**meraki/aio/:**
-- Purpose: Asynchronous SDK using asyncio/aiohttp
-- Contains: Async entry point and HTTP session, mirrors api/ structure
-- Key files: `__init__.py` (AsyncDashboardAPI), `rest_session.py` (async HTTP layer)
-
-**meraki/aio/api/:**
-- Purpose: Scope classes for asynchronous API operations
-- Contains: 17 async scope classes (AsyncOrganizations, AsyncNetworks, etc.)
-- Key files: Mirror meraki/api/ structure with Async prefix and await keywords
-
-**tests/unit/:**
-- Purpose: Unit tests for SDK internals, runnable without API key
-- Contains: DashboardAPI initialization, RestSession behavior, utilities, exceptions
-- Key files: `test_dashboard_api_init.py` (initialization tests), `test_common.py` (utility tests)
-
-**tests/integration/:**
-- Purpose: Integration tests against live Meraki API
-- Contains: Full workflows requiring valid API key and test organization
-- Key files: `test_dashboard_api_python_library.py` (sync tests), `test_async_dashboard_api.py` (async tests)
-
-**tests/generator/:**
-- Purpose: Validation of code generation from OpenAPI spec
-- Contains: Golden file comparison tests ensuring generated code matches expected output
-- Key files: `test_generate_library_golden.py` (output validation), `golden/` (expected files)
-
-**generator/:**
-- Purpose: OpenAPI spec to Python SDK code generation
-- Contains: Jinja2 templates for method/class generation, parser for OpenAPI spec
-- Key files: `generate_library.py` (OpenAPI v2), `generate_library_oasv3.py` (OpenAPI v3), `*_template.jinja2` (templates)
-
-**examples/:**
-- Purpose: Runnable example scripts demonstrating SDK usage
-- Contains: Real-world use cases like client listing, firewall automation
-- Key files: `org_wide_clients.py` (basic example), `aio_ips2firewall.py` (async example)
-
-**notebooks/:**
-- Purpose: Jupyter notebook examples for exploratory usage
-- Contains: Interactive workflows for specific tasks
-- Key files: Uplink preference backup/restore notebook
-
-## Key File Locations
-
-**Entry Points:**
-- `meraki/__init__.py`: DashboardAPI class - instantiate with api_key, exposes scope properties
-- `meraki/aio/__init__.py`: AsyncDashboardAPI class - async context manager, exposes async scope properties
-- `meraki/__main__.py`: Module runnable (if present) or package import for scripts
-
-**Configuration:**
-- `meraki/config.py`: All default constants (timeouts, retry counts, log settings, etc.)
-- `meraki/_version.py`: __version__ string (auto-generated)
-- No .env file in repository; env vars read from MERAKI_DASHBOARD_API_KEY, BE_GEO_ID, MERAKI_PYTHON_SDK_CALLER
-
-**Core Logic:**
-- `meraki/rest_session.py`: RestSession with request(), get(), post(), put(), delete(), get_pages()
-- `meraki/aio/rest_session.py`: AsyncRestSession with identical interface using aiohttp
-- `meraki/api/{scope}.py`: Scope classes (17 total) with operation methods
-- `meraki/aio/api/{scope}.py`: Async scope classes (17 total) mirroring sync structure
-
-**Request Handling:**
-- `meraki/rest_session.py`: HTTP client implementation, retry logic, rate limiting, pagination
-- `meraki/aio/rest_session.py`: Async HTTP client implementation
-- `meraki/response_handler.py`: Response processing (3xx redirect handling)
-- `meraki/common.py`: Validation functions called during initialization
-
-**Error Handling:**
-- `meraki/exceptions.py`: APIKeyError, APIError, AsyncAPIError, PythonVersionError, SessionInputError
-
-**Testing:**
-- `tests/unit/`: Mock-based tests, no API key required
-- `tests/integration/`: Live API tests, requires MERAKI_DASHBOARD_API_KEY
-- `tests/generator/`: Code generation validation against golden files
-- Golden files at `tests/generator/golden/meraki/api/networks.py`, `meraki/aio/api/networks.py`, `meraki/api/batch/networks.py`
-
-## Naming Conventions
-
-**Files:**
-- Scope resource names: camelCase with first letter lowercase (organizations.py, networkDevices.py, cellularGateway.py)
-- Test files: test_{module_under_test}.py or test_{feature}.py
-- Generated code: Classes have generated names matching OpenAPI operationId, methods match API paths
-- Templates: {context}_template.jinja2 (function_template.jinja2, async_function_template.jinja2, batch_function_template.jinja2)
-
-**Directories:**
-- Scope directories: lowercase (api/, aio/, batch/)
-- Test groupings: unit/, integration/, generator/
-- Generator artifacts: golden/ for expected outputs
-
-**Classes:**
-- Sync scopes: PascalCase (Organizations, Networks, Devices)
-- Async scopes: Async{SyncName} (AsyncOrganizations, AsyncNetworks, AsyncDevices)
-- Batch scopes: ActionBatch{SyncName} (ActionBatchOrganizations, ActionBatchNetworks)
-- Main classes: DashboardAPI (sync), AsyncDashboardAPI (async)
-- Exceptions: {Context}Error (APIKeyError, APIError, AsyncAPIError)
-- Sessions: RestSession (sync), AsyncRestSession (async)
-
-**Methods:**
-- API operations: camelCase matching OpenAPI operationId (getOrganizations, createNetwork, updateDevice)
-- Private methods: underscore prefix (_get_pages_iterator, _get_pages_legacy, _session)
-- Properties: snake_case (use_iterator_for_get_pages property)
-- Dunder methods: __init__, __repr__, __str__, __async_enter__, __async_exit__
-
-**Variables:**
-- Module-level constants: SCREAMING_SNAKE_CASE (API_KEY_ENVIRONMENT_VARIABLE, DEFAULT_BASE_URL)
-- Instance attributes: underscore prefix + snake_case (self._api_key, self._base_url, self._session)
-- Local variables: snake_case (params, resource, metadata, total_pages)
-- Generator context: camelCase matching OpenAPI (perPage, startingAfter, endingBefore, organizationId)
-
-## Where to Add New Code
-
-**New API Scope (when OpenAPI spec adds resource):**
-- Sync implementation: `meraki/api/{scope_name}.py` with class {ScopeName} inheriting from object, methods call self._session.{verb}()
-- Async implementation: `meraki/aio/api/{scope_name}.py` with class Async{ScopeName}, methods call await self._session.{verb}()
-- Batch helper: `meraki/api/batch/{scope_name}.py` with class ActionBatch{ScopeName} (stateless helper)
-- Register in: `meraki/__init__.py` (add import and self.{scope_name} property), `meraki/aio/__init__.py` (async equivalent)
-- Pattern: Follow existing scope file structure (getResource, createResource, updateResource, deleteResource methods)
-
-**New Operation (when OpenAPI spec adds endpoint):**
-- Add method to existing scope class in `meraki/api/{scope}.py` and `meraki/aio/api/{scope}.py`
-- Cannot manually add; operations are auto-generated from OpenAPI spec using generator/
-- If manually modifying for testing: follow kwargs.update(locals()), metadata dict, resource path, params extraction pattern
-- Register operation in batch scope if applicable: `meraki/api/batch/{scope}.py`
-
-**New Utility Function:**
-- Shared helpers: `meraki/common.py` (Python checks, validation)
-- Response processing: `meraki/response_handler.py` (HTTP response handling)
-- Exception handling: Add new exception class to `meraki/exceptions.py`
-
-**New Tests:**
-- Unit tests: `tests/unit/test_{feature}.py` with mocked RestSession
-- Integration tests: `tests/integration/test_{feature}.py` with live API calls
-- Generator tests: `tests/generator/test_{aspect}.py` with golden file comparisons
-- Test fixtures: Add to `tests/{category}/conftest.py` (pytest fixtures)
-
-**New Examples:**
-- Example scripts: `examples/{use_case}.py` as runnable Python scripts
-- Jupyter notebooks: `notebooks/{use_case}/` directory with .ipynb file
-- README: Document in examples/README.md and top-level README.md
-
-## Special Directories
-
-**meraki/api/batch/:**
-- Purpose: Batch operation helpers
-- Generated: Partially (method stubs auto-generated)
-- Committed: Yes, checked in to version control
-- Special: Classes are stateless, only build request dicts, do not execute HTTP calls
-
-**tests/generator/golden/:**
-- Purpose: Expected output from code generation
-- Generated: Auto-generated by running generator
-- Committed: Yes, checked in for golden file comparison
-- Special: Used as test fixtures; when generator output matches golden files, tests pass
-
-stitute:
-- Purpose: Cache files
-- Generated: Yes
-- Committed: No (.gitignore excludes)
-- Special: Safe to delete; will be regenerated on next build/test
-
-**.venv/:**
-- Purpose: Python virtual environment
-- Generated: Created by `uv sync`
-- Committed: No (.gitignore excludes)
-- Special: Development only; production uses different installation method
-
-## Generated vs Manual Code
-
-**Generated (from OpenAPI spec):**
-- All scope operation methods: Organizations.getOrganizations(), Networks.createNetwork(), etc.
-- Scope class definitions and parameters
-- Batch operation stubs
-- Generated via `python generator/generate_library_oasv3.py`
-
-**Manual (hand-written):**
-- RestSession HTTP layer: `meraki/rest_session.py`, `meraki/aio/rest_session.py`
-- DashboardAPI initialization: `meraki/__init__.py`, `meraki/aio/__init__.py`
-- Exception classes: `meraki/exceptions.py`
-- Configuration constants: `meraki/config.py`
-- Utility functions: `meraki/common.py`
-- Tests: All test files
-- Generator itself: `generator/*.py`
diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md
deleted file mode 100644
index a0a68f62..00000000
--- a/.planning/codebase/TESTING.md
+++ /dev/null
@@ -1,251 +0,0 @@
-# Testing Patterns
-
-**Analysis Date:** 2026-04-29
-
-## Test Framework
-
-**Runner:**
-- pytest 8.3.5 through 9.x
-- Config: `pyproject.toml` under `[tool.pytest.ini_options]`
-- Test paths: `tests/unit` (unit tests only; excludes `tests/generator`)
-
-**Assertion Library:**
-- pytest's built-in assertions and `assert` statements
-- No separate assertion library; standard pytest patterns
-
-**Run Commands:**
-```bash
-pytest # Run all unit tests
-pytest -v # Verbose output
-pytest tests/unit/ # Run unit tests only
-pytest -k "test_name" # Run specific test
-pytest --cov=meraki # Run with coverage
-pytest --cov=meraki --cov-report=html # Generate HTML coverage report
-pytest -m integration # Run integration tests (if marked)
-```
-
-**Coverage:**
-- Tool: pytest-cov
-- Requirement: 90% minimum (configured in `pyproject.toml` as `fail_under = 90`)
-- Excluded from coverage: `meraki/api/*` and `meraki/aio/api/*` (generated files) and `meraki/aio/__init__.py`
-- Report shows missing lines: `show_missing = true`
-
-## Test File Organization
-
-**Location:**
-- Co-located pattern: test files in `tests/` directory mirror source structure
-- Unit tests: `tests/unit/`
-- Integration tests: `tests/integration/`
-- Generator tests: `tests/generator/` (not run with default pytest)
-
-**Naming:**
-- Files: `test_*.py` prefix pattern (pytest discovery)
-- Example: `test_common.py`, `test_rest_session.py`, `test_exceptions.py`, `test_aio_rest_session.py`
-
-**Structure:**
-```
-tests/
-├── unit/
-│ ├── test_common.py
-│ ├── test_dashboard_api_init.py
-│ ├── test_exceptions.py
-│ ├── test_mock_integration.py
-│ ├── test_response_handler.py
-│ ├── test_rest_session.py
-│ └── test_aio_rest_session.py
-├── integration/
-│ ├── conftest.py (pytest_addoption for --apikey and --o flags)
-│ ├── test_async_dashboard_api.py
-│ └── test_dashboard_api_python_library.py
-└── generator/
- ├── conftest.py
- └── test_pure_functions.py
-```
-
-## Test Structure
-
-**Suite Organization:**
-- Test classes group related functionality: `class TestCheckPythonVersion:`, `class TestValidateUserAgent:`, `class TestAPIKeyError:` (file `tests/unit/test_common.py:16-30`)
-- One test method per assertion focus: `def test_valid_version(self):`, `def test_too_old_raises(self):`, `def test_python2_raises(self):`
-- Async tests use `async def test_*` pattern with pytest-asyncio
-- Async session fixture patches dependencies and creates mock client: (file `tests/unit/test_aio_rest_session.py:57-84`)
-
-**Patterns:**
-- Setup: `@pytest.fixture` decorator for reusable test fixtures
-- Example fixture for sync RestSession: (file `tests/unit/test_rest_session.py:10-32`)
- ```python
- @pytest.fixture
- def session():
- with patch("meraki.rest_session.check_python_version"):
- s = RestSession(
- logger=None,
- api_key="fake_api_key_1234567890123456789012345678901234567890",
- # ... all required parameters
- )
- return s
- ```
-- Teardown: No explicit teardown; fixtures auto-clean when test ends
-- Assertion pattern: `assert` statements with descriptive conditions: `assert "TestApp TestVendor" in result`
-
-## Mocking
-
-**Framework:** `unittest.mock` from Python standard library
-
-**Patterns:**
-```python
-from unittest.mock import MagicMock, patch, AsyncMock
-
-# Patch function/class
-@patch("meraki.rest_session.check_python_version")
-def test_something(self, mock_check):
- s = RestSession(...)
-
-# Patch with context manager
-with patch("meraki.rest_session.check_python_version"):
- from meraki.aio.rest_session import AsyncRestSession
- s = AsyncRestSession(...)
-
-# Create mock response
-resp = MagicMock(spec=requests.Response)
-resp.status_code = 200
-resp.reason = "OK"
-resp.headers = {}
-resp.content = b'{"ok":true}'
-resp.links = {}
-resp.json.return_value = {"ok": True}
-resp.close = MagicMock()
-```
-(file `tests/unit/test_rest_session.py:39-55`)
-
-**Async Mocking:**
-- AsyncMock for async methods: `AsyncMock()` returns awaitable
-- Wrapper class `_AwaitableValue` (file `tests/unit/test_aio_rest_session.py:10-50`) makes values both synchronously usable and awaitable for error handling compatibility
-- Patch `aiohttp.ClientSession`: (file `tests/unit/test_aio_rest_session.py:59-63`)
-
-**What to Mock:**
-- External dependencies: `requests.Response`, `aiohttp.ClientSession`, platform functions
-- Functions that check system state: `platform.python_version_tuple()`
-- Network calls: HTTP session methods
-- Do NOT mock the code being tested (the actual session class)
-
-**What NOT to Mock:**
-- The actual session classes being tested
-- Utility functions like `encode_params()`, `validate_base_url()`
-- Exception classes (let them construct naturally)
-- Local utility functions used by the code under test
-
-## Fixtures and Factories
-
-**Test Data:**
-- Helper method pattern: `def _metadata(operation="getOrganizations", tags=None):` (file `tests/unit/test_rest_session.py:35-36`)
-- Mock response factory: `def _mock_response(status_code=200, ...)` with parameter defaults (file `tests/unit/test_rest_session.py:39-55`)
-- pytest fixture for session: creates session with mocked dependencies (file `tests/unit/test_rest_session.py:10-32`)
-- Fixture scope: default (function scope); creates fresh instance per test
-
-**Location:**
-- Fixtures defined at module level or in fixture file
-- Helper methods defined as class methods with `_` prefix: `def _make_response(self, ...)`
-- Shared fixtures can be moved to `conftest.py` (see `tests/integration/conftest.py`)
-
-**Example fixture:**
-```python
-@pytest.fixture
-def session():
- with patch("meraki.rest_session.check_python_version"):
- s = RestSession(
- logger=None,
- api_key="fake_api_key_1234567890123456789012345678901234567890",
- base_url="https://api.meraki.com/api/v1",
- single_request_timeout=60,
- # ... all parameters
- )
- return s
-```
-
-## Test Types
-
-**Unit Tests:**
-- Scope: Test individual functions and methods in isolation
-- Location: `tests/unit/`
-- Examples: `test_common.py` tests validation functions, `test_exceptions.py` tests exception classes
-- Pattern: Mock external dependencies, test internal logic
-- Coverage requirement: 90% minimum
-
-**Integration Tests:**
-- Scope: Test multiple components working together; typically requires real API key
-- Location: `tests/integration/`
-- Execution: Optional; requires `--apikey` parameter
-- Example: `test_dashboard_api_python_library.py` makes real API calls
-- CLI pattern: `pytest tests/integration/ --apikey=YOUR_KEY`
-
-**E2E Tests:**
-- Not used; integration tests serve as end-to-end validation
-
-**Generator Tests:**
-- Location: `tests/generator/`
-- Purpose: Test code generation templates and functions
-- Pattern: Pure function tests with fixtures
-- Example: `test_pure_functions.py` tests documentation URL generation, pagination generation (file `tests/generator/test_pure_functions.py:1-42`)
-
-## Async Testing
-
-**Pattern:**
-```python
-@pytest.mark.asyncio
-async def test_async_method(self):
- # test code
- await some_async_call()
-```
-
-**Async Session Creation:**
-```python
-@pytest.fixture
-async def async_session():
- with (
- patch("meraki.aio.rest_session.check_python_version"),
- patch("aiohttp.ClientSession") as mock_client,
- ):
- mock_client.return_value = MagicMock()
- from meraki.aio.rest_session import AsyncRestSession
- s = AsyncRestSession(...)
- return s
-```
-(file `tests/unit/test_aio_rest_session.py:57-84`)
-
-**pytest-asyncio Configuration:**
-- `asyncio_mode = "auto"` in `pyproject.toml` (line 56)
-- Automatically runs async tests without needing explicit markers in simple cases
-
-## Error Testing
-
-**Pattern:**
-```python
-def test_invalid_caller_raises(self):
- with pytest.raises(SessionInputError):
- validate_user_agent("", "invalid format!!!")
-
-def test_too_old_raises(self, mock_ver):
- with pytest.raises(PythonVersionError):
- check_python_version()
-```
-(file `tests/unit/test_common.py:42-44`, `tests/unit/test_common.py:22-24`)
-
-**Exception Assertion:**
-- Use `pytest.raises(ExceptionType)` context manager
-- Verify exception message/attributes: `err.message`, `err.status`, `err.reason`
-- Test both exception raising and exception attribute values (file `tests/unit/test_exceptions.py:64-72`)
-
-## Coverage Gaps and Strategy
-
-**Excluded from Coverage:**
-- Generated API endpoint files: `meraki/api/*` and `meraki/aio/api/*` (auto-generated; coverage would be fragile)
-- Async init files: `meraki/aio/__init__.py`
-
-**Testing Strategy:**
-- Unit test the non-generated code: session management, error handling, utilities
-- Integration tests for actual API calls to validate end-to-end flow
-- Generator tests validate template functions work correctly
-
----
-
-*Testing analysis: 2026-04-29*
diff --git a/.planning/milestones/v1.0-REQUIREMENTS.md b/.planning/milestones/v1.0-REQUIREMENTS.md
deleted file mode 100644
index 5bf3d3a7..00000000
--- a/.planning/milestones/v1.0-REQUIREMENTS.md
+++ /dev/null
@@ -1,88 +0,0 @@
-# Requirements Archive: v1.0 OASv3 Generator
-
-**Archived:** 2026-04-30
-**Status:** SHIPPED
-
-For current requirements, see `.planning/REQUIREMENTS.md`.
-
----
-
-# Requirements: Meraki Dashboard API Python SDK
-
-**Defined:** 2026-04-29
-**Core Value:** Developers can interact with every Meraki Dashboard API endpoint through a well-typed, well-documented Python client that stays current with the live API spec.
-
-## v3.1.0 Requirements
-
-Requirements for OASv3 generator milestone. Each maps to roadmap phases.
-
-### Parsing
-
-- [ ] **PARSE-01**: Generator resolves `$ref` JSON pointers with cycle protection and caching
-- [ ] **PARSE-02**: Generator parses `requestBody` for application/json, multipart/form-data, and application/octet-stream
-- [ ] **PARSE-03**: Generator handles `nullable: true` with `| None` type annotations and docstring notes
-- [ ] **PARSE-04**: Generator inherits path-level parameters, operation overrides on name+in match
-- [ ] **PARSE-05**: Generator resolves `oneOf` schemas as "string or object" with sub-property documentation
-- [ ] **PARSE-06**: Generator respects array param `style`/`explode` attributes (default: form + explode:true)
-
-### Code Generation
-
-- [ ] **GEN-01**: Generator produces sync/async/batch modules matching v2 output structure
-- [ ] **GEN-02**: Generated methods use explicit param construction instead of `kwargs.update(locals())`
-- [ ] **GEN-03**: Generator produces `.pyi` type stubs with full signatures via `--stubs` flag
-- [ ] **GEN-04**: Generator handles `x-batchable-actions` for batch class generation
-- [ ] **GEN-05**: CLI accepts same args as v2 (`-h`, `-o`, `-k`, `-v`, `-a`, `-g`) and fetches v3 spec
-
-### Testing & CI
-
-- [ ] **TEST-01**: Synthetic v3 fixture exercises all v3-specific features ($ref, requestBody, oneOf, nullable, multipart, path-level params)
-- [ ] **TEST-02**: Golden-file tests validate v3 generator output for sync, async, and batch modules
-- [ ] **TEST-03**: CI workflow runs semantic diff of v2 vs v3 generator output on live spec
-
-## Future Requirements
-
-### Deprecation Cycle
-
-- **DEP-01**: Rename v2 generator to `generate_library_oasv2.py` with deprecation warning
-- **DEP-02**: Promote v3 generator to `generate_library.py` (new default)
-- **DEP-03**: Remove v2 generator after one minor version cycle with no rollbacks
-
-## Out of Scope
-
-| Feature | Reason |
-|---------|--------|
-| Modifying the v2 generator | Kept for rollback until parity gate passes |
-| Changing runtime SDK behavior | rest_session, pagination, etc. are stable |
-| OpenAPI 3.1 support | Only 3.0.1 `nullable: true` style, not 3.1 `type: [string, null]` |
-| Rewriting Jinja2 templates from scratch | Reuse existing, extend as needed |
-| Pydantic model generation | Over-engineering; dict-based params match existing SDK contract |
-| Client-side request validation | Adds overhead, API validates server-side |
-| OAuth helper generation | Not part of Meraki auth model |
-
-## Traceability
-
-| Requirement | Phase | Status |
-|-------------|-------|--------|
-| PARSE-01 | Phase 1 | Pending |
-| PARSE-02 | Phase 1 | Pending |
-| PARSE-03 | Phase 2 | Pending |
-| PARSE-04 | Phase 2 | Pending |
-| PARSE-05 | Phase 2 | Pending |
-| PARSE-06 | Phase 2 | Pending |
-| GEN-01 | Phase 3 | Pending |
-| GEN-02 | Phase 3 | Pending |
-| GEN-03 | Phase 4 | Pending |
-| GEN-04 | Phase 3 | Pending |
-| GEN-05 | Phase 3 | Pending |
-| TEST-01 | Phase 5 | Pending |
-| TEST-02 | Phase 5 | Pending |
-| TEST-03 | Phase 5 | Pending |
-
-**Coverage:**
-- v3.1.0 requirements: 14 total
-- Mapped to phases: 14
-- Unmapped: 0
-
----
-*Requirements defined: 2026-04-29*
-*Last updated: 2026-04-29 after roadmap creation*
diff --git a/.planning/milestones/v1.0-ROADMAP.md b/.planning/milestones/v1.0-ROADMAP.md
deleted file mode 100644
index 764fcec8..00000000
--- a/.planning/milestones/v1.0-ROADMAP.md
+++ /dev/null
@@ -1,103 +0,0 @@
-# Roadmap: Meraki Dashboard API Python SDK
-
-## Overview
-
-Build a modular OASv3 generator that replaces the abandoned monolithic attempt, following v2's proven parse-organize-render architecture. Parser layer normalizes OASv3 features ($ref, requestBody, oneOf, nullable) into v2-compatible param dicts for template reuse. Five phases deliver foundation parsers, unified parameter handling, module generation, type stubs, and comprehensive testing with CI drift detection.
-
-## Phases
-
-**Phase Numbering:**
-- Integer phases (1, 2, 3): Planned milestone work
-- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
-
-Decimal phases appear between their surrounding integers in numeric order.
-
-- [ ] **Phase 1: Parser Foundation** - Core v3 parsing with $ref resolution, caching, and requestBody handling
-- [ ] **Phase 2: Unified Parameter Parser** - Path-level inheritance, nullable, oneOf, and array serialization
-- [ ] **Phase 3: Generation Integration** - Module generation, batch actions, and CLI entry point
-- [ ] **Phase 4: Type Stubs** - .pyi generation via Jinja2 for static analysis
-- [ ] **Phase 5: Testing & CI** - Golden files, v2 vs v3 drift detection, and integration tests
-
-## Phase Details
-
-### Phase 1: Parser Foundation
-**Goal**: Core v3 parsing functions normalize $ref resolution and requestBody handling
-**Depends on**: Nothing (first phase)
-**Requirements**: PARSE-01, PARSE-02
-**Success Criteria** (what must be TRUE):
- 1. Generator resolves $ref JSON pointers with cycle detection (no stack overflow on circular schemas)
- 2. Generator caches resolved $refs (no O(n^2) performance degradation)
- 3. Generator parses requestBody for application/json, multipart/form-data, and application/octet-stream
- 4. Parser functions return normalized params dict matching v2 format (enables template reuse)
-**Plans**: 2 plans
-Plans:
-- [x] 01-01-PLAN.md: $ref resolution with caching and cycle detection (TDD)
-- [x] 01-02-PLAN.md: requestBody parsing for json, multipart, octet-stream (TDD)
-
-### Phase 2: Unified Parameter Parser
-**Goal**: Unified parse_params_v3 function merges path, query, and body parameters with OASv3 features
-**Depends on**: Phase 1
-**Requirements**: PARSE-03, PARSE-04, PARSE-05, PARSE-06
-**Success Criteria** (what must be TRUE):
- 1. Generator inherits path-level parameters into operations (operation params override on name match)
- 2. Generator adds | None to type annotations for nullable: true parameters
- 3. Generator documents oneOf query params as "string or object" (not generic "object")
- 4. Golden-file test with synthetic v3 fixture validates parser output format
-**Plans**: 2 plans
-Plans:
-- [x] 02-01-PLAN.md: TDD parse_params_v3 with path inheritance, nullable, oneOf, style/explode
-- [x] 02-02-PLAN.md: Golden-file snapshot test for output contract validation
-
-### Phase 3: Generation Integration
-**Goal**: v3 generator produces sync, async, and batch modules from OASv3 spec
-**Depends on**: Phase 2
-**Requirements**: GEN-01, GEN-02, GEN-04, GEN-05
-**Success Criteria** (what must be TRUE):
- 1. Generator produces meraki/api/, meraki/aio/api/, and meraki/api/batch/ modules matching v2 structure
- 2. Generated methods use explicit param construction (not kwargs.update(locals()))
- 3. Generator handles x-batchable-actions for batch class generation (298 batch endpoints)
- 4. CLI accepts same args as v2 and fetches v3 spec with ?version=3 param
-**Plans**: 2 plans
-Plans:
-- [x] 03-01-PLAN.md: Core v3 generator module (sync/async/batch generation, explicit params, batch action matching)
-- [x] 03-02-PLAN.md: CLI entry point with v3 spec fetch and integration tests
-
-### Phase 4: Type Stubs
-**Goal**: Generator produces .pyi type stubs via Jinja2 for static analysis
-**Depends on**: Phase 3
-**Requirements**: GEN-03
-**Success Criteria** (what must be TRUE):
- 1. Generator creates .pyi files with full method signatures when --stubs flag passed
- 2. Package includes py.typed marker for PEP 561 compliance
- 3. Type stubs reflect nullable (str | None) and oneOf (Union[str, dict]) semantics
-**Plans**: 2 plans
-Plans:
-- [x] 04-01-PLAN.md: TDD stub generation module with typed signatures (nullable, oneOf)
-- [x] 04-02-PLAN.md: CLI --stubs flag integration and py.typed marker
-
-### Phase 5: Testing & CI
-**Goal**: Comprehensive test suite and CI drift detection validate v3 generator correctness
-**Depends on**: Phase 4
-**Requirements**: TEST-01, TEST-02, TEST-03
-**Success Criteria** (what must be TRUE):
- 1. Synthetic v3 fixture exercises all v3-specific features ($ref with cycles, requestBody, oneOf, nullable, multipart, path-level params)
- 2. Golden-file tests validate v3 generator output for sync, async, and batch modules (semantic correctness, not byte-for-byte v2 match)
- 3. CI workflow runs semantic diff of v2 vs v3 generator output on live spec (params, types, structure, not text diff)
-**Plans**: 3 plans
-Plans:
-- [x] 05-01-PLAN.md: Comprehensive synthetic v3 fixture with coverage assertion tests
-- [x] 05-02-PLAN.md: Golden-file tests for v3 generator output (sync, async, batch)
-- [x] 05-03-PLAN.md: CI semantic diff workflow for v2/v3 drift detection
-
-## Progress
-
-**Execution Order:**
-Phases execute in numeric order: 1 > 2 > 3 > 4 > 5
-
-| Phase | Plans Complete | Status | Completed |
-|-------|----------------|--------|-----------|
-| 1. Parser Foundation | 2/2 | Complete | 2026-04-30 |
-| 2. Unified Parameter Parser | 2/2 | Complete | 2026-04-30 |
-| 3. Generation Integration | 2/2 | Complete | 2026-04-30 |
-| 4. Type Stubs | 2/2 | Complete | 2026-04-30 |
-| 5. Testing & CI | 0/3 | Planning complete | - |
diff --git a/.planning/phases/06-generator-swap/06-01-PLAN.md b/.planning/phases/06-generator-swap/06-01-PLAN.md
deleted file mode 100644
index 76a684a7..00000000
--- a/.planning/phases/06-generator-swap/06-01-PLAN.md
+++ /dev/null
@@ -1,153 +0,0 @@
----
-phase: 06-generator-swap
-plan: 01
-type: execute
-wave: 1
-depends_on: []
-files_modified:
- - generator/generate_library.py
- - generator/generate_library_v3.py
- - generator/generate_library_oasv2.py
- - tests/generator/test_generate_library_v3.py
-autonomous: true
-requirements: [DEP-01, DEP-02]
-must_haves:
- truths:
- - Running python generator/generate_library.py executes v3 generator code
- - Running python generator/generate_library_oasv2.py emits deprecation warning
- - Both generators produce valid SDK output
- - Tests pass with updated imports
- artifacts:
- - path: generator/generate_library.py
- provides: Default v3 generator entry point
- min_lines: 600
- contains: "parser_v3"
- - path: generator/generate_library_oasv2.py
- provides: Deprecated v2 generator with warning
- min_lines: 800
- contains: "DeprecationWarning"
- - path: tests/generator/test_generate_library_v3.py
- provides: Updated test imports
- exports: ["import generate_library"]
- key_links:
- - from: generator/generate_library.py
- to: parser_v3
- via: import statement
- pattern: "from parser_v3 import"
- - from: generator/generate_library_oasv2.py
- to: warnings module
- via: deprecation call
- pattern: "warnings\\.warn.*DeprecationWarning"
- - from: tests/generator/test_generate_library_v3.py
- to: generator/generate_library.py
- via: import statement
- pattern: "import generate_library"
----
-
-
-Promote v3 generator to default entry point, deprecate v2 generator.
-
-Purpose: Make v3 parser the production default while preserving v2 for one version cycle.
-Output: Renamed files with deprecation warning in v2, updated test imports.
-
-
-
-@C:\Users\jkuchta\.claude\get-shit-done\workflows\execute-plan.md
-@C:\Users\jkuchta\.claude\get-shit-done\templates\summary.md
-
-
-
-@.planning/PROJECT.md
-@.planning/ROADMAP.md
-@.planning/STATE.md
-@.planning/REQUIREMENTS.md
-@.planning/phases/06-generator-swap/06-CONTEXT.md
-
-
-
-
-
- Task 1: Swap generator files and add deprecation warning
-
- - generator/generate_library.py (full file)
- - generator/generate_library_v3.py (full file)
-
-
- generator/generate_library.py
- generator/generate_library_v3.py
- generator/generate_library_oasv2.py
-
-
-Rename current generate_library.py to generate_library_oasv2.py using git mv. Add deprecation warning at top of generate_library_oasv2.py after imports:
-
-```python
-import warnings
-warnings.warn(
- "generate_library_oasv2.py is deprecated and will be removed in v1.2. Use generate_library.py instead.",
- DeprecationWarning,
- stacklevel=2
-)
-```
-
-Then rename generate_library_v3.py to generate_library.py using git mv. Remove imports from generate_library.py (line 14-20) that reference the old generate_library module since it's now this file.
-
-
- python -c "import warnings; warnings.simplefilter('always', DeprecationWarning); exec(open('generator/generate_library_oasv2.py').read())"
-
-
- - generator/generate_library_oasv2.py exists and emits DeprecationWarning when imported
- - generator/generate_library.py exists and contains parser_v3 import
- - generator/generate_library_v3.py no longer exists
- - No circular imports in generate_library.py
-
- File swap complete, deprecation warning working, no import errors.
-
-
-
- Task 2: Update test imports
-
- - tests/generator/test_generate_library_v3.py (full file)
-
-
- tests/generator/test_generate_library_v3.py
-
-
-Replace all occurrences of `generate_library_v3` with `generate_library` in test_generate_library_v3.py. This includes:
-- Import statement (line 41, 56)
-- Patch targets (line 45, 60)
-- Module references in test functions
-
-Do not rename the test file itself (it can still be called test_generate_library_v3.py for now).
-
-
- python -m pytest tests/generator/test_generate_library_v3.py -v
-
-
- - All test imports reference generate_library (not generate_library_v3)
- - All patch decorators target generate_library module
- - All tests pass
- - Test file references correct module name in import and patch statements
-
- Test imports updated, pytest runs clean.
-
-
-
-
-
-1. Run `python generator/generate_library.py -h` (should show v3 help, no errors)
-2. Run `python generator/generate_library_oasv2.py -h` (should show deprecation warning, then help)
-3. Run `python -m pytest tests/generator/test_generate_library_v3.py -v` (all tests pass)
-4. Check git status shows three renames: generate_library.py → generate_library_oasv2.py, generate_library_v3.py → generate_library.py, and modified test file
-
-
-
-- [ ] `python generator/generate_library.py` uses v3 parser (no errors)
-- [ ] `python generator/generate_library_oasv2.py` emits DeprecationWarning
-- [ ] Tests pass with new import paths
-- [ ] No broken imports or circular dependencies
-- [ ] Both generators produce valid output when run
-
-
-
diff --git a/.planning/phases/06-generator-swap/06-01-SUMMARY.md b/.planning/phases/06-generator-swap/06-01-SUMMARY.md
deleted file mode 100644
index 5ded93bb..00000000
--- a/.planning/phases/06-generator-swap/06-01-SUMMARY.md
+++ /dev/null
@@ -1,93 +0,0 @@
----
-phase: 06-generator-swap
-plan: 01
-subsystem: generator
-tags: [deprecation, file-rename, imports]
-dependency_graph:
- requires: [05-03]
- provides: [v3-default-entry]
- affects: [generator-entry, test-imports]
-tech_stack:
- added: []
- patterns: [deprecation-warning, import-redirect]
-key_files:
- created:
- - generator/generate_library_oasv2.py
- modified:
- - generator/generate_library.py
- - generator/parser_v3.py
- - generator/generate_stubs.py
- - tests/generator/test_generate_library_v3.py
- deleted:
- - generator/generate_library_v3.py
-decisions:
- - Import chain redirected through oasv2 module to avoid circular dependencies
- - Deprecation warning fires on module import rather than function call
- - Test file retains v3 naming for continuity
-metrics:
- tasks_completed: 2
- tasks_total: 2
- duration_minutes: 8
- files_modified: 5
- completed_date: 2026-04-30
----
-
-# Phase 06 Plan 01: Generator Swap Summary
-
-v3 generator promoted to default entry point, v2 deprecated with warning.
-
-## What Was Done
-
-Renamed v2 generator to `generate_library_oasv2.py` with DeprecationWarning, promoted v3 generator to `generate_library.py` as production default. Updated import chains in parser_v3.py and generate_stubs.py to reference oasv2 module, preventing circular imports. Test suite updated to import from new default module name.
-
-## Tasks Completed
-
-| Task | Name | Commit | Key Changes |
-|------|------|--------|-------------|
-| 1 | Swap generator files and add deprecation | 4ac836c | Renamed v2→oasv2, v3→default, added warning, fixed imports |
-| 2 | Update test imports | b8d9139 | Changed generate_library_v3→generate_library in tests |
-
-## Deviations from Plan
-
-**1. [Rule 3 - Blocking] Fixed circular import**
-- **Found during:** Task 1 verification
-- **Issue:** generate_library.py imported from parser_v3.py, parser_v3.py imported from generate_library.py
-- **Fix:** Updated parser_v3.py and generate_stubs.py to import from generate_library_oasv2 instead
-- **Files modified:** generator/parser_v3.py, generator/generate_stubs.py
-- **Commit:** 4ac836c
-
-## Verification Results
-
-All plan verification steps passed:
-
-1. ✓ `python generator/generate_library.py -h` shows v3 help (no errors)
-2. ✓ `python generator/generate_library_oasv2.py -h` emits DeprecationWarning
-3. ✓ `python -m pytest tests/generator/test_generate_library_v3.py -v` passes (16/16 tests)
-4. ✓ Git history shows correct renames and modifications
-
-## Output Artifacts
-
-- **generator/generate_library.py**: Default v3 generator entry point (631 lines)
-- **generator/generate_library_oasv2.py**: Deprecated v2 generator with warning (816 lines)
-- **tests/generator/test_generate_library_v3.py**: Updated test imports (213 lines)
-
-## Known Stubs
-
-None. All functionality is wired and operational.
-
-## Self-Check: PASSED
-
-**Created files:**
-- FOUND: generator/generate_library_oasv2.py
-
-**Modified files:**
-- FOUND: generator/generate_library.py
-- FOUND: generator/parser_v3.py
-- FOUND: generator/generate_stubs.py
-- FOUND: tests/generator/test_generate_library_v3.py
-
-**Commits:**
-- FOUND: 4ac836c
-- FOUND: b8d9139
-
-All files and commits verified present.
diff --git a/.planning/phases/06-generator-swap/06-CONTEXT.md b/.planning/phases/06-generator-swap/06-CONTEXT.md
deleted file mode 100644
index 47bb5fbd..00000000
--- a/.planning/phases/06-generator-swap/06-CONTEXT.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# Phase 6: Generator Swap - Context
-
-**Gathered:** 2026-04-30
-**Status:** Ready for planning
-**Mode:** Auto-generated (infrastructure phase)
-
-
-## Phase Boundary
-
-Rename v2 generator to generate_library_oasv2.py with deprecation warning, promote v3 generator to generate_library.py as the new default entry point.
-
-
-
-
-## Implementation Decisions
-
-### Claude's Discretion
-All implementation choices are at Claude's discretion. Key constraints:
-
-- `generate_library.py` must become v3 generator (copy/move generate_library_v3.py content)
-- `generate_library_oasv2.py` must be the old v2 generator (rename generate_library.py)
-- Deprecation warning on v2: `warnings.warn("generate_library_oasv2.py is deprecated, use generate_library.py", DeprecationWarning)`
-- Both generators must still work after the swap
-- Tests must still pass
-
-
-
-
-## Existing Code Insights
-
-### Files to swap
-- `generator/generate_library.py` (current v2, 800+ lines) -> `generator/generate_library_oasv2.py`
-- `generator/generate_library_v3.py` (current v3, 630 lines) -> `generator/generate_library.py`
-
-### Integration Points
-- Tests reference `generate_library_v3.py` in imports
-- CI workflows may reference `generate_library.py`
-- `generate_library.py` is called from project scripts
-
-
-
-
-## Specific Ideas
-
-No specific requirements beyond the swap.
-
-
-
-
-## Deferred Ideas
-
-None.
-
-
diff --git a/.planning/phases/07-legacy-cleanup/07-CONTEXT.md b/.planning/phases/07-legacy-cleanup/07-CONTEXT.md
deleted file mode 100644
index 748ef0d1..00000000
--- a/.planning/phases/07-legacy-cleanup/07-CONTEXT.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Phase 7: Legacy Cleanup - Context
-
-**Gathered:** 2026-04-30
-**Status:** Ready for planning
-**Mode:** Auto-generated (infrastructure phase)
-
-
-## Phase Boundary
-
-Remove abandoned generate_library_oasv3.py, update all imports and CI workflows referencing old filenames.
-
-
-
-
-## Implementation Decisions
-
-### Claude's Discretion
-All implementation choices are at Claude's discretion. Key constraints:
-
-- Delete `generator/generate_library_oasv3.py` (dead code, abandoned monolithic v3 attempt)
-- Update any CI workflows referencing old generator filenames
-- Update any documentation referencing old filenames
-- Ensure all internal imports use correct current filenames
-
-
-
-
-## Existing Code Insights
-
-### Files to remove
-- `generator/generate_library_oasv3.py` (abandoned v3 attempt, not used by anything)
-
-### References to update
-- Check .github/workflows/ for references to generate_library_v3.py or generate_library_oasv3.py
-- Check scripts/semantic_diff_v2_v3.py for old references
-- Check any README or docs referencing old filenames
-
-
-
-
-## Specific Ideas
-
-None.
-
-
-
-
-## Deferred Ideas
-
-None.
-
-
diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md
deleted file mode 100644
index 83b63565..00000000
--- a/.planning/research/ARCHITECTURE.md
+++ /dev/null
@@ -1,664 +0,0 @@
-# Architecture Patterns: OASv3 Code Generator Integration
-
-**Domain:** Code generator for OpenAPI v3 SDK
-**Researched:** 2026-04-29
-
-## Recommended Architecture
-
-The v3 generator should follow v2's modular parse-organize-render pipeline but with a separate spec parsing layer that normalizes OASv3 features into the existing template data model.
-
-```
-┌─────────────────────────────────────────────────────────────────────┐
-│ Main Entry Point (generate_library_oasv3.py) │
-│ - CLI parsing (reuse v2 pattern) │
-│ - Spec fetching (?version=3 query param) │
-│ - Call generate_library() │
-└────────────────────────┬────────────────────────────────────────────┘
- │
- ┌────────────▼──────────────┐
- │ generate_library() │
- │ - Scope initialization │
- │ - Directory creation │
- │ - Non-generated files │
- │ - organize_spec() call │
- └────────────┬───────────────┘
- │
- ┌────────────▼──────────────────────────┐
- │ OASv3 Parser Layer (NEW) │
- │ - resolve_ref() with cache & cycles │
- │ - parse_params_v3() unified function │
- │ - Path/query from parameters[] │
- │ - Body from requestBody │
- │ - Path-level param inheritance │
- │ - get_schema_from_item() with $ref │
- │ - parse_request_body() multi-content │
- └────────────┬───────────────────────────┘
- │
- ┌────────────▼──────────────────────┐
- │ common.organize_spec() (REUSE) │
- │ - Path iteration │
- │ - Scope assignment via tags │
- │ - Operations list │
- └────────────┬────────────────────────┘
- │
- ┌────────────▼──────────────────────────────┐
- │ generate_modules() (REUSE with changes) │
- │ - Template rendering loop │
- │ - Call parse_params_v3 instead of v2 │
- │ - Pass spec for $ref resolution │
- └────────────┬─────────────────────────────┘
- │
- ┌────────────▼──────────────────────────────┐
- │ Jinja2 Templates (REUSE) │
- │ - function_template.jinja2 │
- │ - class_template.jinja2 │
- │ - batch_function_template.jinja2 │
- │ - async_class_template.jinja2 │
- │ - async_function_template.jinja2 │
- │ - batch_class_template.jinja2 │
- └────────────┬─────────────────────────────┘
- │
- ┌────────▼────────┐
- │ Output: meraki/ │
- │ - api/ │
- │ - aio/api/ │
- │ - api/batch/ │
- └─────────────────┘
-```
-
-### Component Boundaries
-
-| Component | Responsibility | Communicates With |
-|-----------|---------------|-------------------|
-| **generate_library_oasv3.py** | CLI entry, spec fetch, pipeline orchestration | Parser layer, common.organize_spec, generate_modules |
-| **OASv3 Parser Layer** | Normalize v3 spec into v2-compatible param dicts | Raw spec dict, returns normalized params |
-| **common.organize_spec()** | Path/scope organization (version-agnostic) | Receives spec paths, returns scopes dict |
-| **generate_modules()** | Module generation loop, template rendering | Parser layer for params, Jinja2 for rendering |
-| **parse_get/post/delete_params()** | HTTP method-specific logic | Parser layer, returns call_line + param dicts |
-| **Jinja2 Templates** | Code generation from normalized data | Template vars, outputs Python code |
-
-## Data Flow
-
-**OASv3 Spec to Generated Code:**
-
-```
-1. Fetch OASv3 spec from Meraki API
- https://api.meraki.com/api/v1/openapiSpec?version=3
-
-2. Parse spec structure
- spec = {
- "openapi": "3.0.1",
- "paths": {
- "/networks/{networkId}": {
- "get": {
- "operationId": "getNetwork",
- "parameters": [{"name": "networkId", "in": "path", "schema": {...}}],
- "requestBody": null # GET has no body
- },
- "put": {
- "operationId": "updateNetwork",
- "parameters": [{"name": "networkId", "in": "path", "schema": {...}}],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/UpdateNetworkRequest"
- }
- }
- }
- }
- }
- }
- },
- "components": {
- "schemas": {
- "UpdateNetworkRequest": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "tags": {"type": "array", "items": {"type": "string"}}
- }
- }
- }
- }
- }
-
-3. Initialize scope structure
- scopes = {"networks": {}, "organizations": {}, ...}
-
-4. organize_spec() groups paths by scope
- scopes["networks"]["/networks/{networkId}"] = {
- "get": endpoint_dict,
- "put": endpoint_dict
- }
-
-5. For each endpoint, parse_params_v3() normalizes parameters
-
- Input (OASv3):
- - parameters: [{"name": "networkId", "in": "path", "schema": {"type": "string"}}]
- - requestBody: {"content": {"application/json": {"schema": {...}}}}
-
- Output (normalized for templates):
- {
- "networkId": {
- "required": True,
- "in": "path",
- "type": "string",
- "description": "Network ID"
- },
- "name": {
- "required": False,
- "in": "body",
- "type": "string",
- "description": "Network name"
- },
- "tags": {
- "required": False,
- "in": "body",
- "type": "array",
- "description": "Network tags"
- }
- }
-
-6. return_params() filters by criteria
- parse_params_v3("updateNetwork", params, requestBody, spec, ["required"])
- # Returns only required params for function signature
-
-7. Template renders with normalized data
- function_template.jinja2 receives:
- - operation: "updateNetwork"
- - function_definition: ", networkId: str"
- - path_params: {"networkId": {...}}
- - body_params: {"name": {...}, "tags": {...}}
- - call_line: "return self._session.put(metadata, resource, payload)"
-
-8. Generated code
- def updateNetwork(self, networkId: str, **kwargs):
- kwargs = locals()
- metadata = {"tags": ["networks", "configure"], "operation": "updateNetwork"}
- networkId = urllib.parse.quote(str(networkId), safe="")
- resource = f"/networks/{networkId}"
- body_params = ["name", "tags"]
- payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
- return self._session.put(metadata, resource, payload)
-```
-
-**State Management:**
-
-| State | Storage | Lifetime |
-|-------|---------|----------|
-| OpenAPI spec | `spec` dict in generate_library() | Function scope |
-| $ref resolution cache | `_ref_cache` dict in resolve_ref() | Function scope, reset per generate_library() call |
-| $ref resolution stack | `_ref_stack` list in resolve_ref() | Call stack (cycle detection) |
-| Scopes dict | `scopes` in generate_library() | Function scope |
-| Jinja2 environment | `jinja_env` in generate_library() | Function scope |
-| Template directory | `template_dir` string | Function scope |
-
-## Integration Points with Existing Code
-
-### NEW Components (v3-specific)
-
-**1. resolve_ref(spec, ref, cache=None, stack=None)**
-- **Purpose:** Resolve `$ref` references with cycle detection and caching
-- **Location:** generate_library_oasv3.py
-- **Dependencies:** None (pure function)
-- **Used by:** get_schema_from_item, parse_request_body
-- **Signature:**
- ```python
- def resolve_ref(spec: dict, ref: str, cache: dict = None, stack: list = None) -> dict | None:
- """
- Resolve $ref like #/components/schemas/Network to actual schema.
-
- Args:
- spec: Full OpenAPI spec dict
- ref: Reference string starting with #/
- cache: Dict for memoization (mutated in place)
- stack: List for cycle detection (mutated in place)
-
- Returns:
- Resolved schema dict or None if reference invalid/cyclic
- """
- ```
-
-**2. get_schema_from_item(item, spec, cache=None)**
-- **Purpose:** Extract schema from parameter/requestBody, resolve $ref if present
-- **Location:** generate_library_oasv3.py
-- **Dependencies:** resolve_ref()
-- **Used by:** parse_params_v3, parse_request_body
-- **Signature:**
- ```python
- def get_schema_from_item(item: dict, spec: dict, cache: dict = None) -> dict | None:
- """
- Extract schema from OASv3 item (parameter or content item).
-
- Args:
- item: Dict with "schema" key (e.g., parameter or content item)
- spec: Full spec for $ref resolution
- cache: Shared cache for resolve_ref
-
- Returns:
- Schema dict (inline or resolved from $ref) or None
- """
- ```
-
-**3. parse_request_body(operation, request_body, spec, cache=None)**
-- **Purpose:** Parse OASv3 requestBody into params dict with "in": "body"
-- **Location:** generate_library_oasv3.py
-- **Dependencies:** resolve_ref, get_schema_from_item
-- **Used by:** parse_params_v3
-- **Signature:**
- ```python
- def parse_request_body(operation: str, request_body: dict, spec: dict, cache: dict = None) -> dict:
- """
- Parse OASv3 requestBody into normalized params dict.
-
- Args:
- operation: Operation ID (for logging)
- request_body: OASv3 requestBody object
- spec: Full spec for $ref resolution
- cache: Shared cache
-
- Returns:
- Dict of {param_name: {required, in, type, description, enum?, items?}}
-
- Notes:
- - Handles application/json (primary), multipart/form-data, application/octet-stream
- - Flattens schema properties into top-level params
- - Sets "in": "body" for all params
- """
- ```
-
-**4. parse_params_v3(operation, parameters, request_body, spec, param_filters=None)**
-- **Purpose:** Unified parameter parser for OASv3 (replaces v2's parse_params)
-- **Location:** generate_library_oasv3.py
-- **Dependencies:** return_params (from common or v2), parse_request_body, get_schema_from_item
-- **Used by:** generate_standard_and_async_functions, generate_action_batch_functions
-- **Signature:**
- ```python
- def parse_params_v3(
- operation: str,
- parameters: list | None,
- request_body: dict | None,
- spec: dict,
- param_filters: list | None = None
- ) -> dict:
- """
- Parse OASv3 parameters and requestBody into normalized dict.
-
- Args:
- operation: Operation ID
- parameters: List of OASv3 parameter objects (path, query, header)
- request_body: OASv3 requestBody object or None
- spec: Full spec for $ref resolution
- param_filters: List of filters for return_params (e.g., ["required", "path"])
-
- Returns:
- Normalized params dict matching v2 format:
- {
- param_name: {
- "required": bool,
- "in": "path" | "query" | "body",
- "type": "string" | "integer" | "boolean" | "array" | "object",
- "description": str,
- "enum": list (optional),
- "items": dict (optional, for arrays)
- }
- }
-
- Notes:
- - Merges parameters[] and requestBody into single dict
- - Handles path-level parameter inheritance (future enhancement)
- - Passes through return_params for filtering
- - Adds pagination params if perPage detected
- """
- ```
-
-### REUSED Components (from v2 or common.py)
-
-**1. organize_spec(paths, scopes)**
-- **Location:** common.py
-- **Status:** REUSE AS-IS
-- **Why:** Path-to-scope mapping is version-agnostic
-
-**2. return_params(operation, params, param_filters)**
-- **Location:** generate_library.py (v2) - MOVE TO common.py
-- **Status:** REUSE with extraction
-- **Why:** Filtering logic (required, path, query, body, array, enum) is identical for v2 and v3
-- **Action:** Extract to common.py, import in both generators
-
-**3. generate_pagination_parameters(operation)**
-- **Location:** generate_library.py (v2) - MOVE TO common.py
-- **Status:** REUSE with extraction
-- **Why:** Pagination is a library feature, not spec-version-specific
-- **Action:** Extract to common.py
-
-**4. docs_url(operation)**
-- **Location:** generate_library.py (v2) - MOVE TO common.py
-- **Status:** REUSE with extraction
-- **Why:** Pure transformation of operation ID to documentation URL
-- **Action:** Extract to common.py
-
-**5. Jinja2 Templates**
-- **Location:** generator/*.jinja2
-- **Status:** REUSE AS-IS
-- **Why:** Templates consume normalized param dicts; v3 parser produces same format as v2
-
-**6. generate_modules(batchable_actions, jinja_env, scopes, template_dir)**
-- **Location:** generate_library.py (v2) - DUPLICATE with modifications
-- **Status:** DUPLICATE and modify for v3
-- **Why:** Core structure identical, but calls parse_params_v3 instead of parse_params
-- **Changes:**
- - Replace `endpoint["parameters"]` with `endpoint.get("parameters")` and `endpoint.get("requestBody")`
- - Replace `parse_params(op, params, filters)` with `parse_params_v3(op, params, request_body, spec, filters)`
- - Thread `spec` through all parsing calls
-
-**7. generate_standard_and_async_functions()**
-- **Location:** generate_library.py (v2) - DUPLICATE with modifications
-- **Status:** DUPLICATE and modify for v3
-- **Changes:** Same as generate_modules
-
-**8. generate_action_batch_functions()**
-- **Location:** generate_library.py (v2) - DUPLICATE with modifications
-- **Status:** DUPLICATE and modify for v3
-- **Changes:** Same as generate_modules
-
-### MODIFIED Components
-
-**parse_get_params, parse_post_and_put_params, parse_delete_params**
-- **Status:** REUSE AS-IS (they consume normalized params)
-- **Why:** HTTP method logic operates on normalized params dict, not raw spec
-
-## Patterns to Follow
-
-### Pattern 1: $ref Resolution with Cycle Detection
-**What:** Recursive reference resolution with memoization and stack-based cycle detection
-**When:** Parsing any OASv3 schema that may contain $ref
-**Example:**
-```python
-def resolve_ref(spec, ref, cache=None, stack=None):
- if cache is None:
- cache = {}
- if stack is None:
- stack = []
-
- # Already resolved
- if ref in cache:
- return cache[ref]
-
- # Cycle detected
- if ref in stack:
- return None
-
- # Parse #/components/schemas/Network -> ["components", "schemas", "Network"]
- if not ref.startswith("#/"):
- return None
- parts = ref[2:].split("/")
-
- # Walk the spec
- stack.append(ref)
- result = spec
- for part in parts:
- if isinstance(result, dict) and part in result:
- result = result[part]
- else:
- stack.pop()
- return None
-
- # Cache and return
- cache[ref] = result
- stack.pop()
- return result
-```
-
-### Pattern 2: Unified Parameter Parsing
-**What:** Single function handles path, query, and body parameters
-**When:** Parsing any OASv3 operation
-**Example:**
-```python
-def parse_params_v3(operation, parameters, request_body, spec, param_filters=None):
- all_params = {}
- cache = {} # Shared across parameters and requestBody
-
- # Parse path/query parameters
- if parameters:
- for p in parameters:
- schema = get_schema_from_item(p, spec, cache)
- # Extract type, description, required from schema
- all_params[p["name"]] = normalize_param(p, schema)
-
- # Parse requestBody
- if request_body:
- body_params = parse_request_body(operation, request_body, spec, cache)
- all_params.update(body_params)
-
- # Add pagination if perPage present
- if "perPage" in all_params:
- all_params.update(generate_pagination_parameters(operation))
-
- # Filter and return
- return return_params(operation, all_params, param_filters)
-```
-
-### Pattern 3: Content-Type Aware Body Parsing
-**What:** Handle multiple requestBody content types (JSON, multipart, octet-stream)
-**When:** Parsing OASv3 requestBody
-**Example:**
-```python
-def parse_request_body(operation, request_body, spec, cache):
- if "content" not in request_body:
- return {}
-
- content = request_body["content"]
-
- # Priority: application/json > multipart/form-data > application/octet-stream
- media_type = None
- if "application/json" in content:
- media_type = content["application/json"]
- elif "multipart/form-data" in content:
- media_type = content["multipart/form-data"]
- elif "application/octet-stream" in content:
- # Binary upload, no schema properties to extract
- return {}
-
- if not media_type:
- return {}
-
- schema = get_schema_from_item(media_type, spec, cache)
- if not schema or "properties" not in schema:
- return {}
-
- # Flatten properties into params
- params = {}
- required_fields = schema.get("required", [])
- for prop_name, prop_schema in schema["properties"].items():
- if "$ref" in prop_schema:
- prop_schema = resolve_ref(spec, prop_schema["$ref"], cache)
- params[prop_name] = {
- "required": prop_name in required_fields,
- "in": "body",
- "type": prop_schema.get("type", "object"),
- "description": prop_schema.get("description", "")
- }
- if "enum" in prop_schema:
- params[prop_name]["enum"] = prop_schema["enum"]
-
- return params
-```
-
-### Pattern 4: Path-Level Parameter Inheritance
-**What:** Path-level parameters apply to all operations on that path
-**When:** Parsing OASv3 paths
-**Example:**
-```python
-# In generate_library, when iterating paths:
-for path, path_item in paths.items():
- # OASv3: path-level parameters inherited by all operations
- path_level_params = path_item.get("parameters", [])
-
- for method in ["get", "post", "put", "delete", "patch"]:
- if method not in path_item:
- continue
-
- endpoint = path_item[method]
-
- # Merge path-level and operation-level parameters
- operation_params = endpoint.get("parameters", [])
- # Operation params override path params with same name
- param_names = {p["name"] for p in operation_params}
- inherited = [p for p in path_level_params if p["name"] not in param_names]
- all_parameters = operation_params + inherited
-
- # Now parse with merged parameters
- parsed = parse_params_v3(
- endpoint["operationId"],
- all_parameters,
- endpoint.get("requestBody"),
- spec
- )
-```
-
-## Anti-Patterns to Avoid
-
-### Anti-Pattern 1: Monolithic Parser Function
-**What:** Single function that handles spec fetching, parsing, organizing, and generation
-**Why bad:** Impossible to test in isolation, cannot reuse components, hard to debug
-**Instead:** Separate concerns into functions with single responsibilities
-**Evidence from abandoned attempt:** generate_library_oasv3.py lines 262-765 is a single function doing everything
-
-### Anti-Pattern 2: Inline $ref Resolution
-**What:** Resolving references at template time or in every parsing function separately
-**Why bad:** No caching, cycle detection fragile, templates become spec-aware
-**Instead:** Resolve all $ref at parse time, cache results, pass normalized dicts to templates
-**Example of wrong approach:**
-```python
-# BAD: Template has to understand $ref
-{% if body_params[param].get("$ref") %}
- # Special handling in template
-{% endif %}
-
-# GOOD: Parser resolves before template
-body_params = {
- "name": {"type": "string", "description": "Network name"} # Already resolved
-}
-```
-
-### Anti-Pattern 3: Duplicating return_params Logic
-**What:** Reimplementing filter logic (required, path, query, etc.) in v3 parser
-**Why bad:** Code duplication, divergence over time, missed edge cases
-**Instead:** Extract return_params to common.py, reuse in both generators
-
-### Anti-Pattern 4: Not Threading Spec Through Functions
-**What:** Passing partial dicts and losing context needed for $ref resolution
-**Why bad:** Cannot resolve references, have to refetch or restructure data
-**Instead:** Thread full `spec` dict through all parsing functions, use cache for performance
-**Example from abandoned attempt:**
-```python
-# Lines 196-259: parse_params needs spec for $ref but loses context in nested calls
-def parse_params(operation, parameters, request_body, spec, param_filters=None):
- # ...
- for p in parameters:
- schema = get_schema_from_item(p, spec) # Correct, spec threaded
- # ...
- body_params = parse_request_body(operation, request_body, spec) # Correct
-```
-
-### Anti-Pattern 5: Using locals() for Param Construction
-**What:** `kwargs.update(locals())` to capture function arguments
-**Why bad:** Captures unintended vars (self, operation, metadata), fails static analysis
-**Instead:** Explicit param dict construction or structured capture
-**Note:** This is v2 tech debt; v3 is opportunity to fix but not blocking for parity
-
-### Anti-Pattern 6: Assuming Single Content Type
-**What:** Only parsing application/json from requestBody
-**Why bad:** Meraki API uses multipart/form-data for firmware uploads, octet-stream for binaries
-**Instead:** Priority-based content type selection (JSON > multipart > octet-stream)
-
-## Scalability Considerations
-
-| Concern | At 100 operations | At 400+ operations (current) | At 1000+ operations |
-|---------|-------------------|------------------------------|---------------------|
-| $ref cache | Not needed | Essential (298 batchable actions share schemas) | Critical (memory vs re-parse tradeoff) |
-| Template rendering | Sequential OK | Sequential OK (fast with Jinja2) | Consider parallel per scope |
-| Spec download | Network OK | Network OK | Consider caching in CI |
-| Code formatting | ruff fast | ruff fast (current: <5s) | Consider incremental formatting |
-
-**Current scale:** 298 operations across 17 scopes, v3 spec is ~3.2MB JSON
-
-**Performance targets:**
-- Full generation (fetch + parse + render + format): <30s
-- Parser layer (parse all operations): <5s
-- Template rendering: <10s
-- Code formatting: <5s
-
-**Bottlenecks to watch:**
-1. $ref resolution without caching (O(n²) with nested schemas)
-2. Jinja2 template reloading per function (use jinja_env.from_string with reuse)
-3. ruff formatting on individual files (batch invocation faster)
-
-## Build Order
-
-**Phase 1: Parser Foundation**
-1. resolve_ref() with tests (cycle detection, caching)
-2. get_schema_from_item() with tests
-3. parse_request_body() with tests (JSON, multipart, octet-stream)
-4. Extract return_params to common.py, update v2 imports
-
-**Phase 2: Unified Parser**
-5. parse_params_v3() with tests (path, query, body merging)
-6. Path-level parameter inheritance
-7. Golden-file test with synthetic v3 fixture
-
-**Phase 3: Generation Integration**
-8. Duplicate generate_modules and modify for parse_params_v3 calls
-9. Duplicate generate_standard_and_async_functions with spec threading
-10. Duplicate generate_action_batch_functions with v3 batch operation detection
-
-**Phase 4: CLI and Entry Point**
-11. generate_library_oasv3.py main() with ?version=3 param
-12. Integration test with live v3 spec
-13. CI drift detection (v2 vs v3 output comparison)
-
-**Dependency order:**
-- resolve_ref is foundation (no deps)
-- get_schema_from_item depends on resolve_ref
-- parse_request_body depends on get_schema_from_item and resolve_ref
-- parse_params_v3 depends on parse_request_body and return_params
-- Generation functions depend on parse_params_v3
-
-**Testing order:**
-- Unit tests for pure functions (resolve_ref, get_schema_from_item)
-- Unit tests for parse functions with synthetic schemas
-- Golden-file tests with synthetic spec (minimal but representative)
-- Integration test with live v3 spec (slow, run in CI)
-
-## Sources
-
-**HIGH confidence:**
-- Existing v2 generator code analysis (generate_library.py, common.py)
-- Abandoned v3 attempt analysis (generate_library_oasv3.py anti-patterns)
-- Jinja2 template structure (function_template.jinja2)
-- Test suite structure (test_pure_functions.py, test_generate_library_golden.py)
-- PROJECT.md requirements and constraints
-
-**MEDIUM confidence:**
-- OASv3 spec structure (from OpenAPI 3.0.1 standard, verified in abandoned code)
-- Multi-version generator patterns (inferred from v2 structure, industry practice)
-
-**LOW confidence:**
-- openapi-generator internal architecture (docs don't cover implementation)
-- Optimal caching strategy (scale assumptions based on current 298 operations)
-
-## Gaps and Assumptions
-
-**Assumptions made:**
-1. v3 spec structure matches OpenAPI 3.0.1 standard (verified in abandoned code)
-2. Meraki v3 spec uses same x-batchable-actions as v2 (PROJECT.md confirms 298 entries)
-3. Templates can consume normalized params without modification (confirmed from template analysis)
-4. path-level parameter inheritance exists in v3 spec (standard feature, need to verify in live spec)
-
-**Gaps to address in implementation:**
-1. oneOf query param handling (mentioned in PROJECT.md, not in abandoned code)
-2. nullable type annotations (mentioned in PROJECT.md, need parser support)
-3. components/schemas usage patterns (need to analyze live v3 spec)
-4. Exact batch operation detection logic for v3 (v2 uses summary match, v3 may differ)
-5. Error handling strategy for malformed $ref or missing schemas
diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md
deleted file mode 100644
index 4cfe24f0..00000000
--- a/.planning/research/FEATURES.md
+++ /dev/null
@@ -1,207 +0,0 @@
-# Feature Landscape: OpenAPI 3.0 Code Generator
-
-**Domain:** Python SDK generation from OpenAPI 3.0 specifications
-**Researched:** 2026-04-29
-**Context:** Building OASv3 generator for Meraki Dashboard API Python SDK
-
-## Table Stakes
-
-Features users expect in any production OpenAPI 3.0 Python generator. Missing these makes the SDK feel incomplete or amateurish.
-
-| Feature | Why Expected | Complexity | Notes |
-|---------|--------------|------------|-------|
-| **$ref resolution** | Core OAS3 feature, all specs use references | Medium | Needs cycle detection, caching. OASV3-MIGRATION.md already addresses |
-| **requestBody parsing** | OAS3 moved body params out of parameters array | Medium | application/json, multipart/form-data, octet-stream. Already planned |
-| **Type annotations** | Python 3.10+ standard, enables IDE autocomplete | Low | Basic types straightforward, `oneOf` needs Union |
-| **Sync and async methods** | Modern Python expects both | Low | Already implemented in v2 generator |
-| **Pagination support** | Large result sets require paging | Low | Already implemented (total_pages, direction params) |
-| **Error handling** | SDK must surface API errors clearly | Low | Already exists in rest_session.py (APIError, APIResponseError) |
-| **Query param serialization** | Arrays, objects, special chars must encode correctly | Medium | Existing encode_params() handles dicts; need array style/explode |
-| **Path parameter substitution** | URLs need variable interpolation | Low | Standard in all generators |
-| **Retry logic** | Network failures happen | Low | Already implemented (MAXIMUM_RETRIES, WAIT_ON_RATE_LIMIT) |
-| **oneOf/anyOf handling** | OAS3 standard for polymorphic params | High | Already planned as "string or object" in docstrings |
-| **nullable type annotations** | OAS3 nullable: true is common | Low | Planned: `param: str \| None` syntax |
-| **Enum support** | Constrained values are common | Low | Standard generator feature |
-| **Multi-content-type requestBody** | APIs often accept JSON OR form data | Medium | Parse all content types, document in signature |
-
-## Differentiators
-
-Features that set excellent SDK generators apart from mediocre ones. Not expected by default, but high value when present.
-
-| Feature | Value Proposition | Complexity | Notes |
-|---------|-------------------|------------|-------|
-| **Type stubs (.pyi)** | Static analysis, mypy, IDE without runtime cost | Medium | Already planned in OASV3-MIGRATION.md with --stubs flag |
-| **kwarg validation with opt-in logging** | Catch typos, inform about invalid params | Low | Already implemented in v2 (validate_kwargs flag) |
-| **Golden-file test suite** | Regression protection on generator changes | Medium | Already planned in OASV3-MIGRATION.md |
-| **CI drift detection** | Automated v2 vs v3 output comparison | Medium | Already planned in OASV3-MIGRATION.md |
-| **Explicit param construction** | Replaces locals() antipattern, enables static analysis | Medium | Already planned; improves over v2 |
-| **Vendor extension preservation** | Allows custom metadata (x-batchable-actions) | Low | Already handling x-batchable-actions |
-| **Docstring generation from descriptions** | API docs inline in code | Low | Standard but quality varies |
-| **Custom HTTP client support** | Pluggable transport (requests vs httpx) | High | Not needed, requests/aiohttp work |
-| **Response model objects** | Type-safe response parsing with dataclasses/pydantic | High | Overkill for dict-based API; adds complexity |
-| **Automatic pagination iterators** | Generator functions for transparent paging | Medium | Already implemented (getPages methods) |
-| **Operation-specific exceptions** | NotFoundError vs UnauthorizedError | Medium | Nice-to-have, current APIError works |
-| **Request/response logging hooks** | Debugging, audit trails | Low | Possible via session config |
-| **Configurable timeouts per endpoint** | Different operations need different timeouts | Low | Global timeout works for most cases |
-| **Array serialization control** | Respect style/explode from spec | Medium | Already planned (OAS3 default: form, explode: true) |
-| **Path-level parameter inheritance** | DRY spec = correct client | Medium | Already planned in OASV3-MIGRATION.md |
-
-## Anti-Features
-
-Features commonly requested or present in other generators that should be avoided for this project.
-
-| Anti-Feature | Why Avoid | What to Do Instead |
-|--------------|-----------|-------------------|
-| **Pydantic model generation** | Runtime overhead, spec churn causes breaking changes | Keep dict-based returns, use type hints for IDE support |
-| **Full OAS 3.1 support** | Different type system (type: [string, null]), adds complexity | Support OAS 3.0 only, document limitation |
-| **Automatic model class generation** | Meraki spec has 1000+ endpoints, classes balloon SDK size | Dict returns work, TypedDict stubs provide typing |
-| **SDK method name customization** | operationId already provides names, customization causes confusion | Use operationId directly |
-| **Client-side validation (pydantic)** | Adds dependency, validation logic duplicates server | Rely on server validation, surface errors clearly |
-| **Sync wrapper around async** | async-first with sync wrapper adds indirection | Generate both from spec independently |
-| **Auto-generated examples in docstrings** | Specs rarely have good examples, stale examples mislead | Provide links to official docs (already doing this) |
-| **Nested SDK namespacing** | client.api.networks.devices.getDevice() too verbose | Flat client.networks.getNetworkDevices() matches v2 |
-| **OAuth flow helpers** | Meraki uses API key only | Simple X-Cisco-Meraki-API-Key header (already done) |
-| **Mock server generation** | Out of scope for SDK generator | User can use stripe-mock or similar |
-| **Webhook signature validation** | Different concern from API client | Separate library if needed |
-| **GraphQL support** | Meraki is REST only | N/A |
-| **Auto-retry on ALL 4xx** | Some 4xx are permanent (400, 401, 403) | Retry 429 only, flag for 4xx opt-in (already done) |
-
-## Feature Dependencies
-
-```
-$ref resolution
- ↓
-requestBody parsing (refs in schemas)
- ↓
-Type annotations (schema types)
-
-oneOf handling
- ↓
-Union type annotations
-
-Path-level parameters
- ↓
-parse_params unification
-
-Explicit param construction
- ↓
-Template changes (function_template.jinja2)
-
-Type stubs
- ↓
-All type annotation logic finalized
-```
-
-## MVP Recommendation
-
-**Phase 1: Core OAS3 Parsing** (table stakes)
-1. $ref resolution with cycle protection
-2. requestBody parsing (JSON, multipart, octet-stream)
-3. oneOf as Union[str, dict] (docstring: "string or object")
-4. nullable type annotations (param: str | None)
-5. Path-level parameter inheritance
-6. Array serialization (style/explode)
-
-**Phase 2: Code Quality** (differentiators)
-1. Explicit param construction (replace locals())
-2. Type stub generation (.pyi)
-3. Golden-file test suite
-4. CI drift detection
-
-**Phase 3: Polish** (optional)
-1. Enhanced docstrings (oneOf sub-properties documented)
-2. Request/response logging hooks
-3. Per-endpoint timeout configuration
-
-**Defer to future milestones:**
-- Response model objects (dict returns work fine)
-- Operation-specific exceptions (nice-to-have)
-- Custom HTTP clients (requests/aiohttp sufficient)
-
-## Feature Prioritization Matrix
-
-| Feature | User Value | Implementation Cost | Priority |
-|---------|------------|---------------------|----------|
-| $ref resolution | Critical | Medium | P0 |
-| requestBody parsing | Critical | Medium | P0 |
-| oneOf handling | High | Medium | P0 |
-| nullable annotations | High | Low | P0 |
-| Type stubs | High | Medium | P1 |
-| Explicit param construction | Medium | Medium | P1 |
-| Golden-file tests | High (dev) | Medium | P1 |
-| CI drift detection | High (dev) | Medium | P1 |
-| Path-level params | Medium | Low | P0 |
-| Array serialization | Medium | Low | P0 |
-| Response models | Low | High | P3 |
-| Custom HTTP clients | Low | High | P3 |
-| Operation exceptions | Low | Medium | P3 |
-
-## Complexity Breakdown
-
-**Low complexity** (< 1 day):
-- Nullable annotations
-- Path-level parameter inheritance
-- Array serialization style/explode
-- Vendor extension preservation
-
-**Medium complexity** (1-3 days):
-- $ref resolution with caching and cycle detection
-- requestBody parsing (multiple content types)
-- oneOf/anyOf Union type generation
-- Type stub generation
-- Explicit param construction
-- Golden-file test suite
-- CI drift detection
-
-**High complexity** (> 3 days):
-- Response model generation (Pydantic/dataclasses)
-- Custom HTTP client pluggability
-- Full OAS 3.1 support (type arrays)
-
-## Integration with Existing Features
-
-| Existing Feature | OAS3 Integration Point | Notes |
-|------------------|------------------------|-------|
-| Pagination (total_pages, direction) | No change | Still added to endpoints with perPage param |
-| Batch actions (x-batchable-actions) | Still present in OAS3 spec | Same matching logic works |
-| Retry logic (MAXIMUM_RETRIES) | No change | rest_session.py handles this |
-| kwarg validation | No change | Works with OAS3 params same way |
-| Sync/async generation | No change | Template-based, agnostic to OAS version |
-| Rate limiting (WAIT_ON_RATE_LIMIT) | No change | rest_session.py feature |
-| encode_params() | Needs array handling | Already handles dict query params; add array support |
-
-## Sources
-
-**High Confidence:**
-- Context7: /openapi-generators/openapi-python-client (type annotations, dataclasses, async support)
-- Context7: /openapitools/openapi-generator (Python generator features)
-- OASV3-MIGRATION.md (project-specific OAS3 features)
-- PROJECT.md (existing v2 features)
-- OpenAPI 3.0 vs 3.1 migration guide (official)
-
-**Medium Confidence:**
-- Stripe Python SDK patterns (async, pagination, type hints, retry logic)
-- openapi-python-client GitHub issues (common pitfalls: oneOf, allOf, nullable enums, file uploads)
-
-**Low Confidence:**
-- WebFetch openapi-python-client repo (feature list)
-- WebFetch openapi-generator.tech docs (config options)
-
-## Feature Coverage Comparison
-
-| Feature Category | openapi-python-client | openapi-generator | Meraki v2 | Meraki v3 (Target) |
-|------------------|----------------------|-------------------|-----------|-------------------|
-| Type annotations | ✓ Full | ✓ Basic | ✗ None | ✓ Full + stubs |
-| Async support | ✓ asyncio | ✓ asyncio/tornado | ✓ aiohttp | ✓ aiohttp |
-| Pagination | Manual | Manual | ✓ Auto (total_pages) | ✓ Auto (total_pages) |
-| Retry logic | Manual (httpx) | Manual | ✓ Built-in | ✓ Built-in |
-| oneOf/anyOf | ✓ Union | ✓ Union | N/A (OAS2) | ✓ Union (docstring) |
-| nullable | ✓ Optional | ✓ Optional | N/A (OAS2) | ✓ Optional (| None) |
-| $ref resolution | ✓ Full | ✓ Full | N/A (OAS2 inline) | ✓ Full + cycle detect |
-| Batch actions | ✗ | ✗ | ✓ x-batchable-actions | ✓ x-batchable-actions |
-| Type stubs | ✗ | ✗ | ✗ | ✓ .pyi generation |
-| kwarg validation | ✗ | ✗ | ✓ Optional logging | ✓ Optional logging |
-| CI drift detection | ✗ | ✗ | ✗ | ✓ Planned |
-| Response models | ✓ Dataclasses | ✗ | ✗ Dicts | ✗ Dicts (intentional) |
-
-**Competitive advantage:** Meraki v3 generator combines strong typing (stubs, annotations) with operational features (pagination, retry, batch, validation) that pure code generators lack.
diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md
deleted file mode 100644
index 963efd69..00000000
--- a/.planning/research/PITFALLS.md
+++ /dev/null
@@ -1,309 +0,0 @@
-# Domain Pitfalls: OASv3 Code Generator
-
-**Domain:** OpenAPI 3.0 code generation for existing v2 system
-**Researched:** 2026-04-29
-**Context:** Adding OASv3 generator alongside working v2 generator for Meraki Dashboard API Python SDK
-
-## Critical Pitfalls
-
-Mistakes that cause rewrites or major issues.
-
-### Pitfall 1: Infinite Loop in $ref Resolution Without Cycle Detection
-**What goes wrong:** Circular `$ref` chains (Schema A → Schema B → Schema A) cause infinite recursion and stack overflow. The abandoned oasv3 file has NO cycle detection in `resolve_ref()` (lines 26-41).
-
-**Why it happens:** OpenAPI 3.0 spec allows circular references for recursive data structures. Simple traversal hits the same refs repeatedly without tracking visited nodes.
-
-**Consequences:** Generator crashes on valid specs with recursive schemas. Silent hang if max recursion isn't reached but loops for seconds. Production failure on spec updates that introduce circular refs.
-
-**Prevention:**
-```python
-def resolve_ref(spec: dict, ref: str, _visiting: set | None = None) -> dict | None:
- if _visiting is None:
- _visiting = set()
-
- if ref in _visiting:
- return None # Circular ref detected
-
- _visiting.add(ref)
- # ... rest of resolution logic
-```
-
-**Detection:** Spec with `#/components/schemas/Foo` containing `properties: { child: { $ref: "#/components/schemas/Foo" } }` triggers the issue. Test fixture MUST include circular ref case.
-
-### Pitfall 2: Missing $ref Cache Causes O(n²) Performance
-**What goes wrong:** Resolving the same `$ref` thousands of times when spec has shared schemas referenced by 298+ endpoints. Each resolution traverses the full JSON pointer path.
-
-**Why it happens:** No memoization. Abandoned oasv3 file calls `resolve_ref()` inline everywhere (lines 54, 167, 188, 235) with no caching.
-
-**Consequences:** 30+ second generation time for live spec (vs <5s for v2). CI timeouts. Developer friction during iteration.
-
-**Prevention:** Add cache on first spec access:
-```python
-if "_ref_cache" not in spec:
- spec["_ref_cache"] = {}
-
-if ref in spec["_ref_cache"]:
- return spec["_ref_cache"][ref]
-
-# resolve and cache before returning
-```
-
-**Detection:** `time python generate_library_oasv3.py` taking >10s on live spec when v2 takes <5s.
-
-### Pitfall 3: Path-Level Parameter Inheritance Not Implemented
-**What goes wrong:** OASv3 allows parameters at path level that apply to all operations. Abandoned file ignores `paths[path]["parameters"]` and only checks `paths[path][method]["parameters"]` (line 209, 441). Missing required path params like `organizationId`.
-
-**Why it happens:** v2 doesn't have path-level params. Direct port of v2 logic misses this OASv3 feature.
-
-**Consequences:** Generated functions missing required parameters. Runtime errors when calling SDK methods. Silent param drops if parameter appears at path level but not operation level.
-
-**Prevention:**
-```python
-# Collect path-level params first
-path_level_params = paths[path].get("parameters", [])
-operation_params = endpoint.get("parameters", [])
-
-# Merge with operation params overriding on (name, in) collision
-merged = merge_params(path_level_params, operation_params)
-```
-
-**Detection:** Golden file test with path-level param that doesn't appear in operation-level params. Diff v3 vs v2 output on live spec shows missing params.
-
-### Pitfall 4: requestBody Content-Type Priority Inverted
-**What goes wrong:** Abandoned file checks `application/json` first (line 155) and ignores other content types. Multipart/form-data endpoints (file uploads) get parsed as JSON and lose binary field markers.
-
-**Why it happens:** Defaulting to JSON without checking all content types. Spec can have multiple `requestBody.content` keys for different consumers.
-
-**Consequences:** File upload endpoints broken (missing `Content-Type: multipart/form-data`). Binary fields treated as string params. Runtime errors when SDK tries to JSON-encode binary data.
-
-**Prevention:**
-```python
-content = request_body.get("content", {})
-
-# Priority order: json → multipart → octet-stream → warn on unsupported
-if "application/json" in content:
- # parse JSON schema
-elif "multipart/form-data" in content:
- # parse form data, mark format:binary fields
-elif "application/octet-stream" in content:
- # single binary body param
-else:
- # Warn about unsupported content type, don't silently drop
-```
-
-**Detection:** Endpoint with `multipart/form-data` in fixture generates code expecting JSON. Manual test of file upload fails.
-
-### Pitfall 5: Template Data Format Mismatch (Breaking Change)
-**What goes wrong:** v3 parser returns `dict[str, dict]` for params but v2 templates expect different keys or structure. Template renders wrong code (empty param lists, missing type annotations).
-
-**Why it happens:** v3 has `parameter.schema.type` nested one level deeper than v2's `parameter.type` OR `parameter.schema.properties`. Changing parse logic without checking template expectations.
-
-**Consequences:** Generated functions missing params entirely. Type annotations wrong (all params become `str`). Pagination/kwargs handling broken. Appears to work but output is subtly wrong.
-
-**Prevention:**
-- Golden file tests that CHECK output structure, not just "does it run"
-- Parse v3 into SAME dict format v2 produces: `{ "paramName": { "type": "...", "required": bool, "in": "...", "description": "..." } }`
-- Test against existing function_template.jinja2 WITHOUT template changes
-
-**Detection:** Golden file content differs from expected in param types or counts. Ruff errors in generated code. CI diff check shows structural differences.
-
-## Moderate Pitfalls
-
-### Pitfall 6: oneOf Reported as Generic "object" Type
-**What goes wrong:** oneOf query params (string OR object with lt/gt/lte/gte) get type annotation `object`, losing string option. Docstring says "object" when user can pass a string.
-
-**Why it happens:** Abandoned file doesn't check for `oneOf` (missing from `get_schema_from_item`). Falls back to `type: object` from schema.
-
-**Consequences:** Misleading documentation. Static analysis tools reject valid string inputs. User confusion when passing string and it works despite docs saying "object".
-
-**Prevention:**
-```python
-if "oneOf" in schema:
- # Check constituent types
- types = [s.get("type") for s in schema["oneOf"]]
- if "string" in types and "object" in types:
- return ("string or object", f"string or object with properties: {list_object_props(schema)}")
-```
-
-**Detection:** Endpoint with oneOf query param generates docstring without "string or" prefix.
-
-### Pitfall 7: No Validation for Missing $ref Targets
-**What goes wrong:** Spec references `#/components/schemas/NonExistent` that doesn't exist. `resolve_ref` returns `None` silently (line 40). Param gets skipped or defaults to wrong type.
-
-**Why it happens:** Defensive programming (return None on error) without logging or validation step.
-
-**Consequences:** Missing parameters in generated code. Silent data loss. Hard to debug ("why is this param not showing up?").
-
-**Prevention:**
-```python
-if result is None:
- # Log warning with context
- print(f"WARNING: Failed to resolve $ref '{ref}' in operation '{operation}'")
- # Option: treat as opaque 'object' type rather than skip
-```
-
-**Detection:** Spec with broken `$ref` generates code without warnings. Add fixture with invalid ref, verify warning appears.
-
-## Minor Pitfalls
-
-### Pitfall 8: Array Serialization Style Ignored
-**What goes wrong:** OASv3 has `style` (form/spaceDelimited/pipeDelimited) and `explode` (true/false) for array params. Abandoned file ignores these (no check for `style` in param parsing).
-
-**Why it happens:** v2 doesn't have these attributes. Defaulting to single strategy without checking spec.
-
-**Consequences:** Array query params serialized wrong format. API rejects requests with "invalid format" errors. Works in some cases (default form+explode matches API expectation) but breaks on non-default.
-
-**Prevention:**
-```python
-# OAS3 defaults: style=form, explode=true for query params
-style = param.get("style", "form")
-explode = param.get("explode", True)
-
-# Document in param description how arrays are sent
-if param_type == "array":
- params[name]["serialization"] = {"style": style, "explode": explode}
-```
-
-**Detection:** Endpoint with array param + explicit style generates code that doesn't respect style.
-
-### Pitfall 9: nullable Not Reflected in Type Annotations
-**What goes wrong:** Param has `nullable: true` but generated function signature is `param: str` instead of `param: str | None`.
-
-**Why it happens:** Abandoned file checks `nullable` in parsing (not shown in excerpt) but doesn't thread through to type annotation logic.
-
-**Consequences:** Type checkers reject valid `None` inputs. Misleading type hints. Runtime passes None but static analysis fails.
-
-**Prevention:**
-```python
-# In function definition builder
-if values["type"] == "string":
- if values.get("nullable", False):
- definition += f", {p}: str | None"
- else:
- definition += f", {p}: str"
-```
-
-**Detection:** Param with `nullable: true` generates signature without `| None`.
-
-### Pitfall 10: Vendor Extension Loss
-**What goes wrong:** `x-batchable-actions` survives (line 292) but other `x-*` fields at parameter or operation level get dropped during parsing.
-
-**Why it happens:** Explicit extraction only for known vendor extensions. Generic `x-*` fields not forwarded.
-
-**Consequences:** Custom tooling that relies on vendor extensions breaks. Metadata loss that downstream consumers need.
-
-**Prevention:**
-```python
-# Copy all x- prefixed keys
-for key, value in schema.items():
- if key.startswith("x-"):
- params[name][key] = value
-```
-
-**Detection:** Spec with custom `x-meraki-preview` flag on param doesn't appear in generated code.
-
-## Phase-Specific Warnings
-
-| Phase Topic | Likely Pitfall | Mitigation |
-|-------------|---------------|------------|
-| Phase 1: Core v3 Parsing | Circular $ref causing crash | Implement cycle detection in `resolve_ref` with visited-set guard |
-| Phase 1: Core v3 Parsing | Missing $ref cache causing slow generation | Add `spec["_ref_cache"]` dict, check before traversal |
-| Phase 2: Unified parse_params | Path-level params ignored | Merge path-level and operation-level params before parsing |
-| Phase 2: Unified parse_params | requestBody content-type priority wrong | Parse all content types, document priority order |
-| Phase 3: HTTP Method Parsers | Template data format doesn't match v2 | Golden file tests BEFORE changing templates |
-| Phase 4: Module Generation | locals() antipattern survives in v3 | Update templates to explicit param construction DURING v3 work |
-| Phase 5: Batch Actions | Operation type lookup fails for v3 structure | Test batch endpoint in golden fixture, verify operation field |
-| Phase 6: oneOf Handling | Generic "object" type loses oneOf semantics | `resolve_oneof_type` helper with accurate type strings |
-| Phase 7: Testing | Golden files too rigid (byte-for-byte match) | v3 golden files allow enhanced docstrings, not v2 parity |
-| Phase 8: CI Drift Detection | False positives from docstring enhancements | Semantic diff (params, types, structure) not text diff |
-
-## Integration Gotchas
-
-### Gotcha 1: Shared common.py Not Threadsafe for spec Param
-**What:** v2's `common.organize_spec()` doesn't expect `spec` arg. Threading spec through all functions breaks imports if v2 and v3 generators run concurrently in tests.
-
-**Impact:** Test pollution. Race conditions if CI runs both generators in parallel.
-
-**Fix:** Either accept breaking import change (update v2 if needed) OR namespace v3 helpers differently (`common_v3.py`).
-
-### Gotcha 2: Template Filter Registration Skipped
-**What:** v2 registers `jinja_env.filters["to_double_quote_list"]` (line 299 in v2). If oasv3 file forgets this, template rendering fails with "unknown filter" error.
-
-**Impact:** Generation crashes late (after parsing succeeds). Confusing error far from root cause.
-
-**Fix:** Copy ALL jinja_env setup from v2, not just template loading. Add test that uses filter.
-
-### Gotcha 3: ruff Invocation Assumes cwd
-**What:** v2 runs `subprocess.run(["ruff", "check", "--fix", "meraki/"])` assuming cwd is project root (line 306). If v3 generator changes cwd or runs from generator/ dir, ruff formats wrong files or fails.
-
-**Impact:** Generated code not formatted. CI fails on style check. Looks like generator is broken when it's just cwd issue.
-
-**Fix:** Use absolute paths for ruff invocation. Test in tmp_path directory like golden tests do.
-
-### Gotcha 4: Non-Generated Files Downloaded Every Run
-**What:** Lines 272-287 in v2 download from GitHub every generation (no cache check). v3 copy/paste means slower iteration and network dependency.
-
-**Impact:** Offline generation fails. Slow feedback loop during development.
-
-**Fix:** Check if files exist and are current version before downloading. Or expect files present (don't auto-download).
-
-## "Looks Done But Isn't" Checklist
-
-Generator appears to work but has subtle correctness issues:
-
-- [ ] Circular $ref test in fixture (not just inline schemas)
-- [ ] Path-level parameters inherited into operations (not just operation-level)
-- [ ] requestBody with multiple content-types handled correctly (not just JSON)
-- [ ] oneOf query params documented as "string or object" (not generic "object")
-- [ ] nullable params get `| None` type annotation (not bare type)
-- [ ] Array params respect style/explode attributes (not default serialization for all)
-- [ ] Missing $ref target logs warning (not silent None return)
-- [ ] Template receives same dict format as v2 (not nested differently)
-- [ ] v3 golden files validate v3-specific output (not byte-for-byte match with v2)
-- [ ] CI drift detection checks semantic structure (not just text diff)
-- [ ] Batch actions work with v3 operation lookup (not hardcoded from v2 assumptions)
-- [ ] Enum assertions survive parsing (not lost in translation)
-- [ ] Pagination params injected for perPage endpoints (not dropped)
-- [ ] locals() antipattern replaced in templates (not persisted from v2)
-- [ ] $ref cache used for performance (not resolving same ref 1000x)
-
-## Technical Debt Patterns
-
-### Debt Pattern 1: Monolithic parse_params (Abandoned Approach)
-**What:** Single 200-line function handling path, query, body, requestBody, $ref, oneOf all inline. Abandoned oasv3 file has this (lines 196-260).
-
-**Why it's debt:** Impossible to test individual parsing steps. Changes in one param type affect all others. Hard to debug which clause failed.
-
-**Better approach:** Separate functions: `parse_path_params()`, `parse_query_params()`, `parse_request_body()`, then merge. Each unit-testable.
-
-### Debt Pattern 2: No Intermediate Representation
-**What:** Parsing directly into template dict format. Any template change requires parser changes.
-
-**Why it's debt:** Tight coupling. Can't reuse parser for different output formats (like .pyi stubs). Hard to diff "what did parsing extract" vs "what did template render".
-
-**Better approach:** Parse to normalized IR (dataclass or TypedDict), then map IR → template dict separately. Enables stub generation reusing same IR.
-
-### Debt Pattern 3: Error Handling by Omission
-**What:** Failed $ref → return None → param dropped silently. Unsupported content-type → skip without warning (implicit in abandoned code).
-
-**Why it's debt:** Silent failures. Debugging "why is param missing" requires reading spec + code. No visibility into what went wrong.
-
-**Better approach:** Explicit warnings. Fail-fast option (`--strict` flag) for CI that errors on missing refs.
-
-## Sources
-
-**Code analysis:**
-- `generator/generate_library_oasv3.py` (abandoned attempt, lines 26-833)
-- `generator/generate_library.py` (v2 production generator, lines 1-800)
-- `generator/function_template.jinja2` (template expectations)
-- `tests/generator/test_generate_library_golden.py` (test patterns)
-- `OASV3-MIGRATION.md` (known challenges)
-
-**Research:**
-- OpenAPI 3.0.3 specification (spec.openapis.org) - $ref resolution warnings, circular reference gaps
-- Swagger.io $ref documentation - sibling element confusion, escape character issues
-- Codebase git history - abandoned oasv3 file introduced commit a0ea07f, minimal iteration
-
-**Confidence:** HIGH for pitfalls observed in abandoned code, MEDIUM for pitfalls from spec analysis (OAS3 docs incomplete on edge cases), LOW for external tooling pitfalls (Brave API unavailable, OpenAPI Generator issues page shallow)
-
-**Analysis method:** Compared abandoned oasv3 file against working v2 generator line-by-line. Identified missing features (cycle detection, caching, path-level params). Cross-referenced with OAS3 spec for undocumented behaviors. Verified template expectations from jinja2 files.
diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md
deleted file mode 100644
index 57e09bf0..00000000
--- a/.planning/research/STACK.md
+++ /dev/null
@@ -1,163 +0,0 @@
-# Technology Stack: OASv3 Code Generator
-
-**Domain:** Python code generation from OpenAPI 3.0.1 specs
-**Researched:** 2026-04-29
-**Overall confidence:** HIGH
-
-## Executive Summary
-
-The OASv3 generator requires minimal new dependencies. The live Meraki spec (tested 2026-04-29) uses requestBody, nullable, and oneOf but has ZERO $refs and no components/schemas, so complex dereferencing libraries are unnecessary. Hand-rolled JSON pointer traversal (already present in abandoned generator) suffices for future-proofing. Type stub generation should use Jinja2 templates (consistency with existing generator) rather than external tools.
-
-## Core Technologies
-
-All existing runtime dependencies remain unchanged. Generator adds one dependency.
-
-| Technology | Version | Purpose | Why |
-|------------|---------|---------|-----|
-| Python | >=3.11 | Runtime requirement | Already specified in pyproject.toml |
-| Jinja2 | ==3.1.6 | Template rendering | Already used for code generation, pinned in `dependency-groups.generator` |
-| requests | >=2.33.1,<3 | Fetching OpenAPI spec | Already used in v2 generator |
-| ruff | >=0.15.12 | Code formatting | Already used for post-generation formatting |
-
-**No new core dependencies required.**
-
-## Supporting Libraries
-
-### For $ref Resolution: NONE
-
-**Decision:** Hand-roll JSON pointer traversal (port from abandoned `generate_library_oasv3.py`).
-
-**Rationale:**
-- Live Meraki v3 spec has **0 $refs** (verified 2026-04-29)
-- Simple `#/components/schemas/X` pointer walking: 15 lines of code
-- No external URLs, no file loading, no complex edge cases
-- Cycle detection: visited set, 5 lines
-- Libraries like `jsonref` (1.1.0), `jsonpointer` (3.1.1), `prance` (25.4.8.0) are overkill
-
-**If future specs have $refs:**
-- `jsonpointer` (3.1.1, MIT): RFC 6901 compliant, production-stable, Python 3.10+
-- Fallback: current hand-rolled resolver handles simple cases fine
-
-### For OpenAPI Parsing: NONE
-
-**Decision:** Direct dict traversal of parsed JSON.
-
-**Rationale:**
-- `openapi-spec-validator` (0.8.5): Validates specs, doesn't help parsing
-- `openapi-core`: Request/response validation, not generation
-- `prance` (25.4.8.0): Parser + validator, but adds dependency for features we don't need (external file refs, validation backends)
-- Meraki spec is pre-validated (published by API team), direct access simpler
-
-### For Type Stub Generation: Jinja2 Templates
-
-**Decision:** Use existing Jinja2 template approach, not `mypy stubgen`.
-
-**Rationale:**
-- `mypy stubgen` (1.20.2): CLI-only, generates drafts with `Any` types, requires manual refinement
-- Jinja2 templates give precise control over generated stubs with OAS types → Python types mapping
-- Consistency with existing generator architecture
-- Pattern already proven in `function_template.jinja2`
-
-**Template approach:**
-```python
-# api_stub_template.jinja2 generates:
-def getOrganization(self, organizationId: str) -> dict: ...
-def updateOrganization(self, organizationId: str, **kwargs: Any) -> dict: ...
-```
-
-**PEP 561 compliance:**
-- Add `py.typed` marker file to package root (already present per grep results)
-- `.pyi` files alongside `.py` modules (e.g., `meraki/api/organizations.pyi`)
-
-## Alternatives Considered
-
-| Category | Recommended | Alternative | Why Not |
-|----------|-------------|-------------|---------|
-| $ref resolution | Hand-rolled | `jsonref` 1.1.0 | Live spec has 0 $refs. Hand-rolled is 20 lines including cycle detection. Library is 3 files + proxy overhead for unused features. |
-| $ref resolution | Hand-rolled | `prance` 25.4.8.0 | Parser includes validation backends, external file handling, URI resolution. Adds 5 dependencies for features not used. Documentation says cycle detection "not mentioned." |
-| OpenAPI parsing | Direct dict access | `openapi-spec-validator` 0.8.5 | Validates specs, doesn't simplify parsing. Adds dependency for unused validation (Meraki spec is pre-validated). |
-| OpenAPI parsing | Direct dict access | `openapi-core` | Request/response validation library, not a spec parser. Wrong tool for code generation. |
-| Type stubs | Jinja2 templates | `mypy stubgen` 1.20.2 | CLI-only, generates `Any` everywhere, requires manual fixes. No programmatic API. Template approach gives full type precision from OAS types. |
-| Type stubs | Jinja2 templates | `stubgen` package | PyPI page failed to load (2026-04-29). Mypy's stubgen is CLI-only per official docs. |
-
-## What NOT to Use
-
-### jsonschema (4.26.0)
-**Why:** JSON Schema validator, not OpenAPI parser. Does NOT provide OpenAPI-specific $ref resolution. Adds validation overhead for features not needed in code generation.
-
-### openapi-python-client (0.28.3)
-**Why:** Full client generator, not a library. Generates entire SDK with opinionated structure. We need selective parsing for our existing architecture, not a competing generator.
-
-### jsonref (1.1.0)
-**Why:** Proxy-based lazy evaluation is clever but unnecessary. Live spec has 0 $refs. If future specs have refs, they're simple `#/components/schemas/X` pointers, not recursive or external. Hand-rolled resolver is clearer and avoids proxy overhead.
-
-## Integration with Existing Generator
-
-The v2 generator uses:
-- `common.organize_spec()` - organizes paths by scope tags
-- Jinja2 templates: `class_template.jinja2`, `function_template.jinja2`, `batch_function_template.jinja2`
-- Jinja2 filter: `to_double_quote_list` for JSON arrays
-- `ruff` for post-generation formatting
-
-**v3 generator additions:**
-- New `resolve_ref()` function (port from abandoned oasv3, already exists)
-- New `parse_request_body()` function for OAS3 requestBody
-- Thread `spec` dict through all parse functions
-- New template: `stub_template.jinja2` for `.pyi` generation
-
-**No changes to:**
-- `common.py`
-- Existing templates (reuse as-is)
-- Runtime SDK (`rest_session.py`, pagination logic, etc.)
-
-## Installation
-
-No changes to runtime dependencies. Generator dependencies already in `pyproject.toml`:
-
-```toml
-[dependency-groups]
-generator = ["jinja2==3.1.6"]
-```
-
-For type stub generation, add `py.typed` marker if not present:
-
-```bash
-echo "" > meraki/py.typed
-```
-
-## Live Spec Verification (2026-04-29)
-
-Tested against `https://api.meraki.com/api/v1/openapiSpec?version=3`:
-
-| Feature | Count | Impact |
-|---------|-------|--------|
-| `openapi` | 3.0.1 | Target version confirmed |
-| `$ref` | 0 | No dereferencing needed for live spec |
-| `components/schemas` | 0 | No reusable schemas |
-| `oneOf` | 2 | Need oneOf type handling |
-| `nullable` | 152 | Need nullable annotation |
-| `requestBody` | 340 | Need requestBody parser |
-| `x-batchable-actions` | 298 | Existing batch handling reusable |
-
-**Implication:** The project requirements list `$ref` resolution as a feature, but the live spec doesn't use it. This suggests it's either:
-1. Future-proofing for anticipated spec changes
-2. Testing requirement (synthetic fixtures may include $refs)
-3. Mistaken assumption from OAS3 spec review
-
-**Recommendation:** Implement simple hand-rolled resolver (20 lines) for completeness, but flag in research that it's not exercised by production spec.
-
-## Sources
-
-**HIGH confidence:**
-- PyPI (official): openapi-spec-validator 0.8.5, prance 25.4.8.0, jsonschema 4.26.0, jsonref 1.1.0, jsonpointer 3.1.1, mypy 1.20.2, openapi-python-client 0.28.3
-- Live Meraki spec verification (2026-04-29): 0 $refs, 340 requestBody, 152 nullable, 2 oneOf
-- PEP 561: Type stub distribution standards (py.typed marker, .pyi files)
-- Existing codebase: pyproject.toml dependency groups, generate_library.py patterns, abandoned generate_library_oasv3.py resolve_ref()
-
-**MEDIUM confidence:**
-- Swagger.io docs: $ref resolution rules, edge cases (escape chars, sibling elements ignored)
-- OpenAPI 3.0.3 spec: $ref processing, JSON Reference standard
-- mypy docs: stubgen CLI-only, generates draft stubs
-
-**LOW confidence:**
-- None (all critical findings verified with authoritative sources)
diff --git a/.planning/research/SUMMARY.md b/.planning/research/SUMMARY.md
deleted file mode 100644
index cb18bb9d..00000000
--- a/.planning/research/SUMMARY.md
+++ /dev/null
@@ -1,284 +0,0 @@
-# Research Summary: OASv3 Generator Project
-
-**Date:** 2026-04-29
-**Project:** Meraki Dashboard API Python SDK - OpenAPI 3.0 Generator
-
-## Executive Summary
-
-- **Live spec tested (2026-04-29) has 0 $refs**, 340 requestBody, 152 nullable, 2 oneOf. Hand-rolled JSON pointer resolver (20 lines) sufficient, no jsonref dependency needed.
-- **Core challenge is OASv3 requestBody parsing** (moved from parameters array), not spec complexity. Templates reusable, parser layer normalizes v3 → v2 data format.
-- **Abandoned attempt has 5 critical bugs**: no cycle detection, no $ref cache (O(n²)), ignores path-level params, wrong content-type priority, no oneOf detection.
-- **Zero new runtime dependencies**. Jinja2 templates (not mypy stubgen) for type stubs. Ruff already present for formatting.
-- **298 batchable actions share schemas**. $ref cache essential for performance even though live spec has 0 refs (future-proofing + test fixtures will use refs).
-
-## Stack Additions Needed
-
-**None for runtime.** Generator uses existing dependencies.
-
-| Component | Version | Purpose | Status |
-|-----------|---------|---------|--------|
-| Jinja2 | 3.1.6 | Template rendering + stub generation | Already in pyproject.toml |
-| requests | >=2.33.1 | Fetch v3 spec (?version=3 param) | Already present |
-| ruff | >=0.15.12 | Post-generation formatting | Already present |
-| Python | >=3.11 | Type annotation syntax (str \| None) | Runtime requirement |
-
-**Rejected:**
-- jsonref (1.1.0): Proxy overhead for 0 refs in live spec
-- prance (25.4.8.0): 5 dependencies for unused file loading
-- openapi-spec-validator (0.8.5): Validation we don't need (spec pre-validated)
-- mypy stubgen (1.20.2): CLI-only, generates Any everywhere, no programmatic API
-
-## Feature Table Stakes vs Differentiators
-
-### Table Stakes (P0)
-
-Must-have for production generator:
-
-| Feature | Complexity | Notes |
-|---------|------------|-------|
-| $ref resolution + cycle detection | Medium | 0 in live spec but needed for fixtures, abandoned code crashes on cycles |
-| requestBody parsing (JSON/multipart/octet-stream) | Medium | 340 uses in live spec, v2 doesn't have this |
-| oneOf as Union[str, dict] | Medium | 2 in live spec, docstring "string or object" |
-| nullable → str \| None annotations | Low | 152 in live spec |
-| Path-level parameter inheritance | Low | OASv3 standard, abandoned code ignores |
-| Array serialization (style/explode) | Low | OASv3 defaults: form + explode=true |
-
-### Differentiators (P1)
-
-High-value, not expected by default:
-
-| Feature | Value | Status |
-|---------|-------|--------|
-| Type stubs (.pyi) via Jinja2 | Static analysis without runtime cost | Planned with --stubs flag |
-| Explicit param construction | Replaces locals() antipattern | Enables static analysis |
-| Golden-file test suite | Regression protection | Planned |
-| CI drift detection | v2 vs v3 output comparison | Planned |
-| kwarg validation + opt-in logging | Catch typos | Already in v2 |
-| Vendor extension (x-batchable-actions) | 298 batch endpoints | Already in v2 |
-
-### Anti-Features (Avoid)
-
-Commonly requested but wrong for this project:
-
-- **Pydantic model generation**: Runtime overhead, spec churn = breaking changes, 1000+ endpoints balloon size
-- **Full OAS 3.1 support**: Different type system (type: [string, null]), adds complexity
-- **Response model objects**: Dict returns work, TypedDict stubs provide typing
-- **Auto-generated examples in docstrings**: Specs rarely have good examples, go stale
-
-## Architecture Integration Strategy
-
-### Parser Layer (NEW)
-
-Normalizes OASv3 → v2-compatible param dicts for template reuse.
-
-```
-OASv3 spec fetch
- ↓
-resolve_ref(spec, ref, cache, stack) ← 20 lines, cycle detection, caching
- ↓
-get_schema_from_item(item, spec, cache) ← Extract schema, handle $ref
- ↓
-parse_request_body(op, requestBody, spec, cache) ← JSON/multipart/octet-stream → params dict
- ↓
-parse_params_v3(op, params, requestBody, spec, filters) ← Unified parser merges path/query/body
- ↓
-return_params(op, params, filters) ← Reuse v2 filtering (extract to common.py)
- ↓
-Templates (REUSE AS-IS) ← function_template.jinja2, class_template.jinja2, batch_template.jinja2
-```
-
-### Component Changes
-
-| Component | Action | Why |
-|-----------|--------|-----|
-| generate_library_oasv3.py | NEW | Entry point, ?version=3 param, spec fetch |
-| OASv3 parser functions | NEW | resolve_ref, get_schema_from_item, parse_request_body, parse_params_v3 |
-| return_params() | EXTRACT to common.py | Reuse filter logic (required, path, query, array, enum) |
-| generate_pagination_parameters() | EXTRACT to common.py | Version-agnostic |
-| docs_url() | EXTRACT to common.py | Pure transform op → URL |
-| organize_spec() | REUSE | Path/scope mapping version-agnostic |
-| Jinja2 templates | REUSE | Consume normalized params, no changes |
-| generate_modules() | DUPLICATE + modify | Call parse_params_v3, thread spec |
-
-### Data Flow Critical Point
-
-**Parser must produce v2-compatible dict format:**
-
-```python
-{
- "paramName": {
- "required": bool,
- "in": "path" | "query" | "body",
- "type": "string" | "integer" | "boolean" | "array" | "object",
- "description": str,
- "enum": list, # optional
- "items": dict, # optional for arrays
- "nullable": bool # NEW for v3
- }
-}
-```
-
-Templates expect this. Change breaks generated code.
-
-## Key Pitfalls to Watch
-
-### Critical (Rewrite Risk)
-
-1. **No cycle detection in $ref**: Circular schemas crash generator. Abandoned code lacks visited-set guard. Add `_visiting` set param to resolve_ref().
-
-2. **No $ref cache**: O(n²) with 298 batchable operations. 30+ second generation vs <5s. Add `spec["_ref_cache"]` dict, check before traversal.
-
-3. **Path-level params ignored**: OASv3 allows `paths[path]["parameters"]` inherited by all operations. Abandoned code only checks operation-level. Merge before parsing.
-
-4. **requestBody content-type priority wrong**: Checks JSON only, multipart endpoints (file uploads) break. Priority: JSON → multipart → octet-stream → warn.
-
-5. **Template data format mismatch**: Parser returns wrong dict structure, templates render empty params. Golden file tests catch this. Match v2 format exactly.
-
-### Moderate (Correctness Issues)
-
-6. **oneOf reported as generic "object"**: Loses "string or object" semantics. Add oneOf detection in get_schema_from_item, docstring enhancement.
-
-7. **Missing $ref target silent**: Returns None, param dropped without warning. Log warning with operation context.
-
-### Minor (Enhancement Opportunities)
-
-8. **Array serialization style ignored**: OASv3 style/explode attributes not checked. Document defaults (form + explode=true).
-
-9. **nullable not in type annotations**: `nullable: true` doesn't add `| None`. Thread nullable through to type builder.
-
-10. **Vendor extension loss**: x-batchable-actions survives, other x-* fields dropped. Forward all x- prefixed keys.
-
-### Integration Gotchas
-
-- **Shared common.py not threadsafe**: If v2/v3 run concurrently in tests, spec param breaks imports. Namespace v3 helpers or accept breaking change.
-- **Template filter registration skipped**: v2 registers `to_double_quote_list`, forget = crash. Copy ALL jinja_env setup.
-- **ruff assumes cwd**: Absolute paths needed. Test in tmp_path.
-
-## Phase Ordering Implications
-
-### Phase 1: Parser Foundation (5-7 days)
-**What:** Core v3 parsing without generation
-
-**Delivers:**
-- resolve_ref() with cycle detection + caching (unit tests)
-- get_schema_from_item() with $ref support
-- parse_request_body() (JSON, multipart, octet-stream)
-- Extract return_params() to common.py
-
-**Pitfalls addressed:** #1 (cycles), #2 (cache), #4 (content-type)
-
-**Research needed:** No. Patterns well-documented, abandoned code shows what NOT to do.
-
-**Rationale:** Foundation must be solid. $ref resolution used by all downstream parsing. Unit tests fast, isolated from generation complexity.
-
-### Phase 2: Unified Parameter Parser (3-5 days)
-**What:** parse_params_v3() merges path/query/body
-
-**Delivers:**
-- parse_params_v3() with path-level inheritance
-- Golden-file test with synthetic v3 fixture
-- Pagination param injection for perPage endpoints
-
-**Pitfalls addressed:** #3 (path-level params), #5 (data format)
-
-**Research needed:** No. Template expectations clear from function_template.jinja2.
-
-**Rationale:** Parser output must match template input. Golden file prevents template breakage. Synthetic fixture exercises edge cases (cycle, oneOf, nullable, multipart) not in live spec.
-
-**Dependencies:** Phase 1 complete (resolve_ref, parse_request_body)
-
-### Phase 3: Generation Integration (4-6 days)
-**What:** Duplicate generate_modules() with v3 parsing
-
-**Delivers:**
-- generate_library_oasv3.py entry point
-- generate_modules() calling parse_params_v3
-- HTTP method parsers (get/post/put/delete) with v3 support
-- Batch action detection for v3 structure
-
-**Pitfalls addressed:** #5 (template format validation)
-
-**Research needed:** No. generate_modules() structure proven in v2.
-
-**Rationale:** Duplication safer than modification. v2 stays stable, v3 iterates independently. Thread `spec` through all parsing calls.
-
-**Dependencies:** Phase 2 complete (parse_params_v3 produces correct format)
-
-### Phase 4: Type Stubs (3-4 days)
-**What:** .pyi generation via Jinja2
-
-**Delivers:**
-- stub_template.jinja2 for function signatures
-- --stubs flag in generate_library_oasv3.py
-- py.typed marker in package root
-- nullable → str | None, oneOf → Union[str, dict]
-
-**Pitfalls addressed:** #6 (oneOf detection), #9 (nullable)
-
-**Research needed:** No. PEP 561 compliance straightforward, Jinja2 approach proven.
-
-**Rationale:** Defer until core generation stable. Type stubs reuse same parse_params_v3 output. Differentiator feature, not table stakes.
-
-**Dependencies:** Phase 3 complete (generation working)
-
-### Phase 5: Quality & CI (2-3 days)
-**What:** Testing, drift detection, docs
-
-**Delivers:**
-- Golden file tests expanded (edge cases: cycle, multipart, oneOf, nullable)
-- CI drift detection (v2 vs v3 semantic diff, not text)
-- Integration test with live v3 spec
-- OASV3-MIGRATION.md updates
-
-**Pitfalls addressed:** #7 (missing $ref warnings), #8 (array style), #10 (vendor extensions)
-
-**Research needed:** No. Test patterns exist in test_generate_library_golden.py.
-
-**Rationale:** Catch regressions before v2 replacement. Semantic diff prevents false positives from docstring enhancements.
-
-**Dependencies:** Phase 4 complete (full feature set)
-
-### Defer to Future
-
-- Response model objects (Pydantic/dataclasses): High complexity, low value for dict-based API
-- Operation-specific exceptions (NotFoundError): Nice-to-have, APIError works
-- Custom HTTP clients (httpx pluggability): requests/aiohttp sufficient
-
-## Open Questions
-
-1. **Does live v3 spec use path-level parameters?** Abandoned code suggests yes (OASv3 standard), but 0 uses found in manual inspection. Verify during Phase 2 golden file fixture creation.
-
-2. **What's the v3 batch action detection logic?** v2 uses summary field match. Does v3 spec structure x-batchable-actions differently? Test during Phase 3 with live spec.
-
-3. **Should v3 fix locals() antipattern?** OASV3-MIGRATION.md suggests explicit param construction. Do it in v3 (fresh start) or defer (maintain v2 parity)? Decide during Phase 3 template work.
-
-4. **CI drift detection semantic vs text diff?** How to diff "params correct, docstrings enhanced" vs "params missing"? Design diffing logic in Phase 5.
-
-5. **Should return_params() extraction break v2 imports?** Move to common.py changes imports. Update v2 (safer) or namespace as common_v3.py (isolation)? Decide in Phase 1.
-
-## Confidence Assessment
-
-| Area | Confidence | Rationale |
-|------|------------|-----------|
-| Stack | HIGH | Live spec tested 2026-04-29. Jinja2/requests/ruff proven in v2. No new dependencies confirmed. |
-| Features | HIGH | OASV3-MIGRATION.md + PROJECT.md align. Table stakes clear from v2 gaps. Live spec verified (340 requestBody, 152 nullable). |
-| Architecture | HIGH | v2 structure analyzed. Parse-organize-render pipeline proven. Abandoned code shows pitfalls. Template reuse confirmed. |
-| Pitfalls | HIGH | Abandoned code line-by-line comparison. 5 critical bugs identified with prevention. OASv3 spec edge cases documented. |
-
-**Gaps identified:**
-- Path-level parameter usage in live spec (assume present per standard)
-- Batch action structure in v3 (likely same x-batchable-actions)
-- oneOf query param frequency (2 in live spec, need broader fixture coverage)
-
-**No research blockers.** All questions answerable during implementation with live spec + fixtures.
-
-## Ready for Requirements
-
-All research complete. Roadmapper can structure phases with confidence in:
-- Technology stack (no new deps, Jinja2 for stubs)
-- Feature priorities (table stakes vs differentiators clear)
-- Architecture approach (parser layer + template reuse)
-- Pitfall prevention (5 critical bugs with solutions)
-- Build order (Phase 1-5 dependencies mapped)
-
-Research files committed together for traceability.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..a169e9b7
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,22 @@
+# Changelog
+
+All notable changes to this library are documented here.
+
+This file is maintained with [towncrier](https://towncrier.readthedocs.io/);
+add a news fragment under `changelog.d/` for every user-facing change. See
+[CONTRIBUTING.md](CONTRIBUTING.md) for the fragment format.
+
+
+
+## 4.3.0 (2026-07-09)
+
+### Added
+
+- On 5xx responses, the SDK now logs the Meraki `X-Request-Id` response header so it can be shared with Meraki to look up the request in server-side logs. If the header is absent, `none` is logged in its place. After retries are exhausted, the request ID is also logged at error level.
+
+
+## 4.2.0b1 (2026-06-10)
+
+### Changed
+
+- Migrated the HTTP transport layer from `requests`/`aiohttp` to `httpx`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 343ad828..f208d604 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -39,6 +39,27 @@ uv sync
- Generated API scope files (`meraki/api/`, `meraki/aio/api/`) are auto-generated from the OpenAPI spec. Changes here will be overwritten. Fix the generator instead.
- Do not vendor or bundle dependencies.
+## Changelog Fragments
+
+Every user-facing change needs a news fragment in `changelog.d/`. Do not edit `CHANGELOG.md` by hand; towncrier builds it at release time.
+
+File name: `changelog.d/{issue}.{type}.md`. With no issue, use a slug prefixed with `+`: `changelog.d/+httpx-migration.changed.md`.
+
+Types: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`.
+
+```bash
+# Issue-linked
+echo "Retry on 429 now honors Retry-After." > changelog.d/1234.fixed.md
+
+# Or via towncrier
+uv run towncrier create -c "Retry on 429 now honors Retry-After." 1234.fixed.md
+
+# Preview the rendered changelog without consuming fragments
+uv run towncrier build --draft --version "$(grep '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/')"
+```
+
+One fragment per change. Multiple fragments per issue are fine (e.g. `1234.added.md` and `1234.fixed.md`). Fragments are not wiped by library regeneration; they survive until the next release build.
+
## Running the Generator
```bash
diff --git a/VERSIONING.md b/VERSIONING.md
new file mode 100644
index 00000000..0ef9bcd2
--- /dev/null
+++ b/VERSIONING.md
@@ -0,0 +1,99 @@
+# Versioning
+
+This document covers the SDK versioning strategy and how it relates to the upstream dashboard API.
+
+## Which version should I use?
+
+Use the latest stable release:
+
+```sh
+pip install --upgrade meraki
+```
+
+Only use a different release if:
+
+- You want to test out the latest SDK features that aren't yet stable, or
+- You want access to beta API operations that haven't graduated to GA yet, and you accept that those operations may change or disappear in future releases, even if you don't update or change your library version.
+
+## How do I know which version I'm using?
+
+If you're using v3.x+:
+
+```python
+import meraki
+print(meraki.__version__) # SDK version, e.g. "4.1.0b2"
+print(meraki.__api_version__) # API version, e.g. "1.70.0-beta.2"
+```
+
+Otherwise, please see [Releases](https://github.com/meraki/dashboard-api-python/releases).
+
+### Why aren't the SDK version and the API version linked (as of [SDK 2.x, released April 8, 2025](https://github.com/meraki/dashboard-api-python/releases/tag/2.0.0))?
+
+The library and the API are related but separate software. Improving the SDK sometimes requires SDK-only changes, which in turn necessitates bumping the SDK version separately from the upstream API.
+
+## Scheme
+
+```text
+MAJOR.MINOR.PATCH[bN]
+```
+
+| Segment | Bumped when |
+| ------- | ----------------------------------------------------------------------------------------------------------------------- |
+| MAJOR | SDK-only breaking changes are made (new HTTP backend, generated code structure changes, Python version support changes) |
+| MINOR | New upstream API release (tracks Meraki dashboard API minor versions) |
+| PATCH | SDK bug fixes or upstream API patch releases |
+| bN | Pre-release beta suffix for unreleased major/minor tracks, including upstream API beta operations |
+
+## Objective
+
+The repo maintainers work to streamline the adoption of new major SDK versions and minimize breaking changes. The general expectation is as follows:
+
+- if you are exclusively using GA operations from the upstream API, and
+- if you are keeping your local Python environment updated to [a contemporary Python version](https://devguide.python.org/versions/), i.e., a version that is not already nor end of life within 6 months, and
+- if you are sticking to the SDK's own supported surfaces (i.e., not monkey-patching the library)
+
+Then you should be able to update your environment to the latest stable library version without breaking changes to your application.
+
+If you identify any unexpected breaking changes that are not a result of the upstream API changing, then please [report the issue](https://github.com/meraki/dashboard-api-python/issues).
+
+## Beta Convention
+
+Pre-release versions use PEP 440 beta suffixes: `4.1.0b1`, `4.1.0b2`, etc.
+
+Beta releases are published to PyPI and installable via `pip install meraki==4.1.0b2`. They are not installed by default (`pip install meraki` gets the latest stable).
+
+### API operation coverage
+
+- **Stable releases** contain only GA (stable) operations from the upstream API. If an operation is marked beta in the dashboard API spec, it is excluded from stable builds.
+- **Beta releases** may include beta API operations from the dashboard API in addition to all GA operations. These operations are subject to change or removal without notice by the upstream API, since beta API operations are subject to unannounced breaking changes.
+
+This means upgrading from a beta to a stable release can _remove_ operations that were previously available, if those operations have not yet graduated to GA upstream.
+
+## SDK-to-API Version Mapping
+
+Each SDK minor release is generated from a specific dashboard API version:
+
+| SDK | API | Status | What changed |
+| --- | ------------ | ----------- | --------------------------------------------------------------------------------------------------------------------- |
+| 4.x | v1.70.0+ | Development | httpx migration (unified sync/async backend), supported Python version changes, all major developments moving forward |
+| 3.x | v1.69.0+ | Stable | OASv3-based, automated release pipeline, supported Python version changes |
+| 2.x | v1 (various) | Deprecated | Internal overhaul (async via aiohttp, pagination, logging, supported Python version changes) |
+| 1.x | v1 (various) | Deprecated | Initial v1 API library, requests-based |
+
+Within a major, the minor version/patch versions advance with each API release. For example, 3.1.0 was generated from API v1.70.0.
+
+Patch versions can also advance based on SDK-only fixes.
+
+## Where Versions Live
+
+| What | Location |
+| --------------- | ------------------------------------------------------------------ |
+| SDK version | `meraki/_version.py` (`__version__`), `pyproject.toml` (`version`) |
+| API version | `meraki/__init__.py` (`__api_version__`) |
+| Git tags | `4.1.0b2` (no `v` prefix for 2.x+) |
+| Release commits | `Auto-generated library v{SDK} for API v{API}.` |
+
+## Release Triggers
+
+- **Automated**: the generator runs when a new OpenAPI spec is detected, producing a minor bump
+- **Manual**: major versions and SDK-only patches are released by maintainers
diff --git a/changelog.d/.gitignore b/changelog.d/.gitignore
new file mode 100644
index 00000000..f935021a
--- /dev/null
+++ b/changelog.d/.gitignore
@@ -0,0 +1 @@
+!.gitignore
diff --git a/HTTPX-MIGRATION.md b/docs/HTTPX-MIGRATION.md
similarity index 88%
rename from HTTPX-MIGRATION.md
rename to docs/HTTPX-MIGRATION.md
index c5acc4e1..9ee139e4 100644
--- a/HTTPX-MIGRATION.md
+++ b/docs/HTTPX-MIGRATION.md
@@ -163,6 +163,60 @@ These have **different signatures and different attribute sources**. Unifying re
---
+## Deprecated: AsyncAPIError
+
+**Status:** Deprecated as of v4.0. Use `APIError` for both sync and async exceptions.
+
+### What Changed
+
+In previous versions, the SDK used two separate exception classes:
+- `APIError` for synchronous errors
+- `AsyncAPIError` for asynchronous errors
+
+Starting in v4.0, both sync and async sessions raise exceptions that inherit from `APIError`. The `AsyncAPIError` class remains available for backwards compatibility but is deprecated.
+
+### Migration
+
+**Before (v3.x):**
+```python
+from meraki.aio import AsyncDashboardAPI
+from meraki.exceptions import AsyncAPIError
+
+async with AsyncDashboardAPI(api_key=API_KEY) as aiomeraki:
+ try:
+ response = await aiomeraki.organizations.getOrganizations()
+ except AsyncAPIError as e:
+ print(f"Error: {e.status} {e.reason}")
+```
+
+**After (v4.0+):**
+```python
+from meraki.aio import AsyncDashboardAPI
+from meraki.exceptions import APIError # Changed
+
+async with AsyncDashboardAPI(api_key=API_KEY) as aiomeraki:
+ try:
+ response = await aiomeraki.organizations.getOrganizations()
+ except APIError as e: # Changed
+ print(f"Error: {e.status} {e.reason}")
+```
+
+### Backwards Compatibility
+
+Existing code using `except AsyncAPIError:` will continue to work because `AsyncAPIError` is now a subclass of `APIError`. However, you will see a `DeprecationWarning` when the exception is raised.
+
+To suppress the warning during migration:
+```python
+import warnings
+warnings.filterwarnings('ignore', category=DeprecationWarning, module='meraki')
+```
+
+### Recommended Action
+
+Update exception handlers to catch `APIError` instead of `AsyncAPIError`. This future-proofs your code and eliminates deprecation warnings.
+
+---
+
## Phase 6: Update Dependencies
**Modify `pyproject.toml`:**
diff --git a/OASV3-MIGRATION.md b/docs/OASV3-MIGRATION.md
similarity index 100%
rename from OASV3-MIGRATION.md
rename to docs/OASV3-MIGRATION.md
diff --git a/docs/generation-report.md b/docs/generation-report.md
new file mode 100644
index 00000000..ffc35b2a
--- /dev/null
+++ b/docs/generation-report.md
@@ -0,0 +1,74 @@
+# Generation Report
+
+## 2026-07-09 | Library v4.3.0 | API 1.72.0
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-07-09 | Library v4.3.0 | API 1.72.0
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-07-08 | Library v4.3.0b1 | API 1.72.0-beta.1
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-07-01 | Library v4.3.0b0 | API 1.72.0-beta.0
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-06-24 | Library v4.2.0b3 | API 1.71.0-beta.3
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-06-17 | Library v4.2.0b2 | API 1.71.0-beta.2
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-06-10 | Library v4.2.0b1 | API 1.71.0-beta.1
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-06-10 | Library v4.1.0b1 | API 1.71.0-beta.1
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-05-27 | Library v4.1.0b3 | API 1.70.0-beta.3
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-05-20 | Library v4.1.0b2 | API 1.70.0-beta.2
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-05-14 | Library v4.1.0b1 | API 1.70.0-beta.1
+
+
+No Python keyword parameter conflicts detected.
+
+
+## 2026-05-08 | Library v4.1.0b0 | API 1.70.0-beta.0
+
+
+No Python keyword parameter conflicts detected.
+
+
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 00000000..76e0862c
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,65 @@
+# Releasing
+
+## Automated releases
+
+A GitHub Action polls [meraki/openapi releases](https://github.com/meraki/openapi/releases) daily at 14:00 UTC. When a new release is detected, it triggers library regeneration, runs tests, and publishes automatically.
+
+## Versioning rules
+
+1. **Source of truth is the target branch.** The current library minor version is read from `pyproject.toml` on `main` (GA), `beta` (pre-releases), or `httpx` (dev pre-releases).
+
+2. **GA releases go to `main`.** Bump library minor, reset patch: `X.Y.Z` becomes `X.(Y+1).0`.
+
+3. **Beta releases go to `beta`.** Library patch and beta number mirror the API release:
+ - Library patch = API patch (e.g. API `1.81.2-beta.0` → library patch `2`)
+ - Library beta number = API beta number (e.g. API `1.81.2-beta.3` → `bN` where N=3)
+ - New API minor bumps library minor (e.g. API minor 80 → 81 bumps library minor)
+
+4. **Dev releases go to `httpx`.** Same versioning logic as beta (mirrors API patch and beta number). Triggered by the same prerelease tags. The generator runs with the `-b httpx` flag and a test org ID for beta endpoint access.
+
+5. **API version is the OpenAPI tag with `v` stripped.** `v1.71.0-beta.2` becomes `1.71.0-beta.2`.
+
+6. **Manual releases can happen independently.** Patches, features, or fixes can be released at any time. They update `pyproject.toml` on their target branch, and the next automated release uses that as its baseline.
+
+7. **GA always bumps minor, never patch.** Even after manual patches (e.g. `3.1.1` becomes `3.2.0`, not `3.1.2`).
+
+8. **No version slot is reused.** If a manual release occupies the next expected version, the automated release takes the one after it.
+
+## Examples
+
+Starting from `main` at `3.1.0`, `beta` at `3.1.0`, `httpx` at `4.0.0`:
+
+| Event | API Version | Library Version | Branch |
+| --- | --- | --- | --- |
+| `v1.71.0-beta.0` detected | `1.71.0-beta.0` | `3.2.0b0` | `beta` |
+| (same trigger) | `1.71.0-beta.0` | `4.1.0b0` | `httpx` |
+| `v1.71.0-beta.1` detected | `1.71.0-beta.1` | `3.2.0b1` | `beta` |
+| (same trigger) | `1.71.0-beta.1` | `4.1.0b1` | `httpx` |
+| `v1.71.1-beta.0` detected | `1.71.1-beta.0` | `3.2.1b0` | `beta` |
+| (same trigger) | `1.71.1-beta.0` | `4.1.1b0` | `httpx` |
+| Manual bugfix (no API change) | `1.70.0` | `3.1.1` | `main` |
+| `v1.71.0` detected (GA) | `1.71.0` | `3.2.0` | `main` |
+| `v1.72.0-beta.0` detected | `1.72.0-beta.0` | `3.3.0b0` | `beta` |
+| (same trigger) | `1.72.0-beta.0` | `4.2.0b0` | `httpx` |
+
+## Beta/dev release additional step
+
+When a new prerelease is detected, the `enable-early-access.yml` workflow runs first (waited on synchronously). This enables early access features on the test organizations so the generated beta and dev SDKs can be tested against them.
+
+## Changelog management
+
+`CHANGELOG.md` is generated by [towncrier](https://towncrier.readthedocs.io/) from news fragments in `changelog.d/`. Contributors add one fragment per user-facing change (see [CONTRIBUTING.md](../CONTRIBUTING.md) for the format); nobody edits `CHANGELOG.md` directly.
+
+The `create-release.yml` workflow runs `towncrier build --yes --version ` after version validation and before `gh release create`. The version comes from the same `pyproject.toml` read that drives the release tag, so there is no separate towncrier version to keep in sync. The build renders the fragments into `CHANGELOG.md`, deletes the consumed fragments, and commits both changes to the release branch before the GitHub release is cut.
+
+This runs only at release time, not during regeneration. `regenerate-library.yml` rewrites generated code under `meraki/` but never touches `changelog.d/` or `CHANGELOG.md`, so fragments added on `main`, `beta`, or `httpx` survive regeneration PRs and are consumed only by the next release on that branch. Each branch keeps its own fragments and accumulates them independently across the three-branch model.
+
+## Release branches and PRs
+
+| Stage | Source branch | Release branch | PR target |
+| --- | --- | --- | --- |
+| GA | `main` | `release` | `main` |
+| Beta | `beta` | `beta-release` | `beta` |
+| Dev | `httpx` | `httpx-release` | `httpx` |
+
+All release PRs are set to auto-merge (squash). Once merged and tests pass, the `create-release.yml` workflow creates a GitHub release, which triggers `publish-release.yml` to publish to PyPI.
diff --git a/docs/superpowers/plans/2026-07-08-httpx-to-main-migration.md b/docs/superpowers/plans/2026-07-08-httpx-to-main-migration.md
new file mode 100644
index 00000000..d42360be
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-08-httpx-to-main-migration.md
@@ -0,0 +1,231 @@
+# httpx → main Final Migration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Merge the httpx-based transport rewrite (branch `origin/httpx`) into `main`, making main's tree exactly httpx while preserving both branches' commit history, then regenerate the API surface to GA.
+
+**Architecture:** A no-fast-forward merge records httpx as a second parent (preserving both histories in the DAG), then `git read-tree --reset` forces the working tree to be byte-identical to httpx (no 155-file conflict resolution). The one genuinely main-only feature not already on httpx (X-Request-Id-on-5xx) is replayed via cherry-pick. The generated `meraki/api` code and version strings arrive as beta throwaway and are regenerated to GA by the maintainer.
+
+**Tech Stack:** git (merge, read-tree, cherry-pick), Python 3, uv, pytest, ruff.
+
+## Global Constraints
+
+- Base branch for the PR: `main`. Work branch: `migrate/httpx`.
+- httpx tip = `origin/httpx` (`6b20c2a`). main tip = `ec91975`. Merge base = `6d3dd19`.
+- Tree after merge MUST equal `origin/httpx` byte-for-byte (verified via `git diff --stat origin/httpx` = empty).
+- Both parent histories MUST remain reachable (merge commit has exactly 2 parents).
+- Do NOT hand-port app-id/bearer or delete()-params — both already exist on httpx tip.
+- Do NOT cherry-pick dangling `6c8bf68` — it is a duplicate of httpx tip's app-id/bearer feature.
+- The maintainer regenerates `meraki/api`, `meraki/aio/api`, and version strings AFTER the merge — the plan does not touch generated code or versions.
+- Pre-existing stashes `stash@{0}` / `stash@{1}` on main are the maintainer's — do not pop or drop them.
+
+---
+
+### Task 1: Create migration branch and history-preserving merge
+
+**Files:**
+- No file edits; git plumbing only. Result: `migrate/httpx` branch with a 2-parent merge commit whose tree == `origin/httpx`.
+
+**Interfaces:**
+- Consumes: `main` (current), `origin/httpx` (fetched).
+- Produces: branch `migrate/httpx` at a merge commit `M` with parents `[main_tip, origin/httpx_tip]` and tree identical to `origin/httpx`.
+
+- [ ] **Step 1: Fetch and confirm clean starting state**
+
+```bash
+cd "c:/Users/jkuchta/Work/_Repos/meraki/dashboard-api-python"
+git fetch origin
+git checkout main
+git status --short # expect: empty (stashes are fine, not shown here)
+git rev-parse --short main origin/httpx
+```
+Expected: working tree clean; `main` = `ec91975...`, `origin/httpx` = `6b20c2a...`.
+
+- [ ] **Step 2: Create the migration branch**
+
+```bash
+git checkout -b migrate/httpx main
+```
+Expected: `Switched to a new branch 'migrate/httpx'`.
+
+- [ ] **Step 3: Start the merge without committing**
+
+```bash
+git merge --no-commit --no-ff origin/httpx
+```
+Expected: reports conflicts (155 files). This is expected and ignored — the next step overwrites the tree wholesale. Do NOT resolve conflicts by hand.
+
+- [ ] **Step 4: Force index + working tree to exactly httpx**
+
+```bash
+git read-tree -u --reset origin/httpx
+```
+Expected: no output. Index and working tree now match `origin/httpx` byte-for-byte; merge is still in progress (MERGE_HEAD set).
+
+- [ ] **Step 5: Verify tree equals httpx before committing**
+
+```bash
+git diff --stat origin/httpx # expect: EMPTY (identical trees)
+git diff --stat --cached origin/httpx # expect: EMPTY
+```
+Expected: both empty. If not empty, STOP — do not commit; investigate.
+
+- [ ] **Step 6: Commit the merge**
+
+```bash
+git commit --no-edit
+```
+Expected: a merge commit is created.
+
+- [ ] **Step 7: Verify 2-parent merge and history preservation**
+
+```bash
+git log -1 --format='%h parents: %p' # expect 2 parent hashes
+git rev-parse --short 'HEAD^1' 'HEAD^2' # expect ec91975 (main), 6b20c2a (httpx)
+git merge-base --is-ancestor ec91975 HEAD && echo "MAIN HISTORY PRESERVED"
+git merge-base --is-ancestor 6b20c2a HEAD && echo "HTTPX HISTORY PRESERVED"
+git rev-list --count origin/httpx..HEAD^1 # expect 83 (main-only commits reachable)
+```
+Expected: 2 parents (main tip + httpx tip), both "PRESERVED" lines print, count = 83.
+
+- [ ] **Step 8: No commit here** — the merge commit from Step 6 is the deliverable. Proceed to Task 2.
+
+---
+
+### Task 2: Replay X-Request-Id-on-5xx (the one missing feature)
+
+**Files:**
+- Modify (via cherry-pick `18e47b1`): `meraki/session/base.py`, `meraki/session/async_.py`
+- Test (via cherry-pick): `tests/unit/test_rest_session.py`, `tests/unit/test_aio_rest_session.py`, `tests/unit/conftest.py`
+- Add (via cherry-pick): `changelog.d/+log-x-request-id-on-5xx.added.md`
+
+**Interfaces:**
+- Consumes: merge commit from Task 1 (httpx `session/` layout).
+- Produces: X-Request-Id logging on 5xx responses in both sync and async sessions, with unit tests. `18e47b1` was built on an older base (`5da8e29`), so a conflict in `base.py`/`async_.py` is possible.
+
+- [ ] **Step 1: Confirm the feature is genuinely absent at the merge tip**
+
+```bash
+git show HEAD:meraki/session/base.py | grep -niE "x-request-id|request_id|requestId" || echo "ABSENT — cherry-pick needed"
+```
+Expected: "ABSENT — cherry-pick needed".
+
+- [ ] **Step 2: Cherry-pick the feature commit**
+
+```bash
+git cherry-pick 18e47b1
+```
+Expected: either clean success, OR a conflict in `meraki/session/base.py` / `meraki/session/async_.py`.
+
+- [ ] **Step 3: If conflict — resolve**
+
+Open each conflicted file. The intent: on a 5xx response, read the `X-Request-Id` response header and include it in the error log line. Keep httpx's surrounding code (its `session/base.py` structure), inserting only the request-id logging from the incoming patch. After resolving:
+
+```bash
+git add meraki/session/base.py meraki/session/async_.py
+git cherry-pick --continue
+```
+Expected: cherry-pick completes. If the changelog/test files also conflict, take the incoming (`--theirs`) version for the new changelog fragment and merge test additions.
+
+- [ ] **Step 4: Run the cherry-picked tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/test_rest_session.py tests/unit/test_aio_rest_session.py -v -k "request_id or requestId or 5xx or request_id_log" 2>&1 | tail -30
+```
+Expected: the X-Request-Id tests from `18e47b1` PASS. If the `-k` filter matches nothing, run the two files in full and confirm the request-id tests are present and green.
+
+- [ ] **Step 5: No separate commit** — `git cherry-pick` already created the commit. Proceed to Task 3.
+
+---
+
+### Task 3: Full verification before handoff
+
+**Files:**
+- No edits. Verification only.
+
+**Interfaces:**
+- Consumes: `migrate/httpx` after Tasks 1-2.
+- Produces: evidence the merged tree imports and the httpx test suite passes on top of the merge. (Generated `meraki/api` is still beta here — regeneration is the maintainer's step, done after this plan.)
+
+- [ ] **Step 1: Sync dependencies to the merged lockfile**
+
+```bash
+uv sync
+```
+Expected: environment resolves against httpx's `uv.lock` (httpx, not requests). No errors.
+
+- [ ] **Step 2: Import smoke test (transport swap sanity)**
+
+```bash
+uv run python -c "import meraki; d = meraki.DashboardAPI.__init__; print('sync import OK'); import meraki.aio; print('async import OK')"
+```
+Expected: both "OK" lines print, no ImportError/ModuleNotFoundError referencing `rest_session`.
+
+- [ ] **Step 3: Run the unit + session test suite**
+
+```bash
+uv run pytest tests/unit -q 2>&1 | tail -30
+```
+Expected: all pass. These are httpx's own tests plus the cherry-picked request-id tests.
+
+- [ ] **Step 4: Run generator tests**
+
+```bash
+uv run pytest tests/generator -q 2>&1 | tail -20
+```
+Expected: pass (validates the httpx generator/scaffolding landed intact).
+
+- [ ] **Step 5: Lint**
+
+```bash
+uv run ruff check meraki tests 2>&1 | tail -20
+```
+Expected: clean, or only pre-existing httpx-branch findings (compare against `git show origin/httpx` if unsure).
+
+- [ ] **Step 6: Record verification result**
+
+If any step fails, STOP and report which — do not open the PR. If all pass, the branch is ready for the maintainer's regeneration step and PR.
+
+---
+
+### Task 4: Handoff for regeneration + PR (maintainer-owned)
+
+**Files:**
+- Maintainer regenerates: `meraki/api/**`, `meraki/aio/api/**`, `meraki/_version.py`, `meraki/__init__.py` (`__api_version__`).
+
+**Interfaces:**
+- Consumes: verified `migrate/httpx` branch.
+- Produces: GA-versioned generated code, then a PR into `main`.
+
+- [ ] **Step 1: Regenerate API surface to GA (maintainer runs the generator)**
+
+The generated code and version strings on the branch are currently httpx's beta values (`4.3.0b1`, `1.72.0-beta.1`). Maintainer runs the generator against the GA spec and sets the GA version. This plan does not prescribe the generator command — it is the maintainer's existing workflow.
+
+- [ ] **Step 2: Re-run verification after regeneration**
+
+```bash
+uv run pytest tests/unit tests/generator -q 2>&1 | tail -20
+uv run python -c "import meraki, meraki.aio; print('import OK')"
+```
+Expected: pass. Confirms regenerated code matches httpx session call signatures.
+
+- [ ] **Step 3: Push and open the PR**
+
+```bash
+git push -u origin migrate/httpx
+```
+Then open a PR `migrate/httpx -> main`. PR body should note: transport rewrite (requests → httpx), both histories preserved, X-Request-Id-on-5xx replayed, API regenerated to GA.
+
+- [ ] **Step 4: Do NOT delete branch `httpx`** until the PR is merged and the team confirms the dev track's next steps.
+
+---
+
+## Appendix: PyPI publish outage (root-caused during this migration)
+
+**Symptom:** Every beta/dev release after 2026-06-10 (`3.2.0b3`, `3.3.0b0`, `4.2.0b1/b2/b3`, `4.3.0b0/b1`) created a GitHub Release but never published to PyPI. GA releases (`3.2.0`, `3.3.0`) published fine.
+
+**Root cause:** `release`-triggered workflows run the workflow file from the **tagged commit's tree**, not the default branch. PRs #407 (`db0ddfe`, beta) and #408 (`1f84868`, httpx) deleted `publish-release.yml` from beta+httpx on 2026-06-10 as "unreachable orchestration." The first beta cut 7 minutes later (`4.2.0b1`) had no publish workflow in its tree, so the `release: created` event had nothing to run. GA survived because GA tags come off `main`, which kept the file. Truth table (publish-release.yml in tag tree ⟺ on PyPI) held with zero exceptions.
+
+**Forward fix (applied 2026-07-08):** restored `publish-release.yml` to `httpx` (`acbfd6a`), `beta` (`7fd69d5`), and `migrate/httpx` (`c7800d2`, part of this migration). `create-release`/`watch-openapi-release`/`regenerate-library`/`enable-early-access` correctly stay single-sourced on `main` (their `workflow_run`/`schedule`/`workflow_dispatch` triggers use main's copy; only `release`-triggered workflows must live on every release-cutting branch).
+
+**Backfill:** decided NOT to backfill the 8 orphaned PyPI versions — superseded quickly, and PyPI uploads are permanent/non-reusable. Gaps remain by choice.
diff --git a/examples/async_crud_lifecycle.py b/examples/async_crud_lifecycle.py
new file mode 100644
index 00000000..a79ac9cb
--- /dev/null
+++ b/examples/async_crud_lifecycle.py
@@ -0,0 +1,344 @@
+"""Async CRUD lifecycle demo for the Meraki Dashboard API Python SDK.
+
+Exercises create/update/delete workflows across org-level and network-level
+resources (policy objects, alerts profiles, group policies, MQTT brokers,
+webhooks, SSIDs, network settings, and action batches) using the async client.
+
+All operations run concurrently per org and each resource is fully cleaned up
+before the script exits.
+
+Usage:
+ export MERAKI_DASHBOARD_API_KEY=
+ python examples/async_crud_lifecycle.py
+"""
+
+import asyncio
+import hashlib
+import time
+from functools import wraps
+
+import meraki.aio
+
+
+KEYWORD = "TestKeyword"
+
+
+def _salt():
+ return hashlib.md5(str(time.time()).encode()).hexdigest()[:6]
+
+
+def timed(fn):
+ @wraps(fn)
+ async def wrapper(*args, **kwargs):
+ start = time.perf_counter()
+ result = await fn(*args, **kwargs)
+ elapsed = time.perf_counter() - start
+ return (*result, elapsed)
+
+ return wrapper
+
+
+@timed
+async def test_policy_object(dashboard, org_id, org_name):
+ """Create -> rename -> delete a policy object."""
+ salt = _salt()
+ print(f"[{org_name}] Creating policy object...")
+ obj = await dashboard.organizations.createOrganizationPolicyObject(
+ org_id,
+ name=f"test-policy-object-{salt}",
+ category="network",
+ type="cidr",
+ cidr="10.0.0.0/24",
+ )
+ obj_id = obj["id"]
+ print(f"[{org_name}] Created policy object: {obj_id}")
+
+ print(f"[{org_name}] Renaming policy object...")
+ await dashboard.organizations.updateOrganizationPolicyObject(org_id, obj_id, name=f"test-policy-object-{salt}-r")
+
+ print(f"[{org_name}] Deleting policy object...")
+ await dashboard.organizations.deleteOrganizationPolicyObject(org_id, obj_id)
+ print(f"[{org_name}] Policy object deleted.")
+
+ return ("policy_object", obj_id)
+
+
+@timed
+async def test_policy_object_group(dashboard, org_id, org_name):
+ """Create -> rename -> delete a policy object group."""
+ salt = _salt()
+ print(f"[{org_name}] Creating policy object group...")
+ group = await dashboard.organizations.createOrganizationPolicyObjectsGroup(
+ org_id, name=f"test-policy-group-{salt}", category="NetworkObjectGroup"
+ )
+ group_id = group["id"]
+ print(f"[{org_name}] Created policy object group: {group_id}")
+
+ print(f"[{org_name}] Renaming policy object group...")
+ await dashboard.organizations.updateOrganizationPolicyObjectsGroup(org_id, group_id, name=f"test-policy-group-{salt}-r")
+
+ print(f"[{org_name}] Deleting policy object group...")
+ await dashboard.organizations.deleteOrganizationPolicyObjectsGroup(org_id, group_id)
+ print(f"[{org_name}] Policy object group deleted.")
+
+ return ("policy_object_group", group_id)
+
+
+@timed
+async def test_alerts_profile(dashboard, org_id, org_name):
+ """Create -> update -> delete an org alerts profile."""
+ salt = _salt()
+ print(f"[{org_name}] Creating alerts profile...")
+ profile = await dashboard.organizations.createOrganizationAlertsProfile(
+ org_id,
+ type="wanUtilization",
+ alertCondition={"duration": 60, "window": 600, "bit_rate_bps": 1000000, "interface": "wan1"},
+ recipients={"emails": ["test@example.com"]},
+ networkTags=["__all_tags__"],
+ description=f"test-alert-profile-{salt}",
+ )
+ profile_id = profile["id"]
+ print(f"[{org_name}] Created alerts profile: {profile_id}")
+
+ print(f"[{org_name}] Updating alerts profile description...")
+ await dashboard.organizations.updateOrganizationAlertsProfile(
+ org_id, profile_id, description=f"test-alert-profile-{salt}-r"
+ )
+
+ print(f"[{org_name}] Deleting alerts profile...")
+ await dashboard.organizations.deleteOrganizationAlertsProfile(org_id, profile_id)
+ print(f"[{org_name}] Alerts profile deleted.")
+
+ return ("alerts_profile", profile_id)
+
+
+@timed
+async def test_group_policy(dashboard, net_id, org_name):
+ """Create -> update -> delete a group policy."""
+ salt = _salt()
+ print(f"[{org_name}] Creating group policy...")
+ gp = await dashboard.networks.createNetworkGroupPolicy(net_id, name=f"test-group-policy-{salt}")
+ gp_id = gp["groupPolicyId"]
+ print(f"[{org_name}] Created group policy: {gp_id}")
+
+ print(f"[{org_name}] Updating group policy...")
+ await dashboard.networks.updateNetworkGroupPolicy(net_id, gp_id, name=f"test-group-policy-{salt}-r")
+
+ print(f"[{org_name}] Deleting group policy...")
+ await dashboard.networks.deleteNetworkGroupPolicy(net_id, gp_id)
+ print(f"[{org_name}] Group policy deleted.")
+
+ return ("group_policy", gp_id)
+
+
+@timed
+async def test_mqtt_broker(dashboard, net_id, org_name):
+ """Create -> update -> delete an MQTT broker."""
+ salt = _salt()
+ print(f"[{org_name}] Creating MQTT broker...")
+ broker = await dashboard.networks.createNetworkMqttBroker(
+ net_id, name=f"test-mqtt-broker-{salt}", host="mqtt.example.com", port=1883
+ )
+ broker_id = broker["id"]
+ print(f"[{org_name}] Created MQTT broker: {broker_id}")
+
+ print(f"[{org_name}] Updating MQTT broker...")
+ await dashboard.networks.updateNetworkMqttBroker(net_id, broker_id, name=f"test-mqtt-broker-{salt}-r")
+
+ print(f"[{org_name}] Deleting MQTT broker...")
+ await dashboard.networks.deleteNetworkMqttBroker(net_id, broker_id)
+ print(f"[{org_name}] MQTT broker deleted.")
+
+ return ("mqtt_broker", broker_id)
+
+
+@timed
+async def test_webhook_http_server(dashboard, net_id, org_name):
+ """Create -> update -> delete a webhook HTTP server."""
+ salt = _salt()
+ print(f"[{org_name}] Creating webhook HTTP server...")
+ server = await dashboard.networks.createNetworkWebhooksHttpServer(
+ net_id, name=f"test-webhook-server-{salt}", url="https://example.com/webhook"
+ )
+ server_id = server["id"]
+ print(f"[{org_name}] Created webhook HTTP server: {server_id}")
+
+ print(f"[{org_name}] Updating webhook HTTP server...")
+ await dashboard.networks.updateNetworkWebhooksHttpServer(net_id, server_id, name=f"test-webhook-server-{salt}-r")
+
+ print(f"[{org_name}] Deleting webhook HTTP server...")
+ await dashboard.networks.deleteNetworkWebhooksHttpServer(net_id, server_id)
+ print(f"[{org_name}] Webhook HTTP server deleted.")
+
+ return ("webhook_http_server", server_id)
+
+
+@timed
+async def test_webhook_payload_template(dashboard, net_id, org_name):
+ """Create -> delete a webhook payload template."""
+ salt = _salt()
+ print(f"[{org_name}] Creating webhook payload template...")
+ template = await dashboard.networks.createNetworkWebhooksPayloadTemplate(
+ net_id,
+ name=f"test-payload-template-{salt}",
+ body='{"event": "{{alertType}}", "network": "{{networkName}}"}',
+ )
+ template_id = template["payloadTemplateId"]
+ print(f"[{org_name}] Created payload template: {template_id}")
+ print(f"[{org_name}] body: {template.get('body', '')[:80]}")
+
+ print(f"[{org_name}] Deleting payload template...")
+ await dashboard.networks.deleteNetworkWebhooksPayloadTemplate(net_id, template_id)
+ print(f"[{org_name}] Payload template deleted.")
+
+ return ("webhook_payload_template", template_id)
+
+
+@timed
+async def test_ssid(dashboard, net_id, org_name):
+ """Read -> update -> restore SSID 0."""
+ print(f"[{org_name}] Reading SSID 0...")
+ ssid = await dashboard.wireless.getNetworkWirelessSsid(net_id, "0")
+ original_name = ssid["name"]
+ print(f"[{org_name}] SSID 0 name: {original_name}")
+
+ new_name = "test-ssid-renamed"
+ print(f"[{org_name}] Updating SSID 0 name to '{new_name}'...")
+ await dashboard.wireless.updateNetworkWirelessSsid(net_id, "0", name=new_name)
+
+ print(f"[{org_name}] Restoring SSID 0 name to '{original_name}'...")
+ await dashboard.wireless.updateNetworkWirelessSsid(net_id, "0", name=original_name)
+ print(f"[{org_name}] SSID 0 restored.")
+
+ return ("ssid_0", net_id)
+
+
+@timed
+async def test_network_settings(dashboard, net_id, org_name):
+ """Read -> toggle -> restore network settings."""
+ print(f"[{org_name}] Reading network settings...")
+ settings = await dashboard.networks.getNetworkSettings(net_id)
+ original = settings.get("localStatusPageEnabled", True)
+ print(f"[{org_name}] localStatusPageEnabled = {original}")
+
+ toggled = not original
+ print(f"[{org_name}] Toggling localStatusPageEnabled to {toggled}...")
+ await dashboard.networks.updateNetworkSettings(net_id, localStatusPageEnabled=toggled)
+
+ print(f"[{org_name}] Restoring localStatusPageEnabled to {original}...")
+ await dashboard.networks.updateNetworkSettings(net_id, localStatusPageEnabled=original)
+ print(f"[{org_name}] Network settings restored.")
+
+ return ("network_settings", net_id)
+
+
+@timed
+async def test_action_batch(dashboard, org_id, net_id, org_name):
+ """Submit async action batch with 3 air marshal rules, poll to completion."""
+ actions = [
+ {
+ "resource": f"/networks/{net_id}/wireless/airMarshal/rules",
+ "operation": "create",
+ "body": {
+ "type": "block",
+ "match": {"string": f"test-rule-{i}", "type": "bssid"},
+ },
+ }
+ for i in range(1, 4)
+ ]
+
+ print(f"[{org_name}] Submitting action batch with 3 air marshal rules...")
+ batch = await dashboard.organizations.createOrganizationActionBatch(org_id, actions, confirmed=True, synchronous=False)
+ batch_id = batch["id"]
+ print(f"[{org_name}] Action batch submitted: {batch_id}")
+
+ while True:
+ status = await dashboard.organizations.getOrganizationActionBatch(org_id, batch_id)
+ if status["status"]["completed"]:
+ break
+ if status["status"]["failed"]:
+ raise RuntimeError(f"Action batch {batch_id} failed in org {org_id}")
+ await asyncio.sleep(1)
+
+ print(f"[{org_name}] Action batch completed. Rules created:")
+ for action in status["actions"]:
+ rule = action.get("body", {})
+ print(f" - type={rule.get('type')}, match={rule.get('match')}")
+
+ return ("action_batch", batch_id)
+
+
+async def process_org(dashboard, org):
+ org_id = org["id"]
+ org_name = org["name"]
+
+ # Create network (needed for network-level tests)
+ print(f"[{org_name}] Creating wireless network...")
+ network = await dashboard.organizations.createOrganizationNetwork(
+ org_id,
+ name=f"test-airmarshal-network-{_salt()}",
+ productTypes=["wireless"],
+ )
+ net_id = network["id"]
+ print(f"[{org_name}] Created network: {net_id}")
+
+ # Run all tests concurrently
+ results = await asyncio.gather(
+ # Org-level (no network dependency)
+ test_policy_object(dashboard, org_id, org_name),
+ test_policy_object_group(dashboard, org_id, org_name),
+ test_alerts_profile(dashboard, org_id, org_name),
+ # Network-level
+ test_group_policy(dashboard, net_id, org_name),
+ test_mqtt_broker(dashboard, net_id, org_name),
+ test_webhook_http_server(dashboard, net_id, org_name),
+ test_webhook_payload_template(dashboard, net_id, org_name),
+ test_ssid(dashboard, net_id, org_name),
+ test_network_settings(dashboard, net_id, org_name),
+ test_action_batch(dashboard, org_id, net_id, org_name),
+ )
+
+ # Delete the network
+ print(f"[{org_name}] Deleting network {net_id}...")
+ await dashboard.networks.deleteNetwork(net_id)
+ print(f"[{org_name}] Network deleted.")
+
+ return {
+ "org_id": org_id,
+ "network_id": net_id,
+ "resources": list(results),
+ }
+
+
+async def main():
+ total_start = time.perf_counter()
+
+ async with meraki.aio.AsyncDashboardAPI(suppress_logging=True) as dashboard:
+ orgs = await dashboard.organizations.getOrganizations()
+ targets = [o for o in orgs if KEYWORD in o["name"]]
+
+ results = await asyncio.gather(*(process_org(dashboard, org) for org in targets))
+
+ total_elapsed = time.perf_counter() - total_start
+
+ print("\n" + "=" * 60)
+ print("Resources touched:")
+ print("=" * 60)
+ for r in results:
+ print(f"\nOrg: {r['org_id']}")
+ print(f" Network: {r['network_id']}")
+ for resource_type, resource_id, elapsed in r["resources"]:
+ print(f" {resource_type}: {resource_id} ({elapsed:.2f}s)")
+
+ print("\n" + "=" * 60)
+ print("Timing summary:")
+ print("=" * 60)
+ for r in results:
+ print(f"\nOrg: {r['org_id']}")
+ for resource_type, _, elapsed in r["resources"]:
+ print(f" {resource_type:30s} {elapsed:6.2f}s")
+ print(f"\nTotal elapsed: {total_elapsed:.2f}s")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/ssid_tool/defaults.json b/examples/ssid_tool/defaults.json
new file mode 100644
index 00000000..cae4aab9
--- /dev/null
+++ b/examples/ssid_tool/defaults.json
@@ -0,0 +1,2743 @@
+{
+ "0": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "deny",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": false
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 0,
+ "name": "Default Settings - wireless WiFi",
+ "enabled": true,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "PSK (WPA2)"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": true,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "psk",
+ "psk": ")S}8ol]=rj8h\\;OODWm3N'+wozGvan",
+ "dot11w": {
+ "enabled": false,
+ "required": false
+ },
+ "dot11r": {
+ "enabled": false,
+ "adaptive": false
+ },
+ "encryptionMode": "wpa",
+ "wpaEncryptionMode": "WPA2 only",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "1": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 1,
+ "name": "Unconfigured SSID 2",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "2": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 2,
+ "name": "Unconfigured SSID 3",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "3": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 3,
+ "name": "Unconfigured SSID 4",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "4": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 4,
+ "name": "Unconfigured SSID 5",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "5": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 5,
+ "name": "Unconfigured SSID 6",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "6": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 6,
+ "name": "Unconfigured SSID 7",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "7": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 7,
+ "name": "Unconfigured SSID 8",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "8": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 8,
+ "name": "Unconfigured SSID 9",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "9": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 9,
+ "name": "Unconfigured SSID 10",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "10": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 10,
+ "name": "Unconfigured SSID 11",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "11": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 11,
+ "name": "Unconfigured SSID 12",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "12": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 12,
+ "name": "Unconfigured SSID 13",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "13": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 13,
+ "name": "Unconfigured SSID 14",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ },
+ "14": {
+ "bonjourForwarding": {
+ "enabled": false,
+ "rules": [],
+ "exception": {
+ "enabled": false
+ }
+ },
+ "deviceTypeGroupPolicies": {
+ "enabled": false,
+ "deviceTypePolicies": []
+ },
+ "eapOverride": {},
+ "firewallL3": {
+ "rules": [
+ {
+ "comment": "Wireless clients accessing LAN",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Local LAN"
+ },
+ {
+ "comment": "Default rule",
+ "ipVer": "ipv4",
+ "policy": "allow",
+ "protocol": "Any",
+ "destPort": "Any",
+ "destCidr": "Any"
+ }
+ ],
+ "allowLanAccess": true
+ },
+ "firewallL7": {
+ "rules": []
+ },
+ "hotspot20": {
+ "enabled": false,
+ "operator": {
+ "name": null
+ },
+ "venue": {
+ "name": null,
+ "type": null
+ },
+ "networkAccessType": null,
+ "domains": [],
+ "roamConsortOis": [],
+ "mccMncs": [],
+ "naiRealms": []
+ },
+ "schedules": {
+ "enabled": false,
+ "ranges": [],
+ "rangesInSeconds": []
+ },
+ "splashSettings": {
+ "splashMethod": "None",
+ "splashUrl": null,
+ "useSplashUrl": false,
+ "splashTimeout": 1440,
+ "redirectUrl": null,
+ "useRedirectUrl": false,
+ "themeId": null,
+ "language": "en",
+ "guestSponsorship": {
+ "durationInMinutes": null,
+ "guestCanRequestTimeframe": false
+ },
+ "blockAllTrafficBeforeSignOn": true,
+ "controllerDisconnectionBehavior": "default",
+ "allowSimultaneousLogins": true,
+ "billing": {
+ "freeAccess": {
+ "enabled": false,
+ "durationInMinutes": 20
+ },
+ "prepaidAccessFastLoginEnabled": null,
+ "replyToEmailAddress": "meraki@chir.us"
+ },
+ "welcomeMessage": null,
+ "userConsent": {
+ "required": false,
+ "message": null
+ },
+ "splashLogo": {
+ "md5": null
+ },
+ "splashImage": {
+ "md5": null
+ },
+ "splashPrepaidFront": {
+ "md5": null
+ },
+ "sentryEnrollment": {
+ "systemsManagerNetwork": {
+ "id": null,
+ "name": null
+ },
+ "strength": "focused",
+ "enforcedSystems": [
+ "iOS",
+ "Android"
+ ]
+ },
+ "selfRegistration": {
+ "enabled": true,
+ "authorizationType": "admin"
+ }
+ },
+ "trafficShapingRules": {
+ "trafficShapingEnabled": true,
+ "defaultRulesEnabled": true,
+ "rules": []
+ },
+ "vpn": {
+ "concentrator": {},
+ "failover": {
+ "requestIp": null,
+ "heartbeatInterval": 10,
+ "idleTimeout": 30
+ },
+ "splitTunnel": {
+ "enabled": false,
+ "rules": []
+ }
+ },
+ "identityPsks": [],
+ "main": {
+ "number": 14,
+ "name": "Unconfigured SSID 15",
+ "enabled": false,
+ "splashPage": "None",
+ "ssidAdminAccessible": false,
+ "accessControl": {
+ "encryption": {
+ "mode": "Open"
+ },
+ "bandwidth": {
+ "limit": "unlimited"
+ },
+ "clientIpAssignment": {
+ "mode": "Local LAN"
+ },
+ "clientsBlockedFromUsingLan": false,
+ "wiredClientsPartOfWifiNetwork": false,
+ "tunnel": {
+ "enabled": false,
+ "summary": "Disabled"
+ },
+ "splashPage": {
+ "enabled": false,
+ "theme": "n/a"
+ }
+ },
+ "authMode": "open",
+ "ipAssignmentMode": "Bridge mode",
+ "useVlanTagging": false,
+ "minBitrate": 11,
+ "bandSelection": "Dual band operation",
+ "perClientBandwidthLimitUp": 0,
+ "perClientBandwidthLimitDown": 0,
+ "perSsidBandwidthLimitUp": 0,
+ "perSsidBandwidthLimitDown": 0,
+ "mandatoryDhcpEnabled": false,
+ "enterpriseAdminAccess": "access enabled",
+ "lanIsolationEnabled": false,
+ "visible": true,
+ "availableOnAllAps": true,
+ "availabilityTags": [],
+ "wifiPersonalNetworkEnabled": false,
+ "speedBurst": {
+ "enabled": false
+ },
+ "namedVlans": {
+ "tagging": {
+ "enabled": false
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/ssid_tool/ssid_tool.py b/examples/ssid_tool/ssid_tool.py
new file mode 100644
index 00000000..029d1cf8
--- /dev/null
+++ b/examples/ssid_tool/ssid_tool.py
@@ -0,0 +1,534 @@
+import argparse
+import asyncio
+import json
+import os
+import sys
+from datetime import datetime
+from pathlib import Path
+
+import meraki.aio
+from meraki.exceptions import AsyncAPIError
+
+TOOL_VERSION = "0.1.0b"
+DEFAULT_NETWORK_ID = ""
+DEFAULT_ORG_ID = ""
+BACKUP_DIR = Path(__file__).parent / "ssid_backups"
+DEFAULTS_FILE = Path(__file__).parent / "defaults.json"
+
+SSID_SUB_RESOURCES = {
+ "main": {
+ "get": "getNetworkWirelessSsid",
+ "update": "updateNetworkWirelessSsid",
+ },
+ "bonjourForwarding": {
+ "get": "getNetworkWirelessSsidBonjourForwarding",
+ "update": "updateNetworkWirelessSsidBonjourForwarding",
+ },
+ "deviceTypeGroupPolicies": {
+ "get": "getNetworkWirelessSsidDeviceTypeGroupPolicies",
+ "update": "updateNetworkWirelessSsidDeviceTypeGroupPolicies",
+ },
+ "eapOverride": {
+ "get": "getNetworkWirelessSsidEapOverride",
+ "update": "updateNetworkWirelessSsidEapOverride",
+ },
+ "firewallL3": {
+ "get": "getNetworkWirelessSsidFirewallL3FirewallRules",
+ "update": "updateNetworkWirelessSsidFirewallL3FirewallRules",
+ },
+ "firewallL7": {
+ "get": "getNetworkWirelessSsidFirewallL7FirewallRules",
+ "update": "updateNetworkWirelessSsidFirewallL7FirewallRules",
+ },
+ "hotspot20": {
+ "get": "getNetworkWirelessSsidHotspot20",
+ "update": "updateNetworkWirelessSsidHotspot20",
+ },
+ "schedules": {
+ "get": "getNetworkWirelessSsidSchedules",
+ "update": "updateNetworkWirelessSsidSchedules",
+ },
+ "splashSettings": {
+ "get": "getNetworkWirelessSsidSplashSettings",
+ "update": "updateNetworkWirelessSsidSplashSettings",
+ },
+ "trafficShapingRules": {
+ "get": "getNetworkWirelessSsidTrafficShapingRules",
+ "update": "updateNetworkWirelessSsidTrafficShapingRules",
+ },
+ "vpn": {
+ "get": "getNetworkWirelessSsidVpn",
+ "update": "updateNetworkWirelessSsidVpn",
+ },
+}
+
+
+class Color:
+ RESET = "\033[0m"
+ RED = "\033[91m"
+ GREEN = "\033[92m"
+ YELLOW = "\033[93m"
+ CYAN = "\033[96m"
+ BOLD = "\033[1m"
+ DIM = "\033[2m"
+
+
+def cprint(msg: str, color: str = Color.RESET) -> None:
+ print(f"{color}{msg}{Color.RESET}")
+
+
+class AsyncSpinner:
+ FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
+
+ def __init__(self, message: str = "Working"):
+ self.message = message
+ self._task: asyncio.Task | None = None
+
+ async def _spin(self) -> None:
+ i = 0
+ try:
+ while True:
+ frame = self.FRAMES[i % len(self.FRAMES)]
+ print(
+ f"\r{Color.CYAN}{frame} {self.message}...{Color.RESET}",
+ end="",
+ flush=True,
+ )
+ i += 1
+ await asyncio.sleep(0.1)
+ except asyncio.CancelledError:
+ print(f"\r{' ' * (len(self.message) + 10)}\r", end="", flush=True)
+
+ async def __aenter__(self):
+ self._task = asyncio.create_task(self._spin())
+ return self
+
+ async def __aexit__(self, *_):
+ if self._task:
+ self._task.cancel()
+ try:
+ await self._task
+ except asyncio.CancelledError:
+ pass
+
+
+IDENTITY_PSK_CREATE_PARAMS = ["name", "passphrase", "groupPolicyId", "expiresAt"]
+
+# Keys that are path parameters or read-only identifiers returned by GET
+PATH_PARAMS = {"networkId", "number", "ssidNumber"}
+
+WRITE_EXCLUDE_KEYS = {
+ "splashSettings": {
+ "sentryEnrollment": "References network-specific Systems Manager network ID",
+ "guestSponsorship": "API returns null for durationInMinutes (expects integer)",
+ "billing": "API returns null for prepaidAccessFastLoginEnabled (expects boolean)",
+ },
+ "hotspot20": {
+ "operator": "API returns null for name (expects string)",
+ "venue": "API returns null for name/type (expects strings)",
+ },
+ "vpn": {
+ "failover": "API returns null for requestIp (expects string)",
+ "concentrator": "Requires a VPN concentrator configured on the network",
+ "splitTunnel": "Depends on concentrator; fails if VPN topology not present",
+ },
+}
+
+
+def strip_nulls(obj: dict) -> dict:
+ def _recurse(val: object) -> object:
+ if isinstance(val, dict):
+ return {k: _recurse(v) for k, v in val.items() if v is not None}
+ if isinstance(val, list):
+ return [_recurse(item) for item in val]
+ return val
+
+ result = _recurse(obj)
+ assert isinstance(result, dict)
+ return result
+
+
+def prepare_payload(resource_name: str, raw: dict, apply_exclusions: bool = True) -> dict:
+ payload = strip_nulls({k: v for k, v in raw.items() if k not in PATH_PARAMS})
+ if apply_exclusions:
+ exclude = WRITE_EXCLUDE_KEYS.get(resource_name)
+ if exclude:
+ payload = {k: v for k, v in payload.items() if k not in exclude}
+ if resource_name == "firewallL3" and "rules" in payload:
+ payload["rules"] = [r for r in payload["rules"] if r.get("destCidr") not in ("Local LAN", "local_lan")]
+ return payload
+
+
+async def fetch_identity_psks(api: meraki.aio.AsyncDashboardAPI, network_id: str, ssid_number: int) -> list[dict]:
+ try:
+ return await api.wireless.getNetworkWirelessSsidIdentityPsks(network_id, str(ssid_number))
+ except AsyncAPIError as e:
+ if e.status in (400, 404):
+ return []
+ raise
+
+
+async def swap_identity_psks(
+ api: meraki.aio.AsyncDashboardAPI,
+ network_id: str,
+ slot_a: int,
+ slot_b: int,
+ psks_a: list[dict],
+ psks_b: list[dict],
+) -> tuple[list[str], list[str]]:
+ success = []
+ failed = []
+
+ async def _delete(slot, psk):
+ try:
+ await api.wireless.deleteNetworkWirelessSsidIdentityPsk(network_id, str(slot), psk["id"])
+ return None
+ except AsyncAPIError as e:
+ return f"identityPsks: delete {psk['name']} from slot {slot} ({e.status})"
+
+ delete_tasks = [_delete(slot_a, p) for p in psks_a] + [_delete(slot_b, p) for p in psks_b]
+ if delete_tasks:
+ delete_results = await asyncio.gather(*delete_tasks)
+ failed.extend(r for r in delete_results if r)
+
+ async def _create(slot, psk):
+ payload = {k: v for k, v in psk.items() if k in IDENTITY_PSK_CREATE_PARAMS and v is not None}
+ if "name" not in payload or "groupPolicyId" not in payload:
+ return None
+ try:
+ await api.wireless.createNetworkWirelessSsidIdentityPsk(network_id, str(slot), **payload)
+ return ("success", f"identityPsks: {psk['name']} -> slot {slot}")
+ except AsyncAPIError as e:
+ return ("failed", f"identityPsks: create {psk['name']} on slot {slot} ({e.status})")
+
+ create_tasks = [_create(slot_b, p) for p in psks_a] + [_create(slot_a, p) for p in psks_b]
+ if create_tasks:
+ create_results = await asyncio.gather(*create_tasks)
+ for r in create_results:
+ if r:
+ (success if r[0] == "success" else failed).append(r[1])
+
+ return success, failed
+
+
+async def fetch_ssid_sub_resources(api: meraki.aio.AsyncDashboardAPI, network_id: str, ssid_number: int) -> tuple[dict, list]:
+ """Fetch all sub-resources for a single SSID slot (excludes main config)."""
+ config = {}
+ errors = []
+
+ async def _fetch_one(resource_name: str, resource_info: dict):
+ getter = getattr(api.wireless, resource_info["get"])
+ try:
+ return resource_name, await getter(network_id, str(ssid_number)), None
+ except AsyncAPIError as e:
+ if e.status in (400, 404):
+ return resource_name, None, str(e)
+ raise
+
+ tasks = [_fetch_one(name, info) for name, info in SSID_SUB_RESOURCES.items() if name != "main"]
+ tasks.append(_fetch_psk_wrapper(api, network_id, ssid_number))
+
+ results = await asyncio.gather(*tasks)
+
+ for result in results:
+ name, data, err = result
+ config[name] = data
+ if err:
+ errors.append((name, err))
+
+ return config, errors
+
+
+async def _fetch_psk_wrapper(api: meraki.aio.AsyncDashboardAPI, network_id: str, ssid_number: int):
+ data = await fetch_identity_psks(api, network_id, ssid_number)
+ return "identityPsks", data, None
+
+
+async def fetch_ssid_full_config(api: meraki.aio.AsyncDashboardAPI, network_id: str, ssid_number: int) -> tuple[dict, list]:
+ """Fetch main config + all sub-resources for a single SSID slot."""
+
+ async def _fetch_main():
+ try:
+ return await api.wireless.getNetworkWirelessSsid(network_id, str(ssid_number))
+ except AsyncAPIError as e:
+ if e.status in (400, 404):
+ return None
+ raise
+
+ main, (sub, errors) = await asyncio.gather(
+ _fetch_main(),
+ fetch_ssid_sub_resources(api, network_id, ssid_number),
+ )
+ sub["main"] = main
+ return sub, errors
+
+
+async def backup_all_ssids(api: meraki.aio.AsyncDashboardAPI, network_id: str) -> tuple[Path, list]:
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
+ filename = f"{timestamp}-{network_id}-ssids.jsonc"
+ filepath = BACKUP_DIR / filename
+ BACKUP_DIR.mkdir(parents=True, exist_ok=True)
+
+ all_ssids = {}
+ all_errors = []
+
+ async with AsyncSpinner("Backing up SSIDs"):
+ ssids = await api.wireless.getNetworkWirelessSsids(network_id)
+
+ async def _backup_slot(ssid: dict):
+ slot = ssid["number"]
+ sub, errors = await fetch_ssid_sub_resources(api, network_id, slot)
+ sub["main"] = ssid
+ return slot, sub, errors
+
+ results = await asyncio.gather(*[_backup_slot(s) for s in ssids])
+ for slot, sub, errors in results:
+ all_ssids[str(slot)] = sub
+ all_errors.extend(errors)
+
+ header = f"// SSID Backup - ssid_tool v{TOOL_VERSION}\n// {datetime.now().isoformat()}\n// Network: {network_id}\n"
+ with open(filepath, "w", encoding="utf-8") as f:
+ f.write(header)
+ json.dump(all_ssids, f, indent=2, ensure_ascii=False)
+
+ return filepath, all_errors
+
+
+async def swap_ssid_slots(
+ api: meraki.aio.AsyncDashboardAPI,
+ network_id: str,
+ slot_a: int,
+ slot_b: int,
+) -> dict:
+ async with AsyncSpinner(f"Reading slots {slot_a} and {slot_b}"):
+ (config_a, _), (config_b, _) = await asyncio.gather(
+ fetch_ssid_full_config(api, network_id, slot_a),
+ fetch_ssid_full_config(api, network_id, slot_b),
+ )
+
+ results = {"success": [], "failed": []}
+
+ # Free up SSID names to avoid uniqueness constraint during swap
+ async with AsyncSpinner("Preparing swap"):
+ await asyncio.gather(
+ api.wireless.updateNetworkWirelessSsid(network_id, str(slot_a), name=f"_swap_tmp_{slot_a}"),
+ api.wireless.updateNetworkWirelessSsid(network_id, str(slot_b), name=f"_swap_tmp_{slot_b}"),
+ )
+
+ async with AsyncSpinner("Swapping configurations"):
+
+ async def _apply(resource_name, src_slot, dst_slot, config):
+ updater = getattr(api.wireless, SSID_SUB_RESOURCES[resource_name]["update"])
+ payload = prepare_payload(resource_name, config.get(resource_name) or {}, apply_exclusions=False)
+ if not payload:
+ return None
+ try:
+ await updater(network_id, str(dst_slot), **payload)
+ return ("success", f"{resource_name}: slot {src_slot} -> slot {dst_slot}")
+ except AsyncAPIError as e:
+ return ("failed", f"{resource_name}: slot {src_slot} -> slot {dst_slot} ({e.status})")
+
+ swap_tasks = []
+ for resource_name in SSID_SUB_RESOURCES:
+ if resource_name in ("vpn", "hotspot20"):
+ continue
+ swap_tasks.append(_apply(resource_name, slot_a, slot_b, config_a))
+ swap_tasks.append(_apply(resource_name, slot_b, slot_a, config_b))
+
+ swap_results = await asyncio.gather(*swap_tasks)
+ for r in swap_results:
+ if r:
+ results[r[0]].append(r[1])
+
+ psks_a = config_a.get("identityPsks", [])
+ psks_b = config_b.get("identityPsks", [])
+ if psks_a or psks_b:
+ psk_success, psk_failed = await swap_identity_psks(api, network_id, slot_a, slot_b, psks_a, psks_b)
+ results["success"].extend(psk_success)
+ results["failed"].extend(psk_failed)
+
+ return results
+
+
+def load_defaults() -> dict:
+ with open(DEFAULTS_FILE, encoding="utf-8") as f:
+ return json.load(f)
+
+
+def display_ssid_summary(ssids: list[dict]) -> None:
+ cprint(f"\n {'Slot':<5} {'Name':<34} {'Auth':<22} {'Status'}", Color.DIM)
+ cprint(f" {'─' * 72}", Color.DIM)
+ for ssid in sorted(ssids, key=lambda s: s["number"]):
+ status = f"{Color.GREEN}ON{Color.RESET}" if ssid["enabled"] else f"{Color.DIM}OFF{Color.RESET}"
+ name = ssid.get("name", "?")
+ auth = ssid.get("authMode", "?")
+ cprint(f" {ssid['number']:<5} {name:<34} {auth:<22} {status}", Color.RESET)
+ print()
+
+
+async def reset_ssid_slot(api: meraki.aio.AsyncDashboardAPI, network_id: str, slot: int) -> dict:
+ defaults = load_defaults()
+ default_config = defaults.get(str(slot))
+ if not default_config:
+ return {"success": [], "failed": [f"No default config for slot {slot}"]}
+
+ cprint(" Creating safety backup...", Color.YELLOW)
+ backup_path, _ = await backup_all_ssids(api, network_id)
+ cprint(f" Saved: {backup_path.name}", Color.GREEN)
+
+ results = {"success": [], "failed": []}
+
+ async with AsyncSpinner(f"Resetting slot {slot} to defaults"):
+
+ async def _reset_resource(resource_name, resource_info):
+ updater = getattr(api.wireless, resource_info["update"])
+ payload = prepare_payload(resource_name, default_config.get(resource_name) or {})
+ if not payload:
+ return None
+ try:
+ await updater(network_id, str(slot), **payload)
+ return ("success", f"{resource_name}: reset to default")
+ except AsyncAPIError as e:
+ return ("failed", f"{resource_name}: ({e.status})")
+
+ reset_results = await asyncio.gather(*[_reset_resource(name, info) for name, info in SSID_SUB_RESOURCES.items()])
+ for r in reset_results:
+ if r:
+ results[r[0]].append(r[1])
+
+ current_psks = await fetch_identity_psks(api, network_id, slot)
+
+ async def _delete_psk(psk):
+ try:
+ await api.wireless.deleteNetworkWirelessSsidIdentityPsk(network_id, str(slot), psk["id"])
+ return ("success", f"identityPsks: deleted {psk['name']}")
+ except AsyncAPIError as e:
+ return ("failed", f"identityPsks: delete {psk['name']} ({e.status})")
+
+ if current_psks:
+ psk_results = await asyncio.gather(*[_delete_psk(p) for p in current_psks])
+ for r in psk_results:
+ results[r[0]].append(r[1])
+
+ return results
+
+
+def display_caveats() -> None:
+ cprint("\n Write Exclusions (not restored during swap/reset):", Color.YELLOW)
+ cprint(f" {'─' * 70}", Color.DIM)
+ for resource, keys in WRITE_EXCLUDE_KEYS.items():
+ for key, reason in keys.items():
+ cprint(f" {resource}.{key}", Color.RESET)
+ cprint(f" {reason}", Color.DIM)
+ cprint(f" {'─' * 70}\n", Color.DIM)
+
+
+def display_menu() -> None:
+ cprint("\n╔══════════════════════════════════════╗", Color.CYAN)
+ cprint("║ Meraki SSID Management Tool ║", Color.CYAN)
+ cprint("╠══════════════════════════════════════╣", Color.CYAN)
+ cprint("║ 1. Backup SSIDs ║", Color.CYAN)
+ cprint("║ 2. Swap SSID Slots ║", Color.CYAN)
+ cprint("║ 3. Reset Slot to Default ║", Color.CYAN)
+ cprint("║ 4. See Caveats ║", Color.CYAN)
+ cprint("║ 0. Exit ║", Color.CYAN)
+ cprint("╚══════════════════════════════════════╝", Color.CYAN)
+
+
+def display_report(title: str, results: dict) -> None:
+ cprint(f"\n{'─' * 40}", Color.DIM)
+ cprint(f" {title}", Color.BOLD)
+ cprint(f"{'─' * 40}", Color.DIM)
+ for item in results.get("success", []):
+ cprint(f" ✓ {item}", Color.GREEN)
+ for item in results.get("failed", []):
+ cprint(f" ✗ {item}", Color.RED)
+ total = len(results.get("success", [])) + len(results.get("failed", []))
+ passed = len(results.get("success", []))
+ cprint(f" {passed}/{total} operations succeeded", Color.DIM)
+ cprint(f"{'─' * 40}\n", Color.DIM)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Meraki SSID Management Tool")
+ parser.add_argument("-n", "--network-id", default=None, help="Network ID")
+ parser.add_argument("-o", "--org-id", default=None, help="Organization ID")
+ return parser.parse_args()
+
+
+async def main() -> None:
+ args = parse_args()
+ network_id = args.network_id or os.environ.get("MERAKI_NETWORK_ID") or DEFAULT_NETWORK_ID
+ if not network_id:
+ cprint("Error: Network ID required (--network-id or MERAKI_NETWORK_ID)", Color.RED)
+ sys.exit(1)
+
+ async with meraki.aio.AsyncDashboardAPI(
+ output_log=True, print_console=False, maximum_retries=10, smart_flow=True, smart_flow_logging=True
+ ) as api:
+ while True:
+ display_menu()
+ choice = input(f"{Color.YELLOW} Select option: {Color.RESET}").strip()
+
+ if choice == "1":
+ filepath, errors = await backup_all_ssids(api, network_id)
+ results = {"success": [f"Saved to {filepath.name}"], "failed": []}
+ if errors:
+ for name, msg in errors:
+ results["failed"].append(f"{name}: {msg}")
+ display_report("Backup Complete", results)
+
+ elif choice == "2":
+ async with AsyncSpinner("Fetching current SSIDs"):
+ ssids = await api.wireless.getNetworkWirelessSsids(network_id)
+ display_ssid_summary(ssids)
+
+ backup_task = asyncio.create_task(backup_all_ssids(api, network_id))
+
+ try:
+ slot_a = int(input(f"{Color.YELLOW} First SSID slot (0-14): {Color.RESET}"))
+ slot_b = int(input(f"{Color.YELLOW} Second SSID slot (0-14): {Color.RESET}"))
+ except ValueError:
+ cprint(" Invalid input.", Color.RED)
+ backup_task.cancel()
+ continue
+ if not (0 <= slot_a <= 14 and 0 <= slot_b <= 14 and slot_a != slot_b):
+ cprint(" Slots must be 0-14 and different.", Color.RED)
+ backup_task.cancel()
+ continue
+
+ cprint(" Waiting for backup to complete...", Color.DIM)
+ backup_path, _ = await backup_task
+ cprint(f" Saved: {backup_path.name}", Color.GREEN)
+
+ results = await swap_ssid_slots(api, network_id, slot_a, slot_b)
+ display_report(f"Swap: Slot {slot_a} <-> Slot {slot_b}", results)
+
+ elif choice == "3":
+ async with AsyncSpinner("Fetching current SSIDs"):
+ ssids = await api.wireless.getNetworkWirelessSsids(network_id)
+ display_ssid_summary(ssids)
+ try:
+ slot = int(input(f"{Color.YELLOW} Slot to reset (0-14): {Color.RESET}"))
+ except ValueError:
+ cprint(" Invalid input.", Color.RED)
+ continue
+ if not 0 <= slot <= 14:
+ cprint(" Slot must be 0-14.", Color.RED)
+ continue
+ results = await reset_ssid_slot(api, network_id, slot)
+ display_report(f"Reset: Slot {slot}", results)
+
+ elif choice == "4":
+ display_caveats()
+
+ elif choice == "0":
+ cprint("Done.", Color.GREEN)
+ break
+
+ else:
+ cprint(" Invalid choice.", Color.RED)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/generator/async_function_template.jinja2 b/generator/async_function_template.jinja2
index 2842de10..99a30c84 100644
--- a/generator/async_function_template.jinja2
+++ b/generator/async_function_template.jinja2
@@ -11,10 +11,17 @@
{% if kwarg_line|length > 0 %}
{{ kwarg_line }}
+ {% if renamed_params|length > 0 %}
+ {% for safe, orig in renamed_params.items() %}
+ if "{{ safe }}" in kwargs:
+ kwargs["{{ orig }}"] = kwargs.pop("{{ safe }}")
+ {% endfor %}
+
+ {% endif %}
{% endif %}
{% if assert_blocks|length > 0 %}
- {% for param, values in assert_blocks %}
- if "{{ param }}" in kwargs:
+ {% for param, values, nullable in assert_blocks %}
+ if "{{ param }}" in kwargs{% if nullable %} and kwargs["{{ param }}"] is not None{% endif %}:
options = {{ values }}
assert kwargs["{{ param }}"] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}'''
{% endfor %}
diff --git a/generator/batch_function_template.jinja2 b/generator/batch_function_template.jinja2
index 88adcb0e..3a30a25a 100644
--- a/generator/batch_function_template.jinja2
+++ b/generator/batch_function_template.jinja2
@@ -11,17 +11,24 @@
{% if kwarg_line|length > 0 %}
{{ kwarg_line }}
+ {% if renamed_params|length > 0 %}
+ {% for safe, orig in renamed_params.items() %}
+ if "{{ safe }}" in kwargs:
+ kwargs["{{ orig }}"] = kwargs.pop("{{ safe }}")
+ {% endfor %}
+
+ {% endif %}
{% endif %}
{% if assert_blocks|length > 0 %}
- {% for param, values in assert_blocks %}
- if "{{ param }}" in kwargs:
+ {% for param, values, nullable in assert_blocks %}
+ if "{{ param }}" in kwargs{% if nullable %} and kwargs["{{ param }}"] is not None{% endif %}:
options = {{ values }}
assert kwargs["{{ param }}"] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}'''
{% endfor %}
{% endif %}
{% for param in path_params %}
- {{ param }} = urllib.parse.quote({{ param }}, safe="")
+ {{ param }} = urllib.parse.quote(str({{ param }}), safe="")
{% endfor %}
resource = f"{{ resource }}"
diff --git a/generator/common.py b/generator/common.py
index d3b64416..38b1d1e2 100644
--- a/generator/common.py
+++ b/generator/common.py
@@ -1,3 +1,124 @@
+import platform
+import sys
+
+
+REVERSE_PAGINATION = ["getNetworkEvents", "getOrganizationConfigurationChanges"]
+
+
+def generate_pagination_parameters(operation: str):
+ ret = {
+ "total_pages": {
+ "type": "integer or string",
+ "description": 'use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages',
+ },
+ "direction": {
+ "type": "string",
+ "description": 'direction to paginate, either "next" or "prev" (default) page'
+ if operation in REVERSE_PAGINATION
+ else 'direction to paginate, either "next" (default) or "prev" page',
+ },
+ }
+ if operation == "getNetworkEvents":
+ ret["event_log_end_time"] = {
+ "type": "string",
+ "description": "ISO8601 Zulu/UTC time, to use in conjunction with startingAfter, "
+ "to retrieve events within a time window",
+ }
+ return ret
+
+
+def check_python_version():
+ version_warning_string = (
+ f"This library generator requires Python 3.10 at minimum. "
+ f"Your interpreter version is: {platform.python_version()}. "
+ f"Please consult the readme at your convenience: "
+ f"https://github.com/meraki/dashboard-api-python/blob/main/generator/readme.md "
+ f"Additional details: "
+ f"python_version_tuple()[0] = {platform.python_version_tuple()[0]}; "
+ f"python_version_tuple()[1] = {platform.python_version_tuple()[1]} "
+ )
+
+ if not (int(platform.python_version_tuple()[0]) == 3 and int(platform.python_version_tuple()[1]) >= 10):
+ sys.exit(version_warning_string)
+
+
+def docs_url(operation: str):
+ base_url = "https://developer.cisco.com/meraki/api-v1/#!"
+ ret = ""
+ for letter in operation:
+ if letter.islower():
+ ret += letter
+ else:
+ ret += f"-{letter.lower()}"
+ return base_url + ret
+
+
+def return_params(operation: str, params: dict, param_filters):
+ if not param_filters:
+ return params
+ else:
+ ret = {}
+ if "required" in param_filters:
+ ret.update({k: v for k, v in params.items() if "required" in v and v["required"]})
+ if "pagination" in param_filters:
+ ret.update(generate_pagination_parameters(operation) if "perPage" in params else {})
+ if "optional" in param_filters:
+ ret.update({k: v for k, v in params.items() if "required" in v and not v["required"]})
+ if "path" in param_filters:
+ ret.update({k: v for k, v in params.items() if "in" in v and v["in"] == "path"})
+ if "query" in param_filters:
+ ret.update({k: v for k, v in params.items() if "in" in v and v["in"] == "query"})
+ if "body" in param_filters:
+ ret.update({k: v for k, v in params.items() if "in" in v and v["in"] == "body"})
+ if "array" in param_filters:
+ ret.update({k: v for k, v in params.items() if "in" in v and v["type"] == "array"})
+ if "enum" in param_filters:
+ ret.update({k: v for k, v in params.items() if "enum" in v})
+ return ret
+
+
+def unpack_param_without_schema(all_params: dict, this_param: dict, name: str, is_required: bool):
+ all_params[name] = {"required": is_required}
+
+ for attribute in ("in", "type"):
+ all_params[name][attribute] = this_param[attribute]
+
+ if "enum" in this_param:
+ all_params[name]["enum"] = this_param["enum"]
+
+ if "description" in this_param:
+ all_params[name]["description"] = this_param["description"]
+ elif is_required:
+ all_params[name]["description"] = "(required)"
+ else:
+ all_params[name]["description"] = this_param.get("description", "")
+
+ return all_params
+
+
+def unpack_param_with_schema(all_params: dict, this_param: dict):
+ keys = this_param["schema"]["properties"]
+
+ for k in keys:
+ if "required" in this_param["schema"] and k in this_param["schema"]["required"]:
+ all_params[k] = {"required": True}
+ else:
+ all_params[k] = {"required": False}
+
+ all_params[k]["in"] = this_param["in"]
+
+ for attribute in ("type", "description"):
+ all_params[k][attribute] = keys[k][attribute]
+
+ if "enum" in keys[k]:
+ all_params[k]["enum"] = keys[k]["enum"]
+
+ if "example" in this_param["schema"] and k in this_param["schema"]["example"]:
+ all_params[k]["example"] = this_param["schema"]["example"][k]
+
+ return all_params
+
+
def organize_spec(paths, scopes):
operations = list() # list of operation IDs
for path, methods in paths.items():
diff --git a/generator/function_template.jinja2 b/generator/function_template.jinja2
index 9d43967b..14b51eb9 100644
--- a/generator/function_template.jinja2
+++ b/generator/function_template.jinja2
@@ -11,10 +11,17 @@
{% if kwarg_line|length > 0 %}
{{ kwarg_line }}
+ {% if renamed_params|length > 0 %}
+ {% for safe, orig in renamed_params.items() %}
+ if "{{ safe }}" in kwargs:
+ kwargs["{{ orig }}"] = kwargs.pop("{{ safe }}")
+ {% endfor %}
+
+ {% endif %}
{% endif %}
{% if assert_blocks|length > 0 %}
- {% for param, values in assert_blocks %}
- if "{{ param }}" in kwargs:
+ {% for param, values, nullable in assert_blocks %}
+ if "{{ param }}" in kwargs{% if nullable %} and kwargs["{{ param }}"] is not None{% endif %}:
options = {{ values }}
assert kwargs["{{ param }}"] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}'''
{% endfor %}
diff --git a/generator/generate_library.py b/generator/generate_library.py
index bb0b4826..d69f0d86 100644
--- a/generator/generate_library.py
+++ b/generator/generate_library.py
@@ -1,27 +1,83 @@
import getopt
import json
+import keyword
import os
import re
import subprocess
import sys
import jinja2
-import requests
+import httpx
import common as common
+from common import REVERSE_PAGINATION, docs_url, check_python_version, return_params
from parser_v3 import parse_params_v3, clear_cache
-from generate_library_oasv2 import (
- REVERSE_PAGINATION,
- docs_url,
- check_python_version,
- return_params,
-)
from generate_stubs import generate_stub_modules
+
+_keyword_param_violations = []
+
+
+def safe_param_name(name: str) -> str:
+ if keyword.iskeyword(name):
+ return name + "_"
+ return name
+
+
+def _write_generation_report(version_number: str, api_version_number: str, is_github_action: bool):
+ from datetime import date
+
+ report_path = "docs/generation-report.md"
+
+ seen = set()
+ unique_violations = []
+ for v in _keyword_param_violations:
+ key = (v["operation"], v["param"])
+ if key not in seen:
+ seen.add(key)
+ unique_violations.append(v)
+
+ lines = []
+ lines.append(f"## {date.today().isoformat()} | Library v{version_number} | API {api_version_number}\n")
+ lines.append("")
+
+ if unique_violations:
+ lines.append("### Python keyword parameter conflicts\n")
+ lines.append("")
+ lines.append("The following operations have parameters whose names are Python reserved keywords.")
+ lines.append("The generator renames them with a trailing underscore (e.g., `from` -> `from_`).")
+ lines.append("These should be reported to the owning teams for resolution in the API spec.\n")
+ lines.append("")
+ lines.append("| Scope | Operation | Location | Param |")
+ lines.append("| --- | --- | --- | --- |")
+ for v in sorted(unique_violations, key=lambda x: (x["scope"], x["operation"])):
+ lines.append(f"| {v['scope']} | `{v['operation']}` | {v['location']} | `{v['param']}` |")
+ lines.append("")
+ else:
+ lines.append("No Python keyword parameter conflicts detected.\n")
+ lines.append("")
+
+ new_entry = "\n".join(lines)
+
+ existing = ""
+ header = "# Generation Report\n\n"
+ if os.path.isfile(report_path):
+ with open(report_path, "r", encoding="utf-8") as f:
+ existing = f.read()
+ if existing.startswith("# Generation Report"):
+ existing = existing[existing.index("\n") + 1 :].lstrip("\n")
+
+ os.makedirs(os.path.dirname(report_path), exist_ok=True)
+ with open(report_path, "w", encoding="utf-8", newline=None) as f:
+ f.write(header + new_entry + "\n" + existing)
+
+ print(f"Generation report written to {report_path}")
+
+
READ_ME = """
=== PREREQUISITES ===
-Include the jinja2 files in same directory as this script, and install Python requests
-pip[3] install requests
+Include the jinja2 files in same directory as this script, and install Python httpx
+pip[3] install httpx
=== DESCRIPTION ===
This script generates the Meraki Python library from the OpenAPI v3 specification.
@@ -35,7 +91,13 @@
def generate_library(
- spec: dict, version_number: str, api_version_number: str, is_github_action: bool, generate_stubs: bool = False
+ spec: dict,
+ version_number: str,
+ api_version_number: str,
+ is_github_action: bool,
+ generate_stubs: bool = False,
+ local_source: bool = False,
+ source_branch: str = "master",
):
# Clear parser cache at entry
clear_cache()
@@ -60,12 +122,29 @@ def generate_library(
"campusGateway",
"spaces",
"nac",
+ "users",
+ "support",
+ "assistant",
]
tags = spec["tags"]
paths = spec["paths"]
scopes = {tag["name"]: {} for tag in tags if tag["name"] in supported_scopes}
+ used_scopes = set()
+ for path_item in paths.values():
+ for method, endpoint in path_item.items():
+ if method not in ("get", "post", "put", "delete", "patch"):
+ continue
+ ep_tags = endpoint.get("tags", [])
+ if len(ep_tags) > 2 and ep_tags[2] == "spaces":
+ used_scopes.add("spaces")
+ elif ep_tags:
+ used_scopes.add(ep_tags[0])
+ unsupported = used_scopes - set(supported_scopes)
+ if unsupported:
+ sys.exit(f"ERROR: spec contains scopes not in supported_scopes: {sorted(unsupported)}")
+
batchable_actions = spec["x-batchable-actions"]
# Set template_dir if a GitHub action is invoking it
@@ -77,6 +156,7 @@ def generate_library(
# Check paths and create directories if needed
directories = [
"meraki",
+ "meraki/session",
"meraki/api",
"meraki/api/batch",
"meraki/aio",
@@ -92,22 +172,37 @@ def generate_library(
"_version.py",
"config.py",
"common.py",
+ "encoding.py",
"exceptions.py",
"response_handler.py",
- "rest_session.py",
+ "session/__init__.py",
+ "session/base.py",
+ "session/sync.py",
+ "session/async_.py",
"api/__init__.py",
"aio/__init__.py",
- "aio/rest_session.py",
"aio/api/__init__.py",
"api/batch/__init__.py",
+ "smart_flow.py",
]
- base_url = "https://raw.githubusercontent.com/meraki/dashboard-api-python/master/meraki/"
+ if local_source:
+ # MERAKI_SOURCE_DIR lets the caller point at a preserved copy of meraki/
+ # (the regen workflow stashes it before `rm -rf meraki`), avoiding the
+ # rate-limited raw.githubusercontent fetch. Falls back to the checkout.
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ local_meraki = os.environ.get("MERAKI_SOURCE_DIR") or os.path.join(repo_root, "meraki")
+ else:
+ base_url = f"https://raw.githubusercontent.com/meraki/dashboard-api-python/{source_branch}/meraki/"
for file in non_generated:
- response = requests.get(f"{base_url}{file}")
- with open(f"meraki/{file}", "w+", encoding="utf-8", newline=None) as fp:
+ if local_source:
+ with open(os.path.join(local_meraki, file), encoding="utf-8") as src:
+ contents = src.read()
+ else:
+ response = httpx.get(f"{base_url}{file}")
+ response.raise_for_status() # 429/5xx from raw.githubusercontent → fail loud, don't write error page into source
contents = response.text
+ with open(f"meraki/{file}", "w+", encoding="utf-8", newline=None) as fp:
if file == "_version.py":
- # replace library version
start = contents.find("__version__ = ")
end = contents.find("\n", start)
contents = f"{contents[:start]}__version__ = '{version_number}'{contents[end:]}"
@@ -140,7 +235,7 @@ def generate_library(
if generate_stubs:
print("Generating .pyi type stubs...")
generate_stub_modules(spec, scopes, jinja_env, template_dir)
- # Write py.typed marker for PEP 561
+ # Write py.typed marker for PEP 561 (cwd-relative, like the module writers)
open("meraki/py.typed", "w").close()
print("Type stubs and py.typed marker created.")
@@ -149,6 +244,9 @@ def generate_library(
subprocess.run(["ruff", "check", "--fix", "--quiet", "meraki/"], check=False)
subprocess.run(["ruff", "format", "--quiet", "meraki/"], check=False)
+ # Write generation report
+ _write_generation_report(version_number, api_version_number, is_github_action)
+
def generate_modules(spec, batchable_actions, jinja_env, scopes, template_dir):
for scope in scopes:
@@ -156,12 +254,11 @@ def generate_modules(spec, batchable_actions, jinja_env, scopes, template_dir):
section = scopes[scope]
# Generate the standard module
- with open(f"meraki/api/{scope}.py", "w", encoding="utf-8", newline=None) as output:
- # Open module file for Asyncio API libraries
- async_output = open(f"meraki/aio/api/{scope}.py", "w", encoding="utf-8", newline=None)
- # Open module file for Action Batch API libraries
- batch_output = open(f"meraki/api/batch/{scope}.py", "w", encoding="utf-8", newline=None)
-
+ with (
+ open(f"meraki/api/{scope}.py", "w", encoding="utf-8", newline=None) as output,
+ open(f"meraki/aio/api/{scope}.py", "w", encoding="utf-8", newline=None) as async_output,
+ open(f"meraki/api/batch/{scope}.py", "w", encoding="utf-8", newline=None) as batch_output,
+ ):
modules = [
{"template_name": "class_template.jinja2", "module_output": output},
{
@@ -239,45 +336,55 @@ def generate_standard_and_async_functions(
# Function definition
definition = ""
defined_params = set()
+ renamed_params = {}
# Add required params to definition
for p, values in return_params(operation, all_params, ["required"]).items():
defined_params.add(p)
+ safe_p = safe_param_name(p)
+ if safe_p != p:
+ renamed_params[safe_p] = p
if values["type"] == "array":
- definition += f", {p}: list"
+ definition += f", {safe_p}: list"
elif values["type"] == "number":
- definition += f", {p}: float"
+ definition += f", {safe_p}: float"
elif values["type"] == "integer":
- definition += f", {p}: int"
+ definition += f", {safe_p}: int"
elif values["type"] == "boolean":
- definition += f", {p}: bool"
+ definition += f", {safe_p}: bool"
elif values["type"] == "object":
- definition += f", {p}: dict"
+ definition += f", {safe_p}: dict"
elif values["type"] == "string":
- definition += f", {p}: str"
+ definition += f", {safe_p}: str"
# Path params must be in the signature for URL construction
for p, values in return_params(operation, all_params, ["path"]).items():
if p not in defined_params:
defined_params.add(p)
+ safe_p = safe_param_name(p)
+ if safe_p != p:
+ renamed_params[safe_p] = p
if values["type"] == "array":
- definition += f", {p}: list"
+ definition += f", {safe_p}: list"
elif values["type"] == "number":
- definition += f", {p}: float"
+ definition += f", {safe_p}: float"
elif values["type"] == "integer":
- definition += f", {p}: int"
+ definition += f", {safe_p}: int"
elif values["type"] == "boolean":
- definition += f", {p}: bool"
+ definition += f", {safe_p}: bool"
elif values["type"] == "object":
- definition += f", {p}: dict"
+ definition += f", {safe_p}: dict"
elif values["type"] == "string":
- definition += f", {p}: str"
+ definition += f", {safe_p}: str"
# Catch params referenced in the URL but not declared as path params
for p in re.findall(r"\{(\w+)\}", path):
if p not in defined_params:
defined_params.add(p)
- definition += f", {p}: str"
+ safe_p = safe_param_name(p)
+ if safe_p != p:
+ renamed_params[safe_p] = p
+ definition += f", {safe_p}: str"
# Add pagination params if perPage exists
if "perPage" in all_params:
@@ -288,6 +395,10 @@ def generate_standard_and_async_functions(
if operation == "getNetworkEvents":
definition += ", event_log_end_time=None"
+ # call_line is bound per-method below; initialize to fail loudly if a
+ # method slips through without setting it (rather than raising NameError).
+ call_line = None
+
# Function body for GET endpoints
query_params = array_params = body_params = path_params = {}
if method == "get":
@@ -295,8 +406,8 @@ def generate_standard_and_async_functions(
array_params = return_params(operation, all_params, ["array"])
path_params = return_params(operation, all_params, ["path"])
- # Function body for POST/PUT endpoints
- elif method == "post" or method == "put":
+ # Function body for POST/PUT/PATCH endpoints
+ elif method == "post" or method == "put" or method == "patch":
body_params = return_params(operation, all_params, ["body"])
path_params = return_params(operation, all_params, ["path"])
@@ -327,7 +438,7 @@ def generate_standard_and_async_functions(
assert_blocks = list()
if enum_params:
for p, values in enum_params.items():
- assert_blocks.append((p, values["enum"]))
+ assert_blocks.append((p, values["enum"], values.get("nullable", False)))
# Generate call_line based on method
if method == "get":
@@ -343,7 +454,7 @@ def generate_standard_and_async_functions(
else:
call_line = "return self._session.get(metadata, resource)"
- elif method == "post" or method == "put":
+ elif method == "post" or method == "put" or method == "patch":
if body_params:
call_line = f"return self._session.{method}(metadata, resource, payload)"
else:
@@ -355,11 +466,35 @@ def generate_standard_and_async_functions(
else:
call_line = "return self._session.delete(metadata, resource)"
+ assert call_line is not None, f"call_line was not set for {operation} (method={method})"
+
# Ensure all URL-referenced params get quote lines in the template
for p in re.findall(r"\{(\w+)\}", path):
if p not in path_params:
path_params[p] = {"type": "string", "in": "path"}
+ # Sanitize path_params keys and resource path for Python keywords
+ safe_path_params = {}
+ safe_resource = path
+ for p, v in path_params.items():
+ safe_p = safe_param_name(p)
+ safe_path_params[safe_p] = v
+ if safe_p != p:
+ safe_resource = safe_resource.replace("{" + p + "}", "{" + safe_p + "}")
+
+ # Record keyword param violations for the generation report
+ if renamed_params:
+ for safe_p, orig_p in renamed_params.items():
+ location = all_params.get(orig_p, {}).get("in", "unknown")
+ _keyword_param_violations.append(
+ {
+ "operation": operation,
+ "param": orig_p,
+ "location": location,
+ "scope": tags[0] if tags else "unknown",
+ }
+ )
+
# Add function to files
with open(
f"{template_dir}function_template.jinja2",
@@ -378,12 +513,13 @@ def generate_standard_and_async_functions(
all_params=list(all_params_for_doc.keys()) if all_params_for_doc else [],
assert_blocks=assert_blocks,
tags=tags,
- resource=path,
+ resource=safe_resource,
query_params=query_params,
array_params=array_params,
body_params=body_params,
- path_params=path_params,
+ path_params=safe_path_params,
call_line=call_line,
+ renamed_params=renamed_params,
)
output.write("\n\n" + rendered)
async_output.write("\n\n" + rendered)
@@ -440,50 +576,68 @@ def generate_action_batch_functions(
if p not in path_params:
path_params[p] = {"type": "string", "in": "path"}
+ # Sanitize path_params keys and resource path for Python keywords
+ safe_path_params = {}
+ safe_resource = path
+ for p, v in path_params.items():
+ safe_p = safe_param_name(p)
+ safe_path_params[safe_p] = v
+ if safe_p != p:
+ safe_resource = safe_resource.replace("{" + p + "}", "{" + safe_p + "}")
+
# Function definition
definition = ""
defined_params = set()
+ renamed_params = {}
for p, values in return_params(operation, all_params, ["required"]).items():
defined_params.add(p)
- # Match OAS schema types to Python types
+ safe_p = safe_param_name(p)
+ if safe_p != p:
+ renamed_params[safe_p] = p
match values["type"]:
case "array":
- definition += f", {p}: list"
+ definition += f", {safe_p}: list"
case "number":
- definition += f", {p}: float"
+ definition += f", {safe_p}: float"
case "integer":
- definition += f", {p}: int"
+ definition += f", {safe_p}: int"
case "boolean":
- definition += f", {p}: bool"
+ definition += f", {safe_p}: bool"
case "object":
- definition += f", {p}: dict"
+ definition += f", {safe_p}: dict"
case "string":
- definition += f", {p}: str"
+ definition += f", {safe_p}: str"
# Path params must be in the signature for URL construction
for p, values in return_params(operation, all_params, ["path"]).items():
if p not in defined_params:
defined_params.add(p)
+ safe_p = safe_param_name(p)
+ if safe_p != p:
+ renamed_params[safe_p] = p
match values["type"]:
case "array":
- definition += f", {p}: list"
+ definition += f", {safe_p}: list"
case "number":
- definition += f", {p}: float"
+ definition += f", {safe_p}: float"
case "integer":
- definition += f", {p}: int"
+ definition += f", {safe_p}: int"
case "boolean":
- definition += f", {p}: bool"
+ definition += f", {safe_p}: bool"
case "object":
- definition += f", {p}: dict"
+ definition += f", {safe_p}: dict"
case "string":
- definition += f", {p}: str"
+ definition += f", {safe_p}: str"
# Catch params referenced in the URL but not declared as path params
for p in re.findall(r"\{(\w+)\}", path):
if p not in defined_params:
defined_params.add(p)
- definition += f", {p}: str"
+ safe_p = safe_param_name(p)
+ if safe_p != p:
+ renamed_params[safe_p] = p
+ definition += f", {safe_p}: str"
# Add pagination params if perPage exists
if "perPage" in all_params:
@@ -520,11 +674,24 @@ def generate_action_batch_functions(
assert_blocks = list()
if enum_params:
for p, values in enum_params.items():
- assert_blocks.append((p, values["enum"]))
+ assert_blocks.append((p, values["enum"], values.get("nullable", False)))
# Function return statement
call_line = "return action"
+ # Record keyword param violations for the generation report
+ if renamed_params:
+ for safe_p, orig_p in renamed_params.items():
+ location = all_params.get(orig_p, {}).get("in", "unknown")
+ _keyword_param_violations.append(
+ {
+ "operation": operation,
+ "param": orig_p,
+ "location": location,
+ "scope": tags[0] if tags else "unknown",
+ }
+ )
+
# Add function to files
with open(
f"{template_dir}batch_function_template.jinja2",
@@ -545,13 +712,14 @@ def generate_action_batch_functions(
all_params=list(all_params_for_doc.keys()) if all_params_for_doc else [],
assert_blocks=assert_blocks,
tags=tags,
- resource=path,
+ resource=safe_resource,
query_params=query_params,
array_params=array_params,
body_params=body_params,
- path_params=path_params,
+ path_params=safe_path_params,
call_line=call_line,
batch_operation=batch_operation,
+ renamed_params=renamed_params,
)
)
@@ -571,9 +739,11 @@ def main(inputs):
api_version_number = "custom"
is_github_action = False
generate_stubs_flag = False
+ local_source = False
+ source_branch = "master"
try:
- opts, args = getopt.getopt(inputs, "ho:k:v:a:g:s")
+ opts, args = getopt.getopt(inputs, "ho:k:v:a:g:slb:")
except getopt.GetoptError:
print_help()
sys.exit(2)
@@ -594,6 +764,10 @@ def main(inputs):
is_github_action = True
elif opt == "-s":
generate_stubs_flag = True
+ elif opt == "-l":
+ local_source = True
+ elif opt == "-b":
+ source_branch = arg
check_python_version()
@@ -603,19 +777,19 @@ def main(inputs):
print_help()
sys.exit(2)
else:
- response = requests.get(
+ response = httpx.get(
f"https://api.meraki.com/api/v1/organizations/{org_id}/openapiSpec",
headers={"Authorization": f"Bearer {api_key}"},
params={"version": 3},
)
- if response.ok:
+ if response.status_code == 200:
spec = response.json()
else:
print_help()
sys.exit(f"API key provided does not have access to org {org_id}")
else:
- response = requests.get("https://api.meraki.com/api/v1/openapiSpec", params={"version": 3})
- if response.ok:
+ response = httpx.get("https://api.meraki.com/api/v1/openapiSpec", params={"version": 3})
+ if response.status_code == 200:
spec = response.json()
print("Successfully pulled Meraki dashboard API OpenAPI v3 spec.")
else:
@@ -625,7 +799,15 @@ def main(inputs):
"If this continues for more than an hour, please contact Meraki support."
)
- generate_library(spec, version_number, api_version_number, is_github_action, generate_stubs=generate_stubs_flag)
+ generate_library(
+ spec,
+ version_number,
+ api_version_number,
+ is_github_action,
+ generate_stubs=generate_stubs_flag,
+ local_source=local_source,
+ source_branch=source_branch,
+ )
if __name__ == "__main__":
diff --git a/generator/generate_library_oasv2.py b/generator/generate_library_oasv2.py
deleted file mode 100644
index f46784c3..00000000
--- a/generator/generate_library_oasv2.py
+++ /dev/null
@@ -1,816 +0,0 @@
-import getopt
-import json
-import os
-import platform
-import re
-import subprocess
-import sys
-import warnings
-
-import jinja2
-import requests
-
-import common as common
-
-if __name__ == "__main__" or "generate_library_oasv2" in sys.argv[0]:
- warnings.warn(
- "generate_library_oasv2.py is deprecated and will be removed in v1.2. Use generate_library.py instead.",
- DeprecationWarning,
- stacklevel=2,
- )
-
-READ_ME = """
-=== PREREQUISITES ===
-Include the jinja2 files in same directory as this script, and install Python requests
-pip[3] install requests
-
-=== DESCRIPTION ===
-This script generates the Meraki Python library using either the public OpenAPI specification, or, with an API key & org
-ID as inputs, a specific dashboard org's OpenAPI spec.
-
-=== USAGE === python[3] generate_library.py [-o ] [-k ] [-v ] [-a
-] [-g ]
-
-API key can, and is recommended to, be set as an environment variable named MERAKI_DASHBOARD_API_KEY."""
-
-REVERSE_PAGINATION = ["getNetworkEvents", "getOrganizationConfigurationChanges"]
-
-
-# Helper function to return pagination parameters depending on endpoint
-def generate_pagination_parameters(operation: str):
- ret = {
- "total_pages": {
- "type": "integer or string",
- "description": 'use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages',
- },
- "direction": {
- "type": "string",
- "description": 'direction to paginate, either "next" or "prev" (default) page'
- if operation in REVERSE_PAGINATION
- else 'direction to paginate, either "next" (default) or "prev" page',
- },
- }
- if operation == "getNetworkEvents":
- ret["event_log_end_time"] = {
- "type": "string",
- "description": "ISO8601 Zulu/UTC time, to use in conjunction with startingAfter, "
- "to retrieve events within a time window",
- }
- return ret
-
-
-def check_python_version():
- # Check minimum Python version
- version_warning_string = (
- f"This library generator requires Python 3.10 at minimum. "
- f"Your interpreter version is: {platform.python_version()}. "
- f"Please consult the readme at your convenience: "
- f"https://github.com/meraki/dashboard-api-python/blob/main/generator/readme.md "
- f"Additional details: "
- f"python_version_tuple()[0] = {platform.python_version_tuple()[0]}; "
- f"python_version_tuple()[1] = {platform.python_version_tuple()[1]} "
- )
-
- if not (int(platform.python_version_tuple()[0]) == 3 and int(platform.python_version_tuple()[1]) >= 10):
- sys.exit(version_warning_string)
-
-
-# Returns full link to endpoint's documentation on Developer Hub
-# Note: updates to the documentation site may impact these URLs.
-def docs_url(operation: str):
- base_url = "https://developer.cisco.com/meraki/api-v1/#!"
- ret = ""
- for letter in operation:
- if letter.islower():
- ret += letter
- else:
- ret += f"-{letter.lower()}"
- return base_url + ret
-
-
-# Helper function to return the right params; used in parse_params
-def return_params(operation: str, params: dict, param_filters):
- # Return parameters based on matching input filters
- if not param_filters:
- return params
- else:
- ret = {}
- if "required" in param_filters:
- ret.update({k: v for k, v in params.items() if "required" in v and v["required"]})
- if "pagination" in param_filters:
- ret.update(generate_pagination_parameters(operation) if "perPage" in params else {})
- if "optional" in param_filters:
- ret.update({k: v for k, v in params.items() if "required" in v and not v["required"]})
- if "path" in param_filters:
- ret.update({k: v for k, v in params.items() if "in" in v and v["in"] == "path"})
- if "query" in param_filters:
- ret.update({k: v for k, v in params.items() if "in" in v and v["in"] == "query"})
- if "body" in param_filters:
- ret.update({k: v for k, v in params.items() if "in" in v and v["in"] == "body"})
- if "array" in param_filters:
- ret.update({k: v for k, v in params.items() if "in" in v and v["type"] == "array"})
- if "enum" in param_filters:
- ret.update({k: v for k, v in params.items() if "enum" in v})
- return ret
-
-
-def unpack_param_without_schema(all_params: dict, this_param: dict, name: str, is_required: bool):
- # Set required attribute
- all_params[name] = {"required": is_required}
-
- # Assign relevant attributes
- for attribute in ("in", "type"):
- all_params[name][attribute] = this_param[attribute]
-
- # Capture the enum if available
- if "enum" in this_param:
- all_params[name]["enum"] = this_param["enum"]
-
- # Assign the description to the parameter if it's available
- if "description" in this_param:
- all_params[name]["description"] = this_param["description"]
-
- # Fall back to required if there is no description
- elif is_required:
- all_params[name]["description"] = "(required)"
-
- # Fall back to whatever the description is otherwise
- else:
- all_params[name]["description"] = this_param["description"]
-
- return all_params
-
-
-def unpack_param_with_schema(all_params: dict, this_param: dict):
- # the parameter will have a top-level object 'schema' and within that, 'properties' in OASv2
- # in OASv3, the parameter will only have this for query and path parameters, and requestBody params
- # will be in a separate key
- keys = this_param["schema"]["properties"]
-
- # parse the properties and assign types and descriptions
- for k in keys:
- # if required, set required true
- if "required" in this_param["schema"] and k in this_param["schema"]["required"]:
- all_params[k] = {"required": True}
- else:
- all_params[k] = {"required": False}
-
- # identify whether the parameter is in the path or query, or for OASv2, in the body
- all_params[k]["in"] = this_param["in"]
-
- # assign the right data type/description to the parameter per the schema
- for attribute in ("type", "description"):
- all_params[k][attribute] = keys[k][attribute]
-
- # capture schema enum if available
- if "enum" in keys[k]:
- all_params[k]["enum"] = keys[k]["enum"]
-
- # capture schema example if available
- if "example" in this_param["schema"] and k in this_param["schema"]["example"]:
- all_params[k]["example"] = this_param["schema"]["example"][k]
-
- return all_params
-
-
-def unpack_params(operation: str, parameters: dict, param_filters):
- # Create dict with information on endpoint's parameters
- unpacked_params = dict()
-
- # Iterate through the endpoint's parameters
- for p in parameters:
- # Name the parameter
- name = p["name"]
-
- # Consult the schema if there is one and it has properties (object-type params)
- if "schema" in p and "properties" in p["schema"]:
- unpacked_params.update(unpack_param_with_schema(unpacked_params, p))
-
- # Schema exists but no properties (simple type like string/integer)
- elif "schema" in p:
- is_required = p.get("required", False)
- flat_param = {**p, "type": p["schema"].get("type", "string")}
- if "enum" in p["schema"]:
- flat_param["enum"] = p["schema"]["enum"]
- unpacked_params.update(unpack_param_without_schema(unpacked_params, flat_param, name, is_required))
-
- # If there is no schema, then consult the required attribute if it exists
- elif "required" in p and p["required"]:
- unpacked_params.update(unpack_param_without_schema(unpacked_params, p, name, True))
-
- # Otherwise, the parameter is not required
- else:
- unpacked_params.update(unpack_param_without_schema(unpacked_params, p, name, False))
-
- # Add custom library parameters to handle pagination
- if "perPage" in unpacked_params:
- unpacked_params.update(generate_pagination_parameters(operation))
-
- # Return parameters based on matching input filters
- return return_params(operation, unpacked_params, param_filters)
-
-
-# Helper function to return parameters within OAS spec, optionally based on list of input filters
-def parse_params(operation: str, parameters: dict, param_filters=None):
- if param_filters is None:
- param_filters = list()
- if parameters is None:
- return {}
-
- return unpack_params(operation, parameters, param_filters)
-
-
-def generate_library(spec: dict, version_number: str, api_version_number: str, is_github_action: bool):
- # Supported scopes list will include organizations, networks, devices, and all product types.
- supported_scopes = [
- "organizations",
- "networks",
- "devices",
- "appliance",
- "camera",
- "cellularGateway",
- "insight",
- "sm",
- "switch",
- "wireless",
- "sensor",
- "administered",
- "licensing",
- "secureConnect",
- "wirelessController",
- "campusGateway",
- "spaces",
- "nac",
- ]
- # legacy scopes = ['organizations', 'networks', 'devices', 'appliance', 'camera', 'cellularGateway', 'insight',
- # 'sm', 'switch', 'wireless']
- tags = spec["tags"]
- paths = spec["paths"]
- # Scopes used when generating the library will depend on the provided version of the API spec.
- scopes = {tag["name"]: {} for tag in tags if tag["name"] in supported_scopes}
-
- batchable_actions = spec["x-batchable-actions"]
-
- # Set template_dir if a GitHub action is invoking it
- if is_github_action:
- template_dir = "generator/"
- else:
- template_dir = ""
-
- # Check paths and create directories if needed
- directories = [
- "meraki",
- "meraki/api",
- "meraki/api/batch",
- "meraki/aio",
- "meraki/aio/api",
- "meraki/api/batch",
- ]
- for directory in directories:
- if not os.path.isdir(directory):
- os.mkdir(directory)
-
- # Files that are not generated
- non_generated = [
- "__init__.py",
- "_version.py",
- "config.py",
- "common.py",
- "exceptions.py",
- "response_handler.py",
- "rest_session.py",
- "api/__init__.py",
- "aio/__init__.py",
- "aio/rest_session.py",
- "aio/api/__init__.py",
- "api/batch/__init__.py",
- ]
- base_url = "https://raw.githubusercontent.com/meraki/dashboard-api-python/master/meraki/"
- for file in non_generated:
- response = requests.get(f"{base_url}{file}")
- with open(f"meraki/{file}", "w+", encoding="utf-8", newline=None) as fp:
- contents = response.text
- if file == "_version.py":
- # replace library version
- start = contents.find("__version__ = ")
- end = contents.find("\n", start)
- contents = f"{contents[:start]}__version__ = '{version_number}'{contents[end:]}"
- elif file == "__init__.py":
- # replace API version
- start = contents.find("__api_version__ = ")
- end = contents.find("\n", start)
- contents = f"{contents[:start]}__api_version__ = '{api_version_number}'{contents[end:]}"
- fp.write(contents)
-
- # Organize data from OpenAPI specification
- operations, scopes = common.organize_spec(paths, scopes)
-
- # Inform the user how many operations were found
- print(f"Total of {len(operations)} endpoints found from OpenAPI spec...")
-
- # Generate API libraries
- # We will use newline=None to ensure that line breaks are handled correctly, especially when generating
- # on Windows and using `git autocrlf true`
- jinja_env = jinja2.Environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True)
- jinja_env.filters["to_double_quote_list"] = lambda lst: json.dumps(lst)
-
- # Iterate through the scopes creating standard, asyncio and batch modules for each
- generate_modules(batchable_actions, jinja_env, scopes, template_dir)
-
- # Format generated code with ruff
- print("Formatting generated code with ruff...")
- subprocess.run(["ruff", "check", "--fix", "--quiet", "meraki/"], check=False)
- subprocess.run(["ruff", "format", "--quiet", "meraki/"], check=False)
-
-
-def generate_modules(batchable_actions, jinja_env, scopes, template_dir):
- for scope in scopes:
- print(f"...generating {scope}")
- section = scopes[scope]
-
- # Generate the standard module
- with open(f"meraki/api/{scope}.py", "w", encoding="utf-8", newline=None) as output:
- # Open module file for Asyncio API libraries
- async_output = open(f"meraki/aio/api/{scope}.py", "w", encoding="utf-8", newline=None)
- # Open module file for Action Batch API libraries
- batch_output = open(f"meraki/api/batch/{scope}.py", "w", encoding="utf-8", newline=None)
-
- modules = [
- {"template_name": "class_template.jinja2", "module_output": output},
- {
- "template_name": "async_class_template.jinja2",
- "module_output": async_output,
- },
- {
- "template_name": "batch_class_template.jinja2",
- "module_output": batch_output,
- },
- ]
-
- # Generate modules
- for module in modules:
- render_class_template(
- jinja_env,
- template_dir,
- module["template_name"],
- module["module_output"],
- scope,
- )
-
- # Generate API & Asyncio API functions
- generate_standard_and_async_functions(jinja_env, template_dir, section, output, async_output)
-
- # Generate API action batch functions
- generate_action_batch_functions(
- jinja_env,
- template_dir,
- section,
- batch_output,
- batchable_actions,
- )
-
-
-def generate_standard_and_async_functions(
- jinja_env: jinja2.Environment,
- template_dir: str,
- section: dict,
- output: open,
- async_output: open,
-):
- for path, methods in section.items():
- for method, endpoint in methods.items():
- # Get metadata
- tags = endpoint["tags"]
- operation = endpoint["operationId"]
- description = endpoint["summary"]
-
- # will need updating for OASv3
- parameters = endpoint["parameters"] if "parameters" in endpoint else None
-
- # Function definition
- definition = ""
- defined_params = set()
- if parameters:
- for p, values in parse_params(operation, parameters, "required").items():
- defined_params.add(p)
- if values["type"] == "array":
- definition += f", {p}: list"
- elif values["type"] == "number":
- definition += f", {p}: float"
- elif values["type"] == "integer":
- definition += f", {p}: int"
- elif values["type"] == "boolean":
- definition += f", {p}: bool"
- elif values["type"] == "object":
- definition += f", {p}: dict"
- elif values["type"] == "string":
- definition += f", {p}: str"
-
- # Path params must be in the signature for URL construction
- for p, values in parse_params(operation, parameters, "path").items():
- if p not in defined_params:
- defined_params.add(p)
- if values["type"] == "array":
- definition += f", {p}: list"
- elif values["type"] == "number":
- definition += f", {p}: float"
- elif values["type"] == "integer":
- definition += f", {p}: int"
- elif values["type"] == "boolean":
- definition += f", {p}: bool"
- elif values["type"] == "object":
- definition += f", {p}: dict"
- elif values["type"] == "string":
- definition += f", {p}: str"
-
- # Catch params referenced in the URL but not declared as path params
- for p in re.findall(r"\{(\w+)\}", path):
- if p not in defined_params:
- defined_params.add(p)
- definition += f", {p}: str"
-
- if "perPage" in parse_params(operation, parameters):
- if operation in REVERSE_PAGINATION:
- definition += ", total_pages=1, direction='prev'"
- else:
- definition += ", total_pages=1, direction='next'"
- if operation == "getNetworkEvents":
- definition += ", event_log_end_time=None"
-
- if parse_params(operation, parameters, ["optional"]):
- definition += ", **kwargs"
-
- # Docstring
- param_descriptions = list()
- all_params = parse_params(operation, parameters, ["required", "pagination", "optional"])
- if all_params:
- for p, values in all_params.items():
- param_descriptions.append(f"{p} ({values['type']}): {values['description']}")
-
- # Combine keyword args with locals
- kwarg_line = ""
- if parse_params(operation, parameters, ["optional"]):
- kwarg_line = "kwargs.update(locals())"
- elif parse_params(operation, parameters, ["query", "array", "body"]):
- kwarg_line = "kwargs = locals()"
-
- # Assert valid values for enum
- enum_params = parse_params(operation, parameters, ["enum"])
- assert_blocks = list()
- if enum_params:
- for p, values in enum_params.items():
- assert_blocks.append((p, values["enum"]))
-
- # Function body for GET endpoints
- query_params = array_params = body_params = path_params = {}
- if method == "get":
- array_params, call_line, path_params, query_params = parse_get_params(operation, parameters)
-
- # Function body for POST/PUT endpoints
- elif method == "post" or method == "put":
- body_params, call_line, path_params = parse_post_and_put_params(method, operation, parameters)
-
- # Function body for DELETE endpoints
- elif method == "delete":
- call_line, path_params, query_params = parse_delete_params(operation, parameters)
-
- # Ensure all URL-referenced params get quote lines in the template
- for p in re.findall(r"\{(\w+)\}", path):
- if p not in path_params:
- path_params[p] = {"type": "string", "in": "path"}
-
- # Add function to files
- with open(
- f"{template_dir}function_template.jinja2",
- encoding="utf-8",
- newline=None,
- ) as fp:
- function_template = fp.read()
- template = jinja_env.from_string(function_template)
- output.write(
- "\n\n"
- + template.render(
- operation=operation,
- function_definition=definition,
- description=description,
- doc_url=docs_url(operation),
- descriptions=param_descriptions,
- kwarg_line=kwarg_line,
- all_params=list(all_params.keys()),
- assert_blocks=assert_blocks,
- tags=tags,
- resource=path,
- query_params=query_params,
- array_params=array_params,
- body_params=body_params,
- path_params=path_params,
- call_line=call_line,
- )
- )
- async_output.write(
- "\n\n"
- + template.render(
- operation=operation,
- function_definition=definition,
- description=description,
- doc_url=docs_url(operation),
- descriptions=param_descriptions,
- kwarg_line=kwarg_line,
- all_params=list(all_params.keys()),
- assert_blocks=assert_blocks,
- tags=tags,
- resource=path,
- query_params=query_params,
- array_params=array_params,
- body_params=body_params,
- path_params=path_params,
- call_line=call_line,
- )
- )
-
-
-def parse_get_params(operation: str, parameters: dict):
- query_params = parse_params(operation, parameters, "query")
- array_params = parse_params(operation, parameters, "array")
- path_params = parse_params(operation, parameters, "path")
- pagination_params = parse_params(operation, parameters, "pagination")
- if query_params or array_params:
- if pagination_params:
- if operation == "getNetworkEvents":
- call_line = (
- "return self._session.get_pages(metadata, resource, params, total_pages, direction, event_log_end_time)"
- )
- else:
- call_line = "return self._session.get_pages(metadata, resource, params, total_pages, direction)"
- else:
- call_line = "return self._session.get(metadata, resource, params)"
- else:
- call_line = "return self._session.get(metadata, resource)"
- return array_params, call_line, path_params, query_params
-
-
-def parse_post_and_put_params(method: str, operation: str, parameters: dict):
- body_params = parse_params(operation, parameters, "body")
- path_params = parse_params(operation, parameters, "path")
- if body_params:
- call_line = f"return self._session.{method}(metadata, resource, payload)"
- else:
- call_line = f"return self._session.{method}(metadata, resource)"
- return body_params, call_line, path_params
-
-
-def parse_delete_params(operation: str, parameters: dict):
- query_params = parse_params(operation, parameters, "query")
- path_params = parse_params(operation, parameters, "path")
-
- if query_params:
- call_line = "return self._session.delete(metadata, resource, params)"
- else:
- call_line = "return self._session.delete(metadata, resource)"
- return call_line, path_params, query_params
-
-
-def generate_action_batch_functions(
- jinja_env: jinja2.Environment,
- template_dir: str,
- section: dict,
- batch_output: open,
- batchable_actions: list,
-):
- for path, methods in section.items():
- for method, endpoint in methods.items():
- batchable_action_summaries = [action["summary"] for action in batchable_actions]
- if endpoint["description"] in batchable_action_summaries:
- # Get metadata
- tags = endpoint["tags"]
- operation = endpoint["operationId"]
- description = endpoint["description"]
- summary = endpoint["summary"]
-
- this_action = [
- action for action in batchable_actions if action["summary"] == description or action["summary"] == summary
- ][0]
-
- batch_operation = this_action["operation"]
-
- # May need update for OASv3
- parameters = endpoint["parameters"] if "parameters" in endpoint else None
-
- # Function body for GET endpoints
- query_params = array_params = body_params = {}
- path_params = parse_params(operation, parameters, "path")
-
- # Ensure all URL-referenced params get quote lines in the template
- for p in re.findall(r"\{(\w+)\}", path):
- if p not in path_params:
- path_params[p] = {"type": "string", "in": "path"}
-
- # Function body for POST/PUT endpoints
- if method == "post" or method == "put":
- # will need update for OASv3
- body_params = parse_params(operation, parameters, "body")
-
- # Function body for DELETE endpoints is empty (HTTP 204)
-
- # Function definition
- definition = ""
- defined_params = set()
- if parameters:
- for p, values in parse_params(operation, parameters, "required").items():
- defined_params.add(p)
- # Match OAS schema types to Python types
- match values["type"]:
- case "array":
- definition += f", {p}: list"
- case "number":
- definition += f", {p}: float"
- case "integer":
- definition += f", {p}: int"
- case "boolean":
- definition += f", {p}: bool"
- case "object":
- definition += f", {p}: dict"
- case "string":
- definition += f", {p}: str"
-
- # Path params must be in the signature for URL construction
- for p, values in parse_params(operation, parameters, "path").items():
- if p not in defined_params:
- defined_params.add(p)
- match values["type"]:
- case "array":
- definition += f", {p}: list"
- case "number":
- definition += f", {p}: float"
- case "integer":
- definition += f", {p}: int"
- case "boolean":
- definition += f", {p}: bool"
- case "object":
- definition += f", {p}: dict"
- case "string":
- definition += f", {p}: str"
-
- # Catch params referenced in the URL but not declared as path params
- for p in re.findall(r"\{(\w+)\}", path):
- if p not in defined_params:
- defined_params.add(p)
- definition += f", {p}: str"
-
- if "perPage" in parse_params(operation, parameters):
- if operation in REVERSE_PAGINATION:
- definition += ", total_pages=1, direction='prev'"
- else:
- definition += ", total_pages=1, direction='next'"
- if operation == "getNetworkEvents":
- definition += ", event_log_end_time=None"
-
- if parse_params(operation, parameters, ["optional"]):
- definition += ", **kwargs"
-
- # Docstring
- param_descriptions = list()
- all_params = parse_params(operation, parameters, ["required", "pagination", "optional"])
- if all_params:
- for p, values in all_params.items():
- param_descriptions.append(f"{p} ({values['type']}): {values['description']}")
-
- # Combine keyword args with locals
- kwarg_line = ""
- if parse_params(operation, parameters, ["optional"]):
- kwarg_line = "kwargs.update(locals())"
-
- # will need update for OASv3
- elif parse_params(operation, parameters, ["query", "array", "body"]):
- kwarg_line = "kwargs = locals()"
-
- # Assert valid values for enum
- enum_params = parse_params(operation, parameters, ["enum"])
- assert_blocks = list()
- if enum_params:
- for p, values in enum_params.items():
- assert_blocks.append((p, values["enum"]))
-
- # Function return statement
- call_line = "return action"
-
- # Add function to files
- with open(
- f"{template_dir}batch_function_template.jinja2",
- encoding="utf-8",
- newline=None,
- ) as fp:
- function_template = fp.read()
- template = jinja_env.from_string(function_template)
- batch_output.write(
- "\n\n"
- + template.render(
- operation=operation,
- function_definition=definition,
- description=description,
- doc_url=docs_url(operation),
- descriptions=param_descriptions,
- kwarg_line=kwarg_line,
- all_params=list(all_params.keys()),
- assert_blocks=assert_blocks,
- tags=tags,
- resource=path,
- query_params=query_params,
- array_params=array_params,
- body_params=body_params,
- path_params=path_params,
- call_line=call_line,
- batch_operation=batch_operation,
- )
- )
-
-
-def render_class_template(
- jinja_env: jinja2.Environment,
- template_dir: str,
- template_name: str,
- output: open,
- scope: str,
-):
- with open(f"{template_dir}{template_name}", encoding="utf-8", newline=None) as fp:
- class_template = fp.read()
- template = jinja_env.from_string(class_template)
- output.write(
- template.render(
- class_name=scope[0].upper() + scope[1:],
- )
- )
-
-
-# Prints a READ_ME help message for user to read
-def print_help():
- lines = READ_ME.split("\n")
- for line in lines:
- print(f"# {line}")
-
-
-# Parse command line arguments
-def main(inputs):
- api_key = os.environ.get("MERAKI_DASHBOARD_API_KEY")
- org_id = None
- version_number = "custom"
- api_version_number = "custom"
- is_github_action = False
-
- try:
- opts, args = getopt.getopt(inputs, "ho:k:v:a:g:")
- except getopt.GetoptError:
- print_help()
- sys.exit(2)
- for opt, arg in opts:
- if opt == "-h":
- print_help()
- sys.exit(2)
- elif opt == "-o":
- org_id = arg
- elif opt == "-k" and api_key is None:
- api_key = arg
- elif opt == "-v":
- version_number = arg
- elif opt == "-a":
- api_version_number = arg
- elif opt == "-g":
- if arg.lower() == "true":
- is_github_action = True
-
- check_python_version()
-
- # Retrieve the latest OpenAPI specification
- if org_id:
- if not api_key:
- print_help()
- sys.exit(2)
- else:
- response = requests.get(
- f"https://api.meraki.com/api/v1/organizations/{org_id}/openapiSpec",
- headers={"Authorization": f"Bearer {api_key}"},
- )
- if response.ok:
- spec = response.json()
- else:
- print_help()
- sys.exit(f"API key provided does not have access to org {org_id}")
- else:
- response = requests.get("https://api.meraki.com/api/v1/openapiSpec")
- # Validate that the spec pulled successfully before trying to generate the library.
- if response.ok:
- spec = response.json()
- print("Successfully pulled Meraki dashboard API OpenAPI spec.")
- else:
- print_help()
- sys.exit(
- "There was an HTTP error pulling the OpenAPI specification. Please try again in a few minutes. "
- "If this continues for more than an hour, please contact Meraki support and mention that "
- '"HTTP GET https://api.meraki.com/api/v1/openapiSpec" is failing.'
- )
-
- generate_library(spec, version_number, api_version_number, is_github_action)
-
-
-if __name__ == "__main__":
- main(sys.argv[1:])
diff --git a/generator/generate_snippets.py b/generator/generate_snippets.py
index a5000fea..12cfc183 100644
--- a/generator/generate_snippets.py
+++ b/generator/generate_snippets.py
@@ -1,9 +1,10 @@
import os
import sys
-import requests
+import httpx
from jinja2 import Template
import common as common
+from parser_v3 import parse_params_v3, clear_cache
CALL_TEMPLATE = Template(
"""import meraki
@@ -39,108 +40,16 @@ def snakify(param):
return ret
-# Helper function to return pagination parameters depending on endpoint
-def generate_pagination_parameters(operation):
- ret = {
- "total_pages": {
- "type": "integer or string",
- "description": 'total number of pages to retrieve, -1 or "all" for all pages',
- },
- "direction": {
- "type": "string",
- "description": 'direction to paginate, either "next" or "prev" (default) page'
- if operation in REVERSE_PAGINATION
- else 'direction to paginate, either "next" (default) or "prev" page',
- },
- }
- return ret
-
-
-# Helper function to return parameters within OAS spec, optionally based on list of input filters
-def parse_params(operation, parameters, param_filters=[]):
- if parameters is None:
- return {}
-
- # Create dict with information on endpoint's parameters
- params = {}
- for p in parameters:
- name = p["name"]
- if "schema" in p:
- keys = p["schema"]["properties"]
- for k in keys:
- if "required" in p["schema"] and k in p["schema"]["required"]:
- params[k] = {"required": True}
- else:
- params[k] = {"required": False}
- params[k]["in"] = p["in"]
- params[k]["type"] = keys[k]["type"]
- params[k]["description"] = keys[k]["description"]
- if "enum" in keys[k]:
- params[k]["enum"] = keys[k]["enum"]
- if "example" in p["schema"] and k in p["schema"]["example"]:
- params[k]["example"] = p["schema"]["example"][k]
- elif "required" in p and p["required"]:
- params[name] = {"required": True}
- params[name]["in"] = p["in"]
- params[name]["type"] = p["type"]
- if "description" in p:
- params[name]["description"] = p["description"]
- else:
- params[name]["description"] = "(required)"
- if "enum" in p:
- params[name]["enum"] = p["enum"]
- else:
- params[name] = {"required": False}
- params[name]["in"] = p["in"]
- params[name]["type"] = p["type"]
- params[name]["description"] = p["description"]
- if "enum" in p:
- params[name]["enum"] = p["enum"]
-
- # Add custom library parameters to handle pagination
- if "perPage" in params:
- params.update(generate_pagination_parameters(operation))
-
- # Return parameters based on matching input filters
- if not param_filters:
- return params
- else:
- ret = {}
- if "required" in param_filters:
- ret.update(
- {k: v for k, v in params.items() if "required" in v and v["required"]}
- )
- if "pagination" in param_filters:
- ret.update(
- generate_pagination_parameters(operation) if "perPage" in params else {}
- )
- if "optional" in param_filters:
- ret.update(
- {
- k: v
- for k, v in params.items()
- if "required" in v and not v["required"]
- }
- )
- if "path" in param_filters:
- ret.update(
- {k: v for k, v in params.items() if "in" in v and v["in"] == "path"}
- )
- if "query" in param_filters:
- ret.update(
- {k: v for k, v in params.items() if "in" in v and v["in"] == "query"}
- )
- if "body" in param_filters:
- ret.update(
- {k: v for k, v in params.items() if "in" in v and v["in"] == "body"}
- )
- if "array" in param_filters:
- ret.update(
- {k: v for k, v in params.items() if "in" in v and v["type"] == "array"}
- )
- if "enum" in param_filters:
- ret.update({k: v for k, v in params.items() if "enum" in v})
- return ret
+# Helper function to return parameters within the OASv3 spec, optionally based on
+# a list of input filters. Delegates parsing/$ref-resolution/requestBody handling to
+# parser_v3.parse_params_v3 (the same parser used by the library/stub generators) so
+# snippets stay consistent with generated code. parse_params_v3 applies the filters via
+# common.return_params and injects pagination params when perPage is present.
+def parse_params(endpoint, path_item, spec, param_filters=None):
+ if param_filters is None:
+ param_filters = []
+ params, _metadata = parse_params_v3(endpoint, path_item, spec, param_filters)
+ return params
# Generate text for parameter assignments
@@ -165,7 +74,7 @@ def process_assignments(parameters):
elif v == "str":
text += f"{param_name} = ''\n"
else:
- if type(v) == str:
+ if isinstance(v, str):
value = f"'{v}'"
else:
value = v
@@ -175,8 +84,11 @@ def process_assignments(parameters):
def main():
- # Get latest OpenAPI specification
- spec = requests.get("https://api.meraki.com/api/v1/openapiSpec").json()
+ # Get the latest OpenAPI v3 specification (version=3 selects OASv3 over the legacy v2 shape)
+ spec = httpx.get("https://api.meraki.com/api/v1/openapiSpec", params={"version": 3}).json()
+
+ # Reset parser_v3's $ref cache at entry, matching the library generator
+ clear_cache()
# Supported scopes list will include organizations, networks, devices, and all product types.
supported_scopes = [
@@ -206,8 +118,15 @@ def main():
# Scopes used when generating the library will depend on the provided version of the API spec.
scopes = {tag["name"]: {} for tag in tags if tag["name"] in supported_scopes}
+ # Filter paths to remove the path-level "parameters" key before organize_spec, which
+ # expects only HTTP method keys. The original path_item (incl. path-level params) is
+ # passed to parse_params_v3 separately via spec["paths"][path].
+ filtered_paths = {}
+ for path, path_item in paths.items():
+ filtered_paths[path] = {k: v for k, v in path_item.items() if k in ["get", "post", "put", "delete", "patch"]}
+
# Organize data from OpenAPI specification
- operations, scopes = common.organize_spec(paths, scopes)
+ operations, scopes = common.organize_spec(filtered_paths, scopes)
# Generate API libraries
for scope in scopes:
@@ -219,26 +138,23 @@ def main():
# Get metadata
tags = endpoint["tags"]
operation = endpoint["operationId"]
- description = endpoint["summary"]
- parameters = (
- endpoint["parameters"] if "parameters" in endpoint else None
- )
- responses = endpoint[
- "responses"
- ] # not actually used here for library generation
+
+ # Full path_item (incl. path-level parameters) for parse_params_v3
+ path_item = spec["paths"][path]
+
+ # An endpoint contributes params if it declares parameters or a requestBody
+ has_params = "parameters" in endpoint or "requestBody" in endpoint
required = {}
optional = {}
- if parameters:
- if "perPage" in parse_params(operation, parameters):
+ if has_params:
+ if "perPage" in parse_params(endpoint, path_item, spec):
pagination = True
else:
pagination = False
- for p, values in parse_params(
- operation, parameters, "required"
- ).items():
+ for p, values in parse_params(endpoint, path_item, spec, ["required"]).items():
if "example" in values:
required[p] = values["example"]
elif p == "organizationId":
@@ -261,7 +177,7 @@ def main():
elif values["type"] == "string":
required[p] = "str"
else:
- sys.exit(p, values)
+ sys.exit(f"Unhandled param type for {p}: {values}")
if pagination:
if operation not in REVERSE_PAGINATION:
@@ -269,9 +185,7 @@ def main():
else:
optional["total_pages"] = 3
- for p, values in parse_params(
- operation, parameters, "optional"
- ).items():
+ for p, values in parse_params(endpoint, path_item, spec, ["optional"]).items():
if "example" in values:
optional[p] = values["example"]
@@ -295,7 +209,7 @@ def main():
parameters_text += "total_pages='all'"
elif k == "total_pages" and v == 1:
parameters_text += "total_pages=1"
- elif type(v) == str:
+ elif isinstance(v, str):
parameters_text += f"\n {k}='{v}', "
else:
parameters_text += f"\n {k}={v}, "
diff --git a/generator/generate_stubs.py b/generator/generate_stubs.py
index f7c95a9d..2619410e 100644
--- a/generator/generate_stubs.py
+++ b/generator/generate_stubs.py
@@ -4,11 +4,17 @@
Produces .pyi files with typed method signatures from parsed OASv3 params.
"""
-import os
+import keyword
import re
import jinja2
from parser_v3 import parse_params_v3
-from generate_library_oasv2 import return_params, REVERSE_PAGINATION
+from common import return_params, REVERSE_PAGINATION
+
+
+def _safe_param_name(name: str) -> str:
+ if keyword.iskeyword(name):
+ return name + "_"
+ return name
def _python_type_annotation(param_dict: dict) -> str:
@@ -65,8 +71,8 @@ def generate_stub_modules(spec: dict, scopes: dict, jinja_env: jinja2.Environmen
for scope in scopes:
section = scopes[scope]
- stub_path = f"meraki/api/{scope}.pyi"
- with open(stub_path, "w", encoding="utf-8", newline=None) as stub_output:
+ # cwd-relative, matching the .py writers in generate_modules()
+ with open(f"meraki/api/{scope}.pyi", "w", encoding="utf-8", newline=None) as stub_output:
# Render class header
with open(f"{template_dir}stub_template.jinja2", encoding="utf-8", newline=None) as fp:
class_template = fp.read()
@@ -96,20 +102,20 @@ def generate_stub_modules(spec: dict, scopes: dict, jinja_env: jinja2.Environmen
for p, values in return_params(operation, all_params, ["required"]).items():
defined_params.add(p)
annotation = _python_type_annotation(values)
- signature_parts.append(f"{p}: {annotation}")
+ signature_parts.append(f"{_safe_param_name(p)}: {annotation}")
# Add path params (if not already in required)
for p, values in return_params(operation, all_params, ["path"]).items():
if p not in defined_params:
defined_params.add(p)
annotation = _python_type_annotation(values)
- signature_parts.append(f"{p}: {annotation}")
+ signature_parts.append(f"{_safe_param_name(p)}: {annotation}")
# Catch params referenced in URL but not declared
for p in re.findall(r"\{(\w+)\}", path):
if p not in defined_params:
defined_params.add(p)
- signature_parts.append(f"{p}: str")
+ signature_parts.append(f"{_safe_param_name(p)}: str")
# Add pagination params if perPage exists
if "perPage" in all_params:
@@ -127,8 +133,7 @@ def generate_stub_modules(spec: dict, scopes: dict, jinja_env: jinja2.Environmen
for p, values in optional_params.items():
if p not in defined_params:
annotation = _python_type_annotation(values)
- # Optional params always get = None default
- signature_parts.append(f"{p}: {annotation} = None")
+ signature_parts.append(f"{_safe_param_name(p)}: {annotation} = None")
signature = ", ".join(signature_parts)
diff --git a/generator/parser_v3.py b/generator/parser_v3.py
index dc05ef41..7bb7bbd5 100644
--- a/generator/parser_v3.py
+++ b/generator/parser_v3.py
@@ -8,7 +8,7 @@
Spec passed as argument to all functions.
"""
-from generate_library_oasv2 import generate_pagination_parameters, return_params
+from common import generate_pagination_parameters, return_params
# Module-level cache for resolved $ref pointers (D-01)
_ref_cache: dict[str, dict] = {}
diff --git a/meraki/__init__.py b/meraki/__init__.py
index bb59b8cb..dbd324d9 100644
--- a/meraki/__init__.py
+++ b/meraki/__init__.py
@@ -24,6 +24,8 @@
# Config import
from meraki.config import (
API_KEY_ENVIRONMENT_VARIABLE,
+ MERAKI_APP_ID,
+ MERAKI_APP_BEARER_TOKEN,
DEFAULT_BASE_URL,
SINGLE_REQUEST_TIMEOUT,
CERTIFICATE_PATH,
@@ -46,13 +48,28 @@
MERAKI_PYTHON_SDK_CALLER,
USE_ITERATOR_FOR_GET_PAGES,
VALIDATE_KWARGS,
+ SMART_FLOW_ENABLED,
+ SMART_FLOW_ORG_RATE,
+ SMART_FLOW_GLOBAL_RATE,
+ SMART_FLOW_CACHE_MODE,
+ SMART_FLOW_CACHE_PATH,
+ SMART_FLOW_CACHE_TTL,
+ SMART_FLOW_LOGGING,
)
-from meraki.rest_session import RestSession
-from meraki.exceptions import APIKeyError
-from meraki._version import __version__ as __version__ # noqa: F401
+from meraki.session.sync import RestSession
+from meraki.exceptions import APIError, APIKeyError, APIResponseError, AsyncAPIError
+from meraki._version import __version__ # noqa: F401
from datetime import datetime
-__api_version__ = "1.69.0"
+__api_version__ = "1.72.0"
+
+__all__ = [
+ "APIError",
+ "APIKeyError",
+ "APIResponseError",
+ "AsyncAPIError",
+ "DashboardAPI",
+]
class DashboardAPI(object):
@@ -60,6 +77,8 @@ class DashboardAPI(object):
**Creates a persistent Meraki dashboard API session**
- api_key (string): API key generated in dashboard; can also be set as an environment variable MERAKI_DASHBOARD_API_KEY
+ - meraki_app_id (string): optional Meraki app ID; can also be set as an environment variable MERAKI_APP_ID
+ - meraki_app_bearer_token (string): optional Meraki app bearer token; can also be set as an environment variable MERAKI_APP_BEARER_TOKEN
- base_url (string): preceding all endpoint resources
- single_request_timeout (integer): maximum number of seconds for each API call
- certificate_path (string): path for TLS/SSL certificate verification if behind local proxy
@@ -82,11 +101,18 @@ class DashboardAPI(object):
- caller (string): optional identifier for API usage tracking; can also be set as an environment variable MERAKI_PYTHON_SDK_CALLER
- use_iterator_for_get_pages (boolean): list* methods will return an iterator with each object instead of a complete list with all items
- validate_kwargs (boolean): log warnings when unrecognized kwargs are passed to API methods
+ - smart_flow_enabled (boolean): enable per-org proactive smart flow via token buckets?
+ - smart_flow_org_rate (float): max requests per second per org (Meraki default: 10)
+ - smart_flow_global_rate (float): max requests per second across all orgs (source IP limit, Meraki default: 100)
+ - smart_flow_cache_mode (string): "lazy" (default) or "eager" - how org/network/device mappings are loaded
+ - smart_flow_cache_path (string): path to persist smart flow mapping cache across sessions
"""
def __init__(
self,
api_key=None,
+ meraki_app_id=MERAKI_APP_ID,
+ meraki_app_bearer_token=MERAKI_APP_BEARER_TOKEN,
base_url=DEFAULT_BASE_URL,
single_request_timeout=SINGLE_REQUEST_TIMEOUT,
certificate_path=CERTIFICATE_PATH,
@@ -109,21 +135,29 @@ def __init__(
use_iterator_for_get_pages=USE_ITERATOR_FOR_GET_PAGES,
inherit_logging_config=INHERIT_LOGGING_CONFIG,
validate_kwargs=VALIDATE_KWARGS,
+ smart_flow_enabled=SMART_FLOW_ENABLED,
+ smart_flow_org_rate=SMART_FLOW_ORG_RATE,
+ smart_flow_global_rate=SMART_FLOW_GLOBAL_RATE,
+ smart_flow_cache_mode=SMART_FLOW_CACHE_MODE,
+ smart_flow_cache_path=SMART_FLOW_CACHE_PATH,
+ smart_flow_cache_ttl=SMART_FLOW_CACHE_TTL,
+ smart_flow_logging=SMART_FLOW_LOGGING,
):
# Check API key
api_key = api_key or os.environ.get(API_KEY_ENVIRONMENT_VARIABLE)
if not api_key:
raise APIKeyError()
+ # Pull the Meraki app ID and bearer token from environment variables if present
+ meraki_app_id = meraki_app_id or os.environ.get("MERAKI_APP_ID")
+ meraki_app_bearer_token = meraki_app_bearer_token or os.environ.get("MERAKI_APP_BEARER_TOKEN")
+
# Pull the BE GEO ID from an environment variable if present
be_geo_id = be_geo_id or os.environ.get("BE_GEO_ID")
# Pull the caller from an environment variable if present
caller = caller or os.environ.get("MERAKI_PYTHON_SDK_CALLER")
- use_iterator_for_get_pages = use_iterator_for_get_pages
- inherit_logging_config = inherit_logging_config
-
# Configure logging
if not suppress_logging:
self._logger = logging.getLogger(__name__)
@@ -159,6 +193,8 @@ def __init__(
self._session = RestSession(
logger=self._logger,
api_key=api_key,
+ meraki_app_id=meraki_app_id,
+ meraki_app_bearer_token=meraki_app_bearer_token,
base_url=base_url,
single_request_timeout=single_request_timeout,
certificate_path=certificate_path,
@@ -175,6 +211,13 @@ def __init__(
caller=caller,
use_iterator_for_get_pages=use_iterator_for_get_pages,
validate_kwargs=validate_kwargs,
+ smart_flow_enabled=smart_flow_enabled,
+ smart_flow_org_rate=smart_flow_org_rate,
+ smart_flow_global_rate=smart_flow_global_rate,
+ smart_flow_cache_mode=smart_flow_cache_mode,
+ smart_flow_cache_path=smart_flow_cache_path,
+ smart_flow_cache_ttl=smart_flow_cache_ttl,
+ smart_flow_logging=smart_flow_logging,
)
# API endpoints by section
@@ -197,3 +240,41 @@ def __init__(
# Batch definitions
self.batch = Batch()
+
+ # Eager load smart limit cache if enabled (skip if disk cache was fresh)
+ if smart_flow_enabled and smart_flow_cache_mode == "eager":
+ limiter = self._session._smart_flow
+ if limiter and not limiter.cache_fresh:
+ self._eager_load_rate_limit_cache()
+
+ def _eager_load_rate_limit_cache(self) -> None:
+ """Populate the smart flow's org/network/device cache at startup."""
+ rate_limiter = self._session._smart_flow
+ if not rate_limiter:
+ return
+
+ try:
+ orgs = self.organizations.getOrganizations()
+ except Exception:
+ return
+
+ for org in orgs:
+ org_id = org["id"]
+ rate_limiter.register_org(org_id)
+
+ try:
+ networks = self.organizations.getOrganizationNetworks(org_id, total_pages="all", perPage=1000)
+ for net in networks:
+ rate_limiter.register_network(net["id"], org_id)
+ except Exception:
+ pass
+
+ try:
+ devices = self.organizations.getOrganizationInventoryDevices(org_id, total_pages="all", perPage=1000)
+ for device in devices:
+ if device.get("serial"):
+ rate_limiter.register_device(device["serial"], org_id)
+ except Exception:
+ pass
+
+ rate_limiter.save_cache()
diff --git a/meraki/_version.py b/meraki/_version.py
index 05527687..111dc917 100644
--- a/meraki/_version.py
+++ b/meraki/_version.py
@@ -1 +1 @@
-__version__ = "3.0.1"
+__version__ = "4.3.0"
diff --git a/meraki/aio/__init__.py b/meraki/aio/__init__.py
index 6e8ed150..17b61d2a 100644
--- a/meraki/aio/__init__.py
+++ b/meraki/aio/__init__.py
@@ -17,7 +17,7 @@
from meraki.aio.api.switch import AsyncSwitch
from meraki.aio.api.wireless import AsyncWireless
from meraki.aio.api.wirelessController import AsyncWirelessController
-from meraki.aio.rest_session import AsyncRestSession
+from meraki.session.async_ import AsyncRestSession
from meraki.exceptions import APIKeyError
from datetime import datetime
@@ -27,6 +27,8 @@
# Config import
from meraki.config import (
API_KEY_ENVIRONMENT_VARIABLE,
+ MERAKI_APP_ID,
+ MERAKI_APP_BEARER_TOKEN,
DEFAULT_BASE_URL,
SINGLE_REQUEST_TIMEOUT,
CERTIFICATE_PATH,
@@ -50,6 +52,13 @@
USE_ITERATOR_FOR_GET_PAGES,
AIO_MAXIMUM_CONCURRENT_REQUESTS,
VALIDATE_KWARGS,
+ SMART_FLOW_ENABLED,
+ SMART_FLOW_ORG_RATE,
+ SMART_FLOW_GLOBAL_RATE,
+ SMART_FLOW_CACHE_MODE,
+ SMART_FLOW_CACHE_PATH,
+ SMART_FLOW_CACHE_TTL,
+ SMART_FLOW_LOGGING,
)
@@ -58,6 +67,8 @@ class AsyncDashboardAPI:
**Creates a persistent Meraki dashboard API session**
- api_key (string): API key generated in dashboard; can also be set as an environment variable MERAKI_DASHBOARD_API_KEY
+ - meraki_app_id (string): optional Meraki app ID; can also be set as an environment variable MERAKI_APP_ID
+ - meraki_app_bearer_token (string): optional Meraki app bearer token; can also be set as an environment variable MERAKI_APP_BEARER_TOKEN
- base_url (string): preceding all endpoint resources
- single_request_timeout (integer): maximum number of seconds for each API call
- certificate_path (string): path for TLS/SSL certificate verification if behind local proxy
@@ -81,11 +92,18 @@ class AsyncDashboardAPI:
- caller (string): optional identifier for API usage tracking; can also be set as an environment variable MERAKI_PYTHON_SDK_CALLER
- use_iterator_for_get_pages (boolean): list* methods will return an iterator with each object instead of a complete list with all items
- validate_kwargs (boolean): log warnings when unrecognized kwargs are passed to API methods
+ - smart_flow_enabled (boolean): enable per-org proactive smart flow via token buckets?
+ - smart_flow_org_rate (float): max requests per second per org (Meraki default: 10)
+ - smart_flow_global_rate (float): max requests per second across all orgs (source IP limit, Meraki default: 100)
+ - smart_flow_cache_mode (string): "lazy" (default) or "eager" - how org/network/device mappings are loaded
+ - smart_flow_cache_path (string): path to persist smart flow mapping cache across sessions
"""
def __init__(
self,
api_key=None,
+ meraki_app_id=MERAKI_APP_ID,
+ meraki_app_bearer_token=MERAKI_APP_BEARER_TOKEN,
base_url=DEFAULT_BASE_URL,
single_request_timeout=SINGLE_REQUEST_TIMEOUT,
certificate_path=CERTIFICATE_PATH,
@@ -109,21 +127,29 @@ def __init__(
inherit_logging_config=INHERIT_LOGGING_CONFIG,
maximum_concurrent_requests=AIO_MAXIMUM_CONCURRENT_REQUESTS,
validate_kwargs=VALIDATE_KWARGS,
+ smart_flow_enabled=SMART_FLOW_ENABLED,
+ smart_flow_org_rate=SMART_FLOW_ORG_RATE,
+ smart_flow_global_rate=SMART_FLOW_GLOBAL_RATE,
+ smart_flow_cache_mode=SMART_FLOW_CACHE_MODE,
+ smart_flow_cache_path=SMART_FLOW_CACHE_PATH,
+ smart_flow_cache_ttl=SMART_FLOW_CACHE_TTL,
+ smart_flow_logging=SMART_FLOW_LOGGING,
):
# Check API key
api_key = api_key or os.environ.get(API_KEY_ENVIRONMENT_VARIABLE)
if not api_key:
raise APIKeyError()
+ # Pull the Meraki app ID and bearer token from environment variables if present
+ meraki_app_id = meraki_app_id or os.environ.get("MERAKI_APP_ID")
+ meraki_app_bearer_token = meraki_app_bearer_token or os.environ.get("MERAKI_APP_BEARER_TOKEN")
+
# Pull the BE GEO ID from an environment variable if present
be_geo_id = be_geo_id or os.environ.get("BE_GEO_ID")
# Pull the caller from an environment variable if present
caller = caller or os.environ.get("MERAKI_PYTHON_SDK_CALLER")
- use_iterator_for_get_pages = use_iterator_for_get_pages
- inherit_logging_config = inherit_logging_config
-
# Configure logging
if not suppress_logging:
self._logger = logging.getLogger(__name__)
@@ -159,6 +185,8 @@ def __init__(
self._session = AsyncRestSession(
logger=self._logger,
api_key=api_key,
+ meraki_app_id=meraki_app_id,
+ meraki_app_bearer_token=meraki_app_bearer_token,
base_url=base_url,
single_request_timeout=single_request_timeout,
certificate_path=certificate_path,
@@ -176,8 +204,19 @@ def __init__(
use_iterator_for_get_pages=use_iterator_for_get_pages,
maximum_concurrent_requests=maximum_concurrent_requests,
validate_kwargs=validate_kwargs,
+ smart_flow_enabled=smart_flow_enabled,
+ smart_flow_org_rate=smart_flow_org_rate,
+ smart_flow_global_rate=smart_flow_global_rate,
+ smart_flow_cache_mode=smart_flow_cache_mode,
+ smart_flow_cache_path=smart_flow_cache_path,
+ smart_flow_cache_ttl=smart_flow_cache_ttl,
+ smart_flow_logging=smart_flow_logging,
)
+ # Store for eager load access
+ self._smart_flow_enabled = smart_flow_enabled
+ self._smart_flow_cache_mode = smart_flow_cache_mode
+
# API endpoints by section
self.administered = AsyncAdministered(self._session)
self.organizations = AsyncOrganizations(self._session)
@@ -200,7 +239,50 @@ def __init__(
self.batch = Batch()
async def __aenter__(self):
+ if self._smart_flow_enabled and self._smart_flow_cache_mode == "eager":
+ limiter = self._session._smart_flow
+ if limiter and not limiter.cache_fresh:
+ await self._eager_load_rate_limit_cache()
return self
async def __aexit__(self, exc_type, exc, tb):
+ # Drain/cancel in-flight background resolve/hydrate + flush tasks and
+ # persist the cache BEFORE closing the httpx client. Closing first would
+ # cause those background tasks to fail with "client has been closed".
+ # shutdown() performs the final save itself, so it is called instead of
+ # save_cache() to keep the persist exactly once.
+ if self._session._smart_flow:
+ await self._session._smart_flow.shutdown()
await self._session.close()
+
+ async def _eager_load_rate_limit_cache(self) -> None:
+ """Populate the smart flow's org/network/device cache at startup."""
+ rate_limiter = self._session._smart_flow
+ if not rate_limiter:
+ return
+
+ try:
+ orgs = await self.organizations.getOrganizations()
+ except Exception:
+ return
+
+ for org in orgs:
+ org_id = org["id"]
+ rate_limiter.register_org(org_id)
+
+ try:
+ networks = await self.organizations.getOrganizationNetworks(org_id, total_pages="all", perPage=1000)
+ for net in networks:
+ rate_limiter.register_network(net["id"], org_id)
+ except Exception:
+ pass
+
+ try:
+ devices = await self.organizations.getOrganizationInventoryDevices(org_id, total_pages="all", perPage=1000)
+ for device in devices:
+ if device.get("serial"):
+ rate_limiter.register_device(device["serial"], org_id)
+ except Exception:
+ pass
+
+ await rate_limiter.save_cache()
diff --git a/meraki/aio/api/appliance.py b/meraki/aio/api/appliance.py
index af94a02c..09c98051 100644
--- a/meraki/aio/api/appliance.py
+++ b/meraki/aio/api/appliance.py
@@ -23,9 +23,50 @@ def getDeviceApplianceDhcpSubnets(self, serial: str):
return self._session.get(metadata, resource)
+ def createDeviceApplianceInterfacesPortsUpdate(self, serial: str, **kwargs):
+ """
+ **Update configurations for an appliance's specified port**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-appliance-interfaces-ports-update
+
+ - serial (string): Serial
+ - interface (object): The interface tuple used to identify the port
+ - enabled (boolean): Indicates whether the port is enabled
+ - personality (object): Describes the port's configurability
+ - uplink (object): The port's settings when in WAN mode
+ - downlink (object): The port's VLAN settings when in LAN mode
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "interfaces", "ports", "update"],
+ "operation": "createDeviceApplianceInterfacesPortsUpdate",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/appliance/interfaces/ports/update"
+
+ body_params = [
+ "interface",
+ "enabled",
+ "personality",
+ "uplink",
+ "downlink",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"createDeviceApplianceInterfacesPortsUpdate: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
def getDeviceAppliancePerformance(self, serial: str, **kwargs):
"""
- **Return the performance score for a single MX**
+ **Return the performance score for a single Secure Appliance or Secure Router**
https://developer.cisco.com/meraki/api-v1/#!get-device-appliance-performance
- serial (string): Serial
@@ -1038,6 +1079,93 @@ def updateNetworkApplianceFirewallSettings(self, networkId: str, **kwargs):
return self._session.put(metadata, resource, payload)
+ def createNetworkApplianceInterfacesL3(self, networkId: str, ipv4: dict, **kwargs):
+ """
+ **Create wired L3 interface**
+ https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-interfaces-l-3
+
+ - networkId (string): Network ID
+ - ipv4 (object): IPv4 configuration
+ - port (object): Port configuration
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "interfaces", "l3"],
+ "operation": "createNetworkApplianceInterfacesL3",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/interfaces/l3"
+
+ body_params = [
+ "port",
+ "ipv4",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"createNetworkApplianceInterfacesL3: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.post(metadata, resource, payload)
+
+ def updateNetworkApplianceInterfacesL3(self, networkId: str, interfaceId: str, **kwargs):
+ """
+ **Update wired L3 interface**
+ https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-interfaces-l-3
+
+ - networkId (string): Network ID
+ - interfaceId (string): Interface ID
+ - port (object): Port configuration
+ - ipv4 (object): IPv4 configuration
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "interfaces", "l3"],
+ "operation": "updateNetworkApplianceInterfacesL3",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
+ resource = f"/networks/{networkId}/appliance/interfaces/l3/{interfaceId}"
+
+ body_params = [
+ "port",
+ "ipv4",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"updateNetworkApplianceInterfacesL3: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
+ def deleteNetworkApplianceInterfacesL3(self, networkId: str, interfaceId: str):
+ """
+ **Delete wired L3 interface**
+ https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-interfaces-l-3
+
+ - networkId (string): Network ID
+ - interfaceId (string): Interface ID
+ """
+
+ metadata = {
+ "tags": ["appliance", "configure", "interfaces", "l3"],
+ "operation": "deleteNetworkApplianceInterfacesL3",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
+ resource = f"/networks/{networkId}/appliance/interfaces/l3/{interfaceId}"
+
+ return self._session.delete(metadata, resource)
+
def getNetworkAppliancePorts(self, networkId: str):
"""
**List per-port VLAN settings for all ports of a secure router or security appliance.**
@@ -1087,6 +1215,7 @@ def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs):
- vlan (integer): Native VLAN when the port is in Trunk mode. Access VLAN when the port is in Access mode.
- allowedVlans (string): Comma-delimited list of VLAN IDs (e.g. '2,15') for all devices. Secure Routers also support VLAN ranges (e.g. '2-10,15'). Use 'all' to permit all VLANs on the port.
- accessPolicy (string): The name of the policy. Only applicable to Access ports. Valid values are: 'open', '8021x-radius', 'mac-radius', 'hybris-radius' for MX64 or Z3 or any MX supporting the per port authentication feature. Otherwise, 'open' is the only valid value and 'open' is the default value if the field is missing.
+ - sgt (object): Security Group Tag settings for the port.
"""
kwargs.update(locals())
@@ -1106,6 +1235,7 @@ def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs):
"vlan",
"allowedVlans",
"accessPolicy",
+ "sgt",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -2378,6 +2508,134 @@ def disconnectNetworkApplianceUmbrellaAccount(self, networkId: str):
return self._session.post(metadata, resource)
+ def exclusionsNetworkApplianceUmbrellaDomains(self, networkId: str, domains: list, **kwargs):
+ """
+ **Specify one or more domain names to be excluded from being routed to Cisco Umbrella.**
+ https://developer.cisco.com/meraki/api-v1/#!exclusions-network-appliance-umbrella-domains
+
+ - networkId (string): Network ID
+ - domains (array): Domain names to exclude from Umbrella DNS routing (e.g., 'example.com', 'corp.example.org'). Standard FQDNs only — wildcards are not supported. Values are lowercased before saving. Each call replaces the full exclusion list.
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "umbrella", "domains"],
+ "operation": "exclusionsNetworkApplianceUmbrellaDomains",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/domains/exclusions"
+
+ body_params = [
+ "domains",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"exclusionsNetworkApplianceUmbrellaDomains: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.put(metadata, resource, payload)
+
+ def addNetworkApplianceUmbrellaPolicies(self, networkId: str, policy: dict, **kwargs):
+ """
+ **Add one Cisco Umbrella DNS security policy to an MX network by policy ID**
+ https://developer.cisco.com/meraki/api-v1/#!add-network-appliance-umbrella-policies
+
+ - networkId (string): Network ID
+ - policy (object): Umbrella policy to add
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "umbrella", "policies"],
+ "operation": "addNetworkApplianceUmbrellaPolicies",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/policies/add"
+
+ body_params = [
+ "policy",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"addNetworkApplianceUmbrellaPolicies: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.post(metadata, resource, payload)
+
+ def removeNetworkApplianceUmbrellaPolicies(self, networkId: str, policy: dict, **kwargs):
+ """
+ **Remove one Cisco Umbrella DNS security policy from an MX network by policy ID**
+ https://developer.cisco.com/meraki/api-v1/#!remove-network-appliance-umbrella-policies
+
+ - networkId (string): Network ID
+ - policy (object): Umbrella policy to remove
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "umbrella", "policies"],
+ "operation": "removeNetworkApplianceUmbrellaPolicies",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/policies/remove"
+
+ body_params = [
+ "policy",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"removeNetworkApplianceUmbrellaPolicies: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
+ def protectionNetworkApplianceUmbrella(self, networkId: str, enabled: bool, **kwargs):
+ """
+ **Enable or disable umbrella protection for an appliance network**
+ https://developer.cisco.com/meraki/api-v1/#!protection-network-appliance-umbrella
+
+ - networkId (string): Network ID
+ - enabled (boolean): Enable or disable umbrella protection
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "umbrella"],
+ "operation": "protectionNetworkApplianceUmbrella",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/protection"
+
+ body_params = [
+ "enabled",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"protectionNetworkApplianceUmbrella: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
def updateNetworkApplianceUplinksNat(self, networkId: str, uplinks: list, **kwargs):
"""
**Update uplink NAT settings of the specified network**
@@ -2415,7 +2673,7 @@ def getNetworkApplianceUplinksUsageHistory(self, networkId: str, **kwargs):
https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-uplinks-usage-history
- networkId (string): Network ID
- - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 365 days from today.
+ - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 30 days from today.
- t1 (string): The end of the timespan for the data. t1 can be a maximum of 31 days after t0.
- timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 31 days. The default is 10 minutes.
- resolution (integer): The time resolution in seconds for returned data. The valid resolutions are: 60, 300, 600, 1800, 3600, 86400. The default is 60.
@@ -2488,6 +2746,8 @@ def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwarg
- dhcpBootNextServer (string): DHCP boot option to direct boot clients to the server to load the boot file from
- dhcpBootFilename (string): DHCP boot option for boot filename
- dhcpOptions (array): The list of DHCP options that will be included in DHCP responses. Each object in the list should have "code", "type", and "value" properties.
+ - sgt (object): Security Group Tag settings for the VLAN.
+ - vrf (object): VRF configuration on the VLAN.
- uplinks (array): Per-uplink NAT exception override configuration on the VLAN. Applicable only for networks that support NAT exceptions.
"""
@@ -2534,6 +2794,8 @@ def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwarg
"dhcpBootNextServer",
"dhcpBootFilename",
"dhcpOptions",
+ "sgt",
+ "vrf",
"uplinks",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -2640,6 +2902,8 @@ def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs):
- mask (integer): Mask used for the subnet of all bound to the template networks. Applicable only for template network.
- ipv6 (object): IPv6 configuration on the VLAN
- mandatoryDhcp (object): Mandatory DHCP will enforce that clients connecting to this VLAN must use the IP address assigned by the DHCP server. Clients who use a static IP address won't be able to associate. Only available on firmware versions 17.0 and above
+ - sgt (object): Security Group Tag settings for the VLAN.
+ - vrf (object): VRF configuration on the VLAN.
- uplinks (array): Per-uplink NAT exception override configuration on the VLAN. Applicable only for networks that support NAT exceptions.
"""
@@ -2690,6 +2954,8 @@ def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs):
"mask",
"ipv6",
"mandatoryDhcp",
+ "sgt",
+ "vrf",
"uplinks",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -2745,7 +3011,7 @@ def updateNetworkApplianceVpnBgp(self, networkId: str, enabled: bool, **kwargs):
- networkId (string): Network ID
- enabled (boolean): Boolean value to enable or disable the BGP configuration. When BGP is enabled, the asNumber (ASN) will be autopopulated with the preconfigured ASN at other Hubs or a default value if there is no ASN configured.
- - asNumber (integer): An Autonomous System Number (ASN) is required if you are to run BGP and peer with another BGP Speaker outside of the Auto VPN domain. This ASN will be applied to the entire Auto VPN domain. The entire 4-byte ASN range is supported. So, the ASN must be an integer between 1 and 4294967295. When absent, this field is not updated. If no value exists then it defaults to 64512.
+ - asNumber (integer): An Autonomous System Number (ASN) is required if you are to run BGP and peer with another BGP Speaker outside of the Auto VPN domain. This ASN will be applied to the entire Auto VPN domain and is only configurable for Auto VPN BGP networks. The entire 4-byte ASN range is supported. So, the ASN must be an integer between 1 and 4294967295. When absent, this field is not updated. If no value exists then it defaults to 64512.
- ibgpHoldTimer (integer): The iBGP holdtimer in seconds. The iBGP holdtimer must be an integer between 12 and 240. When absent, this field is not updated. If no value exists then it defaults to 240.
- routerId (string): The router ID of the appliance
- neighbors (array): List of BGP neighbors. This list replaces the existing set of neighbors. When absent, this field is not updated.
@@ -2803,6 +3069,7 @@ def updateNetworkApplianceVpnSiteToSiteVpn(self, networkId: str, mode: str, **kw
- mode (string): The site-to-site VPN mode. Can be one of 'none', 'spoke' or 'hub'
- hubs (array): The list of VPN hubs, in order of preference. In spoke mode, at least 1 hub is required.
- subnets (array): The list of subnets and their VPN presence.
+ - sgt (object): Security Group Tag settings for the VPN peer.
- subnet (object): Configuration of subnet features
- hostTranslations (array): The list of VPN host translations. Host translations are supported starting from MX firmware version 26.1.2
"""
@@ -2824,6 +3091,7 @@ def updateNetworkApplianceVpnSiteToSiteVpn(self, networkId: str, mode: str, **kw
"mode",
"hubs",
"subnets",
+ "sgt",
"subnet",
"hostTranslations",
]
@@ -2912,6 +3180,167 @@ def swapNetworkApplianceWarmSpare(self, networkId: str):
return self._session.post(metadata, resource)
+ def getOrganizationApplianceDevicesInterfacesL3(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **List L3 interfaces across networks for the organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-interfaces-l-3
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - networkIds (array): Optional Network IDs to filter results
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "devices", "interfaces", "l3"],
+ "operation": "getOrganizationApplianceDevicesInterfacesL3",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/devices/interfaces/l3"
+
+ query_params = [
+ "networkIds",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationApplianceDevicesInterfacesL3: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationApplianceDevicesInterfacesPortsByDevice(self, organizationId: str, **kwargs):
+ """
+ **Returns port configurations for appliances in a given organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-interfaces-ports-by-device
+
+ - organizationId (string): Organization ID
+ - serials (array): Parameter to filter the results by device serials
+ - interfaces (array): Parameter to filter the results by specific interfaces
+ - numbers (array): Parameter to filter the results by specific ports
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "devices", "interfaces", "ports", "byDevice"],
+ "operation": "getOrganizationApplianceDevicesInterfacesPortsByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/devices/interfaces/ports/byDevice"
+
+ query_params = [
+ "serials",
+ "interfaces",
+ "numbers",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ "interfaces",
+ "numbers",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationApplianceDevicesInterfacesPortsByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get(metadata, resource, params)
+
+ def getOrganizationApplianceDevicesPortsTransceiversReadingsHistoryByDevice(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **Return time-series digital optical monitoring (DOM) readings for ports on each DOM-enabled Catalyst appliance in an organization.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-ports-transceivers-readings-history-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 10. Default is 5.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 30 days from today.
+ - t1 (string): The end of the timespan for the data. t1 can be a maximum of 30 days after t0.
+ - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 30 days. The default is 1 day. If interval is provided, the timespan will be autocalculated.
+ - interval (integer): The time interval in seconds for returned data. The valid intervals are: 300, 1200, 14400, 86400. The default is 1200. Interval is calculated if time params are provided.
+ - networkIds (array): Networks for which information should be gathered.
+ - serials (array): Optional parameter to filter usage by appliance serial.
+ - portIds (array): Optional parameter to filter usage by port ID.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "monitor", "devices", "ports", "transceivers", "readings", "history", "byDevice"],
+ "operation": "getOrganizationApplianceDevicesPortsTransceiversReadingsHistoryByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/devices/ports/transceivers/readings/history/byDevice"
+
+ query_params = [
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "t0",
+ "t1",
+ "timespan",
+ "interval",
+ "networkIds",
+ "serials",
+ "portIds",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ "serials",
+ "portIds",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationApplianceDevicesPortsTransceiversReadingsHistoryByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
def getOrganizationApplianceDevicesRedundancyByNetwork(
self, organizationId: str, total_pages=1, direction="next", **kwargs
):
@@ -3628,6 +4057,116 @@ def getOrganizationApplianceFirewallMulticastForwardingByNetwork(
return self._session.get_pages(metadata, resource, params, total_pages, direction)
+ def getOrganizationApplianceInterfacesPacketsOverviewsByDevice(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **Returns packet counter overviews for all interfaces on Secure Routers in the organization, including totals and average rates by packet type over the requested timespan.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-interfaces-packets-overviews-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 14 days from today.
+ - t1 (string): The end of the timespan for the data. t1 can be a maximum of 14 days after t0.
+ - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 14 days. The default is 1 day.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 50. Default is 10.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - networkIds (array): Optional parameter to filter Secure Routers in the provided networks
+ - serials (array): Optional parameter to filter Secure Routers by their serial numbers
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "monitor", "interfaces", "packets", "overviews", "byDevice"],
+ "operation": "getOrganizationApplianceInterfacesPacketsOverviewsByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/interfaces/packets/overviews/byDevice"
+
+ query_params = [
+ "t0",
+ "t1",
+ "timespan",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "networkIds",
+ "serials",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationApplianceInterfacesPacketsOverviewsByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationApplianceRoutingVrfsSettings(self, organizationId: str):
+ """
+ **Return the VRF setting for an organization.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-routing-vrfs-settings
+
+ - organizationId (string): Organization ID
+ """
+
+ metadata = {
+ "tags": ["appliance", "configure", "routing", "vrfs", "settings"],
+ "operation": "getOrganizationApplianceRoutingVrfsSettings",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/routing/vrfs/settings"
+
+ return self._session.get(metadata, resource)
+
+ def updateOrganizationApplianceRoutingVrfsSettings(self, organizationId: str, enabled: bool, **kwargs):
+ """
+ **Update the VRF setting for an organization.**
+ https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-routing-vrfs-settings
+
+ - organizationId (string): Organization ID
+ - enabled (boolean): Boolean indicating whether VRFs are enabled for the organization.
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "routing", "vrfs", "settings"],
+ "operation": "updateOrganizationApplianceRoutingVrfsSettings",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/routing/vrfs/settings"
+
+ body_params = [
+ "enabled",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"updateOrganizationApplianceRoutingVrfsSettings: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.put(metadata, resource, payload)
+
def getOrganizationApplianceSecurityEvents(self, organizationId: str, total_pages=1, direction="next", **kwargs):
"""
**List the security events for an organization**
diff --git a/meraki/aio/api/camera.py b/meraki/aio/api/camera.py
index 14e3184f..98e5c8ac 100644
--- a/meraki/aio/api/camera.py
+++ b/meraki/aio/api/camera.py
@@ -175,7 +175,7 @@ def clipDeviceCamera(self, serial: str, startTimestamp: str, endTimestamp: str,
- serial (string): Serial
- startTimestamp (string): The start time for the clip. The timestamp is expected to be in ISO 8601 format.
- endTimestamp (string): The end time for the clip. The timestamp is expected to be in ISO 8601 format.
- - imagerId (integer): For multi-imager cameras, the imager ID to query. Defaults to '1' if omitted.
+ - imagerId (integer): The imager ID to query. Required for multi-imager cameras (must be between 1 and the imager count). For single-imager cameras, must be omitted or set to 0.
"""
kwargs.update(locals())
diff --git a/meraki/aio/api/devices.py b/meraki/aio/api/devices.py
index 956709e3..1c02af9c 100644
--- a/meraki/aio/api/devices.py
+++ b/meraki/aio/api/devices.py
@@ -105,6 +105,37 @@ def blinkDeviceLeds(self, serial: str, **kwargs):
return self._session.post(metadata, resource, payload)
+ def updateDeviceCellularGeolocations(self, serial: str, enabled: bool, **kwargs):
+ """
+ **Update the enablement of the geolocation feature for a device**
+ https://developer.cisco.com/meraki/api-v1/#!update-device-cellular-geolocations
+
+ - serial (string): Serial
+ - enabled (boolean): Required parameter for the state to update the geolocation settings to (true to enable, false to disable)
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["devices", "configure", "cellular", "geolocations"],
+ "operation": "updateDeviceCellularGeolocations",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/cellular/geolocations"
+
+ body_params = [
+ "enabled",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"updateDeviceCellularGeolocations: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
def getDeviceCellularSims(self, serial: str):
"""
**Return the SIM and APN configurations for a cellular device.**
@@ -129,7 +160,7 @@ def updateDeviceCellularSims(self, serial: str, **kwargs):
- serial (string): Serial
- sims (array): List of SIMs. If a SIM was previously configured and not specified in this request, it will remain unchanged.
- - simOrdering (array): Specifies the ordering of all SIMs for an MG: primary, secondary, and not-in-use (when applicable). It's required for devices with 3 or more SIMs and can be used in place of 'isPrimary' for dual-SIM devices. To indicate eSIM, use 'sim3'. Sim failover will occur only between primary and secondary sim slots.
+ - simOrdering (array): Specifies the ordering of all SIMs for an MG: primary, secondary, and not-in-use (when applicable). It's required for devices with 3 or more SIMs and can be used in place of 'isPrimary' for dual-SIM devices. Use the raw eSIM slot value for the device, such as 'sim2' or 'sim3'. Sim failover will occur only between primary and secondary sim slots.
- simFailover (object): SIM Failover settings.
"""
@@ -157,6 +188,50 @@ def updateDeviceCellularSims(self, serial: str, **kwargs):
return self._session.put(metadata, resource, payload)
+ def createDeviceCellularUplinksBandsMasksUpdate(self, serial: str, slot: str, type: str, masked: list, **kwargs):
+ """
+ **Update the cellular band masks for a device**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-cellular-uplinks-bands-masks-update
+
+ - serial (string): Serial
+ - slot (string): Required parameter for the SIM slot to update the cellular band mask for
+ - type (string): Required parameter for the signal type to update the cellular band mask for
+ - masked (array): Required parameter for the band identifiers to mask for the given SIM slot and signal type. For LTE use bands identifiers like '30', for 5G use band identifiers like 'n30', or use 'all' to mask all bands for that signal type. Maximum 256 bands.
+ """
+
+ kwargs = locals()
+
+ if "slot" in kwargs:
+ options = ["sim1", "sim2", "sim3"]
+ assert kwargs["slot"] in options, f'''"slot" cannot be "{kwargs["slot"]}", & must be set to one of: {options}'''
+ if "type" in kwargs:
+ options = ["5GNSA", "5GSA", "LTE"]
+ assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
+
+ metadata = {
+ "tags": ["devices", "configure", "cellular", "uplinks", "bands", "masks", "update"],
+ "operation": "createDeviceCellularUplinksBandsMasksUpdate",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/cellular/uplinks/bands/masks/update"
+
+ body_params = [
+ "slot",
+ "type",
+ "masked",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"createDeviceCellularUplinksBandsMasksUpdate: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
def getDeviceClients(self, serial: str, **kwargs):
"""
**List the clients of a device, up to a maximum of a month ago**
@@ -350,6 +425,7 @@ def createDeviceLiveToolsMacTable(self, serial: str, **kwargs):
https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-mac-table
- serial (string): Serial
+ - mac (string): Optional parameter to filter MAC table entries by MAC address. Must be a colon-delimited six-octet MAC address, for example '00:11:22:a0:b1:c2'. Matching is case-insensitive.
- callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
"""
@@ -363,6 +439,7 @@ def createDeviceLiveToolsMacTable(self, serial: str, **kwargs):
resource = f"/devices/{serial}/liveTools/macTable"
body_params = [
+ "mac",
"callback",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -604,6 +681,238 @@ def getDeviceLiveToolsPortsCycle(self, serial: str, id: str):
return self._session.get(metadata, resource)
+ def createDeviceLiveToolsPortsStatus(self, serial: str, **kwargs):
+ """
+ **Enqueue a job to retrieve port status for a device**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-ports-status
+
+ - serial (string): Serial
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["devices", "liveTools", "ports", "status"],
+ "operation": "createDeviceLiveToolsPortsStatus",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/ports/status"
+
+ body_params = [
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"createDeviceLiveToolsPortsStatus: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.post(metadata, resource, payload)
+
+ def getDeviceLiveToolsPortsStatus(self, serial: str, jobId: str):
+ """
+ **Return a port status live tool job.**
+ https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-ports-status
+
+ - serial (string): Serial
+ - jobId (string): Job ID
+ """
+
+ metadata = {
+ "tags": ["devices", "liveTools", "ports", "status"],
+ "operation": "getDeviceLiveToolsPortsStatus",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ jobId = urllib.parse.quote(str(jobId), safe="")
+ resource = f"/devices/{serial}/liveTools/ports/status/{jobId}"
+
+ return self._session.get(metadata, resource)
+
+ def createDeviceLiveToolsPowerUsage(self, serial: str, **kwargs):
+ """
+ **Enqueues a live tool job that retrieves details about a device's overall power usage**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-power-usage
+
+ - serial (string): Serial
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["devices", "liveTools", "power", "usage"],
+ "operation": "createDeviceLiveToolsPowerUsage",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/power/usage"
+
+ body_params = [
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"createDeviceLiveToolsPowerUsage: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.post(metadata, resource, payload)
+
+ def getDeviceLiveToolsPowerUsage(self, serial: str, jobId: str):
+ """
+ **Retrieve the status and results of a previously created live tool job fetching details about a device's overall power usage.**
+ https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-power-usage
+
+ - serial (string): Serial
+ - jobId (string): Job ID
+ """
+
+ metadata = {
+ "tags": ["devices", "liveTools", "power", "usage"],
+ "operation": "getDeviceLiveToolsPowerUsage",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ jobId = urllib.parse.quote(str(jobId), safe="")
+ resource = f"/devices/{serial}/liveTools/power/usage/{jobId}"
+
+ return self._session.get(metadata, resource)
+
+ def createDeviceLiveToolsRoutingTableLookup(self, serial: str, **kwargs):
+ """
+ **Enqueue a job to perform a routing table lookup request for a device**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-routing-table-lookup
+
+ - serial (string): Serial
+ - type (string): The type of route defined
+ - destination (object): The destination IP or subnet to lookup
+ - nextHop (object): The next hop to lookup
+ - vpn (object): VPN related search criteria
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ if "type" in kwargs:
+ options = [
+ "BGP",
+ "EIGRP",
+ "HSRP",
+ "IGRP",
+ "ISIS",
+ "LISP",
+ "NAT",
+ "ND",
+ "NHRP",
+ "OMP",
+ "OSPF",
+ "RIP",
+ "default WAN",
+ "direct",
+ "static",
+ ]
+ assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
+
+ metadata = {
+ "tags": ["devices", "liveTools", "routingTable", "lookups"],
+ "operation": "createDeviceLiveToolsRoutingTableLookup",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/lookups"
+
+ body_params = [
+ "type",
+ "destination",
+ "nextHop",
+ "vpn",
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"createDeviceLiveToolsRoutingTableLookup: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
+ def getDeviceLiveToolsRoutingTableLookup(self, serial: str, id: str):
+ """
+ **Return a routing table live tool lookup job for a device**
+ https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-routing-table-lookup
+
+ - serial (string): Serial
+ - id (string): ID
+ """
+
+ metadata = {
+ "tags": ["devices", "liveTools", "routingTable", "lookups"],
+ "operation": "getDeviceLiveToolsRoutingTableLookup",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ id = urllib.parse.quote(str(id), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/lookups/{id}"
+
+ return self._session.get(metadata, resource)
+
+ def createDeviceLiveToolsRoutingTableSummary(self, serial: str, **kwargs):
+ """
+ **Enqueue a routing table summary job for a device**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-routing-table-summary
+
+ - serial (string): Serial
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["devices", "liveTools", "routingTable", "summaries"],
+ "operation": "createDeviceLiveToolsRoutingTableSummary",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/summaries"
+
+ body_params = [
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"createDeviceLiveToolsRoutingTableSummary: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
+ def getDeviceLiveToolsRoutingTableSummary(self, serial: str, id: str):
+ """
+ **Return the status and result of a routing table summary job**
+ https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-routing-table-summary
+
+ - serial (string): Serial
+ - id (string): ID
+ """
+
+ metadata = {
+ "tags": ["devices", "liveTools", "routingTable", "summaries"],
+ "operation": "getDeviceLiveToolsRoutingTableSummary",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ id = urllib.parse.quote(str(id), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/summaries/{id}"
+
+ return self._session.get(metadata, resource)
+
def createDeviceLiveToolsThroughputTest(self, serial: str, **kwargs):
"""
**Enqueue a job to test a device throughput, the test will run for 10 secs to test throughput**
diff --git a/meraki/aio/api/networks.py b/meraki/aio/api/networks.py
index ffa0e04e..83c085c0 100644
--- a/meraki/aio/api/networks.py
+++ b/meraki/aio/api/networks.py
@@ -886,6 +886,37 @@ def removeNetworkDevices(self, networkId: str, serial: str, **kwargs):
return self._session.post(metadata, resource, payload)
+ def updateNetworkDevicesSyslogServers(self, networkId: str, servers: list, **kwargs):
+ """
+ **Updates the syslog servers configuration for a network.**
+ https://developer.cisco.com/meraki/api-v1/#!update-network-devices-syslog-servers
+
+ - networkId (string): Network ID
+ - servers (array): A list of the syslog servers for this network; suggested maximum array size is 10
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["networks", "configure", "devices", "syslog", "servers"],
+ "operation": "updateNetworkDevicesSyslogServers",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/devices/syslog/servers"
+
+ body_params = [
+ "servers",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"updateNetworkDevicesSyslogServers: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
def getNetworkEvents(self, networkId: str, total_pages=1, direction="prev", event_log_end_time=None, **kwargs):
"""
**List the events for the network**
@@ -2661,6 +2692,8 @@ def updateNetworkSnmp(self, networkId: str, **kwargs):
- access (string): The type of SNMP access. Can be one of 'none' (disabled), 'community' (V1/V2c), or 'users' (V3).
- communityString (string): The SNMP community string. Only relevant if 'access' is set to 'community'.
- users (array): The list of SNMP users. Only relevant if 'access' is set to 'users'.
+ - authentication (object): SNMPv3 authentication settings. Only relevant if 'access' is set to 'users'.
+ - privacy (object): SNMPv3 privacy settings. Only relevant if 'access' is set to 'users'.
"""
kwargs.update(locals())
@@ -2682,6 +2715,8 @@ def updateNetworkSnmp(self, networkId: str, **kwargs):
"access",
"communityString",
"users",
+ "authentication",
+ "privacy",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
diff --git a/meraki/aio/api/organizations.py b/meraki/aio/api/organizations.py
index 74a9a387..c043f253 100644
--- a/meraki/aio/api/organizations.py
+++ b/meraki/aio/api/organizations.py
@@ -98,6 +98,7 @@ def updateOrganization(self, organizationId: str, **kwargs):
- name (string): The name of the organization
- management (object): Information about the organization's management system
- api (object): API-specific settings
+ - privacy (object): Privacy-related settings for the organization.
"""
kwargs.update(locals())
@@ -113,6 +114,7 @@ def updateOrganization(self, organizationId: str, **kwargs):
"name",
"management",
"api",
+ "privacy",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -1467,7 +1469,7 @@ def dismissOrganizationAssuranceAlerts(self, organizationId: str, alertIds: list
https://developer.cisco.com/meraki/api-v1/#!dismiss-organization-assurance-alerts
- organizationId (string): Organization ID
- - alertIds (array): Array of alert IDs to dismiss
+ - alertIds (array): Array of alert IDs in this organization to dismiss. Missing or inaccessible alert IDs return 404.
"""
kwargs = locals()
@@ -1828,7 +1830,7 @@ def restoreOrganizationAssuranceAlerts(self, organizationId: str, alertIds: list
https://developer.cisco.com/meraki/api-v1/#!restore-organization-assurance-alerts
- organizationId (string): Organization ID
- - alertIds (array): Array of alert IDs to restore
+ - alertIds (array): Array of alert IDs in this organization to restore. Missing or inaccessible alert IDs return 404.
"""
kwargs = locals()
@@ -2747,6 +2749,58 @@ def getOrganizationDevicesCellularDataDevices(self, organizationId: str, total_p
return self._session.get_pages(metadata, resource, params, total_pages, direction)
+ def getOrganizationDevicesCellularDataProfiles(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **List cellular data management profiles in this organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-profiles
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - profileIds (array): Optional parameter to filter the results by Data Management Profile ID.
+ - serials (array): Devices to find Cellular Data Management Profiles for.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles"],
+ "operation": "getOrganizationDevicesCellularDataProfiles",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles"
+
+ query_params = [
+ "profileIds",
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "profileIds",
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularDataProfiles: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
def createOrganizationDevicesCellularDataProfile(
self, organizationId: str, name: str, description: str, rules: list, **kwargs
):
@@ -2786,16 +2840,18 @@ def createOrganizationDevicesCellularDataProfile(
return self._session.post(metadata, resource, payload)
- def getOrganizationDevicesCellularDataProfiles(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ def getOrganizationDevicesCellularDataProfilesAssignments(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
"""
- **List cellular data management profiles in this organization**
- https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-profiles
+ **List Cellular Data Management Profile assignments in this organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-profiles-assignments
- organizationId (string): Organization ID
- total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
- direction (string): direction to paginate, either "next" (default) or "prev" page
- - profileIds (array): Optional parameter to filter the results by Data Management Profile ID.
- - serials (array): Devices to find Cellular Data Management Profiles for.
+ - profileIds (array): Optional parameter to find assignments by Profile IDs. Maximum 1000 profile IDs.
+ - serials (array): Optional parameter to find assignments by Device Serials. Maximum 1000 serials.
- perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
- startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
- endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
@@ -2804,11 +2860,11 @@ def getOrganizationDevicesCellularDataProfiles(self, organizationId: str, total_
kwargs.update(locals())
metadata = {
- "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles"],
- "operation": "getOrganizationDevicesCellularDataProfiles",
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles", "assignments"],
+ "operation": "getOrganizationDevicesCellularDataProfilesAssignments",
}
organizationId = urllib.parse.quote(str(organizationId), safe="")
- resource = f"/organizations/{organizationId}/devices/cellular/data/profiles"
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/assignments"
query_params = [
"profileIds",
@@ -2833,29 +2889,76 @@ def getOrganizationDevicesCellularDataProfiles(self, organizationId: str, total_
invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
if invalid and self._session._logger:
self._session._logger.warning(
- f"getOrganizationDevicesCellularDataProfiles: ignoring unrecognized kwargs: {invalid}"
+ f"getOrganizationDevicesCellularDataProfilesAssignments: ignoring unrecognized kwargs: {invalid}"
)
return self._session.get_pages(metadata, resource, params, total_pages, direction)
- def deleteOrganizationDevicesCellularDataProfile(self, organizationId: str, profileId: str):
+ def batchOrganizationDevicesCellularDataProfilesAssignmentsCreate(self, organizationId: str, items: list, **kwargs):
"""
- **Delete a cellular data management profile from this organization**
- https://developer.cisco.com/meraki/api-v1/#!delete-organization-devices-cellular-data-profile
+ **Assign devices to a Cellular Data Management Profile in batch**
+ https://developer.cisco.com/meraki/api-v1/#!batch-organization-devices-cellular-data-profiles-assignments-create
- organizationId (string): Organization ID
- - profileId (string): Profile ID
+ - items (array): List of device-to-profile assignments to create.
"""
+ kwargs = locals()
+
metadata = {
- "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles"],
- "operation": "deleteOrganizationDevicesCellularDataProfile",
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles", "assignments"],
+ "operation": "batchOrganizationDevicesCellularDataProfilesAssignmentsCreate",
}
organizationId = urllib.parse.quote(str(organizationId), safe="")
- profileId = urllib.parse.quote(str(profileId), safe="")
- resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/{profileId}"
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/assignments/batchCreate"
- return self._session.delete(metadata, resource)
+ body_params = [
+ "items",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"batchOrganizationDevicesCellularDataProfilesAssignmentsCreate: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
+ def bulkOrganizationDevicesCellularDataProfilesAssignmentsDelete(self, organizationId: str, items: list, **kwargs):
+ """
+ **Unassign devices from a Cellular Data Management Profile in batch**
+ https://developer.cisco.com/meraki/api-v1/#!bulk-organization-devices-cellular-data-profiles-assignments-delete
+
+ - organizationId (string): Organization ID
+ - items (array): List of device-to-profile assignments to remove.
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles", "assignments"],
+ "operation": "bulkOrganizationDevicesCellularDataProfilesAssignmentsDelete",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/assignments/bulkDelete"
+
+ body_params = [
+ "items",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"bulkOrganizationDevicesCellularDataProfilesAssignmentsDelete: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
def updateOrganizationDevicesCellularDataProfile(self, organizationId: str, rules: list, profileId: str, **kwargs):
"""
@@ -2895,6 +2998,284 @@ def updateOrganizationDevicesCellularDataProfile(self, organizationId: str, rule
return self._session.put(metadata, resource, payload)
+ def deleteOrganizationDevicesCellularDataProfile(self, organizationId: str, profileId: str):
+ """
+ **Delete a cellular data management profile from this organization**
+ https://developer.cisco.com/meraki/api-v1/#!delete-organization-devices-cellular-data-profile
+
+ - organizationId (string): Organization ID
+ - profileId (string): Profile ID
+ """
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles"],
+ "operation": "deleteOrganizationDevicesCellularDataProfile",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/{profileId}"
+
+ return self._session.delete(metadata, resource)
+
+ def getOrganizationDevicesCellularDataUsageByDevice(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **List current cellular data usage for devices in this organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-usage-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - serials (array): Filter the results by device serials. Maximum 1000 serials.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "data", "usage", "byDevice"],
+ "operation": "getOrganizationDevicesCellularDataUsageByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/usage/byDevice"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularDataUsageByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesCellularDataUsageHistoryByDeviceByInterval(
+ self, organizationId: str, serials: list, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **List historical cellular data usage grouped by device and interval in this organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-usage-history-by-device-by-interval
+
+ - organizationId (string): Organization ID
+ - serials (array): Required parameter to filter the results by device serials. Maximum 10 serials.
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 10. Default is 5.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 366 days from today.
+ - t1 (string): The end of the timespan for the data. t1 can be a maximum of 31 days after t0.
+ - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 31 days. The default is 1 day. If interval is provided, the timespan will be autocalculated.
+ - interval (integer): The time interval in seconds for returned data. The valid intervals are: 300, 1200, 14400, 86400. The default is 86400. Interval is calculated if time params are provided.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "data", "usage", "history", "byDevice", "byInterval"],
+ "operation": "getOrganizationDevicesCellularDataUsageHistoryByDeviceByInterval",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/usage/history/byDevice/byInterval"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "t0",
+ "t1",
+ "timespan",
+ "interval",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularDataUsageHistoryByDeviceByInterval: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesCellularGeolocations(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **List the latest cellular geolocation telemetry for devices in an organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-geolocations
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - serials (array): Optional parameter to filter the results by device serials. Maximum 1000 serials.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "geolocations"],
+ "operation": "getOrganizationDevicesCellularGeolocations",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/geolocations"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularGeolocations: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesCellularUplinksBandsByDevice(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **List the latest cellular uplink signal information for devices in an organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-uplinks-bands-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - serials (array): Optional parameter to filter the results by device serials. Maximum 1000 serials.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "uplinks", "bands", "byDevice"],
+ "operation": "getOrganizationDevicesCellularUplinksBandsByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/uplinks/bands/byDevice"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularUplinksBandsByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesCellularUplinksTowersByDevice(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **List the latest cellular tower information for devices in an organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-uplinks-towers-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - serials (array): Optional parameter to filter the results by device serials. Maximum 1000 serials.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "uplinks", "towers", "byDevice"],
+ "operation": "getOrganizationDevicesCellularUplinksTowersByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/uplinks/towers/byDevice"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularUplinksTowersByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
def createOrganizationDevicesControllerMigration(self, organizationId: str, serials: list, target: str, **kwargs):
"""
**Migrate devices to another controller or management mode**
@@ -3811,6 +4192,106 @@ def getOrganizationDevicesStatusesOverview(self, organizationId: str, **kwargs):
return self._session.get(metadata, resource, params)
+ def getOrganizationDevicesSyslogServersByNetwork(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **Returns syslog servers configured for the networks within an organization.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-syslog-servers-by-network
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 10.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - networkIds (array): IDs of the networks for which to fetch syslog servers; suggested maximum array size is 100
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "syslog", "servers", "byNetwork"],
+ "operation": "getOrganizationDevicesSyslogServersByNetwork",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/syslog/servers/byNetwork"
+
+ query_params = [
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "networkIds",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesSyslogServersByNetwork: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesSyslogServersRolesByNetwork(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **Returns roles that can be assigned to a syslog server for a given network.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-syslog-servers-roles-by-network
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 10.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - networkIds (array): IDs of the networks for which to fetch valid syslog server roles; suggested maximum array size is 100
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "syslog", "servers", "roles", "byNetwork"],
+ "operation": "getOrganizationDevicesSyslogServersRolesByNetwork",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/syslog/servers/roles/byNetwork"
+
+ query_params = [
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "networkIds",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesSyslogServersRolesByNetwork: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
def getOrganizationDevicesSystemMemoryUsageHistoryByInterval(
self, organizationId: str, total_pages=1, direction="next", **kwargs
):
@@ -5092,6 +5573,7 @@ def updateOrganizationLoginSecurity(self, organizationId: str, **kwargs):
- enforceTwoFactorAuth (boolean): Boolean indicating whether users in this organization will be required to use an extra verification code when logging in to Dashboard. This code will be sent to their mobile phone via SMS, or can be generated by the authenticator application.
- enforceLoginIpRanges (boolean): Boolean indicating whether organization will restrict access to Dashboard (including the API) from certain IP addresses.
- loginIpRanges (array): List of acceptable IP ranges. Entries can be single IP addresses, IP address ranges, and CIDR subnets.
+ - enforceLockedIpSessions (boolean): Boolean indicating whether Dashboard sessions are locked to the IP address from which they were established. Only applicable to organizations that support locked-IP sessions; otherwise the parameter is ignored.
- apiAuthentication (object): Details for indicating whether organization will restrict access to API (but not Dashboard) to certain IP addresses.
"""
@@ -5118,6 +5600,7 @@ def updateOrganizationLoginSecurity(self, organizationId: str, **kwargs):
"enforceTwoFactorAuth",
"enforceLoginIpRanges",
"loginIpRanges",
+ "enforceLockedIpSessions",
"apiAuthentication",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -6258,22 +6741,26 @@ def getOrganizationPolicyObjects(self, organizationId: str, total_pages=1, direc
def createOrganizationPolicyObject(self, organizationId: str, name: str, category: str, type: str, **kwargs):
"""
- **Creates a new Policy Object.**
+ **Creates a new Policy Object**
https://developer.cisco.com/meraki/api-v1/#!create-organization-policy-object
- organizationId (string): Organization ID
- name (string): Name of a policy object, unique within the organization (alphanumeric, space, dash, or underscore characters only)
- category (string): Category of a policy object (one of: adaptivePolicy, network)
- - type (string): Type of a policy object (one of: adaptivePolicyIpv4Cidr, cidr, fqdn, ipAndMask)
+ - type (string): Type of a policy object (one of: adaptivePolicyIpv4Cidr, cidr, fqdn). DEPRECATED: `ipAndMask` is deprecated and will be removed in a future release. Use `cidr` instead.
- cidr (string): CIDR Value of a policy object (e.g. 10.11.12.1/24")
- fqdn (string): Fully qualified domain name of policy object (e.g. "example.com")
- - mask (string): Mask of a policy object (e.g. "255.255.0.0")
- - ip (string): IP Address of a policy object (e.g. "1.2.3.4")
+ - mask (string): Mask of a policy object (e.g. "255.255.0.0"). Used only with deprecated `type=ipAndMask`.
+ - ip (string): IP Address of a policy object (e.g. "1.2.3.4"). Used only with deprecated `type=ipAndMask`.
- groupIds (array): The IDs of policy object groups the policy object belongs to
"""
kwargs.update(locals())
+ if "type" in kwargs:
+ options = ["adaptivePolicyIpv4Cidr", "cidr", "fqdn"]
+ assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
+
metadata = {
"tags": ["organizations", "configure", "policyObjects"],
"operation": "createOrganizationPolicyObject",
@@ -6467,7 +6954,7 @@ def getOrganizationPolicyObject(self, organizationId: str, policyObjectId: str):
def updateOrganizationPolicyObject(self, organizationId: str, policyObjectId: str, **kwargs):
"""
- **Updates a Policy Object.**
+ **Updates a Policy Object**
https://developer.cisco.com/meraki/api-v1/#!update-organization-policy-object
- organizationId (string): Organization ID
@@ -6475,8 +6962,8 @@ def updateOrganizationPolicyObject(self, organizationId: str, policyObjectId: st
- name (string): Name of a policy object, unique within the organization (alphanumeric, space, dash, or underscore characters only)
- cidr (string): CIDR Value of a policy object (e.g. 10.11.12.1/24")
- fqdn (string): Fully qualified domain name of policy object (e.g. "example.com")
- - mask (string): Mask of a policy object (e.g. "255.255.0.0")
- - ip (string): IP Address of a policy object (e.g. "1.2.3.4")
+ - mask (string): Mask of a policy object (e.g. "255.255.0.0"). Used only with deprecated `type=ipAndMask`.
+ - ip (string): IP Address of a policy object (e.g. "1.2.3.4"). Used only with deprecated `type=ipAndMask`.
- groupIds (array): The IDs of policy object groups the policy object belongs to
"""
@@ -7059,17 +7546,16 @@ def getOrganizationSaseSites(self, organizationId: str, total_pages=1, direction
return self._session.get_pages(metadata, resource, params, total_pages, direction)
- def attachOrganizationSaseSites(self, organizationId: str, **kwargs):
+ def attachOrganizationSaseSites(self, organizationId: str, items: list, **kwargs):
"""
**Attach sites in this organization to Secure Access**
https://developer.cisco.com/meraki/api-v1/#!attach-organization-sase-sites
- organizationId (string): Organization ID
- items (array): List of Meraki SD-WAN sites with the associated regions to be attached.
- - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
"""
- kwargs.update(locals())
+ kwargs = locals()
metadata = {
"tags": ["organizations", "configure", "sase", "sites"],
@@ -7080,7 +7566,6 @@ def attachOrganizationSaseSites(self, organizationId: str, **kwargs):
body_params = [
"items",
- "callback",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -7159,7 +7644,6 @@ def detachOrganizationSaseSites(self, organizationId: str, **kwargs):
- organizationId (string): Organization ID
- items (array): List of Secure Access sites to be detached.
- - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
"""
kwargs.update(locals())
diff --git a/meraki/aio/api/switch.py b/meraki/aio/api/switch.py
index 27071f3b..21dcc763 100644
--- a/meraki/aio/api/switch.py
+++ b/meraki/aio/api/switch.py
@@ -25,7 +25,7 @@ def getDeviceSwitchPorts(self, serial: str):
def cycleDeviceSwitchPorts(self, serial: str, ports: list, **kwargs):
"""
- **Cycle a set of switch ports**
+ **Cycle a set of switch ports on non-Catalyst MS devices**
https://developer.cisco.com/meraki/api-v1/#!cycle-device-switch-ports
- serial (string): Serial
@@ -2225,6 +2225,41 @@ def createNetworkSwitchStack(self, networkId: str, name: str, serials: list, **k
return self._session.post(metadata, resource, payload)
+ def updateNetworkSwitchStack(self, networkId: str, switchStackId: str, **kwargs):
+ """
+ **Update a switch stack**
+ https://developer.cisco.com/meraki/api-v1/#!update-network-switch-stack
+
+ - networkId (string): Network ID
+ - switchStackId (string): Switch stack ID
+ - name (string): The name of the switch stack
+ - members (array): The complete list of switches that should be in the stack. Minimum 2 and maximum 8 members. Omitting this field leaves stack membership unchanged.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["switch", "configure", "stacks"],
+ "operation": "updateNetworkSwitchStack",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
+ resource = f"/networks/{networkId}/switch/stacks/{switchStackId}"
+
+ body_params = [
+ "name",
+ "members",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"updateNetworkSwitchStack: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
def getNetworkSwitchStack(self, networkId: str, switchStackId: str):
"""
**Show a switch stack**
diff --git a/meraki/aio/api/wireless.py b/meraki/aio/api/wireless.py
index 380d1dcc..b9ece30d 100644
--- a/meraki/aio/api/wireless.py
+++ b/meraki/aio/api/wireless.py
@@ -2274,7 +2274,7 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
- radiusServerTimeout (integer): The amount of time for which a RADIUS client waits for a reply from the RADIUS server (must be between 1-10 seconds).
- radiusServerAttemptsLimit (integer): The maximum number of transmit attempts after which a RADIUS server is failed over (must be between 1-5).
- radiusFallbackEnabled (boolean): Whether or not higher priority RADIUS servers should be retried after 60 seconds.
- - radiusRadsec (object): The current settings for RADIUS RADSec
+ - radiusRadsec (object): The current settings for RADIUS RadSec
- radiusCoaEnabled (boolean): If true, Meraki devices will act as a RADIUS Dynamic Authorization Server and will respond to RADIUS Change-of-Authorization and Disconnect messages sent by the RADIUS server.
- radiusFailoverPolicy (string): This policy determines how authentication requests should be handled in the event that all of the configured RADIUS servers are unreachable ('Deny access' or 'Allow access')
- radiusLoadBalancingPolicy (string): This policy determines which RADIUS server will be contacted first in an authentication attempt and the ordering of any necessary retry attempts ('Strict priority order' or 'Round robin')
@@ -2312,6 +2312,7 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
- dnsRewrite (object): DNS servers rewrite settings
- speedBurst (object): The SpeedBurst setting for this SSID'
- namedVlans (object): Named VLAN settings.
+ - security (object): Security settings for the SSID
- localAuthFallback (object): The current configuration for Local Authentication Fallback. Enables the Access Point (AP) to store client authentication data for a specified duration that can be adjusted as needed.
- radiusAccountingStartDelay (integer): The delay (in seconds) before sending the first RADIUS accounting start message. Must be between 0 and 60 seconds.
"""
@@ -2463,6 +2464,7 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
"dnsRewrite",
"speedBurst",
"namedVlans",
+ "security",
"localAuthFallback",
"radiusAccountingStartDelay",
]
@@ -3103,6 +3105,7 @@ def updateNetworkWirelessSsidSplashSettings(self, networkId: str, number: str, *
- redirectUrl (string): The custom redirect URL where the users will go after the splash page.
- useRedirectUrl (boolean): The Boolean indicating whether the the user will be redirected to the custom redirect URL after the splash page. A custom redirect URL must be set if this is true.
- welcomeMessage (string): The welcome message for the users on the splash page.
+ - userConsent (object): User consent settings
- themeId (string): The id of the selected splash theme.
- splashLogo (object): The logo used in the splash page.
- splashImage (object): The image used in the splash page.
@@ -3144,6 +3147,7 @@ def updateNetworkWirelessSsidSplashSettings(self, networkId: str, number: str, *
"redirectUrl",
"useRedirectUrl",
"welcomeMessage",
+ "userConsent",
"themeId",
"splashLogo",
"splashImage",
@@ -4235,7 +4239,7 @@ def deleteOrganizationWirelessDevicesProvisioningDeployment(self, organizationId
def getOrganizationWirelessDevicesRadsecCertificatesAuthorities(self, organizationId: str, **kwargs):
"""
- **Query for details on the organization's RADSEC device Certificate Authority certificates (CAs)**
+ **Query for details on the organization's RadSec device Certificate Authority certificates (CAs)**
https://developer.cisco.com/meraki/api-v1/#!get-organization-wireless-devices-radsec-certificates-authorities
- organizationId (string): Organization ID
@@ -4276,7 +4280,7 @@ def getOrganizationWirelessDevicesRadsecCertificatesAuthorities(self, organizati
def updateOrganizationWirelessDevicesRadsecCertificatesAuthorities(self, organizationId: str, **kwargs):
"""
- **Update an organization's RADSEC device Certificate Authority (CA) state**
+ **Update an organization's RadSec device Certificate Authority (CA) state**
https://developer.cisco.com/meraki/api-v1/#!update-organization-wireless-devices-radsec-certificates-authorities
- organizationId (string): Organization ID
@@ -4311,7 +4315,7 @@ def updateOrganizationWirelessDevicesRadsecCertificatesAuthorities(self, organiz
def createOrganizationWirelessDevicesRadsecCertificatesAuthority(self, organizationId: str):
"""
- **Create an organization's RADSEC device Certificate Authority (CA)**
+ **Create an organization's RadSec device Certificate Authority (CA)**
https://developer.cisco.com/meraki/api-v1/#!create-organization-wireless-devices-radsec-certificates-authority
- organizationId (string): Organization ID
@@ -4328,7 +4332,7 @@ def createOrganizationWirelessDevicesRadsecCertificatesAuthority(self, organizat
def getOrganizationWirelessDevicesRadsecCertificatesAuthoritiesCrls(self, organizationId: str, **kwargs):
"""
- **Query for certificate revocation list (CRL) for the organization's RADSEC device Certificate Authorities (CAs).**
+ **Query for certificate revocation list (CRL) for the organization's RadSec device Certificate Authorities (CAs).**
https://developer.cisco.com/meraki/api-v1/#!get-organization-wireless-devices-radsec-certificates-authorities-crls
- organizationId (string): Organization ID
@@ -4369,7 +4373,7 @@ def getOrganizationWirelessDevicesRadsecCertificatesAuthoritiesCrls(self, organi
def getOrganizationWirelessDevicesRadsecCertificatesAuthoritiesCrlsDeltas(self, organizationId: str, **kwargs):
"""
- **Query for all delta certificate revocation list (CRL) for the organization's RADSEC device Certificate Authority (CA) with the given id.**
+ **Query for all delta certificate revocation list (CRL) for the organization's RadSec device Certificate Authority (CA) with the given id.**
https://developer.cisco.com/meraki/api-v1/#!get-organization-wireless-devices-radsec-certificates-authorities-crls-deltas
- organizationId (string): Organization ID
diff --git a/meraki/aio/rest_session.py b/meraki/aio/rest_session.py
deleted file mode 100644
index d5a2b6b1..00000000
--- a/meraki/aio/rest_session.py
+++ /dev/null
@@ -1,497 +0,0 @@
-import asyncio
-import json
-import random
-import ssl
-import urllib.parse
-from datetime import datetime
-
-import aiohttp
-
-from meraki._version import __version__
-from meraki.common import (
- check_python_version,
- reject_v0_base_url,
- validate_user_agent,
-)
-from meraki.config import (
- ACTION_BATCH_RETRY_WAIT_TIME,
- AIO_MAXIMUM_CONCURRENT_REQUESTS,
- BE_GEO_ID,
- CERTIFICATE_PATH,
- DEFAULT_BASE_URL,
- MAXIMUM_RETRIES,
- MERAKI_PYTHON_SDK_CALLER,
- NETWORK_DELETE_RETRY_WAIT_TIME,
- NGINX_429_RETRY_WAIT_TIME,
- REQUESTS_PROXY,
- RETRY_4XX_ERROR,
- RETRY_4XX_ERROR_WAIT_TIME,
- SIMULATE_API_CALLS,
- SINGLE_REQUEST_TIMEOUT,
- USE_ITERATOR_FOR_GET_PAGES,
- WAIT_ON_RATE_LIMIT,
-)
-from meraki.exceptions import APIError, AsyncAPIError
-
-
-# Main module interface
-class AsyncRestSession:
- def __init__(
- self,
- logger,
- api_key,
- base_url=DEFAULT_BASE_URL,
- single_request_timeout=SINGLE_REQUEST_TIMEOUT,
- certificate_path=CERTIFICATE_PATH,
- requests_proxy=REQUESTS_PROXY,
- wait_on_rate_limit=WAIT_ON_RATE_LIMIT,
- nginx_429_retry_wait_time=NGINX_429_RETRY_WAIT_TIME,
- action_batch_retry_wait_time=ACTION_BATCH_RETRY_WAIT_TIME,
- network_delete_retry_wait_time=NETWORK_DELETE_RETRY_WAIT_TIME,
- retry_4xx_error=RETRY_4XX_ERROR,
- retry_4xx_error_wait_time=RETRY_4XX_ERROR_WAIT_TIME,
- maximum_retries=MAXIMUM_RETRIES,
- simulate=SIMULATE_API_CALLS,
- be_geo_id=BE_GEO_ID,
- caller=MERAKI_PYTHON_SDK_CALLER,
- use_iterator_for_get_pages=USE_ITERATOR_FOR_GET_PAGES,
- maximum_concurrent_requests=AIO_MAXIMUM_CONCURRENT_REQUESTS,
- validate_kwargs=False,
- ):
- super().__init__()
-
- # Initialize attributes and properties
- self._version = __version__
- self._api_key = str(api_key)
- self._base_url = str(base_url)
- self._single_request_timeout = single_request_timeout
- self._certificate_path = certificate_path
- self._requests_proxy = requests_proxy
- self._wait_on_rate_limit = wait_on_rate_limit
- self._nginx_429_retry_wait_time = nginx_429_retry_wait_time
- self._action_batch_retry_wait_time = action_batch_retry_wait_time
- self._network_delete_retry_wait_time = network_delete_retry_wait_time
- self._retry_4xx_error = retry_4xx_error
- self._retry_4xx_error_wait_time = retry_4xx_error_wait_time
- self._maximum_retries = maximum_retries
- self._simulate = simulate
- self._concurrent_requests_semaphore = asyncio.Semaphore(maximum_concurrent_requests)
- self._be_geo_id = be_geo_id
- self._caller = caller
- self.use_iterator_for_get_pages = use_iterator_for_get_pages
- self._validate_kwargs = validate_kwargs
-
- # Check minimum Python version
- check_python_version()
-
- # Check base URL
- reject_v0_base_url(self)
-
- # Update the headers for the session
- self._headers = {
- "Authorization": "Bearer " + self._api_key,
- "Content-Type": "application/json",
- "User-Agent": f"python-meraki/aio-{self._version} " + validate_user_agent(self._be_geo_id, self._caller),
- }
- if self._certificate_path:
- self._sslcontext = ssl.create_default_context()
- self._sslcontext.load_verify_locations(certificate_path)
-
- # Initialize a new `aiohttp` session
- self._req_session = aiohttp.ClientSession(
- headers=self._headers,
- timeout=aiohttp.ClientTimeout(total=single_request_timeout),
- )
-
- # Log API calls
- self._logger = logger
- self._parameters = {"version": self._version}
- self._parameters.update(locals())
- self._parameters.pop("self")
- self._parameters.pop("logger")
- self._parameters.pop("__class__")
- self._parameters["api_key"] = "*" * 36 + self._api_key[-4:]
- if self._logger:
- self._logger.info(f"Meraki dashboard API session initialized with these parameters: {self._parameters}")
-
- @property
- def use_iterator_for_get_pages(self):
- return self._use_iterator_for_get_pages
-
- @use_iterator_for_get_pages.setter
- def use_iterator_for_get_pages(self, value):
- if value:
- self.get_pages = self._get_pages_iterator
- else:
- self.get_pages = self._get_pages_legacy
-
- self._use_iterator_for_get_pages = value
-
- async def request(self, metadata, method, url, **kwargs):
- return await self._request(metadata, method, url, allow_redirects=False, **kwargs)
-
- async def _request(self, metadata, method, url, **kwargs):
- # Metadata on endpoint
- tag = metadata["tags"][0]
- operation = metadata["operation"]
-
- # Update request kwargs with session defaults
- if self._certificate_path:
- kwargs.setdefault("ssl", self._sslcontext)
- if self._requests_proxy:
- kwargs.setdefault("proxy", self._requests_proxy)
- kwargs.setdefault("timeout", self._single_request_timeout)
-
- # Ensure proper base URL
- allowed_domains = ["meraki.com", "meraki.cn"]
-
- # aiohttp manipulates URLs as instances of the yarl.URL class
- if not isinstance(url, str):
- url = str(url)
-
- parsed_url = urllib.parse.urlparse(url)
-
- if any(domain in parsed_url.netloc for domain in allowed_domains):
- abs_url = url
- else:
- abs_url = self._base_url + url
-
- # Set maximum number of retries
- retries = self._maximum_retries
-
- # Option to simulate non-safe API calls without actually sending them
- if self._logger:
- self._logger.debug(metadata)
- if self._simulate and method != "GET":
- if self._logger:
- self._logger.info(f"{tag}, {operation} > {abs_url} - SIMULATED")
- return None
- else:
- response = None
- message = None
- for _attempt in range(retries):
- # Make sure that the response object gets closed during retries
- if response:
- response.release()
- response = None
-
- # Acquire semaphore only for the HTTP call, not retry waits
- async with self._concurrent_requests_semaphore:
- try:
- if self._logger:
- self._logger.info(f"{method} {abs_url}")
- response = await self._req_session.request(method, abs_url, **kwargs)
- reason = response.reason if response.reason else None
- status = response.status
- except Exception as e:
- if self._logger:
- self._logger.warning(f"{tag}, {operation} > {abs_url} - {e}, retrying in 1 second")
- await asyncio.sleep(1)
- continue
-
- if 200 <= status < 300:
- if "page" in metadata:
- counter = metadata["page"]
- if self._logger:
- self._logger.info(f"{tag}, {operation}; page {counter} > {abs_url} - {status} {reason}")
- else:
- if self._logger:
- self._logger.info(f"{tag}, {operation} > {abs_url} - {status} {reason}")
- # For non-empty response to GET, ensure valid JSON
- try:
- if method == "GET":
- await response.json(content_type=None)
- return response
- except (
- json.decoder.JSONDecodeError,
- aiohttp.client_exceptions.ContentTypeError,
- ) as e:
- if self._logger:
- self._logger.warning(f"{tag}, {operation} > {abs_url} - {e}, retrying in 1 second")
- await asyncio.sleep(1)
- # Handle 3XX redirects automatically
- elif 300 <= status < 400:
- abs_url = response.headers["Location"]
- substring = "meraki.com/api/v"
- if substring not in abs_url:
- substring = "meraki.cn/api/v"
- self._base_url = abs_url[: abs_url.find(substring) + len(substring) + 1]
- # Rate limit 429 errors
- elif status == 429:
- if "Retry-After" in response.headers:
- wait = int(response.headers["Retry-After"])
- else:
- wait = min(
- (2**_attempt) * (1 + random.random()),
- self._nginx_429_retry_wait_time,
- )
- if self._logger:
- self._logger.warning(f"{tag}, {operation} > {abs_url} - {status} {reason}, retrying in {wait} seconds")
- await asyncio.sleep(wait)
- # 5XX errors
- elif status >= 500:
- if self._logger:
- self._logger.warning(f"{tag}, {operation} > {abs_url} - {status} {reason}, retrying in 1 second")
- await asyncio.sleep(1)
- # 4XX errors
- else:
- try:
- message = await response.json(content_type=None)
- if isinstance(message, dict):
- message_is_dict = True
- else:
- message_is_dict = False
- except (
- json.decoder.JSONDecodeError,
- aiohttp.client_exceptions.ContentTypeError,
- ):
- message_is_dict = False
- try:
- message = (await response.text())[:100]
- except Exception:
- message = None
-
- # Check for specific concurrency errors
- network_delete_concurrency_error_text = "This may be due to concurrent requests to delete networks."
- action_batch_concurrency_error = {
- "errors": [
- "Too many concurrently executing batches. Maximum is 5 confirmed but not yet executed batches."
- ]
- }
- # Check specifically for network delete concurrency error
- if (
- message_is_dict
- and "errors" in message.keys()
- and network_delete_concurrency_error_text in message["errors"][0]
- ):
- wait = random.randint(15, self._network_delete_retry_wait_time)
- if self._logger:
- self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
- await asyncio.sleep(wait)
- retries -= 1
- if retries == 0:
- raise APIError(metadata, response)
- # Check specifically for action batch concurrency error
- elif message == action_batch_concurrency_error:
- wait = self._action_batch_retry_wait_time
- if self._logger:
- self._logger.warning(
- f"{tag}, {operation} > {abs_url} - {status} {reason}, retrying in {wait} seconds"
- )
- await asyncio.sleep(wait)
-
- elif self._retry_4xx_error:
- wait = random.randint(1, self._retry_4xx_error_wait_time)
- if self._logger:
- self._logger.warning(
- f"{tag}, {operation} > {abs_url} - {status} {reason}, retrying in {wait} seconds"
- )
- await asyncio.sleep(wait)
-
- # All other client-side errors
- else:
- if self._logger:
- self._logger.error(f"{tag}, {operation} > {abs_url} - {status} {reason}, {message}")
- raise AsyncAPIError(metadata, response, message)
- raise AsyncAPIError(metadata, response, "Reached retry limit: " + str(message))
-
- async def get(self, metadata, url, params=None):
- metadata["method"] = "GET"
- metadata["url"] = url
- metadata["params"] = params
- async with await self.request(metadata, "GET", url, params=params) as response:
- return await response.json(content_type=None)
-
- async def get_pages(
- self,
- metadata,
- url,
- params=None,
- total_pages=-1,
- direction="next",
- event_log_end_time=None,
- ):
- pass
-
- async def _download_page(self, request):
- response = await request
- result = await response.json(content_type=None)
- return response, result
-
- async def _get_pages_iterator(
- self,
- metadata,
- url,
- params=None,
- total_pages=-1,
- direction="next",
- event_log_end_time=None,
- ):
- if isinstance(total_pages, str) and total_pages.lower() == "all":
- total_pages = -1
- elif isinstance(total_pages, str) and total_pages.isnumeric():
- total_pages = int(total_pages)
- metadata["page"] = 1
-
- request_task = asyncio.create_task(self._download_page(self.request(metadata, "GET", url, params=params)))
-
- # Get additional pages if more than one requested
- while total_pages != 0:
- response, results = await request_task
- links = response.links
-
- # GET the subsequent page
- if direction == "next" and "next" in links:
- # Prevent getNetworkEvents from infinite loop as time goes forward
- if metadata["operation"] == "getNetworkEvents":
- starting_after = urllib.parse.unquote(str(links["next"]["url"]).split("startingAfter=")[1])
- delta = datetime.utcnow() - datetime.fromisoformat(starting_after[:-1])
- # Break out of loop if startingAfter returned from next link is within 5 minutes of current time
- if delta.total_seconds() < 300:
- break
- # Or if next page is past the specified window's end time
- elif event_log_end_time and starting_after > event_log_end_time:
- break
-
- metadata["page"] += 1
- nextlink = links["next"]["url"]
- elif direction == "prev" and "prev" in links:
- # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0)
- if metadata["operation"] == "getNetworkEvents":
- ending_before = urllib.parse.unquote(str(links["prev"]["url"]).split("endingBefore=")[1])
- # Break out of loop if endingBefore returned from prev link is before 2014
- if ending_before < "2014-01-01":
- break
-
- metadata["page"] += 1
- nextlink = links["prev"]["url"]
- else:
- total_pages = 1
-
- response.release()
-
- total_pages = total_pages - 1
-
- if total_pages != 0:
- request_task = asyncio.create_task(self._download_page(self.request(metadata, "GET", nextlink)))
-
- return_items = []
- # just prepare the list
- if isinstance(results, list):
- return_items = results
- elif isinstance(results, dict) and "items" in results:
- return_items = results["items"]
- # For event log endpoint
- elif isinstance(results, dict):
- if direction == "next":
- return_items = results["events"][::-1]
- else:
- return_items = results["events"]
-
- for item in return_items:
- yield item
-
- async def _get_pages_legacy(
- self,
- metadata,
- url,
- params=None,
- total_pages=-1,
- direction="next",
- event_log_end_time=None,
- ):
- if isinstance(total_pages, str) and total_pages.lower() == "all":
- total_pages = -1
- elif isinstance(total_pages, str) and total_pages.isnumeric():
- total_pages = int(total_pages)
- metadata["page"] = 1
-
- async with await self.request(metadata, "GET", url, params=params) as response:
- results = await response.json(content_type=None)
-
- # For event log endpoint when using 'next' direction, so results/events are sorted chronologically
- if isinstance(results, dict) and metadata["operation"] == "getNetworkEvents" and direction == "next":
- results["events"] = results["events"][::-1]
-
- links = response.links
-
- # Get additional pages if more than one requested
- while total_pages != 1:
- # GET the subsequent page
- if direction == "next" and "next" in links:
- # Prevent getNetworkEvents from infinite loop as time goes forward
- if metadata["operation"] == "getNetworkEvents":
- starting_after = urllib.parse.unquote(str(links["next"]["url"]).split("startingAfter=")[1])
- delta = datetime.utcnow() - datetime.fromisoformat(starting_after[:-1])
- # Break out of loop if startingAfter returned from next link is within 5 minutes of current time
- if delta.total_seconds() < 300:
- break
- # Or if next page is past the specified window's end time
- elif event_log_end_time and starting_after > event_log_end_time:
- break
-
- metadata["page"] += 1
- nextlink = links["next"]["url"]
- elif direction == "prev" and "prev" in links:
- # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0)
- if metadata["operation"] == "getNetworkEvents":
- ending_before = urllib.parse.unquote(str(links["prev"]["url"]).split("endingBefore=")[1])
- # Break out of loop if endingBefore returned from prev link is before 2014
- if ending_before < "2014-01-01":
- break
-
- metadata["page"] += 1
- nextlink = links["prev"]["url"]
- else:
- break
-
- async with await self.request(metadata, "GET", nextlink) as response:
- links = response.links
- # Append that page's results, depending on the endpoint
- if isinstance(results, list):
- results.extend(await response.json(content_type=None))
- elif isinstance(results, dict) and "items" in results:
- json_response = await response.json(content_type=None)
- results["items"].extend(json_response["items"])
- if "meta" in results:
- results["meta"]["counts"]["items"]["remaining"] = json_response["meta"]["counts"]["items"]["remaining"]
- # For event log endpoint
- elif isinstance(results, dict):
- json_response = await response.json(content_type=None)
- start = json_response["pageStartAt"]
- end = json_response["pageEndAt"]
- events = json_response["events"]
- if direction == "next":
- events = events[::-1]
- if start < results["pageStartAt"]:
- results["pageStartAt"] = start
- if end > results["pageEndAt"]:
- results["pageEndAt"] = end
- results["events"].extend(events)
-
- total_pages = total_pages - 1
-
- return results
-
- async def post(self, metadata, url, json=None):
- metadata["method"] = "POST"
- metadata["url"] = url
- metadata["json"] = json
- async with await self.request(metadata, "POST", url, json=json) as response:
- return await response.json(content_type=None)
-
- async def put(self, metadata, url, json=None):
- metadata["method"] = "PUT"
- metadata["url"] = url
- metadata["json"] = json
- async with await self.request(metadata, "PUT", url, json=json) as response:
- return await response.json(content_type=None)
-
- async def delete(self, metadata, url):
- metadata["method"] = "DELETE"
- metadata["url"] = url
- async with await self.request(metadata, "DELETE", url):
- return None
-
- async def close(self):
- await self._req_session.close()
diff --git a/meraki/api/appliance.py b/meraki/api/appliance.py
index 620c45eb..82023191 100644
--- a/meraki/api/appliance.py
+++ b/meraki/api/appliance.py
@@ -23,9 +23,50 @@ def getDeviceApplianceDhcpSubnets(self, serial: str):
return self._session.get(metadata, resource)
+ def createDeviceApplianceInterfacesPortsUpdate(self, serial: str, **kwargs):
+ """
+ **Update configurations for an appliance's specified port**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-appliance-interfaces-ports-update
+
+ - serial (string): Serial
+ - interface (object): The interface tuple used to identify the port
+ - enabled (boolean): Indicates whether the port is enabled
+ - personality (object): Describes the port's configurability
+ - uplink (object): The port's settings when in WAN mode
+ - downlink (object): The port's VLAN settings when in LAN mode
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "interfaces", "ports", "update"],
+ "operation": "createDeviceApplianceInterfacesPortsUpdate",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/appliance/interfaces/ports/update"
+
+ body_params = [
+ "interface",
+ "enabled",
+ "personality",
+ "uplink",
+ "downlink",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"createDeviceApplianceInterfacesPortsUpdate: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
def getDeviceAppliancePerformance(self, serial: str, **kwargs):
"""
- **Return the performance score for a single MX**
+ **Return the performance score for a single Secure Appliance or Secure Router**
https://developer.cisco.com/meraki/api-v1/#!get-device-appliance-performance
- serial (string): Serial
@@ -1038,6 +1079,93 @@ def updateNetworkApplianceFirewallSettings(self, networkId: str, **kwargs):
return self._session.put(metadata, resource, payload)
+ def createNetworkApplianceInterfacesL3(self, networkId: str, ipv4: dict, **kwargs):
+ """
+ **Create wired L3 interface**
+ https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-interfaces-l-3
+
+ - networkId (string): Network ID
+ - ipv4 (object): IPv4 configuration
+ - port (object): Port configuration
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "interfaces", "l3"],
+ "operation": "createNetworkApplianceInterfacesL3",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/interfaces/l3"
+
+ body_params = [
+ "port",
+ "ipv4",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"createNetworkApplianceInterfacesL3: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.post(metadata, resource, payload)
+
+ def updateNetworkApplianceInterfacesL3(self, networkId: str, interfaceId: str, **kwargs):
+ """
+ **Update wired L3 interface**
+ https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-interfaces-l-3
+
+ - networkId (string): Network ID
+ - interfaceId (string): Interface ID
+ - port (object): Port configuration
+ - ipv4 (object): IPv4 configuration
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "interfaces", "l3"],
+ "operation": "updateNetworkApplianceInterfacesL3",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
+ resource = f"/networks/{networkId}/appliance/interfaces/l3/{interfaceId}"
+
+ body_params = [
+ "port",
+ "ipv4",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"updateNetworkApplianceInterfacesL3: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
+ def deleteNetworkApplianceInterfacesL3(self, networkId: str, interfaceId: str):
+ """
+ **Delete wired L3 interface**
+ https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-interfaces-l-3
+
+ - networkId (string): Network ID
+ - interfaceId (string): Interface ID
+ """
+
+ metadata = {
+ "tags": ["appliance", "configure", "interfaces", "l3"],
+ "operation": "deleteNetworkApplianceInterfacesL3",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
+ resource = f"/networks/{networkId}/appliance/interfaces/l3/{interfaceId}"
+
+ return self._session.delete(metadata, resource)
+
def getNetworkAppliancePorts(self, networkId: str):
"""
**List per-port VLAN settings for all ports of a secure router or security appliance.**
@@ -1087,6 +1215,7 @@ def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs):
- vlan (integer): Native VLAN when the port is in Trunk mode. Access VLAN when the port is in Access mode.
- allowedVlans (string): Comma-delimited list of VLAN IDs (e.g. '2,15') for all devices. Secure Routers also support VLAN ranges (e.g. '2-10,15'). Use 'all' to permit all VLANs on the port.
- accessPolicy (string): The name of the policy. Only applicable to Access ports. Valid values are: 'open', '8021x-radius', 'mac-radius', 'hybris-radius' for MX64 or Z3 or any MX supporting the per port authentication feature. Otherwise, 'open' is the only valid value and 'open' is the default value if the field is missing.
+ - sgt (object): Security Group Tag settings for the port.
"""
kwargs.update(locals())
@@ -1106,6 +1235,7 @@ def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs):
"vlan",
"allowedVlans",
"accessPolicy",
+ "sgt",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -2378,6 +2508,134 @@ def disconnectNetworkApplianceUmbrellaAccount(self, networkId: str):
return self._session.post(metadata, resource)
+ def exclusionsNetworkApplianceUmbrellaDomains(self, networkId: str, domains: list, **kwargs):
+ """
+ **Specify one or more domain names to be excluded from being routed to Cisco Umbrella.**
+ https://developer.cisco.com/meraki/api-v1/#!exclusions-network-appliance-umbrella-domains
+
+ - networkId (string): Network ID
+ - domains (array): Domain names to exclude from Umbrella DNS routing (e.g., 'example.com', 'corp.example.org'). Standard FQDNs only — wildcards are not supported. Values are lowercased before saving. Each call replaces the full exclusion list.
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "umbrella", "domains"],
+ "operation": "exclusionsNetworkApplianceUmbrellaDomains",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/domains/exclusions"
+
+ body_params = [
+ "domains",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"exclusionsNetworkApplianceUmbrellaDomains: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.put(metadata, resource, payload)
+
+ def addNetworkApplianceUmbrellaPolicies(self, networkId: str, policy: dict, **kwargs):
+ """
+ **Add one Cisco Umbrella DNS security policy to an MX network by policy ID**
+ https://developer.cisco.com/meraki/api-v1/#!add-network-appliance-umbrella-policies
+
+ - networkId (string): Network ID
+ - policy (object): Umbrella policy to add
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "umbrella", "policies"],
+ "operation": "addNetworkApplianceUmbrellaPolicies",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/policies/add"
+
+ body_params = [
+ "policy",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"addNetworkApplianceUmbrellaPolicies: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.post(metadata, resource, payload)
+
+ def removeNetworkApplianceUmbrellaPolicies(self, networkId: str, policy: dict, **kwargs):
+ """
+ **Remove one Cisco Umbrella DNS security policy from an MX network by policy ID**
+ https://developer.cisco.com/meraki/api-v1/#!remove-network-appliance-umbrella-policies
+
+ - networkId (string): Network ID
+ - policy (object): Umbrella policy to remove
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "umbrella", "policies"],
+ "operation": "removeNetworkApplianceUmbrellaPolicies",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/policies/remove"
+
+ body_params = [
+ "policy",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"removeNetworkApplianceUmbrellaPolicies: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
+ def protectionNetworkApplianceUmbrella(self, networkId: str, enabled: bool, **kwargs):
+ """
+ **Enable or disable umbrella protection for an appliance network**
+ https://developer.cisco.com/meraki/api-v1/#!protection-network-appliance-umbrella
+
+ - networkId (string): Network ID
+ - enabled (boolean): Enable or disable umbrella protection
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "umbrella"],
+ "operation": "protectionNetworkApplianceUmbrella",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/protection"
+
+ body_params = [
+ "enabled",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"protectionNetworkApplianceUmbrella: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
def updateNetworkApplianceUplinksNat(self, networkId: str, uplinks: list, **kwargs):
"""
**Update uplink NAT settings of the specified network**
@@ -2415,7 +2673,7 @@ def getNetworkApplianceUplinksUsageHistory(self, networkId: str, **kwargs):
https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-uplinks-usage-history
- networkId (string): Network ID
- - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 365 days from today.
+ - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 30 days from today.
- t1 (string): The end of the timespan for the data. t1 can be a maximum of 31 days after t0.
- timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 31 days. The default is 10 minutes.
- resolution (integer): The time resolution in seconds for returned data. The valid resolutions are: 60, 300, 600, 1800, 3600, 86400. The default is 60.
@@ -2488,6 +2746,8 @@ def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwarg
- dhcpBootNextServer (string): DHCP boot option to direct boot clients to the server to load the boot file from
- dhcpBootFilename (string): DHCP boot option for boot filename
- dhcpOptions (array): The list of DHCP options that will be included in DHCP responses. Each object in the list should have "code", "type", and "value" properties.
+ - sgt (object): Security Group Tag settings for the VLAN.
+ - vrf (object): VRF configuration on the VLAN.
- uplinks (array): Per-uplink NAT exception override configuration on the VLAN. Applicable only for networks that support NAT exceptions.
"""
@@ -2534,6 +2794,8 @@ def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwarg
"dhcpBootNextServer",
"dhcpBootFilename",
"dhcpOptions",
+ "sgt",
+ "vrf",
"uplinks",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -2640,6 +2902,8 @@ def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs):
- mask (integer): Mask used for the subnet of all bound to the template networks. Applicable only for template network.
- ipv6 (object): IPv6 configuration on the VLAN
- mandatoryDhcp (object): Mandatory DHCP will enforce that clients connecting to this VLAN must use the IP address assigned by the DHCP server. Clients who use a static IP address won't be able to associate. Only available on firmware versions 17.0 and above
+ - sgt (object): Security Group Tag settings for the VLAN.
+ - vrf (object): VRF configuration on the VLAN.
- uplinks (array): Per-uplink NAT exception override configuration on the VLAN. Applicable only for networks that support NAT exceptions.
"""
@@ -2690,6 +2954,8 @@ def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs):
"mask",
"ipv6",
"mandatoryDhcp",
+ "sgt",
+ "vrf",
"uplinks",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -2745,7 +3011,7 @@ def updateNetworkApplianceVpnBgp(self, networkId: str, enabled: bool, **kwargs):
- networkId (string): Network ID
- enabled (boolean): Boolean value to enable or disable the BGP configuration. When BGP is enabled, the asNumber (ASN) will be autopopulated with the preconfigured ASN at other Hubs or a default value if there is no ASN configured.
- - asNumber (integer): An Autonomous System Number (ASN) is required if you are to run BGP and peer with another BGP Speaker outside of the Auto VPN domain. This ASN will be applied to the entire Auto VPN domain. The entire 4-byte ASN range is supported. So, the ASN must be an integer between 1 and 4294967295. When absent, this field is not updated. If no value exists then it defaults to 64512.
+ - asNumber (integer): An Autonomous System Number (ASN) is required if you are to run BGP and peer with another BGP Speaker outside of the Auto VPN domain. This ASN will be applied to the entire Auto VPN domain and is only configurable for Auto VPN BGP networks. The entire 4-byte ASN range is supported. So, the ASN must be an integer between 1 and 4294967295. When absent, this field is not updated. If no value exists then it defaults to 64512.
- ibgpHoldTimer (integer): The iBGP holdtimer in seconds. The iBGP holdtimer must be an integer between 12 and 240. When absent, this field is not updated. If no value exists then it defaults to 240.
- routerId (string): The router ID of the appliance
- neighbors (array): List of BGP neighbors. This list replaces the existing set of neighbors. When absent, this field is not updated.
@@ -2803,6 +3069,7 @@ def updateNetworkApplianceVpnSiteToSiteVpn(self, networkId: str, mode: str, **kw
- mode (string): The site-to-site VPN mode. Can be one of 'none', 'spoke' or 'hub'
- hubs (array): The list of VPN hubs, in order of preference. In spoke mode, at least 1 hub is required.
- subnets (array): The list of subnets and their VPN presence.
+ - sgt (object): Security Group Tag settings for the VPN peer.
- subnet (object): Configuration of subnet features
- hostTranslations (array): The list of VPN host translations. Host translations are supported starting from MX firmware version 26.1.2
"""
@@ -2824,6 +3091,7 @@ def updateNetworkApplianceVpnSiteToSiteVpn(self, networkId: str, mode: str, **kw
"mode",
"hubs",
"subnets",
+ "sgt",
"subnet",
"hostTranslations",
]
@@ -2912,6 +3180,167 @@ def swapNetworkApplianceWarmSpare(self, networkId: str):
return self._session.post(metadata, resource)
+ def getOrganizationApplianceDevicesInterfacesL3(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **List L3 interfaces across networks for the organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-interfaces-l-3
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - networkIds (array): Optional Network IDs to filter results
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "devices", "interfaces", "l3"],
+ "operation": "getOrganizationApplianceDevicesInterfacesL3",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/devices/interfaces/l3"
+
+ query_params = [
+ "networkIds",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationApplianceDevicesInterfacesL3: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationApplianceDevicesInterfacesPortsByDevice(self, organizationId: str, **kwargs):
+ """
+ **Returns port configurations for appliances in a given organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-interfaces-ports-by-device
+
+ - organizationId (string): Organization ID
+ - serials (array): Parameter to filter the results by device serials
+ - interfaces (array): Parameter to filter the results by specific interfaces
+ - numbers (array): Parameter to filter the results by specific ports
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "configure", "devices", "interfaces", "ports", "byDevice"],
+ "operation": "getOrganizationApplianceDevicesInterfacesPortsByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/devices/interfaces/ports/byDevice"
+
+ query_params = [
+ "serials",
+ "interfaces",
+ "numbers",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ "interfaces",
+ "numbers",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationApplianceDevicesInterfacesPortsByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get(metadata, resource, params)
+
+ def getOrganizationApplianceDevicesPortsTransceiversReadingsHistoryByDevice(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **Return time-series digital optical monitoring (DOM) readings for ports on each DOM-enabled Catalyst appliance in an organization.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-ports-transceivers-readings-history-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 10. Default is 5.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 30 days from today.
+ - t1 (string): The end of the timespan for the data. t1 can be a maximum of 30 days after t0.
+ - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 30 days. The default is 1 day. If interval is provided, the timespan will be autocalculated.
+ - interval (integer): The time interval in seconds for returned data. The valid intervals are: 300, 1200, 14400, 86400. The default is 1200. Interval is calculated if time params are provided.
+ - networkIds (array): Networks for which information should be gathered.
+ - serials (array): Optional parameter to filter usage by appliance serial.
+ - portIds (array): Optional parameter to filter usage by port ID.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "monitor", "devices", "ports", "transceivers", "readings", "history", "byDevice"],
+ "operation": "getOrganizationApplianceDevicesPortsTransceiversReadingsHistoryByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/devices/ports/transceivers/readings/history/byDevice"
+
+ query_params = [
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "t0",
+ "t1",
+ "timespan",
+ "interval",
+ "networkIds",
+ "serials",
+ "portIds",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ "serials",
+ "portIds",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationApplianceDevicesPortsTransceiversReadingsHistoryByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
def getOrganizationApplianceDevicesRedundancyByNetwork(
self, organizationId: str, total_pages=1, direction="next", **kwargs
):
@@ -3628,6 +4057,116 @@ def getOrganizationApplianceFirewallMulticastForwardingByNetwork(
return self._session.get_pages(metadata, resource, params, total_pages, direction)
+ def getOrganizationApplianceInterfacesPacketsOverviewsByDevice(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **Returns packet counter overviews for all interfaces on Secure Routers in the organization, including totals and average rates by packet type over the requested timespan.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-interfaces-packets-overviews-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 14 days from today.
+ - t1 (string): The end of the timespan for the data. t1 can be a maximum of 14 days after t0.
+ - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 14 days. The default is 1 day.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 50. Default is 10.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - networkIds (array): Optional parameter to filter Secure Routers in the provided networks
+ - serials (array): Optional parameter to filter Secure Routers by their serial numbers
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["appliance", "monitor", "interfaces", "packets", "overviews", "byDevice"],
+ "operation": "getOrganizationApplianceInterfacesPacketsOverviewsByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/interfaces/packets/overviews/byDevice"
+
+ query_params = [
+ "t0",
+ "t1",
+ "timespan",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "networkIds",
+ "serials",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationApplianceInterfacesPacketsOverviewsByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationApplianceRoutingVrfsSettings(self, organizationId: str):
+ """
+ **Return the VRF setting for an organization.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-routing-vrfs-settings
+
+ - organizationId (string): Organization ID
+ """
+
+ metadata = {
+ "tags": ["appliance", "configure", "routing", "vrfs", "settings"],
+ "operation": "getOrganizationApplianceRoutingVrfsSettings",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/routing/vrfs/settings"
+
+ return self._session.get(metadata, resource)
+
+ def updateOrganizationApplianceRoutingVrfsSettings(self, organizationId: str, enabled: bool, **kwargs):
+ """
+ **Update the VRF setting for an organization.**
+ https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-routing-vrfs-settings
+
+ - organizationId (string): Organization ID
+ - enabled (boolean): Boolean indicating whether VRFs are enabled for the organization.
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["appliance", "configure", "routing", "vrfs", "settings"],
+ "operation": "updateOrganizationApplianceRoutingVrfsSettings",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/routing/vrfs/settings"
+
+ body_params = [
+ "enabled",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"updateOrganizationApplianceRoutingVrfsSettings: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.put(metadata, resource, payload)
+
def getOrganizationApplianceSecurityEvents(self, organizationId: str, total_pages=1, direction="next", **kwargs):
"""
**List the security events for an organization**
diff --git a/meraki/api/batch/appliance.py b/meraki/api/batch/appliance.py
index 585380d9..a672cfb5 100644
--- a/meraki/api/batch/appliance.py
+++ b/meraki/api/batch/appliance.py
@@ -5,6 +5,39 @@ class ActionBatchAppliance(object):
def __init__(self):
super(ActionBatchAppliance, self).__init__()
+ def createDeviceApplianceInterfacesPortsUpdate(self, serial: str, **kwargs):
+ """
+ **Update configurations for an appliance's specified port**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-appliance-interfaces-ports-update
+
+ - serial (string): Serial
+ - interface (object): The interface tuple used to identify the port
+ - enabled (boolean): Indicates whether the port is enabled
+ - personality (object): Describes the port's configurability
+ - uplink (object): The port's settings when in WAN mode
+ - downlink (object): The port's VLAN settings when in LAN mode
+ """
+
+ kwargs.update(locals())
+
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/appliance/interfaces/ports/update"
+
+ body_params = [
+ "interface",
+ "enabled",
+ "personality",
+ "uplink",
+ "downlink",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "update",
+ "body": payload,
+ }
+ return action
+
def updateDeviceApplianceRadioSettings(self, serial: str, **kwargs):
"""
**Update the radio settings of an appliance**
@@ -18,7 +51,7 @@ def updateDeviceApplianceRadioSettings(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/appliance/radio/settings"
body_params = [
@@ -45,7 +78,7 @@ def updateDeviceApplianceUplinksSettings(self, serial: str, interfaces: dict, **
kwargs = locals()
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/appliance/uplinks/settings"
body_params = [
@@ -67,7 +100,7 @@ def createDeviceApplianceVmxAuthenticationToken(self, serial: str):
- serial (string): Serial
"""
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/appliance/vmx/authenticationToken"
action = {
@@ -87,7 +120,7 @@ def updateNetworkApplianceConnectivityMonitoringDestinations(self, networkId: st
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/connectivityMonitoringDestinations"
body_params = [
@@ -119,7 +152,7 @@ def updateNetworkApplianceDevicesRedundancy(self, networkId: str, enabled: bool,
options = ["active-active", "active-passive", "disabled"]
assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}'''
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/devices/redundancy"
body_params = [
@@ -144,7 +177,7 @@ def createNetworkApplianceDevicesRedundancySwap(self, networkId: str):
- networkId (string): Network ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/devices/redundancy/swap"
action = {
@@ -164,7 +197,7 @@ def updateNetworkApplianceFirewallL7FirewallRules(self, networkId: str, **kwargs
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/firewall/l7FirewallRules"
body_params = [
@@ -189,7 +222,7 @@ def updateNetworkApplianceFirewallMulticastForwarding(self, networkId: str, rule
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/firewall/multicastForwarding"
body_params = [
@@ -203,6 +236,81 @@ def updateNetworkApplianceFirewallMulticastForwarding(self, networkId: str, rule
}
return action
+ def createNetworkApplianceInterfacesL3(self, networkId: str, ipv4: dict, **kwargs):
+ """
+ **Create wired L3 interface**
+ https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-interfaces-l-3
+
+ - networkId (string): Network ID
+ - ipv4 (object): IPv4 configuration
+ - port (object): Port configuration
+ """
+
+ kwargs.update(locals())
+
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/interfaces/l3"
+
+ body_params = [
+ "port",
+ "ipv4",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "create",
+ "body": payload,
+ }
+ return action
+
+ def updateNetworkApplianceInterfacesL3(self, networkId: str, interfaceId: str, **kwargs):
+ """
+ **Update wired L3 interface**
+ https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-interfaces-l-3
+
+ - networkId (string): Network ID
+ - interfaceId (string): Interface ID
+ - port (object): Port configuration
+ - ipv4 (object): IPv4 configuration
+ """
+
+ kwargs.update(locals())
+
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
+ resource = f"/networks/{networkId}/appliance/interfaces/l3/{interfaceId}"
+
+ body_params = [
+ "port",
+ "ipv4",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "update",
+ "body": payload,
+ }
+ return action
+
+ def deleteNetworkApplianceInterfacesL3(self, networkId: str, interfaceId: str):
+ """
+ **Delete wired L3 interface**
+ https://developer.cisco.com/meraki/api-v1/#!delete-network-appliance-interfaces-l-3
+
+ - networkId (string): Network ID
+ - interfaceId (string): Interface ID
+ """
+
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
+ resource = f"/networks/{networkId}/appliance/interfaces/l3/{interfaceId}"
+
+ action = {
+ "resource": resource,
+ "operation": "destroy",
+ }
+ return action
+
def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs):
"""
**Update the per-port VLAN settings for a single secure router or security appliance port.**
@@ -216,12 +324,13 @@ def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs):
- vlan (integer): Native VLAN when the port is in Trunk mode. Access VLAN when the port is in Access mode.
- allowedVlans (string): Comma-delimited list of VLAN IDs (e.g. '2,15') for all devices. Secure Routers also support VLAN ranges (e.g. '2-10,15'). Use 'all' to permit all VLANs on the port.
- accessPolicy (string): The name of the policy. Only applicable to Access ports. Valid values are: 'open', '8021x-radius', 'mac-radius', 'hybris-radius' for MX64 or Z3 or any MX supporting the per port authentication feature. Otherwise, 'open' is the only valid value and 'open' is the default value if the field is missing.
+ - sgt (object): Security Group Tag settings for the port.
"""
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- portId = urllib.parse.quote(portId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ portId = urllib.parse.quote(str(portId), safe="")
resource = f"/networks/{networkId}/appliance/ports/{portId}"
body_params = [
@@ -231,6 +340,7 @@ def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs):
"vlan",
"allowedVlans",
"accessPolicy",
+ "sgt",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
action = {
@@ -253,7 +363,7 @@ def createNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, prefix:
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics"
body_params = [
@@ -283,8 +393,8 @@ def updateNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDe
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- staticDelegatedPrefixId = urllib.parse.quote(staticDelegatedPrefixId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe="")
resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}"
body_params = [
@@ -309,8 +419,8 @@ def deleteNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDe
- staticDelegatedPrefixId (string): Static delegated prefix ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- staticDelegatedPrefixId = urllib.parse.quote(staticDelegatedPrefixId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe="")
resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}"
action = {
@@ -333,7 +443,7 @@ def createNetworkApplianceRfProfile(self, networkId: str, name: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/rfProfiles"
body_params = [
@@ -365,8 +475,8 @@ def updateNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str, **kw
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- rfProfileId = urllib.parse.quote(rfProfileId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ rfProfileId = urllib.parse.quote(str(rfProfileId), safe="")
resource = f"/networks/{networkId}/appliance/rfProfiles/{rfProfileId}"
body_params = [
@@ -392,8 +502,8 @@ def deleteNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str):
- rfProfileId (string): Rf profile ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- rfProfileId = urllib.parse.quote(rfProfileId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ rfProfileId = urllib.parse.quote(str(rfProfileId), safe="")
resource = f"/networks/{networkId}/appliance/rfProfiles/{rfProfileId}"
action = {
@@ -413,7 +523,7 @@ def updateNetworkApplianceSdwanInternetPolicies(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/sdwan/internetPolicies"
body_params = [
@@ -451,7 +561,7 @@ def updateNetworkApplianceSettings(self, networkId: str, **kwargs):
f'''"deploymentMode" cannot be "{kwargs["deploymentMode"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/settings"
body_params = [
@@ -481,7 +591,7 @@ def updateNetworkApplianceSingleLan(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/singleLan"
body_params = [
@@ -536,8 +646,8 @@ def updateNetworkApplianceSsid(self, networkId: str, number: str, **kwargs):
f'''"wpaEncryptionMode" cannot be "{kwargs["wpaEncryptionMode"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/appliance/ssids/{number}"
body_params = [
@@ -575,7 +685,7 @@ def createNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId:
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses"
body_params = [
@@ -609,8 +719,8 @@ def updateNetworkApplianceTrafficShapingCustomPerformanceClass(
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- customPerformanceClassId = urllib.parse.quote(customPerformanceClassId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe="")
resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}"
body_params = [
@@ -636,8 +746,8 @@ def deleteNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId:
- customPerformanceClassId (string): Custom performance class ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- customPerformanceClassId = urllib.parse.quote(customPerformanceClassId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe="")
resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}"
action = {
@@ -661,7 +771,7 @@ def updateNetworkApplianceTrafficShapingRules(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/trafficShaping/rules"
body_params = [
@@ -687,7 +797,7 @@ def updateNetworkApplianceTrafficShapingUplinkBandwidth(self, networkId: str, **
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkBandwidth"
body_params = [
@@ -717,7 +827,7 @@ def updateNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str, **
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkSelection"
body_params = [
@@ -748,7 +858,7 @@ def updateNetworkApplianceTrafficShapingVpnExclusions(self, networkId: str, **kw
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/trafficShaping/vpnExclusions"
body_params = [
@@ -774,7 +884,7 @@ def connectNetworkApplianceUmbrellaAccount(self, networkId: str, api: dict, **kw
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/umbrella/account/connect"
body_params = [
@@ -796,7 +906,7 @@ def disconnectNetworkApplianceUmbrellaAccount(self, networkId: str):
- networkId (string): Network ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/umbrella/account/disconnect"
action = {
@@ -805,6 +915,106 @@ def disconnectNetworkApplianceUmbrellaAccount(self, networkId: str):
}
return action
+ def exclusionsNetworkApplianceUmbrellaDomains(self, networkId: str, domains: list, **kwargs):
+ """
+ **Specify one or more domain names to be excluded from being routed to Cisco Umbrella.**
+ https://developer.cisco.com/meraki/api-v1/#!exclusions-network-appliance-umbrella-domains
+
+ - networkId (string): Network ID
+ - domains (array): Domain names to exclude from Umbrella DNS routing (e.g., 'example.com', 'corp.example.org'). Standard FQDNs only — wildcards are not supported. Values are lowercased before saving. Each call replaces the full exclusion list.
+ """
+
+ kwargs = locals()
+
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/domains/exclusions"
+
+ body_params = [
+ "domains",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "action",
+ "body": payload,
+ }
+ return action
+
+ def addNetworkApplianceUmbrellaPolicies(self, networkId: str, policy: dict, **kwargs):
+ """
+ **Add one Cisco Umbrella DNS security policy to an MX network by policy ID. Idempotent — if the policy is already applied, the request succeeds and returns the current policy set unchanged.**
+ https://developer.cisco.com/meraki/api-v1/#!add-network-appliance-umbrella-policies
+
+ - networkId (string): Network ID
+ - policy (object): Umbrella policy to add
+ """
+
+ kwargs = locals()
+
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/policies/add"
+
+ body_params = [
+ "policy",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "policies_add",
+ "body": payload,
+ }
+ return action
+
+ def removeNetworkApplianceUmbrellaPolicies(self, networkId: str, policy: dict, **kwargs):
+ """
+ **Remove one Cisco Umbrella DNS security policy from an MX network by policy ID. Returns 204 No Content on success. Behavior when the policy is not currently applied depends on the Cisco Umbrella API response.**
+ https://developer.cisco.com/meraki/api-v1/#!remove-network-appliance-umbrella-policies
+
+ - networkId (string): Network ID
+ - policy (object): Umbrella policy to remove
+ """
+
+ kwargs = locals()
+
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/policies/remove"
+
+ body_params = [
+ "policy",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "policies_remove",
+ "body": payload,
+ }
+ return action
+
+ def protectionNetworkApplianceUmbrella(self, networkId: str, enabled: bool, **kwargs):
+ """
+ **Enable or disable umbrella protection for an appliance network. When 'enabled' is false, 'umbrella.organization.id' and 'umbrella.origin.id' are null in the response.**
+ https://developer.cisco.com/meraki/api-v1/#!protection-network-appliance-umbrella
+
+ - networkId (string): Network ID
+ - enabled (boolean): Enable or disable umbrella protection
+ """
+
+ kwargs = locals()
+
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/appliance/umbrella/protection"
+
+ body_params = [
+ "enabled",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "action",
+ "body": payload,
+ }
+ return action
+
def updateNetworkApplianceUplinksNat(self, networkId: str, uplinks: list, **kwargs):
"""
**Update uplink NAT settings of the specified network**
@@ -816,7 +1026,7 @@ def updateNetworkApplianceUplinksNat(self, networkId: str, uplinks: list, **kwar
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/uplinks/nat"
body_params = [
@@ -853,6 +1063,8 @@ def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwarg
- dhcpBootNextServer (string): DHCP boot option to direct boot clients to the server to load the boot file from
- dhcpBootFilename (string): DHCP boot option for boot filename
- dhcpOptions (array): The list of DHCP options that will be included in DHCP responses. Each object in the list should have "code", "type", and "value" properties.
+ - sgt (object): Security Group Tag settings for the VLAN.
+ - vrf (object): VRF configuration on the VLAN.
- uplinks (array): Per-uplink NAT exception override configuration on the VLAN. Applicable only for networks that support NAT exceptions.
"""
@@ -874,7 +1086,7 @@ def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwarg
f'''"dhcpLeaseTime" cannot be "{kwargs["dhcpLeaseTime"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/vlans"
body_params = [
@@ -895,6 +1107,8 @@ def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwarg
"dhcpBootNextServer",
"dhcpBootFilename",
"dhcpOptions",
+ "sgt",
+ "vrf",
"uplinks",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -916,7 +1130,7 @@ def updateNetworkApplianceVlansSettings(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/vlans/settings"
body_params = [
@@ -957,6 +1171,8 @@ def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs):
- mask (integer): Mask used for the subnet of all bound to the template networks. Applicable only for template network.
- ipv6 (object): IPv6 configuration on the VLAN
- mandatoryDhcp (object): Mandatory DHCP will enforce that clients connecting to this VLAN must use the IP address assigned by the DHCP server. Clients who use a static IP address won't be able to associate. Only available on firmware versions 17.0 and above
+ - sgt (object): Security Group Tag settings for the VLAN.
+ - vrf (object): VRF configuration on the VLAN.
- uplinks (array): Per-uplink NAT exception override configuration on the VLAN. Applicable only for networks that support NAT exceptions.
"""
@@ -978,8 +1194,8 @@ def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs):
f'''"templateVlanType" cannot be "{kwargs["templateVlanType"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- vlanId = urllib.parse.quote(vlanId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ vlanId = urllib.parse.quote(str(vlanId), safe="")
resource = f"/networks/{networkId}/appliance/vlans/{vlanId}"
body_params = [
@@ -1003,6 +1219,8 @@ def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs):
"mask",
"ipv6",
"mandatoryDhcp",
+ "sgt",
+ "vrf",
"uplinks",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -1022,8 +1240,8 @@ def deleteNetworkApplianceVlan(self, networkId: str, vlanId: str):
- vlanId (string): Vlan ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- vlanId = urllib.parse.quote(vlanId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ vlanId = urllib.parse.quote(str(vlanId), safe="")
resource = f"/networks/{networkId}/appliance/vlans/{vlanId}"
action = {
@@ -1039,7 +1257,7 @@ def updateNetworkApplianceVpnBgp(self, networkId: str, enabled: bool, **kwargs):
- networkId (string): Network ID
- enabled (boolean): Boolean value to enable or disable the BGP configuration. When BGP is enabled, the asNumber (ASN) will be autopopulated with the preconfigured ASN at other Hubs or a default value if there is no ASN configured.
- - asNumber (integer): An Autonomous System Number (ASN) is required if you are to run BGP and peer with another BGP Speaker outside of the Auto VPN domain. This ASN will be applied to the entire Auto VPN domain. The entire 4-byte ASN range is supported. So, the ASN must be an integer between 1 and 4294967295. When absent, this field is not updated. If no value exists then it defaults to 64512.
+ - asNumber (integer): An Autonomous System Number (ASN) is required if you are to run BGP and peer with another BGP Speaker outside of the Auto VPN domain. This ASN will be applied to the entire Auto VPN domain and is only configurable for Auto VPN BGP networks. The entire 4-byte ASN range is supported. So, the ASN must be an integer between 1 and 4294967295. When absent, this field is not updated. If no value exists then it defaults to 64512.
- ibgpHoldTimer (integer): The iBGP holdtimer in seconds. The iBGP holdtimer must be an integer between 12 and 240. When absent, this field is not updated. If no value exists then it defaults to 240.
- routerId (string): The router ID of the appliance
- neighbors (array): List of BGP neighbors. This list replaces the existing set of neighbors. When absent, this field is not updated.
@@ -1047,7 +1265,7 @@ def updateNetworkApplianceVpnBgp(self, networkId: str, enabled: bool, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/vpn/bgp"
body_params = [
@@ -1074,6 +1292,7 @@ def updateNetworkApplianceVpnSiteToSiteVpn(self, networkId: str, mode: str, **kw
- mode (string): The site-to-site VPN mode. Can be one of 'none', 'spoke' or 'hub'
- hubs (array): The list of VPN hubs, in order of preference. In spoke mode, at least 1 hub is required.
- subnets (array): The list of subnets and their VPN presence.
+ - sgt (object): Security Group Tag settings for the VPN peer.
- subnet (object): Configuration of subnet features
- hostTranslations (array): The list of VPN host translations. Host translations are supported starting from MX firmware version 26.1.2
"""
@@ -1084,13 +1303,14 @@ def updateNetworkApplianceVpnSiteToSiteVpn(self, networkId: str, mode: str, **kw
options = ["hub", "none", "spoke"]
assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}'''
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/vpn/siteToSiteVpn"
body_params = [
"mode",
"hubs",
"subnets",
+ "sgt",
"subnet",
"hostTranslations",
]
@@ -1117,7 +1337,7 @@ def updateNetworkApplianceWarmSpare(self, networkId: str, enabled: bool, **kwarg
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/warmSpare"
body_params = [
@@ -1143,7 +1363,7 @@ def swapNetworkApplianceWarmSpare(self, networkId: str):
- networkId (string): Network ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/appliance/warmSpare/swap"
action = {
@@ -1163,7 +1383,7 @@ def createOrganizationApplianceDnsLocalProfile(self, organizationId: str, name:
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/local/profiles"
body_params = [
@@ -1188,7 +1408,7 @@ def bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate(self, organizatio
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/assignments/bulkCreate"
body_params = [
@@ -1213,7 +1433,7 @@ def createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete(self, organ
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/assignments/bulkDelete"
body_params = [
@@ -1239,8 +1459,8 @@ def updateOrganizationApplianceDnsLocalProfile(self, organizationId: str, profil
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
- profileId = urllib.parse.quote(profileId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/{profileId}"
body_params = [
@@ -1263,8 +1483,8 @@ def deleteOrganizationApplianceDnsLocalProfile(self, organizationId: str, profil
- profileId (string): Profile ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- profileId = urllib.parse.quote(profileId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/{profileId}"
action = {
@@ -1288,7 +1508,7 @@ def createOrganizationApplianceDnsLocalRecord(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/local/records"
body_params = [
@@ -1318,8 +1538,8 @@ def updateOrganizationApplianceDnsLocalRecord(self, organizationId: str, recordI
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- recordId = urllib.parse.quote(recordId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ recordId = urllib.parse.quote(str(recordId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/local/records/{recordId}"
body_params = [
@@ -1344,8 +1564,8 @@ def deleteOrganizationApplianceDnsLocalRecord(self, organizationId: str, recordI
- recordId (string): Record ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- recordId = urllib.parse.quote(recordId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ recordId = urllib.parse.quote(str(recordId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/local/records/{recordId}"
action = {
@@ -1369,7 +1589,7 @@ def createOrganizationApplianceDnsSplitProfile(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/split/profiles"
body_params = [
@@ -1396,7 +1616,7 @@ def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate(self, organ
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/assignments/bulkCreate"
body_params = [
@@ -1421,7 +1641,7 @@ def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete(self, organ
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/assignments/bulkDelete"
body_params = [
@@ -1449,8 +1669,8 @@ def updateOrganizationApplianceDnsSplitProfile(self, organizationId: str, profil
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- profileId = urllib.parse.quote(profileId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/{profileId}"
body_params = [
@@ -1475,8 +1695,8 @@ def deleteOrganizationApplianceDnsSplitProfile(self, organizationId: str, profil
- profileId (string): Profile ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- profileId = urllib.parse.quote(profileId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/{profileId}"
action = {
@@ -1485,6 +1705,31 @@ def deleteOrganizationApplianceDnsSplitProfile(self, organizationId: str, profil
}
return action
+ def updateOrganizationApplianceRoutingVrfsSettings(self, organizationId: str, enabled: bool, **kwargs):
+ """
+ **Update the VRF setting for an organization.**
+ https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-routing-vrfs-settings
+
+ - organizationId (string): Organization ID
+ - enabled (boolean): Boolean indicating whether VRFs are enabled for the organization.
+ """
+
+ kwargs = locals()
+
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/appliance/routing/vrfs/settings"
+
+ body_params = [
+ "enabled",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "update",
+ "body": payload,
+ }
+ return action
+
def updateOrganizationApplianceVpnSiteToSiteIpsecPeersSlas(self, organizationId: str, **kwargs):
"""
**Update the IPsec SLA policies for an organization**
@@ -1496,7 +1741,7 @@ def updateOrganizationApplianceVpnSiteToSiteIpsecPeersSlas(self, organizationId:
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/appliance/vpn/siteToSite/ipsec/peers/slas"
body_params = [
@@ -1524,7 +1769,7 @@ def updateOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str,
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/appliance/vpn/thirdPartyVPNPeers"
body_params = [
@@ -1552,7 +1797,7 @@ def assignOrganizationPoliciesGlobalGroupPoliciesApplianceVlans(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/assign"
body_params = [
@@ -1581,7 +1826,7 @@ def removeOrganizationPoliciesGlobalGroupPoliciesApplianceVlans(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/remove"
body_params = [
diff --git a/meraki/api/batch/camera.py b/meraki/api/batch/camera.py
index 9a2e7b2d..c4bde39e 100644
--- a/meraki/api/batch/camera.py
+++ b/meraki/api/batch/camera.py
@@ -18,7 +18,7 @@ def updateDeviceCameraCustomAnalytics(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/camera/customAnalytics"
body_params = [
@@ -67,7 +67,7 @@ def updateDeviceCameraQualityAndRetention(self, serial: str, **kwargs):
f'''"motionDetectorVersion" cannot be "{kwargs["motionDetectorVersion"]}", & must be set to one of: {options}'''
)
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/camera/qualityAndRetention"
body_params = [
@@ -101,7 +101,7 @@ def updateDeviceCameraSense(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/camera/sense"
body_params = [
@@ -129,7 +129,7 @@ def updateDeviceCameraVideoSettings(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/camera/video/settings"
body_params = [
@@ -154,7 +154,7 @@ def updateDeviceCameraWirelessProfiles(self, serial: str, ids: dict, **kwargs):
kwargs = locals()
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/camera/wirelessProfiles"
body_params = [
diff --git a/meraki/api/batch/campusGateway.py b/meraki/api/batch/campusGateway.py
index c2a6b8e9..6127c57b 100644
--- a/meraki/api/batch/campusGateway.py
+++ b/meraki/api/batch/campusGateway.py
@@ -24,7 +24,7 @@ def createNetworkCampusGatewayCluster(
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/campusGateway/clusters"
body_params = [
@@ -62,8 +62,8 @@ def updateNetworkCampusGatewayCluster(self, networkId: str, clusterId: str, **kw
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- clusterId = urllib.parse.quote(clusterId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ clusterId = urllib.parse.quote(str(clusterId), safe="")
resource = f"/networks/{networkId}/campusGateway/clusters/{clusterId}"
body_params = [
diff --git a/meraki/api/batch/cellularGateway.py b/meraki/api/batch/cellularGateway.py
index e57b9508..2c2f7a62 100644
--- a/meraki/api/batch/cellularGateway.py
+++ b/meraki/api/batch/cellularGateway.py
@@ -17,7 +17,7 @@ def updateDeviceCellularGatewayLan(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/cellularGateway/lan"
body_params = [
@@ -43,7 +43,7 @@ def updateDeviceCellularGatewayPortForwardingRules(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/cellularGateway/portForwardingRules"
body_params = [
@@ -68,7 +68,7 @@ def updateNetworkCellularGatewayConnectivityMonitoringDestinations(self, network
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/cellularGateway/connectivityMonitoringDestinations"
body_params = [
@@ -95,7 +95,7 @@ def updateNetworkCellularGatewayDhcp(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/cellularGateway/dhcp"
body_params = [
@@ -123,7 +123,7 @@ def updateNetworkCellularGatewaySubnetPool(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/cellularGateway/subnetPool"
body_params = [
@@ -149,7 +149,7 @@ def updateNetworkCellularGatewayUplink(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/cellularGateway/uplink"
body_params = [
@@ -175,8 +175,8 @@ def updateOrganizationCellularGatewayEsimsInventory(self, organizationId: str, i
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- id = urllib.parse.quote(id, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/organizations/{organizationId}/cellularGateway/esims/inventory/{id}"
body_params = [
@@ -207,7 +207,7 @@ def createOrganizationCellularGatewayEsimsServiceProvidersAccount(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts"
body_params = [
@@ -238,8 +238,8 @@ def updateOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organiza
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- accountId = urllib.parse.quote(accountId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ accountId = urllib.parse.quote(str(accountId), safe="")
resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/{accountId}"
body_params = [
@@ -263,8 +263,8 @@ def deleteOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organiza
- accountId (string): Account ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- accountId = urllib.parse.quote(accountId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ accountId = urllib.parse.quote(str(accountId), safe="")
resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/{accountId}"
action = {
@@ -284,7 +284,7 @@ def createOrganizationCellularGatewayEsimsSwap(self, organizationId: str, swaps:
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/cellularGateway/esims/swap"
body_params = [
@@ -307,8 +307,8 @@ def updateOrganizationCellularGatewayEsimsSwap(self, id: str, organizationId: st
- organizationId (string): Organization ID
"""
- id = urllib.parse.quote(id, safe="")
- organizationId = urllib.parse.quote(organizationId, safe="")
+ id = urllib.parse.quote(str(id), safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/cellularGateway/esims/swap/{id}"
action = {
diff --git a/meraki/api/batch/devices.py b/meraki/api/batch/devices.py
index 026f19b4..027be86b 100644
--- a/meraki/api/batch/devices.py
+++ b/meraki/api/batch/devices.py
@@ -24,7 +24,7 @@ def updateDevice(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}"
body_params = [
@@ -46,6 +46,67 @@ def updateDevice(self, serial: str, **kwargs):
}
return action
+ def updateDeviceCellularGeolocations(self, serial: str, enabled: bool, **kwargs):
+ """
+ **Update the enablement of the geolocation feature for a device**
+ https://developer.cisco.com/meraki/api-v1/#!update-device-cellular-geolocations
+
+ - serial (string): Serial
+ - enabled (boolean): Required parameter for the state to update the geolocation settings to (true to enable, false to disable)
+ """
+
+ kwargs = locals()
+
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/cellular/geolocations"
+
+ body_params = [
+ "enabled",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "update",
+ "body": payload,
+ }
+ return action
+
+ def createDeviceCellularUplinksBandsMasksUpdate(self, serial: str, slot: str, type: str, masked: list, **kwargs):
+ """
+ **Update the cellular band masks for a device**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-cellular-uplinks-bands-masks-update
+
+ - serial (string): Serial
+ - slot (string): Required parameter for the SIM slot to update the cellular band mask for
+ - type (string): Required parameter for the signal type to update the cellular band mask for
+ - masked (array): Required parameter for the band identifiers to mask for the given SIM slot and signal type. For LTE use bands identifiers like '30', for 5G use band identifiers like 'n30', or use 'all' to mask all bands for that signal type. Maximum 256 bands.
+ """
+
+ kwargs = locals()
+
+ if "slot" in kwargs:
+ options = ["sim1", "sim2", "sim3"]
+ assert kwargs["slot"] in options, f'''"slot" cannot be "{kwargs["slot"]}", & must be set to one of: {options}'''
+ if "type" in kwargs:
+ options = ["5GNSA", "5GSA", "LTE"]
+ assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
+
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/cellular/uplinks/bands/masks/update"
+
+ body_params = [
+ "slot",
+ "type",
+ "masked",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "update",
+ "body": payload,
+ }
+ return action
+
def createDeviceLiveToolsLedsBlink(self, serial: str, duration: int, **kwargs):
"""
**Enqueue a job to blink LEDs on a device. This endpoint has a rate limit of one request every 10 seconds.**
@@ -58,7 +119,7 @@ def createDeviceLiveToolsLedsBlink(self, serial: str, duration: int, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/liveTools/leds/blink"
body_params = [
@@ -73,6 +134,134 @@ def createDeviceLiveToolsLedsBlink(self, serial: str, duration: int, **kwargs):
}
return action
+ def createDeviceLiveToolsPortsStatus(self, serial: str, **kwargs):
+ """
+ **Enqueue a job to retrieve port status for a device. This endpoint has a sustained rate limit of one request every five seconds per device, with an allowed burst of five requests.**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-ports-status
+
+ - serial (string): Serial
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/ports/status"
+
+ body_params = [
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "status",
+ "body": payload,
+ }
+ return action
+
+ def createDeviceLiveToolsPowerUsage(self, serial: str, **kwargs):
+ """
+ **Enqueues a live tool job that retrieves details about a device's overall power usage. This endpoint has a sustained rate limit of one request every five seconds per device, with an allowed burst of five requests.**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-power-usage
+
+ - serial (string): Serial
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/power/usage"
+
+ body_params = [
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "job",
+ "body": payload,
+ }
+ return action
+
+ def createDeviceLiveToolsRoutingTableLookup(self, serial: str, **kwargs):
+ """
+ **Enqueue a job to perform a routing table lookup request for a device. The routing table lookup request fetches a specific set of routes based on filters. Any combination of search filters can be applied. Only Cisco Secure Routers are supported.**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-routing-table-lookup
+
+ - serial (string): Serial
+ - type (string): The type of route defined
+ - destination (object): The destination IP or subnet to lookup
+ - nextHop (object): The next hop to lookup
+ - vpn (object): VPN related search criteria
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ if "type" in kwargs:
+ options = [
+ "BGP",
+ "EIGRP",
+ "HSRP",
+ "IGRP",
+ "ISIS",
+ "LISP",
+ "NAT",
+ "ND",
+ "NHRP",
+ "OMP",
+ "OSPF",
+ "RIP",
+ "default WAN",
+ "direct",
+ "static",
+ ]
+ assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
+
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/lookups"
+
+ body_params = [
+ "type",
+ "destination",
+ "nextHop",
+ "vpn",
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "lookup",
+ "body": payload,
+ }
+ return action
+
+ def createDeviceLiveToolsRoutingTableSummary(self, serial: str, **kwargs):
+ """
+ **Enqueue a routing table summary job for a device. The job fetches summary data such as route counts by VRF and protocol. Only Cisco Secure Routers are supported.**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-routing-table-summary
+
+ - serial (string): Serial
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/summaries"
+
+ body_params = [
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "summary",
+ "body": payload,
+ }
+ return action
+
def createDeviceLiveToolsThroughputTest(self, serial: str, **kwargs):
"""
**Enqueue a job to test a device throughput, the test will run for 10 secs to test throughput. This endpoint has a rate limit of one request every five seconds per device.**
@@ -84,7 +273,7 @@ def createDeviceLiveToolsThroughputTest(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/liveTools/throughputTest"
body_params = [
@@ -110,7 +299,7 @@ def updateDeviceManagementInterface(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/managementInterface"
body_params = [
diff --git a/meraki/api/batch/insight.py b/meraki/api/batch/insight.py
index 7e850f1c..ecadc1f4 100644
--- a/meraki/api/batch/insight.py
+++ b/meraki/api/batch/insight.py
@@ -18,7 +18,7 @@ def createOrganizationInsightMonitoredMediaServer(self, organizationId: str, nam
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/insight/monitoredMediaServers"
body_params = [
@@ -48,8 +48,8 @@ def updateOrganizationInsightMonitoredMediaServer(self, organizationId: str, mon
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- monitoredMediaServerId = urllib.parse.quote(monitoredMediaServerId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe="")
resource = f"/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}"
body_params = [
@@ -74,8 +74,8 @@ def deleteOrganizationInsightMonitoredMediaServer(self, organizationId: str, mon
- monitoredMediaServerId (string): Monitored media server ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- monitoredMediaServerId = urllib.parse.quote(monitoredMediaServerId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe="")
resource = f"/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}"
action = {
diff --git a/meraki/api/batch/networks.py b/meraki/api/batch/networks.py
index 5fd9dc9f..b40f4cc8 100644
--- a/meraki/api/batch/networks.py
+++ b/meraki/api/batch/networks.py
@@ -20,7 +20,7 @@ def updateNetwork(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}"
body_params = [
@@ -46,7 +46,7 @@ def deleteNetwork(self, networkId: str):
- networkId (string): Network ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}"
action = {
@@ -67,7 +67,7 @@ def bindNetwork(self, networkId: str, configTemplateId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/bind"
body_params = [
@@ -103,7 +103,7 @@ def provisionNetworkClients(self, networkId: str, clients: list, devicePolicy: s
f'''"devicePolicy" cannot be "{kwargs["devicePolicy"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/clients/provision"
body_params = [
@@ -134,7 +134,7 @@ def claimNetworkDevices(self, networkId: str, serials: list, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/devices/claim"
body_params = [
@@ -164,7 +164,7 @@ def vmxNetworkDevicesClaim(self, networkId: str, size: str, **kwargs):
options = ["100", "large", "medium", "small", "xlarge"]
assert kwargs["size"] in options, f'''"size" cannot be "{kwargs["size"]}", & must be set to one of: {options}'''
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/devices/claim/vmx"
body_params = [
@@ -189,7 +189,7 @@ def removeNetworkDevices(self, networkId: str, serial: str, **kwargs):
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/devices/remove"
body_params = [
@@ -203,6 +203,31 @@ def removeNetworkDevices(self, networkId: str, serial: str, **kwargs):
}
return action
+ def updateNetworkDevicesSyslogServers(self, networkId: str, servers: list, **kwargs):
+ """
+ **Updates the syslog servers configuration for a network.**
+ https://developer.cisco.com/meraki/api-v1/#!update-network-devices-syslog-servers
+
+ - networkId (string): Network ID
+ - servers (array): A list of the syslog servers for this network; suggested maximum array size is 10
+ """
+
+ kwargs = locals()
+
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/devices/syslog/servers"
+
+ body_params = [
+ "servers",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "servers",
+ "body": payload,
+ }
+ return action
+
def updateNetworkFirmwareUpgrades(self, networkId: str, **kwargs):
"""
**Update firmware upgrade information for a network**
@@ -216,7 +241,7 @@ def updateNetworkFirmwareUpgrades(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/firmwareUpgrades"
body_params = [
@@ -262,7 +287,7 @@ def createNetworkFirmwareUpgradesRollback(self, networkId: str, reasons: list, *
f'''"product" cannot be "{kwargs["product"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/firmwareUpgrades/rollbacks"
body_params = [
@@ -294,7 +319,7 @@ def createNetworkFirmwareUpgradesStagedGroup(self, networkId: str, name: str, is
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/firmwareUpgrades/staged/groups"
body_params = [
@@ -320,8 +345,8 @@ def deleteNetworkFirmwareUpgradesStagedGroup(self, networkId: str, groupId: str)
- groupId (string): Group ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- groupId = urllib.parse.quote(groupId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ groupId = urllib.parse.quote(str(groupId), safe="")
resource = f"/networks/{networkId}/firmwareUpgrades/staged/groups/{groupId}"
action = {
@@ -341,7 +366,7 @@ def batchNetworkFloorPlansAutoLocateJobs(self, networkId: str, jobs: list, **kwa
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/floorPlans/autoLocate/jobs/batch"
body_params = [
@@ -364,8 +389,8 @@ def cancelNetworkFloorPlansAutoLocateJob(self, networkId: str, jobId: str):
- jobId (string): Job ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- jobId = urllib.parse.quote(jobId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ jobId = urllib.parse.quote(str(jobId), safe="")
resource = f"/networks/{networkId}/floorPlans/autoLocate/jobs/{jobId}/cancel"
action = {
@@ -386,8 +411,8 @@ def publishNetworkFloorPlansAutoLocateJob(self, networkId: str, jobId: str, **kw
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- jobId = urllib.parse.quote(jobId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ jobId = urllib.parse.quote(str(jobId), safe="")
resource = f"/networks/{networkId}/floorPlans/autoLocate/jobs/{jobId}/publish"
body_params = [
@@ -413,8 +438,8 @@ def recalculateNetworkFloorPlansAutoLocateJob(self, networkId: str, jobId: str,
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- jobId = urllib.parse.quote(jobId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ jobId = urllib.parse.quote(str(jobId), safe="")
resource = f"/networks/{networkId}/floorPlans/autoLocate/jobs/{jobId}/recalculate"
body_params = [
@@ -439,7 +464,7 @@ def batchNetworkFloorPlansDevicesUpdate(self, networkId: str, assignments: list,
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/floorPlans/devices/batchUpdate"
body_params = [
@@ -472,8 +497,8 @@ def updateNetworkFloorPlan(self, networkId: str, floorPlanId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- floorPlanId = urllib.parse.quote(floorPlanId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ floorPlanId = urllib.parse.quote(str(floorPlanId), safe="")
resource = f"/networks/{networkId}/floorPlans/{floorPlanId}"
body_params = [
@@ -503,8 +528,8 @@ def deleteNetworkFloorPlan(self, networkId: str, floorPlanId: str):
- floorPlanId (string): Floor plan ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- floorPlanId = urllib.parse.quote(floorPlanId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ floorPlanId = urllib.parse.quote(str(floorPlanId), safe="")
resource = f"/networks/{networkId}/floorPlans/{floorPlanId}"
action = {
@@ -540,7 +565,7 @@ def createNetworkGroupPolicy(self, networkId: str, name: str, **kwargs):
f'''"splashAuthSettings" cannot be "{kwargs["splashAuthSettings"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/groupPolicies"
body_params = [
@@ -589,8 +614,8 @@ def updateNetworkGroupPolicy(self, networkId: str, groupPolicyId: str, **kwargs)
f'''"splashAuthSettings" cannot be "{kwargs["splashAuthSettings"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- groupPolicyId = urllib.parse.quote(groupPolicyId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ groupPolicyId = urllib.parse.quote(str(groupPolicyId), safe="")
resource = f"/networks/{networkId}/groupPolicies/{groupPolicyId}"
body_params = [
@@ -623,8 +648,8 @@ def deleteNetworkGroupPolicy(self, networkId: str, groupPolicyId: str, **kwargs)
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- groupPolicyId = urllib.parse.quote(groupPolicyId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ groupPolicyId = urllib.parse.quote(str(groupPolicyId), safe="")
resource = f"/networks/{networkId}/groupPolicies/{groupPolicyId}"
action = {
@@ -656,7 +681,7 @@ def createNetworkMerakiAuthUser(self, networkId: str, email: str, authorizations
f'''"accountType" cannot be "{kwargs["accountType"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/merakiAuthUsers"
body_params = [
@@ -688,8 +713,8 @@ def deleteNetworkMerakiAuthUser(self, networkId: str, merakiAuthUserId: str, **k
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- merakiAuthUserId = urllib.parse.quote(merakiAuthUserId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ merakiAuthUserId = urllib.parse.quote(str(merakiAuthUserId), safe="")
resource = f"/networks/{networkId}/merakiAuthUsers/{merakiAuthUserId}"
action = {
@@ -713,8 +738,8 @@ def updateNetworkMerakiAuthUser(self, networkId: str, merakiAuthUserId: str, **k
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- merakiAuthUserId = urllib.parse.quote(merakiAuthUserId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ merakiAuthUserId = urllib.parse.quote(str(merakiAuthUserId), safe="")
resource = f"/networks/{networkId}/merakiAuthUsers/{merakiAuthUserId}"
body_params = [
@@ -746,7 +771,7 @@ def createNetworkMqttBroker(self, networkId: str, name: str, host: str, port: in
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/mqttBrokers"
body_params = [
@@ -780,8 +805,8 @@ def updateNetworkMqttBroker(self, networkId: str, mqttBrokerId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- mqttBrokerId = urllib.parse.quote(mqttBrokerId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ mqttBrokerId = urllib.parse.quote(str(mqttBrokerId), safe="")
resource = f"/networks/{networkId}/mqttBrokers/{mqttBrokerId}"
body_params = [
@@ -808,8 +833,8 @@ def deleteNetworkMqttBroker(self, networkId: str, mqttBrokerId: str):
- mqttBrokerId (string): Mqtt broker ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- mqttBrokerId = urllib.parse.quote(mqttBrokerId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ mqttBrokerId = urllib.parse.quote(str(mqttBrokerId), safe="")
resource = f"/networks/{networkId}/mqttBrokers/{mqttBrokerId}"
action = {
@@ -833,7 +858,7 @@ def updateNetworkSettings(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/settings"
body_params = [
@@ -859,7 +884,7 @@ def splitNetwork(self, networkId: str):
- networkId (string): Network ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/split"
action = {
@@ -879,7 +904,7 @@ def unbindNetwork(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/unbind"
body_params = [
@@ -907,7 +932,7 @@ def createNetworkVlanProfile(self, networkId: str, name: str, vlanNames: list, v
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/vlanProfiles"
body_params = [
@@ -933,8 +958,8 @@ def deleteNetworkVlanProfile(self, networkId: str, iname: str):
- iname (string): Iname
"""
- networkId = urllib.parse.quote(networkId, safe="")
- iname = urllib.parse.quote(iname, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ iname = urllib.parse.quote(str(iname), safe="")
resource = f"/networks/{networkId}/vlanProfiles/{iname}"
action = {
@@ -958,7 +983,7 @@ def createNetworkWebhooksPayloadTemplate(self, networkId: str, name: str, **kwar
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/webhooks/payloadTemplates"
body_params = [
@@ -985,8 +1010,8 @@ def deleteNetworkWebhooksPayloadTemplate(self, networkId: str, payloadTemplateId
- payloadTemplateId (string): Payload template ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- payloadTemplateId = urllib.parse.quote(payloadTemplateId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ payloadTemplateId = urllib.parse.quote(str(payloadTemplateId), safe="")
resource = f"/networks/{networkId}/webhooks/payloadTemplates/{payloadTemplateId}"
action = {
@@ -1011,8 +1036,8 @@ def updateNetworkWebhooksPayloadTemplate(self, networkId: str, payloadTemplateId
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- payloadTemplateId = urllib.parse.quote(payloadTemplateId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ payloadTemplateId = urllib.parse.quote(str(payloadTemplateId), safe="")
resource = f"/networks/{networkId}/webhooks/payloadTemplates/{payloadTemplateId}"
body_params = [
diff --git a/meraki/api/batch/organizations.py b/meraki/api/batch/organizations.py
index 9041517f..846c2023 100644
--- a/meraki/api/batch/organizations.py
+++ b/meraki/api/batch/organizations.py
@@ -5,6 +5,37 @@ class ActionBatchOrganizations(object):
def __init__(self):
super(ActionBatchOrganizations, self).__init__()
+ def updateOrganization(self, organizationId: str, **kwargs):
+ """
+ **Update an organization**
+ https://developer.cisco.com/meraki/api-v1/#!update-organization
+
+ - organizationId (string): Organization ID
+ - name (string): The name of the organization
+ - management (object): Information about the organization's management system
+ - api (object): API-specific settings
+ - privacy (object): Privacy-related settings for the organization.
+ """
+
+ kwargs.update(locals())
+
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}"
+
+ body_params = [
+ "name",
+ "management",
+ "api",
+ "privacy",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "update",
+ "body": payload,
+ }
+ return action
+
def createOrganizationAdaptivePolicyAcl(self, organizationId: str, name: str, rules: list, ipVersion: str, **kwargs):
"""
**Creates new adaptive policy ACL**
@@ -25,7 +56,7 @@ def createOrganizationAdaptivePolicyAcl(self, organizationId: str, name: str, ru
f'''"ipVersion" cannot be "{kwargs["ipVersion"]}", & must be set to one of: {options}'''
)
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/acls"
body_params = [
@@ -63,8 +94,8 @@ def updateOrganizationAdaptivePolicyAcl(self, organizationId: str, aclId: str, *
f'''"ipVersion" cannot be "{kwargs["ipVersion"]}", & must be set to one of: {options}'''
)
- organizationId = urllib.parse.quote(organizationId, safe="")
- aclId = urllib.parse.quote(aclId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ aclId = urllib.parse.quote(str(aclId), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/acls/{aclId}"
body_params = [
@@ -90,8 +121,8 @@ def deleteOrganizationAdaptivePolicyAcl(self, organizationId: str, aclId: str):
- aclId (string): Acl ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- aclId = urllib.parse.quote(aclId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ aclId = urllib.parse.quote(str(aclId), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/acls/{aclId}"
action = {
@@ -114,7 +145,7 @@ def createOrganizationAdaptivePolicyGroup(self, organizationId: str, name: str,
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/groups"
body_params = [
@@ -146,8 +177,8 @@ def updateOrganizationAdaptivePolicyGroup(self, organizationId: str, id: str, **
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- id = urllib.parse.quote(id, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/groups/{id}"
body_params = [
@@ -173,8 +204,8 @@ def deleteOrganizationAdaptivePolicyGroup(self, organizationId: str, id: str):
- id (string): ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- id = urllib.parse.quote(id, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/groups/{id}"
action = {
@@ -203,7 +234,7 @@ def createOrganizationAdaptivePolicyPolicy(self, organizationId: str, sourceGrou
f'''"lastEntryRule" cannot be "{kwargs["lastEntryRule"]}", & must be set to one of: {options}'''
)
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/policies"
body_params = [
@@ -241,8 +272,8 @@ def updateOrganizationAdaptivePolicyPolicy(self, organizationId: str, id: str, *
f'''"lastEntryRule" cannot be "{kwargs["lastEntryRule"]}", & must be set to one of: {options}'''
)
- organizationId = urllib.parse.quote(organizationId, safe="")
- id = urllib.parse.quote(id, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/policies/{id}"
body_params = [
@@ -268,8 +299,8 @@ def deleteOrganizationAdaptivePolicyPolicy(self, organizationId: str, id: str):
- id (string): ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- id = urllib.parse.quote(id, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/policies/{id}"
action = {
@@ -289,7 +320,7 @@ def updateOrganizationAdaptivePolicySettings(self, organizationId: str, **kwargs
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/adaptivePolicy/settings"
body_params = [
@@ -333,7 +364,7 @@ def createOrganizationAlertsProfile(
]
assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/alerts/profiles"
body_params = [
@@ -381,8 +412,8 @@ def updateOrganizationAlertsProfile(self, organizationId: str, alertConfigId: st
]
assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
- organizationId = urllib.parse.quote(organizationId, safe="")
- alertConfigId = urllib.parse.quote(alertConfigId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ alertConfigId = urllib.parse.quote(str(alertConfigId), safe="")
resource = f"/organizations/{organizationId}/alerts/profiles/{alertConfigId}"
body_params = [
@@ -410,8 +441,8 @@ def deleteOrganizationAlertsProfile(self, organizationId: str, alertConfigId: st
- alertConfigId (string): Alert config ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- alertConfigId = urllib.parse.quote(alertConfigId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ alertConfigId = urllib.parse.quote(str(alertConfigId), safe="")
resource = f"/organizations/{organizationId}/alerts/profiles/{alertConfigId}"
action = {
@@ -439,7 +470,7 @@ def createOrganizationBrandingPolicy(self, organizationId: str, name: str, **kwa
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/brandingPolicies"
body_params = [
@@ -469,7 +500,7 @@ def updateOrganizationBrandingPoliciesPriorities(self, organizationId: str, **kw
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/brandingPolicies/priorities"
body_params = [
@@ -503,8 +534,8 @@ def updateOrganizationBrandingPolicy(self, organizationId: str, brandingPolicyId
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- brandingPolicyId = urllib.parse.quote(brandingPolicyId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ brandingPolicyId = urllib.parse.quote(str(brandingPolicyId), safe="")
resource = f"/organizations/{organizationId}/brandingPolicies/{brandingPolicyId}"
body_params = [
@@ -531,8 +562,8 @@ def deleteOrganizationBrandingPolicy(self, organizationId: str, brandingPolicyId
- brandingPolicyId (string): Branding policy ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- brandingPolicyId = urllib.parse.quote(brandingPolicyId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ brandingPolicyId = urllib.parse.quote(str(brandingPolicyId), safe="")
resource = f"/organizations/{organizationId}/brandingPolicies/{brandingPolicyId}"
action = {
@@ -554,7 +585,7 @@ def createOrganizationConfigTemplate(self, organizationId: str, name: str, **kwa
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/configTemplates"
body_params = [
@@ -583,8 +614,8 @@ def updateOrganizationConfigTemplate(self, organizationId: str, configTemplateId
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- configTemplateId = urllib.parse.quote(configTemplateId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ configTemplateId = urllib.parse.quote(str(configTemplateId), safe="")
resource = f"/organizations/{organizationId}/configTemplates/{configTemplateId}"
body_params = [
@@ -614,7 +645,7 @@ def createOrganizationDevicesCellularDataProfile(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/devices/cellular/data/profiles"
body_params = [
@@ -630,18 +661,44 @@ def createOrganizationDevicesCellularDataProfile(
}
return action
- def deleteOrganizationDevicesCellularDataProfile(self, organizationId: str, profileId: str):
+ def batchOrganizationDevicesCellularDataProfilesAssignmentsCreate(self, organizationId: str, items: list, **kwargs):
"""
- **Delete a cellular data management profile from this organization. Removes the profile, including its associated rules and node assignments, and notifies affected devices of the resulting configuration change.**
- https://developer.cisco.com/meraki/api-v1/#!delete-organization-devices-cellular-data-profile
+ **Assign devices to a Cellular Data Management Profile in batch. Creates up to 100 device-to-profile assignments and returns the created assignment IDs.**
+ https://developer.cisco.com/meraki/api-v1/#!batch-organization-devices-cellular-data-profiles-assignments-create
- organizationId (string): Organization ID
- - profileId (string): Profile ID
+ - items (array): List of device-to-profile assignments to create.
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- profileId = urllib.parse.quote(profileId, safe="")
- resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/{profileId}"
+ kwargs = locals()
+
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/assignments/batchCreate"
+
+ body_params = [
+ "items",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "create",
+ "body": payload,
+ }
+ return action
+
+ def bulkOrganizationDevicesCellularDataProfilesAssignmentsDelete(self, organizationId: str, items: list, **kwargs):
+ """
+ **Unassign devices from a Cellular Data Management Profile in batch. Removes up to 100 device-to-profile assignments and returns no response body on success.**
+ https://developer.cisco.com/meraki/api-v1/#!bulk-organization-devices-cellular-data-profiles-assignments-delete
+
+ - organizationId (string): Organization ID
+ - items (array): List of device-to-profile assignments to remove.
+ """
+
+ kwargs = locals()
+
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/assignments/bulkDelete"
action = {
"resource": resource,
@@ -662,8 +719,8 @@ def updateOrganizationDevicesCellularDataProfile(self, organizationId: str, rule
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- profileId = urllib.parse.quote(profileId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/{profileId}"
body_params = [
@@ -679,6 +736,25 @@ def updateOrganizationDevicesCellularDataProfile(self, organizationId: str, rule
}
return action
+ def deleteOrganizationDevicesCellularDataProfile(self, organizationId: str, profileId: str):
+ """
+ **Delete a cellular data management profile from this organization. Removes the profile, including its associated rules and node assignments, and notifies affected devices of the resulting configuration change.**
+ https://developer.cisco.com/meraki/api-v1/#!delete-organization-devices-cellular-data-profile
+
+ - organizationId (string): Organization ID
+ - profileId (string): Profile ID
+ """
+
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/{profileId}"
+
+ action = {
+ "resource": resource,
+ "operation": "destroy",
+ }
+ return action
+
def createOrganizationDevicesControllerMigration(self, organizationId: str, serials: list, target: str, **kwargs):
"""
**Migrate devices to another controller or management mode**
@@ -697,7 +773,7 @@ def createOrganizationDevicesControllerMigration(self, organizationId: str, seri
f'''"target" cannot be "{kwargs["target"]}", & must be set to one of: {options}'''
)
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/devices/controller/migrations"
body_params = [
@@ -724,7 +800,7 @@ def bulkUpdateOrganizationDevicesDetails(self, organizationId: str, serials: lis
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/devices/details/bulkUpdate"
body_params = [
@@ -750,7 +826,7 @@ def bulkOrganizationDevicesPacketCaptureCapturesDelete(self, organizationId: str
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/devices/packetCapture/captures/bulkDelete"
action = {
@@ -768,8 +844,8 @@ def deleteOrganizationDevicesPacketCaptureCapture(self, organizationId: str, cap
- captureId (string): Capture ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- captureId = urllib.parse.quote(captureId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ captureId = urllib.parse.quote(str(captureId), safe="")
resource = f"/organizations/{organizationId}/devices/packetCapture/captures/{captureId}"
action = {
@@ -795,7 +871,7 @@ def createOrganizationDevicesPacketCaptureSchedule(self, organizationId: str, de
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/devices/packetCapture/schedules"
body_params = [
@@ -826,7 +902,7 @@ def reorderOrganizationDevicesPacketCaptureSchedules(self, organizationId: str,
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/devices/packetCapture/schedules/reorder"
body_params = [
@@ -858,8 +934,8 @@ def updateOrganizationDevicesPacketCaptureSchedule(self, organizationId: str, sc
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- scheduleId = urllib.parse.quote(scheduleId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ scheduleId = urllib.parse.quote(str(scheduleId), safe="")
resource = f"/organizations/{organizationId}/devices/packetCapture/schedules/{scheduleId}"
body_params = [
@@ -890,8 +966,8 @@ def deleteOrganizationDevicesPacketCaptureSchedule(self, organizationId: str, sc
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
- scheduleId = urllib.parse.quote(scheduleId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ scheduleId = urllib.parse.quote(str(scheduleId), safe="")
resource = f"/organizations/{organizationId}/devices/packetCapture/schedules/{scheduleId}"
action = {
@@ -912,8 +988,8 @@ def updateOrganizationEarlyAccessFeaturesOptIn(self, organizationId: str, optInI
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- optInId = urllib.parse.quote(optInId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ optInId = urllib.parse.quote(str(optInId), safe="")
resource = f"/organizations/{organizationId}/earlyAccess/features/optIns/{optInId}"
body_params = [
@@ -938,7 +1014,7 @@ def disableOrganizationIntegrationsXdrNetworks(self, organizationId: str, networ
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/integrations/xdr/networks/disable"
body_params = [
@@ -963,7 +1039,7 @@ def enableOrganizationIntegrationsXdrNetworks(self, organizationId: str, network
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/integrations/xdr/networks/enable"
body_params = [
@@ -989,7 +1065,7 @@ def claimOrganizationInventoryOrders(self, organizationId: str, claimId: str, **
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/inventory/orders/claim"
body_params = [
@@ -1017,7 +1093,7 @@ def assignOrganizationLicensesSeats(self, organizationId: str, licenseId: str, n
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/licenses/assignSeats"
body_params = [
@@ -1045,7 +1121,7 @@ def moveOrganizationLicenses(self, organizationId: str, destOrganizationId: str,
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/licenses/move"
body_params = [
@@ -1075,7 +1151,7 @@ def moveOrganizationLicensesSeats(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/licenses/moveSeats"
body_params = [
@@ -1103,7 +1179,7 @@ def renewOrganizationLicensesSeats(self, organizationId: str, licenseIdToRenew:
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/licenses/renewSeats"
body_params = [
@@ -1130,8 +1206,8 @@ def updateOrganizationLicense(self, organizationId: str, licenseId: str, **kwarg
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- licenseId = urllib.parse.quote(licenseId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ licenseId = urllib.parse.quote(str(licenseId), safe="")
resource = f"/organizations/{organizationId}/licenses/{licenseId}"
body_params = [
@@ -1164,12 +1240,13 @@ def updateOrganizationLoginSecurity(self, organizationId: str, **kwargs):
- enforceTwoFactorAuth (boolean): Boolean indicating whether users in this organization will be required to use an extra verification code when logging in to Dashboard. This code will be sent to their mobile phone via SMS, or can be generated by the authenticator application.
- enforceLoginIpRanges (boolean): Boolean indicating whether organization will restrict access to Dashboard (including the API) from certain IP addresses.
- loginIpRanges (array): List of acceptable IP ranges. Entries can be single IP addresses, IP address ranges, and CIDR subnets.
+ - enforceLockedIpSessions (boolean): Boolean indicating whether Dashboard sessions are locked to the IP address from which they were established. Only applicable to organizations that support locked-IP sessions; otherwise the parameter is ignored.
- apiAuthentication (object): Details for indicating whether organization will restrict access to API (but not Dashboard) to certain IP addresses.
"""
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/loginSecurity"
body_params = [
@@ -1186,6 +1263,7 @@ def updateOrganizationLoginSecurity(self, organizationId: str, **kwargs):
"enforceTwoFactorAuth",
"enforceLoginIpRanges",
"loginIpRanges",
+ "enforceLockedIpSessions",
"apiAuthentication",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -1212,7 +1290,7 @@ def createOrganizationNetwork(self, organizationId: str, name: str, productTypes
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/networks"
body_params = [
@@ -1244,7 +1322,7 @@ def combineOrganizationNetworks(self, organizationId: str, name: str, networkIds
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/networks/combine"
body_params = [
@@ -1272,7 +1350,7 @@ def createOrganizationPoliciesGlobalFirewallRuleset(self, organizationId: str, n
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policies/global/firewall/rulesets"
body_params = [
@@ -1313,7 +1391,7 @@ def createOrganizationPoliciesGlobalFirewallRulesetsRule(
f'''"policy" cannot be "{kwargs["policy"]}", & must be set to one of: {options}'''
)
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policies/global/firewall/rulesets/rules"
body_params = [
@@ -1343,8 +1421,8 @@ def deleteOrganizationPoliciesGlobalFirewallRulesetsRule(self, organizationId: s
- ruleId (string): Rule ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- ruleId = urllib.parse.quote(ruleId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ ruleId = urllib.parse.quote(str(ruleId), safe="")
resource = f"/organizations/{organizationId}/policies/global/firewall/rulesets/rules/{ruleId}"
action = {
@@ -1378,8 +1456,8 @@ def updateOrganizationPoliciesGlobalFirewallRulesetsRule(self, organizationId: s
f'''"policy" cannot be "{kwargs["policy"]}", & must be set to one of: {options}'''
)
- organizationId = urllib.parse.quote(organizationId, safe="")
- ruleId = urllib.parse.quote(ruleId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ ruleId = urllib.parse.quote(str(ruleId), safe="")
resource = f"/organizations/{organizationId}/policies/global/firewall/rulesets/rules/{ruleId}"
body_params = [
@@ -1413,8 +1491,8 @@ def updateOrganizationPoliciesGlobalFirewallRuleset(self, organizationId: str, r
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- rulesetId = urllib.parse.quote(rulesetId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ rulesetId = urllib.parse.quote(str(rulesetId), safe="")
resource = f"/organizations/{organizationId}/policies/global/firewall/rulesets/{rulesetId}"
body_params = [
@@ -1438,8 +1516,8 @@ def deleteOrganizationPoliciesGlobalFirewallRuleset(self, organizationId: str, r
- rulesetId (string): Ruleset ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- rulesetId = urllib.parse.quote(rulesetId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ rulesetId = urllib.parse.quote(str(rulesetId), safe="")
resource = f"/organizations/{organizationId}/policies/global/firewall/rulesets/{rulesetId}"
action = {
@@ -1460,7 +1538,7 @@ def createOrganizationPoliciesGlobalGroupPolicy(self, organizationId: str, name:
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policies/global/group/policies"
body_params = [
@@ -1489,7 +1567,7 @@ def assignOrganizationPoliciesGlobalGroupPoliciesAdaptivePolicyGroups(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policies/global/group/policies/adaptivePolicyGroups/assign"
body_params = [
@@ -1518,7 +1596,7 @@ def removeOrganizationPoliciesGlobalGroupPoliciesAdaptivePolicyGroups(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policies/global/group/policies/adaptivePolicyGroups/remove"
body_params = [
@@ -1548,7 +1626,7 @@ def createOrganizationPoliciesGlobalGroupPoliciesFirewallRulesetsAssignment(
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policies/global/group/policies/firewall/rulesets/assignments"
body_params = [
@@ -1580,8 +1658,8 @@ def updateOrganizationPoliciesGlobalGroupPoliciesFirewallRulesetsAssignment(
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- assignmentId = urllib.parse.quote(assignmentId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ assignmentId = urllib.parse.quote(str(assignmentId), safe="")
resource = (
f"/organizations/{organizationId}/policies/global/group/policies/firewall/rulesets/assignments/{assignmentId}"
)
@@ -1608,8 +1686,8 @@ def deleteOrganizationPoliciesGlobalGroupPoliciesFirewallRulesetsAssignment(self
- assignmentId (string): Assignment ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- assignmentId = urllib.parse.quote(assignmentId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ assignmentId = urllib.parse.quote(str(assignmentId), safe="")
resource = (
f"/organizations/{organizationId}/policies/global/group/policies/firewall/rulesets/assignments/{assignmentId}"
)
@@ -1633,8 +1711,8 @@ def updateOrganizationPoliciesGlobalGroupPolicy(self, organizationId: str, polic
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- policyId = urllib.parse.quote(policyId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ policyId = urllib.parse.quote(str(policyId), safe="")
resource = f"/organizations/{organizationId}/policies/global/group/policies/{policyId}"
body_params = [
@@ -1658,8 +1736,8 @@ def deleteOrganizationPoliciesGlobalGroupPolicy(self, organizationId: str, polic
- policyId (string): Policy ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- policyId = urllib.parse.quote(policyId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ policyId = urllib.parse.quote(str(policyId), safe="")
resource = f"/organizations/{organizationId}/policies/global/group/policies/{policyId}"
action = {
@@ -1670,23 +1748,27 @@ def deleteOrganizationPoliciesGlobalGroupPolicy(self, organizationId: str, polic
def createOrganizationPolicyObject(self, organizationId: str, name: str, category: str, type: str, **kwargs):
"""
- **Creates a new Policy Object.**
+ **Creates a new Policy Object. Note: type `ipAndMask` is deprecated; use `cidr`.**
https://developer.cisco.com/meraki/api-v1/#!create-organization-policy-object
- organizationId (string): Organization ID
- name (string): Name of a policy object, unique within the organization (alphanumeric, space, dash, or underscore characters only)
- category (string): Category of a policy object (one of: adaptivePolicy, network)
- - type (string): Type of a policy object (one of: adaptivePolicyIpv4Cidr, cidr, fqdn, ipAndMask)
+ - type (string): Type of a policy object (one of: adaptivePolicyIpv4Cidr, cidr, fqdn). DEPRECATED: `ipAndMask` is deprecated and will be removed in a future release. Use `cidr` instead.
- cidr (string): CIDR Value of a policy object (e.g. 10.11.12.1/24")
- fqdn (string): Fully qualified domain name of policy object (e.g. "example.com")
- - mask (string): Mask of a policy object (e.g. "255.255.0.0")
- - ip (string): IP Address of a policy object (e.g. "1.2.3.4")
+ - mask (string): Mask of a policy object (e.g. "255.255.0.0"). Used only with deprecated `type=ipAndMask`.
+ - ip (string): IP Address of a policy object (e.g. "1.2.3.4"). Used only with deprecated `type=ipAndMask`.
- groupIds (array): The IDs of policy object groups the policy object belongs to
"""
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ if "type" in kwargs:
+ options = ["adaptivePolicyIpv4Cidr", "cidr", "fqdn"]
+ assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
+
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policyObjects"
body_params = [
@@ -1720,7 +1802,7 @@ def createOrganizationPolicyObjectsGroup(self, organizationId: str, name: str, *
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/policyObjects/groups"
body_params = [
@@ -1749,8 +1831,8 @@ def updateOrganizationPolicyObjectsGroup(self, organizationId: str, policyObject
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- policyObjectGroupId = urllib.parse.quote(policyObjectGroupId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ policyObjectGroupId = urllib.parse.quote(str(policyObjectGroupId), safe="")
resource = f"/organizations/{organizationId}/policyObjects/groups/{policyObjectGroupId}"
body_params = [
@@ -1774,8 +1856,8 @@ def deleteOrganizationPolicyObjectsGroup(self, organizationId: str, policyObject
- policyObjectGroupId (string): Policy object group ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- policyObjectGroupId = urllib.parse.quote(policyObjectGroupId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ policyObjectGroupId = urllib.parse.quote(str(policyObjectGroupId), safe="")
resource = f"/organizations/{organizationId}/policyObjects/groups/{policyObjectGroupId}"
action = {
@@ -1786,7 +1868,7 @@ def deleteOrganizationPolicyObjectsGroup(self, organizationId: str, policyObject
def updateOrganizationPolicyObject(self, organizationId: str, policyObjectId: str, **kwargs):
"""
- **Updates a Policy Object.**
+ **Updates a Policy Object. Note: type `ipAndMask` is deprecated; use `cidr`.**
https://developer.cisco.com/meraki/api-v1/#!update-organization-policy-object
- organizationId (string): Organization ID
@@ -1794,15 +1876,15 @@ def updateOrganizationPolicyObject(self, organizationId: str, policyObjectId: st
- name (string): Name of a policy object, unique within the organization (alphanumeric, space, dash, or underscore characters only)
- cidr (string): CIDR Value of a policy object (e.g. 10.11.12.1/24")
- fqdn (string): Fully qualified domain name of policy object (e.g. "example.com")
- - mask (string): Mask of a policy object (e.g. "255.255.0.0")
- - ip (string): IP Address of a policy object (e.g. "1.2.3.4")
+ - mask (string): Mask of a policy object (e.g. "255.255.0.0"). Used only with deprecated `type=ipAndMask`.
+ - ip (string): IP Address of a policy object (e.g. "1.2.3.4"). Used only with deprecated `type=ipAndMask`.
- groupIds (array): The IDs of policy object groups the policy object belongs to
"""
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- policyObjectId = urllib.parse.quote(policyObjectId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ policyObjectId = urllib.parse.quote(str(policyObjectId), safe="")
resource = f"/organizations/{organizationId}/policyObjects/{policyObjectId}"
body_params = [
@@ -1830,8 +1912,8 @@ def deleteOrganizationPolicyObject(self, organizationId: str, policyObjectId: st
- policyObjectId (string): Policy object ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- policyObjectId = urllib.parse.quote(policyObjectId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ policyObjectId = urllib.parse.quote(str(policyObjectId), safe="")
resource = f"/organizations/{organizationId}/policyObjects/{policyObjectId}"
action = {
@@ -1853,7 +1935,7 @@ def createOrganizationSamlIdp(self, organizationId: str, x509certSha1Fingerprint
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/saml/idps"
body_params = [
@@ -1883,8 +1965,8 @@ def updateOrganizationSamlIdp(self, organizationId: str, idpId: str, **kwargs):
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- idpId = urllib.parse.quote(idpId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ idpId = urllib.parse.quote(str(idpId), safe="")
resource = f"/organizations/{organizationId}/saml/idps/{idpId}"
body_params = [
@@ -1909,8 +1991,8 @@ def deleteOrganizationSamlIdp(self, organizationId: str, idpId: str):
- idpId (string): Idp ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- idpId = urllib.parse.quote(idpId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ idpId = urllib.parse.quote(str(idpId), safe="")
resource = f"/organizations/{organizationId}/saml/idps/{idpId}"
action = {
@@ -1930,7 +2012,7 @@ def batchOrganizationSaseConnectorsDelete(self, organizationId: str, **kwargs):
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/sase/connectors/batchDelete"
body_params = [
@@ -1955,7 +2037,7 @@ def createOrganizationSaseIntegration(self, organizationId: str, api: dict, **kw
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/sase/integrations"
body_params = [
@@ -1978,8 +2060,8 @@ def deleteOrganizationSaseIntegration(self, organizationId: str, integrationId:
- integrationId (string): Integration ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- integrationId = urllib.parse.quote(integrationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ integrationId = urllib.parse.quote(str(integrationId), safe="")
resource = f"/organizations/{organizationId}/sase/integrations/{integrationId}"
action = {
@@ -1988,24 +2070,22 @@ def deleteOrganizationSaseIntegration(self, organizationId: str, integrationId:
}
return action
- def attachOrganizationSaseSites(self, organizationId: str, **kwargs):
+ def attachOrganizationSaseSites(self, organizationId: str, items: list, **kwargs):
"""
**Attach sites in this organization to Secure Access. For an organization, a maximum of 2500 sites can be attached if they are in spoke mode or a maximum of 10 sites can be attached in hub mode.**
https://developer.cisco.com/meraki/api-v1/#!attach-organization-sase-sites
- organizationId (string): Organization ID
- items (array): List of Meraki SD-WAN sites with the associated regions to be attached.
- - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
"""
- kwargs.update(locals())
+ kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/sase/sites/attach"
body_params = [
"items",
- "callback",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
action = {
@@ -2022,12 +2102,11 @@ def detachOrganizationSaseSites(self, organizationId: str, **kwargs):
- organizationId (string): Organization ID
- items (array): List of Secure Access sites to be detached.
- - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
"""
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/sase/sites/detach"
action = {
@@ -2048,8 +2127,8 @@ def updateOrganizationSaseSite(self, organizationId: str, siteId: str, **kwargs)
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- siteId = urllib.parse.quote(siteId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ siteId = urllib.parse.quote(str(siteId), safe="")
resource = f"/organizations/{organizationId}/sase/sites/{siteId}"
body_params = [
@@ -2064,6 +2143,54 @@ def updateOrganizationSaseSite(self, organizationId: str, siteId: str, **kwargs)
}
return action
+ def updateOrganizationSnmp(self, organizationId: str, **kwargs):
+ """
+ **Update the SNMP settings for an organization**
+ https://developer.cisco.com/meraki/api-v1/#!update-organization-snmp
+
+ - organizationId (string): Organization ID
+ - v2cEnabled (boolean): Boolean indicating whether SNMP version 2c is enabled for the organization.
+ - v3Enabled (boolean): Boolean indicating whether SNMP version 3 is enabled for the organization.
+ - v3AuthMode (string): The SNMP version 3 authentication mode. Can be either 'MD5' or 'SHA'.
+ - v3AuthPass (string): The SNMP version 3 authentication password. Must be at least 8 characters if specified.
+ - v3PrivMode (string): The SNMP version 3 privacy mode. Can be either 'DES' or 'AES128'.
+ - v3PrivPass (string): The SNMP version 3 privacy password. Must be at least 8 characters if specified.
+ - peerIps (array): The list of IPv4 addresses that are allowed to access the SNMP server.
+ """
+
+ kwargs.update(locals())
+
+ if "v3AuthMode" in kwargs:
+ options = ["MD5", "SHA"]
+ assert kwargs["v3AuthMode"] in options, (
+ f'''"v3AuthMode" cannot be "{kwargs["v3AuthMode"]}", & must be set to one of: {options}'''
+ )
+ if "v3PrivMode" in kwargs:
+ options = ["AES128", "DES"]
+ assert kwargs["v3PrivMode"] in options, (
+ f'''"v3PrivMode" cannot be "{kwargs["v3PrivMode"]}", & must be set to one of: {options}'''
+ )
+
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/snmp"
+
+ body_params = [
+ "v2cEnabled",
+ "v3Enabled",
+ "v3AuthMode",
+ "v3AuthPass",
+ "v3PrivMode",
+ "v3PrivPass",
+ "peerIps",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "update",
+ "body": payload,
+ }
+ return action
+
def deleteOrganizationSplashAsset(self, organizationId: str, id: str):
"""
**Delete a Splash Theme Asset**
@@ -2073,8 +2200,8 @@ def deleteOrganizationSplashAsset(self, organizationId: str, id: str):
- id (string): ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- id = urllib.parse.quote(id, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/organizations/{organizationId}/splash/assets/{id}"
action = {
@@ -2095,7 +2222,7 @@ def createOrganizationSplashTheme(self, organizationId: str, **kwargs):
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/splash/themes"
body_params = [
@@ -2119,8 +2246,8 @@ def deleteOrganizationSplashTheme(self, organizationId: str, id: str):
- id (string): ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- id = urllib.parse.quote(id, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/organizations/{organizationId}/splash/themes/{id}"
action = {
@@ -2142,8 +2269,8 @@ def createOrganizationSplashThemeAsset(self, organizationId: str, themeIdentifie
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- themeIdentifier = urllib.parse.quote(themeIdentifier, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ themeIdentifier = urllib.parse.quote(str(themeIdentifier), safe="")
resource = f"/organizations/{organizationId}/splash/themes/{themeIdentifier}/assets"
body_params = [
diff --git a/meraki/api/batch/sensor.py b/meraki/api/batch/sensor.py
index 777ea1c2..c8363c1d 100644
--- a/meraki/api/batch/sensor.py
+++ b/meraki/api/batch/sensor.py
@@ -22,7 +22,7 @@ def createDeviceSensorCommand(self, serial: str, operation: str, **kwargs):
f'''"operation" cannot be "{kwargs["operation"]}", & must be set to one of: {options}'''
)
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/sensor/commands"
body_params = [
@@ -47,7 +47,7 @@ def updateDeviceSensorRelationships(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/sensor/relationships"
body_params = [
@@ -78,7 +78,7 @@ def createNetworkSensorAlertsProfile(self, networkId: str, name: str, conditions
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/sensor/alerts/profiles"
body_params = [
@@ -116,8 +116,8 @@ def updateNetworkSensorAlertsProfile(self, networkId: str, id: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- id = urllib.parse.quote(id, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/networks/{networkId}/sensor/alerts/profiles/{id}"
body_params = [
@@ -146,8 +146,8 @@ def deleteNetworkSensorAlertsProfile(self, networkId: str, id: str):
- id (string): ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- id = urllib.parse.quote(id, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/networks/{networkId}/sensor/alerts/profiles/{id}"
action = {
@@ -168,8 +168,8 @@ def updateNetworkSensorMqttBroker(self, networkId: str, mqttBrokerId: str, enabl
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
- mqttBrokerId = urllib.parse.quote(mqttBrokerId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ mqttBrokerId = urllib.parse.quote(str(mqttBrokerId), safe="")
resource = f"/networks/{networkId}/sensor/mqttBrokers/{mqttBrokerId}"
body_params = [
diff --git a/meraki/api/batch/sm.py b/meraki/api/batch/sm.py
index fab4bc90..939db0ef 100644
--- a/meraki/api/batch/sm.py
+++ b/meraki/api/batch/sm.py
@@ -14,8 +14,8 @@ def deleteNetworkSmUserAccessDevice(self, networkId: str, userAccessDeviceId: st
- userAccessDeviceId (string): User access device ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- userAccessDeviceId = urllib.parse.quote(userAccessDeviceId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ userAccessDeviceId = urllib.parse.quote(str(userAccessDeviceId), safe="")
resource = f"/networks/{networkId}/sm/userAccessDevices/{userAccessDeviceId}"
action = {
@@ -41,7 +41,7 @@ def createOrganizationSmAdminsRole(self, organizationId: str, name: str, **kwarg
options = ["all_tags", "some", "without_all_tags", "without_some"]
assert kwargs["scope"] in options, f'''"scope" cannot be "{kwargs["scope"]}", & must be set to one of: {options}'''
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/sm/admins/roles"
body_params = [
@@ -75,8 +75,8 @@ def updateOrganizationSmAdminsRole(self, organizationId: str, roleId: str, **kwa
options = ["all_tags", "some", "without_all_tags", "without_some"]
assert kwargs["scope"] in options, f'''"scope" cannot be "{kwargs["scope"]}", & must be set to one of: {options}'''
- organizationId = urllib.parse.quote(organizationId, safe="")
- roleId = urllib.parse.quote(roleId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ roleId = urllib.parse.quote(str(roleId), safe="")
resource = f"/organizations/{organizationId}/sm/admins/roles/{roleId}"
body_params = [
@@ -101,8 +101,8 @@ def deleteOrganizationSmAdminsRole(self, organizationId: str, roleId: str):
- roleId (string): Role ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- roleId = urllib.parse.quote(roleId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ roleId = urllib.parse.quote(str(roleId), safe="")
resource = f"/organizations/{organizationId}/sm/admins/roles/{roleId}"
action = {
@@ -122,7 +122,7 @@ def updateOrganizationSmSentryPoliciesAssignments(self, organizationId: str, ite
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/sm/sentry/policies/assignments"
body_params = [
diff --git a/meraki/api/batch/spaces.py b/meraki/api/batch/spaces.py
index 990bdcaa..66623f39 100644
--- a/meraki/api/batch/spaces.py
+++ b/meraki/api/batch/spaces.py
@@ -13,7 +13,7 @@ def removeOrganizationSpacesIntegration(self, organizationId: str):
- organizationId (string): Organization ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/spaces/integration/remove"
action = {
diff --git a/meraki/api/batch/switch.py b/meraki/api/batch/switch.py
index 436f449e..670ef832 100644
--- a/meraki/api/batch/switch.py
+++ b/meraki/api/batch/switch.py
@@ -7,7 +7,7 @@ def __init__(self):
def cycleDeviceSwitchPorts(self, serial: str, ports: list, **kwargs):
"""
- **Cycle a set of switch ports**
+ **Cycle a set of switch ports on non-Catalyst MS devices. For Catalyst support, use /devices/{serial}/liveTools/ports/cycle, which supports all switch product families.**
https://developer.cisco.com/meraki/api-v1/#!cycle-device-switch-ports
- serial (string): Serial
@@ -16,7 +16,7 @@ def cycleDeviceSwitchPorts(self, serial: str, ports: list, **kwargs):
kwargs = locals()
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/switch/ports/cycle"
body_params = [
@@ -88,8 +88,8 @@ def updateDeviceSwitchPort(self, serial: str, portId: str, **kwargs):
f'''"accessPolicyType" cannot be "{kwargs["accessPolicyType"]}", & must be set to one of: {options}'''
)
- serial = urllib.parse.quote(serial, safe="")
- portId = urllib.parse.quote(portId, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
+ portId = urllib.parse.quote(str(portId), safe="")
resource = f"/devices/{serial}/switch/ports/{portId}"
body_params = [
@@ -163,7 +163,7 @@ def createDeviceSwitchRoutingInterface(self, serial: str, name: str, **kwargs):
f'''"multicastRouting" cannot be "{kwargs["multicastRouting"]}", & must be set to one of: {options}'''
)
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/switch/routing/interfaces"
body_params = [
@@ -218,8 +218,8 @@ def updateDeviceSwitchRoutingInterface(self, serial: str, interfaceId: str, **kw
f'''"multicastRouting" cannot be "{kwargs["multicastRouting"]}", & must be set to one of: {options}'''
)
- serial = urllib.parse.quote(serial, safe="")
- interfaceId = urllib.parse.quote(interfaceId, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
resource = f"/devices/{serial}/switch/routing/interfaces/{interfaceId}"
body_params = [
@@ -253,8 +253,8 @@ def deleteDeviceSwitchRoutingInterface(self, serial: str, interfaceId: str):
- interfaceId (string): Interface ID
"""
- serial = urllib.parse.quote(serial, safe="")
- interfaceId = urllib.parse.quote(interfaceId, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
resource = f"/devices/{serial}/switch/routing/interfaces/{interfaceId}"
action = {
@@ -306,8 +306,8 @@ def updateDeviceSwitchRoutingInterfaceDhcp(self, serial: str, interfaceId: str,
f'''"dnsNameserversOption" cannot be "{kwargs["dnsNameserversOption"]}", & must be set to one of: {options}'''
)
- serial = urllib.parse.quote(serial, safe="")
- interfaceId = urllib.parse.quote(interfaceId, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
resource = f"/devices/{serial}/switch/routing/interfaces/{interfaceId}/dhcp"
body_params = [
@@ -347,7 +347,7 @@ def createDeviceSwitchRoutingStaticRoute(self, serial: str, subnet: str, nextHop
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/switch/routing/staticRoutes"
body_params = [
@@ -384,8 +384,8 @@ def updateDeviceSwitchRoutingStaticRoute(self, serial: str, staticRouteId: str,
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
- staticRouteId = urllib.parse.quote(staticRouteId, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
+ staticRouteId = urllib.parse.quote(str(staticRouteId), safe="")
resource = f"/devices/{serial}/switch/routing/staticRoutes/{staticRouteId}"
body_params = [
@@ -414,8 +414,8 @@ def deleteDeviceSwitchRoutingStaticRoute(self, serial: str, staticRouteId: str):
- staticRouteId (string): Static route ID
"""
- serial = urllib.parse.quote(serial, safe="")
- staticRouteId = urllib.parse.quote(staticRouteId, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
+ staticRouteId = urllib.parse.quote(str(staticRouteId), safe="")
resource = f"/devices/{serial}/switch/routing/staticRoutes/{staticRouteId}"
action = {
@@ -436,7 +436,7 @@ def updateDeviceSwitchWarmSpare(self, serial: str, enabled: bool, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/switch/warmSpare"
body_params = [
@@ -493,7 +493,7 @@ def createNetworkSwitchAccessPolicy(
f'''"accessPolicyType" cannot be "{kwargs["accessPolicyType"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/accessPolicies"
body_params = [
@@ -566,8 +566,8 @@ def updateNetworkSwitchAccessPolicy(self, networkId: str, accessPolicyNumber: st
f'''"accessPolicyType" cannot be "{kwargs["accessPolicyType"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- accessPolicyNumber = urllib.parse.quote(accessPolicyNumber, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ accessPolicyNumber = urllib.parse.quote(str(accessPolicyNumber), safe="")
resource = f"/networks/{networkId}/switch/accessPolicies/{accessPolicyNumber}"
body_params = [
@@ -608,8 +608,8 @@ def deleteNetworkSwitchAccessPolicy(self, networkId: str, accessPolicyNumber: st
- accessPolicyNumber (string): Access policy number
"""
- networkId = urllib.parse.quote(networkId, safe="")
- accessPolicyNumber = urllib.parse.quote(accessPolicyNumber, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ accessPolicyNumber = urllib.parse.quote(str(accessPolicyNumber), safe="")
resource = f"/networks/{networkId}/switch/accessPolicies/{accessPolicyNumber}"
action = {
@@ -632,7 +632,7 @@ def updateNetworkSwitchAlternateManagementInterface(self, networkId: str, **kwar
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/alternateManagementInterface"
body_params = [
@@ -670,7 +670,7 @@ def updateNetworkSwitchDhcpServerPolicy(self, networkId: str, **kwargs):
f'''"defaultPolicy" cannot be "{kwargs["defaultPolicy"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/dhcpServerPolicy"
body_params = [
@@ -703,7 +703,7 @@ def createNetworkSwitchDhcpServerPolicyArpInspectionTrustedServer(
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/dhcpServerPolicy/arpInspection/trustedServers"
body_params = [
@@ -733,8 +733,8 @@ def updateNetworkSwitchDhcpServerPolicyArpInspectionTrustedServer(self, networkI
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- trustedServerId = urllib.parse.quote(trustedServerId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ trustedServerId = urllib.parse.quote(str(trustedServerId), safe="")
resource = f"/networks/{networkId}/switch/dhcpServerPolicy/arpInspection/trustedServers/{trustedServerId}"
body_params = [
@@ -759,8 +759,8 @@ def deleteNetworkSwitchDhcpServerPolicyArpInspectionTrustedServer(self, networkI
- trustedServerId (string): Trusted server ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- trustedServerId = urllib.parse.quote(trustedServerId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ trustedServerId = urllib.parse.quote(str(trustedServerId), safe="")
resource = f"/networks/{networkId}/switch/dhcpServerPolicy/arpInspection/trustedServers/{trustedServerId}"
action = {
@@ -780,7 +780,7 @@ def updateNetworkSwitchDscpToCosMappings(self, networkId: str, mappings: list, *
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/dscpToCosMappings"
body_params = [
@@ -806,7 +806,7 @@ def createNetworkSwitchLinkAggregation(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/linkAggregations"
body_params = [
@@ -834,8 +834,8 @@ def updateNetworkSwitchLinkAggregation(self, networkId: str, linkAggregationId:
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- linkAggregationId = urllib.parse.quote(linkAggregationId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ linkAggregationId = urllib.parse.quote(str(linkAggregationId), safe="")
resource = f"/networks/{networkId}/switch/linkAggregations/{linkAggregationId}"
body_params = [
@@ -859,8 +859,8 @@ def deleteNetworkSwitchLinkAggregation(self, networkId: str, linkAggregationId:
- linkAggregationId (string): Link aggregation ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- linkAggregationId = urllib.parse.quote(linkAggregationId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ linkAggregationId = urllib.parse.quote(str(linkAggregationId), safe="")
resource = f"/networks/{networkId}/switch/linkAggregations/{linkAggregationId}"
action = {
@@ -881,7 +881,7 @@ def updateNetworkSwitchMtu(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/mtu"
body_params = [
@@ -912,8 +912,8 @@ def updateNetworkSwitchPortSchedule(self, networkId: str, portScheduleId: str, *
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- portScheduleId = urllib.parse.quote(portScheduleId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ portScheduleId = urllib.parse.quote(str(portScheduleId), safe="")
resource = f"/networks/{networkId}/switch/portSchedules/{portScheduleId}"
body_params = [
@@ -951,7 +951,7 @@ def createNetworkSwitchQosRule(self, networkId: str, vlan: int, **kwargs):
f'''"protocol" cannot be "{kwargs["protocol"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/qosRules"
body_params = [
@@ -982,7 +982,7 @@ def updateNetworkSwitchQosRulesOrder(self, networkId: str, ruleIds: list, **kwar
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/qosRules/order"
body_params = [
@@ -1005,8 +1005,8 @@ def deleteNetworkSwitchQosRule(self, networkId: str, qosRuleId: str):
- qosRuleId (string): Qos rule ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- qosRuleId = urllib.parse.quote(qosRuleId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ qosRuleId = urllib.parse.quote(str(qosRuleId), safe="")
resource = f"/networks/{networkId}/switch/qosRules/{qosRuleId}"
action = {
@@ -1039,8 +1039,8 @@ def updateNetworkSwitchQosRule(self, networkId: str, qosRuleId: str, **kwargs):
f'''"protocol" cannot be "{kwargs["protocol"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- qosRuleId = urllib.parse.quote(qosRuleId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ qosRuleId = urllib.parse.quote(str(qosRuleId), safe="")
resource = f"/networks/{networkId}/switch/qosRules/{qosRuleId}"
body_params = [
@@ -1072,7 +1072,7 @@ def updateNetworkSwitchRoutingMulticast(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/routing/multicast"
body_params = [
@@ -1082,7 +1082,7 @@ def updateNetworkSwitchRoutingMulticast(self, networkId: str, **kwargs):
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
action = {
"resource": resource,
- "operation": "update",
+ "operation": "ms/multicast/actions/update",
"body": payload,
}
return action
@@ -1102,7 +1102,7 @@ def createNetworkSwitchRoutingMulticastRendezvousPoint(
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/routing/multicast/rendezvousPoints"
body_params = [
@@ -1127,8 +1127,8 @@ def deleteNetworkSwitchRoutingMulticastRendezvousPoint(self, networkId: str, ren
- rendezvousPointId (string): Rendezvous point ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- rendezvousPointId = urllib.parse.quote(rendezvousPointId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ rendezvousPointId = urllib.parse.quote(str(rendezvousPointId), safe="")
resource = f"/networks/{networkId}/switch/routing/multicast/rendezvousPoints/{rendezvousPointId}"
action = {
@@ -1153,8 +1153,8 @@ def updateNetworkSwitchRoutingMulticastRendezvousPoint(
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- rendezvousPointId = urllib.parse.quote(rendezvousPointId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ rendezvousPointId = urllib.parse.quote(str(rendezvousPointId), safe="")
resource = f"/networks/{networkId}/switch/routing/multicast/rendezvousPoints/{rendezvousPointId}"
body_params = [
@@ -1188,7 +1188,7 @@ def updateNetworkSwitchRoutingOspf(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/routing/ospf"
body_params = [
@@ -1225,7 +1225,7 @@ def updateNetworkSwitchSettings(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/settings"
body_params = [
@@ -1245,6 +1245,35 @@ def updateNetworkSwitchSettings(self, networkId: str, **kwargs):
}
return action
+ def updateNetworkSwitchStack(self, networkId: str, switchStackId: str, **kwargs):
+ """
+ **Update a switch stack. At least one of 'name' or 'members' must be provided. If 'members' is provided, it replaces the entire stack membership.**
+ https://developer.cisco.com/meraki/api-v1/#!update-network-switch-stack
+
+ - networkId (string): Network ID
+ - switchStackId (string): Switch stack ID
+ - name (string): The name of the switch stack
+ - members (array): The complete list of switches that should be in the stack. Minimum 2 and maximum 8 members. Omitting this field leaves stack membership unchanged.
+ """
+
+ kwargs.update(locals())
+
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
+ resource = f"/networks/{networkId}/switch/stacks/{switchStackId}"
+
+ body_params = [
+ "name",
+ "members",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+ action = {
+ "resource": resource,
+ "operation": "stacks/actions/update",
+ "body": payload,
+ }
+ return action
+
def createNetworkSwitchStackRoutingInterface(self, networkId: str, switchStackId: str, name: str, **kwargs):
"""
**Create a layer 3 interface for a switch stack**
@@ -1278,8 +1307,8 @@ def createNetworkSwitchStackRoutingInterface(self, networkId: str, switchStackId
f'''"multicastRouting" cannot be "{kwargs["multicastRouting"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- switchStackId = urllib.parse.quote(switchStackId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
resource = f"/networks/{networkId}/switch/stacks/{switchStackId}/routing/interfaces"
body_params = [
@@ -1335,9 +1364,9 @@ def updateNetworkSwitchStackRoutingInterface(self, networkId: str, switchStackId
f'''"multicastRouting" cannot be "{kwargs["multicastRouting"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- switchStackId = urllib.parse.quote(switchStackId, safe="")
- interfaceId = urllib.parse.quote(interfaceId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
resource = f"/networks/{networkId}/switch/stacks/{switchStackId}/routing/interfaces/{interfaceId}"
body_params = [
@@ -1372,9 +1401,9 @@ def deleteNetworkSwitchStackRoutingInterface(self, networkId: str, switchStackId
- interfaceId (string): Interface ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- switchStackId = urllib.parse.quote(switchStackId, safe="")
- interfaceId = urllib.parse.quote(interfaceId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
resource = f"/networks/{networkId}/switch/stacks/{switchStackId}/routing/interfaces/{interfaceId}"
action = {
@@ -1428,9 +1457,9 @@ def updateNetworkSwitchStackRoutingInterfaceDhcp(self, networkId: str, switchSta
f'''"dnsNameserversOption" cannot be "{kwargs["dnsNameserversOption"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- switchStackId = urllib.parse.quote(switchStackId, safe="")
- interfaceId = urllib.parse.quote(interfaceId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
+ interfaceId = urllib.parse.quote(str(interfaceId), safe="")
resource = f"/networks/{networkId}/switch/stacks/{switchStackId}/routing/interfaces/{interfaceId}/dhcp"
body_params = [
@@ -1473,8 +1502,8 @@ def createNetworkSwitchStackRoutingStaticRoute(
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- switchStackId = urllib.parse.quote(switchStackId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
resource = f"/networks/{networkId}/switch/stacks/{switchStackId}/routing/staticRoutes"
body_params = [
@@ -1512,9 +1541,9 @@ def updateNetworkSwitchStackRoutingStaticRoute(self, networkId: str, switchStack
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- switchStackId = urllib.parse.quote(switchStackId, safe="")
- staticRouteId = urllib.parse.quote(staticRouteId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
+ staticRouteId = urllib.parse.quote(str(staticRouteId), safe="")
resource = f"/networks/{networkId}/switch/stacks/{switchStackId}/routing/staticRoutes/{staticRouteId}"
body_params = [
@@ -1544,9 +1573,9 @@ def deleteNetworkSwitchStackRoutingStaticRoute(self, networkId: str, switchStack
- staticRouteId (string): Static route ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- switchStackId = urllib.parse.quote(switchStackId, safe="")
- staticRouteId = urllib.parse.quote(staticRouteId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
+ staticRouteId = urllib.parse.quote(str(staticRouteId), safe="")
resource = f"/networks/{networkId}/switch/stacks/{switchStackId}/routing/staticRoutes/{staticRouteId}"
action = {
@@ -1569,7 +1598,7 @@ def updateNetworkSwitchStormControl(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/stormControl"
body_params = [
@@ -1598,7 +1627,7 @@ def updateNetworkSwitchStp(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/switch/stp"
body_params = [
@@ -1673,10 +1702,10 @@ def updateOrganizationConfigTemplateSwitchProfilePort(
f'''"accessPolicyType" cannot be "{kwargs["accessPolicyType"]}", & must be set to one of: {options}'''
)
- organizationId = urllib.parse.quote(organizationId, safe="")
- configTemplateId = urllib.parse.quote(configTemplateId, safe="")
- profileId = urllib.parse.quote(profileId, safe="")
- portId = urllib.parse.quote(portId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ configTemplateId = urllib.parse.quote(str(configTemplateId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
+ portId = urllib.parse.quote(str(portId), safe="")
resource = (
f"/organizations/{organizationId}/configTemplates/{configTemplateId}/switch/profiles/{profileId}/ports/{portId}"
)
@@ -1730,7 +1759,7 @@ def cloneOrganizationSwitchDevices(self, organizationId: str, sourceSerial: str,
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/switch/devices/clone"
body_params = [
diff --git a/meraki/api/batch/wireless.py b/meraki/api/batch/wireless.py
index 7f0b4308..69e45e3f 100644
--- a/meraki/api/batch/wireless.py
+++ b/meraki/api/batch/wireless.py
@@ -16,7 +16,7 @@ def updateDeviceWirelessAlternateManagementInterfaceIpv6(self, serial: str, **kw
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/wireless/alternateManagementInterface/ipv6"
body_params = [
@@ -46,7 +46,7 @@ def updateDeviceWirelessBluetoothSettings(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/wireless/bluetooth/settings"
body_params = [
@@ -74,7 +74,7 @@ def updateDeviceWirelessElectronicShelfLabel(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/wireless/electronicShelfLabel"
body_params = [
@@ -102,7 +102,7 @@ def updateDeviceWirelessRadioSettings(self, serial: str, **kwargs):
kwargs.update(locals())
- serial = urllib.parse.quote(serial, safe="")
+ serial = urllib.parse.quote(str(serial), safe="")
resource = f"/devices/{serial}/wireless/radio/settings"
body_params = [
@@ -134,7 +134,7 @@ def createNetworkWirelessAirMarshalRule(self, networkId: str, type: str, match:
options = ["alert", "allow", "block"]
assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/airMarshal/rules"
body_params = [
@@ -144,7 +144,7 @@ def createNetworkWirelessAirMarshalRule(self, networkId: str, type: str, match:
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
action = {
"resource": resource,
- "operation": "update",
+ "operation": "create",
"body": payload,
}
return action
@@ -166,8 +166,8 @@ def updateNetworkWirelessAirMarshalRule(self, networkId: str, ruleId: str, **kwa
options = ["alert", "allow", "block"]
assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
- networkId = urllib.parse.quote(networkId, safe="")
- ruleId = urllib.parse.quote(ruleId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ ruleId = urllib.parse.quote(str(ruleId), safe="")
resource = f"/networks/{networkId}/wireless/airMarshal/rules/{ruleId}"
body_params = [
@@ -191,8 +191,8 @@ def deleteNetworkWirelessAirMarshalRule(self, networkId: str, ruleId: str):
- ruleId (string): Rule ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- ruleId = urllib.parse.quote(ruleId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ ruleId = urllib.parse.quote(str(ruleId), safe="")
resource = f"/networks/{networkId}/wireless/airMarshal/rules/{ruleId}"
action = {
@@ -218,7 +218,7 @@ def updateNetworkWirelessAirMarshalSettings(self, networkId: str, defaultPolicy:
f'''"defaultPolicy" cannot be "{kwargs["defaultPolicy"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/airMarshal/settings"
body_params = [
@@ -246,7 +246,7 @@ def updateNetworkWirelessAlternateManagementInterface(self, networkId: str, **kw
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/alternateManagementInterface"
body_params = [
@@ -275,7 +275,7 @@ def updateNetworkWirelessBilling(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/billing"
body_params = [
@@ -307,7 +307,7 @@ def updateNetworkWirelessElectronicShelfLabel(self, networkId: str, **kwargs):
options = ["Bluetooth", "high frequency"]
assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}'''
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/electronicShelfLabel"
body_params = [
@@ -336,7 +336,7 @@ def createNetworkWirelessEthernetPortsProfile(self, networkId: str, name: str, p
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/ethernet/ports/profiles"
body_params = [
@@ -364,7 +364,7 @@ def assignNetworkWirelessEthernetPortsProfiles(self, networkId: str, serials: li
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/ethernet/ports/profiles/assign"
body_params = [
@@ -390,7 +390,7 @@ def setNetworkWirelessEthernetPortsProfilesDefault(self, networkId: str, profile
kwargs = locals()
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/ethernet/ports/profiles/setDefault"
body_params = [
@@ -418,8 +418,8 @@ def updateNetworkWirelessEthernetPortsProfile(self, networkId: str, profileId: s
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- profileId = urllib.parse.quote(profileId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
resource = f"/networks/{networkId}/wireless/ethernet/ports/profiles/{profileId}"
body_params = [
@@ -444,8 +444,8 @@ def deleteNetworkWirelessEthernetPortsProfile(self, networkId: str, profileId: s
- profileId (string): Profile ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- profileId = urllib.parse.quote(profileId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
resource = f"/networks/{networkId}/wireless/ethernet/ports/profiles/{profileId}"
action = {
@@ -466,7 +466,7 @@ def updateNetworkWirelessLocationScanning(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/location/scanning"
body_params = [
@@ -495,7 +495,7 @@ def updateNetworkWirelessRadioRrm(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/radio/rrm"
body_params = [
@@ -544,7 +544,7 @@ def createNetworkWirelessRfProfile(self, networkId: str, name: str, bandSelectio
f'''"bandSelectionType" cannot be "{kwargs["bandSelectionType"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/rfProfiles"
body_params = [
@@ -603,8 +603,8 @@ def updateNetworkWirelessRfProfile(self, networkId: str, rfProfileId: str, **kwa
f'''"bandSelectionType" cannot be "{kwargs["bandSelectionType"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- rfProfileId = urllib.parse.quote(rfProfileId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ rfProfileId = urllib.parse.quote(str(rfProfileId), safe="")
resource = f"/networks/{networkId}/wireless/rfProfiles/{rfProfileId}"
body_params = [
@@ -639,8 +639,8 @@ def deleteNetworkWirelessRfProfile(self, networkId: str, rfProfileId: str):
- rfProfileId (string): Rf profile ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- rfProfileId = urllib.parse.quote(rfProfileId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ rfProfileId = urllib.parse.quote(str(rfProfileId), safe="")
resource = f"/networks/{networkId}/wireless/rfProfiles/{rfProfileId}"
action = {
@@ -673,7 +673,7 @@ def updateNetworkWirelessSettings(self, networkId: str, **kwargs):
f'''"upgradeStrategy" cannot be "{kwargs["upgradeStrategy"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/settings"
body_params = [
@@ -725,7 +725,7 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
- radiusServerTimeout (integer): The amount of time for which a RADIUS client waits for a reply from the RADIUS server (must be between 1-10 seconds).
- radiusServerAttemptsLimit (integer): The maximum number of transmit attempts after which a RADIUS server is failed over (must be between 1-5).
- radiusFallbackEnabled (boolean): Whether or not higher priority RADIUS servers should be retried after 60 seconds.
- - radiusRadsec (object): The current settings for RADIUS RADSec
+ - radiusRadsec (object): The current settings for RADIUS RadSec
- radiusCoaEnabled (boolean): If true, Meraki devices will act as a RADIUS Dynamic Authorization Server and will respond to RADIUS Change-of-Authorization and Disconnect messages sent by the RADIUS server.
- radiusFailoverPolicy (string): This policy determines how authentication requests should be handled in the event that all of the configured RADIUS servers are unreachable ('Deny access' or 'Allow access')
- radiusLoadBalancingPolicy (string): This policy determines which RADIUS server will be contacted first in an authentication attempt and the ordering of any necessary retry attempts ('Strict priority order' or 'Round robin')
@@ -763,6 +763,7 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
- dnsRewrite (object): DNS servers rewrite settings
- speedBurst (object): The SpeedBurst setting for this SSID'
- namedVlans (object): Named VLAN settings.
+ - security (object): Security settings for the SSID
- localAuthFallback (object): The current configuration for Local Authentication Fallback. Enables the Access Point (AP) to store client authentication data for a specified duration that can be adjusted as needed.
- radiusAccountingStartDelay (integer): The delay (in seconds) before sending the first RADIUS accounting start message. Must be between 0 and 60 seconds.
"""
@@ -843,8 +844,8 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
f'''"radiusAttributeForGroupPolicies" cannot be "{kwargs["radiusAttributeForGroupPolicies"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}"
body_params = [
@@ -910,6 +911,7 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
"dnsRewrite",
"speedBurst",
"namedVlans",
+ "security",
"localAuthFallback",
"radiusAccountingStartDelay",
]
@@ -935,8 +937,8 @@ def updateNetworkWirelessSsidBonjourForwarding(self, networkId: str, number: str
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/bonjourForwarding"
body_params = [
@@ -965,8 +967,8 @@ def updateNetworkWirelessSsidDeviceTypeGroupPolicies(self, networkId: str, numbe
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/deviceTypeGroupPolicies"
body_params = [
@@ -996,8 +998,8 @@ def updateNetworkWirelessSsidEapOverride(self, networkId: str, number: str, **kw
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/eapOverride"
body_params = [
@@ -1027,8 +1029,8 @@ def updateNetworkWirelessSsidFirewallL3FirewallRules(self, networkId: str, numbe
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/firewall/l3FirewallRules"
body_params = [
@@ -1055,8 +1057,8 @@ def updateNetworkWirelessSsidFirewallL7FirewallRules(self, networkId: str, numbe
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/firewall/l7FirewallRules"
body_params = [
@@ -1104,8 +1106,8 @@ def updateNetworkWirelessSsidHotspot20(self, networkId: str, number: str, **kwar
f'''"networkAccessType" cannot be "{kwargs["networkAccessType"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/hotspot20"
body_params = [
@@ -1141,8 +1143,8 @@ def createNetworkWirelessSsidIdentityPsk(self, networkId: str, number: str, name
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/identityPsks"
body_params = [
@@ -1175,9 +1177,9 @@ def updateNetworkWirelessSsidIdentityPsk(self, networkId: str, number: str, iden
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
- identityPskId = urllib.parse.quote(identityPskId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
+ identityPskId = urllib.parse.quote(str(identityPskId), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/identityPsks/{identityPskId}"
body_params = [
@@ -1204,9 +1206,9 @@ def deleteNetworkWirelessSsidIdentityPsk(self, networkId: str, number: str, iden
- identityPskId (string): Identity psk ID
"""
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
- identityPskId = urllib.parse.quote(identityPskId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
+ identityPskId = urllib.parse.quote(str(identityPskId), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/identityPsks/{identityPskId}"
action = {
@@ -1228,8 +1230,8 @@ def updateNetworkWirelessSsidOpenRoaming(self, networkId: str, number: str, **kw
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/openRoaming"
body_params = [
@@ -1258,8 +1260,8 @@ def updateNetworkWirelessSsidSchedules(self, networkId: str, number: str, **kwar
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/schedules"
body_params = [
@@ -1288,6 +1290,7 @@ def updateNetworkWirelessSsidSplashSettings(self, networkId: str, number: str, *
- redirectUrl (string): The custom redirect URL where the users will go after the splash page.
- useRedirectUrl (boolean): The Boolean indicating whether the the user will be redirected to the custom redirect URL after the splash page. A custom redirect URL must be set if this is true.
- welcomeMessage (string): The welcome message for the users on the splash page.
+ - userConsent (object): User consent settings
- themeId (string): The id of the selected splash theme.
- splashLogo (object): The logo used in the splash page.
- splashImage (object): The image used in the splash page.
@@ -1314,8 +1317,8 @@ def updateNetworkWirelessSsidSplashSettings(self, networkId: str, number: str, *
f'''"controllerDisconnectionBehavior" cannot be "{kwargs["controllerDisconnectionBehavior"]}", & must be set to one of: {options}'''
)
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/splash/settings"
body_params = [
@@ -1325,6 +1328,7 @@ def updateNetworkWirelessSsidSplashSettings(self, networkId: str, number: str, *
"redirectUrl",
"useRedirectUrl",
"welcomeMessage",
+ "userConsent",
"themeId",
"splashLogo",
"splashImage",
@@ -1362,8 +1366,8 @@ def updateNetworkWirelessSsidTrafficShapingRules(self, networkId: str, number: s
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/trafficShaping/rules"
body_params = [
@@ -1393,8 +1397,8 @@ def updateNetworkWirelessSsidVpn(self, networkId: str, number: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
- number = urllib.parse.quote(number, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ number = urllib.parse.quote(str(number), safe="")
resource = f"/networks/{networkId}/wireless/ssids/{number}/vpn"
body_params = [
@@ -1424,7 +1428,7 @@ def updateNetworkWirelessZigbee(self, networkId: str, **kwargs):
kwargs.update(locals())
- networkId = urllib.parse.quote(networkId, safe="")
+ networkId = urllib.parse.quote(str(networkId), safe="")
resource = f"/networks/{networkId}/wireless/zigbee"
body_params = [
@@ -1453,7 +1457,7 @@ def createOrganizationWirelessDevicesProvisioningDeployment(self, organizationId
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/wireless/devices/provisioning/deployments"
body_params = [
@@ -1480,7 +1484,7 @@ def updateOrganizationWirelessDevicesProvisioningDeployments(self, organizationI
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/wireless/devices/provisioning/deployments"
body_params = [
@@ -1504,8 +1508,8 @@ def deleteOrganizationWirelessDevicesProvisioningDeployment(self, organizationId
- deploymentId (string): Deployment ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- deploymentId = urllib.parse.quote(deploymentId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ deploymentId = urllib.parse.quote(str(deploymentId), safe="")
resource = f"/organizations/{organizationId}/wireless/devices/provisioning/deployments/{deploymentId}"
action = {
@@ -1531,7 +1535,7 @@ def createOrganizationWirelessLocationScanningReceiver(
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/wireless/location/scanning/receivers"
body_params = [
@@ -1563,8 +1567,8 @@ def updateOrganizationWirelessLocationScanningReceiver(self, organizationId: str
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- receiverId = urllib.parse.quote(receiverId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ receiverId = urllib.parse.quote(str(receiverId), safe="")
resource = f"/organizations/{organizationId}/wireless/location/scanning/receivers/{receiverId}"
body_params = [
@@ -1589,8 +1593,8 @@ def deleteOrganizationWirelessLocationScanningReceiver(self, organizationId: str
- receiverId (string): Receiver ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- receiverId = urllib.parse.quote(receiverId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ receiverId = urllib.parse.quote(str(receiverId), safe="")
resource = f"/organizations/{organizationId}/wireless/location/scanning/receivers/{receiverId}"
action = {
@@ -1613,7 +1617,7 @@ def updateOrganizationWirelessMqttSettings(self, organizationId: str, network: d
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/wireless/mqtt/settings"
body_params = [
@@ -1641,7 +1645,7 @@ def recalculateOrganizationWirelessRadioAutoRfChannels(self, organizationId: str
kwargs = locals()
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/wireless/radio/autoRf/channels/recalculate"
body_params = [
@@ -1671,7 +1675,7 @@ def createOrganizationWirelessSsidsFirewallIsolationAllowlistEntry(
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
resource = f"/organizations/{organizationId}/wireless/ssids/firewall/isolation/allowlist/entries"
body_params = [
@@ -1697,8 +1701,8 @@ def deleteOrganizationWirelessSsidsFirewallIsolationAllowlistEntry(self, organiz
- entryId (string): Entry ID
"""
- organizationId = urllib.parse.quote(organizationId, safe="")
- entryId = urllib.parse.quote(entryId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ entryId = urllib.parse.quote(str(entryId), safe="")
resource = f"/organizations/{organizationId}/wireless/ssids/firewall/isolation/allowlist/entries/{entryId}"
action = {
@@ -1720,8 +1724,8 @@ def updateOrganizationWirelessSsidsFirewallIsolationAllowlistEntry(self, organiz
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- entryId = urllib.parse.quote(entryId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ entryId = urllib.parse.quote(str(entryId), safe="")
resource = f"/organizations/{organizationId}/wireless/ssids/firewall/isolation/allowlist/entries/{entryId}"
body_params = [
@@ -1749,8 +1753,8 @@ def updateOrganizationWirelessZigbeeDevice(self, organizationId: str, id: str, e
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- id = urllib.parse.quote(id, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ id = urllib.parse.quote(str(id), safe="")
resource = f"/organizations/{organizationId}/wireless/zigbee/devices/{id}"
body_params = [
@@ -1777,8 +1781,8 @@ def updateOrganizationWirelessZigbeeDoorLock(self, organizationId: str, doorLock
kwargs.update(locals())
- organizationId = urllib.parse.quote(organizationId, safe="")
- doorLockId = urllib.parse.quote(doorLockId, safe="")
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ doorLockId = urllib.parse.quote(str(doorLockId), safe="")
resource = f"/organizations/{organizationId}/wireless/zigbee/doorLocks/{doorLockId}"
body_params = [
diff --git a/meraki/api/camera.py b/meraki/api/camera.py
index 2d19e19f..2f834255 100644
--- a/meraki/api/camera.py
+++ b/meraki/api/camera.py
@@ -175,7 +175,7 @@ def clipDeviceCamera(self, serial: str, startTimestamp: str, endTimestamp: str,
- serial (string): Serial
- startTimestamp (string): The start time for the clip. The timestamp is expected to be in ISO 8601 format.
- endTimestamp (string): The end time for the clip. The timestamp is expected to be in ISO 8601 format.
- - imagerId (integer): For multi-imager cameras, the imager ID to query. Defaults to '1' if omitted.
+ - imagerId (integer): The imager ID to query. Required for multi-imager cameras (must be between 1 and the imager count). For single-imager cameras, must be omitted or set to 0.
"""
kwargs.update(locals())
diff --git a/meraki/api/devices.py b/meraki/api/devices.py
index a972f626..a89bc236 100644
--- a/meraki/api/devices.py
+++ b/meraki/api/devices.py
@@ -105,6 +105,37 @@ def blinkDeviceLeds(self, serial: str, **kwargs):
return self._session.post(metadata, resource, payload)
+ def updateDeviceCellularGeolocations(self, serial: str, enabled: bool, **kwargs):
+ """
+ **Update the enablement of the geolocation feature for a device**
+ https://developer.cisco.com/meraki/api-v1/#!update-device-cellular-geolocations
+
+ - serial (string): Serial
+ - enabled (boolean): Required parameter for the state to update the geolocation settings to (true to enable, false to disable)
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["devices", "configure", "cellular", "geolocations"],
+ "operation": "updateDeviceCellularGeolocations",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/cellular/geolocations"
+
+ body_params = [
+ "enabled",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"updateDeviceCellularGeolocations: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
def getDeviceCellularSims(self, serial: str):
"""
**Return the SIM and APN configurations for a cellular device.**
@@ -129,7 +160,7 @@ def updateDeviceCellularSims(self, serial: str, **kwargs):
- serial (string): Serial
- sims (array): List of SIMs. If a SIM was previously configured and not specified in this request, it will remain unchanged.
- - simOrdering (array): Specifies the ordering of all SIMs for an MG: primary, secondary, and not-in-use (when applicable). It's required for devices with 3 or more SIMs and can be used in place of 'isPrimary' for dual-SIM devices. To indicate eSIM, use 'sim3'. Sim failover will occur only between primary and secondary sim slots.
+ - simOrdering (array): Specifies the ordering of all SIMs for an MG: primary, secondary, and not-in-use (when applicable). It's required for devices with 3 or more SIMs and can be used in place of 'isPrimary' for dual-SIM devices. Use the raw eSIM slot value for the device, such as 'sim2' or 'sim3'. Sim failover will occur only between primary and secondary sim slots.
- simFailover (object): SIM Failover settings.
"""
@@ -157,6 +188,50 @@ def updateDeviceCellularSims(self, serial: str, **kwargs):
return self._session.put(metadata, resource, payload)
+ def createDeviceCellularUplinksBandsMasksUpdate(self, serial: str, slot: str, type: str, masked: list, **kwargs):
+ """
+ **Update the cellular band masks for a device**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-cellular-uplinks-bands-masks-update
+
+ - serial (string): Serial
+ - slot (string): Required parameter for the SIM slot to update the cellular band mask for
+ - type (string): Required parameter for the signal type to update the cellular band mask for
+ - masked (array): Required parameter for the band identifiers to mask for the given SIM slot and signal type. For LTE use bands identifiers like '30', for 5G use band identifiers like 'n30', or use 'all' to mask all bands for that signal type. Maximum 256 bands.
+ """
+
+ kwargs = locals()
+
+ if "slot" in kwargs:
+ options = ["sim1", "sim2", "sim3"]
+ assert kwargs["slot"] in options, f'''"slot" cannot be "{kwargs["slot"]}", & must be set to one of: {options}'''
+ if "type" in kwargs:
+ options = ["5GNSA", "5GSA", "LTE"]
+ assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
+
+ metadata = {
+ "tags": ["devices", "configure", "cellular", "uplinks", "bands", "masks", "update"],
+ "operation": "createDeviceCellularUplinksBandsMasksUpdate",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/cellular/uplinks/bands/masks/update"
+
+ body_params = [
+ "slot",
+ "type",
+ "masked",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"createDeviceCellularUplinksBandsMasksUpdate: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
def getDeviceClients(self, serial: str, **kwargs):
"""
**List the clients of a device, up to a maximum of a month ago**
@@ -350,6 +425,7 @@ def createDeviceLiveToolsMacTable(self, serial: str, **kwargs):
https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-mac-table
- serial (string): Serial
+ - mac (string): Optional parameter to filter MAC table entries by MAC address. Must be a colon-delimited six-octet MAC address, for example '00:11:22:a0:b1:c2'. Matching is case-insensitive.
- callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
"""
@@ -363,6 +439,7 @@ def createDeviceLiveToolsMacTable(self, serial: str, **kwargs):
resource = f"/devices/{serial}/liveTools/macTable"
body_params = [
+ "mac",
"callback",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -604,6 +681,238 @@ def getDeviceLiveToolsPortsCycle(self, serial: str, id: str):
return self._session.get(metadata, resource)
+ def createDeviceLiveToolsPortsStatus(self, serial: str, **kwargs):
+ """
+ **Enqueue a job to retrieve port status for a device**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-ports-status
+
+ - serial (string): Serial
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["devices", "liveTools", "ports", "status"],
+ "operation": "createDeviceLiveToolsPortsStatus",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/ports/status"
+
+ body_params = [
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"createDeviceLiveToolsPortsStatus: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.post(metadata, resource, payload)
+
+ def getDeviceLiveToolsPortsStatus(self, serial: str, jobId: str):
+ """
+ **Return a port status live tool job.**
+ https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-ports-status
+
+ - serial (string): Serial
+ - jobId (string): Job ID
+ """
+
+ metadata = {
+ "tags": ["devices", "liveTools", "ports", "status"],
+ "operation": "getDeviceLiveToolsPortsStatus",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ jobId = urllib.parse.quote(str(jobId), safe="")
+ resource = f"/devices/{serial}/liveTools/ports/status/{jobId}"
+
+ return self._session.get(metadata, resource)
+
+ def createDeviceLiveToolsPowerUsage(self, serial: str, **kwargs):
+ """
+ **Enqueues a live tool job that retrieves details about a device's overall power usage**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-power-usage
+
+ - serial (string): Serial
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["devices", "liveTools", "power", "usage"],
+ "operation": "createDeviceLiveToolsPowerUsage",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/power/usage"
+
+ body_params = [
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"createDeviceLiveToolsPowerUsage: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.post(metadata, resource, payload)
+
+ def getDeviceLiveToolsPowerUsage(self, serial: str, jobId: str):
+ """
+ **Retrieve the status and results of a previously created live tool job fetching details about a device's overall power usage.**
+ https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-power-usage
+
+ - serial (string): Serial
+ - jobId (string): Job ID
+ """
+
+ metadata = {
+ "tags": ["devices", "liveTools", "power", "usage"],
+ "operation": "getDeviceLiveToolsPowerUsage",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ jobId = urllib.parse.quote(str(jobId), safe="")
+ resource = f"/devices/{serial}/liveTools/power/usage/{jobId}"
+
+ return self._session.get(metadata, resource)
+
+ def createDeviceLiveToolsRoutingTableLookup(self, serial: str, **kwargs):
+ """
+ **Enqueue a job to perform a routing table lookup request for a device**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-routing-table-lookup
+
+ - serial (string): Serial
+ - type (string): The type of route defined
+ - destination (object): The destination IP or subnet to lookup
+ - nextHop (object): The next hop to lookup
+ - vpn (object): VPN related search criteria
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ if "type" in kwargs:
+ options = [
+ "BGP",
+ "EIGRP",
+ "HSRP",
+ "IGRP",
+ "ISIS",
+ "LISP",
+ "NAT",
+ "ND",
+ "NHRP",
+ "OMP",
+ "OSPF",
+ "RIP",
+ "default WAN",
+ "direct",
+ "static",
+ ]
+ assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
+
+ metadata = {
+ "tags": ["devices", "liveTools", "routingTable", "lookups"],
+ "operation": "createDeviceLiveToolsRoutingTableLookup",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/lookups"
+
+ body_params = [
+ "type",
+ "destination",
+ "nextHop",
+ "vpn",
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"createDeviceLiveToolsRoutingTableLookup: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
+ def getDeviceLiveToolsRoutingTableLookup(self, serial: str, id: str):
+ """
+ **Return a routing table live tool lookup job for a device**
+ https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-routing-table-lookup
+
+ - serial (string): Serial
+ - id (string): ID
+ """
+
+ metadata = {
+ "tags": ["devices", "liveTools", "routingTable", "lookups"],
+ "operation": "getDeviceLiveToolsRoutingTableLookup",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ id = urllib.parse.quote(str(id), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/lookups/{id}"
+
+ return self._session.get(metadata, resource)
+
+ def createDeviceLiveToolsRoutingTableSummary(self, serial: str, **kwargs):
+ """
+ **Enqueue a routing table summary job for a device**
+ https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-routing-table-summary
+
+ - serial (string): Serial
+ - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["devices", "liveTools", "routingTable", "summaries"],
+ "operation": "createDeviceLiveToolsRoutingTableSummary",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/summaries"
+
+ body_params = [
+ "callback",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"createDeviceLiveToolsRoutingTableSummary: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
+ def getDeviceLiveToolsRoutingTableSummary(self, serial: str, id: str):
+ """
+ **Return the status and result of a routing table summary job**
+ https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-routing-table-summary
+
+ - serial (string): Serial
+ - id (string): ID
+ """
+
+ metadata = {
+ "tags": ["devices", "liveTools", "routingTable", "summaries"],
+ "operation": "getDeviceLiveToolsRoutingTableSummary",
+ }
+ serial = urllib.parse.quote(str(serial), safe="")
+ id = urllib.parse.quote(str(id), safe="")
+ resource = f"/devices/{serial}/liveTools/routingTable/summaries/{id}"
+
+ return self._session.get(metadata, resource)
+
def createDeviceLiveToolsThroughputTest(self, serial: str, **kwargs):
"""
**Enqueue a job to test a device throughput, the test will run for 10 secs to test throughput**
diff --git a/meraki/api/networks.py b/meraki/api/networks.py
index eb8ae8e4..c1858a71 100644
--- a/meraki/api/networks.py
+++ b/meraki/api/networks.py
@@ -886,6 +886,37 @@ def removeNetworkDevices(self, networkId: str, serial: str, **kwargs):
return self._session.post(metadata, resource, payload)
+ def updateNetworkDevicesSyslogServers(self, networkId: str, servers: list, **kwargs):
+ """
+ **Updates the syslog servers configuration for a network.**
+ https://developer.cisco.com/meraki/api-v1/#!update-network-devices-syslog-servers
+
+ - networkId (string): Network ID
+ - servers (array): A list of the syslog servers for this network; suggested maximum array size is 10
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["networks", "configure", "devices", "syslog", "servers"],
+ "operation": "updateNetworkDevicesSyslogServers",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ resource = f"/networks/{networkId}/devices/syslog/servers"
+
+ body_params = [
+ "servers",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"updateNetworkDevicesSyslogServers: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
def getNetworkEvents(self, networkId: str, total_pages=1, direction="prev", event_log_end_time=None, **kwargs):
"""
**List the events for the network**
@@ -2661,6 +2692,8 @@ def updateNetworkSnmp(self, networkId: str, **kwargs):
- access (string): The type of SNMP access. Can be one of 'none' (disabled), 'community' (V1/V2c), or 'users' (V3).
- communityString (string): The SNMP community string. Only relevant if 'access' is set to 'community'.
- users (array): The list of SNMP users. Only relevant if 'access' is set to 'users'.
+ - authentication (object): SNMPv3 authentication settings. Only relevant if 'access' is set to 'users'.
+ - privacy (object): SNMPv3 privacy settings. Only relevant if 'access' is set to 'users'.
"""
kwargs.update(locals())
@@ -2682,6 +2715,8 @@ def updateNetworkSnmp(self, networkId: str, **kwargs):
"access",
"communityString",
"users",
+ "authentication",
+ "privacy",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
diff --git a/meraki/api/organizations.py b/meraki/api/organizations.py
index 8190a410..896e794e 100644
--- a/meraki/api/organizations.py
+++ b/meraki/api/organizations.py
@@ -98,6 +98,7 @@ def updateOrganization(self, organizationId: str, **kwargs):
- name (string): The name of the organization
- management (object): Information about the organization's management system
- api (object): API-specific settings
+ - privacy (object): Privacy-related settings for the organization.
"""
kwargs.update(locals())
@@ -113,6 +114,7 @@ def updateOrganization(self, organizationId: str, **kwargs):
"name",
"management",
"api",
+ "privacy",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -1467,7 +1469,7 @@ def dismissOrganizationAssuranceAlerts(self, organizationId: str, alertIds: list
https://developer.cisco.com/meraki/api-v1/#!dismiss-organization-assurance-alerts
- organizationId (string): Organization ID
- - alertIds (array): Array of alert IDs to dismiss
+ - alertIds (array): Array of alert IDs in this organization to dismiss. Missing or inaccessible alert IDs return 404.
"""
kwargs = locals()
@@ -1828,7 +1830,7 @@ def restoreOrganizationAssuranceAlerts(self, organizationId: str, alertIds: list
https://developer.cisco.com/meraki/api-v1/#!restore-organization-assurance-alerts
- organizationId (string): Organization ID
- - alertIds (array): Array of alert IDs to restore
+ - alertIds (array): Array of alert IDs in this organization to restore. Missing or inaccessible alert IDs return 404.
"""
kwargs = locals()
@@ -2747,6 +2749,58 @@ def getOrganizationDevicesCellularDataDevices(self, organizationId: str, total_p
return self._session.get_pages(metadata, resource, params, total_pages, direction)
+ def getOrganizationDevicesCellularDataProfiles(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **List cellular data management profiles in this organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-profiles
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - profileIds (array): Optional parameter to filter the results by Data Management Profile ID.
+ - serials (array): Devices to find Cellular Data Management Profiles for.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles"],
+ "operation": "getOrganizationDevicesCellularDataProfiles",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles"
+
+ query_params = [
+ "profileIds",
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "profileIds",
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularDataProfiles: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
def createOrganizationDevicesCellularDataProfile(
self, organizationId: str, name: str, description: str, rules: list, **kwargs
):
@@ -2786,16 +2840,18 @@ def createOrganizationDevicesCellularDataProfile(
return self._session.post(metadata, resource, payload)
- def getOrganizationDevicesCellularDataProfiles(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ def getOrganizationDevicesCellularDataProfilesAssignments(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
"""
- **List cellular data management profiles in this organization**
- https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-profiles
+ **List Cellular Data Management Profile assignments in this organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-profiles-assignments
- organizationId (string): Organization ID
- total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
- direction (string): direction to paginate, either "next" (default) or "prev" page
- - profileIds (array): Optional parameter to filter the results by Data Management Profile ID.
- - serials (array): Devices to find Cellular Data Management Profiles for.
+ - profileIds (array): Optional parameter to find assignments by Profile IDs. Maximum 1000 profile IDs.
+ - serials (array): Optional parameter to find assignments by Device Serials. Maximum 1000 serials.
- perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
- startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
- endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
@@ -2804,11 +2860,11 @@ def getOrganizationDevicesCellularDataProfiles(self, organizationId: str, total_
kwargs.update(locals())
metadata = {
- "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles"],
- "operation": "getOrganizationDevicesCellularDataProfiles",
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles", "assignments"],
+ "operation": "getOrganizationDevicesCellularDataProfilesAssignments",
}
organizationId = urllib.parse.quote(str(organizationId), safe="")
- resource = f"/organizations/{organizationId}/devices/cellular/data/profiles"
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/assignments"
query_params = [
"profileIds",
@@ -2833,29 +2889,76 @@ def getOrganizationDevicesCellularDataProfiles(self, organizationId: str, total_
invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
if invalid and self._session._logger:
self._session._logger.warning(
- f"getOrganizationDevicesCellularDataProfiles: ignoring unrecognized kwargs: {invalid}"
+ f"getOrganizationDevicesCellularDataProfilesAssignments: ignoring unrecognized kwargs: {invalid}"
)
return self._session.get_pages(metadata, resource, params, total_pages, direction)
- def deleteOrganizationDevicesCellularDataProfile(self, organizationId: str, profileId: str):
+ def batchOrganizationDevicesCellularDataProfilesAssignmentsCreate(self, organizationId: str, items: list, **kwargs):
"""
- **Delete a cellular data management profile from this organization**
- https://developer.cisco.com/meraki/api-v1/#!delete-organization-devices-cellular-data-profile
+ **Assign devices to a Cellular Data Management Profile in batch**
+ https://developer.cisco.com/meraki/api-v1/#!batch-organization-devices-cellular-data-profiles-assignments-create
- organizationId (string): Organization ID
- - profileId (string): Profile ID
+ - items (array): List of device-to-profile assignments to create.
"""
+ kwargs = locals()
+
metadata = {
- "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles"],
- "operation": "deleteOrganizationDevicesCellularDataProfile",
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles", "assignments"],
+ "operation": "batchOrganizationDevicesCellularDataProfilesAssignmentsCreate",
}
organizationId = urllib.parse.quote(str(organizationId), safe="")
- profileId = urllib.parse.quote(str(profileId), safe="")
- resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/{profileId}"
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/assignments/batchCreate"
- return self._session.delete(metadata, resource)
+ body_params = [
+ "items",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"batchOrganizationDevicesCellularDataProfilesAssignmentsCreate: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
+
+ def bulkOrganizationDevicesCellularDataProfilesAssignmentsDelete(self, organizationId: str, items: list, **kwargs):
+ """
+ **Unassign devices from a Cellular Data Management Profile in batch**
+ https://developer.cisco.com/meraki/api-v1/#!bulk-organization-devices-cellular-data-profiles-assignments-delete
+
+ - organizationId (string): Organization ID
+ - items (array): List of device-to-profile assignments to remove.
+ """
+
+ kwargs = locals()
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles", "assignments"],
+ "operation": "bulkOrganizationDevicesCellularDataProfilesAssignmentsDelete",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/assignments/bulkDelete"
+
+ body_params = [
+ "items",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"bulkOrganizationDevicesCellularDataProfilesAssignmentsDelete: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.post(metadata, resource, payload)
def updateOrganizationDevicesCellularDataProfile(self, organizationId: str, rules: list, profileId: str, **kwargs):
"""
@@ -2895,6 +2998,284 @@ def updateOrganizationDevicesCellularDataProfile(self, organizationId: str, rule
return self._session.put(metadata, resource, payload)
+ def deleteOrganizationDevicesCellularDataProfile(self, organizationId: str, profileId: str):
+ """
+ **Delete a cellular data management profile from this organization**
+ https://developer.cisco.com/meraki/api-v1/#!delete-organization-devices-cellular-data-profile
+
+ - organizationId (string): Organization ID
+ - profileId (string): Profile ID
+ """
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "cellular", "data", "profiles"],
+ "operation": "deleteOrganizationDevicesCellularDataProfile",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ profileId = urllib.parse.quote(str(profileId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/profiles/{profileId}"
+
+ return self._session.delete(metadata, resource)
+
+ def getOrganizationDevicesCellularDataUsageByDevice(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **List current cellular data usage for devices in this organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-usage-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - serials (array): Filter the results by device serials. Maximum 1000 serials.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "data", "usage", "byDevice"],
+ "operation": "getOrganizationDevicesCellularDataUsageByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/usage/byDevice"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularDataUsageByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesCellularDataUsageHistoryByDeviceByInterval(
+ self, organizationId: str, serials: list, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **List historical cellular data usage grouped by device and interval in this organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-data-usage-history-by-device-by-interval
+
+ - organizationId (string): Organization ID
+ - serials (array): Required parameter to filter the results by device serials. Maximum 10 serials.
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 10. Default is 5.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 366 days from today.
+ - t1 (string): The end of the timespan for the data. t1 can be a maximum of 31 days after t0.
+ - timespan (number): The timespan for which the information will be fetched. If specifying timespan, do not specify parameters t0 and t1. The value must be in seconds and be less than or equal to 31 days. The default is 1 day. If interval is provided, the timespan will be autocalculated.
+ - interval (integer): The time interval in seconds for returned data. The valid intervals are: 300, 1200, 14400, 86400. The default is 86400. Interval is calculated if time params are provided.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "data", "usage", "history", "byDevice", "byInterval"],
+ "operation": "getOrganizationDevicesCellularDataUsageHistoryByDeviceByInterval",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/data/usage/history/byDevice/byInterval"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "t0",
+ "t1",
+ "timespan",
+ "interval",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularDataUsageHistoryByDeviceByInterval: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesCellularGeolocations(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **List the latest cellular geolocation telemetry for devices in an organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-geolocations
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - serials (array): Optional parameter to filter the results by device serials. Maximum 1000 serials.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "geolocations"],
+ "operation": "getOrganizationDevicesCellularGeolocations",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/geolocations"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularGeolocations: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesCellularUplinksBandsByDevice(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **List the latest cellular uplink signal information for devices in an organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-uplinks-bands-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - serials (array): Optional parameter to filter the results by device serials. Maximum 1000 serials.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "uplinks", "bands", "byDevice"],
+ "operation": "getOrganizationDevicesCellularUplinksBandsByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/uplinks/bands/byDevice"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularUplinksBandsByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesCellularUplinksTowersByDevice(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **List the latest cellular tower information for devices in an organization**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-cellular-uplinks-towers-by-device
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - serials (array): Optional parameter to filter the results by device serials. Maximum 1000 serials.
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 100.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "monitor", "devices", "cellular", "uplinks", "towers", "byDevice"],
+ "operation": "getOrganizationDevicesCellularUplinksTowersByDevice",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/cellular/uplinks/towers/byDevice"
+
+ query_params = [
+ "serials",
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "serials",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesCellularUplinksTowersByDevice: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
def createOrganizationDevicesControllerMigration(self, organizationId: str, serials: list, target: str, **kwargs):
"""
**Migrate devices to another controller or management mode**
@@ -3811,6 +4192,106 @@ def getOrganizationDevicesStatusesOverview(self, organizationId: str, **kwargs):
return self._session.get(metadata, resource, params)
+ def getOrganizationDevicesSyslogServersByNetwork(self, organizationId: str, total_pages=1, direction="next", **kwargs):
+ """
+ **Returns syslog servers configured for the networks within an organization.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-syslog-servers-by-network
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 10.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - networkIds (array): IDs of the networks for which to fetch syslog servers; suggested maximum array size is 100
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "syslog", "servers", "byNetwork"],
+ "operation": "getOrganizationDevicesSyslogServersByNetwork",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/syslog/servers/byNetwork"
+
+ query_params = [
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "networkIds",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesSyslogServersByNetwork: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
+ def getOrganizationDevicesSyslogServersRolesByNetwork(
+ self, organizationId: str, total_pages=1, direction="next", **kwargs
+ ):
+ """
+ **Returns roles that can be assigned to a syslog server for a given network.**
+ https://developer.cisco.com/meraki/api-v1/#!get-organization-devices-syslog-servers-roles-by-network
+
+ - organizationId (string): Organization ID
+ - total_pages (integer or string): use with perPage to get total results up to total_pages*perPage; -1 or "all" for all pages
+ - direction (string): direction to paginate, either "next" (default) or "prev" page
+ - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 10.
+ - startingAfter (string): A token used by the server to indicate the start of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - endingBefore (string): A token used by the server to indicate the end of the page. Often this is a timestamp or an ID but it is not limited to those. This parameter should not be defined by client applications. The link for the first, last, prev, or next page in the HTTP Link header should define it.
+ - networkIds (array): IDs of the networks for which to fetch valid syslog server roles; suggested maximum array size is 100
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["organizations", "configure", "devices", "syslog", "servers", "roles", "byNetwork"],
+ "operation": "getOrganizationDevicesSyslogServersRolesByNetwork",
+ }
+ organizationId = urllib.parse.quote(str(organizationId), safe="")
+ resource = f"/organizations/{organizationId}/devices/syslog/servers/roles/byNetwork"
+
+ query_params = [
+ "perPage",
+ "startingAfter",
+ "endingBefore",
+ "networkIds",
+ ]
+ params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params}
+
+ array_params = [
+ "networkIds",
+ ]
+ for k, v in kwargs.items():
+ if k.strip() in array_params:
+ params[f"{k.strip()}[]"] = kwargs[f"{k}"]
+ params.pop(k.strip())
+
+ if self._session._validate_kwargs:
+ all_params = query_params + array_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(
+ f"getOrganizationDevicesSyslogServersRolesByNetwork: ignoring unrecognized kwargs: {invalid}"
+ )
+
+ return self._session.get_pages(metadata, resource, params, total_pages, direction)
+
def getOrganizationDevicesSystemMemoryUsageHistoryByInterval(
self, organizationId: str, total_pages=1, direction="next", **kwargs
):
@@ -5092,6 +5573,7 @@ def updateOrganizationLoginSecurity(self, organizationId: str, **kwargs):
- enforceTwoFactorAuth (boolean): Boolean indicating whether users in this organization will be required to use an extra verification code when logging in to Dashboard. This code will be sent to their mobile phone via SMS, or can be generated by the authenticator application.
- enforceLoginIpRanges (boolean): Boolean indicating whether organization will restrict access to Dashboard (including the API) from certain IP addresses.
- loginIpRanges (array): List of acceptable IP ranges. Entries can be single IP addresses, IP address ranges, and CIDR subnets.
+ - enforceLockedIpSessions (boolean): Boolean indicating whether Dashboard sessions are locked to the IP address from which they were established. Only applicable to organizations that support locked-IP sessions; otherwise the parameter is ignored.
- apiAuthentication (object): Details for indicating whether organization will restrict access to API (but not Dashboard) to certain IP addresses.
"""
@@ -5118,6 +5600,7 @@ def updateOrganizationLoginSecurity(self, organizationId: str, **kwargs):
"enforceTwoFactorAuth",
"enforceLoginIpRanges",
"loginIpRanges",
+ "enforceLockedIpSessions",
"apiAuthentication",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -6258,22 +6741,26 @@ def getOrganizationPolicyObjects(self, organizationId: str, total_pages=1, direc
def createOrganizationPolicyObject(self, organizationId: str, name: str, category: str, type: str, **kwargs):
"""
- **Creates a new Policy Object.**
+ **Creates a new Policy Object**
https://developer.cisco.com/meraki/api-v1/#!create-organization-policy-object
- organizationId (string): Organization ID
- name (string): Name of a policy object, unique within the organization (alphanumeric, space, dash, or underscore characters only)
- category (string): Category of a policy object (one of: adaptivePolicy, network)
- - type (string): Type of a policy object (one of: adaptivePolicyIpv4Cidr, cidr, fqdn, ipAndMask)
+ - type (string): Type of a policy object (one of: adaptivePolicyIpv4Cidr, cidr, fqdn). DEPRECATED: `ipAndMask` is deprecated and will be removed in a future release. Use `cidr` instead.
- cidr (string): CIDR Value of a policy object (e.g. 10.11.12.1/24")
- fqdn (string): Fully qualified domain name of policy object (e.g. "example.com")
- - mask (string): Mask of a policy object (e.g. "255.255.0.0")
- - ip (string): IP Address of a policy object (e.g. "1.2.3.4")
+ - mask (string): Mask of a policy object (e.g. "255.255.0.0"). Used only with deprecated `type=ipAndMask`.
+ - ip (string): IP Address of a policy object (e.g. "1.2.3.4"). Used only with deprecated `type=ipAndMask`.
- groupIds (array): The IDs of policy object groups the policy object belongs to
"""
kwargs.update(locals())
+ if "type" in kwargs:
+ options = ["adaptivePolicyIpv4Cidr", "cidr", "fqdn"]
+ assert kwargs["type"] in options, f'''"type" cannot be "{kwargs["type"]}", & must be set to one of: {options}'''
+
metadata = {
"tags": ["organizations", "configure", "policyObjects"],
"operation": "createOrganizationPolicyObject",
@@ -6467,7 +6954,7 @@ def getOrganizationPolicyObject(self, organizationId: str, policyObjectId: str):
def updateOrganizationPolicyObject(self, organizationId: str, policyObjectId: str, **kwargs):
"""
- **Updates a Policy Object.**
+ **Updates a Policy Object**
https://developer.cisco.com/meraki/api-v1/#!update-organization-policy-object
- organizationId (string): Organization ID
@@ -6475,8 +6962,8 @@ def updateOrganizationPolicyObject(self, organizationId: str, policyObjectId: st
- name (string): Name of a policy object, unique within the organization (alphanumeric, space, dash, or underscore characters only)
- cidr (string): CIDR Value of a policy object (e.g. 10.11.12.1/24")
- fqdn (string): Fully qualified domain name of policy object (e.g. "example.com")
- - mask (string): Mask of a policy object (e.g. "255.255.0.0")
- - ip (string): IP Address of a policy object (e.g. "1.2.3.4")
+ - mask (string): Mask of a policy object (e.g. "255.255.0.0"). Used only with deprecated `type=ipAndMask`.
+ - ip (string): IP Address of a policy object (e.g. "1.2.3.4"). Used only with deprecated `type=ipAndMask`.
- groupIds (array): The IDs of policy object groups the policy object belongs to
"""
@@ -7059,17 +7546,16 @@ def getOrganizationSaseSites(self, organizationId: str, total_pages=1, direction
return self._session.get_pages(metadata, resource, params, total_pages, direction)
- def attachOrganizationSaseSites(self, organizationId: str, **kwargs):
+ def attachOrganizationSaseSites(self, organizationId: str, items: list, **kwargs):
"""
**Attach sites in this organization to Secure Access**
https://developer.cisco.com/meraki/api-v1/#!attach-organization-sase-sites
- organizationId (string): Organization ID
- items (array): List of Meraki SD-WAN sites with the associated regions to be attached.
- - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
"""
- kwargs.update(locals())
+ kwargs = locals()
metadata = {
"tags": ["organizations", "configure", "sase", "sites"],
@@ -7080,7 +7566,6 @@ def attachOrganizationSaseSites(self, organizationId: str, **kwargs):
body_params = [
"items",
- "callback",
]
payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
@@ -7159,7 +7644,6 @@ def detachOrganizationSaseSites(self, organizationId: str, **kwargs):
- organizationId (string): Organization ID
- items (array): List of Secure Access sites to be detached.
- - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret
"""
kwargs.update(locals())
diff --git a/meraki/api/switch.py b/meraki/api/switch.py
index 46b9aa8b..86ac6cc3 100644
--- a/meraki/api/switch.py
+++ b/meraki/api/switch.py
@@ -25,7 +25,7 @@ def getDeviceSwitchPorts(self, serial: str):
def cycleDeviceSwitchPorts(self, serial: str, ports: list, **kwargs):
"""
- **Cycle a set of switch ports**
+ **Cycle a set of switch ports on non-Catalyst MS devices**
https://developer.cisco.com/meraki/api-v1/#!cycle-device-switch-ports
- serial (string): Serial
@@ -2225,6 +2225,41 @@ def createNetworkSwitchStack(self, networkId: str, name: str, serials: list, **k
return self._session.post(metadata, resource, payload)
+ def updateNetworkSwitchStack(self, networkId: str, switchStackId: str, **kwargs):
+ """
+ **Update a switch stack**
+ https://developer.cisco.com/meraki/api-v1/#!update-network-switch-stack
+
+ - networkId (string): Network ID
+ - switchStackId (string): Switch stack ID
+ - name (string): The name of the switch stack
+ - members (array): The complete list of switches that should be in the stack. Minimum 2 and maximum 8 members. Omitting this field leaves stack membership unchanged.
+ """
+
+ kwargs.update(locals())
+
+ metadata = {
+ "tags": ["switch", "configure", "stacks"],
+ "operation": "updateNetworkSwitchStack",
+ }
+ networkId = urllib.parse.quote(str(networkId), safe="")
+ switchStackId = urllib.parse.quote(str(switchStackId), safe="")
+ resource = f"/networks/{networkId}/switch/stacks/{switchStackId}"
+
+ body_params = [
+ "name",
+ "members",
+ ]
+ payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params}
+
+ if self._session._validate_kwargs:
+ all_params = [] + body_params
+ invalid = [k for k in kwargs if k.strip() not in all_params and k != "self"]
+ if invalid and self._session._logger:
+ self._session._logger.warning(f"updateNetworkSwitchStack: ignoring unrecognized kwargs: {invalid}")
+
+ return self._session.put(metadata, resource, payload)
+
def getNetworkSwitchStack(self, networkId: str, switchStackId: str):
"""
**Show a switch stack**
diff --git a/meraki/api/wireless.py b/meraki/api/wireless.py
index 6ec80c8e..38af3999 100644
--- a/meraki/api/wireless.py
+++ b/meraki/api/wireless.py
@@ -2274,7 +2274,7 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
- radiusServerTimeout (integer): The amount of time for which a RADIUS client waits for a reply from the RADIUS server (must be between 1-10 seconds).
- radiusServerAttemptsLimit (integer): The maximum number of transmit attempts after which a RADIUS server is failed over (must be between 1-5).
- radiusFallbackEnabled (boolean): Whether or not higher priority RADIUS servers should be retried after 60 seconds.
- - radiusRadsec (object): The current settings for RADIUS RADSec
+ - radiusRadsec (object): The current settings for RADIUS RadSec
- radiusCoaEnabled (boolean): If true, Meraki devices will act as a RADIUS Dynamic Authorization Server and will respond to RADIUS Change-of-Authorization and Disconnect messages sent by the RADIUS server.
- radiusFailoverPolicy (string): This policy determines how authentication requests should be handled in the event that all of the configured RADIUS servers are unreachable ('Deny access' or 'Allow access')
- radiusLoadBalancingPolicy (string): This policy determines which RADIUS server will be contacted first in an authentication attempt and the ordering of any necessary retry attempts ('Strict priority order' or 'Round robin')
@@ -2312,6 +2312,7 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
- dnsRewrite (object): DNS servers rewrite settings
- speedBurst (object): The SpeedBurst setting for this SSID'
- namedVlans (object): Named VLAN settings.
+ - security (object): Security settings for the SSID
- localAuthFallback (object): The current configuration for Local Authentication Fallback. Enables the Access Point (AP) to store client authentication data for a specified duration that can be adjusted as needed.
- radiusAccountingStartDelay (integer): The delay (in seconds) before sending the first RADIUS accounting start message. Must be between 0 and 60 seconds.
"""
@@ -2463,6 +2464,7 @@ def updateNetworkWirelessSsid(self, networkId: str, number: str, **kwargs):
"dnsRewrite",
"speedBurst",
"namedVlans",
+ "security",
"localAuthFallback",
"radiusAccountingStartDelay",
]
@@ -3103,6 +3105,7 @@ def updateNetworkWirelessSsidSplashSettings(self, networkId: str, number: str, *
- redirectUrl (string): The custom redirect URL where the users will go after the splash page.
- useRedirectUrl (boolean): The Boolean indicating whether the the user will be redirected to the custom redirect URL after the splash page. A custom redirect URL must be set if this is true.
- welcomeMessage (string): The welcome message for the users on the splash page.
+ - userConsent (object): User consent settings
- themeId (string): The id of the selected splash theme.
- splashLogo (object): The logo used in the splash page.
- splashImage (object): The image used in the splash page.
@@ -3144,6 +3147,7 @@ def updateNetworkWirelessSsidSplashSettings(self, networkId: str, number: str, *
"redirectUrl",
"useRedirectUrl",
"welcomeMessage",
+ "userConsent",
"themeId",
"splashLogo",
"splashImage",
@@ -4235,7 +4239,7 @@ def deleteOrganizationWirelessDevicesProvisioningDeployment(self, organizationId
def getOrganizationWirelessDevicesRadsecCertificatesAuthorities(self, organizationId: str, **kwargs):
"""
- **Query for details on the organization's RADSEC device Certificate Authority certificates (CAs)**
+ **Query for details on the organization's RadSec device Certificate Authority certificates (CAs)**
https://developer.cisco.com/meraki/api-v1/#!get-organization-wireless-devices-radsec-certificates-authorities
- organizationId (string): Organization ID
@@ -4276,7 +4280,7 @@ def getOrganizationWirelessDevicesRadsecCertificatesAuthorities(self, organizati
def updateOrganizationWirelessDevicesRadsecCertificatesAuthorities(self, organizationId: str, **kwargs):
"""
- **Update an organization's RADSEC device Certificate Authority (CA) state**
+ **Update an organization's RadSec device Certificate Authority (CA) state**
https://developer.cisco.com/meraki/api-v1/#!update-organization-wireless-devices-radsec-certificates-authorities
- organizationId (string): Organization ID
@@ -4311,7 +4315,7 @@ def updateOrganizationWirelessDevicesRadsecCertificatesAuthorities(self, organiz
def createOrganizationWirelessDevicesRadsecCertificatesAuthority(self, organizationId: str):
"""
- **Create an organization's RADSEC device Certificate Authority (CA)**
+ **Create an organization's RadSec device Certificate Authority (CA)**
https://developer.cisco.com/meraki/api-v1/#!create-organization-wireless-devices-radsec-certificates-authority
- organizationId (string): Organization ID
@@ -4328,7 +4332,7 @@ def createOrganizationWirelessDevicesRadsecCertificatesAuthority(self, organizat
def getOrganizationWirelessDevicesRadsecCertificatesAuthoritiesCrls(self, organizationId: str, **kwargs):
"""
- **Query for certificate revocation list (CRL) for the organization's RADSEC device Certificate Authorities (CAs).**
+ **Query for certificate revocation list (CRL) for the organization's RadSec device Certificate Authorities (CAs).**
https://developer.cisco.com/meraki/api-v1/#!get-organization-wireless-devices-radsec-certificates-authorities-crls
- organizationId (string): Organization ID
@@ -4369,7 +4373,7 @@ def getOrganizationWirelessDevicesRadsecCertificatesAuthoritiesCrls(self, organi
def getOrganizationWirelessDevicesRadsecCertificatesAuthoritiesCrlsDeltas(self, organizationId: str, **kwargs):
"""
- **Query for all delta certificate revocation list (CRL) for the organization's RADSEC device Certificate Authority (CA) with the given id.**
+ **Query for all delta certificate revocation list (CRL) for the organization's RadSec device Certificate Authority (CA) with the given id.**
https://developer.cisco.com/meraki/api-v1/#!get-organization-wireless-devices-radsec-certificates-authorities-crls-deltas
- organizationId (string): Organization ID
diff --git a/meraki/common.py b/meraki/common.py
index 80c4e366..207ea5f3 100644
--- a/meraki/common.py
+++ b/meraki/common.py
@@ -51,6 +51,17 @@ def validate_user_agent(be_geo_id, caller):
return caller_string
+def validate_meraki_app_value(argument, value):
+ # Meraki app ID / bearer token must fit within a 127-character header value
+ if value and len(str(value)) > 127:
+ raise SessionInputError(
+ argument,
+ value,
+ f"{argument} must be 127 characters or fewer (got {len(str(value))}).",
+ None,
+ )
+
+
def reject_v0_base_url(self):
if "v0" in self._base_url:
sys.exit(
@@ -83,7 +94,10 @@ def validate_base_url(self, url):
"gov-meraki.com",
]
parsed_url = urllib.parse.urlparse(url)
- if any(domain in parsed_url.netloc for domain in allowed_domains):
+ # Match on the host boundary to avoid lookalike-host SSRF (e.g.
+ # "api.meraki.com.attacker.net"). Lowercase and strip any port first.
+ host = parsed_url.netloc.lower().split(":")[0]
+ if any(host == domain or host.endswith("." + domain) for domain in allowed_domains):
abs_url = url
else:
abs_url = self._base_url + url
diff --git a/meraki/config.py b/meraki/config.py
index 0dc893ac..3bf90ad6 100644
--- a/meraki/config.py
+++ b/meraki/config.py
@@ -1,17 +1,32 @@
-# Package Constants
+from pathlib import Path
+
+# =============================================================================
+# CONNECTION & AUTH
+# =============================================================================
+
+# --- API Key ---
# Meraki dashboard API key, set either at instantiation or as an environment variable
API_KEY_ENVIRONMENT_VARIABLE = "MERAKI_DASHBOARD_API_KEY"
+# --- Meraki App ---
+# Meraki app ID, set either at instantiation or as an environment variable
+MERAKI_APP_ID = ""
+
+# Meraki app bearer token, set either at instantiation or as an environment variable
+MERAKI_APP_BEARER_TOKEN = ""
+
+# --- Base URL ---
# Base URL preceding all endpoint resources
DEFAULT_BASE_URL = "https://api.meraki.com/api/v1"
-# Alternate base URLs
+# Regional base URLs
CANADA_BASE_URL = "https://api.meraki.ca/api/v1"
CHINA_BASE_URL = "https://api.meraki.cn/api/v1"
INDIA_BASE_URL = "https://api.meraki.in/api/v1"
UNITED_STATES_FED_BASE_URL = "https://api.gov-meraki.com/api/v1"
+# --- Transport ---
# Maximum number of seconds for each API call
SINGLE_REQUEST_TIMEOUT = 60
@@ -21,10 +36,105 @@
# Proxy server and port, if needed, for HTTPS
REQUESTS_PROXY = ""
+
+# =============================================================================
+# IDENTITY & ATTESTATION
+# =============================================================================
+
+# --- SDK Caller ---
+# Optional identifier for API usage tracking; can also be set as an environment variable MERAKI_PYTHON_SDK_CALLER
+# It's good practice to use this to identify your application using the format:
+# CamelCasedApplicationName/OptionalVersionNumber CamelCasedVendorName
+# Please note:
+# 1. Application name precedes vendor name in all cases.
+# 2. If your application or vendor name normally contains spaces or special casing, you should omit them in favor of
+# normal CamelCasing here.
+# 3. The slash and version number are optional. Leave both out if you like.
+# 4. The slash is a forward slash, '/' -- not a backslash.
+# 5. Don't use the 'Meraki' or 'Cisco' names in your application name here. Maybe in general? I'm a config file, not a
+# lawyer.
+# Example 1: if your application is named 'Mambo', version number is 5.0, and your vendor/company name is Vega, then
+# you would use, at minimum: 'Mambo Vega'. Optionally: 'Mambo/5.0 Vega'.
+# Example 2: if your application is named 'Sunshine Rainbows', and company name is 'hunter2 for Life', and if you
+# don't want to report version number, then you would use, at minimum: 'SunshineRainbows hunter2ForLife'
+# The choice is yours as long as you follow the format. You should **not** include other information in this string.
+# If you are an official ecosystem partner, this is required.
+# For more guidance, please refer to https://developer.cisco.com/meraki/api-v1/user-agents-overview/
+MERAKI_PYTHON_SDK_CALLER = ""
+
+# --- Legacy ---
+# Legacy partner identifier for API usage tracking; can also be set as an environment variable BE_GEO_ID
+# This is no longer used. Please use MERAKI_PYTHON_SDK_CALLER instead.
+BE_GEO_ID = ""
+
+
+# =============================================================================
+# REQUEST HANDLING
+# =============================================================================
+
+# --- Concurrency ---
+# Maximum concurrent connections for the async HTTP client (httpx.Limits(max_connections=N)).
+# This is a local resource constraint (open sockets), NOT a rate limit. It controls how many
+# requests can be in-flight simultaneously. For rate limiting, see Smart Limiting below.
+AIO_MAXIMUM_CONCURRENT_REQUESTS = 90
+
+# --- Smart Limiting ---
+# Proactive per-org rate limiting via token buckets. Unlike AIO_MAXIMUM_CONCURRENT_REQUESTS
+# (which caps how many requests are in-flight at once), smart limiting caps how many requests
+# per second are sent to each organization, preventing 429s before they happen.
+# The SDK parses request URLs to determine which org a request targets, using a cache of
+# network -> org and device serial -> org mappings gathered via getOrganizationInventoryDevices()
+# and getOrganizationNetworks().
+
+# Enable per-org rate limiting? When False, the SDK relies solely on 429 retry logic.
+# If you disable this feature, you will produce more 429 errors, which in turn wastes API budget
+# and unnecessarily interferes with other applications interacting with your organization(s).
+SMART_FLOW_ENABLED = True
+
+# Maximum requests per second per organization. Meraki's default org-level limit is 10 req/s.
+# The default setting is 9, which helps reserve a minimum budget for other applications. You
+# can further reduce this if you are working in an organization with lots of other applications.
+SMART_FLOW_ORG_RATE = 9
+
+# Maximum requests per second across all organizations (source IP limit).
+# Meraki enforces a global 100 req/s limit per source IP, independent of per-org limits.
+# All requests deduct from this budget; per-org buckets provide additional throttling.
+SMART_FLOW_GLOBAL_RATE = 100
+
+# Path to the rate limit mapping cache file. The cache persists network -> org and
+# serial -> org mappings across sessions so subsequent runs skip the eager load API calls
+# if the cache is fresh. Set to empty string to disable persistence.
+# Default: ~/.meraki/.cache/rate_limit_cache.json (platform-agnostic)
+SMART_FLOW_CACHE_PATH = str(Path.home() / ".meraki" / ".cache" / "rate_limit_cache.json")
+
+# How long (in seconds) before the disk cache is considered stale and re-fetched.
+# Default is 604800 (7 days). Set to None to never expire.
+# Organizations with frequent cross-org device or network movement may consider
+# reducing this value to better match their real-world use and improve the effectiveness.
+SMART_FLOW_CACHE_TTL = 604800.0
+
+# How org/network/device mappings are loaded. Options: "lazy", "eager".
+# "lazy" (default): mappings are collected passively as API calls are made.
+# "eager": all mappings are fetched at session init via getOrganizations().
+# Costs more API calls at startup for large deployments, but reduces cache misses during operation.
+# You may notice a brief delay as the cache is created, which indicates either
+# that your environment did not previously have a cache, or the previous cache had expired.
+SMART_FLOW_CACHE_MODE = "lazy"
+
+# Log smart flow activity (bucket creation, rate adjustments, learned mappings, cache events)
+# to the standard session log. Disable this if you don't want to see smart_flow log messages
+# in your logs.
+SMART_FLOW_LOGGING = True
+
+
+# --- Retry Behavior ---
# Retry if 429 rate limit error encountered?
# Please note, setting to False means your application will not retry upon a 429. Not intended for production apps.
WAIT_ON_RATE_LIMIT = True
+# Retry up to this many times when encountering 429s or other server-side errors
+MAXIMUM_RETRIES = 5
+
# Nginx 429 retry wait time
NGINX_429_RETRY_WAIT_TIME = 60
@@ -40,9 +150,16 @@
# Other 4XX error retry wait time
RETRY_4XX_ERROR_WAIT_TIME = 60
-# Retry up to this many times when encountering 429s or other server-side errors
-MAXIMUM_RETRIES = 2
+# --- Pagination ---
+# Use iterator for pages. May offer improved performance in some instances.
+USE_ITERATOR_FOR_GET_PAGES = False
+
+# =============================================================================
+# LOGGING & OBSERVABILITY
+# =============================================================================
+
+# --- File Logging ---
# Create an output log file?
OUTPUT_LOG = True
@@ -52,9 +169,11 @@
# Log file name appended with date and timestamp
LOG_FILE_PREFIX = "meraki_api_"
+# --- Console ---
# Print output logging to console?
PRINT_TO_CONSOLE = True
+# --- Control ---
# Disable all logging? You're on your own then!
SUPPRESS_LOGGING = False
@@ -62,38 +181,15 @@
# library's default logging handlers, formatters etc.--instead, you can inherit an external logger instance.
INHERIT_LOGGING_CONFIG = False
-# Use iterator for pages. May offer improved performance in some instances. Off by default for backwards compatibility.
-USE_ITERATOR_FOR_GET_PAGES = False
+# =============================================================================
+# DEVELOPMENT
+# =============================================================================
+
+# --- Simulation ---
# Simulate POST/PUT/DELETE calls to prevent changes?
SIMULATE_API_CALLS = False
-# Number of concurrent API requests for asynchronous class
-AIO_MAXIMUM_CONCURRENT_REQUESTS = 8
-
-# Legacy partner identifier for API usage tracking; can also be set as an environment variable BE_GEO_ID
-# This is no longer used. Please use MERAKI_PYTHON_SDK_CALLER instead.
-BE_GEO_ID = ""
-
-# Optional identifier for API usage tracking; can also be set as an environment variable MERAKI_PYTHON_SDK_CALLER
-# It's good practice to use this to identify your application using the format:
-# CamelCasedApplicationName/OptionalVersionNumber CamelCasedVendorName
-# Please note:
-# 1. Application name precedes vendor name in all cases.
-# 2. If your application or vendor name normally contains spaces or special casing, you should omit them in favor of
-# normal CamelCasing here.
-# 3. The slash and version number are optional. Leave both out if you like.
-# 4. The slash is a forward slash, '/' -- not a backslash.
-# 5. Don't use the 'Meraki' or 'Cisco' names in your application name here. Maybe in general? I'm a config file, not a
-# lawyer.
-# Example 1: if your application is named 'Mambo', version number is 5.0, and your vendor/company name is Vega, then
-# you would use, at minimum: 'Mambo Vega'. Optionally: 'Mambo/5.0 Vega'.
-# Example 2: if your application is named 'Sunshine Rainbows', and company name is 'hunter2 for Life', and if you
-# don't want to report version number, then you would use, at minimum: 'SunshineRainbows hunter2ForLife'
-# The choice is yours as long as you follow the format. You should **not** include other information in this string.
-# If you are an official ecosystem partner, this is required.
-# For more guidance, please refer to https://developer.cisco.com/meraki/api-v1/user-agents-overview/
-MERAKI_PYTHON_SDK_CALLER = ""
-
+# --- Validation ---
# Log a warning when unrecognized kwargs are passed to API methods?
VALIDATE_KWARGS = False
diff --git a/meraki/encoding.py b/meraki/encoding.py
new file mode 100644
index 00000000..13a376e1
--- /dev/null
+++ b/meraki/encoding.py
@@ -0,0 +1,70 @@
+"""Meraki-specific parameter encoding using only stdlib.
+
+This module provides encode_meraki_params(), a pure function replacement for
+the monkey-patched requests._encode_params in rest_session.py. Uses only
+urllib.parse (no requests dependency). See HTTP-04.
+"""
+
+from urllib.parse import urlencode
+
+
+def encode_meraki_params(data):
+ """Encode parameters for Meraki API requests.
+
+ Supports:
+ - str/bytes: passthrough
+ - file-like (has .read): passthrough
+ - dict: URL encode with array-of-objects support
+ - list of 2-tuples: URL encode with array-of-objects support
+ - other: passthrough
+
+ Array-of-objects encoding (Meraki-specific):
+ {"param[]": [{"key1": "val1"}]} -> "param%5B%5Dkey1=val1"
+
+ Args:
+ data: Parameters to encode. Dict, list of tuples, str, bytes,
+ file-like object, or any other type (passthrough).
+
+ Returns:
+ URL-encoded string for dict/list inputs, original value for passthrough types.
+ """
+ if isinstance(data, (str, bytes)):
+ return data
+ elif hasattr(data, "read"):
+ return data
+ elif hasattr(data, "__iter__"):
+ result = []
+
+ # Convert to key-val list (stdlib replacement for requests.utils.to_key_val_list)
+ if hasattr(data, "items"):
+ items = list(data.items())
+ else:
+ items = list(data)
+
+ for k, vs in items:
+ # Normalize scalar to list
+ if isinstance(vs, str) or not hasattr(vs, "__iter__"):
+ vs = [vs]
+
+ for v in vs:
+ if v is not None and not isinstance(v, dict):
+ # Simple key-value pair
+ result.append(
+ (
+ k.encode("utf-8") if isinstance(k, str) else k,
+ v.encode("utf-8") if isinstance(v, str) else v,
+ )
+ )
+ elif v is not None:
+ # Array-of-objects: concatenate dict keys to param name
+ for k_inner, v_inner in v.items():
+ result.append(
+ (
+ (k + k_inner).encode("utf-8") if isinstance(k, str) else k_inner,
+ v_inner.encode("utf-8") if isinstance(v_inner, str) else v_inner,
+ )
+ )
+
+ return urlencode(result, doseq=True)
+ else:
+ return data
diff --git a/meraki/exceptions.py b/meraki/exceptions.py
index 3b2a3cce..84a5b824 100644
--- a/meraki/exceptions.py
+++ b/meraki/exceptions.py
@@ -39,7 +39,9 @@ def __init__(self, metadata, response):
self.tag = metadata["tags"][0]
self.operation = metadata["operation"]
self.status = self.response.status_code if self.response is not None and self.response.status_code else None
- self.reason = self.response.reason if self.response is not None and self.response.reason else None
+ self.reason = (
+ self.response.reason_phrase if self.response is not None and hasattr(self.response, "reason_phrase") else None
+ )
try:
self.message = self.response.json() if self.response is not None and self.response.json() else None
except ValueError:
@@ -53,20 +55,41 @@ def __repr__(self):
# To catch exceptions while making AIO API calls
-class AsyncAPIError(Exception):
- def __init__(self, metadata, response, message):
- self.response = response
- self.tag = metadata["tags"][0]
- self.operation = metadata["operation"]
- self.status = response.status if response is not None and response.status else None
- self.reason = response.reason if response is not None and response.reason else None
- self.message = message
- if isinstance(self.message, str):
- self.message = self.message.strip()
- if self.status == 404 and self.reason == "Not Found":
- self.message += "please wait a minute if the key or org was just newly created."
+class AsyncAPIError(APIError):
+ """Deprecated: Use APIError for both sync and async exceptions.
+
+ This exception is deprecated as of version 4.0. Catch APIError instead,
+ which now handles both synchronous and asynchronous errors.
+
+ Existing code using ``except AsyncAPIError:`` will continue to work
+ because AsyncAPIError is now a subclass of APIError.
+ """
+
+ def __init__(self, metadata, response, message=None):
+ import warnings
+
+ warnings.warn(
+ "AsyncAPIError is deprecated. Catch APIError instead, which now handles both sync and async errors.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
- super().__init__(f"{self.tag}, {self.operation} - {self.status} {self.reason}, {self.message}")
+ if message is not None:
+ # Old 3-arg form: replicate original AsyncAPIError logic
+ self.response = response
+ self.tag = metadata["tags"][0]
+ self.operation = metadata["operation"]
+ self.status = response.status_code if response is not None and hasattr(response, "status_code") else None
+ self.reason = response.reason_phrase if response is not None and hasattr(response, "reason_phrase") else None
+ self.message = message
+ if isinstance(self.message, str):
+ self.message = self.message.strip()
+ if self.status == 404 and self.reason == "Not Found":
+ self.message += "please wait a minute if the key or org was just newly created."
+ Exception.__init__(self, f"{self.tag}, {self.operation} - {self.status} {self.reason}, {self.message}")
+ else:
+ # New 2-arg form: delegate to APIError
+ super().__init__(metadata, response)
def __repr__(self):
return f"{self.tag}, {self.operation} - {self.status} {self.reason}, {self.message}"
diff --git a/meraki/rest_session.py b/meraki/rest_session.py
deleted file mode 100644
index f6e1872e..00000000
--- a/meraki/rest_session.py
+++ /dev/null
@@ -1,605 +0,0 @@
-import random
-import urllib.parse
-from datetime import datetime, timezone
-import json
-import time
-
-import requests
-from requests.utils import to_key_val_list
-from requests.compat import basestring, urlencode
-
-from meraki._version import __version__
-from meraki.common import (
- check_python_version,
- iterator_for_get_pages_bool,
- reject_v0_base_url,
- use_iterator_for_get_pages_setter,
- validate_base_url,
- validate_user_agent,
-)
-from meraki.config import (
- ACTION_BATCH_RETRY_WAIT_TIME,
- BE_GEO_ID,
- CERTIFICATE_PATH,
- DEFAULT_BASE_URL,
- MAXIMUM_RETRIES,
- MERAKI_PYTHON_SDK_CALLER,
- NETWORK_DELETE_RETRY_WAIT_TIME,
- NGINX_429_RETRY_WAIT_TIME,
- REQUESTS_PROXY,
- RETRY_4XX_ERROR,
- RETRY_4XX_ERROR_WAIT_TIME,
- SIMULATE_API_CALLS,
- SINGLE_REQUEST_TIMEOUT,
- USE_ITERATOR_FOR_GET_PAGES,
- WAIT_ON_RATE_LIMIT,
-)
-from meraki.exceptions import APIError, APIResponseError, SessionInputError
-from meraki.response_handler import handle_3xx
-
-
-def encode_params(_, data):
- """Encode parameters in a piece of data.
-
- Will successfully encode parameters when passed as a dict or a list of
- 2-tuples. Order is retained if data is a list of 2-tuples but arbitrary
- if parameters are supplied as a dict.
-
- MERAKI OVERRIDE:
- By default, when parameters are supplied as a dict, only the object keys
- are encoded.
-
- Ex. {"param": [{"key_1":"value_1"}, {"key_2":"value_2"}]} => ?param[]=key_1¶m[]=key_2
-
- Now when parameters are supplied as a dict, dict keys will be appended to
- parameter names. This adds support for the "array of objects" query parameter type.
-
- Ex. {"param": [{"key_1":"value_1"}, {"key_2":"value_2"}]} => ?param[]key_1=value_1¶m[]key_2=value_2
- """
- if isinstance(data, (str, bytes)):
- return data
- elif hasattr(data, "read"):
- return data
- elif hasattr(data, "__iter__"):
- result = []
- # Get each query parameter key value pair
- for k, vs in to_key_val_list(data):
- """
- Turn value into list/iterable if it is not already.
- Ex. {"param": "value"} => {"param": ["value"]}
- """
- if isinstance(vs, basestring) or not hasattr(vs, "__iter__"):
- vs = [vs]
- for v in vs:
- # List params
- if v is not None and not isinstance(v, dict):
- """
- Add a query parameter key-value pair for each value to the list of results.
- Ex. {"param": ["value_1", "value_2"]} => [(param, value_1), (param, value_2)]
- """
- result.append(
- (
- k.encode("utf-8") if isinstance(k, str) else k,
- v.encode("utf-8") if isinstance(v, str) else v,
- )
- )
- # Dict params
- else:
- """
- Append each dict key to the parameter name.
- Add a query parameter key-value pair for each value to the list of results.
- {"param": [{"key_1": "value_1"}, {"key_2": "value_2"}]} => [(param + key_1, value1), (param + key_2, value2)]
- """
- for k_1, v_1 in v.items():
- result.append(
- (
- (k + k_1).encode("utf-8") if isinstance(k, str) else k_1,
- (v + v_1).encode("utf-8") if isinstance(v, str) else v_1,
- )
- )
- # Return URL encoded string
- return urlencode(result, doseq=True)
- else:
- return data
-
-
-# Monkey patch the _encode_params from the requests library with the encode_params function above
-requests.models.RequestEncodingMixin._encode_params = encode_params
-
-
-def user_agent_extended(be_geo_id, caller):
- # Generate the extended portion of the User-Agent
- user_agent = dict()
-
- if caller:
- user_agent["caller"] = caller
- elif be_geo_id:
- user_agent["caller"] = be_geo_id
- else:
- user_agent["caller"] = "unidentified"
-
- caller_string = f"Caller/({user_agent['caller']})"
-
- return caller_string
-
-
-# Main module interface
-class RestSession(object):
- def __init__(
- self,
- logger,
- api_key,
- base_url=DEFAULT_BASE_URL,
- single_request_timeout=SINGLE_REQUEST_TIMEOUT,
- certificate_path=CERTIFICATE_PATH,
- requests_proxy=REQUESTS_PROXY,
- wait_on_rate_limit=WAIT_ON_RATE_LIMIT,
- nginx_429_retry_wait_time=NGINX_429_RETRY_WAIT_TIME,
- action_batch_retry_wait_time=ACTION_BATCH_RETRY_WAIT_TIME,
- network_delete_retry_wait_time=NETWORK_DELETE_RETRY_WAIT_TIME,
- retry_4xx_error=RETRY_4XX_ERROR,
- retry_4xx_error_wait_time=RETRY_4XX_ERROR_WAIT_TIME,
- maximum_retries=MAXIMUM_RETRIES,
- simulate=SIMULATE_API_CALLS,
- be_geo_id=BE_GEO_ID,
- caller=MERAKI_PYTHON_SDK_CALLER,
- use_iterator_for_get_pages=USE_ITERATOR_FOR_GET_PAGES,
- validate_kwargs=False,
- ):
- super(RestSession, self).__init__()
-
- # Initialize attributes and properties
- self._version = __version__
- self._api_key = str(api_key)
- self._base_url = str(base_url)
- self._single_request_timeout = single_request_timeout
- self._certificate_path = certificate_path
- self._requests_proxy = requests_proxy
- self._wait_on_rate_limit = wait_on_rate_limit
- self._nginx_429_retry_wait_time = nginx_429_retry_wait_time
- self._action_batch_retry_wait_time = action_batch_retry_wait_time
- self._network_delete_retry_wait_time = network_delete_retry_wait_time
- self._retry_4xx_error = retry_4xx_error
- self._retry_4xx_error_wait_time = retry_4xx_error_wait_time
- self._maximum_retries = maximum_retries
- self._simulate = simulate
- self._be_geo_id = be_geo_id
- self._caller = caller
- self.use_iterator_for_get_pages = use_iterator_for_get_pages
- self._validate_kwargs = validate_kwargs
-
- # Initialize a new `requests` session
- self._req_session = requests.session()
- self._req_session.encoding = "utf-8"
-
- # Check the Python version
- check_python_version()
-
- # Check base URL
- reject_v0_base_url(self)
-
- # Update the headers for the session
- self._req_session.headers = {
- "Authorization": "Bearer " + self._api_key,
- "Content-Type": "application/json",
- "User-Agent": f"python-meraki/{self._version} " + validate_user_agent(self._be_geo_id, self._caller),
- }
-
- # Log API calls
- self._logger = logger
- self._parameters = {"version": self._version}
- self._parameters.update(locals())
- self._parameters.pop("self")
- self._parameters.pop("logger")
- self._parameters.pop("__class__")
- self._parameters["api_key"] = "*" * 36 + self._api_key[-4:]
- if self._logger:
- self._logger.info(f"Meraki dashboard API session initialized with these parameters: {self._parameters}")
-
- @property
- def use_iterator_for_get_pages(self):
- return iterator_for_get_pages_bool(self)
-
- @use_iterator_for_get_pages.setter
- def use_iterator_for_get_pages(self, value):
- use_iterator_for_get_pages_setter(self, value)
-
- def request(self, metadata, method, url, **kwargs):
- # Metadata on endpoint
- tag = metadata["tags"][0]
- operation = metadata["operation"]
-
- # Update request kwargs with session defaults
- self.prepare_request(kwargs)
-
- # Ensure proper base URL
- abs_url = validate_base_url(self, url)
-
- # Set the maximum number of retries
- retries = self._maximum_retries
-
- # Option to simulate non-safe API calls without actually sending them
- if self._logger:
- self._logger.debug(metadata)
- if self._simulate and method != "GET":
- if self._logger:
- self._logger.info(f"{tag}, {operation} - SIMULATED")
- return None
- else:
- response = None
- while retries > 0:
- # Make the HTTP request to the API endpoint
- try:
- if response:
- response.close()
- if self._logger:
- self._logger.info(f"{method} {abs_url}")
- response = self._req_session.request(method, abs_url, allow_redirects=False, **kwargs)
- reason = response.reason if response.reason else ""
- status = response.status_code
- except requests.exceptions.RequestException as e:
- if self._logger:
- self._logger.warning(f"{tag}, {operation} - {e}, retrying in 1 second")
- time.sleep(1)
- retries -= 1
- if retries == 0:
- if e.response and e.response.status_code:
- raise APIError(
- metadata,
- APIResponseError(e.__class__.__name__, e.response.status_code, str(e)),
- )
- else:
- raise APIError(
- metadata,
- APIResponseError(e.__class__.__name__, 503, str(e)),
- )
- else:
- continue
-
- match status:
- # Handle 3xx redirects automatically
- case status if 300 <= status < 400:
- abs_url = handle_3xx(self, response)
- # Handle 2xx success
- case status if 200 <= status < 300:
- if "page" in metadata:
- counter = metadata["page"]
- if self._logger:
- self._logger.info(f"{tag}, {operation}; page {counter} - {status} {reason}")
- else:
- if self._logger:
- self._logger.info(f"{tag}, {operation} - {status} {reason}")
- # For non-empty response to GET, ensure valid JSON
- try:
- if method == "GET" and response.content.strip():
- response.json()
- return response
- except json.decoder.JSONDecodeError as e:
- if self._logger:
- self._logger.warning(f"{tag}, {operation} - {e}, retrying in 1 second")
- time.sleep(1)
- retries -= 1
- if retries == 0:
- raise APIError(metadata, response)
- else:
- continue
- # Handle rate limiting
- case 429:
- # Retry if 429 retries are enabled and there are retries left
- if self._wait_on_rate_limit and retries > 0:
- if "Retry-After" in response.headers:
- wait = int(response.headers["Retry-After"])
- else:
- attempt = self._maximum_retries - retries
- wait = min(
- (2**attempt) * (1 + random.random()),
- self._nginx_429_retry_wait_time,
- )
- if self._logger:
- self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
- time.sleep(wait)
- retries -= 1
- if retries == 0:
- raise APIError(metadata, response)
- # We're either out of retries or the client told us not to retry
- else:
- raise APIError(metadata, response)
- # Handle 5xx errors
- case status if 500 <= status:
- if self._logger:
- self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in 1 second")
- time.sleep(1)
- retries -= 1
- if retries == 0:
- raise APIError(metadata, response)
- # Handle other 4xx errors
- case status if status != 429 and 400 <= status < 500:
- retries = self.handle_4xx_errors(metadata, operation, reason, response, retries, status, tag)
-
- return response
-
- def prepare_request(self, kwargs):
- if self._certificate_path:
- kwargs.setdefault("verify", self._certificate_path)
- if self._requests_proxy:
- kwargs.setdefault("proxies", {"https": self._requests_proxy})
- kwargs.setdefault("timeout", self._single_request_timeout)
-
- def handle_4xx_errors(self, metadata, operation, reason, response, retries, status, tag):
- try:
- message = response.json()
- message_is_dict = True
- except ValueError:
- message = response.content[:100]
- message_is_dict = False
-
- # Check specifically for concurrency errors
- network_delete_concurrency_error_text = "concurrent"
- action_batch_concurrency_error_text = "executing batches"
-
- # First, we check for network deletion concurrency errors
- if operation == "deleteNetwork" and response.status_code == 400:
- # message['errors'][0] is the first error, and it contains helpful text
- # here we use it to confirm that the 400 error is related to concurrent requests
- if network_delete_concurrency_error_text in message["errors"][0]:
- wait = random.randint(30, self._network_delete_retry_wait_time)
- if self._logger:
- self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
- time.sleep(wait)
- retries -= 1
- if retries == 0:
- raise APIError(metadata, response)
-
- # Next, we check for action batch concurrency errors
- # message['errors'][0] is the first error, and it contains helpful text
- # here we use it to confirm that the 400 error is related to concurrent requests
- elif message_is_dict and "errors" in message.keys() and action_batch_concurrency_error_text in message["errors"][0]:
- wait = self._action_batch_retry_wait_time
- if self._logger:
- self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
- time.sleep(wait)
- retries -= 1
- if retries == 0:
- raise APIError(metadata, response)
-
- # Then we check if the user asked to retry other 4xx errors, based on their session config
- elif self._retry_4xx_error:
- wait = random.randint(1, self._retry_4xx_error_wait_time)
- if self._logger:
- self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
- time.sleep(wait)
- retries -= 1
- if retries == 0:
- raise APIError(metadata, response)
-
- # All other client-side errors will raise an error
- else:
- if self._logger:
- self._logger.error(f"{tag}, {operation} - {status} {reason}, {message}")
- raise APIError(metadata, response)
- return retries
-
- def get(self, metadata, url, params=None):
- metadata["method"] = "GET"
- metadata["url"] = url
- metadata["params"] = params
- response = self.request(metadata, "GET", url, params=params)
- ret = None
- if response:
- if response.content.strip():
- ret = response.json()
- response.close()
- return ret
-
- def _get_pages_iterator(
- self,
- metadata,
- url,
- params=None,
- total_pages=-1,
- direction="next",
- event_log_end_time=None,
- ):
- if isinstance(total_pages, str) and total_pages.lower() == "all":
- total_pages = -1
- elif isinstance(total_pages, str) and total_pages.isnumeric():
- total_pages = int(total_pages)
- elif not isinstance(total_pages, int):
- raise SessionInputError(
- "total_pages",
- total_pages,
- "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).",
- None,
- )
- metadata["page"] = 1
-
- response = self.request(metadata, "GET", url, params=params)
-
- # Get additional pages if more than one requested
- while total_pages != 0:
- results = response.json()
- links = response.links
-
- # GET the subsequent page
- if direction == "next" and "next" in links:
- # Prevent getNetworkEvents from infinite loop as time goes forward
- if metadata["operation"] == "getNetworkEvents":
- starting_after = urllib.parse.unquote(str(links["next"]["url"]).split("startingAfter=")[1])
- delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after)
- # Break out of loop if startingAfter returned from next link is within 5 minutes of current time
- if delta.total_seconds() < 300:
- break
- # Or if the next page is past the specified window's end time
- elif event_log_end_time and starting_after > event_log_end_time:
- break
-
- metadata["page"] += 1
- nextlink = links["next"]["url"]
- elif direction == "prev" and "prev" in links:
- # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0)
- if metadata["operation"] == "getNetworkEvents":
- ending_before = urllib.parse.unquote(str(links["prev"]["url"]).split("endingBefore=")[1])
- # Break out of loop if endingBefore returned from prev link is before 2014
- if ending_before < "2014-01-01":
- break
-
- metadata["page"] += 1
- nextlink = links["prev"]["url"]
- else:
- total_pages = 1
-
- response.close()
-
- return_items = []
- # Just prepare the list
- if isinstance(results, list):
- return_items = results
- elif isinstance(results, dict) and "items" in results:
- return_items = results["items"]
- # For event log endpoint
- elif isinstance(results, dict):
- if direction == "next":
- return_items = results["events"][::-1]
- else:
- return_items = results["events"]
-
- for item in return_items:
- yield item
-
- total_pages = total_pages - 1
-
- if total_pages != 0:
- response = self.request(metadata, "GET", nextlink)
-
- def _get_pages_legacy(
- self,
- metadata,
- url,
- params=None,
- total_pages=-1,
- direction="next",
- event_log_end_time=None,
- ):
- if isinstance(total_pages, str) and total_pages.lower() == "all":
- total_pages = -1
- elif isinstance(total_pages, str) and total_pages.isnumeric():
- total_pages = int(total_pages)
- elif not isinstance(total_pages, int):
- raise SessionInputError(
- "total_pages",
- total_pages,
- "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).",
- None,
- )
-
- metadata["page"] = 1
-
- response = self.request(metadata, "GET", url, params=params)
-
- # Handle GETs that produce 204 No Content responses, e.g. getOrganizationClientSearch
- if response.status_code == 204:
- results = None
- else:
- results = response.json()
-
- # For event log endpoint when using 'next' direction, so results/events are sorted chronologically
- if isinstance(results, dict) and metadata["operation"] == "getNetworkEvents" and direction == "next":
- results["events"] = results["events"][::-1]
-
- # Get additional pages if more than one requested
- while total_pages != 1:
- links = response.links
- response.close()
- response = None
-
- # GET the subsequent page
- if direction == "next" and "next" in links:
- # Prevent getNetworkEvents from infinite loop as time goes forward
- if metadata["operation"] == "getNetworkEvents":
- starting_after = urllib.parse.unquote(links["next"]["url"].split("startingAfter=")[1])
- delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after)
- # Break out of loop if startingAfter returned from next link is within 5 minutes of current time
- if delta.total_seconds() < 300:
- break
- # Or if next page is past the specified window's end time
- elif event_log_end_time and starting_after > event_log_end_time:
- break
-
- metadata["page"] += 1
- response = self.request(metadata, "GET", links["next"]["url"])
- elif direction == "prev" and "prev" in links:
- # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0)
- if metadata["operation"] == "getNetworkEvents":
- ending_before = urllib.parse.unquote(links["prev"]["url"].split("endingBefore=")[1])
- # Break out of loop if endingBefore returned from prev link is before 2014
- if ending_before < "2014-01-01":
- break
-
- metadata["page"] += 1
- response = self.request(metadata, "GET", links["prev"]["url"])
- else:
- break
-
- # Append that page's results, depending on the endpoint
- if isinstance(results, list):
- results.extend(response.json())
- elif isinstance(results, dict) and "items" in results:
- results["items"].extend(response.json()["items"])
- if "meta" in results:
- results["meta"]["counts"]["items"]["remaining"] = response.json()["meta"]["counts"]["items"]["remaining"]
- # For event log endpoint
- elif isinstance(results, dict):
- try:
- start = response.json()["pageStartAt"]
- except KeyError:
- if self._logger:
- self._logger.warning(f"pageStartAt missing from response: {response.headers}")
- end = response.json()["pageEndAt"]
- events = response.json()["events"]
- if direction == "next":
- events = events[::-1]
- if start < results["pageStartAt"]:
- results["pageStartAt"] = start
- if end > results["pageEndAt"]:
- results["pageEndAt"] = end
- results["events"].extend(events)
-
- total_pages -= 1
-
- if response:
- response.close()
-
- return results
-
- def post(self, metadata, url, json=None):
- metadata["method"] = "POST"
- metadata["url"] = url
- metadata["json"] = json
- response = self.request(metadata, "POST", url, json=json)
- ret = None
- if response:
- if response.content.strip():
- ret = response.json()
- response.close()
- return ret
-
- def put(self, metadata, url, json=None):
- metadata["method"] = "PUT"
- metadata["url"] = url
- metadata["json"] = json
- response = self.request(metadata, "PUT", url, json=json)
- ret = None
- if response:
- if response.content.strip():
- ret = response.json()
- response.close()
- return ret
-
- def delete(self, metadata, url, json=None):
- metadata["method"] = "DELETE"
- metadata["url"] = url
- metadata["json"] = json
- response = self.request(metadata, "DELETE", url, json=json)
- if response:
- response.close()
- return None
diff --git a/meraki/session/__init__.py b/meraki/session/__init__.py
new file mode 100644
index 00000000..be173387
--- /dev/null
+++ b/meraki/session/__init__.py
@@ -0,0 +1,7 @@
+"""Session implementations for Meraki Dashboard API."""
+
+from meraki.session.base import SessionBase
+from meraki.session.sync import RestSession
+from meraki.session.async_ import AsyncRestSession
+
+__all__ = ["SessionBase", "RestSession", "AsyncRestSession"]
diff --git a/meraki/session/async_.py b/meraki/session/async_.py
new file mode 100644
index 00000000..90829fca
--- /dev/null
+++ b/meraki/session/async_.py
@@ -0,0 +1,689 @@
+"""Asynchronous REST session for Meraki Dashboard API."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import random
+import urllib.parse
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional
+
+import httpx
+
+from meraki.common import validate_base_url, validate_user_agent
+from meraki.config import AIO_MAXIMUM_CONCURRENT_REQUESTS
+from meraki.exceptions import APIError, SessionInputError
+from meraki.smart_flow import AsyncOrgRateLimiter
+from meraki.session.base import SessionBase, apply_meraki_param_encoding
+
+
+class AsyncRestSession(SessionBase):
+ """Asynchronous session using httpx.AsyncClient.
+
+ Inherits config storage from SessionBase.
+ Overrides request() as async with await on _send_request/_sleep.
+ Uses httpx.Limits for concurrency control (replaces asyncio.Semaphore per D-02).
+ """
+
+ def __init__(
+ self,
+ logger,
+ api_key,
+ maximum_concurrent_requests: int = AIO_MAXIMUM_CONCURRENT_REQUESTS,
+ **kwargs: Any,
+ ) -> None:
+ super().__init__(logger, api_key, **kwargs)
+
+ # Build headers dict
+ headers = self._build_headers()
+ # Async user-agent prefix
+ headers["User-Agent"] = f"python-meraki/aio-{self._version} " + validate_user_agent(self._be_geo_id, self._caller)
+
+ # Build client config (per D-02: Limits replaces Semaphore, per D-06: proxy passthrough)
+ client_kwargs: Dict[str, Any] = {
+ "timeout": self._single_request_timeout,
+ "limits": httpx.Limits(max_connections=maximum_concurrent_requests),
+ "headers": headers,
+ }
+ if self._certificate_path:
+ client_kwargs["verify"] = self._certificate_path
+ if self._requests_proxy:
+ client_kwargs["proxy"] = self._requests_proxy
+
+ # Persistent async client with connection pooling
+ self._client = httpx.AsyncClient(**client_kwargs)
+
+ # Per-org smart flow (opt-in)
+ if self._smart_flow_enabled:
+ self._smart_flow = AsyncOrgRateLimiter(
+ rate=self._smart_flow_org_rate,
+ capacity=int(self._smart_flow_org_rate),
+ global_rate=self._smart_flow_global_rate,
+ cache_path=self._smart_flow_cache_path or None,
+ cache_ttl=self._smart_flow_cache_ttl,
+ logger=self._logger if self._smart_flow_logging else None,
+ )
+ self._smart_flow.set_resolver(self._resolve_org_for_limiter)
+ self._smart_flow.set_hydrator(self._hydrate_org_for_limiter)
+
+ # Trigger the property setter to bind the correct get_pages implementation
+ self.use_iterator_for_get_pages = self._use_iterator_for_get_pages
+
+ @property
+ def use_iterator_for_get_pages(self):
+ return self._use_iterator_for_get_pages
+
+ @use_iterator_for_get_pages.setter
+ def use_iterator_for_get_pages(self, value):
+ if value:
+ self.get_pages = self._get_pages_iterator
+ else:
+ self.get_pages = self._get_pages_legacy
+ self._use_iterator_for_get_pages = value
+
+ # ------------------------------------------------------------------
+ # Abstract method implementations
+ # ------------------------------------------------------------------
+
+ async def _send_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
+ """Send HTTP request via httpx.AsyncClient (pool limits enforce concurrency per D-02)."""
+ # Pre-encode Meraki array-of-objects params; httpx mishandles them.
+ url = apply_meraki_param_encoding(url, kwargs)
+ response = await self._client.request(method, url, follow_redirects=False, **kwargs)
+ return response
+
+ async def _sleep(self, seconds: float) -> None:
+ """Async sleep for retry delays."""
+ await asyncio.sleep(seconds)
+
+ def _transport_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
+ """No-op: httpx config handled at client initialization level."""
+ return kwargs
+
+ # ------------------------------------------------------------------
+ # Smart flow resolver
+ # ------------------------------------------------------------------
+
+ async def _acquire_global_bucket(self) -> None:
+ """Gate internal hydration/resolution traffic on the global bucket.
+
+ These internal GETs bypass self.request() to avoid resolver reentrancy,
+ but must still account against the global (source IP) bucket they help
+ populate. Defensive: no-op if smart flow or the bucket is unavailable.
+ """
+ if not self._smart_flow:
+ return
+ bucket = getattr(self._smart_flow, "_global_bucket", None)
+ if bucket is not None:
+ await bucket.acquire()
+
+ async def _resolve_org_for_limiter(self, id_type: str, identifier: str) -> Optional[str]:
+ """Resolve a network/device ID to its org by calling the API directly."""
+ if id_type == "network":
+ endpoint = f"{self._base_url}/networks/{identifier}"
+ else:
+ endpoint = f"{self._base_url}/devices/{identifier}"
+ try:
+ await self._acquire_global_bucket()
+ response = await self._client.request("GET", endpoint, follow_redirects=True)
+ if response.status_code == 200:
+ data = response.json()
+ return data.get("organizationId")
+ except Exception:
+ pass
+ return None
+
+ async def _hydrate_org_for_limiter(self, org_id: str) -> None:
+ """Fetch all networks and devices for an org and register them with the limiter."""
+ networks = await self._fetch_all_pages(f"{self._base_url}/organizations/{org_id}/networks?perPage=1000")
+ for net in networks:
+ if "id" in net:
+ self._smart_flow.register_network(net["id"], org_id)
+
+ devices = await self._fetch_all_pages(f"{self._base_url}/organizations/{org_id}/inventoryDevices?perPage=1000")
+ for dev in devices:
+ if "serial" in dev:
+ self._smart_flow.register_device(dev["serial"], org_id)
+
+ async def _fetch_all_pages(self, url: str) -> list:
+ """Paginate through a Meraki list endpoint using Link headers."""
+ results = []
+ while url:
+ await self._acquire_global_bucket()
+ response = await self._client.request("GET", url, follow_redirects=True)
+ if response.status_code != 200:
+ break
+ page = response.json()
+ if isinstance(page, list):
+ results.extend(page)
+ next_link = response.links.get("next", {}).get("url")
+ url = next_link if next_link else None
+ return results
+
+ # ------------------------------------------------------------------
+ # Async request override (awaits abstract methods)
+ # ------------------------------------------------------------------
+
+ async def request(self, metadata: Dict[str, Any], method: str, url: str, **kwargs: Any) -> Optional[httpx.Response]:
+ """Execute an API request with retry loop and status dispatch (async version).
+
+ Mirrors SessionBase.request() but awaits _send_request and _sleep.
+ """
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+
+ # Prepare transport-specific kwargs
+ kwargs = self._transport_kwargs(kwargs)
+
+ # aiohttp manipulates URLs as instances of yarl.URL
+ if not isinstance(url, str):
+ url = str(url)
+
+ # Resolve absolute URL
+ abs_url = validate_base_url(self, url)
+
+ # Simulate non-GET calls
+ if self._logger:
+ self._logger.debug(metadata)
+ if self._simulate and method != "GET":
+ if self._logger:
+ self._logger.info(f"{tag}, {operation} - SIMULATED")
+ return None
+
+ retries = self._maximum_retries
+ response: Optional[httpx.Response] = None
+
+ while retries > 0:
+ # Per-org rate limiting (proactive throttle before sending)
+ if self._smart_flow:
+ await self._smart_flow.acquire(abs_url)
+
+ # Attempt the request
+ try:
+ if response:
+ await response.aclose()
+ if self._logger:
+ self._logger.info(f"{method} {abs_url}")
+ response = await self._send_request(method, abs_url, **kwargs)
+ except httpx.HTTPError as e:
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - {e}, retrying in 1 second")
+ await self._sleep(1)
+ retries -= 1
+ if retries == 0:
+ raise APIError(
+ metadata,
+ type(
+ "FakeResponse",
+ (),
+ {"status_code": 503, "reason_phrase": str(e), "json": lambda self: {}, "content": b""},
+ )(),
+ )
+ continue
+
+ status = response.status_code
+ reason = response.reason_phrase if response.reason_phrase else ""
+
+ # Dispatch by status code
+ if 300 <= status < 400:
+ abs_url = self._handle_redirect_async(response)
+ elif 200 <= status < 300:
+ if self._smart_flow:
+ self._smart_flow.on_success(abs_url)
+ # _handle_success_async returns (response, parsed_body); parsed_body
+ # is the decoded GET body (or None) so we don't parse JSON twice.
+ result, parsed_body = await self._handle_success_async(response, metadata, method)
+ if result is None:
+ # JSON decode failure, retry
+ retries -= 1
+ if retries == 0:
+ raise APIError(metadata, response)
+ await self._sleep(1)
+ continue
+ if self._smart_flow and method == "GET" and parsed_body is not None:
+ try:
+ self._smart_flow.learn_from_response(abs_url, parsed_body)
+ except (ValueError, AttributeError):
+ pass
+ return result
+ elif status == 429:
+ if self._smart_flow:
+ self._smart_flow.on_rate_limited(abs_url)
+ wait = self._handle_rate_limit_async(response, metadata, retries)
+ await self._sleep(wait)
+ retries -= 1
+ if retries == 0:
+ raise APIError(metadata, response)
+ elif status >= 500:
+ request_id = response.headers.get("X-Request-Id") or "none"
+ if self._logger:
+ self._logger.warning(
+ f"{tag}, {operation} - {status} {reason} (X-Request-Id: {request_id}), retrying in 1 second"
+ )
+ await self._sleep(1)
+ retries -= 1
+ if retries == 0:
+ if self._logger:
+ self._logger.error(
+ f"{tag}, {operation} - {status} {reason} failed after retries. "
+ f"Provide this X-Request-Id to Meraki for log lookup: {request_id}"
+ )
+ raise APIError(metadata, response)
+ elif 400 <= status < 500:
+ retries = await self._handle_client_error_async(response, metadata, retries)
+
+ return response
+
+ # ------------------------------------------------------------------
+ # Async status handlers
+ # ------------------------------------------------------------------
+
+ async def _handle_success_async(
+ self,
+ response: Any,
+ metadata: Dict[str, Any],
+ method: str,
+ ) -> tuple[Optional[Any], Optional[Any]]:
+ """Handle 2xx responses (async).
+
+ Returns (response, parsed_body). parsed_body is the decoded GET JSON body
+ (or None for non-GET / empty bodies), parsed once here so callers do not
+ re-parse. On JSON decode failure returns (None, None) to signal a retry.
+ """
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+ reason = response.reason_phrase if response.reason_phrase else ""
+ status = response.status_code
+
+ if "page" in metadata:
+ counter = metadata["page"]
+ if self._logger:
+ self._logger.info(f"{tag}, {operation}; page {counter} - {status} {reason}")
+ else:
+ if self._logger:
+ self._logger.info(f"{tag}, {operation} - {status} {reason}")
+
+ # For non-empty GET responses, validate (and capture) the JSON once.
+ try:
+ if method == "GET" and response.content.strip():
+ return response, response.json()
+ return response, None
+ except (json.decoder.JSONDecodeError, ValueError):
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - JSON decode error, retrying in 1 second")
+ return None, None
+
+ def _handle_redirect_async(self, response: Any) -> str:
+ """Handle 3xx redirects for aiohttp responses."""
+ abs_url = str(response.headers["Location"])
+ substring = "meraki.com/api/v"
+ if substring not in abs_url:
+ substring = "meraki.cn/api/v"
+ if substring in abs_url:
+ self._base_url = abs_url[: abs_url.find(substring) + len(substring) + 1]
+ return abs_url
+
+ def _handle_rate_limit_async(
+ self,
+ response: Any,
+ metadata: Dict[str, Any],
+ retries: int,
+ ) -> float:
+ """Handle 429 rate limiting (async). Returns seconds to wait."""
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+ reason = response.reason_phrase if response.reason_phrase else ""
+ status = response.status_code
+
+ if not self._wait_on_rate_limit or retries <= 0:
+ raise APIError(metadata, response)
+
+ if "Retry-After" in response.headers:
+ wait = int(response.headers["Retry-After"])
+ else:
+ attempt = self._maximum_retries - retries
+ wait = min(
+ (2**attempt) * (1 + random.random()),
+ self._nginx_429_retry_wait_time,
+ )
+
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
+ return wait
+
+ async def _handle_client_error_async(
+ self,
+ response: Any,
+ metadata: Dict[str, Any],
+ retries: int,
+ ) -> int:
+ """Handle 4xx client errors (async). Returns updated retry count."""
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+ reason = response.reason_phrase if response.reason_phrase else ""
+ status = response.status_code
+
+ # Parse response body
+ try:
+ message = response.json()
+ message_is_dict = isinstance(message, dict)
+ except (json.decoder.JSONDecodeError, ValueError):
+ message_is_dict = False
+ try:
+ message = response.text[:100]
+ except Exception:
+ message = None
+
+ # Network delete concurrency error
+ if (
+ metadata.get("operation") == "deleteNetwork"
+ and status == 400
+ and message_is_dict
+ and "errors" in message
+ and "concurrent" in str(message["errors"][0])
+ ):
+ wait = random.randint(30, self._network_delete_retry_wait_time)
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
+ await self._sleep(wait)
+ retries -= 1
+ if retries == 0:
+ raise APIError(metadata, response)
+ return retries
+
+ # Action batch concurrency error
+ if message_is_dict and "errors" in message and "executing batches" in str(message["errors"][0]).lower():
+ wait = self._action_batch_retry_wait_time
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
+ await self._sleep(wait)
+ retries -= 1
+ if retries == 0:
+ raise APIError(metadata, response)
+ return retries
+
+ # Retry other 4xx if configured
+ if self._retry_4xx_error:
+ wait = random.randint(1, self._retry_4xx_error_wait_time)
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
+ await self._sleep(wait)
+ retries -= 1
+ if retries == 0:
+ raise APIError(metadata, response)
+ return retries
+
+ # Non-retryable client error
+ if self._logger:
+ self._logger.error(f"{tag}, {operation} - {status} {reason}, {message}")
+ raise APIError(metadata, response)
+
+ # ------------------------------------------------------------------
+ # Convenience HTTP methods
+ # ------------------------------------------------------------------
+
+ async def get(self, metadata, url, params=None):
+ metadata["method"] = "GET"
+ metadata["url"] = url
+ metadata["params"] = params
+ response = await self.request(metadata, "GET", url, params=params)
+ if response:
+ if response.content.strip():
+ return response.json()
+ return None
+
+ async def get_pages(self, metadata, url, params=None, total_pages=-1, direction="next", event_log_end_time=None):
+ pass
+
+ async def _download_page(self, request):
+ response = await request
+ # Guard against 204 No Content pages (empty body -> no JSON to parse).
+ if response.status_code == 204:
+ result = None
+ else:
+ result = response.json()
+ return response, result
+
+ async def _get_pages_iterator(
+ self,
+ metadata,
+ url,
+ params=None,
+ total_pages=-1,
+ direction="next",
+ event_log_end_time=None,
+ ):
+ if isinstance(total_pages, str) and total_pages.lower() == "all":
+ total_pages = -1
+ elif isinstance(total_pages, str) and total_pages.isnumeric():
+ total_pages = int(total_pages)
+ elif not isinstance(total_pages, int):
+ raise SessionInputError(
+ "total_pages",
+ total_pages,
+ "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).",
+ None,
+ )
+ metadata["page"] = 1
+
+ request_task = asyncio.create_task(self._download_page(self.request(metadata, "GET", url, params=params)))
+
+ # Wrap in try/finally so an in-flight prefetch task is cancelled if the
+ # consumer breaks early (avoids an orphaned request and "Task pending" warning).
+ try:
+ # Get additional pages if more than one requested
+ while total_pages != 0:
+ response, results = await request_task
+
+ # Guard against 204 No Content pages (empty body -> stop cleanly).
+ if response.status_code == 204:
+ await response.aclose()
+ return
+
+ links = response.links
+
+ # GET the subsequent page
+ if direction == "next" and "next" in links:
+ # Prevent getNetworkEvents from infinite loop as time goes forward
+ if metadata["operation"] == "getNetworkEvents":
+ starting_after = urllib.parse.unquote(str(links["next"]["url"]).split("startingAfter=")[1])
+ delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after)
+ # Break out of loop if startingAfter returned from next link is within 5 minutes of current time
+ if delta.total_seconds() < 300:
+ break
+ # Or if next page is past the specified window's end time
+ elif event_log_end_time and starting_after > event_log_end_time:
+ break
+
+ metadata["page"] += 1
+ nextlink = links["next"]["url"]
+ elif direction == "prev" and "prev" in links:
+ # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0)
+ if metadata["operation"] == "getNetworkEvents":
+ ending_before = urllib.parse.unquote(str(links["prev"]["url"]).split("endingBefore=")[1])
+ # Break out of loop if endingBefore returned from prev link is before 2014
+ if ending_before < "2014-01-01":
+ break
+
+ metadata["page"] += 1
+ nextlink = links["prev"]["url"]
+ else:
+ total_pages = 1
+
+ await response.aclose()
+
+ total_pages = total_pages - 1
+
+ if total_pages != 0:
+ request_task = asyncio.create_task(self._download_page(self.request(metadata, "GET", nextlink)))
+
+ return_items = []
+ # just prepare the list
+ if isinstance(results, list):
+ return_items = results
+ elif isinstance(results, dict) and "items" in results:
+ return_items = results["items"]
+ # For event log endpoint
+ elif isinstance(results, dict):
+ if direction == "next":
+ return_items = results["events"][::-1]
+ else:
+ return_items = results["events"]
+
+ for item in return_items:
+ yield item
+ finally:
+ # Cancel and drain any still-pending prefetch (e.g. consumer broke early).
+ if request_task is not None and not request_task.done():
+ request_task.cancel()
+ try:
+ await request_task
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ pass
+ elif request_task is not None and request_task.cancelled() is False:
+ # Task completed: close its response to release the connection.
+ try:
+ resolved_response, _ = request_task.result()
+ await resolved_response.aclose()
+ except Exception:
+ pass
+
+ async def _get_pages_legacy(
+ self,
+ metadata,
+ url,
+ params=None,
+ total_pages=-1,
+ direction="next",
+ event_log_end_time=None,
+ ):
+ if isinstance(total_pages, str) and total_pages.lower() == "all":
+ total_pages = -1
+ elif isinstance(total_pages, str) and total_pages.isnumeric():
+ total_pages = int(total_pages)
+ elif not isinstance(total_pages, int):
+ raise SessionInputError(
+ "total_pages",
+ total_pages,
+ "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).",
+ None,
+ )
+ metadata["page"] = 1
+
+ response = await self.request(metadata, "GET", url, params=params)
+
+ if response.status_code == 204:
+ results = None
+ else:
+ results = response.json()
+
+ # For event log endpoint when using 'next' direction
+ if isinstance(results, dict) and metadata["operation"] == "getNetworkEvents" and direction == "next":
+ results["events"] = results["events"][::-1]
+
+ links = response.links
+ await response.aclose()
+
+ # Get additional pages if more than one requested
+ while total_pages != 1:
+ # GET the subsequent page
+ if direction == "next" and "next" in links:
+ # Prevent getNetworkEvents from infinite loop as time goes forward
+ if metadata["operation"] == "getNetworkEvents":
+ starting_after = urllib.parse.unquote(str(links["next"]["url"]).split("startingAfter=")[1])
+ delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after)
+ if delta.total_seconds() < 300:
+ break
+ elif event_log_end_time and starting_after > event_log_end_time:
+ break
+
+ metadata["page"] += 1
+ nextlink = links["next"]["url"]
+ elif direction == "prev" and "prev" in links:
+ if metadata["operation"] == "getNetworkEvents":
+ ending_before = urllib.parse.unquote(str(links["prev"]["url"]).split("endingBefore=")[1])
+ if ending_before < "2014-01-01":
+ break
+
+ metadata["page"] += 1
+ nextlink = links["prev"]["url"]
+ else:
+ break
+
+ response = await self.request(metadata, "GET", nextlink)
+ links = response.links
+ if isinstance(results, list):
+ results.extend(response.json())
+ elif isinstance(results, dict) and "items" in results:
+ json_response = response.json()
+ results["items"].extend(json_response["items"])
+ if "meta" in results:
+ results["meta"]["counts"]["items"]["remaining"] = json_response["meta"]["counts"]["items"]["remaining"]
+ # For event log endpoint
+ elif isinstance(results, dict):
+ json_response = response.json()
+ start = json_response["pageStartAt"]
+ end = json_response["pageEndAt"]
+ events = json_response["events"]
+ if direction == "next":
+ events = events[::-1]
+ if start < results["pageStartAt"]:
+ results["pageStartAt"] = start
+ if end > results["pageEndAt"]:
+ results["pageEndAt"] = end
+ results["events"].extend(events)
+
+ await response.aclose()
+ total_pages = total_pages - 1
+
+ return results
+
+ async def post(self, metadata, url, json=None):
+ metadata["method"] = "POST"
+ metadata["url"] = url
+ metadata["json"] = json
+ response = await self.request(metadata, "POST", url, json=json)
+ if response:
+ if response.content.strip():
+ return response.json()
+ return None
+
+ async def put(self, metadata, url, json=None):
+ metadata["method"] = "PUT"
+ metadata["url"] = url
+ metadata["json"] = json
+ response = await self.request(metadata, "PUT", url, json=json)
+ if response:
+ if response.content.strip():
+ return response.json()
+ return None
+
+ async def patch(self, metadata, url, json=None):
+ metadata["method"] = "PATCH"
+ metadata["url"] = url
+ metadata["json"] = json
+ response = await self.request(metadata, "PATCH", url, json=json)
+ if response:
+ if response.content.strip():
+ return response.json()
+ return None
+
+ async def delete(self, metadata, url, params=None):
+ metadata["method"] = "DELETE"
+ metadata["url"] = url
+ metadata["params"] = params
+ await self.request(metadata, "DELETE", url, params=params)
+ return None
+
+ async def close(self):
+ """Close the underlying httpx.AsyncClient and release connections."""
+ await self._client.aclose()
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ await self.close()
diff --git a/meraki/session/base.py b/meraki/session/base.py
new file mode 100644
index 00000000..d4d5249b
--- /dev/null
+++ b/meraki/session/base.py
@@ -0,0 +1,513 @@
+"""Abstract base class for sync and async Meraki API sessions."""
+
+from __future__ import annotations
+
+import json
+import random
+from abc import ABC, abstractmethod
+from typing import Any, Dict, Optional
+
+from meraki._version import __version__
+from meraki.common import (
+ check_python_version,
+ reject_v0_base_url,
+ validate_base_url,
+ validate_meraki_app_value,
+ validate_user_agent,
+)
+from meraki.config import (
+ ACTION_BATCH_RETRY_WAIT_TIME,
+ BE_GEO_ID,
+ CERTIFICATE_PATH,
+ DEFAULT_BASE_URL,
+ MAXIMUM_RETRIES,
+ MERAKI_APP_BEARER_TOKEN,
+ MERAKI_APP_ID,
+ MERAKI_PYTHON_SDK_CALLER,
+ NETWORK_DELETE_RETRY_WAIT_TIME,
+ NGINX_429_RETRY_WAIT_TIME,
+ SMART_FLOW_ENABLED,
+ SMART_FLOW_CACHE_PATH,
+ SMART_FLOW_CACHE_TTL,
+ SMART_FLOW_CACHE_MODE,
+ SMART_FLOW_GLOBAL_RATE,
+ SMART_FLOW_LOGGING,
+ SMART_FLOW_ORG_RATE,
+ REQUESTS_PROXY,
+ RETRY_4XX_ERROR,
+ RETRY_4XX_ERROR_WAIT_TIME,
+ SIMULATE_API_CALLS,
+ SINGLE_REQUEST_TIMEOUT,
+ USE_ITERATOR_FOR_GET_PAGES,
+ WAIT_ON_RATE_LIMIT,
+)
+import httpx
+
+from meraki.exceptions import APIError, APIResponseError
+from meraki.response_handler import handle_3xx
+
+
+def params_need_meraki_encoding(params: Any) -> bool:
+ """Return True if params is a dict containing list-of-dict values.
+
+ Meraki's array-of-objects query encoding (param[]k1=v1¶m[]k2=v2) is only
+ needed for this shape. Scalars and scalar lists (e.g. networkIds[]=a&b) are
+ encoded correctly by httpx and must be left untouched.
+ """
+ if not isinstance(params, dict):
+ return False
+ for value in params.values():
+ if isinstance(value, (list, tuple)):
+ if any(isinstance(item, dict) for item in value):
+ return True
+ return False
+
+
+def apply_meraki_param_encoding(url: str, kwargs: Dict[str, Any]) -> str:
+ """Pre-encode list-of-dict params via encode_meraki_params and fold into URL.
+
+ When params contains array-of-objects values, httpx stringifies them
+ incorrectly. We build the query string ourselves, append it to the URL, and
+ drop params so httpx does not re-encode. Scalar / scalar-list params are left
+ for httpx to handle as before.
+ """
+ from meraki.encoding import encode_meraki_params
+
+ params = kwargs.get("params")
+ if not params_need_meraki_encoding(params):
+ return url
+
+ query = encode_meraki_params(params)
+ kwargs["params"] = None
+ if not query:
+ return url
+ separator = "&" if "?" in url else "?"
+ return f"{url}{separator}{query}"
+
+
+class SessionBase(ABC):
+ """Abstract base class providing config storage, URL resolution, retry loop, and status dispatch.
+
+ Subclasses must implement:
+ _send_request: perform the actual HTTP call
+ _sleep: pause execution (sync or async)
+ _transport_kwargs: prepare transport-specific request kwargs
+ """
+
+ def __init__(
+ self,
+ logger: Any,
+ api_key: str,
+ meraki_app_id: str = MERAKI_APP_ID,
+ meraki_app_bearer_token: str = MERAKI_APP_BEARER_TOKEN,
+ base_url: str = DEFAULT_BASE_URL,
+ single_request_timeout: int = SINGLE_REQUEST_TIMEOUT,
+ certificate_path: str = CERTIFICATE_PATH,
+ requests_proxy: str = REQUESTS_PROXY,
+ wait_on_rate_limit: bool = WAIT_ON_RATE_LIMIT,
+ nginx_429_retry_wait_time: int = NGINX_429_RETRY_WAIT_TIME,
+ action_batch_retry_wait_time: int = ACTION_BATCH_RETRY_WAIT_TIME,
+ network_delete_retry_wait_time: int = NETWORK_DELETE_RETRY_WAIT_TIME,
+ retry_4xx_error: bool = RETRY_4XX_ERROR,
+ retry_4xx_error_wait_time: int = RETRY_4XX_ERROR_WAIT_TIME,
+ maximum_retries: int = MAXIMUM_RETRIES,
+ simulate: bool = SIMULATE_API_CALLS,
+ be_geo_id: str = BE_GEO_ID,
+ caller: str = MERAKI_PYTHON_SDK_CALLER,
+ use_iterator_for_get_pages: bool = USE_ITERATOR_FOR_GET_PAGES,
+ validate_kwargs: bool = False,
+ smart_flow_enabled: bool = SMART_FLOW_ENABLED,
+ smart_flow_org_rate: float = SMART_FLOW_ORG_RATE,
+ smart_flow_global_rate: float = SMART_FLOW_GLOBAL_RATE,
+ smart_flow_cache_mode: str = SMART_FLOW_CACHE_MODE,
+ smart_flow_cache_path: str = SMART_FLOW_CACHE_PATH,
+ smart_flow_cache_ttl: Optional[float] = SMART_FLOW_CACHE_TTL,
+ smart_flow_logging: bool = SMART_FLOW_LOGGING,
+ ) -> None:
+ super().__init__()
+
+ # Store config attributes
+ self._version = __version__
+ self._api_key = str(api_key)
+ validate_meraki_app_value("meraki_app_id", meraki_app_id)
+ validate_meraki_app_value("meraki_app_bearer_token", meraki_app_bearer_token)
+ self._meraki_app_id = meraki_app_id
+ self._meraki_app_bearer_token = meraki_app_bearer_token
+ self._base_url = str(base_url)
+ self._single_request_timeout = single_request_timeout
+ self._certificate_path = certificate_path
+ self._requests_proxy = requests_proxy
+ self._wait_on_rate_limit = wait_on_rate_limit
+ self._nginx_429_retry_wait_time = nginx_429_retry_wait_time
+ self._action_batch_retry_wait_time = action_batch_retry_wait_time
+ self._network_delete_retry_wait_time = network_delete_retry_wait_time
+ self._retry_4xx_error = retry_4xx_error
+ self._retry_4xx_error_wait_time = retry_4xx_error_wait_time
+ self._maximum_retries = maximum_retries
+ self._simulate = simulate
+ self._be_geo_id = be_geo_id
+ self._caller = caller
+ self._use_iterator_for_get_pages = use_iterator_for_get_pages
+ self._validate_kwargs = validate_kwargs
+ self._smart_flow_enabled = smart_flow_enabled
+ self._smart_flow_org_rate = smart_flow_org_rate
+ self._smart_flow_global_rate = smart_flow_global_rate
+ self._smart_flow_cache_mode = smart_flow_cache_mode
+ self._smart_flow_cache_path = smart_flow_cache_path
+ self._smart_flow_cache_ttl = smart_flow_cache_ttl
+ self._smart_flow_logging = smart_flow_logging
+
+ # Check Python version
+ check_python_version()
+
+ # Reject v0 base URL
+ reject_v0_base_url(self)
+
+ # Logger and masked parameters for logging
+ self._logger = logger
+ self._parameters: Dict[str, Any] = {"version": self._version}
+ self._parameters["api_key"] = "*" * 36 + self._api_key[-4:]
+ if self._meraki_app_bearer_token:
+ self._parameters["meraki_app_bearer_token"] = "*" * 36 + str(self._meraki_app_bearer_token)[-4:]
+ self._parameters["base_url"] = self._base_url
+ self._parameters["single_request_timeout"] = self._single_request_timeout
+ self._parameters["certificate_path"] = self._certificate_path
+ self._parameters["requests_proxy"] = self._requests_proxy
+ self._parameters["wait_on_rate_limit"] = self._wait_on_rate_limit
+ self._parameters["nginx_429_retry_wait_time"] = self._nginx_429_retry_wait_time
+ self._parameters["action_batch_retry_wait_time"] = self._action_batch_retry_wait_time
+ self._parameters["network_delete_retry_wait_time"] = self._network_delete_retry_wait_time
+ self._parameters["retry_4xx_error"] = self._retry_4xx_error
+ self._parameters["retry_4xx_error_wait_time"] = self._retry_4xx_error_wait_time
+ self._parameters["maximum_retries"] = self._maximum_retries
+ self._parameters["simulate"] = self._simulate
+ self._parameters["be_geo_id"] = self._be_geo_id
+ self._parameters["caller"] = self._caller
+ self._parameters["use_iterator_for_get_pages"] = self._use_iterator_for_get_pages
+ self._parameters["smart_flow"] = self._smart_flow_enabled
+
+ # Smart flow limiter is initialized to None here; subclasses create the
+ # appropriate sync or async variant when smart_flow is enabled.
+ self._smart_flow = None
+
+ if self._logger:
+ self._logger.info(f"Meraki dashboard API session initialized with these parameters: {self._parameters}")
+
+ # ------------------------------------------------------------------
+ # Abstract methods (subclass contract)
+ # ------------------------------------------------------------------
+
+ @abstractmethod
+ def _send_request(self, method: str, url: str, **kwargs: Any) -> "httpx.Response":
+ """Send the HTTP request. Implemented by sync/async subclasses."""
+ ...
+
+ @abstractmethod
+ def _sleep(self, seconds: float) -> None:
+ """Sleep for the given duration. Sync uses time.sleep, async uses asyncio.sleep."""
+ ...
+
+ @abstractmethod
+ def _transport_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
+ """Prepare transport-specific kwargs (verify, proxy, timeout, etc.)."""
+ ...
+
+ # ------------------------------------------------------------------
+ # Template method: request
+ # ------------------------------------------------------------------
+
+ def request(self, metadata: Dict[str, Any], method: str, url: str, **kwargs: Any) -> Optional["httpx.Response"]:
+ """Execute an API request with retry loop and status dispatch.
+
+ Args:
+ metadata: Endpoint metadata (tags, operation, optional page counter).
+ method: HTTP method (GET, POST, PUT, DELETE).
+ url: Endpoint URL (relative or absolute).
+ **kwargs: Additional request kwargs (json, params, etc.).
+
+ Returns:
+ httpx.Response on success, or None if simulated.
+ """
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+
+ # Prepare transport-specific kwargs
+ kwargs = self._transport_kwargs(kwargs)
+
+ # Resolve absolute URL
+ abs_url = validate_base_url(self, url)
+
+ # Simulate non-GET calls
+ if self._logger:
+ self._logger.debug(metadata)
+ if self._simulate and method != "GET":
+ if self._logger:
+ self._logger.info(f"{tag}, {operation} - SIMULATED")
+ return None
+
+ retries = self._maximum_retries
+ response: Optional["httpx.Response"] = None
+
+ while retries > 0:
+ # Per-org rate limiting (proactive throttle before sending)
+ if self._smart_flow:
+ self._smart_flow.acquire(abs_url)
+
+ # Attempt the request
+ try:
+ if self._logger:
+ self._logger.info(f"{method} {abs_url}")
+ response = self._send_request(method, abs_url, **kwargs)
+ except httpx.HTTPError as e:
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - {e}, retrying in 1 second")
+ self._sleep(1)
+ retries -= 1
+ if retries == 0:
+ raise APIError(
+ metadata,
+ APIResponseError(e.__class__.__name__, 503, str(e)),
+ )
+ continue
+
+ status = response.status_code
+
+ # Dispatch by status code
+ if 300 <= status < 400:
+ abs_url = self._handle_redirect(response)
+ elif 200 <= status < 300:
+ if self._smart_flow:
+ self._smart_flow.on_success(abs_url)
+ result = self._handle_success(response, metadata, method, retries)
+ if result is None:
+ # JSON decode failure, retry
+ retries -= 1
+ if retries == 0:
+ raise APIError(metadata, response)
+ self._sleep(1)
+ continue
+ if self._smart_flow and method == "GET" and result.content.strip():
+ try:
+ self._smart_flow.learn_from_response(abs_url, result.json())
+ except (ValueError, AttributeError):
+ pass
+ return result
+ elif status == 429:
+ if self._smart_flow:
+ self._smart_flow.on_rate_limited(abs_url)
+ wait = self._handle_rate_limit(response, metadata, retries)
+ self._sleep(wait)
+ retries -= 1
+ if retries == 0:
+ raise APIError(metadata, response)
+ elif status >= 500:
+ self._handle_server_error(response, metadata)
+ self._sleep(1)
+ retries -= 1
+ if retries == 0:
+ self._log_server_error_exhausted(response, metadata)
+ raise APIError(metadata, response)
+ elif 400 <= status < 500:
+ retries = self._handle_client_error(response, metadata, retries)
+
+ return response
+
+ # ------------------------------------------------------------------
+ # Status handlers (each kept under cyclomatic complexity 10)
+ # ------------------------------------------------------------------
+
+ def _handle_success(
+ self,
+ response: "httpx.Response",
+ metadata: Dict[str, Any],
+ method: str,
+ retries: int,
+ ) -> Optional["httpx.Response"]:
+ """Handle 2xx responses. Returns response or None if JSON validation fails."""
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+ reason = response.reason_phrase if hasattr(response, "reason_phrase") else ""
+ status = response.status_code
+
+ if "page" in metadata:
+ counter = metadata["page"]
+ if self._logger:
+ self._logger.info(f"{tag}, {operation}; page {counter} - {status} {reason}")
+ else:
+ if self._logger:
+ self._logger.info(f"{tag}, {operation} - {status} {reason}")
+
+ # For non-empty GET responses, validate JSON
+ try:
+ if method == "GET" and response.content.strip():
+ response.json()
+ return response
+ except (json.decoder.JSONDecodeError, ValueError):
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - JSON decode error, retrying in 1 second")
+ return None
+
+ def _handle_redirect(self, response: "httpx.Response") -> str:
+ """Handle 3xx redirects. Returns the new absolute URL."""
+ return handle_3xx(self, response)
+
+ def _handle_rate_limit(
+ self,
+ response: "httpx.Response",
+ metadata: Dict[str, Any],
+ retries: int,
+ ) -> float:
+ """Handle 429 rate limiting. Returns seconds to wait.
+
+ Raises APIError if rate limit retries disabled or retries exhausted.
+ """
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+ reason = response.reason_phrase if hasattr(response, "reason_phrase") else ""
+ status = response.status_code
+
+ if not self._wait_on_rate_limit or retries <= 0:
+ raise APIError(metadata, response)
+
+ if "Retry-After" in response.headers:
+ wait = int(response.headers["Retry-After"])
+ else:
+ attempt = self._maximum_retries - retries
+ wait = min(
+ (2**attempt) * (1 + random.random()),
+ self._nginx_429_retry_wait_time,
+ )
+
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
+ return wait
+
+ def _handle_server_error(self, response: "httpx.Response", metadata: Dict[str, Any]) -> None:
+ """Handle 5xx server errors. Logs warning (with Meraki X-Request-Id) before retry."""
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+ reason = response.reason_phrase if hasattr(response, "reason_phrase") else ""
+ status = response.status_code
+ request_id = response.headers.get("X-Request-Id") or "none"
+
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - {status} {reason} (X-Request-Id: {request_id}), retrying in 1 second")
+
+ def _log_server_error_exhausted(self, response: "httpx.Response", metadata: Dict[str, Any]) -> None:
+ """Log at error level once 5xx retries are exhausted, surfacing the X-Request-Id for Meraki log lookup."""
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+ reason = response.reason_phrase if hasattr(response, "reason_phrase") else ""
+ status = response.status_code
+ request_id = response.headers.get("X-Request-Id") or "none"
+
+ if self._logger:
+ self._logger.error(
+ f"{tag}, {operation} - {status} {reason} failed after retries. "
+ f"Provide this X-Request-Id to Meraki for log lookup: {request_id}"
+ )
+
+ def _handle_client_error(
+ self,
+ response: "httpx.Response",
+ metadata: Dict[str, Any],
+ retries: int,
+ ) -> int:
+ """Handle 4xx client errors. Returns updated retry count.
+
+ Raises APIError if error is not retryable or retries exhausted.
+ """
+ # Parse response body
+ try:
+ message = response.json()
+ except (ValueError, json.decoder.JSONDecodeError):
+ message = response.content[:100]
+
+ # Determine wait time based on error type
+ wait = self._classify_client_error_wait(metadata, response, message)
+
+ if wait is not None:
+ return self._retry_with_wait(wait, metadata, response, retries)
+
+ # Non-retryable client error
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+ reason = response.reason_phrase if hasattr(response, "reason_phrase") else ""
+ status = response.status_code
+ if self._logger:
+ self._logger.error(f"{tag}, {operation} - {status} {reason}, {message}")
+ raise APIError(metadata, response)
+
+ # ------------------------------------------------------------------
+ # Helper methods
+ # ------------------------------------------------------------------
+
+ def _classify_client_error_wait(
+ self,
+ metadata: Dict[str, Any],
+ response: "httpx.Response",
+ message: Any,
+ ) -> Optional[float]:
+ """Determine retry wait time for a 4xx error, or None if non-retryable."""
+ if self._is_network_delete_concurrency(metadata, response, message):
+ return float(random.randint(30, self._network_delete_retry_wait_time))
+ if self._is_action_batch_concurrency(message):
+ return float(self._action_batch_retry_wait_time)
+ if self._retry_4xx_error:
+ return float(random.randint(1, self._retry_4xx_error_wait_time))
+ return None
+
+ def _retry_with_wait(
+ self,
+ wait: float,
+ metadata: Dict[str, Any],
+ response: "httpx.Response",
+ retries: int,
+ ) -> int:
+ """Log, sleep, decrement retries; raise APIError if exhausted."""
+ tag = metadata["tags"][0]
+ operation = metadata["operation"]
+ reason = response.reason_phrase if hasattr(response, "reason_phrase") else ""
+ status = response.status_code
+
+ if self._logger:
+ self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
+ self._sleep(wait)
+ retries -= 1
+ if retries == 0:
+ raise APIError(metadata, response)
+ return retries
+
+ def _is_network_delete_concurrency(
+ self,
+ metadata: Dict[str, Any],
+ response: "httpx.Response",
+ message: Any,
+ ) -> bool:
+ """Check if error is a network delete concurrency conflict."""
+ if metadata.get("operation") != "deleteNetwork":
+ return False
+ if response.status_code != 400:
+ return False
+ if isinstance(message, dict) and "errors" in message:
+ return "concurrent" in str(message["errors"][0])
+ return False
+
+ def _is_action_batch_concurrency(self, message: Any) -> bool:
+ """Check if error is an action batch concurrency conflict."""
+ if isinstance(message, dict) and "errors" in message:
+ return "executing batches" in str(message["errors"][0]).lower()
+ return False
+
+ def _build_headers(self) -> Dict[str, str]:
+ """Build standard request headers."""
+ headers = {
+ "Authorization": "Bearer " + self._api_key,
+ "Content-Type": "application/json",
+ "User-Agent": f"python-meraki/{self._version} " + validate_user_agent(self._be_geo_id, self._caller),
+ }
+ if self._meraki_app_id:
+ headers["X-MerakiApp"] = self._meraki_app_id
+ if self._meraki_app_bearer_token:
+ headers["X-MerakiApp-Authorization"] = "Bearer " + self._meraki_app_bearer_token
+ return headers
diff --git a/meraki/session/sync.py b/meraki/session/sync.py
new file mode 100644
index 00000000..b8eb5d27
--- /dev/null
+++ b/meraki/session/sync.py
@@ -0,0 +1,405 @@
+"""Synchronous REST session for Meraki Dashboard API."""
+
+from __future__ import annotations
+
+import time
+import urllib.parse
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional
+
+import httpx
+
+from meraki.common import (
+ iterator_for_get_pages_bool,
+ use_iterator_for_get_pages_setter,
+)
+from meraki.exceptions import SessionInputError
+from meraki.smart_flow import OrgRateLimiter
+from meraki.session.base import SessionBase, apply_meraki_param_encoding
+
+
+class RestSession(SessionBase):
+ """Synchronous session using httpx.Client.
+
+ Inherits config, retry loop, and status dispatch from SessionBase.
+ Implements transport-specific sleep and request methods.
+ """
+
+ def __init__(self, logger, api_key, **kwargs: Any) -> None:
+ super().__init__(logger, api_key, **kwargs)
+
+ # Build client config from session config (per D-06: requests_proxy -> proxy kwarg)
+ client_kwargs: Dict[str, Any] = {
+ "timeout": self._single_request_timeout,
+ }
+ if self._certificate_path:
+ client_kwargs["verify"] = self._certificate_path
+ if self._requests_proxy:
+ client_kwargs["proxy"] = self._requests_proxy
+
+ # Persistent httpx client with connection pooling
+ self._client = httpx.Client(**client_kwargs)
+ self._client.headers.update(self._build_headers())
+
+ # Per-org smart flow (opt-in)
+ if self._smart_flow_enabled:
+ self._smart_flow = OrgRateLimiter(
+ rate=self._smart_flow_org_rate,
+ capacity=int(self._smart_flow_org_rate),
+ global_rate=self._smart_flow_global_rate,
+ cache_path=self._smart_flow_cache_path or None,
+ cache_ttl=self._smart_flow_cache_ttl,
+ logger=self._logger if self._smart_flow_logging else None,
+ )
+ self._smart_flow.set_resolver(self._resolve_org_for_limiter)
+ self._smart_flow.set_hydrator(self._hydrate_org_for_limiter)
+
+ def close(self):
+ """Close the underlying httpx.Client and release connections."""
+ self._client.close()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ self.close()
+
+ @property
+ def use_iterator_for_get_pages(self):
+ return iterator_for_get_pages_bool(self)
+
+ @use_iterator_for_get_pages.setter
+ def use_iterator_for_get_pages(self, value):
+ use_iterator_for_get_pages_setter(self, value)
+
+ def _send_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
+ """Send HTTP request via persistent httpx.Client."""
+ # Pre-encode Meraki array-of-objects params; httpx mishandles them.
+ url = apply_meraki_param_encoding(url, kwargs)
+ response = self._client.request(method, url, follow_redirects=False, **kwargs)
+ return response
+
+ def _sleep(self, seconds: float) -> None:
+ """Blocking sleep for retry delays."""
+ time.sleep(seconds)
+
+ def _transport_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
+ """No-op: httpx config handled at client initialization level."""
+ return kwargs
+
+ # ------------------------------------------------------------------
+ # Smart flow resolver
+ # ------------------------------------------------------------------
+
+ def _acquire_global_bucket(self) -> None:
+ """Gate internal hydration/resolution traffic on the global bucket.
+
+ These internal GETs bypass self.request() to avoid resolver reentrancy,
+ but must still account against the global (source IP) bucket they help
+ populate. Defensive: no-op if smart flow or the bucket is unavailable.
+ """
+ if not self._smart_flow:
+ return
+ bucket = getattr(self._smart_flow, "_global_bucket", None)
+ if bucket is not None:
+ bucket.acquire()
+
+ def _resolve_org_for_limiter(self, id_type: str, identifier: str) -> Optional[str]:
+ """Resolve a network/device ID to its org by calling the API directly."""
+ if id_type == "network":
+ endpoint = f"{self._base_url}/networks/{identifier}"
+ else:
+ endpoint = f"{self._base_url}/devices/{identifier}"
+ try:
+ self._acquire_global_bucket()
+ response = self._client.request("GET", endpoint, follow_redirects=True)
+ if response.status_code == 200:
+ data = response.json()
+ return data.get("organizationId")
+ except Exception:
+ pass
+ return None
+
+ def _hydrate_org_for_limiter(self, org_id: str) -> None:
+ """Fetch all networks and devices for an org and register them with the limiter."""
+ networks = self._fetch_all_pages(f"{self._base_url}/organizations/{org_id}/networks?perPage=1000")
+ for net in networks:
+ if "id" in net:
+ self._smart_flow.register_network(net["id"], org_id)
+
+ devices = self._fetch_all_pages(f"{self._base_url}/organizations/{org_id}/inventoryDevices?perPage=1000")
+ for dev in devices:
+ if "serial" in dev:
+ self._smart_flow.register_device(dev["serial"], org_id)
+
+ def _fetch_all_pages(self, url: str) -> list:
+ """Paginate through a Meraki list endpoint using Link headers."""
+ results = []
+ while url:
+ self._acquire_global_bucket()
+ response = self._client.request("GET", url, follow_redirects=True)
+ if response.status_code != 200:
+ break
+ page = response.json()
+ if isinstance(page, list):
+ results.extend(page)
+ next_link = response.links.get("next", {}).get("url")
+ url = next_link if next_link else None
+ return results
+
+ # ------------------------------------------------------------------
+ # Convenience HTTP methods
+ # ------------------------------------------------------------------
+
+ def get(self, metadata, url, params=None):
+ metadata["method"] = "GET"
+ metadata["url"] = url
+ metadata["params"] = params
+ response = self.request(metadata, "GET", url, params=params)
+ ret = None
+ if response:
+ if response.content.strip():
+ ret = response.json()
+ response.close()
+ return ret
+
+ def post(self, metadata, url, json=None):
+ metadata["method"] = "POST"
+ metadata["url"] = url
+ metadata["json"] = json
+ response = self.request(metadata, "POST", url, json=json)
+ ret = None
+ if response:
+ if response.content.strip():
+ ret = response.json()
+ response.close()
+ return ret
+
+ def put(self, metadata, url, json=None):
+ metadata["method"] = "PUT"
+ metadata["url"] = url
+ metadata["json"] = json
+ response = self.request(metadata, "PUT", url, json=json)
+ ret = None
+ if response:
+ if response.content.strip():
+ ret = response.json()
+ response.close()
+ return ret
+
+ def patch(self, metadata, url, json=None):
+ metadata["method"] = "PATCH"
+ metadata["url"] = url
+ metadata["json"] = json
+ response = self.request(metadata, "PATCH", url, json=json)
+ ret = None
+ if response:
+ if response.content.strip():
+ ret = response.json()
+ response.close()
+ return ret
+
+ def delete(self, metadata, url, params=None):
+ metadata["method"] = "DELETE"
+ metadata["url"] = url
+ metadata["params"] = params
+ response = self.request(metadata, "DELETE", url, params=params)
+ if response:
+ response.close()
+ return None
+
+ # ------------------------------------------------------------------
+ # Pagination
+ # ------------------------------------------------------------------
+
+ def get_pages(self, metadata, url, params=None, total_pages=-1, direction="next", event_log_end_time=None):
+ """Dispatch to iterator or legacy pagination based on config."""
+ if self._use_iterator_for_get_pages:
+ return self._get_pages_iterator(metadata, url, params, total_pages, direction, event_log_end_time)
+ return self._get_pages_legacy(metadata, url, params, total_pages, direction, event_log_end_time)
+
+ def _get_pages_iterator(
+ self,
+ metadata,
+ url,
+ params=None,
+ total_pages=-1,
+ direction="next",
+ event_log_end_time=None,
+ ):
+ if isinstance(total_pages, str) and total_pages.lower() == "all":
+ total_pages = -1
+ elif isinstance(total_pages, str) and total_pages.isnumeric():
+ total_pages = int(total_pages)
+ elif not isinstance(total_pages, int):
+ raise SessionInputError(
+ "total_pages",
+ total_pages,
+ "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).",
+ None,
+ )
+ metadata["page"] = 1
+
+ response = self.request(metadata, "GET", url, params=params)
+
+ # Get additional pages if more than one requested
+ while total_pages != 0:
+ # Guard against 204 No Content pages (empty body -> stop cleanly),
+ # matching the legacy paginator's behavior.
+ if response.status_code == 204:
+ response.close()
+ return
+ results = response.json()
+ links = response.links
+
+ # GET the subsequent page
+ if direction == "next" and "next" in links:
+ # Prevent getNetworkEvents from infinite loop as time goes forward
+ if metadata["operation"] == "getNetworkEvents":
+ starting_after = urllib.parse.unquote(str(links["next"]["url"]).split("startingAfter=")[1])
+ delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after)
+ # Break out of loop if startingAfter returned from next link is within 5 minutes of current time
+ if delta.total_seconds() < 300:
+ break
+ # Or if the next page is past the specified window's end time
+ elif event_log_end_time and starting_after > event_log_end_time:
+ break
+
+ metadata["page"] += 1
+ nextlink = links["next"]["url"]
+ elif direction == "prev" and "prev" in links:
+ # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0)
+ if metadata["operation"] == "getNetworkEvents":
+ ending_before = urllib.parse.unquote(str(links["prev"]["url"]).split("endingBefore=")[1])
+ # Break out of loop if endingBefore returned from prev link is before 2014
+ if ending_before < "2014-01-01":
+ break
+
+ metadata["page"] += 1
+ nextlink = links["prev"]["url"]
+ else:
+ total_pages = 1
+
+ response.close()
+
+ return_items = []
+ # Just prepare the list
+ if isinstance(results, list):
+ return_items = results
+ elif isinstance(results, dict) and "items" in results:
+ return_items = results["items"]
+ # For event log endpoint
+ elif isinstance(results, dict):
+ if direction == "next":
+ return_items = results["events"][::-1]
+ else:
+ return_items = results["events"]
+
+ for item in return_items:
+ yield item
+
+ total_pages = total_pages - 1
+
+ if total_pages != 0:
+ response = self.request(metadata, "GET", nextlink)
+
+ def _get_pages_legacy(
+ self,
+ metadata,
+ url,
+ params=None,
+ total_pages=-1,
+ direction="next",
+ event_log_end_time=None,
+ ):
+ if isinstance(total_pages, str) and total_pages.lower() == "all":
+ total_pages = -1
+ elif isinstance(total_pages, str) and total_pages.isnumeric():
+ total_pages = int(total_pages)
+ elif not isinstance(total_pages, int):
+ raise SessionInputError(
+ "total_pages",
+ total_pages,
+ "total_pages must be either an integer or 'all' as a string (remember to add the quotation marks).",
+ None,
+ )
+
+ metadata["page"] = 1
+
+ response = self.request(metadata, "GET", url, params=params)
+
+ # Handle GETs that produce 204 No Content responses
+ if response.status_code == 204:
+ results = None
+ else:
+ results = response.json()
+
+ # For event log endpoint when using 'next' direction, so results/events are sorted chronologically
+ if isinstance(results, dict) and metadata["operation"] == "getNetworkEvents" and direction == "next":
+ results["events"] = results["events"][::-1]
+
+ # Get additional pages if more than one requested
+ while total_pages != 1:
+ links = response.links
+ response.close()
+ response = None
+
+ # GET the subsequent page
+ if direction == "next" and "next" in links:
+ # Prevent getNetworkEvents from infinite loop as time goes forward
+ if metadata["operation"] == "getNetworkEvents":
+ starting_after = urllib.parse.unquote(links["next"]["url"].split("startingAfter=")[1])
+ delta = datetime.now(timezone.utc) - datetime.fromisoformat(starting_after)
+ # Break out of loop if startingAfter returned from next link is within 5 minutes of current time
+ if delta.total_seconds() < 300:
+ break
+ # Or if next page is past the specified window's end time
+ elif event_log_end_time and starting_after > event_log_end_time:
+ break
+
+ metadata["page"] += 1
+ response = self.request(metadata, "GET", links["next"]["url"])
+ elif direction == "prev" and "prev" in links:
+ # Prevent getNetworkEvents from infinite loop as time goes backward (to epoch 0)
+ if metadata["operation"] == "getNetworkEvents":
+ ending_before = urllib.parse.unquote(links["prev"]["url"].split("endingBefore=")[1])
+ # Break out of loop if endingBefore returned from prev link is before 2014
+ if ending_before < "2014-01-01":
+ break
+
+ metadata["page"] += 1
+ response = self.request(metadata, "GET", links["prev"]["url"])
+ else:
+ break
+
+ # Append that page's results, depending on the endpoint
+ if isinstance(results, list):
+ results.extend(response.json())
+ elif isinstance(results, dict) and "items" in results:
+ results["items"].extend(response.json()["items"])
+ if "meta" in results:
+ results["meta"]["counts"]["items"]["remaining"] = response.json()["meta"]["counts"]["items"]["remaining"]
+ # For event log endpoint
+ elif isinstance(results, dict):
+ try:
+ start = response.json()["pageStartAt"]
+ except KeyError:
+ if self._logger:
+ self._logger.warning(f"pageStartAt missing from response: {response.headers}")
+ start = results["pageStartAt"] # fallback: keep existing value
+ end = response.json()["pageEndAt"]
+ events = response.json()["events"]
+ if direction == "next":
+ events = events[::-1]
+ if start < results["pageStartAt"]:
+ results["pageStartAt"] = start
+ if end > results["pageEndAt"]:
+ results["pageEndAt"] = end
+ results["events"].extend(events)
+
+ total_pages -= 1
+
+ if response:
+ response.close()
+
+ return results
diff --git a/meraki/smart_flow.py b/meraki/smart_flow.py
new file mode 100644
index 00000000..56ad5f13
--- /dev/null
+++ b/meraki/smart_flow.py
@@ -0,0 +1,754 @@
+"""Per-org token bucket rate limiter for Meraki Dashboard API (smart flow).
+
+The Meraki API enforces rate limits per organization (default 10 req/s). This module
+provides proactive rate limiting that prevents 429 errors before they happen by
+tracking request rates per org and throttling when approaching the limit.
+
+Key concepts:
+- URL patterns are parsed to extract org/network/device identifiers
+- A lazy cache maps network IDs and device serials to their parent org ID
+- Each org gets its own token bucket, refilling at the configured rate
+- Unknown identifiers route through a conservative shared bucket until resolved
+"""
+
+from __future__ import annotations
+
+import asyncio
+import re
+import threading
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Callable, Coroutine, Dict, Optional, Set
+
+import json
+
+
+# URL patterns for extracting resource identifiers
+_ORG_PATTERN = re.compile(r"/organizations/([^/]+)")
+_NETWORK_PATTERN = re.compile(r"/networks/([^/]+)")
+_DEVICE_PATTERN = re.compile(r"/devices/([^/]+)")
+
+
+def _parse_saved_at(value: Any) -> Optional[float]:
+ """Parse ISO 8601Z saved_at timestamp to epoch seconds."""
+ if not isinstance(value, str):
+ return None
+ try:
+ dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
+ return dt.timestamp()
+ except ValueError:
+ return None
+
+
+class TokenBucket:
+ """Thread-safe token bucket for synchronous rate limiting."""
+
+ def __init__(self, rate: float, capacity: int):
+ self._rate = rate
+ self._capacity = capacity
+ self._tokens = float(capacity)
+ self._last = time.monotonic()
+ self._lock = threading.Lock()
+
+ @property
+ def rate(self) -> float:
+ return self._rate
+
+ @rate.setter
+ def rate(self, value: float) -> None:
+ self._rate = max(0.5, value)
+
+ def acquire(self) -> None:
+ # Reserve the token (read-modify-write) under the lock, then sleep
+ # outside it so concurrent callers don't serialize behind one sleeper.
+ with self._lock:
+ now = time.monotonic()
+ # Refill, then deduct this request unconditionally. Tokens may go
+ # negative: that deficit IS the reservation, so concurrent callers
+ # each compute their own wait against the accumulated deficit rather
+ # than all colliding on the same instant.
+ elapsed = now - self._last
+ self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
+ self._last = now
+ self._tokens -= 1.0
+
+ wait = -self._tokens / self._rate if self._tokens < 0 else 0.0
+
+ if wait > 0.0:
+ time.sleep(wait)
+
+
+class AsyncTokenBucket:
+ """Async token bucket for asynchronous rate limiting."""
+
+ def __init__(self, rate: float, capacity: int):
+ self._rate = rate
+ self._capacity = capacity
+ self._tokens = float(capacity)
+ self._last: Optional[float] = None
+ self._lock = asyncio.Lock()
+
+ @property
+ def rate(self) -> float:
+ return self._rate
+
+ @rate.setter
+ def rate(self, value: float) -> None:
+ self._rate = max(0.5, value)
+
+ async def acquire(self) -> None:
+ # Reserve the token under the lock, then await the sleep OUTSIDE the
+ # lock so concurrent coroutines on the same bucket aren't serialized
+ # behind a single sleeper (preserving burst/parallelism).
+ loop = asyncio.get_event_loop()
+ async with self._lock:
+ now = loop.time()
+ if self._last is None:
+ self._last = now
+
+ # Refill, then deduct this request unconditionally. Tokens may go
+ # negative: that deficit IS the reservation, so concurrent callers
+ # each compute their own wait against the accumulated deficit rather
+ # than all colliding on the same instant.
+ elapsed = now - self._last
+ self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
+ self._last = now
+ self._tokens -= 1.0
+
+ wait = -self._tokens / self._rate if self._tokens < 0 else 0.0
+
+ if wait > 0.0:
+ await asyncio.sleep(wait)
+
+
+class OrgRateLimiter:
+ """Per-org rate limiter with URL-based routing and org resolution cache.
+
+ Maintains a token bucket per organization and a shared "unknown" bucket for
+ requests whose org cannot yet be determined. The cache maps network IDs and
+ device serials to org IDs, populated eagerly at init or lazily from responses.
+ """
+
+ def __init__(
+ self,
+ rate: float = 10.0,
+ capacity: int = 10,
+ global_rate: float = 100.0,
+ cache_path: Optional[str] = None,
+ cache_ttl: Optional[float] = 604800.0,
+ logger: Any = None,
+ ):
+ self._rate = rate
+ self._capacity = capacity
+ self._global_rate = global_rate
+ self._logger = logger
+ self._cache_path = Path(cache_path) if cache_path else None
+ self._cache_ttl = cache_ttl
+
+ # org_id -> bucket
+ self._org_buckets: Dict[str, TokenBucket] = {}
+ # network_id -> org_id, serial -> org_id
+ self._network_to_org: Dict[str, str] = {}
+ self._serial_to_org: Dict[str, str] = {}
+ # Global bucket: source IP limit shared by all requests
+ self._global_bucket = TokenBucket(rate=global_rate, capacity=int(global_rate))
+
+ self._cache_fresh = False
+ self._dirty = 0
+ self._pending_lookups: Set[str] = set()
+ self._hydrated_orgs: Set[str] = set()
+ self._resolver: Optional[Callable[[str, str], Optional[str]]] = None
+ self._hydrator: Optional[Callable[[str], None]] = None
+ self._load_cache()
+
+ def set_resolver(self, resolver: Callable[[str, str], Optional[str]]) -> None:
+ """Set a callback to resolve unknown network/device IDs to org IDs.
+
+ The callback receives (id_type, identifier) where id_type is "network" or "device",
+ and should return the org_id or None.
+ """
+ self._resolver = resolver
+
+ def set_hydrator(self, hydrator: Callable[[str], None]) -> None:
+ """Set a callback to bulk-populate all networks/devices for an org.
+
+ Called once per org after first resolution. The callback should call
+ register_network/register_device for each mapping discovered.
+ """
+ self._hydrator = hydrator
+
+ @property
+ def cache_fresh(self) -> bool:
+ return self._cache_fresh
+
+ def _log(self, msg: str) -> None:
+ if self._logger:
+ self._logger.debug(f"smart_flow, {msg}")
+
+ def _maybe_flush(self) -> None:
+ if self._dirty >= 50:
+ self.save_cache()
+ self._dirty = 0
+
+ def _get_or_create_bucket(self, org_id: str) -> TokenBucket:
+ if org_id not in self._org_buckets:
+ self._org_buckets[org_id] = TokenBucket(self._rate, self._capacity)
+ self._log(f"new bucket for org {org_id} at {self._rate} req/s")
+ return self._org_buckets[org_id]
+
+ def resolve_org(self, url: str) -> Optional[str]:
+ """Extract org ID from URL, using cache for network/device lookups."""
+ m = _ORG_PATTERN.search(url)
+ if m:
+ return m.group(1)
+
+ m = _NETWORK_PATTERN.search(url)
+ if m:
+ return self._network_to_org.get(m.group(1))
+
+ m = _DEVICE_PATTERN.search(url)
+ if m:
+ return self._serial_to_org.get(m.group(1))
+
+ return None
+
+ def acquire(self, url: str) -> None:
+ """Block until tokens are available from both global and per-org buckets."""
+ self._global_bucket.acquire()
+
+ org_id = self.resolve_org(url)
+ if org_id:
+ self._get_or_create_bucket(org_id).acquire()
+ else:
+ self._resolve_inline(url)
+ org_id = self.resolve_org(url)
+ if org_id:
+ self._get_or_create_bucket(org_id).acquire()
+
+ def _resolve_inline(self, url: str) -> None:
+ """Attempt a synchronous lookup for an unresolved network/device ID."""
+ if not self._resolver:
+ return
+
+ m = _NETWORK_PATTERN.search(url)
+ if m:
+ identifier, id_type = m.group(1), "network"
+ else:
+ m = _DEVICE_PATTERN.search(url)
+ if m:
+ identifier, id_type = m.group(1), "device"
+ else:
+ return
+
+ if identifier in self._pending_lookups:
+ return
+ self._pending_lookups.add(identifier)
+ try:
+ org_id = self._resolver(id_type, identifier)
+ if org_id:
+ if id_type == "network":
+ self._network_to_org[identifier] = org_id
+ else:
+ self._serial_to_org[identifier] = org_id
+ self._get_or_create_bucket(org_id)
+ self._dirty += 1
+ self._log(f"resolved {id_type} {identifier} -> org {org_id}")
+ if self._hydrator and org_id not in self._hydrated_orgs:
+ self._hydrated_orgs.add(org_id)
+ self._log(f"hydrating org {org_id}")
+ self._hydrator(org_id)
+ self._log(
+ f"hydrated org {org_id} "
+ f"({len(self._network_to_org)} networks, {len(self._serial_to_org)} devices total)"
+ )
+ self._maybe_flush()
+ except Exception:
+ pass
+ finally:
+ self._pending_lookups.discard(identifier)
+
+ def on_rate_limited(self, url: str) -> None:
+ """Tighten the appropriate bucket (multiplicative decrease)."""
+ org_id = self.resolve_org(url)
+ if org_id and org_id in self._org_buckets:
+ bucket = self._org_buckets[org_id]
+ bucket.rate = bucket.rate * 0.7
+ self._log(f"rate limited org {org_id}, decreased to {bucket.rate:.1f} req/s")
+ elif self._is_unresolved_scoped_url(url):
+ # URL targets a specific network/device whose org isn't resolved
+ # yet. Penalizing the global bucket would punish every other org for
+ # one org's 429, so skip and let background resolution catch up.
+ self._log("rate limited on unresolved network/device url, skipping global penalty")
+ else:
+ self._global_bucket.rate = self._global_bucket.rate * 0.7
+ self._log(f"rate limited (global), decreased to {self._global_bucket.rate:.1f} req/s")
+
+ @staticmethod
+ def _is_unresolved_scoped_url(url: str) -> bool:
+ """True if the URL has a network/device component but no explicit org.
+
+ These are the URLs whose org we can't yet attribute the 429 to; the
+ offending org is specific (just unknown), so the global bucket must not
+ be punished on its behalf. An explicit /organizations/ URL is NOT
+ considered unresolved (it names its org directly).
+ """
+ if OrgRateLimiter._org_id_from_url(url):
+ return False
+ return bool(OrgRateLimiter._network_id_from_url(url) or OrgRateLimiter._serial_from_url(url))
+
+ def on_success(self, url: str) -> None:
+ """Slowly widen buckets back toward configured rates (additive increase)."""
+ org_id = self.resolve_org(url)
+ if org_id and org_id in self._org_buckets:
+ bucket = self._org_buckets[org_id]
+ if bucket.rate < self._rate:
+ bucket.rate = min(self._rate, bucket.rate + 0.2)
+ if self._global_bucket.rate < self._global_rate:
+ self._global_bucket.rate = min(self._global_rate, self._global_bucket.rate + 0.5)
+
+ def register_org(self, org_id: str) -> None:
+ """Ensure a bucket exists for this org."""
+ self._get_or_create_bucket(org_id)
+
+ def register_network(self, network_id: str, org_id: str) -> None:
+ """Cache a network -> org mapping."""
+ self._network_to_org[network_id] = org_id
+
+ def register_device(self, serial: str, org_id: str) -> None:
+ """Cache a serial -> org mapping."""
+ self._serial_to_org[serial] = org_id
+
+ def learn_from_response(self, url: str, body: Any) -> None:
+ """Extract org/network/device mappings from a URL and response body."""
+ org_id = self._org_id_from_url(url)
+ if not org_id:
+ org_id = self._org_id_from_body(body)
+ if not org_id:
+ return
+
+ self._get_or_create_bucket(org_id)
+ changed_networks = 0
+ changed_devices = 0
+
+ network_id = self._network_id_from_url(url)
+ if network_id and self._network_to_org.get(network_id) != org_id:
+ self._network_to_org[network_id] = org_id
+ changed_networks += 1
+
+ serial = self._serial_from_url(url)
+ if serial and self._serial_to_org.get(serial) != org_id:
+ self._serial_to_org[serial] = org_id
+ changed_devices += 1
+
+ if isinstance(body, dict):
+ n, d = self._learn_from_body(body, org_id)
+ changed_networks += n
+ changed_devices += d
+
+ total = changed_networks + changed_devices
+ if total:
+ self._dirty += total
+ if self._logger:
+ self._log(
+ f"learned {total} new mapping{'s' if total != 1 else ''} "
+ f"({changed_networks} network{'s' if changed_networks != 1 else ''}, "
+ f"{changed_devices} device{'s' if changed_devices != 1 else ''}) "
+ f"from {url}"
+ )
+ self._maybe_flush()
+
+ def _learn_from_body(self, body: dict, org_id: str) -> tuple:
+ """Returns (changed_networks, changed_devices) counts."""
+ changed_networks = 0
+ changed_devices = 0
+ if "networkId" in body and self._network_to_org.get(body["networkId"]) != org_id:
+ self._network_to_org[body["networkId"]] = org_id
+ changed_networks += 1
+ if "serial" in body and self._serial_to_org.get(body["serial"]) != org_id:
+ self._serial_to_org[body["serial"]] = org_id
+ changed_devices += 1
+ net = body.get("network")
+ if isinstance(net, dict) and "id" in net:
+ if self._network_to_org.get(net["id"]) != org_id:
+ self._network_to_org[net["id"]] = org_id
+ changed_networks += 1
+ return changed_networks, changed_devices
+
+ @staticmethod
+ def _org_id_from_url(url: str) -> Optional[str]:
+ m = _ORG_PATTERN.search(url)
+ return m.group(1) if m else None
+
+ @staticmethod
+ def _network_id_from_url(url: str) -> Optional[str]:
+ m = _NETWORK_PATTERN.search(url)
+ return m.group(1) if m else None
+
+ @staticmethod
+ def _serial_from_url(url: str) -> Optional[str]:
+ m = _DEVICE_PATTERN.search(url)
+ return m.group(1) if m else None
+
+ @staticmethod
+ def _org_id_from_body(body: Any) -> Optional[str]:
+ if not isinstance(body, dict):
+ return None
+ if "organizationId" in body:
+ return body["organizationId"]
+ org = body.get("organization")
+ if isinstance(org, dict) and "id" in org:
+ return org["id"]
+ return None
+
+ def save_cache(self) -> None:
+ """Persist the mapping cache to disk with a timestamp."""
+ if not self._cache_path:
+ return
+ self._cache_path.parent.mkdir(parents=True, exist_ok=True)
+ data = {
+ "saved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "networks": [{"id": net_id, "organization": {"id": org_id}} for net_id, org_id in self._network_to_org.items()],
+ "devices": [{"serial": serial, "organization": {"id": org_id}} for serial, org_id in self._serial_to_org.items()],
+ }
+ self._cache_path.write_text(json.dumps(data), encoding="utf-8")
+ n = len(self._network_to_org) + len(self._serial_to_org)
+ self._log(f"saved cache ({n} mappings) to {self._cache_path}")
+
+ def _load_cache(self) -> None:
+ """Load mapping cache from disk if it exists and hasn't expired."""
+ if not self._cache_path or not self._cache_path.exists():
+ return
+ try:
+ data = json.loads(self._cache_path.read_text(encoding="utf-8"))
+ if self._cache_ttl is not None:
+ saved_at = data.get("saved_at")
+ if saved_at is None:
+ self._log("cache expired, will rebuild")
+ return
+ saved_ts = _parse_saved_at(saved_at)
+ if saved_ts is None or (time.time() - saved_ts) > self._cache_ttl:
+ self._log("cache expired, will rebuild")
+ return
+ for net in data.get("networks", []):
+ self._network_to_org[net["id"]] = net["organization"]["id"]
+ for dev in data.get("devices", []):
+ self._serial_to_org[dev["serial"]] = dev["organization"]["id"]
+ self._cache_fresh = True
+ n = len(self._network_to_org) + len(self._serial_to_org)
+ self._log(f"loaded cache ({n} mappings) from {self._cache_path}")
+ except (json.JSONDecodeError, OSError, KeyError):
+ pass
+
+
+class AsyncOrgRateLimiter:
+ """Async per-org rate limiter with URL-based routing and org resolution cache.
+
+ Same strategy as OrgRateLimiter but uses AsyncTokenBucket for non-blocking waits.
+ """
+
+ def __init__(
+ self,
+ rate: float = 10.0,
+ capacity: int = 10,
+ global_rate: float = 100.0,
+ cache_path: Optional[str] = None,
+ cache_ttl: Optional[float] = 604800.0,
+ logger: Any = None,
+ ):
+ self._rate = rate
+ self._capacity = capacity
+ self._global_rate = global_rate
+ self._logger = logger
+ self._cache_path = Path(cache_path) if cache_path else None
+ self._cache_ttl = cache_ttl
+
+ self._org_buckets: Dict[str, AsyncTokenBucket] = {}
+ self._network_to_org: Dict[str, str] = {}
+ self._serial_to_org: Dict[str, str] = {}
+ self._global_bucket = AsyncTokenBucket(rate=global_rate, capacity=int(global_rate))
+
+ self._cache_fresh = False
+ self._dirty = 0
+ self._flush_task: Optional[asyncio.Task] = None
+ # Hold strong refs to in-flight background tasks; the event loop only
+ # keeps weak refs, so without this they can be GC'd mid-flight.
+ self._bg_tasks: Set[asyncio.Task] = set()
+ self._pending_lookups: Set[str] = set()
+ self._hydrated_orgs: Set[str] = set()
+ self._resolver: Optional[Callable[[str, str], Coroutine[Any, Any, Optional[str]]]] = None
+ self._hydrator: Optional[Callable[[str], Coroutine[Any, Any, None]]] = None
+ self._load_cache()
+
+ def set_resolver(self, resolver: Callable[[str, str], Coroutine[Any, Any, Optional[str]]]) -> None:
+ """Set a callback to resolve unknown network/device IDs to org IDs.
+
+ The callback receives (id_type, identifier) where id_type is "network" or "device",
+ and should return the org_id or None.
+ """
+ self._resolver = resolver
+
+ def set_hydrator(self, hydrator: Callable[[str], Coroutine[Any, Any, None]]) -> None:
+ """Set a callback to bulk-populate all networks/devices for an org.
+
+ Called once per org after first resolution. The callback should call
+ register_network/register_device for each mapping discovered.
+ """
+ self._hydrator = hydrator
+
+ @property
+ def cache_fresh(self) -> bool:
+ return self._cache_fresh
+
+ def _log(self, msg: str) -> None:
+ if self._logger:
+ self._logger.debug(f"smart_flow, {msg}")
+
+ def _maybe_flush(self) -> None:
+ if self._dirty >= 50 and (self._flush_task is None or self._flush_task.done()):
+ pending = self._dirty
+ self._flush_task = asyncio.ensure_future(self.save_cache())
+ self._flush_task.add_done_callback(lambda t: self._on_flush_done(t, pending))
+
+ def _on_flush_done(self, task: asyncio.Task, pending: int) -> None:
+ # Only zero the dirty counter if the save actually succeeded; otherwise
+ # the unsaved mappings would be silently lost.
+ exc = task.exception()
+ if exc is not None:
+ self._log(f"cache flush failed, retaining {pending} dirty mappings: {exc!r}")
+ return
+ # Subtract what we flushed rather than hard-zeroing, in case more
+ # mappings were learned while the save was in flight.
+ self._dirty = max(0, self._dirty - pending)
+
+ def _get_or_create_bucket(self, org_id: str) -> AsyncTokenBucket:
+ if org_id not in self._org_buckets:
+ self._org_buckets[org_id] = AsyncTokenBucket(self._rate, self._capacity)
+ self._log(f"new bucket for org {org_id} at {self._rate} req/s")
+ return self._org_buckets[org_id]
+
+ def resolve_org(self, url: str) -> Optional[str]:
+ """Extract org ID from URL, using cache for network/device lookups."""
+ m = _ORG_PATTERN.search(url)
+ if m:
+ return m.group(1)
+
+ m = _NETWORK_PATTERN.search(url)
+ if m:
+ return self._network_to_org.get(m.group(1))
+
+ m = _DEVICE_PATTERN.search(url)
+ if m:
+ return self._serial_to_org.get(m.group(1))
+
+ return None
+
+ async def acquire(self, url: str) -> None:
+ """Await until tokens from both global and per-org buckets are available."""
+ await self._global_bucket.acquire()
+
+ org_id = self.resolve_org(url)
+ if org_id:
+ await self._get_or_create_bucket(org_id).acquire()
+ else:
+ self._trigger_background_resolve(url)
+
+ def _trigger_background_resolve(self, url: str) -> None:
+ """Fire a one-shot background lookup for an unresolved network/device ID."""
+ if not self._resolver:
+ return
+
+ m = _NETWORK_PATTERN.search(url)
+ if m:
+ identifier, id_type = m.group(1), "network"
+ else:
+ m = _DEVICE_PATTERN.search(url)
+ if m:
+ identifier, id_type = m.group(1), "device"
+ else:
+ return
+
+ if identifier in self._pending_lookups:
+ return
+ self._pending_lookups.add(identifier)
+ t = asyncio.ensure_future(self._resolve_and_cache(id_type, identifier))
+ self._bg_tasks.add(t)
+ t.add_done_callback(self._bg_tasks.discard)
+
+ async def _resolve_and_cache(self, id_type: str, identifier: str) -> None:
+ """Background task: call resolver, cache result, then hydrate the full org."""
+ try:
+ org_id = await self._resolver(id_type, identifier)
+ if not org_id:
+ return
+ if id_type == "network":
+ self._network_to_org[identifier] = org_id
+ else:
+ self._serial_to_org[identifier] = org_id
+ self._get_or_create_bucket(org_id)
+ self._dirty += 1
+ self._log(f"resolved {id_type} {identifier} -> org {org_id}")
+ if self._hydrator and org_id not in self._hydrated_orgs:
+ self._hydrated_orgs.add(org_id)
+ self._log(f"hydrating org {org_id}")
+ await self._hydrator(org_id)
+ self._log(
+ f"hydrated org {org_id} ({len(self._network_to_org)} networks, {len(self._serial_to_org)} devices total)"
+ )
+ self._maybe_flush()
+ except Exception as e:
+ self._log(f"background resolve of {id_type} {identifier} failed: {e!r}")
+ finally:
+ self._pending_lookups.discard(identifier)
+
+ def on_rate_limited(self, url: str) -> None:
+ """Tighten the appropriate bucket (multiplicative decrease)."""
+ org_id = self.resolve_org(url)
+ if org_id and org_id in self._org_buckets:
+ bucket = self._org_buckets[org_id]
+ bucket.rate = bucket.rate * 0.7
+ self._log(f"rate limited org {org_id}, decreased to {bucket.rate:.1f} req/s")
+ elif OrgRateLimiter._is_unresolved_scoped_url(url):
+ # URL targets a specific network/device whose org isn't resolved
+ # yet. Penalizing the global bucket would punish every other org for
+ # one org's 429, so skip and let background resolution catch up.
+ self._log("rate limited on unresolved network/device url, skipping global penalty")
+ else:
+ self._global_bucket.rate = self._global_bucket.rate * 0.7
+ self._log(f"rate limited (global), decreased to {self._global_bucket.rate:.1f} req/s")
+
+ def on_success(self, url: str) -> None:
+ """Slowly widen buckets back toward configured rates (additive increase)."""
+ org_id = self.resolve_org(url)
+ if org_id and org_id in self._org_buckets:
+ bucket = self._org_buckets[org_id]
+ if bucket.rate < self._rate:
+ bucket.rate = min(self._rate, bucket.rate + 0.2)
+ if self._global_bucket.rate < self._global_rate:
+ self._global_bucket.rate = min(self._global_rate, self._global_bucket.rate + 0.5)
+
+ async def shutdown(self) -> None:
+ """Gracefully drain background work and persist the cache.
+
+ Awaits/cancels all in-flight resolve tasks, awaits any pending flush,
+ then does a final save. Idempotent and safe to call when there is no
+ outstanding work (e.g. from an __aexit__ handler).
+ """
+ if self._bg_tasks:
+ await asyncio.gather(*list(self._bg_tasks), return_exceptions=True)
+ if self._flush_task is not None and not self._flush_task.done():
+ try:
+ await self._flush_task
+ except Exception as e:
+ self._log(f"flush task errored during shutdown: {e!r}")
+ await self.save_cache()
+
+ def register_org(self, org_id: str) -> None:
+ """Ensure a bucket exists for this org."""
+ self._get_or_create_bucket(org_id)
+
+ def register_network(self, network_id: str, org_id: str) -> None:
+ """Cache a network -> org mapping."""
+ self._network_to_org[network_id] = org_id
+
+ def register_device(self, serial: str, org_id: str) -> None:
+ """Cache a serial -> org mapping."""
+ self._serial_to_org[serial] = org_id
+
+ def learn_from_response(self, url: str, body: Any) -> None:
+ """Extract org/network/device mappings from a URL and response body."""
+ org_id = OrgRateLimiter._org_id_from_url(url)
+ if not org_id:
+ org_id = OrgRateLimiter._org_id_from_body(body)
+ if not org_id:
+ return
+
+ self._get_or_create_bucket(org_id)
+ changed_networks = 0
+ changed_devices = 0
+
+ network_id = OrgRateLimiter._network_id_from_url(url)
+ if network_id and self._network_to_org.get(network_id) != org_id:
+ self._network_to_org[network_id] = org_id
+ changed_networks += 1
+
+ serial = OrgRateLimiter._serial_from_url(url)
+ if serial and self._serial_to_org.get(serial) != org_id:
+ self._serial_to_org[serial] = org_id
+ changed_devices += 1
+
+ if isinstance(body, dict):
+ if "networkId" in body and self._network_to_org.get(body["networkId"]) != org_id:
+ self._network_to_org[body["networkId"]] = org_id
+ changed_networks += 1
+ if "serial" in body and self._serial_to_org.get(body["serial"]) != org_id:
+ self._serial_to_org[body["serial"]] = org_id
+ changed_devices += 1
+ net = body.get("network")
+ if isinstance(net, dict) and "id" in net:
+ if self._network_to_org.get(net["id"]) != org_id:
+ self._network_to_org[net["id"]] = org_id
+ changed_networks += 1
+
+ total = changed_networks + changed_devices
+ if total:
+ self._dirty += total
+ if self._logger:
+ self._log(
+ f"learned {total} new mapping{'s' if total != 1 else ''} "
+ f"({changed_networks} network{'s' if changed_networks != 1 else ''}, "
+ f"{changed_devices} device{'s' if changed_devices != 1 else ''}) "
+ f"from {url}"
+ )
+ self._maybe_flush()
+
+ async def save_cache(self) -> None:
+ """Persist the mapping cache to disk in a background thread."""
+ if not self._cache_path:
+ return
+ path = self._cache_path
+ data = json.dumps(
+ {
+ "saved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "networks": [
+ {"id": net_id, "organization": {"id": org_id}} for net_id, org_id in self._network_to_org.items()
+ ],
+ "devices": [
+ {"serial": serial, "organization": {"id": org_id}} for serial, org_id in self._serial_to_org.items()
+ ],
+ }
+ )
+ loop = asyncio.get_event_loop()
+ await loop.run_in_executor(None, self._write_cache, path, data)
+ n = len(self._network_to_org) + len(self._serial_to_org)
+ self._log(f"saved cache ({n} mappings) to {path}")
+
+ @staticmethod
+ def _write_cache(path: Path, data: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(data, encoding="utf-8")
+
+ def _load_cache(self) -> None:
+ """Load mapping cache from disk if it exists and hasn't expired."""
+ if not self._cache_path or not self._cache_path.exists():
+ return
+ try:
+ data = json.loads(self._cache_path.read_text(encoding="utf-8"))
+ if self._cache_ttl is not None:
+ saved_at = data.get("saved_at")
+ if saved_at is None:
+ self._log("cache expired, will rebuild")
+ return
+ saved_ts = _parse_saved_at(saved_at)
+ if saved_ts is None or (time.time() - saved_ts) > self._cache_ttl:
+ self._log("cache expired, will rebuild")
+ return
+ for net in data.get("networks", []):
+ self._network_to_org[net["id"]] = net["organization"]["id"]
+ for dev in data.get("devices", []):
+ self._serial_to_org[dev["serial"]] = dev["organization"]["id"]
+ self._cache_fresh = True
+ n = len(self._network_to_org) + len(self._serial_to_org)
+ self._log(f"loaded cache ({n} mappings) from {self._cache_path}")
+ except (json.JSONDecodeError, OSError, KeyError):
+ pass
diff --git a/pyproject.toml b/pyproject.toml
index 8dddfcf2..0bc08619 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "meraki"
-version = "3.0.1"
+version = "4.3.0"
description = "Cisco Meraki Dashboard API library"
authors = [
{name = "Cisco Meraki", email = "api-feedback@meraki.net"}
@@ -14,8 +14,7 @@ classifiers = [
"Programming Language :: Python :: 3",
]
dependencies = [
- "requests>=2.33.1,<3",
- "aiohttp>=3.13.5,<4",
+ "httpx>=0.28,<1",
]
[project.urls]
@@ -37,12 +36,15 @@ dev = [
"pytest>=8.3.5,<10",
"pytest-asyncio>=1.0,<2",
"pytest-cov>=7.1.0,<8",
- "responses>=0.25,<1",
- "flake8>=7.0,<8",
+ "respx>=0.23.1,<1",
+ "pytest-benchmark>=2.0.0",
"pre-commit>=4.6.0",
"ruff>=0.15.12",
+ "pytest-json-report>=1.5.0",
+ "hypothesis>=6.122.0,<7",
+ "towncrier>=24.8.0",
]
-generator = ["jinja2==3.1.6"]
+generator = ["jinja2==3.1.6", "httpx>=0.28,<1"]
[tool.uv]
default-groups = ["dev"]
@@ -52,7 +54,7 @@ line-length = 127
[tool.pytest.ini_options]
testpaths = ["tests/unit"]
-norecursedirs = ["tests/generator"]
+norecursedirs = ["tests/generator", ".hypothesis"]
asyncio_mode = "auto"
[tool.coverage.run]
@@ -63,3 +65,46 @@ omit = ["meraki/api/*", "meraki/aio/api/*", "meraki/aio/__init__.py"]
fail_under = 90
show_missing = true
exclude_lines = ["pragma: no cover", "if __name__"]
+
+[tool.towncrier]
+directory = "changelog.d"
+filename = "CHANGELOG.md"
+name = "meraki"
+title_format = "## {version} ({project_date})"
+issue_format = "[#{issue}](https://github.com/meraki/dashboard-api-python/issues/{issue})"
+start_string = "\n"
+wrap = false
+all_bullets = true
+
+# Version is passed via `--version` from a single source of truth (pyproject
+# [project].version) at build time, so there is no version pinned here to drift.
+
+[[tool.towncrier.type]]
+directory = "added"
+name = "Added"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "changed"
+name = "Changed"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "deprecated"
+name = "Deprecated"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "removed"
+name = "Removed"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "fixed"
+name = "Fixed"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "security"
+name = "Security"
+showcontent = true
diff --git a/scripts/semantic_diff_v2_v3.py b/scripts/semantic_diff_v2_v3.py
deleted file mode 100644
index 58df4d8e..00000000
--- a/scripts/semantic_diff_v2_v3.py
+++ /dev/null
@@ -1,339 +0,0 @@
-#!/usr/bin/env python3
-"""Semantic diff between v2 and v3 generator output.
-
-Compares method signatures, parameter lists, and types.
-Ignores formatting, kwargs style, and whitespace.
-
-Usage:
- python scripts/semantic_diff_v2_v3.py [spec.json]
- python scripts/semantic_diff_v2_v3.py --live
-
-Exit codes:
- 0: Only known/expected differences (or identical)
- 1: Unexpected semantic drift detected
-"""
-
-import argparse
-import contextlib
-import json
-import os
-import re
-import shutil
-import sys
-import tempfile
-from pathlib import Path
-from unittest.mock import patch, MagicMock
-
-GENERATOR_DIR = Path(__file__).resolve().parent.parent / "generator"
-sys.path.insert(0, str(GENERATOR_DIR))
-
-
-def extract_methods(content: str) -> dict[str, dict]:
- """Extract method name -> {signature, params, body_section} from module."""
- methods = {}
- # Match method definitions (may span multiple lines after ruff formatting)
- pattern = re.compile(r"^\s{4}def (\w+)\(\s*self(.*?)\s*\):", re.MULTILINE | re.DOTALL)
- for match in pattern.finditer(content):
- name = match.group(1)
- if name == "__init__":
- continue
- # Normalize multiline signatures: collapse whitespace
- raw_sig = re.sub(r"\s+", " ", match.group(2)).strip(", ")
- # Parse param names and types from signature
- params = {}
- if raw_sig:
- for part in re.split(r",\s*", raw_sig):
- part = part.strip()
- if "=" in part:
- part = part.split("=")[0].strip()
- if ":" in part:
- pname, ptype = part.split(":", 1)
- params[pname.strip()] = ptype.strip()
- elif part == "**kwargs":
- params["**kwargs"] = "**kwargs"
- else:
- params[part] = "untyped"
- methods[name] = {"signature": raw_sig, "params": params}
- return methods
-
-
-def extract_method_bodies(content: str) -> dict[str, str]:
- """Extract method name -> full body text (everything until the next method or EOF)."""
- bodies = {}
- pattern = re.compile(r"^ def (\w+)\(.*?\):\n", re.MULTILINE | re.DOTALL)
- matches = list(pattern.finditer(content))
- for i, match in enumerate(matches):
- name = match.group(1)
- if name == "__init__":
- continue
- start = match.end()
- end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
- bodies[name] = content[start:end]
- return bodies
-
-
-def check_body_wiring(content: str, scope: str) -> list[dict]:
- """Check that positional params are reachable from the payload/params dict.
-
- Detects the case where a method has required positional args listed in
- body_params but no kwargs.update(locals()) or kwargs = locals() to merge
- them into kwargs for the dict comprehension.
- """
- methods = extract_methods(content)
- bodies = extract_method_bodies(content)
- drifts = []
-
- for name, info in methods.items():
- body = bodies.get(name, "")
- if "body_params" not in body and "query_params" not in body:
- continue
-
- has_merge = "kwargs.update(locals())" in body or "kwargs = locals()" in body
-
- if has_merge:
- continue
-
- positional_params = {p for p in info["params"] if p != "**kwargs" and "=" not in p and p != "self"}
-
- if "body_params" in body:
- bp_match = re.search(r"body_params\s*=\s*\[(.*?)\]", body, re.DOTALL)
- if bp_match:
- listed = set(re.findall(r'"(\w+)"', bp_match.group(1)))
- unwired = positional_params & listed
- if unwired:
- drifts.append(
- {
- "type": "BODY_WIRING",
- "scope": scope,
- "method": name,
- "detail": f"Positional params {unwired} in body_params but no kwargs merge",
- }
- )
-
- if "query_params" in body:
- qp_match = re.search(r"query_params\s*=\s*\[(.*?)\]", body, re.DOTALL)
- if qp_match:
- listed = set(re.findall(r'"(\w+)"', qp_match.group(1)))
- unwired = positional_params & listed
- if unwired:
- drifts.append(
- {
- "type": "QUERY_WIRING",
- "scope": scope,
- "method": name,
- "detail": f"Positional params {unwired} in query_params but no kwargs merge",
- }
- )
-
- return drifts
-
-
-def compare_modules(v2_content: str, v3_content: str, scope: str) -> list[dict]:
- """Compare two module contents semantically. Returns list of drift entries."""
- v2_methods = extract_methods(v2_content)
- v3_methods = extract_methods(v3_content)
-
- drifts = []
-
- # Methods in v2 but not v3
- for name in sorted(set(v2_methods) - set(v3_methods)):
- drifts.append(
- {"type": "MISSING_IN_V3", "scope": scope, "method": name, "detail": f"Method {name} exists in v2 but not v3"}
- )
-
- # Methods in v3 but not v2 (informational, not failure)
- for name in sorted(set(v3_methods) - set(v2_methods)):
- drifts.append(
- {
- "type": "MISSING_IN_V2",
- "scope": scope,
- "method": name,
- "detail": f"Method {name} exists in v3 but not v2 (likely new endpoint)",
- }
- )
-
- # Methods in both: compare params
- for name in sorted(set(v2_methods) & set(v3_methods)):
- v2_params = v2_methods[name]["params"]
- v3_params = v3_methods[name]["params"]
-
- # Compare param names (ignore **kwargs, it's expected to differ)
- v2_names = set(v2_params.keys()) - {"**kwargs"}
- v3_names = set(v3_params.keys()) - {"**kwargs"}
-
- if v2_names != v3_names:
- missing_in_v3 = v2_names - v3_names
- extra_in_v3 = v3_names - v2_names
- if missing_in_v3 or extra_in_v3:
- drifts.append(
- {
- "type": "PARAM_DIFF",
- "scope": scope,
- "method": name,
- "detail": f"Param diff: missing_in_v3={missing_in_v3}, extra_in_v3={extra_in_v3}",
- }
- )
-
- # Compare types for shared params
- shared = v2_names & v3_names
- for p in sorted(shared):
- v2_type = v2_params[p]
- v3_type = v3_params[p]
- if v2_type != v3_type and v2_type != "untyped" and v3_type != "untyped":
- drifts.append(
- {"type": "TYPE_DIFF", "scope": scope, "method": name, "detail": f"Param '{p}': v2={v2_type}, v3={v3_type}"}
- )
-
- return drifts
-
-
-def mock_requests_get(url):
- """Mock requests.get for offline generation."""
- m = MagicMock()
- m.text = f"# placeholder for {url.split('/')[-1]}\n"
- m.ok = True
- return m
-
-
-def run_v2_generator(spec: dict, output_dir: Path):
- """Run v2 generator in output_dir."""
- import generate_library_oasv2 as gen_v2
-
- original = os.getcwd()
- try:
- os.chdir(output_dir)
- with (
- patch("generate_library_oasv2.requests.get", side_effect=mock_requests_get),
- contextlib.redirect_stdout(open(os.devnull, "w")),
- ):
- gen_v2.generate_library(spec, "0.0.0-diff", "v1", False)
- finally:
- os.chdir(original)
-
-
-def run_v3_generator(spec: dict, output_dir: Path):
- """Run v3 generator in output_dir."""
- import generate_library as gen_v3
-
- original = os.getcwd()
- try:
- os.chdir(output_dir)
- with (
- patch("generate_library.requests.get", side_effect=mock_requests_get),
- contextlib.redirect_stdout(open(os.devnull, "w")),
- ):
- gen_v3.generate_library(spec, "0.0.0-diff", "v1", False)
- finally:
- os.chdir(original)
-
-
-def main():
- parser = argparse.ArgumentParser(description="Semantic diff v2 vs v3 generator output")
- parser.add_argument("spec_file", nargs="?", help="Path to OAS spec JSON (omit for live fetch)")
- parser.add_argument("--live", action="store_true", help="Fetch live spec from api.meraki.com")
- parser.add_argument("--json", action="store_true", help="Output as JSON")
- parser.add_argument("--fail-on-missing", action="store_true", help="Exit 1 if v3 is missing methods that v2 has")
- args = parser.parse_args()
-
- # Load spec
- if args.spec_file:
- with open(args.spec_file) as f:
- spec = json.load(f)
- elif args.live:
- import requests
-
- resp = requests.get("https://api.meraki.com/api/v1/openapiSpec", params={"version": 3})
- resp.raise_for_status()
- spec = resp.json()
- else:
- print("Error: provide spec_file path or --live flag", file=sys.stderr)
- sys.exit(2)
-
- # Setup temp dirs with templates
- v2_dir = Path(tempfile.mkdtemp(prefix="v2_"))
- v3_dir = Path(tempfile.mkdtemp(prefix="v3_"))
-
- for d in (v2_dir, v3_dir):
- for tmpl in GENERATOR_DIR.glob("*.jinja2"):
- shutil.copy2(tmpl, d / tmpl.name)
- # Copy pyproject.toml for ruff config
- pyproject = GENERATOR_DIR.parent / "pyproject.toml"
- if pyproject.exists():
- shutil.copy2(pyproject, d / "pyproject.toml")
-
- # Run both generators
- print("Running v2 generator...", file=sys.stderr)
- run_v2_generator(spec, v2_dir)
- print("Running v3 generator...", file=sys.stderr)
- run_v3_generator(spec, v3_dir)
-
- # Compare each scope
- all_drifts = []
- v2_api = v2_dir / "meraki" / "api"
- v3_api = v3_dir / "meraki" / "api"
-
- if v2_api.exists() and v3_api.exists():
- v2_modules = {f.stem for f in v2_api.glob("*.py") if f.stem != "__init__"}
- v3_modules = {f.stem for f in v3_api.glob("*.py") if f.stem != "__init__"}
-
- for scope in sorted(v2_modules & v3_modules):
- v2_content = (v2_api / f"{scope}.py").read_text()
- v3_content = (v3_api / f"{scope}.py").read_text()
- drifts = compare_modules(v2_content, v3_content, scope)
- all_drifts.extend(drifts)
- all_drifts.extend(check_body_wiring(v3_content, scope))
-
- # Cleanup
- shutil.rmtree(v2_dir, ignore_errors=True)
- shutil.rmtree(v3_dir, ignore_errors=True)
-
- # Report
- if args.json:
- print(json.dumps(all_drifts, indent=2))
- else:
- if not all_drifts:
- print("No semantic drift detected between v2 and v3 output.")
- else:
- missing_v3 = [d for d in all_drifts if d["type"] == "MISSING_IN_V3"]
- missing_v2 = [d for d in all_drifts if d["type"] == "MISSING_IN_V2"]
- param_diffs = [d for d in all_drifts if d["type"] == "PARAM_DIFF"]
- type_diffs = [d for d in all_drifts if d["type"] == "TYPE_DIFF"]
- wiring = [d for d in all_drifts if d["type"] in ("BODY_WIRING", "QUERY_WIRING")]
-
- print("\n=== Semantic Drift Report ===")
- print(f"Methods missing in v3: {len(missing_v3)}")
- print(f"Methods only in v3: {len(missing_v2)}")
- print(f"Parameter differences: {len(param_diffs)}")
- print(f"Type differences: {len(type_diffs)}")
- print(f"Body/query wiring issues: {len(wiring)}")
-
- if missing_v3:
- print(f"\n--- Missing in v3 ({len(missing_v3)}) ---")
- for d in missing_v3[:20]:
- print(f" {d['scope']}.{d['method']}")
-
- if wiring:
- print(f"\n--- Wiring Issues ({len(wiring)}) ---")
- for d in wiring[:20]:
- print(f" {d['scope']}.{d['method']}: {d['detail']}")
-
- if param_diffs:
- print(f"\n--- Param Diffs ({len(param_diffs)}) ---")
- for d in param_diffs[:20]:
- print(f" {d['scope']}.{d['method']}: {d['detail']}")
-
- if type_diffs:
- print(f"\n--- Type Diffs ({len(type_diffs)}) ---")
- for d in type_diffs[:20]:
- print(f" {d['scope']}.{d['method']}: {d['detail']}")
-
- # Exit code: fail on critical issues
- critical = [d for d in all_drifts if d["type"] in ("MISSING_IN_V3", "BODY_WIRING", "QUERY_WIRING")]
- if args.fail_on_missing and critical:
- sys.exit(1)
-
- sys.exit(0)
-
-
-if __name__ == "__main__":
- main()
diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/benchmarks/conftest.py b/tests/benchmarks/conftest.py
new file mode 100644
index 00000000..f8053422
--- /dev/null
+++ b/tests/benchmarks/conftest.py
@@ -0,0 +1,37 @@
+"""Benchmark test fixtures with respx-mocked HTTP responses."""
+
+import httpx
+import pytest
+import respx
+
+import meraki
+
+BASE = "https://api.meraki.com/api/v1"
+ORG_ID = "123456"
+NETWORK_ID = "N_123456"
+
+# Canned response payloads (realistic sizes)
+ORGS_RESPONSE = [{"id": ORG_ID, "name": "Test Org", "url": f"https://n1.meraki.com/o/{ORG_ID}/manage/organization/overview"}]
+NETWORKS_RESPONSE = [{"id": NETWORK_ID, "organizationId": ORG_ID, "name": "Test Net", "productTypes": ["appliance", "switch"]}]
+IDENTITY_RESPONSE = {"name": "Test User", "email": "test@example.com", "authentication": {"api": {"key": {"created": True}}}}
+
+
+@pytest.fixture
+def mock_routes():
+ """Set up respx routes for benchmark tests."""
+ with respx.mock(assert_all_mocked=True, assert_all_called=False) as rsps:
+ rsps.get(f"{BASE}/organizations").mock(return_value=httpx.Response(200, json=ORGS_RESPONSE))
+ rsps.get(f"{BASE}/organizations/{ORG_ID}/networks").mock(return_value=httpx.Response(200, json=NETWORKS_RESPONSE))
+ rsps.get(f"{BASE}/administered/identities/me").mock(return_value=httpx.Response(200, json=IDENTITY_RESPONSE))
+ yield rsps
+
+
+@pytest.fixture
+def benchmark_dashboard(mock_routes):
+ """DashboardAPI client for benchmarking (mocked HTTP)."""
+ return meraki.DashboardAPI(
+ "fake_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ maximum_retries=1,
+ smart_flow_enabled=False,
+ )
diff --git a/tests/benchmarks/test_latency_benchmark.py b/tests/benchmarks/test_latency_benchmark.py
new file mode 100644
index 00000000..979c670c
--- /dev/null
+++ b/tests/benchmarks/test_latency_benchmark.py
@@ -0,0 +1,27 @@
+"""Request latency benchmarks with regression thresholds.
+
+Run: pytest tests/benchmarks/test_latency_benchmark.py --benchmark-json=latency.json
+"""
+
+MAX_MEAN_LATENCY_SECONDS = 0.05
+
+
+def test_latency_get_organizations(benchmark, benchmark_dashboard):
+ """Single GET /organizations latency."""
+ result = benchmark(benchmark_dashboard.organizations.getOrganizations)
+ assert isinstance(result, list)
+ assert benchmark.stats.stats.mean < MAX_MEAN_LATENCY_SECONDS
+
+
+def test_latency_get_networks(benchmark, benchmark_dashboard):
+ """Single GET /organizations/{id}/networks latency."""
+ result = benchmark(benchmark_dashboard.organizations.getOrganizationNetworks, "123456")
+ assert isinstance(result, list)
+ assert benchmark.stats.stats.mean < MAX_MEAN_LATENCY_SECONDS
+
+
+def test_latency_get_identity(benchmark, benchmark_dashboard):
+ """Single GET /administered/identities/me latency."""
+ result = benchmark(benchmark_dashboard.administered.getAdministeredIdentitiesMe)
+ assert isinstance(result, dict)
+ assert benchmark.stats.stats.mean < MAX_MEAN_LATENCY_SECONDS
diff --git a/tests/benchmarks/test_memory_benchmark.py b/tests/benchmarks/test_memory_benchmark.py
new file mode 100644
index 00000000..3790610d
--- /dev/null
+++ b/tests/benchmarks/test_memory_benchmark.py
@@ -0,0 +1,67 @@
+"""Memory benchmarks with leak detection thresholds.
+
+Run: pytest tests/benchmarks/test_memory_benchmark.py --benchmark-json=memory.json
+"""
+
+import tracemalloc
+
+import httpx
+import pytest
+import respx
+
+import meraki
+
+BASE = "https://api.meraki.com/api/v1"
+
+MAX_SINGLE_REQUEST_BYTES = 512 * 1024
+MAX_BATCH_BYTES_PER_REQUEST = 64 * 1024
+
+
+@pytest.fixture
+def fresh_mock_routes():
+ """Fresh respx routes for memory isolation."""
+ with respx.mock(assert_all_mocked=True, assert_all_called=False) as rsps:
+ rsps.get(f"{BASE}/organizations").mock(return_value=httpx.Response(200, json=[{"id": "123456", "name": "Test Org"}]))
+ yield rsps
+
+
+@pytest.fixture
+def fresh_dashboard(fresh_mock_routes):
+ """Fresh DashboardAPI for memory measurement."""
+ return meraki.DashboardAPI(
+ "fake_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ maximum_retries=1,
+ )
+
+
+def test_memory_single_request(fresh_dashboard):
+ """Memory peak for a single request stays under ceiling."""
+ tracemalloc.start()
+ fresh_dashboard.organizations.getOrganizations()
+ current, peak = tracemalloc.get_traced_memory()
+ tracemalloc.stop()
+
+ assert peak < MAX_SINGLE_REQUEST_BYTES, f"Single request peak {peak} bytes exceeds {MAX_SINGLE_REQUEST_BYTES} ceiling"
+
+
+def test_memory_batch_no_leak(fresh_dashboard):
+ """Memory growth is sub-linear over 50 requests (no leak)."""
+ batch_size = 50
+ tracemalloc.start()
+ for _ in range(batch_size):
+ fresh_dashboard.organizations.getOrganizations()
+ current, peak = tracemalloc.get_traced_memory()
+ tracemalloc.stop()
+
+ per_request = current / batch_size
+ assert per_request < MAX_BATCH_BYTES_PER_REQUEST, (
+ f"Avg {per_request:.0f} bytes/request suggests memory leak (ceiling: {MAX_BATCH_BYTES_PER_REQUEST})"
+ )
+
+
+def test_connection_pool_reuse(benchmark, fresh_dashboard):
+ """Steady-state requests reuse pool (benchmark only, no threshold)."""
+ fresh_dashboard.organizations.getOrganizations()
+ result = benchmark(fresh_dashboard.organizations.getOrganizations)
+ assert result is not None
diff --git a/tests/benchmarks/test_throughput_benchmark.py b/tests/benchmarks/test_throughput_benchmark.py
new file mode 100644
index 00000000..fb09ad77
--- /dev/null
+++ b/tests/benchmarks/test_throughput_benchmark.py
@@ -0,0 +1,40 @@
+"""Throughput benchmarks: requests/second.
+
+Run: pytest tests/benchmarks/test_throughput_benchmark.py --benchmark-json=throughput.json
+"""
+
+BATCH_SIZE = 50
+MIN_RPS = 20
+
+
+def test_throughput_sequential_batch(benchmark, benchmark_dashboard):
+ """Throughput: N sequential requests."""
+
+ def batch_requests():
+ for _ in range(BATCH_SIZE):
+ benchmark_dashboard.organizations.getOrganizations()
+
+ benchmark.pedantic(batch_requests, iterations=1, rounds=5)
+ elapsed = benchmark.stats.stats.mean
+ rps = BATCH_SIZE / elapsed if elapsed > 0 else 0
+ benchmark.extra_info["effective_rps"] = rps
+ assert rps > MIN_RPS, f"RPS {rps:.0f} below minimum {MIN_RPS}"
+
+
+def test_throughput_mixed_endpoints(benchmark, benchmark_dashboard):
+ """Throughput: mixed endpoint calls."""
+
+ def mixed_requests():
+ for i in range(BATCH_SIZE):
+ if i % 3 == 0:
+ benchmark_dashboard.administered.getAdministeredIdentitiesMe()
+ elif i % 3 == 1:
+ benchmark_dashboard.organizations.getOrganizations()
+ else:
+ benchmark_dashboard.organizations.getOrganizationNetworks("123456")
+
+ benchmark.pedantic(mixed_requests, iterations=1, rounds=5)
+ elapsed = benchmark.stats.stats.mean
+ rps = BATCH_SIZE / elapsed if elapsed > 0 else 0
+ benchmark.extra_info["effective_rps"] = rps
+ assert rps > MIN_RPS, f"RPS {rps:.0f} below minimum {MIN_RPS}"
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..fe465682
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,30 @@
+"""Root test configuration: shared fixtures for all test modules."""
+
+import pytest
+
+from tests.unit.conftest import FAKE_API_KEY, make_metadata, make_mock_response
+
+
+@pytest.fixture
+def mock_response_factory():
+ """Factory for creating mock httpx.Response objects."""
+ return make_mock_response
+
+
+@pytest.fixture
+def fake_api_key():
+ return FAKE_API_KEY
+
+
+@pytest.fixture(autouse=True)
+def _clean_env(monkeypatch):
+ """Remove Meraki env vars so tests don't leak state."""
+ monkeypatch.delenv("MERAKI_DASHBOARD_API_KEY", raising=False)
+ monkeypatch.delenv("BE_GEO_ID", raising=False)
+ monkeypatch.delenv("MERAKI_PYTHON_SDK_CALLER", raising=False)
+
+
+@pytest.fixture
+def metadata_factory():
+ """Factory for endpoint metadata dicts."""
+ return make_metadata
diff --git a/tests/generator/test_generate_library_golden.py b/tests/generator/test_generate_library_golden.py
deleted file mode 100644
index 5fe1348f..00000000
--- a/tests/generator/test_generate_library_golden.py
+++ /dev/null
@@ -1,112 +0,0 @@
-"""
-Golden-file integration test for the full generate_library pipeline.
-
-To regenerate golden files after intentional changes:
- pytest --update-golden tests/generator/test_generate_library_golden.py
-"""
-
-import json
-import os
-import shutil
-from pathlib import Path
-from unittest.mock import patch, MagicMock
-
-import pytest
-
-import generate_library_oasv2 as gen
-
-FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
-GOLDEN_DIR = Path(__file__).resolve().parent / "golden"
-GENERATOR_DIR = Path(__file__).resolve().parent.parent.parent / "generator"
-
-GOLDEN_FILES = [
- "meraki/api/networks.py",
- "meraki/aio/api/networks.py",
- "meraki/api/batch/networks.py",
-]
-
-
-@pytest.fixture
-def update_golden(request):
- return request.config.getoption("--update-golden")
-
-
-@pytest.fixture
-def synthetic_spec():
- with open(FIXTURES_DIR / "synthetic_spec.json") as f:
- return json.load(f)
-
-
-@pytest.fixture
-def output_dir(tmp_path):
- for tmpl in GENERATOR_DIR.glob("*.jinja2"):
- shutil.copy2(tmpl, tmp_path / tmpl.name)
- # Copy pyproject.toml so ruff uses project settings (e.g. line-length)
- project_root = GENERATOR_DIR.parent
- shutil.copy2(project_root / "pyproject.toml", tmp_path / "pyproject.toml")
- return tmp_path
-
-
-def _mock_requests_get(url):
- mock_response = MagicMock()
- mock_response.text = f"# placeholder for {url.split('/')[-1]}\n"
- mock_response.ok = True
- return mock_response
-
-
-def _run_generation(synthetic_spec, output_dir):
- original_cwd = os.getcwd()
- try:
- os.chdir(output_dir)
- with patch("generate_library_oasv2.requests.get", side_effect=_mock_requests_get):
- gen.generate_library(
- spec=synthetic_spec,
- version_number="0.0.0-test",
- api_version_number="v1-test",
- is_github_action=False,
- )
- finally:
- os.chdir(original_cwd)
-
-
-class TestGoldenFiles:
- def test_generate_and_compare(self, synthetic_spec, output_dir, update_golden):
- _run_generation(synthetic_spec, output_dir)
-
- for rel_path in GOLDEN_FILES:
- generated_file = output_dir / rel_path
- golden_file = GOLDEN_DIR / rel_path
-
- assert generated_file.exists(), f"Expected generated file not found: {rel_path}"
-
- generated_content = generated_file.read_text(encoding="utf-8")
-
- if update_golden:
- golden_file.parent.mkdir(parents=True, exist_ok=True)
- golden_file.write_text(generated_content, encoding="utf-8")
- else:
- assert golden_file.exists(), f"Golden file missing: {golden_file}\nRun with --update-golden to generate."
- expected_content = golden_file.read_text(encoding="utf-8")
- assert generated_content == expected_content, (
- f"Generated output differs from golden file: {rel_path}\nRun with --update-golden to update."
- )
-
- def test_no_real_http_calls(self, synthetic_spec, output_dir):
- original_cwd = os.getcwd()
- try:
- os.chdir(output_dir)
- with patch("generate_library_oasv2.requests.get", side_effect=_mock_requests_get) as mocked:
- gen.generate_library(
- spec=synthetic_spec,
- version_number="0.0.0-test",
- api_version_number="v1-test",
- is_github_action=False,
- )
- assert mocked.call_count > 0
- for call in mocked.call_args_list:
- url = call[0][0]
- from urllib.parse import urlparse
-
- assert urlparse(url).hostname == "raw.githubusercontent.com"
- finally:
- os.chdir(original_cwd)
diff --git a/tests/generator/test_generate_library_v3.py b/tests/generator/test_generate_library_v3.py
index c4ddb721..91fc30ba 100644
--- a/tests/generator/test_generate_library_v3.py
+++ b/tests/generator/test_generate_library_v3.py
@@ -5,7 +5,9 @@
import shutil
import sys
from pathlib import Path
-from unittest.mock import patch, MagicMock
+from unittest.mock import patch
+
+import httpx
import pytest
@@ -31,11 +33,12 @@ def output_dir(tmp_path):
return tmp_path
-def _mock_requests_get(url):
- mock_response = MagicMock()
- mock_response.text = f"# placeholder for {url.split('/')[-1]}\n"
- mock_response.ok = True
- return mock_response
+def _mock_httpx_get(url):
+ return httpx.Response(
+ 200,
+ text=f"# placeholder for {url.split('/')[-1]}\n",
+ request=httpx.Request("GET", url), # raise_for_status() needs a request set
+ )
def _run_v3_generation(v3_spec, output_dir):
@@ -44,7 +47,7 @@ def _run_v3_generation(v3_spec, output_dir):
original_cwd = os.getcwd()
try:
os.chdir(output_dir)
- with patch("generate_library.requests.get", side_effect=_mock_requests_get):
+ with patch("generate_library.httpx.get", side_effect=_mock_httpx_get):
gen_v3.generate_library(
spec=v3_spec,
version_number="0.0.0-test",
@@ -61,7 +64,7 @@ def _run_v3_generation_with_stubs(v3_spec, output_dir):
original_cwd = os.getcwd()
try:
os.chdir(output_dir)
- with patch("generate_library.requests.get", side_effect=_mock_requests_get):
+ with patch("generate_library.httpx.get", side_effect=_mock_httpx_get):
gen_v3.generate_library(
spec=v3_spec,
version_number="0.0.0-test",
@@ -138,11 +141,11 @@ def test_spec_fetch_uses_version_3(self):
"""GEN-05: Spec fetch includes ?version=3."""
import generate_library as gen_v3
- with patch("generate_library.requests.get") as mock_get:
- mock_response = MagicMock()
- mock_response.ok = True
- mock_response.json.return_value = {"paths": {}, "tags": [], "x-batchable-actions": []}
- mock_get.return_value = mock_response
+ with patch("generate_library.httpx.get") as mock_get:
+ mock_get.return_value = httpx.Response(
+ 200,
+ json={"paths": {}, "tags": [], "x-batchable-actions": []},
+ )
with patch.object(gen_v3, "generate_library"):
gen_v3.main(["-v", "1.0"])
@@ -155,19 +158,21 @@ def test_org_specific_fetch_uses_version_3(self):
"""GEN-05: Org-specific fetch includes ?version=3 and auth header."""
import generate_library as gen_v3
- with patch("generate_library.requests.get") as mock_get:
- mock_response = MagicMock()
- mock_response.ok = True
- mock_response.json.return_value = {"paths": {}, "tags": [], "x-batchable-actions": []}
- mock_get.return_value = mock_response
-
- with patch.object(gen_v3, "generate_library"):
- gen_v3.main(["-o", "12345", "-k", "testkey", "-v", "1.0"])
-
- args, kwargs = mock_get.call_args
- assert "12345" in args[0]
- assert kwargs.get("params") == {"version": 3}
- assert "Bearer testkey" in kwargs.get("headers", {}).get("Authorization", "")
+ with patch.dict(os.environ, {}, clear=False):
+ os.environ.pop("MERAKI_DASHBOARD_API_KEY", None)
+ with patch("generate_library.httpx.get") as mock_get:
+ mock_get.return_value = httpx.Response(
+ 200,
+ json={"paths": {}, "tags": [], "x-batchable-actions": []},
+ )
+
+ with patch.object(gen_v3, "generate_library"):
+ gen_v3.main(["-o", "12345", "-k", "testkey", "-v", "1.0"])
+
+ args, kwargs = mock_get.call_args
+ assert "12345" in args[0]
+ assert kwargs.get("params") == {"version": 3}
+ assert "Bearer testkey" in kwargs.get("headers", {}).get("Authorization", "")
class TestV3Stubs:
diff --git a/tests/generator/test_generate_stubs.py b/tests/generator/test_generate_stubs.py
index 736e7e43..555a9907 100644
--- a/tests/generator/test_generate_stubs.py
+++ b/tests/generator/test_generate_stubs.py
@@ -1,10 +1,10 @@
"""Tests for .pyi stub generation."""
+
import json
import os
import shutil
import sys
from pathlib import Path
-from unittest.mock import patch, MagicMock
import pytest
diff --git a/tests/generator/test_generator_audit.py b/tests/generator/test_generator_audit.py
new file mode 100644
index 00000000..fc07f758
--- /dev/null
+++ b/tests/generator/test_generator_audit.py
@@ -0,0 +1,196 @@
+"""
+Regression tests for GROUP A (generator) audit fixes.
+
+Covers:
+ #1 generate_snippets.py: sys.exit must use a single f-string message
+ #3 batch_function_template.jinja2: path-param quoting wraps str()
+ #16 generate_library.py: PATCH endpoints generate without NameError
+ #18 generate_library.py + templates: keyword-named params (e.g. 'from')
+ are remapped from their safe signature name back to the original.
+
+Hermetic: no network. The generator package uses bare `import common`, so the
+generator directory is placed on sys.path. parse logic is exercised with small
+in-memory spec dicts.
+"""
+
+import io
+import os
+import sys
+
+import jinja2
+import pytest
+
+# Repo layout: /generator/*.py and /tests/unit/this_file
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+GENERATOR_DIR = os.path.join(REPO_ROOT, "generator")
+
+if GENERATOR_DIR not in sys.path:
+ sys.path.insert(0, GENERATOR_DIR)
+
+import generate_library # noqa: E402
+from parser_v3 import clear_cache # noqa: E402
+
+
+TEMPLATE_DIR = GENERATOR_DIR + os.sep # trailing sep so f"{template_dir}name" resolves
+
+
+def _jinja_env():
+ import json
+
+ env = jinja2.Environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True)
+ env.filters["to_double_quote_list"] = lambda lst: json.dumps(lst)
+ return env
+
+
+# ---------------------------------------------------------------------------
+# #1 generate_snippets.py: sys.exit f-string diagnostic
+# ---------------------------------------------------------------------------
+def test_snippets_sys_exit_uses_fstring_message():
+ src_path = os.path.join(GENERATOR_DIR, "generate_snippets.py")
+ with open(src_path, encoding="utf-8") as fp:
+ src = fp.read()
+
+ # The buggy form passed two positional args to sys.exit (TypeError).
+ assert "sys.exit(p, values)" not in src
+ # The fixed form is a single f-string argument.
+ assert 'sys.exit(f"Unhandled param type for {p}: {values}")' in src
+
+
+# ---------------------------------------------------------------------------
+# #3 batch_function_template.jinja2: str() around path param before quote
+# ---------------------------------------------------------------------------
+def test_batch_template_path_param_wraps_str():
+ env = _jinja_env()
+ with open(os.path.join(GENERATOR_DIR, "batch_function_template.jinja2"), encoding="utf-8") as fp:
+ template = env.from_string(fp.read())
+
+ rendered = template.render(
+ operation="createDeviceThing",
+ function_definition=", serial: str",
+ description="desc",
+ doc_url="http://example",
+ descriptions=[],
+ kwarg_line="",
+ all_params=[],
+ assert_blocks=[],
+ tags=["devices"],
+ resource="/devices/{serial}/things",
+ query_params={},
+ array_params={},
+ body_params={},
+ path_params={"serial": {"type": "string", "in": "path"}},
+ call_line="return action",
+ batch_operation="create",
+ renamed_params={},
+ )
+
+ # A non-string (int) path param would TypeError without str() wrapping.
+ assert 'urllib.parse.quote(str(serial), safe="")' in rendered
+ assert 'urllib.parse.quote(serial, safe="")' not in rendered
+
+
+# ---------------------------------------------------------------------------
+# #16 generate_library.py: PATCH endpoint generates a patch call_line
+# ---------------------------------------------------------------------------
+def _patch_spec():
+ return {
+ "paths": {
+ "/things/{thingId}": {
+ "patch": {
+ "operationId": "updateThing",
+ "tags": ["organizations"],
+ "summary": "Update a thing",
+ "parameters": [
+ {
+ "name": "thingId",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {"name": {"type": "string", "description": "n"}},
+ }
+ }
+ }
+ },
+ }
+ }
+ }
+ }
+
+
+def test_patch_endpoint_generates_without_nameerror():
+ clear_cache()
+ spec = _patch_spec()
+ section = {"/things/{thingId}": {"patch": spec["paths"]["/things/{thingId}"]["patch"]}}
+
+ output = io.StringIO()
+ async_output = io.StringIO()
+
+ # Would raise NameError (unbound call_line) before fix #16.
+ generate_library.generate_standard_and_async_functions(_jinja_env(), TEMPLATE_DIR, section, output, async_output, spec)
+
+ rendered = output.getvalue()
+ assert "def updateThing(self" in rendered
+ # patch mirrors put/post: dispatches to self._session.patch with the payload.
+ assert "self._session.patch(metadata, resource, payload)" in rendered
+
+
+# ---------------------------------------------------------------------------
+# #18 keyword param 'from' is remapped from safe name 'from_' back to 'from'
+# ---------------------------------------------------------------------------
+def _keyword_param_spec():
+ return {
+ "paths": {
+ "/networks/{networkId}/events": {
+ "get": {
+ "operationId": "getNetworkThings",
+ "tags": ["networks"],
+ "summary": "Get things",
+ "parameters": [
+ {
+ "name": "networkId",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ },
+ {
+ "name": "from",
+ "in": "query",
+ "required": True,
+ "schema": {"type": "string", "description": "start"},
+ },
+ ],
+ }
+ }
+ }
+ }
+
+
+def test_keyword_param_remapped_in_generated_function():
+ clear_cache()
+ spec = _keyword_param_spec()
+ path = "/networks/{networkId}/events"
+ section = {path: {"get": spec["paths"][path]["get"]}}
+
+ output = io.StringIO()
+ async_output = io.StringIO()
+
+ generate_library.generate_standard_and_async_functions(_jinja_env(), TEMPLATE_DIR, section, output, async_output, spec)
+
+ rendered = output.getvalue()
+ # 'from' is a Python keyword -> signature uses 'from_'
+ assert "from_: str" in rendered
+ # remap loop restores the original key so the value reaches the request
+ assert 'kwargs["from"] = kwargs.pop("from_")' in rendered
+ # 'from' must still be the query param key used to build params
+ assert '"from"' in rendered
+
+
+if __name__ == "__main__":
+ sys.exit(pytest.main([__file__, "-q"]))
diff --git a/tests/generator/test_golden_v3_output.py b/tests/generator/test_golden_v3_output.py
index 68cefd8b..64567721 100644
--- a/tests/generator/test_golden_v3_output.py
+++ b/tests/generator/test_golden_v3_output.py
@@ -6,7 +6,9 @@
import shutil
import sys
from pathlib import Path
-from unittest.mock import patch, MagicMock
+from unittest.mock import patch
+
+import httpx
import pytest
@@ -41,12 +43,13 @@ def _generate_fresh_output(spec, output_dir):
os.chdir(output_dir)
def mock_get(url):
- m = MagicMock()
- m.text = f"# placeholder for {url.split('/')[-1]}\n"
- m.ok = True
- return m
+ return httpx.Response(
+ 200,
+ text=f"# placeholder for {url.split('/')[-1]}\n",
+ request=httpx.Request("GET", url), # raise_for_status() needs a request set
+ )
- with patch("generate_library.requests.get", side_effect=mock_get):
+ with patch("generate_library.httpx.get", side_effect=mock_get):
gen_v3.generate_library(spec, "0.0.0-golden", "v1", False)
sync = (output_dir / "meraki/api/networks.py").read_text()
diff --git a/tests/generator/test_pure_functions.py b/tests/generator/test_pure_functions.py
index 71e7fd9f..9b849f1e 100644
--- a/tests/generator/test_pure_functions.py
+++ b/tests/generator/test_pure_functions.py
@@ -1,6 +1,6 @@
import pytest
-import generate_library_oasv2 as gen
+import common as gen
class TestDocsUrl:
@@ -182,163 +182,3 @@ def test_enum_captured(self):
False,
)
assert result["sortOrder"]["enum"] == ["asc", "desc"]
-
-
-class TestParseParams:
- def test_none_parameters(self):
- assert gen.parse_params("someOp", None) == {}
-
- def test_empty_list(self):
- assert gen.parse_params("someOp", []) == {}
-
- def test_path_param(self):
- params = [
- {
- "name": "networkId",
- "in": "path",
- "type": "string",
- "required": True,
- "description": "Network ID",
- }
- ]
- result = gen.parse_params("someOp", params)
- assert "networkId" in result
- assert result["networkId"]["required"] is True
-
-
-class TestParseGetParams:
- def test_with_pagination(self):
- parameters = [
- {
- "name": "networkId",
- "in": "path",
- "type": "string",
- "required": True,
- "description": "Network ID",
- },
- {
- "name": "perPage",
- "in": "query",
- "type": "integer",
- "required": False,
- "description": "Per page",
- },
- {
- "name": "startingAfter",
- "in": "query",
- "type": "string",
- "required": False,
- "description": "Starting after",
- },
- ]
- array_params, call_line, path_params, query_params = gen.parse_get_params("getNetworkClients", parameters)
- assert "get_pages" in call_line
- assert "networkId" in path_params
- assert "perPage" in query_params
-
- def test_without_query_params(self):
- parameters = [
- {
- "name": "serial",
- "in": "path",
- "type": "string",
- "required": True,
- "description": "Serial",
- },
- ]
- array_params, call_line, path_params, query_params = gen.parse_get_params("getDevice", parameters)
- assert call_line == "return self._session.get(metadata, resource)"
-
- def test_network_events_pagination(self):
- parameters = [
- {
- "name": "networkId",
- "in": "path",
- "type": "string",
- "required": True,
- "description": "Network ID",
- },
- {
- "name": "perPage",
- "in": "query",
- "type": "integer",
- "required": False,
- "description": "Per page",
- },
- ]
- _, call_line, _, _ = gen.parse_get_params("getNetworkEvents", parameters)
- assert "event_log_end_time" in call_line
-
-
-class TestParsePostAndPutParams:
- def test_post_with_body(self):
- parameters = [
- {
- "name": "networkId",
- "in": "path",
- "type": "string",
- "required": True,
- "description": "Network ID",
- },
- {
- "name": "createBody",
- "in": "body",
- "schema": {
- "properties": {"name": {"type": "string", "description": "Name"}},
- },
- },
- ]
- body_params, call_line, path_params = gen.parse_post_and_put_params("post", "createNetwork", parameters)
- assert "post" in call_line
- assert "payload" in call_line
- assert "name" in body_params
-
- def test_put_without_body(self):
- parameters = [
- {
- "name": "networkId",
- "in": "path",
- "type": "string",
- "required": True,
- "description": "Network ID",
- },
- ]
- body_params, call_line, path_params = gen.parse_post_and_put_params("put", "updateThing", parameters)
- assert "payload" not in call_line
-
-
-class TestParseDeleteParams:
- def test_simple_delete(self):
- parameters = [
- {
- "name": "networkId",
- "in": "path",
- "type": "string",
- "required": True,
- "description": "Network ID",
- },
- ]
- call_line, path_params, query_params = gen.parse_delete_params("deleteNetwork", parameters)
- assert call_line == "return self._session.delete(metadata, resource)"
- assert "networkId" in path_params
-
- def test_delete_with_query_params(self):
- parameters = [
- {
- "name": "networkId",
- "in": "path",
- "type": "string",
- "required": True,
- "description": "Network ID",
- },
- {
- "name": "force",
- "in": "query",
- "type": "boolean",
- "required": False,
- "description": "Force delete",
- },
- ]
- call_line, path_params, query_params = gen.parse_delete_params("deleteNetwork", parameters)
- assert "params" in call_line
- assert "force" in query_params
diff --git a/tests/generator/test_semantic_diff.py b/tests/generator/test_semantic_diff.py
deleted file mode 100644
index 7f2f7f36..00000000
--- a/tests/generator/test_semantic_diff.py
+++ /dev/null
@@ -1,153 +0,0 @@
-"""Unit tests for semantic_diff_v2_v3.py core functions."""
-
-import sys
-from pathlib import Path
-
-
-SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "scripts"
-sys.path.insert(0, str(SCRIPTS_DIR))
-
-from semantic_diff_v2_v3 import extract_methods, compare_modules, check_body_wiring # noqa: E402
-
-
-class TestExtractMethods:
- def test_extracts_simple_method(self):
- content = " def getNetwork(self, networkId: str):\n pass\n"
- methods = extract_methods(content)
- assert "getNetwork" in methods
- assert methods["getNetwork"]["params"] == {"networkId": "str"}
-
- def test_extracts_multiple_params(self):
- content = " def updateNetwork(self, networkId: str, name: str, **kwargs):\n pass\n"
- methods = extract_methods(content)
- assert "updateNetwork" in methods
- assert "networkId" in methods["updateNetwork"]["params"]
- assert "name" in methods["updateNetwork"]["params"]
- assert "**kwargs" in methods["updateNetwork"]["params"]
-
- def test_ignores_init(self):
- content = " def __init__(self, session):\n pass\n def getNetwork(self, id: str):\n pass\n"
- methods = extract_methods(content)
- assert "__init__" not in methods
- assert "getNetwork" in methods
-
- def test_handles_default_values(self):
- content = " def listDevices(self, total_pages=1, direction='next'):\n pass\n"
- methods = extract_methods(content)
- assert "listDevices" in methods
- assert "total_pages" in methods["listDevices"]["params"]
- assert "direction" in methods["listDevices"]["params"]
-
- def test_empty_content(self):
- methods = extract_methods("")
- assert methods == {}
-
-
-class TestCompareModules:
- def test_identical_modules(self):
- content = " def getNetwork(self, networkId: str):\n pass\n"
- drifts = compare_modules(content, content, "networks")
- assert len(drifts) == 0
-
- def test_missing_in_v3(self):
- v2 = " def getNetwork(self, id: str):\n pass\n def deleteNetwork(self, id: str):\n pass\n"
- v3 = " def getNetwork(self, id: str):\n pass\n"
- drifts = compare_modules(v2, v3, "networks")
- missing = [d for d in drifts if d["type"] == "MISSING_IN_V3"]
- assert len(missing) == 1
- assert missing[0]["method"] == "deleteNetwork"
-
- def test_extra_in_v3(self):
- v2 = " def getNetwork(self, id: str):\n pass\n"
- v3 = " def getNetwork(self, id: str):\n pass\n def newMethod(self, id: str):\n pass\n"
- drifts = compare_modules(v2, v3, "networks")
- extra = [d for d in drifts if d["type"] == "MISSING_IN_V2"]
- assert len(extra) == 1
- assert extra[0]["method"] == "newMethod"
-
- def test_param_diff(self):
- v2 = " def getNetwork(self, networkId: str, orgId: str):\n pass\n"
- v3 = " def getNetwork(self, networkId: str):\n pass\n"
- drifts = compare_modules(v2, v3, "networks")
- param_diffs = [d for d in drifts if d["type"] == "PARAM_DIFF"]
- assert len(param_diffs) == 1
-
- def test_type_diff(self):
- v2 = " def getNetwork(self, count: str):\n pass\n"
- v3 = " def getNetwork(self, count: int):\n pass\n"
- drifts = compare_modules(v2, v3, "networks")
- type_diffs = [d for d in drifts if d["type"] == "TYPE_DIFF"]
- assert len(type_diffs) == 1
- assert "count" in type_diffs[0]["detail"]
-
- def test_kwargs_ignored(self):
- v2 = " def getNetwork(self, id: str, **kwargs):\n pass\n"
- v3 = " def getNetwork(self, id: str):\n pass\n"
- drifts = compare_modules(v2, v3, "networks")
- # **kwargs difference should not trigger PARAM_DIFF
- param_diffs = [d for d in drifts if d["type"] == "PARAM_DIFF"]
- assert len(param_diffs) == 0
-
-
-class TestCheckBodyWiring:
- def test_detects_missing_kwargs_merge(self):
- content = (
- " def createNetwork(self, orgId: str, name: str, productTypes: list, **kwargs):\n"
- " metadata = {}\n"
- " orgId = urllib.parse.quote(str(orgId), safe='')\n"
- " resource = f'/organizations/{orgId}/networks'\n"
- ' body_params = ["name", "productTypes", "tags"]\n'
- " payload = {k: v for k, v in kwargs.items() if k in body_params}\n"
- " return self._session.post(metadata, resource, payload)\n"
- )
- drifts = check_body_wiring(content, "organizations")
- wiring = [d for d in drifts if d["type"] == "BODY_WIRING"]
- assert len(wiring) == 1
- assert "name" in wiring[0]["detail"] or "productTypes" in wiring[0]["detail"]
-
- def test_passes_with_kwargs_update_locals(self):
- content = (
- " def createNetwork(self, orgId: str, name: str, productTypes: list, **kwargs):\n"
- " kwargs.update(locals())\n"
- " metadata = {}\n"
- ' body_params = ["name", "productTypes", "tags"]\n'
- " payload = {k: v for k, v in kwargs.items() if k in body_params}\n"
- " return self._session.post(metadata, resource, payload)\n"
- )
- drifts = check_body_wiring(content, "organizations")
- assert len(drifts) == 0
-
- def test_passes_with_kwargs_equals_locals(self):
- content = (
- " def createNetwork(self, orgId: str, name: str, productTypes: list):\n"
- " kwargs = locals()\n"
- " metadata = {}\n"
- ' body_params = ["name", "productTypes"]\n'
- " payload = {k: v for k, v in kwargs.items() if k in body_params}\n"
- " return self._session.post(metadata, resource, payload)\n"
- )
- drifts = check_body_wiring(content, "organizations")
- assert len(drifts) == 0
-
- def test_ignores_path_only_params(self):
- content = (
- " def getNetwork(self, networkId: str):\n"
- " metadata = {}\n"
- " networkId = urllib.parse.quote(str(networkId), safe='')\n"
- " return self._session.get(metadata, resource)\n"
- )
- drifts = check_body_wiring(content, "networks")
- assert len(drifts) == 0
-
- def test_detects_query_wiring_issue(self):
- content = (
- " def listClients(self, networkId: str, mac: str, **kwargs):\n"
- " metadata = {}\n"
- ' query_params = ["mac", "ip"]\n'
- " params = {k: v for k, v in kwargs.items() if k in query_params}\n"
- " return self._session.get(metadata, resource, params)\n"
- )
- drifts = check_body_wiring(content, "networks")
- query = [d for d in drifts if d["type"] == "QUERY_WIRING"]
- assert len(query) == 1
- assert "mac" in query[0]["detail"]
diff --git a/tests/integration/baseline/README.md b/tests/integration/baseline/README.md
new file mode 100644
index 00000000..7c4d3aa9
--- /dev/null
+++ b/tests/integration/baseline/README.md
@@ -0,0 +1,29 @@
+# Integration Test Baseline
+
+Machine-readable pass/fail snapshot of all integration tests captured before the httpx migration (v4.0 Phase 8).
+
+## Purpose
+
+Phase 13 uses `report.json` as the regression gate. The rule (D-07): **same or better**. No new failures allowed after migration; new passes are OK.
+
+## How it was captured
+
+```bash
+pytest tests/integration/ \
+ --apikey="$MERAKI_DASHBOARD_API_KEY" \
+ --o="$MERAKI_ORG_ID" \
+ -v \
+ --json-report \
+ --json-report-file=tests/integration/baseline/report.json \
+ --json-report-indent=2 \
+ --json-report-omit=collectors,log,streams
+```
+
+## Schema
+
+See pytest-json-report docs. Key fields per test: `nodeid`, `outcome`, `call.duration`.
+
+## Regression comparison (Phase 13)
+
+For each test in baseline where `outcome == "passed"`, Phase 13 must also pass.
+Tests that failed in baseline are "known failures" and do not block.
diff --git a/tests/integration/baseline/report.json b/tests/integration/baseline/report.json
new file mode 100644
index 00000000..9a9fde0f
--- /dev/null
+++ b/tests/integration/baseline/report.json
@@ -0,0 +1,1068 @@
+{
+ "created": 1777681221.1439042,
+ "duration": 115.48956537246704,
+ "exitcode": 0,
+ "root": "C:\\Users\\jkuchta\\Work\\_Repos\\meraki\\dashboard-api-python",
+ "environment": {},
+ "summary": {
+ "passed": 32,
+ "total": 32,
+ "collected": 32
+ },
+ "collectors": [
+ {
+ "nodeid": "",
+ "outcome": "passed",
+ "result": [
+ {
+ "nodeid": "tests/integration",
+ "type": "Dir"
+ }
+ ]
+ },
+ {
+ "nodeid": "tests/integration/baseline",
+ "outcome": "passed",
+ "result": []
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py",
+ "outcome": "passed",
+ "result": [
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_administered_identities_me",
+ "type": "Coroutine",
+ "lineno": 43
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_organizations",
+ "type": "Coroutine",
+ "lineno": 50
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_organization",
+ "type": "Coroutine",
+ "lineno": 56
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_create_network",
+ "type": "Coroutine",
+ "lineno": 62
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_networks",
+ "type": "Coroutine",
+ "lineno": 67
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_update_network",
+ "type": "Coroutine",
+ "lineno": 73
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_create_organization_policy_objects",
+ "type": "Coroutine",
+ "lineno": 84
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_organization_policy_objects",
+ "type": "Coroutine",
+ "lineno": 107
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_network_appliance_l3_firewall_rules",
+ "type": "Coroutine",
+ "lineno": 113
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_update_network_appliance_vlan_settings",
+ "type": "Coroutine",
+ "lineno": 119
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_create_network_appliance_vlan",
+ "type": "Coroutine",
+ "lineno": 125
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_update_l3_firewall_rules",
+ "type": "Coroutine",
+ "lineno": 144
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_delete_policy_objects",
+ "type": "Coroutine",
+ "lineno": 183
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_delete_network",
+ "type": "Coroutine",
+ "lineno": 200
+ }
+ ]
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py",
+ "outcome": "passed",
+ "result": [
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_administered_identities_me",
+ "type": "Function",
+ "lineno": 46
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_organizations",
+ "type": "Function",
+ "lineno": 53
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_organization",
+ "type": "Function",
+ "lineno": 59
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_create_network",
+ "type": "Function",
+ "lineno": 65
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_networks",
+ "type": "Function",
+ "lineno": 70
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_update_network",
+ "type": "Function",
+ "lineno": 76
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_create_organization_policy_objects",
+ "type": "Function",
+ "lineno": 88
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_organization_policy_objects",
+ "type": "Function",
+ "lineno": 111
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_network_appliance_l3_firewall_rules",
+ "type": "Function",
+ "lineno": 117
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_update_network_appliance_vlan_settings",
+ "type": "Function",
+ "lineno": 123
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_create_network_appliance_vlan",
+ "type": "Function",
+ "lineno": 129
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_update_l3_firewall_rules",
+ "type": "Function",
+ "lineno": 148
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_delete_policy_objects",
+ "type": "Function",
+ "lineno": 187
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_delete_network",
+ "type": "Function",
+ "lineno": 207
+ }
+ ]
+ },
+ {
+ "nodeid": "tests/integration/test_iterator_async.py",
+ "outcome": "passed",
+ "result": [
+ {
+ "nodeid": "tests/integration/test_iterator_async.py::test_iterator_async_full_lifecycle",
+ "type": "Coroutine",
+ "lineno": 29
+ }
+ ]
+ },
+ {
+ "nodeid": "tests/integration/test_iterator_sync.py",
+ "outcome": "passed",
+ "result": [
+ {
+ "nodeid": "tests/integration/test_iterator_sync.py::test_iterator_sync_full_lifecycle",
+ "type": "Function",
+ "lineno": 25
+ }
+ ]
+ },
+ {
+ "nodeid": "tests/integration/test_org_wide_workflows.py",
+ "outcome": "passed",
+ "result": [
+ {
+ "nodeid": "tests/integration/test_org_wide_workflows.py::test_sync_org_wide_clients_workflow",
+ "type": "Function",
+ "lineno": 8
+ },
+ {
+ "nodeid": "tests/integration/test_org_wide_workflows.py::test_async_org_wide_clients_workflow",
+ "type": "Coroutine",
+ "lineno": 31
+ }
+ ]
+ },
+ {
+ "nodeid": "tests/integration",
+ "outcome": "passed",
+ "result": [
+ {
+ "nodeid": "tests/integration/baseline",
+ "type": "Dir"
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py",
+ "type": "Module"
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py",
+ "type": "Module"
+ },
+ {
+ "nodeid": "tests/integration/test_iterator_async.py",
+ "type": "Module"
+ },
+ {
+ "nodeid": "tests/integration/test_iterator_sync.py",
+ "type": "Module"
+ },
+ {
+ "nodeid": "tests/integration/test_org_wide_workflows.py",
+ "type": "Module"
+ }
+ ]
+ }
+ ],
+ "tests": [
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_administered_identities_me",
+ "lineno": 46,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_administered_identities_me",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0010485999991942663,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.7933835000003455,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00014060000103199854,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_organizations",
+ "lineno": 53,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_organizations",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0001731000011204742,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.46288840000124765,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00014340000052470714,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_organization",
+ "lineno": 59,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_organization",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00026960000104736537,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.29764999999679276,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.0001370999998471234,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_create_network",
+ "lineno": 65,
+ "outcome": "passed",
+ "keywords": [
+ "test_create_network",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 3.396638100002747,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.00013219999891589396,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00012569999671541154,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_networks",
+ "lineno": 70,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_networks",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00013870000111637637,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.28337069999906817,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00013720000060857274,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_update_network",
+ "lineno": 76,
+ "outcome": "passed",
+ "keywords": [
+ "test_update_network",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0001995999991777353,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.8609858999989228,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00013269999908516183,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_create_organization_policy_objects",
+ "lineno": 88,
+ "outcome": "passed",
+ "keywords": [
+ "test_create_organization_policy_objects",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.000229199999012053,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 1.1937579000004916,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00017239999942830764,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_organization_policy_objects",
+ "lineno": 111,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_organization_policy_objects",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00024359999952139333,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.5413779999980761,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.000134599999000784,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_get_network_appliance_l3_firewall_rules",
+ "lineno": 117,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_network_appliance_l3_firewall_rules",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00015999999959603883,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.5264481999984127,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00013760000001639128,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_update_network_appliance_vlan_settings",
+ "lineno": 123,
+ "outcome": "passed",
+ "keywords": [
+ "test_update_network_appliance_vlan_settings",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00015310000162571669,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.5576407999978983,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00013490000128513202,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_create_network_appliance_vlan",
+ "lineno": 129,
+ "outcome": "passed",
+ "keywords": [
+ "test_create_network_appliance_vlan",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0001491999973950442,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 1.5098424999996496,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00013490000128513202,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_update_l3_firewall_rules",
+ "lineno": 148,
+ "outcome": "passed",
+ "keywords": [
+ "test_update_l3_firewall_rules",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00014770000052521937,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.9906009999976959,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00012829999832320027,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_delete_policy_objects",
+ "lineno": 187,
+ "outcome": "passed",
+ "keywords": [
+ "test_delete_policy_objects",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00014250000094762072,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 2.2484381000031135,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00019489999976940453,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_sync.py::test_delete_network",
+ "lineno": 207,
+ "outcome": "passed",
+ "keywords": [
+ "test_delete_network",
+ "test_client_crud_lifecycle_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0001919999995152466,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 13.989967000001343,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00013349999790079892,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_administered_identities_me",
+ "lineno": 43,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_administered_identities_me",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.002668099998118123,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.47884870000052615,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00013730000137002207,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_organizations",
+ "lineno": 50,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_organizations",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00017490000027464703,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.2649894999995013,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.0001354000014543999,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_organization",
+ "lineno": 56,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_organization",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00022680000256514177,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.2856160000010277,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00011770000128308311,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_create_network",
+ "lineno": 62,
+ "outcome": "passed",
+ "keywords": [
+ "test_create_network",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 3.051430600000458,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.00020430000222404487,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.0001145999995060265,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_networks",
+ "lineno": 67,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_networks",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00015210000128718093,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.2914024999990943,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.0001396000006934628,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_update_network",
+ "lineno": 73,
+ "outcome": "passed",
+ "keywords": [
+ "test_update_network",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00021579999884124845,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.8056949999991048,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00014290000035543926,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_create_organization_policy_objects",
+ "lineno": 84,
+ "outcome": "passed",
+ "keywords": [
+ "test_create_organization_policy_objects",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00023490000239689834,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.5688245999990613,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00012700000297627412,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_organization_policy_objects",
+ "lineno": 107,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_organization_policy_objects",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00017690000095171854,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.2803993999987142,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.0001430000011168886,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_get_network_appliance_l3_firewall_rules",
+ "lineno": 113,
+ "outcome": "passed",
+ "keywords": [
+ "test_get_network_appliance_l3_firewall_rules",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00022739999985788018,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.30385259999820846,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.0002696999981708359,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_update_network_appliance_vlan_settings",
+ "lineno": 119,
+ "outcome": "passed",
+ "keywords": [
+ "test_update_network_appliance_vlan_settings",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00022909999825060368,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.41092959999878076,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00011660000018309802,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_create_network_appliance_vlan",
+ "lineno": 125,
+ "outcome": "passed",
+ "keywords": [
+ "test_create_network_appliance_vlan",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00021700000070268288,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 1.2798312999984773,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.0001354000014543999,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_update_l3_firewall_rules",
+ "lineno": 144,
+ "outcome": "passed",
+ "keywords": [
+ "test_update_l3_firewall_rules",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0001785000022209715,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.7769468000005872,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00010830000246642157,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_delete_policy_objects",
+ "lineno": 183,
+ "outcome": "passed",
+ "keywords": [
+ "test_delete_policy_objects",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0003133000027446542,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 1.5033273000008194,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00012889999925391749,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_client_crud_lifecycle_async.py::test_delete_network",
+ "lineno": 200,
+ "outcome": "passed",
+ "keywords": [
+ "test_delete_network",
+ "test_client_crud_lifecycle_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.00017279999883612618,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 9.322395800001686,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00013279999984661117,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_org_wide_workflows.py::test_sync_org_wide_clients_workflow",
+ "lineno": 8,
+ "outcome": "passed",
+ "keywords": [
+ "test_sync_org_wide_clients_workflow",
+ "test_org_wide_workflows.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0001756999990902841,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 0.839036400000623,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00014450000162469223,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_org_wide_workflows.py::test_async_org_wide_clients_workflow",
+ "lineno": 31,
+ "outcome": "passed",
+ "keywords": [
+ "test_async_org_wide_clients_workflow",
+ "asyncio",
+ "pytestmark",
+ "test_org_wide_workflows.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0008937999991758261,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 1.0785622999974294,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.0010290999998687766,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_iterator_sync.py::test_iterator_sync_full_lifecycle",
+ "lineno": 25,
+ "outcome": "passed",
+ "keywords": [
+ "test_iterator_sync_full_lifecycle",
+ "test_iterator_sync.py",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0001694999991741497,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 32.9232955999978,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.00015500000154133886,
+ "outcome": "passed"
+ }
+ },
+ {
+ "nodeid": "tests/integration/test_iterator_async.py::test_iterator_async_full_lifecycle",
+ "lineno": 29,
+ "outcome": "passed",
+ "keywords": [
+ "test_iterator_async_full_lifecycle",
+ "test_iterator_async.py",
+ "asyncio",
+ "integration",
+ "tests",
+ "dashboard-api-python",
+ ""
+ ],
+ "setup": {
+ "duration": 0.0010623999987728894,
+ "outcome": "passed"
+ },
+ "call": {
+ "duration": 33.057837699998345,
+ "outcome": "passed"
+ },
+ "teardown": {
+ "duration": 0.002345099997910438,
+ "outcome": "passed"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index ef234fc4..efb553b9 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -1,3 +1,37 @@
+import pytest
+
+FILE_ORDER = [
+ "test_client_crud_lifecycle_sync.py",
+ "test_client_crud_lifecycle_async.py",
+ "test_lifecycle_sync.py",
+ "test_lifecycle_async.py",
+ "test_org_wide_workflows.py",
+ "test_iterator_sync.py",
+ "test_iterator_async.py",
+]
+
+
+def pytest_collection_modifyitems(items):
+ def sort_key(item):
+ filename = item.fspath.basename
+ try:
+ return FILE_ORDER.index(filename)
+ except ValueError:
+ return len(FILE_ORDER)
+
+ items.sort(key=sort_key)
+
+
def pytest_addoption(parser):
parser.addoption("--apikey", action="store", default="")
parser.addoption("--o", action="store", default="")
+
+
+@pytest.fixture(scope="session")
+def api_key(pytestconfig):
+ return pytestconfig.getoption("apikey")
+
+
+@pytest.fixture(scope="session")
+def org_id(pytestconfig):
+ return pytestconfig.getoption("o")
diff --git a/tests/integration/test_async_dashboard_api.py b/tests/integration/test_async_dashboard_api.py
deleted file mode 100644
index 59bce163..00000000
--- a/tests/integration/test_async_dashboard_api.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import pytest
-
-import meraki.aio
-
-
-@pytest.fixture(scope="session")
-def api_key(pytestconfig):
- return pytestconfig.getoption("apikey")
-
-
-@pytest.fixture(scope="session")
-def org_id(pytestconfig):
- return pytestconfig.getoption("o")
-
-
-@pytest.mark.asyncio
-async def test_get_organizations(api_key):
- async with meraki.aio.AsyncDashboardAPI(
- api_key,
- suppress_logging=True,
- maximum_retries=1000,
- caller="PytestForPythonLibrary Meraki",
- ) as dashboard:
- organizations = await dashboard.organizations.getOrganizations()
- assert organizations is not None
- assert len(organizations) > 0
-
-
-@pytest.mark.asyncio
-async def test_get_organization(api_key, org_id):
- async with meraki.aio.AsyncDashboardAPI(
- api_key,
- suppress_logging=True,
- maximum_retries=1000,
- caller="PytestForPythonLibrary Meraki",
- ) as dashboard:
- organization = await dashboard.organizations.getOrganization(org_id)
- assert isinstance(organization, dict)
- assert isinstance(organization["id"], str)
-
-
-@pytest.mark.asyncio
-async def test_get_organization_networks(api_key, org_id):
- async with meraki.aio.AsyncDashboardAPI(
- api_key,
- suppress_logging=True,
- maximum_retries=1000,
- caller="PytestForPythonLibrary Meraki",
- ) as dashboard:
- networks = await dashboard.organizations.getOrganizationNetworks(org_id)
- assert networks is not None
- assert len(networks) > 0
diff --git a/tests/integration/test_client_crud_lifecycle_async.py b/tests/integration/test_client_crud_lifecycle_async.py
new file mode 100644
index 00000000..50668413
--- /dev/null
+++ b/tests/integration/test_client_crud_lifecycle_async.py
@@ -0,0 +1,250 @@
+import platform
+import random
+
+import pytest
+import pytest_asyncio
+
+import meraki.aio
+
+pytestmark = pytest.mark.asyncio(loop_scope="session")
+
+
+@pytest.fixture(scope="session")
+def version_salt():
+ python_version = platform.python_version()
+ salt = str(random.randint(1, 17381738))
+ return f"{python_version} {salt}"
+
+
+@pytest_asyncio.fixture(scope="session", loop_scope="session")
+async def dashboard(api_key):
+ async with meraki.aio.AsyncDashboardAPI(
+ api_key,
+ suppress_logging=True,
+ network_delete_retry_wait_time=1000,
+ maximum_retries=1000,
+ caller="PythonSDKTest Cisco",
+ ) as dashboard:
+ yield dashboard
+
+
+@pytest_asyncio.fixture(scope="session", loop_scope="session")
+async def network(dashboard, org_id, version_salt):
+ name = f"_GitHubAction Test Network {version_salt}"
+ product_types = ["appliance", "switch", "wireless", "systemsManager", "sensor"]
+ network_kwargs = {
+ "tags": ["test_tag", "github", "shouldBeDeleted"],
+ "timezone": "America/Los_Angeles",
+ }
+
+ created_network = await dashboard.organizations.createOrganizationNetwork(org_id, name, product_types, **network_kwargs)
+ yield created_network
+
+
+async def test_get_administered_identities_me(dashboard):
+ me = await dashboard.administered.getAdministeredIdentitiesMe()
+ assert me is not None
+ assert isinstance(me["name"], str)
+ assert me["authentication"]["api"]["key"]["created"]
+
+
+async def test_get_organizations(dashboard):
+ organizations = await dashboard.organizations.getOrganizations()
+ assert organizations is not None
+ assert len(organizations) > 0
+
+
+async def test_get_organization(dashboard, org_id):
+ organization = await dashboard.organizations.getOrganization(org_id)
+ assert isinstance(organization, dict)
+ assert isinstance(organization["id"], str)
+
+
+async def test_create_network(dashboard, org_id, network, version_salt):
+ assert network is not None
+ assert network["name"] == f"_GitHubAction Test Network {version_salt}"
+
+
+async def test_get_networks(dashboard, org_id):
+ networks = await dashboard.organizations.getOrganizationNetworks(org_id)
+ assert networks is not None
+ assert len(networks) > 0
+
+
+async def test_update_network(dashboard, network):
+ new_name = f"{network['name']} new"
+ updated_network_data = {
+ "name": new_name,
+ "tags": ["updated_test_tag", "github", "shouldBeDeleted"],
+ }
+ updated_network = await dashboard.networks.updateNetwork(network["id"], **updated_network_data)
+ assert updated_network is not None
+ assert updated_network["name"] == new_name
+
+
+async def test_create_organization_policy_objects(dashboard, org_id, network, version_salt):
+ policy_objects = [
+ {
+ "name": f"Ham {version_salt}".replace(".", "-"),
+ "category": "network",
+ "type": "cidr",
+ "cidr": "10.51.1.253",
+ "networkIds": [network["id"]],
+ },
+ {
+ "name": f"Hamlet {version_salt}".replace(".", "-"),
+ "category": "network",
+ "type": "cidr",
+ "cidr": "10.17.38.0/24",
+ },
+ ]
+
+ for policy_object in policy_objects:
+ new_object = await dashboard.organizations.createOrganizationPolicyObject(org_id, **policy_object)
+ assert new_object is not None
+ assert isinstance(new_object["id"], str)
+
+
+async def test_get_organization_policy_objects(dashboard, org_id):
+ policy_objects = await dashboard.organizations.getOrganizationPolicyObjects(org_id)
+ assert policy_objects is not None
+ assert len(policy_objects) > 0
+
+
+async def test_get_network_appliance_l3_firewall_rules(dashboard, network):
+ rules = await dashboard.appliance.getNetworkApplianceFirewallL3FirewallRules(network["id"])
+ assert rules is not None
+ assert len(rules) > 0
+
+
+async def test_update_network_appliance_vlan_settings(dashboard, network):
+ response = await dashboard.appliance.updateNetworkApplianceVlansSettings(network["id"], vlansEnabled=True)
+ assert response is not None
+ assert response["vlansEnabled"]
+
+
+async def test_create_network_appliance_vlan(dashboard, network):
+ name = "testy_vlan"
+ name2 = "home_base"
+ new_vlans = [
+ {"id": 51, "name": name, "subnet": "10.51.1.0/24", "applianceIp": "10.51.1.1"},
+ {
+ "id": 1738,
+ "name": name2,
+ "subnet": "10.17.38.0/24",
+ "applianceIp": "10.17.38.1",
+ },
+ ]
+ for vlan in new_vlans:
+ new_vlan = await dashboard.appliance.createNetworkApplianceVlan(network["id"], **vlan)
+ assert new_vlan is not None
+ assert len(new_vlan) > 0
+ assert new_vlan["name"] == vlan["name"]
+
+
+async def test_update_l3_firewall_rules(dashboard, org_id, network, version_salt):
+ all_policy_objects = await dashboard.organizations.getOrganizationPolicyObjects(org_id)
+
+ policy_objects = [
+ policy_object for policy_object in all_policy_objects if f"{version_salt}".replace(".", "-") in policy_object["name"]
+ ]
+ new_rules = {
+ "rules": [
+ {
+ "comment": "HamByIP",
+ "policy": "deny",
+ "protocol": "tcp",
+ "srcPort": "1738",
+ "srcCidr": "VLAN(1738).*",
+ "destPort": "1928",
+ "destCidr": f"OBJ({policy_objects[0]['id']})",
+ "syslogEnabled": False,
+ },
+ {
+ "comment": "Ham",
+ "policy": "deny",
+ "protocol": "tcp",
+ "srcPort": "Any",
+ "srcCidr": f"OBJ({policy_objects[1]['id']})",
+ "destPort": "Any",
+ "destCidr": "11.1.1.1/32",
+ "syslogEnabled": False,
+ },
+ ]
+ }
+ updated_rules = (await dashboard.appliance.updateNetworkApplianceFirewallL3FirewallRules(network["id"], **new_rules))[
+ "rules"
+ ]
+ assert updated_rules is not None
+ assert len(updated_rules) == 3
+ assert updated_rules[0]["comment"] == "HamByIP"
+ assert updated_rules[1]["comment"] == "Ham"
+
+
+async def test_delete_policy_objects(dashboard, org_id, version_salt):
+ all_policy_objects = await dashboard.organizations.getOrganizationPolicyObjects(org_id)
+
+ for policy_object in all_policy_objects:
+ if f"{version_salt}".replace(".", "-") in policy_object["name"]:
+ response = await dashboard.organizations.deleteOrganizationPolicyObject(org_id, policy_object["id"])
+ assert response is None
+
+ remaining_policy_objects = await dashboard.organizations.getOrganizationPolicyObjects(org_id)
+ missed_policy_objects = [
+ policy_object
+ for policy_object in remaining_policy_objects
+ if f"{version_salt}".replace(".", "-") in policy_object["name"]
+ ]
+ assert len(missed_policy_objects) == 0
+
+
+async def test_delete_network(dashboard, org_id, network):
+ import asyncio
+ from meraki.api.batch.networks import ActionBatchNetworks
+ from meraki.exceptions import APIError
+
+ action = ActionBatchNetworks().deleteNetwork(network["id"])
+ max_attempts = 5
+
+ for attempt in range(1, max_attempts + 1):
+ delay = 2**attempt
+
+ await asyncio.sleep(delay)
+ try:
+ batch = await dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id,
+ actions=[action],
+ confirmed=False,
+ synchronous=False,
+ )
+ except APIError as e:
+ # Action batches are asynchronous: a prior attempt's batch can
+ # finish deleting the network after we stopped observing it. The
+ # network being gone is the desired end state, so a "not found"
+ # here means the delete already succeeded.
+ if e.status == 400 and "not found" in str(e.message).lower():
+ return
+ raise
+ assert batch is not None
+ assert batch["id"]
+
+ await asyncio.sleep(delay)
+ await dashboard.organizations.updateOrganizationActionBatch(
+ organizationId=org_id,
+ actionBatchId=batch["id"],
+ confirmed=True,
+ )
+
+ for _ in range(10):
+ await asyncio.sleep(delay)
+ status = await dashboard.organizations.getOrganizationActionBatch(
+ organizationId=org_id,
+ actionBatchId=batch["id"],
+ )
+ if status["status"]["completed"]:
+ return
+ if status["status"]["failed"]:
+ break
+
+ if attempt == max_attempts:
+ pytest.fail(f"Action batch failed after {max_attempts} attempts")
diff --git a/tests/integration/test_dashboard_api_python_library.py b/tests/integration/test_client_crud_lifecycle_sync.py
similarity index 73%
rename from tests/integration/test_dashboard_api_python_library.py
rename to tests/integration/test_client_crud_lifecycle_sync.py
index 0c3778b8..9f003b67 100644
--- a/tests/integration/test_dashboard_api_python_library.py
+++ b/tests/integration/test_client_crud_lifecycle_sync.py
@@ -6,12 +6,6 @@
import meraki
-@pytest.fixture(scope="session")
-def api_key(pytestconfig):
- # Replace with a valid Meraki API key
- return pytestconfig.getoption("apikey")
-
-
@pytest.fixture(scope="session")
def dashboard(api_key):
return meraki.DashboardAPI(
@@ -19,7 +13,7 @@ def dashboard(api_key):
suppress_logging=True,
network_delete_retry_wait_time=1000,
maximum_retries=1000,
- caller="PytestForPythonLibrary Meraki",
+ caller="PythonSDKTest Cisco",
)
@@ -46,9 +40,7 @@ def network(dashboard, org_id, version_salt):
"timezone": "America/Los_Angeles",
}
- created_network = dashboard.organizations.createOrganizationNetwork(
- org_id, name, product_types, **network_kwargs
- )
+ created_network = dashboard.organizations.createOrganizationNetwork(org_id, name, product_types, **network_kwargs)
yield created_network
@@ -89,9 +81,7 @@ def test_update_network(dashboard, network):
"name": new_name,
"tags": ["updated_test_tag", "github", "shouldBeDeleted"],
}
- updated_network = dashboard.networks.updateNetwork(
- network["id"], **updated_network_data
- )
+ updated_network = dashboard.networks.updateNetwork(network["id"], **updated_network_data)
assert updated_network is not None
assert updated_network["name"] == new_name
@@ -114,9 +104,7 @@ def test_create_organization_policy_objects(dashboard, org_id, network, version_
]
for policy_object in policy_objects:
- new_object = dashboard.organizations.createOrganizationPolicyObject(
- org_id, **policy_object
- )
+ new_object = dashboard.organizations.createOrganizationPolicyObject(org_id, **policy_object)
assert new_object is not None
assert isinstance(new_object["id"], str)
@@ -128,17 +116,13 @@ def test_get_organization_policy_objects(dashboard, org_id):
def test_get_network_appliance_l3_firewall_rules(dashboard, network):
- rules = dashboard.appliance.getNetworkApplianceFirewallL3FirewallRules(
- network["id"]
- )
+ rules = dashboard.appliance.getNetworkApplianceFirewallL3FirewallRules(network["id"])
assert rules is not None
assert len(rules) > 0
def test_update_network_appliance_vlan_settings(dashboard, network):
- response = dashboard.appliance.updateNetworkApplianceVlansSettings(
- network["id"], vlansEnabled=True
- )
+ response = dashboard.appliance.updateNetworkApplianceVlansSettings(network["id"], vlansEnabled=True)
assert response is not None
assert response["vlansEnabled"]
@@ -168,9 +152,7 @@ def test_update_l3_firewall_rules(dashboard, org_id, network, version_salt):
# only interact with the ones created for this test run
policy_objects = [
- policy_object
- for policy_object in all_policy_objects
- if f"{version_salt}".replace(".", "-") in policy_object["name"]
+ policy_object for policy_object in all_policy_objects if f"{version_salt}".replace(".", "-") in policy_object["name"]
]
new_rules = {
"rules": [
@@ -196,9 +178,7 @@ def test_update_l3_firewall_rules(dashboard, org_id, network, version_salt):
},
]
}
- updated_rules = dashboard.appliance.updateNetworkApplianceFirewallL3FirewallRules(
- network["id"], **new_rules
- )["rules"]
+ updated_rules = dashboard.appliance.updateNetworkApplianceFirewallL3FirewallRules(network["id"], **new_rules)["rules"]
assert updated_rules is not None
assert len(updated_rules) == 3
assert updated_rules[0]["comment"] == "HamByIP"
@@ -212,15 +192,11 @@ def test_delete_policy_objects(dashboard, org_id, version_salt):
# only interact with the ones this test run created
for policy_object in all_policy_objects:
if f"{version_salt}".replace(".", "-") in policy_object["name"]:
- response = dashboard.organizations.deleteOrganizationPolicyObject(
- org_id, policy_object["id"]
- )
+ response = dashboard.organizations.deleteOrganizationPolicyObject(org_id, policy_object["id"])
assert response is None
# ensure this one's policy objects are cleaned up
- remaining_policy_objects = dashboard.organizations.getOrganizationPolicyObjects(
- org_id
- )
+ remaining_policy_objects = dashboard.organizations.getOrganizationPolicyObjects(org_id)
missed_policy_objects = [
policy_object
for policy_object in remaining_policy_objects
@@ -229,6 +205,53 @@ def test_delete_policy_objects(dashboard, org_id, version_salt):
assert len(missed_policy_objects) == 0
-def test_delete_network(dashboard, network):
- response = dashboard.networks.deleteNetwork(network["id"])
- assert response is None
+def test_delete_network(dashboard, org_id, network):
+ import time
+ from meraki.api.batch.networks import ActionBatchNetworks
+ from meraki.exceptions import APIError
+
+ action = ActionBatchNetworks().deleteNetwork(network["id"])
+ max_attempts = 5
+
+ for attempt in range(1, max_attempts + 1):
+ delay = 2**attempt
+
+ time.sleep(delay)
+ try:
+ batch = dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id,
+ actions=[action],
+ confirmed=False,
+ synchronous=False,
+ )
+ except APIError as e:
+ # Action batches are asynchronous: a prior attempt's batch can
+ # finish deleting the network after we stopped observing it. The
+ # network being gone is the desired end state, so a "not found"
+ # here means the delete already succeeded.
+ if e.status == 400 and "not found" in str(e.message).lower():
+ return
+ raise
+ assert batch is not None
+ assert batch["id"]
+
+ time.sleep(delay)
+ dashboard.organizations.updateOrganizationActionBatch(
+ organizationId=org_id,
+ actionBatchId=batch["id"],
+ confirmed=True,
+ )
+
+ for _ in range(10):
+ time.sleep(delay)
+ status = dashboard.organizations.getOrganizationActionBatch(
+ organizationId=org_id,
+ actionBatchId=batch["id"],
+ )
+ if status["status"]["completed"]:
+ return
+ if status["status"]["failed"]:
+ break
+
+ if attempt == max_attempts:
+ pytest.fail(f"Action batch failed after {max_attempts} attempts")
diff --git a/tests/integration/test_iterator_async.py b/tests/integration/test_iterator_async.py
new file mode 100644
index 00000000..a28eaa18
--- /dev/null
+++ b/tests/integration/test_iterator_async.py
@@ -0,0 +1,142 @@
+import asyncio
+import platform
+import random
+
+import pytest
+
+import meraki.aio
+from meraki.api.batch.organizations import ActionBatchOrganizations
+
+pytestmark = pytest.mark.asyncio(loop_scope="module")
+
+BATCH_SIZE_MAX = 50
+TOTAL_POL_OBJS = 100
+
+
+async def _poll_batch(dashboard, org_id, batch_id, max_checks=10, delay=4):
+ for _ in range(max_checks):
+ await asyncio.sleep(delay)
+ status = await dashboard.organizations.getOrganizationActionBatch(
+ organizationId=org_id,
+ actionBatchId=batch_id,
+ )
+ if status["status"]["completed"]:
+ return True
+ if status["status"]["failed"]:
+ return False
+ return False
+
+
+async def test_iterator_async_full_lifecycle(api_key, org_id):
+ python_version = platform.python_version()
+ salt = str(random.randint(1, 17381738))
+ version_salt = f"{python_version} {salt}".replace(".", "-")
+
+ batch_orgs = ActionBatchOrganizations()
+
+ async with meraki.aio.AsyncDashboardAPI(
+ api_key,
+ suppress_logging=True,
+ maximum_retries=1000,
+ caller="PythonSDKTestIteratorAsync Cisco",
+ ) as dashboard:
+ # Phase 1: Clean up stale action batches
+ batches = await dashboard.organizations.getOrganizationActionBatches(organizationId=org_id)
+ unconfirmed = [b for b in batches if not b["confirmed"]]
+ for b in unconfirmed:
+ await dashboard.organizations.deleteOrganizationActionBatch(organizationId=org_id, actionBatchId=b["id"])
+ remaining = await dashboard.organizations.getOrganizationActionBatches(organizationId=org_id)
+ assert all(b["confirmed"] for b in remaining)
+
+ # Phase 2: Clean up existing policy objects
+ existing_objects = await dashboard.organizations.getOrganizationPolicyObjects(organizationId=org_id, total_pages=-1)
+ if len(existing_objects) > 0:
+ delete_actions = [
+ batch_orgs.deleteOrganizationPolicyObject(organizationId=org_id, policyObjectId=obj["id"])
+ for obj in existing_objects
+ ]
+ for i in range(0, len(delete_actions), BATCH_SIZE_MAX):
+ chunk = delete_actions[i : i + BATCH_SIZE_MAX]
+ batch = await dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id, actions=chunk, synchronous=False, confirmed=True
+ )
+ assert await _poll_batch(dashboard, org_id, batch["id"])
+
+ leftover = await dashboard.organizations.getOrganizationPolicyObjects(organizationId=org_id, total_pages=-1)
+ assert len(leftover) == 0
+
+ # Phase 3: Create policy objects
+ create_actions = [
+ batch_orgs.createOrganizationPolicyObject(
+ organizationId=org_id,
+ name=f"_test_iter_{version_salt}_{i:03d}",
+ category="network",
+ type="cidr",
+ cidr=f"10.{i // 256}.{i % 256}.0/24",
+ )
+ for i in range(TOTAL_POL_OBJS)
+ ]
+
+ create_batch_ids = []
+ for i in range(0, len(create_actions), BATCH_SIZE_MAX):
+ chunk = create_actions[i : i + BATCH_SIZE_MAX]
+ batch = await dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id, actions=chunk, synchronous=False, confirmed=True
+ )
+ assert batch["id"]
+ create_batch_ids.append(batch["id"])
+
+ # Phase 4: Poll until all create batches complete
+ for batch_id in create_batch_ids:
+ assert await _poll_batch(dashboard, org_id, batch_id), f"Create batch {batch_id} did not complete"
+
+ # Phase 5: Test pagination iterator vs legacy
+ async with (
+ meraki.aio.AsyncDashboardAPI(
+ api_key,
+ suppress_logging=True,
+ maximum_retries=5,
+ use_iterator_for_get_pages=False,
+ caller="PythonSDKTestIteratorAsync Cisco",
+ ) as dashboard_legacy,
+ meraki.aio.AsyncDashboardAPI(
+ api_key,
+ suppress_logging=True,
+ maximum_retries=5,
+ use_iterator_for_get_pages=True,
+ caller="PythonSDKTestIteratorAsync Cisco",
+ ) as dashboard_iterator,
+ ):
+ legacy_objects = await dashboard_legacy.organizations.getOrganizationPolicyObjects(
+ org_id, perPage=10, total_pages=-1
+ )
+ legacy_ids = {o["id"] for o in legacy_objects if o["name"].startswith(f"_test_iter_{version_salt}")}
+
+ iterator_ids = set()
+ async for obj in dashboard_iterator.organizations.getOrganizationPolicyObjects(org_id, perPage=10, total_pages=-1):
+ if obj["name"].startswith(f"_test_iter_{version_salt}"):
+ iterator_ids.add(obj["id"])
+
+ assert legacy_ids == iterator_ids
+ assert len(legacy_ids) >= TOTAL_POL_OBJS
+
+ # Phase 6: Delete policy objects
+ all_objects = await dashboard.organizations.getOrganizationPolicyObjects(organizationId=org_id, total_pages=-1)
+ test_objects = [o for o in all_objects if o["name"].startswith(f"_test_iter_{version_salt}")]
+
+ delete_actions = [
+ batch_orgs.deleteOrganizationPolicyObject(organizationId=org_id, policyObjectId=obj["id"]) for obj in test_objects
+ ]
+
+ delete_batch_ids = []
+ for i in range(0, len(delete_actions), BATCH_SIZE_MAX):
+ chunk = delete_actions[i : i + BATCH_SIZE_MAX]
+ batch = await dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id, actions=chunk, synchronous=False, confirmed=True
+ )
+ assert batch["id"]
+ delete_batch_ids.append(batch["id"])
+
+ # Phase 7: Poll until all delete batches complete
+ for batch_id in delete_batch_ids:
+ assert await _poll_batch(dashboard, org_id, batch_id), f"Delete batch {batch_id} did not complete"
diff --git a/tests/integration/test_iterator_sync.py b/tests/integration/test_iterator_sync.py
new file mode 100644
index 00000000..3ba7a679
--- /dev/null
+++ b/tests/integration/test_iterator_sync.py
@@ -0,0 +1,135 @@
+import platform
+import random
+import time
+
+import meraki
+from meraki.api.batch.organizations import ActionBatchOrganizations
+
+BATCH_SIZE_MAX = 50
+TOTAL_POL_OBJS = 100
+
+
+def _poll_batch(dashboard, org_id, batch_id, max_checks=10, delay=4):
+ for _ in range(max_checks):
+ time.sleep(delay)
+ status = dashboard.organizations.getOrganizationActionBatch(
+ organizationId=org_id,
+ actionBatchId=batch_id,
+ )
+ if status["status"]["completed"]:
+ return True
+ if status["status"]["failed"]:
+ return False
+ return False
+
+
+def test_iterator_sync_full_lifecycle(api_key, org_id):
+ python_version = platform.python_version()
+ salt = str(random.randint(1, 17381738))
+ version_salt = f"{python_version} {salt}".replace(".", "-")
+
+ dashboard = meraki.DashboardAPI(
+ api_key,
+ suppress_logging=True,
+ maximum_retries=1000,
+ caller="PythonSDKTestIteratorSync Cisco",
+ )
+ batch_orgs = ActionBatchOrganizations()
+
+ # Phase 1: Clean up stale action batches
+ batches = dashboard.organizations.getOrganizationActionBatches(organizationId=org_id)
+ unconfirmed = [b for b in batches if not b["confirmed"]]
+ for b in unconfirmed:
+ dashboard.organizations.deleteOrganizationActionBatch(organizationId=org_id, actionBatchId=b["id"])
+ remaining = dashboard.organizations.getOrganizationActionBatches(organizationId=org_id)
+ assert all(b["confirmed"] for b in remaining)
+
+ # Phase 2: Clean up existing policy objects
+ existing_objects = dashboard.organizations.getOrganizationPolicyObjects(organizationId=org_id, total_pages=-1)
+ if len(existing_objects) > 0:
+ delete_actions = [
+ batch_orgs.deleteOrganizationPolicyObject(organizationId=org_id, policyObjectId=obj["id"])
+ for obj in existing_objects
+ ]
+ for i in range(0, len(delete_actions), BATCH_SIZE_MAX):
+ chunk = delete_actions[i : i + BATCH_SIZE_MAX]
+ batch = dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id, actions=chunk, synchronous=False, confirmed=True
+ )
+ assert _poll_batch(dashboard, org_id, batch["id"])
+
+ leftover = dashboard.organizations.getOrganizationPolicyObjects(organizationId=org_id, total_pages=-1)
+ assert len(leftover) == 0
+
+ # Phase 3: Create policy objects
+ create_actions = [
+ batch_orgs.createOrganizationPolicyObject(
+ organizationId=org_id,
+ name=f"_test_iter_{version_salt}_{i:03d}",
+ category="network",
+ type="cidr",
+ cidr=f"10.{i // 256}.{i % 256}.0/24",
+ )
+ for i in range(TOTAL_POL_OBJS)
+ ]
+
+ create_batch_ids = []
+ for i in range(0, len(create_actions), BATCH_SIZE_MAX):
+ chunk = create_actions[i : i + BATCH_SIZE_MAX]
+ batch = dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id, actions=chunk, synchronous=False, confirmed=True
+ )
+ assert batch["id"]
+ create_batch_ids.append(batch["id"])
+
+ # Phase 4: Poll until all create batches complete
+ for batch_id in create_batch_ids:
+ assert _poll_batch(dashboard, org_id, batch_id), f"Create batch {batch_id} did not complete"
+
+ # Phase 5: Test pagination iterator vs legacy
+ dashboard_legacy = meraki.DashboardAPI(
+ api_key,
+ suppress_logging=True,
+ maximum_retries=5,
+ use_iterator_for_get_pages=False,
+ caller="PythonSDKTestIteratorSync Cisco",
+ )
+ dashboard_iterator = meraki.DashboardAPI(
+ api_key,
+ suppress_logging=True,
+ maximum_retries=5,
+ use_iterator_for_get_pages=True,
+ caller="PythonSDKTestIteratorSync Cisco",
+ )
+
+ legacy_objects = dashboard_legacy.organizations.getOrganizationPolicyObjects(org_id, perPage=10, total_pages=-1)
+ legacy_ids = {o["id"] for o in legacy_objects if o["name"].startswith(f"_test_iter_{version_salt}")}
+
+ iterator_ids = set()
+ for obj in dashboard_iterator.organizations.getOrganizationPolicyObjects(org_id, perPage=10, total_pages=-1):
+ if obj["name"].startswith(f"_test_iter_{version_salt}"):
+ iterator_ids.add(obj["id"])
+
+ assert legacy_ids == iterator_ids
+ assert len(legacy_ids) >= TOTAL_POL_OBJS
+
+ # Phase 6: Delete policy objects
+ all_objects = dashboard.organizations.getOrganizationPolicyObjects(organizationId=org_id, total_pages=-1)
+ test_objects = [o for o in all_objects if o["name"].startswith(f"_test_iter_{version_salt}")]
+
+ delete_actions = [
+ batch_orgs.deleteOrganizationPolicyObject(organizationId=org_id, policyObjectId=obj["id"]) for obj in test_objects
+ ]
+
+ delete_batch_ids = []
+ for i in range(0, len(delete_actions), BATCH_SIZE_MAX):
+ chunk = delete_actions[i : i + BATCH_SIZE_MAX]
+ batch = dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id, actions=chunk, synchronous=False, confirmed=True
+ )
+ assert batch["id"]
+ delete_batch_ids.append(batch["id"])
+
+ # Phase 7: Poll until all delete batches complete
+ for batch_id in delete_batch_ids:
+ assert _poll_batch(dashboard, org_id, batch_id), f"Delete batch {batch_id} did not complete"
diff --git a/tests/integration/test_lifecycle_async.py b/tests/integration/test_lifecycle_async.py
new file mode 100644
index 00000000..10aa03fc
--- /dev/null
+++ b/tests/integration/test_lifecycle_async.py
@@ -0,0 +1,190 @@
+import asyncio
+import hashlib
+import platform
+import random
+import time
+
+import pytest
+import pytest_asyncio
+
+import meraki.aio
+from meraki.api.batch.networks import ActionBatchNetworks
+
+pytestmark = pytest.mark.asyncio(loop_scope="session")
+
+
+def _salt():
+ return hashlib.md5(str(time.time()).encode()).hexdigest()[:6]
+
+
+@pytest.fixture(scope="session")
+def version_salt():
+ python_version = platform.python_version()
+ salt = str(random.randint(1, 17381738))
+ return f"{python_version} {salt}"
+
+
+@pytest_asyncio.fixture(scope="session", loop_scope="session")
+async def dashboard(api_key):
+ async with meraki.aio.AsyncDashboardAPI(
+ api_key,
+ suppress_logging=True,
+ network_delete_retry_wait_time=1000,
+ maximum_retries=1000,
+ caller="PythonSDKTest Cisco",
+ ) as dashboard:
+ yield dashboard
+
+
+@pytest_asyncio.fixture(scope="session", loop_scope="session")
+async def network(dashboard, org_id, version_salt):
+ name = f"_GitHubAction Test Lifecycle Network {version_salt}"
+ product_types = ["wireless", "appliance"]
+ network_kwargs = {
+ "tags": ["test_tag", "github", "shouldBeDeleted"],
+ "timezone": "America/Los_Angeles",
+ }
+
+ created_network = await dashboard.organizations.createOrganizationNetwork(org_id, name, product_types, **network_kwargs)
+ yield created_network
+
+ action = ActionBatchNetworks().deleteNetwork(created_network["id"])
+ for attempt in range(1, 6):
+ delay = 2**attempt
+ await asyncio.sleep(delay)
+ batch = await dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id, actions=[action], confirmed=True, synchronous=False
+ )
+ for _ in range(10):
+ await asyncio.sleep(delay)
+ status = await dashboard.organizations.getOrganizationActionBatch(org_id, batch["id"])
+ if status["status"]["completed"]:
+ return
+ if status["status"]["failed"]:
+ break
+ if attempt == 5:
+ pytest.fail("Network cleanup action batch failed after 5 attempts")
+
+
+async def test_policy_object(dashboard, org_id):
+ salt = _salt()
+ obj = await dashboard.organizations.createOrganizationPolicyObject(
+ org_id, name=f"test-policy-object-{salt}", category="network", type="cidr", cidr="10.0.0.0/24"
+ )
+ assert isinstance(obj["id"], str)
+
+ await dashboard.organizations.updateOrganizationPolicyObject(org_id, obj["id"], name=f"test-policy-object-{salt}-r")
+ await dashboard.organizations.deleteOrganizationPolicyObject(org_id, obj["id"])
+
+
+async def test_policy_object_group(dashboard, org_id):
+ salt = _salt()
+ group = await dashboard.organizations.createOrganizationPolicyObjectsGroup(
+ org_id, name=f"test-policy-group-{salt}", category="NetworkObjectGroup"
+ )
+ assert isinstance(group["id"], str)
+
+ await dashboard.organizations.updateOrganizationPolicyObjectsGroup(org_id, group["id"], name=f"test-policy-group-{salt}-r")
+ await dashboard.organizations.deleteOrganizationPolicyObjectsGroup(org_id, group["id"])
+
+
+async def test_alerts_profile(dashboard, org_id):
+ salt = _salt()
+ profile = await dashboard.organizations.createOrganizationAlertsProfile(
+ org_id,
+ type="wanUtilization",
+ alertCondition={"duration": 60, "window": 600, "bit_rate_bps": 1000000, "interface": "wan1"},
+ recipients={"emails": ["test@example.com"]},
+ networkTags=["__all_tags__"],
+ description=f"test-alert-profile-{salt}",
+ )
+ assert isinstance(profile["id"], str)
+
+ await dashboard.organizations.updateOrganizationAlertsProfile(
+ org_id, profile["id"], description=f"test-alert-profile-{salt}-r"
+ )
+ await dashboard.organizations.deleteOrganizationAlertsProfile(org_id, profile["id"])
+
+
+async def test_group_policy(dashboard, network):
+ salt = _salt()
+ gp = await dashboard.networks.createNetworkGroupPolicy(network["id"], name=f"test-group-policy-{salt}")
+ assert "groupPolicyId" in gp
+
+ await dashboard.networks.updateNetworkGroupPolicy(network["id"], gp["groupPolicyId"], name=f"test-group-policy-{salt}-r")
+ await dashboard.networks.deleteNetworkGroupPolicy(network["id"], gp["groupPolicyId"])
+
+
+async def test_mqtt_broker(dashboard, network):
+ salt = _salt()
+ broker = await dashboard.networks.createNetworkMqttBroker(
+ network["id"], name=f"test-mqtt-broker-{salt}", host="mqtt.example.com", port=1883
+ )
+ assert isinstance(broker["id"], str)
+
+ await dashboard.networks.updateNetworkMqttBroker(network["id"], broker["id"], name=f"test-mqtt-broker-{salt}-r")
+ await dashboard.networks.deleteNetworkMqttBroker(network["id"], broker["id"])
+
+
+async def test_webhook_http_server(dashboard, network):
+ salt = _salt()
+ server = await dashboard.networks.createNetworkWebhooksHttpServer(
+ network["id"], name=f"test-webhook-server-{salt}", url="https://example.com/webhook"
+ )
+ assert isinstance(server["id"], str)
+
+ await dashboard.networks.updateNetworkWebhooksHttpServer(network["id"], server["id"], name=f"test-webhook-server-{salt}-r")
+ await dashboard.networks.deleteNetworkWebhooksHttpServer(network["id"], server["id"])
+
+
+async def test_webhook_payload_template(dashboard, network):
+ salt = _salt()
+ template = await dashboard.networks.createNetworkWebhooksPayloadTemplate(
+ network["id"],
+ name=f"test-payload-template-{salt}",
+ body='{"event": "{{alertType}}", "network": "{{networkName}}"}',
+ )
+ assert "payloadTemplateId" in template
+
+ await dashboard.networks.deleteNetworkWebhooksPayloadTemplate(network["id"], template["payloadTemplateId"])
+
+
+async def test_ssid(dashboard, network):
+ ssid = await dashboard.wireless.getNetworkWirelessSsid(network["id"], "0")
+ original_name = ssid["name"]
+
+ await dashboard.wireless.updateNetworkWirelessSsid(network["id"], "0", name="test-ssid-renamed")
+ await dashboard.wireless.updateNetworkWirelessSsid(network["id"], "0", name=original_name)
+
+
+async def test_network_settings(dashboard, network):
+ settings = await dashboard.networks.getNetworkSettings(network["id"])
+ original = settings.get("localStatusPageEnabled", True)
+
+ await dashboard.networks.updateNetworkSettings(network["id"], localStatusPageEnabled=not original)
+ await dashboard.networks.updateNetworkSettings(network["id"], localStatusPageEnabled=original)
+
+
+async def test_action_batch(dashboard, org_id, network):
+ actions = [
+ {
+ "resource": f"/networks/{network['id']}/wireless/airMarshal/rules",
+ "operation": "create",
+ "body": {"type": "block", "match": {"string": f"test-rule-{i}", "type": "bssid"}},
+ }
+ for i in range(1, 4)
+ ]
+
+ batch = await dashboard.organizations.createOrganizationActionBatch(org_id, actions, confirmed=True, synchronous=False)
+ assert isinstance(batch["id"], str)
+
+ for _ in range(30):
+ await asyncio.sleep(2)
+ status = await dashboard.organizations.getOrganizationActionBatch(org_id, batch["id"])
+ if status["status"]["completed"]:
+ assert len(status["actions"]) == 3
+ return
+ if status["status"]["failed"]:
+ pytest.fail(f"Action batch {batch['id']} failed")
+
+ pytest.fail("Action batch did not complete within timeout")
diff --git a/tests/integration/test_lifecycle_sync.py b/tests/integration/test_lifecycle_sync.py
new file mode 100644
index 00000000..3487b262
--- /dev/null
+++ b/tests/integration/test_lifecycle_sync.py
@@ -0,0 +1,183 @@
+import hashlib
+import platform
+import random
+import time
+
+import pytest
+
+import meraki
+from meraki.api.batch.networks import ActionBatchNetworks
+
+
+def _salt():
+ return hashlib.md5(str(time.time()).encode()).hexdigest()[:6]
+
+
+@pytest.fixture(scope="session")
+def version_salt():
+ python_version = platform.python_version()
+ salt = str(random.randint(1, 17381738))
+ return f"{python_version} {salt}"
+
+
+@pytest.fixture(scope="session")
+def dashboard(api_key):
+ return meraki.DashboardAPI(
+ api_key,
+ suppress_logging=True,
+ network_delete_retry_wait_time=1000,
+ maximum_retries=1000,
+ caller="PythonSDKTest Cisco",
+ )
+
+
+@pytest.fixture(scope="session")
+def network(dashboard, org_id, version_salt):
+ name = f"_GitHubAction Test Lifecycle Network {version_salt}"
+ product_types = ["wireless", "appliance"]
+ network_kwargs = {
+ "tags": ["test_tag", "github", "shouldBeDeleted"],
+ "timezone": "America/Los_Angeles",
+ }
+
+ created_network = dashboard.organizations.createOrganizationNetwork(org_id, name, product_types, **network_kwargs)
+ yield created_network
+
+ action = ActionBatchNetworks().deleteNetwork(created_network["id"])
+ for attempt in range(1, 6):
+ delay = 2**attempt
+ time.sleep(delay)
+ batch = dashboard.organizations.createOrganizationActionBatch(
+ organizationId=org_id, actions=[action], confirmed=True, synchronous=False
+ )
+ for _ in range(10):
+ time.sleep(delay)
+ status = dashboard.organizations.getOrganizationActionBatch(org_id, batch["id"])
+ if status["status"]["completed"]:
+ return
+ if status["status"]["failed"]:
+ break
+ if attempt == 5:
+ pytest.fail("Network cleanup action batch failed after 5 attempts")
+
+
+def test_policy_object(dashboard, org_id):
+ salt = _salt()
+ obj = dashboard.organizations.createOrganizationPolicyObject(
+ org_id, name=f"test-policy-object-{salt}", category="network", type="cidr", cidr="10.0.0.0/24"
+ )
+ assert isinstance(obj["id"], str)
+
+ dashboard.organizations.updateOrganizationPolicyObject(org_id, obj["id"], name=f"test-policy-object-{salt}-r")
+ dashboard.organizations.deleteOrganizationPolicyObject(org_id, obj["id"])
+
+
+def test_policy_object_group(dashboard, org_id):
+ salt = _salt()
+ group = dashboard.organizations.createOrganizationPolicyObjectsGroup(
+ org_id, name=f"test-policy-group-{salt}", category="NetworkObjectGroup"
+ )
+ assert isinstance(group["id"], str)
+
+ dashboard.organizations.updateOrganizationPolicyObjectsGroup(org_id, group["id"], name=f"test-policy-group-{salt}-r")
+ dashboard.organizations.deleteOrganizationPolicyObjectsGroup(org_id, group["id"])
+
+
+def test_alerts_profile(dashboard, org_id):
+ salt = _salt()
+ profile = dashboard.organizations.createOrganizationAlertsProfile(
+ org_id,
+ type="wanUtilization",
+ alertCondition={"duration": 60, "window": 600, "bit_rate_bps": 1000000, "interface": "wan1"},
+ recipients={"emails": ["test@example.com"]},
+ networkTags=["__all_tags__"],
+ description=f"test-alert-profile-{salt}",
+ )
+ assert isinstance(profile["id"], str)
+
+ dashboard.organizations.updateOrganizationAlertsProfile(org_id, profile["id"], description=f"test-alert-profile-{salt}-r")
+ dashboard.organizations.deleteOrganizationAlertsProfile(org_id, profile["id"])
+
+
+def test_group_policy(dashboard, network):
+ salt = _salt()
+ gp = dashboard.networks.createNetworkGroupPolicy(network["id"], name=f"test-group-policy-{salt}")
+ assert "groupPolicyId" in gp
+
+ dashboard.networks.updateNetworkGroupPolicy(network["id"], gp["groupPolicyId"], name=f"test-group-policy-{salt}-r")
+ dashboard.networks.deleteNetworkGroupPolicy(network["id"], gp["groupPolicyId"])
+
+
+def test_mqtt_broker(dashboard, network):
+ salt = _salt()
+ broker = dashboard.networks.createNetworkMqttBroker(
+ network["id"], name=f"test-mqtt-broker-{salt}", host="mqtt.example.com", port=1883
+ )
+ assert isinstance(broker["id"], str)
+
+ dashboard.networks.updateNetworkMqttBroker(network["id"], broker["id"], name=f"test-mqtt-broker-{salt}-r")
+ dashboard.networks.deleteNetworkMqttBroker(network["id"], broker["id"])
+
+
+def test_webhook_http_server(dashboard, network):
+ salt = _salt()
+ server = dashboard.networks.createNetworkWebhooksHttpServer(
+ network["id"], name=f"test-webhook-server-{salt}", url="https://example.com/webhook"
+ )
+ assert isinstance(server["id"], str)
+
+ dashboard.networks.updateNetworkWebhooksHttpServer(network["id"], server["id"], name=f"test-webhook-server-{salt}-r")
+ dashboard.networks.deleteNetworkWebhooksHttpServer(network["id"], server["id"])
+
+
+def test_webhook_payload_template(dashboard, network):
+ salt = _salt()
+ template = dashboard.networks.createNetworkWebhooksPayloadTemplate(
+ network["id"],
+ name=f"test-payload-template-{salt}",
+ body='{"event": "{{alertType}}", "network": "{{networkName}}"}',
+ )
+ assert "payloadTemplateId" in template
+
+ dashboard.networks.deleteNetworkWebhooksPayloadTemplate(network["id"], template["payloadTemplateId"])
+
+
+def test_ssid(dashboard, network):
+ ssid = dashboard.wireless.getNetworkWirelessSsid(network["id"], "0")
+ original_name = ssid["name"]
+
+ dashboard.wireless.updateNetworkWirelessSsid(network["id"], "0", name="test-ssid-renamed")
+ dashboard.wireless.updateNetworkWirelessSsid(network["id"], "0", name=original_name)
+
+
+def test_network_settings(dashboard, network):
+ settings = dashboard.networks.getNetworkSettings(network["id"])
+ original = settings.get("localStatusPageEnabled", True)
+
+ dashboard.networks.updateNetworkSettings(network["id"], localStatusPageEnabled=not original)
+ dashboard.networks.updateNetworkSettings(network["id"], localStatusPageEnabled=original)
+
+
+def test_action_batch(dashboard, org_id, network):
+ actions = [
+ {
+ "resource": f"/networks/{network['id']}/wireless/airMarshal/rules",
+ "operation": "create",
+ "body": {"type": "block", "match": {"string": f"test-rule-{i}", "type": "bssid"}},
+ }
+ for i in range(1, 4)
+ ]
+
+ batch = dashboard.organizations.createOrganizationActionBatch(org_id, actions, confirmed=True, synchronous=False)
+ assert isinstance(batch["id"], str)
+
+ for _ in range(30):
+ time.sleep(2)
+ status = dashboard.organizations.getOrganizationActionBatch(org_id, batch["id"])
+ if status["status"]["completed"]:
+ assert len(status["actions"]) == 3
+ return
+ if status["status"]["failed"]:
+ pytest.fail(f"Action batch {batch['id']} failed")
+
+ pytest.fail("Action batch did not complete within timeout")
diff --git a/tests/integration/test_org_wide_workflows.py b/tests/integration/test_org_wide_workflows.py
new file mode 100644
index 00000000..6a7d18ee
--- /dev/null
+++ b/tests/integration/test_org_wide_workflows.py
@@ -0,0 +1,60 @@
+import asyncio
+
+import pytest
+
+import meraki
+import meraki.aio
+
+
+def test_sync_org_wide_clients_workflow(api_key, org_id):
+ """Multi-endpoint chain: orgs -> networks -> clients."""
+ dashboard = meraki.DashboardAPI(
+ api_key,
+ suppress_logging=True,
+ maximum_retries=5,
+ caller="PythonSDKTestOrgWideWorkflows Cisco",
+ )
+
+ networks = dashboard.organizations.getOrganizationNetworks(org_id, total_pages="all", perPage=1000)
+ assert isinstance(networks, list)
+ assert len(networks) > 0
+
+ # Take first network and fetch its clients
+ network_id = networks[0]["id"]
+ clients = dashboard.networks.getNetworkClients(network_id, timespan=86400, perPage=1000, total_pages="all")
+ assert isinstance(clients, list)
+
+ if len(clients) > 0:
+ assert isinstance(clients[0], dict)
+ assert "mac" in clients[0]
+
+
+@pytest.mark.asyncio
+async def test_async_org_wide_clients_workflow(api_key, org_id):
+ """Concurrent async fetching via asyncio.as_completed."""
+ async with meraki.aio.AsyncDashboardAPI(
+ api_key,
+ suppress_logging=True,
+ maximum_retries=5,
+ caller="PythonSDKTestOrgWideWorkflows Cisco",
+ ) as dashboard:
+ networks = await dashboard.organizations.getOrganizationNetworks(org_id)
+ assert len(networks) > 0
+
+ # Take up to first 3 networks
+ target_networks = networks[:3]
+
+ async def fetch_clients(net_id):
+ return await dashboard.networks.getNetworkClients(net_id, timespan=86400, perPage=1000, total_pages="all")
+
+ tasks = [asyncio.create_task(fetch_clients(n["id"])) for n in target_networks]
+
+ results = []
+ for coro in asyncio.as_completed(tasks):
+ result = await coro
+ results.append(result)
+
+ # All results should be lists
+ assert len(results) == len(target_networks)
+ for result in results:
+ assert isinstance(result, list)
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
new file mode 100644
index 00000000..316aafc8
--- /dev/null
+++ b/tests/unit/conftest.py
@@ -0,0 +1,144 @@
+"""Shared fixtures and helpers for unit tests."""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+
+from meraki.session.sync import RestSession
+
+
+FAKE_API_KEY = "fake_api_key_1234567890123456789012345678901234567890"
+
+DEFAULT_SESSION_KWARGS = {
+ "base_url": "https://api.meraki.com/api/v1",
+ "single_request_timeout": 60,
+ "certificate_path": "",
+ "requests_proxy": "",
+ "wait_on_rate_limit": True,
+ "nginx_429_retry_wait_time": 2,
+ "action_batch_retry_wait_time": 2,
+ "network_delete_retry_wait_time": 2,
+ "retry_4xx_error": False,
+ "retry_4xx_error_wait_time": 1,
+ "maximum_retries": 3,
+ "simulate": False,
+ "be_geo_id": "",
+ "caller": "TestApp TestVendor",
+ "use_iterator_for_get_pages": False,
+}
+
+
+def make_metadata(operation="getOrganizations", tags=None, **extra):
+ meta = {"tags": tags or ["organizations"], "operation": operation}
+ meta.update(extra)
+ return meta
+
+
+def make_mock_response(
+ status_code=200,
+ json_data=None,
+ reason_phrase="OK",
+ headers=None,
+ content=None,
+ links=None,
+):
+ resp = MagicMock(spec=httpx.Response)
+ resp.status_code = status_code
+ resp.reason_phrase = reason_phrase
+ resp.headers = headers or {}
+ resp.links = links or {}
+ if content is not None:
+ resp.content = content
+ else:
+ resp.content = json.dumps(json_data if json_data is not None else {"ok": True}).encode()
+ resp.json.return_value = json_data if json_data is not None else {"ok": True}
+ resp.text = json.dumps(json_data if json_data is not None else {"ok": True})
+ resp.close = MagicMock()
+ return resp
+
+
+def make_async_mock_response(
+ status_code=200,
+ json_data=None,
+ reason_phrase="OK",
+ headers=None,
+ content=None,
+ links=None,
+):
+ resp = make_mock_response(
+ status_code=status_code,
+ json_data=json_data,
+ reason_phrase=reason_phrase,
+ headers=headers,
+ content=content,
+ links=links,
+ )
+ resp.close = MagicMock(side_effect=RuntimeError("Attempted to call sync close on an async stream."))
+ resp.aclose = AsyncMock()
+ return resp
+
+
+def make_sync_session(logger=None, **overrides):
+ kwargs = {**DEFAULT_SESSION_KWARGS, **overrides}
+ with patch("meraki.session.base.check_python_version"):
+ with patch("httpx.Client") as mock_client:
+ mock_instance = MagicMock()
+ mock_instance.headers = MagicMock(spec=dict)
+ mock_client.return_value = mock_instance
+ s = RestSession(logger=logger, api_key=FAKE_API_KEY, **kwargs)
+ if s._smart_flow:
+ s._smart_flow = MagicMock()
+ return s
+
+
+def make_async_session(logger=None, **overrides):
+ kwargs = {"maximum_concurrent_requests": 8, **DEFAULT_SESSION_KWARGS, **overrides}
+ with (
+ patch("meraki.session.base.check_python_version"),
+ patch("httpx.AsyncClient") as mock_client,
+ ):
+ mock_instance = MagicMock()
+ mock_instance.headers = {}
+ mock_instance.request = AsyncMock()
+ mock_client.return_value = mock_instance
+ from meraki.session.async_ import AsyncRestSession
+
+ s = AsyncRestSession(logger=logger, api_key=FAKE_API_KEY, **kwargs)
+ return s
+
+
+# --- Pytest fixtures wrapping the factories ---
+
+
+@pytest.fixture
+def session():
+ return make_sync_session()
+
+
+@pytest.fixture
+def session_with_logger():
+ return make_sync_session(logger=MagicMock())
+
+
+@pytest.fixture
+def async_session():
+ return make_async_session()
+
+
+@pytest.fixture
+def async_session_with_logger():
+ return make_async_session(logger=MagicMock())
+
+
+@pytest.fixture
+def async_session_with_cert(tmp_path):
+ cert_file = tmp_path / "cert.pem"
+ cert_file.write_text("FAKE CERT")
+ return make_async_session(certificate_path=str(cert_file))
+
+
+@pytest.fixture
+def async_session_with_proxy():
+ return make_async_session(requests_proxy="http://proxy:8080")
diff --git a/tests/unit/test_aio_rest_session.py b/tests/unit/test_aio_rest_session.py
index e14f9131..2db7750e 100644
--- a/tests/unit/test_aio_rest_session.py
+++ b/tests/unit/test_aio_rest_session.py
@@ -1,220 +1,27 @@
import json
from unittest.mock import AsyncMock, MagicMock, patch
-import aiohttp
+import httpx
import pytest
-from meraki.exceptions import AsyncAPIError
+from meraki.exceptions import APIError
-
-class _AwaitableValue:
- """A wrapper that makes a value both usable directly and awaitable.
-
- This is needed because the async rest session does `await response.json()`
- while APIError.__init__ calls `response.json()` synchronously.
- """
-
- def __init__(self, value):
- self._value = value
-
- def __await__(self):
- async def _resolve():
- return self._value
-
- return _resolve().__await__()
-
- def __bool__(self):
- return bool(self._value)
-
- def __getitem__(self, key):
- return self._value[key]
-
- def __contains__(self, item):
- return item in self._value
-
- def __eq__(self, other):
- if isinstance(other, _AwaitableValue):
- return self._value == other._value
- return self._value == other
-
- def __repr__(self):
- return repr(self._value)
-
- def keys(self):
- return self._value.keys()
-
- def values(self):
- return self._value.values()
-
- def items(self):
- return self._value.items()
+from tests.unit.conftest import make_metadata as _metadata, make_async_mock_response as _mock_aio_response
async def _noop_sleep(*args, **kwargs):
pass
-@pytest.fixture
-def async_session():
- with (
- patch("meraki.aio.rest_session.check_python_version"),
- patch("aiohttp.ClientSession") as mock_client,
- ):
- mock_client.return_value = MagicMock()
- from meraki.aio.rest_session import AsyncRestSession
-
- s = AsyncRestSession(
- logger=None,
- api_key="fake_api_key_1234567890123456789012345678901234567890",
- base_url="https://api.meraki.com/api/v1",
- single_request_timeout=60,
- certificate_path="",
- requests_proxy="",
- wait_on_rate_limit=True,
- nginx_429_retry_wait_time=2,
- action_batch_retry_wait_time=2,
- network_delete_retry_wait_time=2,
- retry_4xx_error=False,
- retry_4xx_error_wait_time=1,
- maximum_retries=3,
- simulate=False,
- be_geo_id="",
- caller="TestApp TestVendor",
- use_iterator_for_get_pages=False,
- maximum_concurrent_requests=8,
- )
- return s
-
-
-@pytest.fixture
-def async_session_with_logger():
- with (
- patch("meraki.aio.rest_session.check_python_version"),
- patch("aiohttp.ClientSession") as mock_client,
- ):
- mock_client.return_value = MagicMock()
- from meraki.aio.rest_session import AsyncRestSession
-
- logger = MagicMock()
- s = AsyncRestSession(
- logger=logger,
- api_key="fake_api_key_1234567890123456789012345678901234567890",
- base_url="https://api.meraki.com/api/v1",
- single_request_timeout=60,
- certificate_path="",
- requests_proxy="",
- wait_on_rate_limit=True,
- nginx_429_retry_wait_time=2,
- action_batch_retry_wait_time=2,
- network_delete_retry_wait_time=2,
- retry_4xx_error=False,
- retry_4xx_error_wait_time=1,
- maximum_retries=3,
- simulate=False,
- be_geo_id="",
- caller="TestApp TestVendor",
- use_iterator_for_get_pages=False,
- maximum_concurrent_requests=8,
- )
- return s
-
-
-@pytest.fixture
-def async_session_with_cert(tmp_path):
- cert_file = tmp_path / "cert.pem"
- cert_file.write_text("FAKE CERT")
- with (
- patch("meraki.aio.rest_session.check_python_version"),
- patch("aiohttp.ClientSession") as mock_client,
- patch("ssl.create_default_context") as mock_ssl,
- ):
- mock_client.return_value = MagicMock()
- mock_ctx = MagicMock()
- mock_ssl.return_value = mock_ctx
- from meraki.aio.rest_session import AsyncRestSession
-
- s = AsyncRestSession(
- logger=None,
- api_key="fake_api_key_1234567890123456789012345678901234567890",
- base_url="https://api.meraki.com/api/v1",
- single_request_timeout=60,
- certificate_path=str(cert_file),
- requests_proxy="",
- wait_on_rate_limit=True,
- nginx_429_retry_wait_time=2,
- action_batch_retry_wait_time=2,
- network_delete_retry_wait_time=2,
- retry_4xx_error=False,
- retry_4xx_error_wait_time=1,
- maximum_retries=3,
- simulate=False,
- be_geo_id="",
- caller="TestApp TestVendor",
- use_iterator_for_get_pages=False,
- maximum_concurrent_requests=8,
- )
- return s
-
-
-@pytest.fixture
-def async_session_with_proxy():
- with (
- patch("meraki.aio.rest_session.check_python_version"),
- patch("aiohttp.ClientSession") as mock_client,
- ):
- mock_client.return_value = MagicMock()
- from meraki.aio.rest_session import AsyncRestSession
-
- s = AsyncRestSession(
- logger=None,
- api_key="fake_api_key_1234567890123456789012345678901234567890",
- base_url="https://api.meraki.com/api/v1",
- single_request_timeout=60,
- certificate_path="",
- requests_proxy="http://proxy:8080",
- wait_on_rate_limit=True,
- nginx_429_retry_wait_time=2,
- action_batch_retry_wait_time=2,
- network_delete_retry_wait_time=2,
- retry_4xx_error=False,
- retry_4xx_error_wait_time=1,
- maximum_retries=3,
- simulate=False,
- be_geo_id="",
- caller="TestApp TestVendor",
- use_iterator_for_get_pages=False,
- maximum_concurrent_requests=8,
- )
- return s
-
-
-def _metadata(operation="getOrganizations", tags=None):
- return {"tags": tags or ["organizations"], "operation": operation}
-
-
-def _mock_aio_response(status=200, json_data=None, reason="OK", headers=None, links=None):
- resp = MagicMock()
- resp.status = status
- resp.reason = reason
- resp.headers = headers or {}
- resp.links = links or {}
- resp.json = MagicMock(return_value=_AwaitableValue(json_data if json_data is not None else {"ok": True}))
- resp.text = AsyncMock(return_value="")
- resp.release = MagicMock()
- resp.__aenter__ = AsyncMock(return_value=resp)
- resp.__aexit__ = AsyncMock(return_value=False)
- return resp
-
-
-SLEEP_PATCH = "meraki.aio.rest_session.asyncio.sleep"
+SLEEP_PATCH = "meraki.session.async_.asyncio.sleep"
# --- Init tests ---
class TestAsyncInit:
- def test_certificate_path_creates_ssl_context(self, async_session_with_cert):
- assert hasattr(async_session_with_cert, "_sslcontext")
+ def test_certificate_path_passed_to_client(self, async_session_with_cert):
+ assert async_session_with_cert._certificate_path
def test_proxy_stored(self, async_session_with_proxy):
assert async_session_with_proxy._requests_proxy == "http://proxy:8080"
@@ -239,31 +46,13 @@ def test_use_iterator_property_setter(self, async_session):
class TestAsyncRequestKwargs:
@pytest.mark.asyncio
- async def test_ssl_context_passed(self, async_session_with_cert):
- resp_200 = _mock_aio_response(200)
- async_session_with_cert._req_session.request = AsyncMock(return_value=resp_200)
-
- await async_session_with_cert._request(_metadata(), "GET", "/orgs")
- call_kwargs = async_session_with_cert._req_session.request.call_args[1]
- assert "ssl" in call_kwargs
-
- @pytest.mark.asyncio
- async def test_proxy_passed(self, async_session_with_proxy):
- resp_200 = _mock_aio_response(200)
- async_session_with_proxy._req_session.request = AsyncMock(return_value=resp_200)
-
- await async_session_with_proxy._request(_metadata(), "GET", "/orgs")
- call_kwargs = async_session_with_proxy._req_session.request.call_args[1]
- assert call_kwargs["proxy"] == "http://proxy:8080"
-
- @pytest.mark.asyncio
- async def test_timeout_set(self, async_session):
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ async def test_follow_redirects_false(self, async_session):
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(return_value=resp_200)
- await async_session._request(_metadata(), "GET", "/orgs")
- call_kwargs = async_session._req_session.request.call_args[1]
- assert call_kwargs["timeout"] == 60
+ await async_session.request(_metadata(), "GET", "/orgs")
+ call_kwargs = async_session._client.request.call_args[1]
+ assert call_kwargs.get("follow_redirects") is False
# --- URL handling ---
@@ -272,42 +61,42 @@ async def test_timeout_set(self, async_session):
class TestAsyncURLHandling:
@pytest.mark.asyncio
async def test_relative_url_prepends_base(self, async_session):
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(return_value=resp_200)
- await async_session._request(_metadata(), "GET", "/organizations")
- call_args = async_session._req_session.request.call_args[0]
+ await async_session.request(_metadata(), "GET", "/organizations")
+ call_args = async_session._client.request.call_args[0]
assert call_args[1] == "https://api.meraki.com/api/v1/organizations"
@pytest.mark.asyncio
async def test_absolute_meraki_url_not_prepended(self, async_session):
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(return_value=resp_200)
- await async_session._request(_metadata(), "GET", "https://n123.meraki.com/api/v1/orgs")
- call_args = async_session._req_session.request.call_args[0]
+ await async_session.request(_metadata(), "GET", "https://n123.meraki.com/api/v1/orgs")
+ call_args = async_session._client.request.call_args[0]
assert call_args[1] == "https://n123.meraki.com/api/v1/orgs"
@pytest.mark.asyncio
async def test_meraki_cn_domain_recognized(self, async_session):
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(return_value=resp_200)
- await async_session._request(_metadata(), "GET", "https://n123.meraki.cn/api/v1/orgs")
- call_args = async_session._req_session.request.call_args[0]
+ await async_session.request(_metadata(), "GET", "https://n123.meraki.cn/api/v1/orgs")
+ call_args = async_session._client.request.call_args[0]
assert call_args[1] == "https://n123.meraki.cn/api/v1/orgs"
@pytest.mark.asyncio
async def test_non_string_url_converted(self, async_session):
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(return_value=resp_200)
class FakeURL:
def __str__(self):
return "https://n1.meraki.com/api/v1/orgs"
- await async_session._request(_metadata(), "GET", FakeURL())
- call_args = async_session._req_session.request.call_args[0]
+ await async_session.request(_metadata(), "GET", FakeURL())
+ call_args = async_session._client.request.call_args[0]
assert call_args[1] == "https://n1.meraki.com/api/v1/orgs"
@@ -317,36 +106,59 @@ def __str__(self):
class TestAsyncRetry429:
@pytest.mark.asyncio
async def test_retry_on_429_with_retry_after(self, async_session):
- resp_429 = _mock_aio_response(429, reason="Too Many Requests", headers={"Retry-After": "1"})
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(side_effect=[resp_429, resp_200])
+ resp_429 = _mock_aio_response(status_code=429, reason_phrase="Too Many Requests", headers={"Retry-After": "1"})
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[resp_429, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_retry_on_429_without_retry_after(self, async_session):
- resp_429 = _mock_aio_response(429, reason="Too Many Requests")
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(side_effect=[resp_429, resp_200])
+ resp_429 = _mock_aio_response(status_code=429, reason_phrase="Too Many Requests")
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[resp_429, resp_200])
with (
patch(SLEEP_PATCH, side_effect=_noop_sleep),
patch("random.randint", return_value=1),
):
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_429_raises_after_max_retries(self, async_session):
async_session._maximum_retries = 2
- resp_429 = _mock_aio_response(429, reason="Too Many Requests", headers={"Retry-After": "1"})
- async_session._req_session.request = AsyncMock(return_value=resp_429)
+ resp_429 = _mock_aio_response(status_code=429, reason_phrase="Too Many Requests", headers={"Retry-After": "1"})
+ async_session._client.request = AsyncMock(return_value=resp_429)
+
+ with patch(SLEEP_PATCH, side_effect=_noop_sleep):
+ with pytest.raises(APIError):
+ await async_session.request(_metadata(), "GET", "/organizations")
+
+ @pytest.mark.asyncio
+ async def test_429_retry_count_matches_maximum_retries(self, async_session):
+ async_session._maximum_retries = 3
+ resp_429 = _mock_aio_response(status_code=429, reason_phrase="Too Many Requests", headers={"Retry-After": "1"})
+ async_session._client.request = AsyncMock(return_value=resp_429)
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- with pytest.raises(AsyncAPIError):
- await async_session._request(_metadata(), "GET", "/organizations")
+ with pytest.raises(APIError):
+ await async_session.request(_metadata(), "GET", "/organizations")
+
+ assert async_session._client.request.call_count == 3
+
+ @pytest.mark.asyncio
+ async def test_429_uses_retry_after_header_value(self, async_session):
+ resp_429 = _mock_aio_response(status_code=429, reason_phrase="Too Many Requests", headers={"Retry-After": "42"})
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[resp_429, resp_200])
+
+ with patch(SLEEP_PATCH, side_effect=_noop_sleep) as mock_sleep:
+ await async_session.request(_metadata(), "GET", "/organizations")
+
+ mock_sleep.assert_called_once_with(42)
# --- Retry on 5xx ---
@@ -355,23 +167,76 @@ async def test_429_raises_after_max_retries(self, async_session):
class TestAsyncRetry5xx:
@pytest.mark.asyncio
async def test_retry_on_500(self, async_session):
- resp_500 = _mock_aio_response(500, reason="Internal Server Error")
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(side_effect=[resp_500, resp_200])
+ resp_500 = _mock_aio_response(status_code=500, reason_phrase="Internal Server Error")
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[resp_500, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_5xx_raises_after_max_retries(self, async_session):
async_session._maximum_retries = 2
- resp_500 = _mock_aio_response(500, reason="Internal Server Error")
- async_session._req_session.request = AsyncMock(return_value=resp_500)
+ resp_500 = _mock_aio_response(status_code=500, reason_phrase="Internal Server Error")
+ async_session._client.request = AsyncMock(return_value=resp_500)
+
+ with patch(SLEEP_PATCH, side_effect=_noop_sleep):
+ with pytest.raises(APIError):
+ await async_session.request(_metadata(), "GET", "/organizations")
+
+ @pytest.mark.asyncio
+ async def test_request_id_logged_in_warning(self, async_session_with_logger):
+ session = async_session_with_logger
+ resp_500 = _mock_aio_response(
+ status_code=500,
+ reason_phrase="Internal Server Error",
+ headers={"X-Request-Id": "abc123def456"},
+ )
+ resp_200 = _mock_aio_response(status_code=200)
+ session._client.request = AsyncMock(side_effect=[resp_500, resp_200])
+
+ with patch(SLEEP_PATCH, side_effect=_noop_sleep):
+ result = await session.request(_metadata(), "GET", "/organizations")
+
+ assert result.status_code == 200
+ warning_messages = [c.args[0] for c in session._logger.warning.call_args_list]
+ assert any("X-Request-Id: abc123def456" in m for m in warning_messages)
+
+ @pytest.mark.asyncio
+ async def test_request_id_logged_as_error_after_exhausting_retries(self, async_session_with_logger):
+ session = async_session_with_logger
+ session._maximum_retries = 2
+ resp_500 = _mock_aio_response(
+ status_code=500,
+ reason_phrase="Internal Server Error",
+ headers={"X-Request-Id": "deadbeef00112233"},
+ )
+ session._client.request = AsyncMock(return_value=resp_500)
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- with pytest.raises(AsyncAPIError):
- await async_session._request(_metadata(), "GET", "/organizations")
+ with pytest.raises(APIError):
+ await session.request(_metadata(), "GET", "/organizations")
+
+ error_messages = [c.args[0] for c in session._logger.error.call_args_list]
+ assert any("deadbeef00112233" in m for m in error_messages)
+ assert any("Provide this X-Request-Id to Meraki" in m for m in error_messages)
+
+ @pytest.mark.asyncio
+ async def test_no_request_id_logs_none(self, async_session_with_logger):
+ session = async_session_with_logger
+ session._maximum_retries = 2
+ resp_500 = _mock_aio_response(status_code=500, reason_phrase="Internal Server Error", headers={})
+ session._client.request = AsyncMock(return_value=resp_500)
+
+ with patch(SLEEP_PATCH, side_effect=_noop_sleep):
+ with pytest.raises(APIError):
+ await session.request(_metadata(), "GET", "/organizations")
+
+ warning_messages = [c.args[0] for c in session._logger.warning.call_args_list]
+ assert any("X-Request-Id: none" in m for m in warning_messages)
+ error_messages = [c.args[0] for c in session._logger.error.call_args_list]
+ assert any("log lookup: none" in m for m in error_messages)
# --- Connection errors ---
@@ -380,21 +245,21 @@ async def test_5xx_raises_after_max_retries(self, async_session):
class TestAsyncConnectionErrors:
@pytest.mark.asyncio
async def test_retry_on_exception(self, async_session):
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(side_effect=[Exception("Connection refused"), resp_200])
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[httpx.ConnectError("Connection refused"), resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_exception_raises_after_max_retries(self, async_session):
async_session._maximum_retries = 2
- async_session._req_session.request = AsyncMock(side_effect=Exception("Connection refused"))
+ async_session._client.request = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- with pytest.raises(AsyncAPIError):
- await async_session._request(_metadata(), "GET", "/organizations")
+ with pytest.raises(APIError):
+ await async_session.request(_metadata(), "GET", "/organizations")
# --- 4xx errors ---
@@ -403,117 +268,109 @@ async def test_exception_raises_after_max_retries(self, async_session):
class TestAsync4xx:
@pytest.mark.asyncio
async def test_generic_4xx_raises(self, async_session):
- resp_400 = _mock_aio_response(400, json_data={"errors": ["bad"]}, reason="Bad Request")
- async_session._req_session.request = AsyncMock(return_value=resp_400)
+ resp_400 = _mock_aio_response(status_code=400, json_data={"errors": ["bad"]}, reason_phrase="Bad Request")
+ async_session._client.request = AsyncMock(return_value=resp_400)
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- with pytest.raises(AsyncAPIError):
- await async_session._request(_metadata(), "GET", "/organizations")
+ with pytest.raises(APIError):
+ await async_session.request(_metadata(), "GET", "/organizations")
@pytest.mark.asyncio
async def test_retry_4xx_when_enabled(self, async_session):
async_session._retry_4xx_error = True
- resp_400 = _mock_aio_response(400, json_data={"errors": ["something"]}, reason="Bad Request")
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(side_effect=[resp_400, resp_200])
+ resp_400 = _mock_aio_response(status_code=400, json_data={"errors": ["something"]}, reason_phrase="Bad Request")
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[resp_400, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
with patch("random.randint", return_value=1):
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_network_delete_concurrency_retries(self, async_session):
async_session._maximum_retries = 3
error_msg = {"errors": ["This may be due to concurrent requests to delete networks. Please retry."]}
- resp_400 = _mock_aio_response(400, json_data=error_msg, reason="Bad Request")
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(side_effect=[resp_400, resp_200])
+ resp_400 = _mock_aio_response(status_code=400, json_data=error_msg, reason_phrase="Bad Request")
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[resp_400, resp_200])
with (
patch(SLEEP_PATCH, side_effect=_noop_sleep),
- patch("time.sleep"),
patch("random.randint", return_value=1),
):
- result = await async_session._request(_metadata(), "GET", "/networks")
- assert result.status == 200
+ result = await async_session.request(_metadata(operation="deleteNetwork"), "GET", "/networks")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_network_delete_concurrency_exhausts_retries(self, async_session):
async_session._maximum_retries = 2
error_msg = {"errors": ["This may be due to concurrent requests to delete networks. Please retry."]}
- resp_400 = _mock_aio_response(400, json_data=error_msg, reason="Bad Request")
- async_session._req_session.request = AsyncMock(return_value=resp_400)
-
- from meraki.exceptions import APIError
+ resp_400 = _mock_aio_response(status_code=400, json_data=error_msg, reason_phrase="Bad Request")
+ async_session._client.request = AsyncMock(return_value=resp_400)
with (
patch(SLEEP_PATCH, side_effect=_noop_sleep),
- patch("time.sleep"),
patch("random.randint", return_value=1),
):
- with pytest.raises((APIError, AsyncAPIError)):
- await async_session._request(_metadata(), "GET", "/networks")
+ with pytest.raises(APIError):
+ await async_session.request(_metadata(operation="deleteNetwork"), "GET", "/networks")
@pytest.mark.asyncio
async def test_action_batch_concurrency_retries(self, async_session):
error_msg = {
"errors": ["Too many concurrently executing batches. Maximum is 5 confirmed but not yet executed batches."]
}
- resp_400 = _mock_aio_response(400, json_data=error_msg, reason="Bad Request")
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(side_effect=[resp_400, resp_200])
+ resp_400 = _mock_aio_response(status_code=400, json_data=error_msg, reason_phrase="Bad Request")
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[resp_400, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- result = await async_session._request(_metadata(), "GET", "/batches")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/batches")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_4xx_non_json_response(self, async_session):
resp_400 = MagicMock()
- resp_400.status = 400
- resp_400.reason = "Bad Request"
+ resp_400.status_code = 400
+ resp_400.reason_phrase = "Bad Request"
resp_400.headers = {}
resp_400.links = {}
- resp_400.json = AsyncMock(side_effect=json.decoder.JSONDecodeError("", "", 0))
- resp_400.text = AsyncMock(return_value="Some HTML error page content")
- resp_400.release = MagicMock()
- resp_400.__aenter__ = AsyncMock(return_value=resp_400)
- resp_400.__aexit__ = AsyncMock(return_value=False)
+ resp_400.json = MagicMock(side_effect=json.decoder.JSONDecodeError("", "", 0))
+ resp_400.text = "Some HTML error page content"
+ resp_400.close = MagicMock()
- async_session._req_session.request = AsyncMock(return_value=resp_400)
+ async_session._client.request = AsyncMock(return_value=resp_400)
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- with pytest.raises(AsyncAPIError):
- await async_session._request(_metadata(), "GET", "/organizations")
+ with pytest.raises(APIError):
+ await async_session.request(_metadata(), "GET", "/organizations")
@pytest.mark.asyncio
async def test_4xx_non_json_text_fails_too(self, async_session):
resp_400 = MagicMock()
- resp_400.status = 400
- resp_400.reason = "Bad Request"
+ resp_400.status_code = 400
+ resp_400.reason_phrase = "Bad Request"
resp_400.headers = {}
resp_400.links = {}
- resp_400.json = AsyncMock(side_effect=aiohttp.client_exceptions.ContentTypeError(MagicMock(), MagicMock()))
- resp_400.text = AsyncMock(side_effect=Exception("read error"))
- resp_400.release = MagicMock()
- resp_400.__aenter__ = AsyncMock(return_value=resp_400)
- resp_400.__aexit__ = AsyncMock(return_value=False)
+ resp_400.json = MagicMock(side_effect=ValueError("Invalid JSON"))
+ resp_400.text = MagicMock(side_effect=Exception("read error"))
+ resp_400.close = MagicMock()
- async_session._req_session.request = AsyncMock(return_value=resp_400)
+ async_session._client.request = AsyncMock(return_value=resp_400)
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- with pytest.raises(AsyncAPIError):
- await async_session._request(_metadata(), "GET", "/organizations")
+ with pytest.raises(APIError):
+ await async_session.request(_metadata(), "GET", "/organizations")
@pytest.mark.asyncio
async def test_4xx_non_dict_json_response(self, async_session):
- resp_400 = _mock_aio_response(400, json_data="just a string", reason="Bad Request")
- async_session._req_session.request = AsyncMock(return_value=resp_400)
+ resp_400 = _mock_aio_response(status_code=400, json_data="just a string", reason_phrase="Bad Request")
+ async_session._client.request = AsyncMock(return_value=resp_400)
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- with pytest.raises(AsyncAPIError):
- await async_session._request(_metadata(), "GET", "/organizations")
+ with pytest.raises(APIError):
+ await async_session.request(_metadata(), "GET", "/organizations")
# --- 3xx redirect ---
@@ -523,31 +380,31 @@ class TestAsyncRedirect:
@pytest.mark.asyncio
async def test_follows_redirect(self, async_session):
resp_301 = _mock_aio_response(
- 301,
- reason="Moved",
+ status_code=301,
+ reason_phrase="Moved",
headers={"Location": "https://n123.meraki.com/api/v1/organizations"},
)
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(side_effect=[resp_301, resp_200])
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[resp_301, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
assert async_session._base_url == "https://n123.meraki.com/api/v1"
@pytest.mark.asyncio
async def test_redirect_to_cn_domain(self, async_session):
resp_301 = _mock_aio_response(
- 301,
- reason="Moved",
+ status_code=301,
+ reason_phrase="Moved",
headers={"Location": "https://n123.meraki.cn/api/v1/organizations"},
)
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(side_effect=[resp_301, resp_200])
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(side_effect=[resp_301, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
assert "meraki.cn" in async_session._base_url
@@ -558,22 +415,22 @@ class TestAsyncSimulate:
@pytest.mark.asyncio
async def test_simulate_skips_non_get(self, async_session):
async_session._simulate = True
- result = await async_session._request(_metadata(), "POST", "/organizations")
+ result = await async_session.request(_metadata(), "POST", "/organizations")
assert result is None
@pytest.mark.asyncio
async def test_simulate_allows_get(self, async_session):
async_session._simulate = True
- resp_200 = _mock_aio_response(200)
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(return_value=resp_200)
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_simulate_with_logger(self, async_session_with_logger):
async_session_with_logger._simulate = True
- result = await async_session_with_logger._request(_metadata(), "POST", "/organizations")
+ result = await async_session_with_logger.request(_metadata(), "POST", "/organizations")
assert result is None
assert async_session_with_logger._logger.info.call_count >= 1
@@ -584,59 +441,58 @@ async def test_simulate_with_logger(self, async_session_with_logger):
class TestAsync2xxResponse:
@pytest.mark.asyncio
async def test_success_with_page_metadata(self, async_session_with_logger):
- resp_200 = _mock_aio_response(200, json_data=[{"id": 1}])
- async_session_with_logger._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
+ async_session_with_logger._client.request = AsyncMock(return_value=resp_200)
metadata = _metadata()
metadata["page"] = 3
- result = await async_session_with_logger._request(metadata, "GET", "/organizations")
- assert result.status == 200
+ result = await async_session_with_logger.request(metadata, "GET", "/organizations")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_success_without_page_metadata(self, async_session_with_logger):
- resp_200 = _mock_aio_response(200, json_data=[{"id": 1}])
- async_session_with_logger._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
+ async_session_with_logger._client.request = AsyncMock(return_value=resp_200)
- result = await async_session_with_logger._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session_with_logger.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_get_retries_on_invalid_json(self, async_session):
resp_bad_json = MagicMock()
- resp_bad_json.status = 200
- resp_bad_json.reason = "OK"
+ resp_bad_json.status_code = 200
+ resp_bad_json.reason_phrase = "OK"
resp_bad_json.headers = {}
resp_bad_json.links = {}
- resp_bad_json.json = AsyncMock(side_effect=json.decoder.JSONDecodeError("", "", 0))
- resp_bad_json.release = MagicMock()
- resp_bad_json.__aenter__ = AsyncMock(return_value=resp_bad_json)
- resp_bad_json.__aexit__ = AsyncMock(return_value=False)
+ resp_bad_json.json = MagicMock(side_effect=json.decoder.JSONDecodeError("", "", 0))
+ resp_bad_json.close = MagicMock(side_effect=RuntimeError("Attempted to call an sync close on an async stream."))
+ resp_bad_json.aclose = AsyncMock()
- resp_200 = _mock_aio_response(200, json_data={"ok": True})
+ resp_200 = _mock_aio_response(status_code=200, json_data={"ok": True})
- async_session._req_session.request = AsyncMock(side_effect=[resp_bad_json, resp_200])
+ async_session._client.request = AsyncMock(side_effect=[resp_bad_json, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
@pytest.mark.asyncio
async def test_non_get_returns_without_json_validation(self, async_session):
- resp_200 = _mock_aio_response(200, json_data={"id": "abc"})
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200, json_data={"id": "abc"})
+ async_session._client.request = AsyncMock(return_value=resp_200)
- result = await async_session._request(_metadata(), "POST", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "POST", "/organizations")
+ assert result.status_code == 200
resp_200.json.assert_not_called()
@pytest.mark.asyncio
async def test_response_with_no_reason(self, async_session):
- resp_200 = _mock_aio_response(200)
+ resp_200 = _mock_aio_response(status_code=200)
resp_200.reason = None
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ async_session._client.request = AsyncMock(return_value=resp_200)
- result = await async_session._request(_metadata(), "GET", "/organizations")
- assert result.status == 200
+ result = await async_session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
# --- Logger coverage in request flow ---
@@ -645,78 +501,77 @@ async def test_response_with_no_reason(self, async_session):
class TestAsyncRequestLogging:
@pytest.mark.asyncio
async def test_logs_debug_metadata(self, async_session_with_logger):
- resp_200 = _mock_aio_response(200)
- async_session_with_logger._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session_with_logger._client.request = AsyncMock(return_value=resp_200)
- await async_session_with_logger._request(_metadata(), "GET", "/organizations")
+ await async_session_with_logger.request(_metadata(), "GET", "/organizations")
async_session_with_logger._logger.debug.assert_called()
@pytest.mark.asyncio
async def test_logs_request_url(self, async_session_with_logger):
- resp_200 = _mock_aio_response(200)
- async_session_with_logger._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session_with_logger._client.request = AsyncMock(return_value=resp_200)
- await async_session_with_logger._request(_metadata(), "GET", "/organizations")
+ await async_session_with_logger.request(_metadata(), "GET", "/organizations")
info_calls = [str(c) for c in async_session_with_logger._logger.info.call_args_list]
assert any("GET" in c for c in info_calls)
@pytest.mark.asyncio
async def test_logs_warning_on_connection_error(self, async_session_with_logger):
- resp_200 = _mock_aio_response(200)
- async_session_with_logger._req_session.request = AsyncMock(side_effect=[Exception("timeout"), resp_200])
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session_with_logger._client.request = AsyncMock(side_effect=[httpx.ConnectError("timeout"), resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- await async_session_with_logger._request(_metadata(), "GET", "/organizations")
+ await async_session_with_logger.request(_metadata(), "GET", "/organizations")
async_session_with_logger._logger.warning.assert_called()
@pytest.mark.asyncio
async def test_logs_warning_on_429(self, async_session_with_logger):
- resp_429 = _mock_aio_response(429, reason="Too Many Requests", headers={"Retry-After": "1"})
- resp_200 = _mock_aio_response(200)
- async_session_with_logger._req_session.request = AsyncMock(side_effect=[resp_429, resp_200])
+ resp_429 = _mock_aio_response(status_code=429, reason_phrase="Too Many Requests", headers={"Retry-After": "1"})
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session_with_logger._client.request = AsyncMock(side_effect=[resp_429, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- await async_session_with_logger._request(_metadata(), "GET", "/organizations")
+ await async_session_with_logger.request(_metadata(), "GET", "/organizations")
async_session_with_logger._logger.warning.assert_called()
@pytest.mark.asyncio
async def test_logs_warning_on_5xx(self, async_session_with_logger):
- resp_500 = _mock_aio_response(500, reason="Server Error")
- resp_200 = _mock_aio_response(200)
- async_session_with_logger._req_session.request = AsyncMock(side_effect=[resp_500, resp_200])
+ resp_500 = _mock_aio_response(status_code=500, reason_phrase="Server Error")
+ resp_200 = _mock_aio_response(status_code=200)
+ async_session_with_logger._client.request = AsyncMock(side_effect=[resp_500, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- await async_session_with_logger._request(_metadata(), "GET", "/organizations")
+ await async_session_with_logger.request(_metadata(), "GET", "/organizations")
async_session_with_logger._logger.warning.assert_called()
@pytest.mark.asyncio
async def test_logs_error_on_4xx(self, async_session_with_logger):
- resp_400 = _mock_aio_response(400, json_data={"errors": ["bad"]}, reason="Bad Request")
- async_session_with_logger._req_session.request = AsyncMock(return_value=resp_400)
+ resp_400 = _mock_aio_response(status_code=400, json_data={"errors": ["bad"]}, reason_phrase="Bad Request")
+ async_session_with_logger._client.request = AsyncMock(return_value=resp_400)
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- with pytest.raises(AsyncAPIError):
- await async_session_with_logger._request(_metadata(), "GET", "/organizations")
+ with pytest.raises(APIError):
+ await async_session_with_logger.request(_metadata(), "GET", "/organizations")
async_session_with_logger._logger.error.assert_called()
@pytest.mark.asyncio
async def test_logs_warning_on_bad_json_200(self, async_session_with_logger):
resp_bad = MagicMock()
- resp_bad.status = 200
- resp_bad.reason = "OK"
+ resp_bad.status_code = 200
+ resp_bad.reason_phrase = "OK"
resp_bad.headers = {}
resp_bad.links = {}
- resp_bad.json = AsyncMock(side_effect=aiohttp.client_exceptions.ContentTypeError(MagicMock(), MagicMock()))
- resp_bad.release = MagicMock()
- resp_bad.__aenter__ = AsyncMock(return_value=resp_bad)
- resp_bad.__aexit__ = AsyncMock(return_value=False)
+ resp_bad.json = MagicMock(side_effect=ValueError("Invalid JSON"))
+ resp_bad.close = MagicMock(side_effect=RuntimeError("Attempted to call an sync close on an async stream."))
+ resp_bad.aclose = AsyncMock()
- resp_200 = _mock_aio_response(200)
+ resp_200 = _mock_aio_response(status_code=200)
- async_session_with_logger._req_session.request = AsyncMock(side_effect=[resp_bad, resp_200])
+ async_session_with_logger._client.request = AsyncMock(side_effect=[resp_bad, resp_200])
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
- await async_session_with_logger._request(_metadata(), "GET", "/organizations")
+ await async_session_with_logger.request(_metadata(), "GET", "/organizations")
async_session_with_logger._logger.warning.assert_called()
@@ -726,49 +581,60 @@ async def test_logs_warning_on_bad_json_200(self, async_session_with_logger):
class TestAsyncHTTPVerbs:
@pytest.mark.asyncio
async def test_get(self, async_session):
- resp_200 = _mock_aio_response(200, json_data={"data": [1, 2, 3]})
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200, json_data={"data": [1, 2, 3]})
+ async_session._client.request = AsyncMock(return_value=resp_200)
result = await async_session.get(_metadata(), "/organizations")
assert result == {"data": [1, 2, 3]}
@pytest.mark.asyncio
async def test_get_with_params(self, async_session):
- resp_200 = _mock_aio_response(200, json_data=[{"id": 1}])
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
+ async_session._client.request = AsyncMock(return_value=resp_200)
result = await async_session.get(_metadata(), "/organizations", params={"perPage": 10})
assert result == [{"id": 1}]
@pytest.mark.asyncio
async def test_post(self, async_session):
- resp_201 = _mock_aio_response(201, json_data={"id": "new"})
- async_session._req_session.request = AsyncMock(return_value=resp_201)
+ resp_201 = _mock_aio_response(status_code=201, json_data={"id": "new"})
+ async_session._client.request = AsyncMock(return_value=resp_201)
result = await async_session.post(_metadata(), "/organizations", json={"name": "Test"})
assert result == {"id": "new"}
@pytest.mark.asyncio
async def test_put(self, async_session):
- resp_200 = _mock_aio_response(200, json_data={"id": "updated"})
- async_session._req_session.request = AsyncMock(return_value=resp_200)
+ resp_200 = _mock_aio_response(status_code=200, json_data={"id": "updated"})
+ async_session._client.request = AsyncMock(return_value=resp_200)
result = await async_session.put(_metadata(), "/organizations/1", json={"name": "New"})
assert result == {"id": "updated"}
@pytest.mark.asyncio
async def test_delete(self, async_session):
- resp_204 = _mock_aio_response(204, json_data=None, reason="No Content")
- async_session._req_session.request = AsyncMock(return_value=resp_204)
+ resp_204 = _mock_aio_response(status_code=204, json_data=None, reason_phrase="No Content")
+ async_session._client.request = AsyncMock(return_value=resp_204)
result = await async_session.delete(_metadata(), "/organizations/1")
assert result is None
+ @pytest.mark.asyncio
+ async def test_delete_passes_query_params(self, async_session):
+ resp_204 = _mock_aio_response(status_code=204, json_data=None, reason_phrase="No Content")
+ async_session._client.request = AsyncMock(return_value=resp_204)
+
+ params = {"force": "true"}
+ result = await async_session.delete(_metadata(), "/networks/1/groupPolicies/1", params)
+ assert result is None
+ call_kwargs = async_session._client.request.call_args
+ assert call_kwargs.kwargs.get("params") == params
+
@pytest.mark.asyncio
async def test_close(self, async_session):
- async_session._req_session.close = AsyncMock()
+ async_session._client.aclose = AsyncMock()
await async_session.close()
- async_session._req_session.close.assert_called_once()
+ async_session._client.aclose.assert_called_once()
# --- Pagination: _get_pages_legacy ---
@@ -777,59 +643,59 @@ async def test_close(self, async_session):
class TestAsyncPaginationLegacy:
@pytest.mark.asyncio
async def test_single_page_no_links(self, async_session):
- resp = _mock_aio_response(200, json_data=[{"id": 1}, {"id": 2}])
- async_session._req_session.request = AsyncMock(return_value=resp)
+ resp = _mock_aio_response(status_code=200, json_data=[{"id": 1}, {"id": 2}])
+ async_session._client.request = AsyncMock(return_value=resp)
result = await async_session._get_pages_legacy(_metadata(), "/organizations")
assert result == [{"id": 1}, {"id": 2}]
@pytest.mark.asyncio
async def test_multiple_pages_list(self, async_session):
- resp1 = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp1 = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
resp1.links = {"next": {"url": "https://api.meraki.com/api/v1/organizations?startingAfter=1"}}
- resp2 = _mock_aio_response(200, json_data=[{"id": 2}])
+ resp2 = _mock_aio_response(status_code=200, json_data=[{"id": 2}])
resp2.links = {}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
result = await async_session._get_pages_legacy(_metadata(), "/organizations")
assert result == [{"id": 1}, {"id": 2}]
@pytest.mark.asyncio
async def test_total_pages_string_all(self, async_session):
- resp = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
resp.links = {}
- async_session._req_session.request = AsyncMock(return_value=resp)
+ async_session._client.request = AsyncMock(return_value=resp)
result = await async_session._get_pages_legacy(_metadata(), "/organizations", total_pages="all")
assert result == [{"id": 1}]
@pytest.mark.asyncio
async def test_total_pages_numeric_string(self, async_session):
- resp = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
resp.links = {}
- async_session._req_session.request = AsyncMock(return_value=resp)
+ async_session._client.request = AsyncMock(return_value=resp)
result = await async_session._get_pages_legacy(_metadata(), "/organizations", total_pages="1")
assert result == [{"id": 1}]
@pytest.mark.asyncio
async def test_total_pages_limit(self, async_session):
- resp1 = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp1 = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
resp1.links = {"next": {"url": "https://api.meraki.com/api/v1/organizations?startingAfter=1"}}
- resp2 = _mock_aio_response(200, json_data=[{"id": 2}])
+ resp2 = _mock_aio_response(status_code=200, json_data=[{"id": 2}])
resp2.links = {"next": {"url": "https://api.meraki.com/api/v1/organizations?startingAfter=2"}}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
result = await async_session._get_pages_legacy(_metadata(), "/organizations", total_pages=2)
assert result == [{"id": 1}, {"id": 2}]
@pytest.mark.asyncio
async def test_prev_direction(self, async_session):
- resp1 = _mock_aio_response(200, json_data=[{"id": 2}])
+ resp1 = _mock_aio_response(status_code=200, json_data=[{"id": 2}])
resp1.links = {"prev": {"url": "https://api.meraki.com/api/v1/organizations?endingBefore=2"}}
- resp2 = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp2 = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
resp2.links = {}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
result = await async_session._get_pages_legacy(_metadata(), "/organizations", direction="prev")
assert result == [{"id": 2}, {"id": 1}]
@@ -852,7 +718,7 @@ async def test_items_dict_pagination(self, async_session):
},
)
resp2.links = {}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
result = await async_session._get_pages_legacy(_metadata(), "/items")
assert result == {
@@ -880,11 +746,11 @@ async def test_event_log_pagination_next(self, async_session):
},
)
resp2.links = {}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
metadata = _metadata(operation="getNetworkEvents")
- with patch("meraki.aio.rest_session.datetime") as mock_dt:
- mock_dt.utcnow.return_value = type(
+ with patch("meraki.session.async_.datetime") as mock_dt:
+ mock_dt.now.return_value = type(
"FakeDT",
(),
{"__sub__": lambda self, other: type("TD", (), {"total_seconds": lambda s: 86400})()},
@@ -906,13 +772,13 @@ async def test_event_log_breaks_on_recent_starting_after(self, async_session):
},
)
resp1.links = {"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2024-01-01T00:00:00Z"}}
- async_session._req_session.request = AsyncMock(return_value=resp1)
+ async_session._client.request = AsyncMock(return_value=resp1)
metadata = _metadata(operation="getNetworkEvents")
from datetime import datetime
- with patch("meraki.aio.rest_session.datetime") as mock_dt:
- mock_dt.utcnow.return_value = datetime(2024, 1, 1, 0, 2, 0)
+ with patch("meraki.session.async_.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2024, 1, 1, 0, 2, 0)
mock_dt.fromisoformat.return_value = datetime(2024, 1, 1, 0, 0, 0)
result = await async_session._get_pages_legacy(metadata, "/events", direction="next")
@@ -929,13 +795,13 @@ async def test_event_log_breaks_on_end_time(self, async_session):
},
)
resp1.links = {"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2024-06-01T00:00:00Z"}}
- async_session._req_session.request = AsyncMock(return_value=resp1)
+ async_session._client.request = AsyncMock(return_value=resp1)
metadata = _metadata(operation="getNetworkEvents")
from datetime import datetime
- with patch("meraki.aio.rest_session.datetime") as mock_dt:
- mock_dt.utcnow.return_value = datetime(2025, 1, 1, 0, 0, 0)
+ with patch("meraki.session.async_.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2025, 1, 1, 0, 0, 0)
mock_dt.fromisoformat.return_value = datetime(2024, 6, 1, 0, 0, 0)
result = await async_session._get_pages_legacy(
metadata,
@@ -956,7 +822,7 @@ async def test_event_log_prev_direction_breaks_before_2014(self, async_session):
},
)
resp1.links = {"prev": {"url": "https://api.meraki.com/api/v1/events?endingBefore=2013-12-31T00:00:00Z"}}
- async_session._req_session.request = AsyncMock(return_value=resp1)
+ async_session._client.request = AsyncMock(return_value=resp1)
metadata = _metadata(operation="getNetworkEvents")
result = await async_session._get_pages_legacy(metadata, "/events", direction="prev")
@@ -969,9 +835,9 @@ async def test_event_log_prev_direction_breaks_before_2014(self, async_session):
class TestAsyncPaginationIterator:
@pytest.mark.asyncio
async def test_single_page_yields_items(self, async_session):
- resp = _mock_aio_response(200, json_data=[{"id": 1}, {"id": 2}])
+ resp = _mock_aio_response(status_code=200, json_data=[{"id": 1}, {"id": 2}])
resp.links = {}
- async_session._req_session.request = AsyncMock(return_value=resp)
+ async_session._client.request = AsyncMock(return_value=resp)
items = []
async for item in async_session._get_pages_iterator(_metadata(), "/organizations"):
@@ -980,11 +846,11 @@ async def test_single_page_yields_items(self, async_session):
@pytest.mark.asyncio
async def test_multiple_pages_yields_all(self, async_session):
- resp1 = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp1 = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
resp1.links = {"next": {"url": "https://api.meraki.com/api/v1/organizations?startingAfter=1"}}
- resp2 = _mock_aio_response(200, json_data=[{"id": 2}])
+ resp2 = _mock_aio_response(status_code=200, json_data=[{"id": 2}])
resp2.links = {}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
items = []
async for item in async_session._get_pages_iterator(_metadata(), "/organizations"):
@@ -993,9 +859,9 @@ async def test_multiple_pages_yields_all(self, async_session):
@pytest.mark.asyncio
async def test_total_pages_string_all(self, async_session):
- resp = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
resp.links = {}
- async_session._req_session.request = AsyncMock(return_value=resp)
+ async_session._client.request = AsyncMock(return_value=resp)
items = []
async for item in async_session._get_pages_iterator(_metadata(), "/organizations", total_pages="all"):
@@ -1004,9 +870,9 @@ async def test_total_pages_string_all(self, async_session):
@pytest.mark.asyncio
async def test_total_pages_numeric_string(self, async_session):
- resp = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
resp.links = {}
- async_session._req_session.request = AsyncMock(return_value=resp)
+ async_session._client.request = AsyncMock(return_value=resp)
items = []
async for item in async_session._get_pages_iterator(_metadata(), "/organizations", total_pages="2"):
@@ -1015,9 +881,9 @@ async def test_total_pages_numeric_string(self, async_session):
@pytest.mark.asyncio
async def test_items_dict_yields_items(self, async_session):
- resp = _mock_aio_response(200, json_data={"items": [{"id": 1}, {"id": 2}]})
+ resp = _mock_aio_response(status_code=200, json_data={"items": [{"id": 1}, {"id": 2}]})
resp.links = {}
- async_session._req_session.request = AsyncMock(return_value=resp)
+ async_session._client.request = AsyncMock(return_value=resp)
items = []
async for item in async_session._get_pages_iterator(_metadata(), "/items"):
@@ -1035,7 +901,7 @@ async def test_event_log_next_yields_reversed(self, async_session):
},
)
resp.links = {}
- async_session._req_session.request = AsyncMock(return_value=resp)
+ async_session._client.request = AsyncMock(return_value=resp)
metadata = _metadata(operation="getNetworkEvents")
items = []
@@ -1054,7 +920,7 @@ async def test_event_log_prev_yields_normal(self, async_session):
},
)
resp.links = {}
- async_session._req_session.request = AsyncMock(return_value=resp)
+ async_session._client.request = AsyncMock(return_value=resp)
metadata = _metadata(operation="someOtherOp")
items = []
@@ -1064,11 +930,11 @@ async def test_event_log_prev_yields_normal(self, async_session):
@pytest.mark.asyncio
async def test_prev_direction_pagination(self, async_session):
- resp1 = _mock_aio_response(200, json_data=[{"id": 2}])
+ resp1 = _mock_aio_response(status_code=200, json_data=[{"id": 2}])
resp1.links = {"prev": {"url": "https://api.meraki.com/api/v1/orgs?endingBefore=2"}}
- resp2 = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp2 = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
resp2.links = {}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
items = []
async for item in async_session._get_pages_iterator(_metadata(), "/orgs", direction="prev"):
@@ -1096,14 +962,14 @@ async def test_iterator_event_log_breaks_on_recent(self, async_session):
},
)
resp2.links = {"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2024-01-02T23:58:00Z"}}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
metadata = _metadata(operation="getNetworkEvents")
from datetime import datetime
call_count = [0]
- def fake_utcnow():
+ def fake_now(tz=None):
return datetime(2025, 1, 1, 0, 0, 0)
def fake_fromisoformat(s):
@@ -1112,8 +978,8 @@ def fake_fromisoformat(s):
return datetime(2024, 1, 1, 0, 0, 0)
return datetime(2025, 1, 1, 0, 0, 0)
- with patch("meraki.aio.rest_session.datetime") as mock_dt:
- mock_dt.utcnow = fake_utcnow
+ with patch("meraki.session.async_.datetime") as mock_dt:
+ mock_dt.now = fake_now
mock_dt.fromisoformat = fake_fromisoformat
items = []
async for item in async_session._get_pages_iterator(metadata, "/events", direction="next"):
@@ -1141,14 +1007,14 @@ async def test_iterator_event_log_breaks_on_end_time(self, async_session):
},
)
resp2.links = {"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2024-06-01T00:00:00Z"}}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
metadata = _metadata(operation="getNetworkEvents")
from datetime import datetime
call_count = [0]
- def fake_utcnow():
+ def fake_now(tz=None):
return datetime(2025, 1, 1, 0, 0, 0)
def fake_fromisoformat(s):
@@ -1157,8 +1023,8 @@ def fake_fromisoformat(s):
return datetime(2024, 4, 1, 0, 0, 0)
return datetime(2024, 6, 1, 0, 0, 0)
- with patch("meraki.aio.rest_session.datetime") as mock_dt:
- mock_dt.utcnow = fake_utcnow
+ with patch("meraki.session.async_.datetime") as mock_dt:
+ mock_dt.now = fake_now
mock_dt.fromisoformat = fake_fromisoformat
items = []
async for item in async_session._get_pages_iterator(
@@ -1190,7 +1056,7 @@ async def test_iterator_event_log_prev_breaks_before_2014(self, async_session):
},
)
resp2.links = {"prev": {"url": "https://api.meraki.com/api/v1/events?endingBefore=2013-06-01T00:00:00Z"}}
- async_session._req_session.request = AsyncMock(side_effect=[resp1, resp2])
+ async_session._client.request = AsyncMock(side_effect=[resp1, resp2])
metadata = _metadata(operation="getNetworkEvents")
items = []
@@ -1206,8 +1072,222 @@ async def test_iterator_event_log_prev_breaks_before_2014(self, async_session):
class TestAsyncDownloadPage:
@pytest.mark.asyncio
async def test_download_page(self, async_session):
- resp = _mock_aio_response(200, json_data=[{"id": 1}])
+ resp = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
request_coro = AsyncMock(return_value=resp)()
response, result = await async_session._download_page(request_coro)
assert result == [{"id": 1}]
- assert response.status == 200
+ assert response.status_code == 200
+
+ @pytest.mark.asyncio
+ async def test_download_page_204_no_json(self, async_session):
+ """A 204 page yields result=None without calling .json()."""
+ resp = _mock_aio_response(status_code=204, reason_phrase="No Content", content=b"")
+ resp.json = MagicMock(side_effect=json.decoder.JSONDecodeError("", "", 0))
+ request_coro = AsyncMock(return_value=resp)()
+ response, result = await async_session._download_page(request_coro)
+ assert result is None
+ resp.json.assert_not_called()
+
+
+# --- Fix #4: async iterator 204 guard ---
+
+
+class TestAsyncIterator204Guard:
+ @pytest.mark.asyncio
+ async def test_204_page_terminates_iterator_cleanly(self, async_session):
+ """A 204 page terminates the async iterator without JSONDecodeError."""
+ resp_204 = _mock_aio_response(status_code=204, reason_phrase="No Content", content=b"")
+ resp_204.links = {}
+ resp_204.json = MagicMock(side_effect=json.decoder.JSONDecodeError("", "", 0))
+ async_session._client.request = AsyncMock(return_value=resp_204)
+
+ items = []
+ async for item in async_session._get_pages_iterator(_metadata(), "/organizations"):
+ items.append(item)
+ assert items == []
+ resp_204.json.assert_not_called()
+
+
+# --- Fix #5: smart-flow GET parses JSON only once ---
+
+
+class TestAsyncSuccessSingleParse:
+ @pytest.mark.asyncio
+ async def test_smart_flow_get_parses_json_once(self, async_session):
+ """With smart flow on, a successful GET must call response.json() exactly once."""
+ async_session._smart_flow = MagicMock()
+ async_session._smart_flow.acquire = AsyncMock()
+
+ body = {"organizationId": "42", "id": "N_1"}
+ resp = _mock_aio_response(status_code=200, json_data=body)
+ async_session._client.request = AsyncMock(return_value=resp)
+
+ await async_session.request(_metadata(), "GET", "/networks/N_1")
+
+ assert resp.json.call_count == 1
+ # learn_from_response receives the already-decoded body (same object)
+ async_session._smart_flow.learn_from_response.assert_called_once()
+ passed_body = async_session._smart_flow.learn_from_response.call_args.args[1]
+ assert passed_body == body
+
+ @pytest.mark.asyncio
+ async def test_handle_success_async_returns_response_and_body(self, async_session):
+ body = [{"id": 1}]
+ resp = _mock_aio_response(status_code=200, json_data=body)
+ result, parsed = await async_session._handle_success_async(resp, _metadata(), "GET")
+ assert result is resp
+ assert parsed == body
+ assert resp.json.call_count == 1
+
+ @pytest.mark.asyncio
+ async def test_handle_success_async_non_get_no_body(self, async_session):
+ resp = _mock_aio_response(status_code=201, json_data={"id": "x"})
+ result, parsed = await async_session._handle_success_async(resp, _metadata(), "POST")
+ assert result is resp
+ assert parsed is None
+ resp.json.assert_not_called()
+
+
+# --- Fix #9: early consumer break cancels the prefetch task ---
+
+
+class TestAsyncIteratorPrefetchCancel:
+ @pytest.mark.asyncio
+ async def test_early_break_cancels_prefetch(self, async_session):
+ """Breaking out of the iterator early cancels the in-flight prefetch task."""
+ import asyncio
+
+ resp1 = _mock_aio_response(status_code=200, json_data=[{"id": 1}])
+ resp1.links = {"next": {"url": "https://api.meraki.com/api/v1/orgs?startingAfter=1"}}
+
+ # Second request hangs forever so the prefetch task is genuinely pending at break.
+ never = asyncio.Event()
+
+ call_count = [0]
+
+ async def fake_request(*args, **kwargs):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return resp1
+ await never.wait() # never resolves
+ return _mock_aio_response(status_code=200, json_data=[{"id": 2}])
+
+ async_session._client.request = AsyncMock(side_effect=fake_request)
+
+ captured = {}
+ orig_create_task = asyncio.create_task
+
+ def capture_create_task(coro):
+ task = orig_create_task(coro)
+ captured.setdefault("tasks", []).append(task)
+ return task
+
+ agen = async_session._get_pages_iterator(_metadata(), "/orgs", total_pages=-1)
+ with patch("meraki.session.async_.asyncio.create_task", side_effect=capture_create_task):
+ first = await agen.__anext__()
+ assert first == {"id": 1}
+ # Break early: close the generator, triggering finally -> cancel prefetch.
+ await agen.aclose()
+
+ # The second (prefetch) task should have been cancelled.
+ prefetch_tasks = captured["tasks"]
+ assert len(prefetch_tasks) >= 2
+ assert prefetch_tasks[-1].cancelled()
+
+
+# --- Fix #12: internal resolver/hydrator GETs drain the global bucket ---
+
+
+def _make_async_smart_flow_session():
+ from tests.unit.conftest import DEFAULT_SESSION_KWARGS, FAKE_API_KEY
+
+ kwargs = {"maximum_concurrent_requests": 8, **DEFAULT_SESSION_KWARGS, "smart_flow_enabled": True}
+ with (
+ patch("meraki.session.base.check_python_version"),
+ patch("httpx.AsyncClient") as mock_client,
+ ):
+ mock_instance = MagicMock()
+ mock_instance.headers = {}
+ mock_instance.request = AsyncMock()
+ mock_client.return_value = mock_instance
+ from meraki.session.async_ import AsyncRestSession
+
+ return AsyncRestSession(logger=None, api_key=FAKE_API_KEY, **kwargs)
+
+
+class TestAsyncGlobalBucketAccounting:
+ @pytest.mark.asyncio
+ async def test_resolver_acquires_global_bucket(self):
+ s = _make_async_smart_flow_session()
+ s._smart_flow._global_bucket = MagicMock()
+ s._smart_flow._global_bucket.acquire = AsyncMock()
+ resp = _mock_aio_response(status_code=200, json_data={"organizationId": "999"})
+ s._client.request = AsyncMock(return_value=resp)
+
+ org = await s._resolve_org_for_limiter("network", "N_1")
+ assert org == "999"
+ s._smart_flow._global_bucket.acquire.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_hydrator_acquires_global_bucket_per_page(self):
+ s = _make_async_smart_flow_session()
+ s._smart_flow._global_bucket = MagicMock()
+ s._smart_flow._global_bucket.acquire = AsyncMock()
+
+ page1 = _mock_aio_response(status_code=200, json_data=[{"id": "N_1"}])
+ page1.links = {"next": {"url": "https://api.meraki.com/api/v1/organizations/9/networks?page=2"}}
+ page2 = _mock_aio_response(status_code=200, json_data=[{"id": "N_2"}])
+ page2.links = {}
+ empty = _mock_aio_response(status_code=200, json_data=[])
+ empty.links = {}
+ s._client.request = AsyncMock(side_effect=[page1, page2, empty])
+
+ await s._hydrate_org_for_limiter("9")
+ assert s._smart_flow._global_bucket.acquire.await_count == 3
+
+ @pytest.mark.asyncio
+ async def test_acquire_global_bucket_defensive(self, async_session):
+ async_session._smart_flow = None
+ await async_session._acquire_global_bucket() # must not raise
+
+
+# --- Fix #15: Meraki array-of-objects param encoding on the wire (async) ---
+
+
+class TestMerakiParamEncodingAsync:
+ @pytest.mark.asyncio
+ async def test_list_of_dict_params_use_meraki_encoding(self, async_session):
+ resp = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(return_value=resp)
+
+ params = {"variables[]": [{"name": "n1", "value": "v1"}]}
+ await async_session.request(_metadata(), "GET", "/things", params=params)
+
+ call = async_session._client.request.call_args
+ sent_url = call.args[1]
+ assert "variables%5B%5Dname=n1" in sent_url
+ assert "variables%5B%5Dvalue=v1" in sent_url
+ assert call.kwargs.get("params") is None
+
+ @pytest.mark.asyncio
+ async def test_scalar_list_params_unchanged(self, async_session):
+ resp = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(return_value=resp)
+
+ params = {"networkIds[]": ["a", "b"]}
+ await async_session.request(_metadata(), "GET", "/things", params=params)
+
+ call = async_session._client.request.call_args
+ assert call.kwargs.get("params") == params
+ assert "networkIds" not in call.args[1]
+
+ @pytest.mark.asyncio
+ async def test_scalar_params_unchanged(self, async_session):
+ resp = _mock_aio_response(status_code=200)
+ async_session._client.request = AsyncMock(return_value=resp)
+
+ params = {"perPage": 10}
+ await async_session.request(_metadata(), "GET", "/things", params=params)
+
+ call = async_session._client.request.call_args
+ assert call.kwargs.get("params") == params
diff --git a/tests/unit/test_api_smoke.py b/tests/unit/test_api_smoke.py
new file mode 100644
index 00000000..d840c1d8
--- /dev/null
+++ b/tests/unit/test_api_smoke.py
@@ -0,0 +1,103 @@
+"""Smoke tests: all generated API modules import and instantiate."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+SYNC_API_CLASSES = [
+ ("meraki.api.administered", "Administered"),
+ ("meraki.api.appliance", "Appliance"),
+ ("meraki.api.camera", "Camera"),
+ ("meraki.api.campusGateway", "CampusGateway"),
+ ("meraki.api.cellularGateway", "CellularGateway"),
+ ("meraki.api.devices", "Devices"),
+ ("meraki.api.insight", "Insight"),
+ ("meraki.api.licensing", "Licensing"),
+ ("meraki.api.networks", "Networks"),
+ ("meraki.api.organizations", "Organizations"),
+ ("meraki.api.sensor", "Sensor"),
+ ("meraki.api.sm", "Sm"),
+ ("meraki.api.spaces", "Spaces"),
+ ("meraki.api.switch", "Switch"),
+ ("meraki.api.wireless", "Wireless"),
+ ("meraki.api.wirelessController", "WirelessController"),
+]
+
+ASYNC_API_CLASSES = [
+ ("meraki.aio.api.administered", "AsyncAdministered"),
+ ("meraki.aio.api.appliance", "AsyncAppliance"),
+ ("meraki.aio.api.camera", "AsyncCamera"),
+ ("meraki.aio.api.campusGateway", "AsyncCampusGateway"),
+ ("meraki.aio.api.cellularGateway", "AsyncCellularGateway"),
+ ("meraki.aio.api.devices", "AsyncDevices"),
+ ("meraki.aio.api.insight", "AsyncInsight"),
+ ("meraki.aio.api.licensing", "AsyncLicensing"),
+ ("meraki.aio.api.networks", "AsyncNetworks"),
+ ("meraki.aio.api.organizations", "AsyncOrganizations"),
+ ("meraki.aio.api.sensor", "AsyncSensor"),
+ ("meraki.aio.api.sm", "AsyncSm"),
+ ("meraki.aio.api.spaces", "AsyncSpaces"),
+ ("meraki.aio.api.switch", "AsyncSwitch"),
+ ("meraki.aio.api.wireless", "AsyncWireless"),
+ ("meraki.aio.api.wirelessController", "AsyncWirelessController"),
+]
+
+
+class TestSyncAPIModuleSmoke:
+ @pytest.mark.parametrize("module_path,class_name", SYNC_API_CLASSES)
+ def test_import_and_instantiate(self, module_path, class_name):
+ """Each sync API class imports and instantiates with a mock session."""
+ import importlib
+
+ module = importlib.import_module(module_path)
+ cls = getattr(module, class_name)
+ instance = cls(session=MagicMock())
+ assert instance is not None
+
+
+class TestAsyncAPIModuleSmoke:
+ @pytest.mark.parametrize("module_path,class_name", ASYNC_API_CLASSES)
+ def test_import_and_instantiate(self, module_path, class_name):
+ """Each async API class imports and instantiates with a mock session."""
+ import importlib
+
+ module = importlib.import_module(module_path)
+ cls = getattr(module, class_name)
+ instance = cls(session=MagicMock())
+ assert instance is not None
+
+
+class TestDashboardAPISectionsSmoke:
+ def test_all_sync_sections_accessible(self):
+ """DashboardAPI exposes all API sections after init."""
+ with patch("meraki.session.base.check_python_version"):
+ import meraki
+
+ api = meraki.DashboardAPI(
+ api_key="fake_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ )
+
+ expected_sections = [
+ "administered",
+ "appliance",
+ "camera",
+ "campusGateway",
+ "cellularGateway",
+ "devices",
+ "insight",
+ "licensing",
+ "networks",
+ "organizations",
+ "sensor",
+ "sm",
+ "spaces",
+ "switch",
+ "wireless",
+ "wirelessController",
+ "batch",
+ ]
+ for section in expected_sections:
+ attr = getattr(api, section, None)
+ assert attr is not None, f"DashboardAPI missing section: {section}"
diff --git a/tests/unit/test_async_lifecycle.py b/tests/unit/test_async_lifecycle.py
new file mode 100644
index 00000000..a9b7a78a
--- /dev/null
+++ b/tests/unit/test_async_lifecycle.py
@@ -0,0 +1,139 @@
+"""Test AsyncDashboardAPI context manager and session lifecycle."""
+
+import asyncio
+from unittest.mock import patch, AsyncMock, MagicMock
+
+import pytest
+
+from meraki.aio import AsyncDashboardAPI
+from meraki.exceptions import APIKeyError
+
+
+class TestAsyncDashboardAPILifecycle:
+ def test_missing_api_key_raises(self, monkeypatch):
+ monkeypatch.delenv("MERAKI_DASHBOARD_API_KEY", raising=False)
+ with pytest.raises(APIKeyError):
+ AsyncDashboardAPI(api_key=None, suppress_logging=True)
+
+ def test_instantiation_with_valid_key(self):
+ with patch("meraki.session.base.check_python_version"):
+ api = AsyncDashboardAPI(
+ api_key="fake_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ )
+ assert api._session is not None
+
+ @pytest.mark.asyncio
+ async def test_context_manager_enters_and_exits(self):
+ with patch("meraki.session.base.check_python_version"):
+ api = AsyncDashboardAPI(
+ api_key="fake_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ )
+
+ api._session._client = MagicMock()
+ api._session._client.aclose = AsyncMock()
+
+ async with api as dashboard:
+ assert dashboard is api
+
+ api._session._client.aclose.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_aexit_shuts_down_smart_flow_before_closing_client(self):
+ # Regression for #7: __aexit__ must drain/cancel in-flight background
+ # smart-flow tasks (via shutdown()) and persist the cache BEFORE the
+ # httpx client is closed, otherwise those tasks fail with
+ # "client has been closed" and resolution is lost.
+ with patch("meraki.session.base.check_python_version"):
+ api = AsyncDashboardAPI(
+ api_key="fake_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ )
+
+ order = []
+ bg_task_done = asyncio.Event()
+
+ async def slow_background():
+ # Simulate an in-flight resolve/hydrate task touching the client.
+ await asyncio.sleep(0.05)
+ bg_task_done.set()
+
+ bg = asyncio.create_task(slow_background())
+
+ smart_flow = MagicMock()
+ save_calls = {"n": 0}
+
+ async def fake_shutdown():
+ order.append("shutdown")
+ # Drain the in-flight background task before the client closes.
+ await bg
+ save_calls["n"] += 1 # shutdown performs the final save itself
+
+ smart_flow.shutdown = AsyncMock(side_effect=fake_shutdown)
+ api._session._smart_flow = smart_flow
+
+ async def fake_close():
+ order.append("close")
+
+ api._session.close = AsyncMock(side_effect=fake_close)
+
+ async with api:
+ pass
+
+ # shutdown ran exactly once, before the client/session close
+ smart_flow.shutdown.assert_awaited_once()
+ assert order == ["shutdown", "close"]
+ # background task was awaited to completion (no lingering task)
+ assert bg.done()
+ assert bg_task_done.is_set()
+ # cache persisted exactly once (shutdown does the save, not save_cache)
+ assert save_calls["n"] == 1
+
+ @pytest.mark.asyncio
+ async def test_aexit_skips_shutdown_when_smart_flow_disabled(self):
+ # When smart flow is disabled the limiter is None; __aexit__ must not
+ # attempt to call shutdown() and must still close the client.
+ with patch("meraki.session.base.check_python_version"):
+ api = AsyncDashboardAPI(
+ api_key="fake_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=False,
+ )
+
+ assert api._session._smart_flow is None
+ api._session.close = AsyncMock()
+
+ async with api:
+ pass
+
+ api._session.close.assert_awaited_once()
+
+ def test_all_api_sections_assigned(self):
+ with patch("meraki.session.base.check_python_version"):
+ api = AsyncDashboardAPI(
+ api_key="fake_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ )
+
+ sections = [
+ "administered",
+ "appliance",
+ "camera",
+ "campusGateway",
+ "cellularGateway",
+ "devices",
+ "insight",
+ "licensing",
+ "networks",
+ "organizations",
+ "sensor",
+ "sm",
+ "spaces",
+ "switch",
+ "wireless",
+ "wirelessController",
+ ]
+ for section in sections:
+ assert hasattr(api, section), f"Missing API section: {section}"
+ assert getattr(api, section) is not None
diff --git a/tests/unit/test_common.py b/tests/unit/test_common.py
index 90293b4d..fd1f2a44 100644
--- a/tests/unit/test_common.py
+++ b/tests/unit/test_common.py
@@ -28,6 +28,21 @@ def test_python2_raises(self, mock_ver):
with pytest.raises(PythonVersionError):
check_python_version()
+ def test_check_python_version_valid_does_not_raise(self):
+ """check_python_version should not raise on current interpreter (>=3.10)."""
+ check_python_version()
+
+ def test_check_python_version_rejects_39(self):
+ """check_python_version raises PythonVersionError for 3.9."""
+ with patch("platform.python_version_tuple", return_value=("3", "9", "0")):
+ with pytest.raises(PythonVersionError):
+ check_python_version()
+
+ def test_check_python_version_accepts_310(self):
+ """check_python_version accepts exactly 3.10.0 (minimum)."""
+ with patch("platform.python_version_tuple", return_value=("3", "10", "0")):
+ check_python_version()
+
class TestValidateUserAgent:
def test_valid_caller(self):
@@ -74,6 +89,20 @@ def test_trailing_slash_stripped(self):
reject_v0_base_url(session)
assert session._base_url == "https://api.meraki.com/api/v1"
+ def test_reject_v0_strips_trailing_slash(self):
+ """reject_v0_base_url strips trailing slash from valid URLs."""
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1/"
+ reject_v0_base_url(obj)
+ assert obj._base_url == "https://api.meraki.com/api/v1"
+
+ def test_reject_v0_leaves_valid_url_unchanged(self):
+ """reject_v0_base_url leaves clean v1 URL untouched."""
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ reject_v0_base_url(obj)
+ assert obj._base_url == "https://api.meraki.com/api/v1"
+
class TestValidateBaseUrl:
def test_absolute_meraki_url_passthrough(self):
@@ -112,6 +141,111 @@ def test_gov_domain(self):
result = validate_base_url(session, "https://n1.gov-meraki.com/api/v1/x")
assert result == "https://n1.gov-meraki.com/api/v1/x"
+ def test_meraki_com_absolute(self):
+ from meraki.common import validate_base_url
+
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ result = validate_base_url(obj, "https://n1.meraki.com/api/v1/networks")
+ assert result == "https://n1.meraki.com/api/v1/networks"
+
+ def test_relative_url_prepends_base(self):
+ from meraki.common import validate_base_url
+
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ result = validate_base_url(obj, "/organizations")
+ assert result == "https://api.meraki.com/api/v1/organizations"
+
+ def test_canada_domain_treated_as_absolute(self):
+ from meraki.common import validate_base_url
+
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.ca/api/v1"
+ result = validate_base_url(obj, "https://api.meraki.ca/api/v1/networks")
+ assert result == "https://api.meraki.ca/api/v1/networks"
+
+ def test_china_domain_treated_as_absolute(self):
+ from meraki.common import validate_base_url
+
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.cn/api/v1"
+ result = validate_base_url(obj, "https://api.meraki.cn/api/v1/networks")
+ assert result == "https://api.meraki.cn/api/v1/networks"
+
+ def test_india_domain_treated_as_absolute(self):
+ from meraki.common import validate_base_url
+
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.in/api/v1"
+ result = validate_base_url(obj, "https://api.meraki.in/api/v1/networks")
+ assert result == "https://api.meraki.in/api/v1/networks"
+
+ def test_gov_meraki_domain_treated_as_absolute(self):
+ from meraki.common import validate_base_url
+
+ obj = MagicMock()
+ obj._base_url = "https://api.gov-meraki.com/api/v1"
+ result = validate_base_url(obj, "https://api.gov-meraki.com/api/v1/networks")
+ assert result == "https://api.gov-meraki.com/api/v1/networks"
+
+ def test_unknown_domain_treated_as_relative(self):
+ from meraki.common import validate_base_url
+
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ result = validate_base_url(obj, "https://evil.com/steal")
+ assert result == "https://api.meraki.com/api/v1https://evil.com/steal"
+
+ # --- Fix #14: host-boundary trusted-domain check (SSRF / key-exfil) ---
+
+ def test_legit_api_host_trusted(self):
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ result = validate_base_url(obj, "https://api.meraki.com/api/v1/orgs")
+ assert result == "https://api.meraki.com/api/v1/orgs"
+
+ def test_legit_shard_subdomain_trusted(self):
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ result = validate_base_url(obj, "https://n123.meraki.com/api/v1/orgs")
+ assert result == "https://n123.meraki.com/api/v1/orgs"
+
+ def test_legit_cn_host_trusted(self):
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.cn/api/v1"
+ result = validate_base_url(obj, "https://api.meraki.cn/api/v1/orgs")
+ assert result == "https://api.meraki.cn/api/v1/orgs"
+
+ def test_lookalike_suffix_rejected(self):
+ # "meraki.com" appears as a substring but not on a host boundary.
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ evil = "https://api.meraki.com.attacker.net/steal"
+ result = validate_base_url(obj, evil)
+ assert result == "https://api.meraki.com/api/v1" + evil
+
+ def test_lookalike_evil_example_rejected(self):
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ evil = "https://meraki.com.evil.example/steal"
+ result = validate_base_url(obj, evil)
+ assert result == "https://api.meraki.com/api/v1" + evil
+
+ def test_lookalike_prefix_rejected(self):
+ # "evil-meraki.com" embeds the domain but is a different host.
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ evil = "https://evil-meraki.com/steal"
+ result = validate_base_url(obj, evil)
+ assert result == "https://api.meraki.com/api/v1" + evil
+
+ def test_trusted_host_with_port(self):
+ obj = MagicMock()
+ obj._base_url = "https://api.meraki.com/api/v1"
+ result = validate_base_url(obj, "https://api.meraki.com:443/api/v1/orgs")
+ assert result == "https://api.meraki.com:443/api/v1/orgs"
+
class TestIteratorForGetPages:
def test_getter(self):
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
new file mode 100644
index 00000000..aac803f6
--- /dev/null
+++ b/tests/unit/test_config.py
@@ -0,0 +1,40 @@
+"""Verify config.py constants are sane and consumed correctly."""
+
+from meraki import config
+
+
+class TestConfigDefaults:
+ def test_default_base_url_is_v1(self):
+ assert "v1" in config.DEFAULT_BASE_URL
+ assert config.DEFAULT_BASE_URL.startswith("https://")
+
+ def test_alternate_urls_are_https(self):
+ for url in [
+ config.CANADA_BASE_URL,
+ config.CHINA_BASE_URL,
+ config.INDIA_BASE_URL,
+ config.UNITED_STATES_FED_BASE_URL,
+ ]:
+ assert url.startswith("https://"), f"{url} is not HTTPS"
+ assert "/api/v1" in url
+
+ def test_timeout_is_positive(self):
+ assert config.SINGLE_REQUEST_TIMEOUT > 0
+
+ def test_maximum_retries_is_positive(self):
+ assert config.MAXIMUM_RETRIES >= 1
+
+ def test_retry_wait_times_are_positive(self):
+ assert config.NGINX_429_RETRY_WAIT_TIME > 0
+ assert config.ACTION_BATCH_RETRY_WAIT_TIME > 0
+ assert config.NETWORK_DELETE_RETRY_WAIT_TIME > 0
+ assert config.RETRY_4XX_ERROR_WAIT_TIME > 0
+
+ def test_aio_max_concurrent_is_positive(self):
+ assert config.AIO_MAXIMUM_CONCURRENT_REQUESTS >= 1
+
+ def test_wait_on_rate_limit_defaults_true(self):
+ assert config.WAIT_ON_RATE_LIMIT is True
+
+ def test_simulate_defaults_false(self):
+ assert config.SIMULATE_API_CALLS is False
diff --git a/tests/unit/test_dashboard_api_init.py b/tests/unit/test_dashboard_api_init.py
index b2fb2575..c146d1e9 100644
--- a/tests/unit/test_dashboard_api_init.py
+++ b/tests/unit/test_dashboard_api_init.py
@@ -1,6 +1,6 @@
import logging
import os
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
import pytest
@@ -9,24 +9,20 @@
class TestDashboardAPIInit:
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_api_key_from_param(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
suppress_logging=True,
caller="TestApp TestVendor",
)
- assert (
- d._session._api_key == "test_key_1234567890123456789012345678901234567890"
- )
+ assert d._session._api_key == "test_key_1234567890123456789012345678901234567890"
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_api_key_from_env(self, mock_check):
with patch.dict(
os.environ,
- {
- "MERAKI_DASHBOARD_API_KEY": "env_key_12345678901234567890123456789012345678"
- },
+ {"MERAKI_DASHBOARD_API_KEY": "env_key_12345678901234567890123456789012345678"},
):
d = meraki.DashboardAPI(
suppress_logging=True,
@@ -40,7 +36,7 @@ def test_missing_api_key_raises(self):
with pytest.raises(APIKeyError):
meraki.DashboardAPI(suppress_logging=True, caller="TestApp TestVendor")
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_suppress_logging_sets_none(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -49,7 +45,7 @@ def test_suppress_logging_sets_none(self, mock_check):
)
assert d._logger is None
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_simulate_mode_propagates(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -59,7 +55,7 @@ def test_simulate_mode_propagates(self, mock_check):
)
assert d._session._simulate is True
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_custom_base_url(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -69,7 +65,7 @@ def test_custom_base_url(self, mock_check):
)
assert d._session._base_url == "https://api.meraki.cn/api/v1"
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_maximum_retries_propagates(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -79,7 +75,7 @@ def test_maximum_retries_propagates(self, mock_check):
)
assert d._session._maximum_retries == 10
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_use_iterator_propagates(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -89,16 +85,43 @@ def test_use_iterator_propagates(self, mock_check):
)
assert d._session.use_iterator_for_get_pages is True
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
+ def test_use_iterator_default_false_propagates(self, mock_check):
+ # Regression for removed dead self-assignment
+ # `use_iterator_for_get_pages = use_iterator_for_get_pages`: param
+ # must still reach the session unchanged.
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ use_iterator_for_get_pages=False,
+ caller="TestApp TestVendor",
+ )
+ assert d._session.use_iterator_for_get_pages is False
+
+ @patch("meraki.session.base.check_python_version")
+ def test_inherit_logging_config_param_takes_effect(self, mock_check):
+ # Regression for removed dead self-assignment
+ # `inherit_logging_config = inherit_logging_config`: passing True must
+ # leave the logger at its inherited (non-DEBUG-forced) level.
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=False,
+ inherit_logging_config=True,
+ caller="TestApp TestVendor",
+ )
+ assert d._logger is not None
+ d._logger.handlers.clear()
+
+ @patch("meraki.session.base.check_python_version")
def test_caller_from_env(self, mock_check):
with patch.dict(os.environ, {"MERAKI_PYTHON_SDK_CALLER": "EnvApp EnvVendor"}):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
suppress_logging=True,
)
- assert "EnvApp EnvVendor" in d._session._req_session.headers["User-Agent"]
+ assert "EnvApp EnvVendor" in d._session._client.headers["User-Agent"]
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_all_api_sections_initialized(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -111,9 +134,46 @@ def test_all_api_sections_initialized(self, mock_check):
assert d.appliance is not None
assert d.wireless is not None
+ @patch("meraki.session.base.check_python_version")
+ def test_be_geo_id_from_env(self, mock_check):
+ with patch.dict(os.environ, {"BE_GEO_ID": "GeoApp V1"}):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ )
+ assert "GeoApp V1" in d._session._client.headers["User-Agent"]
+
+ @patch("meraki.session.base.check_python_version")
+ def test_batch_initialized(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ caller="TestApp TestVendor",
+ )
+ assert d.batch is not None
+
+ @patch("meraki.session.base.check_python_version")
+ def test_all_additional_sections(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ caller="TestApp TestVendor",
+ )
+ assert d.administered is not None
+ assert d.camera is not None
+ assert d.cellularGateway is not None
+ assert d.insight is not None
+ assert d.licensing is not None
+ assert d.sensor is not None
+ assert d.sm is not None
+ assert d.switch is not None
+ assert d.spaces is not None
+ assert d.wirelessController is not None
+ assert d.campusGateway is not None
+
class TestDashboardAPILogging:
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_inherit_logging_config(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -124,7 +184,7 @@ def test_inherit_logging_config(self, mock_check):
assert d._logger is not None
assert d._logger.name == "meraki"
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_output_log_with_print_console(self, mock_check, tmp_path):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -140,10 +200,9 @@ def test_output_log_with_print_console(self, mock_check, tmp_path):
assert d._logger.level == logging.DEBUG
assert hasattr(d, "_log_file")
assert "test_log__" in d._log_file
- # Clean up handlers to avoid pollution
d._logger.handlers.clear()
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_output_log_path_without_trailing_slash(self, mock_check, tmp_path):
log_path = str(tmp_path).rstrip("/").rstrip("\\")
d = meraki.DashboardAPI(
@@ -160,7 +219,7 @@ def test_output_log_path_without_trailing_slash(self, mock_check, tmp_path):
assert "pfx_log__" in d._log_file
d._logger.handlers.clear()
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_output_log_path_with_trailing_slash(self, mock_check, tmp_path):
log_path = str(tmp_path) + "/"
d = meraki.DashboardAPI(
@@ -176,7 +235,7 @@ def test_output_log_path_with_trailing_slash(self, mock_check, tmp_path):
assert "//" not in d._log_file.replace("\\", "/").replace("//", "/", 1)
d._logger.handlers.clear()
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_no_output_log_with_print_console(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -190,7 +249,7 @@ def test_no_output_log_with_print_console(self, mock_check):
assert d._logger.level == logging.DEBUG
d._logger.handlers.clear()
- @patch("meraki.rest_session.check_python_version")
+ @patch("meraki.session.base.check_python_version")
def test_no_output_log_no_print_console(self, mock_check):
d = meraki.DashboardAPI(
"test_key_1234567890123456789012345678901234567890",
@@ -202,3 +261,238 @@ def test_no_output_log_no_print_console(self, mock_check):
)
assert d._logger is not None
d._logger.handlers.clear()
+
+
+class TestDashboardAPILoggingHandlers:
+ @patch("meraki.session.base.check_python_version")
+ def test_handlers_added_when_no_existing_handlers(self, mock_check, tmp_path):
+ with patch("meraki.__init__.logging.getLogger") as mock_get_logger:
+ mock_logger = MagicMock()
+ mock_logger.hasHandlers.return_value = False
+ mock_get_logger.return_value = mock_logger
+ meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=False,
+ inherit_logging_config=False,
+ output_log=True,
+ log_path=str(tmp_path),
+ log_file_prefix="t",
+ print_console=True,
+ caller="TestApp TestVendor",
+ )
+ assert mock_logger.addHandler.call_count == 2
+
+ @patch("meraki.session.base.check_python_version")
+ def test_no_handlers_added_when_already_has_handlers(self, mock_check, tmp_path):
+ with patch("meraki.__init__.logging.getLogger") as mock_get_logger:
+ mock_logger = MagicMock()
+ mock_logger.hasHandlers.return_value = True
+ mock_get_logger.return_value = mock_logger
+ meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=False,
+ inherit_logging_config=False,
+ output_log=True,
+ log_path=str(tmp_path),
+ log_file_prefix="t",
+ print_console=True,
+ caller="TestApp TestVendor",
+ )
+ assert mock_logger.addHandler.call_count == 0
+
+ @patch("meraki.session.base.check_python_version")
+ def test_console_only_handler_when_no_output_log(self, mock_check):
+ with patch("meraki.__init__.logging.getLogger") as mock_get_logger:
+ mock_logger = MagicMock()
+ mock_logger.hasHandlers.return_value = False
+ mock_get_logger.return_value = mock_logger
+ meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=False,
+ inherit_logging_config=False,
+ output_log=False,
+ print_console=True,
+ caller="TestApp TestVendor",
+ )
+ assert mock_logger.addHandler.call_count == 1
+
+
+class TestDashboardAPISmartLimiting:
+ @patch("meraki.session.base.check_python_version")
+ def test_smart_flow_enabled_by_default(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ caller="TestApp TestVendor",
+ )
+ assert d._session._smart_flow is not None
+
+ @patch("meraki.session.base.check_python_version")
+ def test_smart_flow_disabled_explicitly(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=False,
+ caller="TestApp TestVendor",
+ )
+ assert d._session._smart_flow is None
+
+ @patch("meraki.session.base.check_python_version")
+ def test_smart_flow_creates_limiter(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=True,
+ smart_flow_cache_mode="lazy",
+ caller="TestApp TestVendor",
+ )
+ assert d._session._smart_flow is not None
+
+ @patch("meraki.session.base.check_python_version")
+ def test_smart_flow_with_cache_path(self, mock_check, tmp_path):
+ cache_file = str(tmp_path / "test_cache.json")
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=True,
+ smart_flow_cache_mode="lazy",
+ smart_flow_cache_path=cache_file,
+ caller="TestApp TestVendor",
+ )
+ assert d._session._smart_flow is not None
+
+
+class TestDashboardAPIEagerLoad:
+ @patch("meraki.session.base.check_python_version")
+ def test_eager_load_populates_cache(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=True,
+ smart_flow_cache_mode="lazy",
+ caller="TestApp TestVendor",
+ )
+ mock_orgs = [{"id": "org_1"}, {"id": "org_2"}]
+ mock_networks = [{"id": "N_1"}, {"id": "N_2"}]
+ mock_devices = [{"serial": "QABC-1234-5678"}, {"serial": "QDEF-5678-9012"}]
+
+ with patch.object(d.organizations, "getOrganizations", return_value=mock_orgs):
+ with patch.object(d.organizations, "getOrganizationNetworks", return_value=mock_networks):
+ with patch.object(d.organizations, "getOrganizationInventoryDevices", return_value=mock_devices):
+ d._eager_load_rate_limit_cache()
+
+ limiter = d._session._smart_flow
+ assert limiter.resolve_org("/organizations/org_1/x") == "org_1"
+ assert limiter.resolve_org("/networks/N_1/x") is not None
+ assert limiter.resolve_org("/devices/QABC-1234-5678/x") is not None
+
+ @patch("meraki.session.base.check_python_version")
+ def test_eager_load_handles_org_failure(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=True,
+ smart_flow_cache_mode="lazy",
+ caller="TestApp TestVendor",
+ )
+ with patch.object(d.organizations, "getOrganizations", side_effect=Exception("API error")):
+ d._eager_load_rate_limit_cache()
+
+ @patch("meraki.session.base.check_python_version")
+ def test_eager_load_handles_network_failure(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=True,
+ smart_flow_cache_mode="lazy",
+ caller="TestApp TestVendor",
+ )
+ mock_orgs = [{"id": "org_1"}]
+ with patch.object(d.organizations, "getOrganizations", return_value=mock_orgs):
+ with patch.object(d.organizations, "getOrganizationNetworks", side_effect=Exception("net fail")):
+ with patch.object(d.organizations, "getOrganizationInventoryDevices", return_value=[]):
+ d._eager_load_rate_limit_cache()
+ assert d._session._smart_flow.resolve_org("/organizations/org_1/x") == "org_1"
+
+ @patch("meraki.session.base.check_python_version")
+ def test_eager_load_handles_device_failure(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=True,
+ smart_flow_cache_mode="lazy",
+ caller="TestApp TestVendor",
+ )
+ mock_orgs = [{"id": "org_1"}]
+ mock_networks = [{"id": "N_1"}]
+ with patch.object(d.organizations, "getOrganizations", return_value=mock_orgs):
+ with patch.object(d.organizations, "getOrganizationNetworks", return_value=mock_networks):
+ with patch.object(d.organizations, "getOrganizationInventoryDevices", side_effect=Exception("dev fail")):
+ d._eager_load_rate_limit_cache()
+ assert d._session._smart_flow.resolve_org("/networks/N_1/x") == "org_1"
+
+ @patch("meraki.session.base.check_python_version")
+ def test_eager_load_skips_devices_without_serial(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=True,
+ smart_flow_cache_mode="lazy",
+ caller="TestApp TestVendor",
+ )
+ mock_orgs = [{"id": "org_1"}]
+ mock_devices = [{"serial": "QABC-1234-5678"}, {"name": "no-serial-device"}]
+ with patch.object(d.organizations, "getOrganizations", return_value=mock_orgs):
+ with patch.object(d.organizations, "getOrganizationNetworks", return_value=[]):
+ with patch.object(d.organizations, "getOrganizationInventoryDevices", return_value=mock_devices):
+ d._eager_load_rate_limit_cache()
+ limiter = d._session._smart_flow
+ assert limiter.resolve_org("/devices/QABC-1234-5678/x") == "org_1"
+
+ @patch("meraki.session.base.check_python_version")
+ def test_eager_load_noop_without_limiter(self, mock_check):
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=False,
+ caller="TestApp TestVendor",
+ )
+ d._eager_load_rate_limit_cache()
+
+ @patch("meraki.session.base.check_python_version")
+ def test_eager_load_runs_during_init_when_cache_not_fresh(self, mock_check, tmp_path):
+ cache_file = str(tmp_path / "nonexistent_cache.json")
+ with patch("meraki.api.organizations.Organizations.getOrganizations", return_value=[]):
+ meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=True,
+ smart_flow_cache_mode="eager",
+ smart_flow_cache_path=cache_file,
+ caller="TestApp TestVendor",
+ )
+
+ @patch("meraki.session.base.check_python_version")
+ def test_eager_load_skipped_when_cache_fresh(self, mock_check, tmp_path):
+ import json
+ from datetime import datetime, timezone
+
+ cache_file = tmp_path / "cache.json"
+ cache_file.write_text(
+ json.dumps(
+ {
+ "saved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "networks": [{"id": "N_cached", "organization": {"id": "org_cached"}}],
+ "devices": [],
+ }
+ )
+ )
+ d = meraki.DashboardAPI(
+ "test_key_1234567890123456789012345678901234567890",
+ suppress_logging=True,
+ smart_flow_enabled=True,
+ smart_flow_cache_mode="eager",
+ smart_flow_cache_path=str(cache_file),
+ caller="TestApp TestVendor",
+ )
+ assert d._session._smart_flow.resolve_org("/networks/N_cached/x") == "org_cached"
diff --git a/tests/unit/test_encoding.py b/tests/unit/test_encoding.py
new file mode 100644
index 00000000..de8b2f9a
--- /dev/null
+++ b/tests/unit/test_encoding.py
@@ -0,0 +1,123 @@
+"""Tests for meraki.encoding module (HTTP-04, QUAL-03)."""
+
+import inspect
+
+from hypothesis import given, strategies as st
+from urllib.parse import parse_qs
+
+from meraki.encoding import encode_meraki_params
+
+
+class TestEncodeMerakiParams:
+ """Unit tests replicating behavioral spec from test_rest_session.py."""
+
+ def test_string_passthrough(self):
+ assert encode_meraki_params("already_encoded") == "already_encoded"
+
+ def test_bytes_passthrough(self):
+ assert encode_meraki_params(b"raw") == b"raw"
+
+ def test_file_like_passthrough(self):
+ class FakeFile:
+ def read(self):
+ pass
+
+ f = FakeFile()
+ assert encode_meraki_params(f) is f
+
+ def test_non_iterable_passthrough(self):
+ assert encode_meraki_params(42) == 42
+
+ def test_simple_dict(self):
+ result = encode_meraki_params({"key": "value"})
+ assert "key=value" in result
+
+ def test_list_values(self):
+ result = encode_meraki_params({"tag": ["a", "b"]})
+ assert "tag=a" in result
+ assert "tag=b" in result
+
+ def test_array_of_objects(self):
+ result = encode_meraki_params({"param[]": [{"key1": "val1"}, {"key2": "val2"}]})
+ assert "param%5B%5Dkey1=val1" in result
+ assert "param%5B%5Dkey2=val2" in result
+
+ def test_list_of_tuples(self):
+ result = encode_meraki_params([("k", "v")])
+ assert "k=v" in result
+
+
+class TestNoRequestsDependency:
+ """Verify HTTP-04: no requests import in encoding module."""
+
+ def test_no_requests_import(self):
+ import meraki.encoding
+
+ source = inspect.getsource(meraki.encoding)
+ assert "import requests" not in source
+ assert "from requests" not in source
+
+
+# --- Property-based tests (QUAL-03, per D-04: roundtrip fidelity) ---
+
+# Strategy: printable text keys (no surrogates, no empty)
+_key_strategy = st.text(
+ alphabet=st.characters(whitelist_categories=("L", "N", "P"), blacklist_characters="="),
+ min_size=1,
+ max_size=20,
+)
+
+_value_strategy = st.text(
+ alphabet=st.characters(whitelist_categories=("L", "N", "P"), blacklist_characters="="),
+ min_size=1,
+ max_size=50,
+)
+
+
+@given(
+ st.dictionaries(
+ keys=_key_strategy,
+ values=st.lists(_value_strategy, min_size=1, max_size=5),
+ )
+)
+def test_roundtrip_simple(data):
+ """(D-04) Encoded output parsed back with parse_qs reconstructs keys and values."""
+ encoded = encode_meraki_params(data)
+ if not data:
+ assert encoded == ""
+ return
+ decoded = parse_qs(encoded)
+ assert set(decoded.keys()) == set(data.keys())
+ for k in data:
+ assert decoded[k] == data[k]
+
+
+@given(
+ st.dictionaries(
+ keys=_key_strategy,
+ values=st.lists(
+ st.dictionaries(
+ keys=_key_strategy,
+ values=_value_strategy,
+ min_size=1,
+ max_size=3,
+ ),
+ min_size=1,
+ max_size=3,
+ ),
+ )
+)
+def test_roundtrip_array_of_objects(data):
+ """(D-04, D-05) Array-of-objects encoding roundtrips: param+inner_key maps to value."""
+ encoded = encode_meraki_params(data)
+ if not data:
+ assert encoded == ""
+ return
+ decoded = parse_qs(encoded)
+ # Verify all expected keys exist
+ for param, obj_list in data.items():
+ for obj in obj_list:
+ for inner_key, inner_val in obj.items():
+ composite_key = param + inner_key
+ assert composite_key in decoded
+ assert inner_val in decoded[composite_key]
diff --git a/tests/unit/test_encoding_property.py b/tests/unit/test_encoding_property.py
new file mode 100644
index 00000000..c5dd5302
--- /dev/null
+++ b/tests/unit/test_encoding_property.py
@@ -0,0 +1,71 @@
+"""Property-based tests for meraki.encoding using hypothesis."""
+
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from meraki.encoding import encode_meraki_params
+
+
+simple_params = st.dictionaries(
+ keys=st.text(min_size=1, max_size=20, alphabet=st.characters(whitelist_categories=("L", "N"))),
+ values=st.one_of(st.text(max_size=50), st.integers(), st.floats(allow_nan=False, allow_infinity=False)),
+ min_size=1,
+ max_size=10,
+)
+
+
+class TestEncodeRoundtripProperties:
+ @given(data=simple_params)
+ @settings(max_examples=200)
+ def test_always_returns_string(self, data):
+ """encode_meraki_params always returns a string for dict input."""
+ result = encode_meraki_params(data)
+ assert isinstance(result, str)
+
+ @given(data=simple_params)
+ @settings(max_examples=200)
+ def test_all_keys_present_in_output(self, data):
+ """Every key from input dict appears (URL-encoded) in output."""
+ from urllib.parse import quote_plus
+
+ result = encode_meraki_params(data)
+ for key in data:
+ encoded_key = quote_plus(key)
+ assert encoded_key in result, f"Key '{key}' (encoded: '{encoded_key}') missing from output"
+
+ @given(text=st.text(max_size=100))
+ def test_string_passthrough(self, text):
+ """String input passes through unchanged."""
+ assert encode_meraki_params(text) is text
+
+ @given(data=st.binary(max_size=100))
+ def test_bytes_passthrough(self, data):
+ """Bytes input passes through unchanged."""
+ assert encode_meraki_params(data) is data
+
+ def test_none_values_excluded(self):
+ """None values are excluded from encoding."""
+ data = {"key1": "value1", "key2": None}
+ result = encode_meraki_params(data)
+ assert "key1" in result
+ assert "key2" not in result
+
+ def test_empty_dict_returns_empty_string(self):
+ """Empty dict produces empty string."""
+ result = encode_meraki_params({})
+ assert result == ""
+
+ def test_file_like_object_passthrough(self):
+ """Objects with .read() attribute pass through."""
+ import io
+
+ f = io.BytesIO(b"file content")
+ assert encode_meraki_params(f) is f
+
+ def test_array_of_objects_encoding(self):
+ """Array-of-objects uses Meraki bracket concatenation."""
+ data = {"rules[]": [{"policy": "allow", "destPort": "80"}]}
+ result = encode_meraki_params(data)
+ assert "rules" in result
+ assert "policy" in result
+ assert "allow" in result
diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py
index 05e1e184..64f9e0bf 100644
--- a/tests/unit/test_exceptions.py
+++ b/tests/unit/test_exceptions.py
@@ -51,12 +51,10 @@ def test_str(self):
class TestAPIError:
- def _make_response(
- self, status_code=400, reason="Bad Request", json_data=None, content=b""
- ):
+ def _make_response(self, status_code=400, reason_phrase="Bad Request", json_data=None, content=b""):
resp = MagicMock()
resp.status_code = status_code
- resp.reason = reason
+ resp.reason_phrase = reason_phrase
resp.json.return_value = json_data or {"errors": ["something"]}
resp.content = content
return resp
@@ -84,7 +82,7 @@ def test_none_response_fields(self):
metadata = {"tags": ["orgs"], "operation": "getOrgs"}
resp = MagicMock()
resp.status_code = None
- resp.reason = None
+ resp.reason_phrase = None
resp.json.return_value = None
err = APIError(metadata, resp)
assert err.status is None
@@ -95,7 +93,7 @@ def test_json_decode_error_falls_back_to_content(self):
metadata = {"tags": ["orgs"], "operation": "getOrgs"}
resp = MagicMock()
resp.status_code = 500
- resp.reason = "Server Error"
+ resp.reason_phrase = "Server Error"
resp.json.side_effect = ValueError("No JSON")
resp.content = b"Internal Server Error"
err = APIError(metadata, resp)
@@ -105,7 +103,7 @@ def test_404_appends_wait_message(self):
metadata = {"tags": ["orgs"], "operation": "getOrg"}
resp = MagicMock()
resp.status_code = 404
- resp.reason = "Not Found"
+ resp.reason_phrase = "Not Found"
resp.json.side_effect = ValueError("No JSON")
resp.content = b"Not found here"
err = APIError(metadata, resp)
@@ -115,7 +113,7 @@ def test_non_404_does_not_append_wait_message(self):
metadata = {"tags": ["orgs"], "operation": "getOrg"}
resp = MagicMock()
resp.status_code = 500
- resp.reason = "Server Error"
+ resp.reason_phrase = "Server Error"
resp.json.side_effect = ValueError("No JSON")
resp.content = b"error text"
err = APIError(metadata, resp)
@@ -123,16 +121,17 @@ def test_non_404_does_not_append_wait_message(self):
class TestAsyncAPIError:
- def _make_response(self, status=400, reason="Bad Request"):
+ def _make_response(self, status_code=400, reason_phrase="Bad Request"):
resp = MagicMock()
- resp.status = status
- resp.reason = reason
+ resp.status_code = status_code
+ resp.reason_phrase = reason_phrase
return resp
def test_basic_init(self):
metadata = {"tags": ["devices"], "operation": "getDevices"}
resp = self._make_response()
- err = AsyncAPIError(metadata, resp, {"errors": ["fail"]})
+ with pytest.warns(DeprecationWarning):
+ err = AsyncAPIError(metadata, resp, {"errors": ["fail"]})
assert err.tag == "devices"
assert err.operation == "getDevices"
assert err.status == 400
@@ -142,7 +141,8 @@ def test_basic_init(self):
def test_repr(self):
metadata = {"tags": ["devices"], "operation": "getDevices"}
resp = self._make_response()
- err = AsyncAPIError(metadata, resp, "some error")
+ with pytest.warns(DeprecationWarning):
+ err = AsyncAPIError(metadata, resp, "some error")
r = repr(err)
assert "devices" in r
assert "400" in r
@@ -150,27 +150,52 @@ def test_repr(self):
def test_string_message_stripped(self):
metadata = {"tags": ["orgs"], "operation": "getOrgs"}
resp = self._make_response()
- err = AsyncAPIError(metadata, resp, " spaces around ")
+ with pytest.warns(DeprecationWarning):
+ err = AsyncAPIError(metadata, resp, " spaces around ")
assert err.message == "spaces around"
def test_404_appends_wait_message(self):
metadata = {"tags": ["orgs"], "operation": "getOrg"}
- resp = self._make_response(status=404, reason="Not Found")
- err = AsyncAPIError(metadata, resp, "resource missing")
+ resp = self._make_response(status_code=404, reason_phrase="Not Found")
+ with pytest.warns(DeprecationWarning):
+ err = AsyncAPIError(metadata, resp, "resource missing")
assert "please wait" in err.message
def test_non_404_does_not_append_wait_message(self):
metadata = {"tags": ["orgs"], "operation": "getOrg"}
- resp = self._make_response(status=500, reason="Server Error")
- err = AsyncAPIError(metadata, resp, "server broke")
+ resp = self._make_response(status_code=500, reason_phrase="Server Error")
+ with pytest.warns(DeprecationWarning):
+ err = AsyncAPIError(metadata, resp, "server broke")
assert "please wait" not in err.message
def test_none_response(self):
metadata = {"tags": ["orgs"], "operation": "getOrg"}
- err = AsyncAPIError(metadata, None, "no response")
+ with pytest.warns(DeprecationWarning):
+ err = AsyncAPIError(metadata, None, "no response")
assert err.status is None
assert err.reason is None
+ def test_is_subclass_of_api_error(self):
+ metadata = {"tags": ["devices"], "operation": "getDevices"}
+ resp = self._make_response()
+ with pytest.warns(DeprecationWarning):
+ err = AsyncAPIError(metadata, resp, "msg")
+ assert isinstance(err, APIError)
+
+ def test_emits_deprecation_warning(self):
+ metadata = {"tags": ["devices"], "operation": "getDevices"}
+ resp = self._make_response()
+ with pytest.warns(DeprecationWarning, match="AsyncAPIError is deprecated"):
+ AsyncAPIError(metadata, resp, {"errors": ["fail"]})
+
+ def test_2arg_signature_delegates_to_parent(self):
+ metadata = {"tags": ["networks"], "operation": "getNetworks"}
+ resp = self._make_response()
+ resp.json.return_value = {"errors": ["server failed"]}
+ with pytest.warns(DeprecationWarning):
+ err = AsyncAPIError(metadata, resp)
+ assert err.message == {"errors": ["server failed"]}
+
class TestPythonVersionError:
def test_message_preserved(self):
@@ -181,9 +206,7 @@ def test_message_preserved(self):
class TestSessionInputError:
def test_fields(self):
- err = SessionInputError(
- "CALLER", "bad!!!", "Format wrong", "https://docs.example.com"
- )
+ err = SessionInputError("CALLER", "bad!!!", "Format wrong", "https://docs.example.com")
assert err.argument == "CALLER"
assert err.value == "bad!!!"
assert err.message == "Format wrong"
diff --git a/tests/unit/test_mock_integration.py b/tests/unit/test_mock_integration.py
index 055738ac..3d968394 100644
--- a/tests/unit/test_mock_integration.py
+++ b/tests/unit/test_mock_integration.py
@@ -4,8 +4,9 @@
but against canned HTTP responses, so it can run in CI without API keys.
"""
+import httpx
import pytest
-import responses
+import respx
import meraki
@@ -18,7 +19,7 @@
@pytest.fixture
def mock_api():
- with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+ with respx.mock(assert_all_mocked=False) as rsps:
yield rsps
@@ -37,14 +38,15 @@ def dashboard(mock_api):
class TestGetAdministeredIdentitiesMe:
def test_returns_identity(self, mock_api, dashboard):
- mock_api.add(
- responses.GET,
- f"{BASE}/administered/identities/me",
- json={
- "name": "Test User",
- "email": "test@example.com",
- "authentication": {"api": {"key": {"created": True}}},
- },
+ mock_api.get(f"{BASE}/administered/identities/me").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "name": "Test User",
+ "email": "test@example.com",
+ "authentication": {"api": {"key": {"created": True}}},
+ },
+ )
)
me = dashboard.administered.getAdministeredIdentitiesMe()
assert me is not None
@@ -57,20 +59,14 @@ def test_returns_identity(self, mock_api, dashboard):
class TestOrganizations:
def test_get_organizations(self, mock_api, dashboard):
- mock_api.add(
- responses.GET,
- f"{BASE}/organizations",
- json=[{"id": ORG_ID, "name": "Test Org"}],
- )
+ mock_api.get(f"{BASE}/organizations").mock(return_value=httpx.Response(200, json=[{"id": ORG_ID, "name": "Test Org"}]))
orgs = dashboard.organizations.getOrganizations()
assert orgs is not None
assert len(orgs) > 0
def test_get_organization(self, mock_api, dashboard):
- mock_api.add(
- responses.GET,
- f"{BASE}/organizations/{ORG_ID}",
- json={"id": ORG_ID, "name": "Test Org"},
+ mock_api.get(f"{BASE}/organizations/{ORG_ID}").mock(
+ return_value=httpx.Response(200, json={"id": ORG_ID, "name": "Test Org"})
)
org = dashboard.organizations.getOrganization(ORG_ID)
assert isinstance(org, dict)
@@ -82,16 +78,17 @@ def test_get_organization(self, mock_api, dashboard):
class TestNetworks:
def test_create_network(self, mock_api, dashboard):
- mock_api.add(
- responses.POST,
- f"{BASE}/organizations/{ORG_ID}/networks",
- json={
- "id": NETWORK_ID,
- "name": "_Test Network",
- "productTypes": ["appliance", "switch", "wireless"],
- "tags": ["test_tag"],
- "timeZone": "America/Los_Angeles",
- },
+ mock_api.post(f"{BASE}/organizations/{ORG_ID}/networks").mock(
+ return_value=httpx.Response(
+ 201,
+ json={
+ "id": NETWORK_ID,
+ "name": "_Test Network",
+ "productTypes": ["appliance", "switch", "wireless"],
+ "tags": ["test_tag"],
+ "timeZone": "America/Los_Angeles",
+ },
+ )
)
network = dashboard.organizations.createOrganizationNetwork(
ORG_ID,
@@ -104,38 +101,30 @@ def test_create_network(self, mock_api, dashboard):
assert network["name"] == "_Test Network"
def test_get_organization_networks(self, mock_api, dashboard):
- mock_api.add(
- responses.GET,
- f"{BASE}/organizations/{ORG_ID}/networks",
- json=[{"id": NETWORK_ID, "name": "_Test Network"}],
+ mock_api.get(f"{BASE}/organizations/{ORG_ID}/networks").mock(
+ return_value=httpx.Response(200, json=[{"id": NETWORK_ID, "name": "_Test Network"}])
)
networks = dashboard.organizations.getOrganizationNetworks(ORG_ID)
assert networks is not None
assert len(networks) > 0
def test_update_network(self, mock_api, dashboard):
- mock_api.add(
- responses.PUT,
- f"{BASE}/networks/{NETWORK_ID}",
- json={
- "id": NETWORK_ID,
- "name": "_Test Network new",
- "tags": ["updated_test_tag"],
- },
- )
- updated = dashboard.networks.updateNetwork(
- NETWORK_ID, name="_Test Network new", tags=["updated_test_tag"]
+ mock_api.put(f"{BASE}/networks/{NETWORK_ID}").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "id": NETWORK_ID,
+ "name": "_Test Network new",
+ "tags": ["updated_test_tag"],
+ },
+ )
)
+ updated = dashboard.networks.updateNetwork(NETWORK_ID, name="_Test Network new", tags=["updated_test_tag"])
assert updated is not None
assert updated["name"] == "_Test Network new"
def test_delete_network(self, mock_api, dashboard):
- mock_api.add(
- responses.DELETE,
- f"{BASE}/networks/{NETWORK_ID}",
- body="",
- status=204,
- )
+ mock_api.delete(f"{BASE}/networks/{NETWORK_ID}").mock(return_value=httpx.Response(204))
result = dashboard.networks.deleteNetwork(NETWORK_ID)
assert result is None
@@ -145,16 +134,17 @@ def test_delete_network(self, mock_api, dashboard):
class TestPolicyObjects:
def test_create_policy_object(self, mock_api, dashboard):
- mock_api.add(
- responses.POST,
- f"{BASE}/organizations/{ORG_ID}/policyObjects",
- json={
- "id": POLICY_OBJ_1_ID,
- "name": "Ham",
- "category": "network",
- "type": "cidr",
- "cidr": "10.51.1.253",
- },
+ mock_api.post(f"{BASE}/organizations/{ORG_ID}/policyObjects").mock(
+ return_value=httpx.Response(
+ 201,
+ json={
+ "id": POLICY_OBJ_1_ID,
+ "name": "Ham",
+ "category": "network",
+ "type": "cidr",
+ "cidr": "10.51.1.253",
+ },
+ )
)
obj = dashboard.organizations.createOrganizationPolicyObject(
ORG_ID, name="Ham", category="network", type="cidr", cidr="10.51.1.253"
@@ -163,28 +153,24 @@ def test_create_policy_object(self, mock_api, dashboard):
assert isinstance(obj["id"], str)
def test_get_policy_objects(self, mock_api, dashboard):
- mock_api.add(
- responses.GET,
- f"{BASE}/organizations/{ORG_ID}/policyObjects",
- json=[
- {"id": POLICY_OBJ_1_ID, "name": "Ham"},
- {"id": POLICY_OBJ_2_ID, "name": "Hamlet"},
- ],
+ mock_api.get(f"{BASE}/organizations/{ORG_ID}/policyObjects").mock(
+ return_value=httpx.Response(
+ 200,
+ json=[
+ {"id": POLICY_OBJ_1_ID, "name": "Ham"},
+ {"id": POLICY_OBJ_2_ID, "name": "Hamlet"},
+ ],
+ )
)
objs = dashboard.organizations.getOrganizationPolicyObjects(ORG_ID)
assert objs is not None
assert len(objs) > 0
def test_delete_policy_object(self, mock_api, dashboard):
- mock_api.add(
- responses.DELETE,
- f"{BASE}/organizations/{ORG_ID}/policyObjects/{POLICY_OBJ_1_ID}",
- body="",
- status=204,
- )
- result = dashboard.organizations.deleteOrganizationPolicyObject(
- ORG_ID, POLICY_OBJ_1_ID
+ mock_api.delete(f"{BASE}/organizations/{ORG_ID}/policyObjects/{POLICY_OBJ_1_ID}").mock(
+ return_value=httpx.Response(204)
)
+ result = dashboard.organizations.deleteOrganizationPolicyObject(ORG_ID, POLICY_OBJ_1_ID)
assert result is None
@@ -193,43 +179,43 @@ def test_delete_policy_object(self, mock_api, dashboard):
class TestAppliance:
def test_get_l3_firewall_rules(self, mock_api, dashboard):
- mock_api.add(
- responses.GET,
- f"{BASE}/networks/{NETWORK_ID}/appliance/firewall/l3FirewallRules",
- json={
- "rules": [
- {"comment": "Default rule", "policy": "allow", "protocol": "Any"}
- ]
- },
- )
- rules = dashboard.appliance.getNetworkApplianceFirewallL3FirewallRules(
- NETWORK_ID
+ mock_api.get(f"{BASE}/networks/{NETWORK_ID}/appliance/firewall/l3FirewallRules").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "rules": [
+ {
+ "comment": "Default rule",
+ "policy": "allow",
+ "protocol": "Any",
+ }
+ ]
+ },
+ )
)
+ rules = dashboard.appliance.getNetworkApplianceFirewallL3FirewallRules(NETWORK_ID)
assert rules is not None
assert len(rules) > 0
def test_update_vlan_settings(self, mock_api, dashboard):
- mock_api.add(
- responses.PUT,
- f"{BASE}/networks/{NETWORK_ID}/appliance/vlans/settings",
- json={"vlansEnabled": True},
- )
- resp = dashboard.appliance.updateNetworkApplianceVlansSettings(
- NETWORK_ID, vlansEnabled=True
+ mock_api.put(f"{BASE}/networks/{NETWORK_ID}/appliance/vlans/settings").mock(
+ return_value=httpx.Response(200, json={"vlansEnabled": True})
)
+ resp = dashboard.appliance.updateNetworkApplianceVlansSettings(NETWORK_ID, vlansEnabled=True)
assert resp is not None
assert resp["vlansEnabled"]
def test_create_vlan(self, mock_api, dashboard):
- mock_api.add(
- responses.POST,
- f"{BASE}/networks/{NETWORK_ID}/appliance/vlans",
- json={
- "id": "51",
- "name": "testy_vlan",
- "subnet": "10.51.1.0/24",
- "applianceIp": "10.51.1.1",
- },
+ mock_api.post(f"{BASE}/networks/{NETWORK_ID}/appliance/vlans").mock(
+ return_value=httpx.Response(
+ 201,
+ json={
+ "id": "51",
+ "name": "testy_vlan",
+ "subnet": "10.51.1.0/24",
+ "applianceIp": "10.51.1.1",
+ },
+ )
)
vlan = dashboard.appliance.createNetworkApplianceVlan(
NETWORK_ID,
@@ -242,16 +228,25 @@ def test_create_vlan(self, mock_api, dashboard):
assert vlan["name"] == "testy_vlan"
def test_update_l3_firewall_rules(self, mock_api, dashboard):
- mock_api.add(
- responses.PUT,
- f"{BASE}/networks/{NETWORK_ID}/appliance/firewall/l3FirewallRules",
- json={
- "rules": [
- {"comment": "HamByIP", "policy": "deny", "protocol": "tcp"},
- {"comment": "Ham", "policy": "deny", "protocol": "tcp"},
- {"comment": "Default rule", "policy": "allow", "protocol": "Any"},
- ]
- },
+ mock_api.put(f"{BASE}/networks/{NETWORK_ID}/appliance/firewall/l3FirewallRules").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "rules": [
+ {
+ "comment": "HamByIP",
+ "policy": "deny",
+ "protocol": "tcp",
+ },
+ {"comment": "Ham", "policy": "deny", "protocol": "tcp"},
+ {
+ "comment": "Default rule",
+ "policy": "allow",
+ "protocol": "Any",
+ },
+ ]
+ },
+ )
)
updated = dashboard.appliance.updateNetworkApplianceFirewallL3FirewallRules(
NETWORK_ID,
diff --git a/tests/unit/test_rest_session.py b/tests/unit/test_rest_session.py
index 1fb4b58a..1c593c5d 100644
--- a/tests/unit/test_rest_session.py
+++ b/tests/unit/test_rest_session.py
@@ -1,111 +1,11 @@
from unittest.mock import MagicMock, patch
+import httpx
import pytest
-import requests
from meraki.exceptions import APIError, SessionInputError
-from meraki.rest_session import RestSession, encode_params, user_agent_extended
-
-
-@pytest.fixture
-def session():
- with patch("meraki.rest_session.check_python_version"):
- s = RestSession(
- logger=None,
- api_key="fake_api_key_1234567890123456789012345678901234567890",
- base_url="https://api.meraki.com/api/v1",
- single_request_timeout=60,
- certificate_path="",
- requests_proxy="",
- wait_on_rate_limit=True,
- nginx_429_retry_wait_time=2,
- action_batch_retry_wait_time=2,
- network_delete_retry_wait_time=2,
- retry_4xx_error=False,
- retry_4xx_error_wait_time=1,
- maximum_retries=3,
- simulate=False,
- be_geo_id="",
- caller="TestApp TestVendor",
- use_iterator_for_get_pages=False,
- )
- return s
-
-
-def _metadata(operation="getOrganizations", tags=None):
- return {"tags": tags or ["organizations"], "operation": operation}
-
-
-def _mock_response(
- status_code=200,
- json_data=None,
- reason="OK",
- headers=None,
- content=b'{"ok":true}',
- links=None,
-):
- resp = MagicMock(spec=requests.Response)
- resp.status_code = status_code
- resp.reason = reason
- resp.headers = headers or {}
- resp.content = content
- resp.links = links or {}
- resp.json.return_value = json_data if json_data is not None else {"ok": True}
- resp.close = MagicMock()
- return resp
-
-
-# --- encode_params tests ---
-
-
-class TestEncodeParams:
- def test_string_passthrough(self):
- assert encode_params(None, "already_encoded") == "already_encoded"
-
- def test_bytes_passthrough(self):
- assert encode_params(None, b"raw") == b"raw"
-
- def test_file_like_passthrough(self):
- class FakeFile:
- def read(self):
- pass
-
- f = FakeFile()
- assert encode_params(None, f) is f
-
- def test_simple_dict(self):
- result = encode_params(None, {"key": "value"})
- assert "key=value" in result
- def test_list_values(self):
- result = encode_params(None, {"tag": ["a", "b"]})
- assert "tag=a" in result
- assert "tag=b" in result
-
- def test_dict_values_appended_keys(self):
- result = encode_params(None, {"param[]": [{"key1": "val1"}, {"key2": "val2"}]})
- assert "param%5B%5Dkey1=val1" in result
- assert "param%5B%5Dkey2=val2" in result
-
- def test_none_passthrough(self):
- assert encode_params(None, 42) == 42
-
-
-# --- user_agent_extended tests ---
-
-
-class TestUserAgentExtended:
- def test_with_caller(self):
- result = user_agent_extended(None, "MyApp MyVendor")
- assert "MyApp MyVendor" in result
-
- def test_with_be_geo_id_fallback(self):
- result = user_agent_extended("geo123", None)
- assert "geo123" in result
-
- def test_unidentified_fallback(self):
- result = user_agent_extended(None, None)
- assert "unidentified" in result
+from tests.unit.conftest import make_metadata as _metadata, make_mock_response as _mock_response
# --- Retry logic tests ---
@@ -114,11 +14,9 @@ def test_unidentified_fallback(self):
class TestRetryLogic:
@patch("time.sleep", return_value=None)
def test_retry_on_429_with_retry_after(self, mock_sleep, session):
- resp_429 = _mock_response(
- 429, reason="Too Many Requests", headers={"Retry-After": "1"}
- )
+ resp_429 = _mock_response(429, reason_phrase="Too Many Requests", headers={"Retry-After": "1"})
resp_200 = _mock_response(200)
- session._req_session.request = MagicMock(side_effect=[resp_429, resp_200])
+ session._client.request = MagicMock(side_effect=[resp_429, resp_200])
result = session.request(_metadata(), "GET", "/organizations")
assert result.status_code == 200
@@ -126,9 +24,9 @@ def test_retry_on_429_with_retry_after(self, mock_sleep, session):
@patch("time.sleep", return_value=None)
def test_retry_on_429_without_retry_after(self, mock_sleep, session):
- resp_429 = _mock_response(429, reason="Too Many Requests", headers={})
+ resp_429 = _mock_response(429, reason_phrase="Too Many Requests", headers={})
resp_200 = _mock_response(200)
- session._req_session.request = MagicMock(side_effect=[resp_429, resp_200])
+ session._client.request = MagicMock(side_effect=[resp_429, resp_200])
with patch("random.randint", return_value=1):
result = session.request(_metadata(), "GET", "/organizations")
@@ -137,10 +35,8 @@ def test_retry_on_429_without_retry_after(self, mock_sleep, session):
@patch("time.sleep", return_value=None)
def test_429_raises_after_max_retries(self, mock_sleep, session):
session._maximum_retries = 2
- resp_429 = _mock_response(
- 429, reason="Too Many Requests", headers={"Retry-After": "1"}
- )
- session._req_session.request = MagicMock(return_value=resp_429)
+ resp_429 = _mock_response(429, reason_phrase="Too Many Requests", headers={"Retry-After": "1"})
+ session._client.request = MagicMock(return_value=resp_429)
with pytest.raises(APIError):
session.request(_metadata(), "GET", "/organizations")
@@ -148,17 +44,17 @@ def test_429_raises_after_max_retries(self, mock_sleep, session):
@patch("time.sleep", return_value=None)
def test_429_raises_immediately_when_wait_disabled(self, mock_sleep, session):
session._wait_on_rate_limit = False
- resp_429 = _mock_response(429, reason="Too Many Requests", headers={})
- session._req_session.request = MagicMock(return_value=resp_429)
+ resp_429 = _mock_response(429, reason_phrase="Too Many Requests", headers={})
+ session._client.request = MagicMock(return_value=resp_429)
with pytest.raises(APIError):
session.request(_metadata(), "GET", "/organizations")
@patch("time.sleep", return_value=None)
def test_retry_on_5xx(self, mock_sleep, session):
- resp_500 = _mock_response(500, reason="Internal Server Error")
+ resp_500 = _mock_response(500, reason_phrase="Internal Server Error")
resp_200 = _mock_response(200)
- session._req_session.request = MagicMock(side_effect=[resp_500, resp_200])
+ session._client.request = MagicMock(side_effect=[resp_500, resp_200])
result = session.request(_metadata(), "GET", "/organizations")
assert result.status_code == 200
@@ -166,18 +62,17 @@ def test_retry_on_5xx(self, mock_sleep, session):
@patch("time.sleep", return_value=None)
def test_5xx_raises_after_max_retries(self, mock_sleep, session):
session._maximum_retries = 2
- resp_500 = _mock_response(500, reason="Internal Server Error")
- session._req_session.request = MagicMock(return_value=resp_500)
+ resp_500 = _mock_response(500, reason_phrase="Internal Server Error")
+ session._client.request = MagicMock(return_value=resp_500)
with pytest.raises(APIError):
session.request(_metadata(), "GET", "/organizations")
@patch("time.sleep", return_value=None)
def test_retry_on_connection_error(self, mock_sleep, session):
- exc = requests.exceptions.ConnectionError("Connection refused")
- exc.response = None
+ exc = httpx.ConnectError("Connection refused")
resp_200 = _mock_response(200)
- session._req_session.request = MagicMock(side_effect=[exc, resp_200])
+ session._client.request = MagicMock(side_effect=[exc, resp_200])
result = session.request(_metadata(), "GET", "/organizations")
assert result.status_code == 200
@@ -185,13 +80,114 @@ def test_retry_on_connection_error(self, mock_sleep, session):
@patch("time.sleep", return_value=None)
def test_connection_error_raises_after_max_retries(self, mock_sleep, session):
session._maximum_retries = 1
- exc = requests.exceptions.ConnectionError("Connection refused")
- exc.response = None
- session._req_session.request = MagicMock(side_effect=[exc])
+ exc = httpx.ConnectError("Connection refused")
+ session._client.request = MagicMock(side_effect=[exc])
+
+ with pytest.raises(APIError):
+ session.request(_metadata(), "GET", "/organizations")
+
+ @patch("time.sleep", return_value=None)
+ def test_429_retry_count_matches_maximum_retries(self, mock_sleep, session):
+ """Verify exactly maximum_retries attempts occur before APIError."""
+ session._maximum_retries = 3
+ resp_429 = _mock_response(429, reason_phrase="Too Many Requests", headers={"Retry-After": "1"})
+ session._client.request = MagicMock(return_value=resp_429)
+
+ with pytest.raises(APIError):
+ session.request(_metadata(), "GET", "/organizations")
+
+ assert session._client.request.call_count == 3
+
+ @patch("time.sleep", return_value=None)
+ def test_429_uses_retry_after_header_value(self, mock_sleep, session):
+ """Verify sleep uses exact Retry-After header value."""
+ resp_429 = _mock_response(429, reason_phrase="Too Many Requests", headers={"Retry-After": "7"})
+ resp_200 = _mock_response(200)
+ session._client.request = MagicMock(side_effect=[resp_429, resp_200])
+
+ session.request(_metadata(), "GET", "/organizations")
+ mock_sleep.assert_called_with(7)
+
+ @patch("time.sleep", return_value=None)
+ def test_server_error_retries_exactly_maximum_retries(self, mock_sleep, session):
+ """Verify 5xx retries exhaust exactly maximum_retries attempts."""
+ session._maximum_retries = 2
+ resp_500 = _mock_response(500, reason_phrase="Internal Server Error")
+ session._client.request = MagicMock(return_value=resp_500)
+
+ with pytest.raises(APIError):
+ session.request(_metadata(), "GET", "/organizations")
+
+ assert session._client.request.call_count == 2
+ assert mock_sleep.call_count == 2
+
+ @patch("time.sleep", return_value=None)
+ def test_connection_error_retries_exactly_maximum_retries(self, mock_sleep, session):
+ """Verify HTTPError retries exhaust exactly maximum_retries attempts."""
+ session._maximum_retries = 3
+ session._client.request = MagicMock(side_effect=httpx.ConnectError("connection refused"))
+
+ with pytest.raises(APIError):
+ session.request(_metadata(), "GET", "/organizations")
+
+ assert session._client.request.call_count == 3
+ assert mock_sleep.call_count == 3
+
+
+# --- X-Request-Id logging on 5xx ---
+
+
+class TestRequestIdLoggingOn5xx:
+ @patch("time.sleep", return_value=None)
+ def test_request_id_logged_in_warning(self, mock_sleep, session_with_logger):
+ session = session_with_logger
+ resp_500 = _mock_response(
+ 500,
+ reason_phrase="Internal Server Error",
+ headers={"X-Request-Id": "abc123def456"},
+ )
+ resp_200 = _mock_response(200)
+ session._client.request = MagicMock(side_effect=[resp_500, resp_200])
+
+ result = session.request(_metadata(), "GET", "/organizations")
+
+ assert result.status_code == 200
+ warning_messages = [c.args[0] for c in session._logger.warning.call_args_list]
+ assert any("X-Request-Id: abc123def456" in m for m in warning_messages)
+
+ @patch("time.sleep", return_value=None)
+ def test_request_id_logged_as_error_after_exhausting_retries(self, mock_sleep, session_with_logger):
+ session = session_with_logger
+ session._maximum_retries = 2
+ resp_500 = _mock_response(
+ 500,
+ reason_phrase="Internal Server Error",
+ headers={"X-Request-Id": "deadbeef00112233"},
+ )
+ session._client.request = MagicMock(return_value=resp_500)
+
+ with pytest.raises(APIError):
+ session.request(_metadata(), "GET", "/organizations")
+
+ error_messages = [c.args[0] for c in session._logger.error.call_args_list]
+ assert any("deadbeef00112233" in m for m in error_messages)
+ assert any("Provide this X-Request-Id to Meraki" in m for m in error_messages)
+
+ @patch("time.sleep", return_value=None)
+ def test_no_request_id_logs_none(self, mock_sleep, session_with_logger):
+ session = session_with_logger
+ session._maximum_retries = 2
+ resp_500 = _mock_response(500, reason_phrase="Internal Server Error", headers={})
+ session._client.request = MagicMock(return_value=resp_500)
with pytest.raises(APIError):
session.request(_metadata(), "GET", "/organizations")
+ warning_messages = [c.args[0] for c in session._logger.warning.call_args_list]
+ assert any("X-Request-Id: none" in m for m in warning_messages)
+ error_messages = [c.args[0] for c in session._logger.error.call_args_list]
+ assert any("log lookup: none" in m for m in error_messages)
+
# --- Pagination tests ---
@@ -200,7 +196,7 @@ class TestPaginationLegacy:
@patch("time.sleep", return_value=None)
def test_single_page(self, mock_sleep, session):
resp = _mock_response(200, json_data=[{"id": "1"}], links={})
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
result = session._get_pages_legacy(_metadata(), "/organizations")
assert result == [{"id": "1"}]
@@ -210,18 +206,12 @@ def test_multiple_pages(self, mock_sleep, session):
resp1 = _mock_response(
200,
json_data=[{"id": "1"}],
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/organizations?startingAfter=1"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/organizations?startingAfter=1"}},
)
resp2 = _mock_response(200, json_data=[{"id": "2"}], links={})
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
- result = session._get_pages_legacy(
- _metadata(), "/organizations", total_pages=-1
- )
+ result = session._get_pages_legacy(_metadata(), "/organizations", total_pages=-1)
assert result == [{"id": "1"}, {"id": "2"}]
@patch("time.sleep", return_value=None)
@@ -229,22 +219,14 @@ def test_total_pages_limit(self, mock_sleep, session):
resp1 = _mock_response(
200,
json_data=[{"id": "1"}],
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/organizations?startingAfter=1"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/organizations?startingAfter=1"}},
)
resp2 = _mock_response(
200,
json_data=[{"id": "2"}],
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/organizations?startingAfter=2"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/organizations?startingAfter=2"}},
)
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
result = session._get_pages_legacy(_metadata(), "/organizations", total_pages=2)
assert result == [{"id": "1"}, {"id": "2"}]
@@ -252,24 +234,20 @@ def test_total_pages_limit(self, mock_sleep, session):
@patch("time.sleep", return_value=None)
def test_total_pages_string_all(self, mock_sleep, session):
resp = _mock_response(200, json_data=[{"id": "1"}], links={})
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
- result = session._get_pages_legacy(
- _metadata(), "/organizations", total_pages="all"
- )
+ result = session._get_pages_legacy(_metadata(), "/organizations", total_pages="all")
assert result == [{"id": "1"}]
@patch("time.sleep", return_value=None)
def test_total_pages_invalid_raises(self, mock_sleep, session):
with pytest.raises(SessionInputError):
- session._get_pages_legacy(
- _metadata(), "/organizations", total_pages="invalid"
- )
+ session._get_pages_legacy(_metadata(), "/organizations", total_pages="invalid")
@patch("time.sleep", return_value=None)
def test_204_no_content(self, mock_sleep, session):
- resp = _mock_response(204, json_data=None, reason="No Content", links={})
- session._req_session.request = MagicMock(return_value=resp)
+ resp = _mock_response(204, json_data=None, reason_phrase="No Content", links={})
+ session._client.request = MagicMock(return_value=resp)
result = session._get_pages_legacy(_metadata(), "/organizations")
assert result is None
@@ -282,9 +260,7 @@ def test_items_dict_pagination(self, mock_sleep, session):
"items": [{"id": "1"}],
"meta": {"counts": {"items": {"remaining": 1}}},
},
- links={
- "next": {"url": "https://api.meraki.com/api/v1/things?startingAfter=1"}
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/things?startingAfter=1"}},
)
resp2 = _mock_response(
200,
@@ -294,7 +270,7 @@ def test_items_dict_pagination(self, mock_sleep, session):
},
links={},
)
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
result = session._get_pages_legacy(_metadata(), "/things", total_pages=-1)
assert result["items"] == [{"id": "1"}, {"id": "2"}]
@@ -305,7 +281,7 @@ class TestPaginationIterator:
@patch("time.sleep", return_value=None)
def test_single_page_yields_items(self, mock_sleep, session):
resp = _mock_response(200, json_data=[{"id": "1"}, {"id": "2"}], links={})
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
results = list(session._get_pages_iterator(_metadata(), "/organizations"))
assert results == [{"id": "1"}, {"id": "2"}]
@@ -315,24 +291,18 @@ def test_multiple_pages_yields_all(self, mock_sleep, session):
resp1 = _mock_response(
200,
json_data=[{"id": "1"}],
- links={
- "next": {"url": "https://api.meraki.com/api/v1/orgs?startingAfter=1"}
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/orgs?startingAfter=1"}},
)
resp2 = _mock_response(200, json_data=[{"id": "2"}], links={})
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
- results = list(
- session._get_pages_iterator(_metadata(), "/organizations", total_pages=-1)
- )
+ results = list(session._get_pages_iterator(_metadata(), "/organizations", total_pages=-1))
assert results == [{"id": "1"}, {"id": "2"}]
@patch("time.sleep", return_value=None)
def test_items_dict_yields_items(self, mock_sleep, session):
- resp = _mock_response(
- 200, json_data={"items": [{"id": "a"}, {"id": "b"}]}, links={}
- )
- session._req_session.request = MagicMock(return_value=resp)
+ resp = _mock_response(200, json_data={"items": [{"id": "a"}, {"id": "b"}]}, links={})
+ session._client.request = MagicMock(return_value=resp)
results = list(session._get_pages_iterator(_metadata(), "/things"))
assert results == [{"id": "a"}, {"id": "b"}]
@@ -344,10 +314,8 @@ def test_items_dict_yields_items(self, mock_sleep, session):
class TestHandle4xxErrors:
@patch("time.sleep", return_value=None)
def test_generic_4xx_raises_immediately(self, mock_sleep, session):
- resp_400 = _mock_response(
- 400, json_data={"errors": ["bad request"]}, reason="Bad Request"
- )
- session._req_session.request = MagicMock(return_value=resp_400)
+ resp_400 = _mock_response(400, json_data={"errors": ["bad request"]}, reason_phrase="Bad Request")
+ session._client.request = MagicMock(return_value=resp_400)
with pytest.raises(APIError):
session.request(_metadata(), "GET", "/organizations")
@@ -357,15 +325,13 @@ def test_network_delete_concurrency_retries(self, mock_sleep, session):
resp_400 = _mock_response(
400,
json_data={"errors": ["concurrent requests detected"]},
- reason="Bad Request",
+ reason_phrase="Bad Request",
)
resp_200 = _mock_response(200)
- session._req_session.request = MagicMock(side_effect=[resp_400, resp_200])
+ session._client.request = MagicMock(side_effect=[resp_400, resp_200])
with patch("random.randint", return_value=1):
- result = session.request(
- _metadata(operation="deleteNetwork"), "DELETE", "/networks/123"
- )
+ result = session.request(_metadata(operation="deleteNetwork"), "DELETE", "/networks/123")
assert result.status_code == 200
@patch("time.sleep", return_value=None)
@@ -373,10 +339,10 @@ def test_action_batch_concurrency_retries(self, mock_sleep, session):
resp_400 = _mock_response(
400,
json_data={"errors": ["Too many concurrently executing batches"]},
- reason="Bad Request",
+ reason_phrase="Bad Request",
)
resp_200 = _mock_response(200)
- session._req_session.request = MagicMock(side_effect=[resp_400, resp_200])
+ session._client.request = MagicMock(side_effect=[resp_400, resp_200])
result = session.request(_metadata(operation="createBatch"), "POST", "/batches")
assert result.status_code == 200
@@ -384,11 +350,9 @@ def test_action_batch_concurrency_retries(self, mock_sleep, session):
@patch("time.sleep", return_value=None)
def test_retry_4xx_when_enabled(self, mock_sleep, session):
session._retry_4xx_error = True
- resp_400 = _mock_response(
- 400, json_data={"errors": ["something"]}, reason="Bad Request"
- )
+ resp_400 = _mock_response(400, json_data={"errors": ["something"]}, reason_phrase="Bad Request")
resp_200 = _mock_response(200)
- session._req_session.request = MagicMock(side_effect=[resp_400, resp_200])
+ session._client.request = MagicMock(side_effect=[resp_400, resp_200])
with patch("random.randint", return_value=1):
result = session.request(_metadata(), "GET", "/organizations")
@@ -407,7 +371,7 @@ def test_simulate_skips_non_get(self, session):
def test_simulate_allows_get(self, session):
session._simulate = True
resp_200 = _mock_response(200)
- session._req_session.request = MagicMock(return_value=resp_200)
+ session._client.request = MagicMock(return_value=resp_200)
result = session.request(_metadata(), "GET", "/organizations")
assert result.status_code == 200
@@ -420,41 +384,35 @@ class TestRedirect:
def test_follows_redirect(self, mock_sleep, session):
resp_301 = _mock_response(
301,
- reason="Moved",
+ reason_phrase="Moved",
headers={"Location": "https://n123.meraki.com/api/v1/organizations"},
)
resp_200 = _mock_response(200)
- session._req_session.request = MagicMock(side_effect=[resp_301, resp_200])
+ session._client.request = MagicMock(side_effect=[resp_301, resp_200])
result = session.request(_metadata(), "GET", "/organizations")
assert result.status_code == 200
-# --- prepare_request ---
+# --- _transport_kwargs ---
-class TestPrepareRequest:
- def test_sets_timeout(self, session):
- kwargs = {}
- session.prepare_request(kwargs)
- assert kwargs["timeout"] == 60
+class TestTransportKwargs:
+ def test_returns_kwargs_unchanged(self, session):
+ kwargs = {"params": {"foo": "bar"}}
+ result = session._transport_kwargs(kwargs)
+ assert result == {"params": {"foo": "bar"}}
- def test_sets_certificate(self, session):
- session._certificate_path = "/path/to/cert.pem"
+ def test_does_not_inject_timeout(self, session):
kwargs = {}
- session.prepare_request(kwargs)
- assert kwargs["verify"] == "/path/to/cert.pem"
+ result = session._transport_kwargs(kwargs)
+ assert "timeout" not in result
- def test_sets_proxy(self, session):
- session._requests_proxy = "https://proxy:8080"
+ def test_does_not_inject_verify(self, session):
+ session._certificate_path = "/path/to/cert.pem"
kwargs = {}
- session.prepare_request(kwargs)
- assert kwargs["proxies"] == {"https": "https://proxy:8080"}
-
- def test_does_not_override_existing(self, session):
- kwargs = {"timeout": 30}
- session.prepare_request(kwargs)
- assert kwargs["timeout"] == 30
+ result = session._transport_kwargs(kwargs)
+ assert "verify" not in result
# --- HTTP verb methods ---
@@ -463,56 +421,62 @@ def test_does_not_override_existing(self, session):
class TestHTTPVerbs:
def test_get_returns_json(self, session):
resp = _mock_response(200, json_data={"id": "1"}, content=b'{"id":"1"}')
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
result = session.get(_metadata(), "/organizations")
assert result == {"id": "1"}
def test_get_returns_none_on_empty_content(self, session):
resp = _mock_response(200, json_data=None, content=b" ")
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
result = session.get(_metadata(), "/organizations")
assert result is None
def test_get_returns_none_when_simulated(self, session):
session._simulate = True
resp = _mock_response(200, json_data={"id": "1"}, content=b'{"id":"1"}')
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
# GET still goes through in simulate mode
result = session.get(_metadata(), "/organizations")
assert result == {"id": "1"}
def test_post_returns_json(self, session):
resp = _mock_response(201, json_data={"id": "new"}, content=b'{"id":"new"}')
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
result = session.post(_metadata(), "/organizations", json={"name": "Test"})
assert result == {"id": "new"}
def test_post_returns_none_on_empty_content(self, session):
resp = _mock_response(204, json_data=None, content=b"")
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
result = session.post(_metadata(), "/organizations", json={"name": "Test"})
assert result is None
def test_put_returns_json(self, session):
- resp = _mock_response(
- 200, json_data={"id": "updated"}, content=b'{"id":"updated"}'
- )
- session._req_session.request = MagicMock(return_value=resp)
+ resp = _mock_response(200, json_data={"id": "updated"}, content=b'{"id":"updated"}')
+ session._client.request = MagicMock(return_value=resp)
result = session.put(_metadata(), "/organizations/1", json={"name": "New"})
assert result == {"id": "updated"}
def test_put_returns_none_on_empty_content(self, session):
resp = _mock_response(200, json_data=None, content=b" ")
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
result = session.put(_metadata(), "/organizations/1", json={"name": "New"})
assert result is None
def test_delete_returns_none(self, session):
- resp = _mock_response(204, reason="No Content", content=b"")
- session._req_session.request = MagicMock(return_value=resp)
+ resp = _mock_response(204, reason_phrase="No Content", content=b"")
+ session._client.request = MagicMock(return_value=resp)
result = session.delete(_metadata(), "/organizations/1")
assert result is None
+ def test_delete_passes_query_params(self, session):
+ resp = _mock_response(204, reason_phrase="No Content", content=b"")
+ session._client.request = MagicMock(return_value=resp)
+ params = {"force": "true"}
+ session.delete(_metadata(), "/networks/1/groupPolicies/1", params)
+ call_kwargs = session._client.request.call_args
+ assert call_kwargs.kwargs.get("params") == params
+
# --- Connection error with response object ---
@@ -521,14 +485,11 @@ class TestConnectionErrorWithResponse:
@patch("time.sleep", return_value=None)
def test_connection_error_with_response_status(self, mock_sleep, session):
session._maximum_retries = 1
- exc = requests.exceptions.ConnectionError("refused")
- exc.response = MagicMock()
- exc.response.status_code = 502
- session._req_session.request = MagicMock(side_effect=[exc])
+ exc = httpx.ConnectError("refused")
+ session._client.request = MagicMock(side_effect=[exc])
- with pytest.raises(APIError) as exc_info:
+ with pytest.raises(APIError):
session.request(_metadata(), "GET", "/organizations")
- assert exc_info.value.status == 502
# --- JSON decode retry exhaustion ---
@@ -542,7 +503,7 @@ def test_bad_json_raises_after_max_retries(self, mock_sleep, session):
session._maximum_retries = 2
resp = _mock_response(200, content=b'{"ok":true}')
resp.json.side_effect = json_mod.decoder.JSONDecodeError("", "", 0)
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
with pytest.raises(APIError):
session.request(_metadata(), "GET", "/organizations")
@@ -557,12 +518,10 @@ def test_prev_direction(self, mock_sleep, session):
resp1 = _mock_response(
200,
json_data=[{"id": "2"}],
- links={
- "prev": {"url": "https://api.meraki.com/api/v1/orgs?endingBefore=2"}
- },
+ links={"prev": {"url": "https://api.meraki.com/api/v1/orgs?endingBefore=2"}},
)
resp2 = _mock_response(200, json_data=[{"id": "1"}], links={})
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
result = session._get_pages_legacy(_metadata(), "/orgs", direction="prev")
assert result == [{"id": "2"}, {"id": "1"}]
@@ -578,7 +537,7 @@ def test_event_log_next_reverses_events(self, mock_sleep, session):
},
links={},
)
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
metadata = _metadata(operation="getNetworkEvents")
result = session._get_pages_legacy(metadata, "/events", direction="next")
@@ -595,22 +554,14 @@ def test_event_log_breaks_on_recent_starting_after(self, mock_sleep, session):
"pageStartAt": "2024-01-01T00:00:00Z",
"pageEndAt": "2024-01-01T01:00:00Z",
},
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/events?startingAfter=2024-01-01T00:00:00+00:00"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2024-01-01T00:00:00+00:00"}},
)
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
metadata = _metadata(operation="getNetworkEvents")
- with patch("meraki.rest_session.datetime") as mock_dt:
- mock_dt.now.return_value = datetime(
- 2024, 1, 1, 0, 2, 0, tzinfo=timezone.utc
- )
- mock_dt.fromisoformat.return_value = datetime(
- 2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc
- )
+ with patch("meraki.session.sync.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2024, 1, 1, 0, 2, 0, tzinfo=timezone.utc)
+ mock_dt.fromisoformat.return_value = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
result = session._get_pages_legacy(metadata, "/events", direction="next")
assert result["events"] == [{"ts": "a"}]
@@ -625,22 +576,14 @@ def test_event_log_breaks_on_end_time(self, mock_sleep, session):
"pageStartAt": "2024-01-01T00:00:00Z",
"pageEndAt": "2024-01-01T01:00:00Z",
},
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/events?startingAfter=2024-06-01T00:00:00+00:00"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2024-06-01T00:00:00+00:00"}},
)
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
metadata = _metadata(operation="getNetworkEvents")
- with patch("meraki.rest_session.datetime") as mock_dt:
- mock_dt.now.return_value = datetime(
- 2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc
- )
- mock_dt.fromisoformat.return_value = datetime(
- 2024, 6, 1, 0, 0, 0, tzinfo=timezone.utc
- )
+ with patch("meraki.session.sync.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
+ mock_dt.fromisoformat.return_value = datetime(2024, 6, 1, 0, 0, 0, tzinfo=timezone.utc)
result = session._get_pages_legacy(
metadata,
"/events",
@@ -658,13 +601,9 @@ def test_event_log_prev_breaks_before_2014(self, mock_sleep, session):
"pageStartAt": "2014-01-01T00:00:00Z",
"pageEndAt": "2014-01-02T00:00:00Z",
},
- links={
- "prev": {
- "url": "https://api.meraki.com/api/v1/events?endingBefore=2013-12-31T00:00:00Z"
- }
- },
+ links={"prev": {"url": "https://api.meraki.com/api/v1/events?endingBefore=2013-12-31T00:00:00Z"}},
)
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
metadata = _metadata(operation="getNetworkEvents")
result = session._get_pages_legacy(metadata, "/events", direction="prev")
@@ -673,7 +612,7 @@ def test_event_log_prev_breaks_before_2014(self, mock_sleep, session):
@patch("time.sleep", return_value=None)
def test_total_pages_numeric_string(self, mock_sleep, session):
resp = _mock_response(200, json_data=[{"id": "1"}], links={})
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
result = session._get_pages_legacy(_metadata(), "/orgs", total_pages="3")
assert result == [{"id": "1"}]
@@ -687,11 +626,7 @@ def test_event_log_multi_page_extends_events(self, mock_sleep, session):
"pageStartAt": "2024-01-01T00:00:00Z",
"pageEndAt": "2024-01-01T01:00:00Z",
},
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/events?startingAfter=2014-06-01T00:00:00+00:00"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2014-06-01T00:00:00+00:00"}},
)
resp2 = _mock_response(
200,
@@ -702,18 +637,14 @@ def test_event_log_multi_page_extends_events(self, mock_sleep, session):
},
links={},
)
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
metadata = _metadata(operation="getNetworkEvents")
from datetime import datetime, timezone
- with patch("meraki.rest_session.datetime") as mock_dt:
- mock_dt.now.return_value = datetime(
- 2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc
- )
- mock_dt.fromisoformat.return_value = datetime(
- 2014, 6, 1, 0, 0, 0, tzinfo=timezone.utc
- )
+ with patch("meraki.session.sync.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
+ mock_dt.fromisoformat.return_value = datetime(2014, 6, 1, 0, 0, 0, tzinfo=timezone.utc)
result = session._get_pages_legacy(metadata, "/events", direction="next")
# First page reversed, second page reversed and appended
@@ -725,39 +656,128 @@ def test_event_log_multi_page_extends_events(self, mock_sleep, session):
# --- Pagination iterator: extended coverage ---
+class TestBackoffStrategy:
+ @patch("time.sleep", return_value=None)
+ @patch("random.random", return_value=0.5)
+ def test_429_exponential_backoff_without_retry_after(self, mock_random, mock_sleep, session):
+ """Without Retry-After, backoff is 2^attempt * (1 + random), capped."""
+ session._maximum_retries = 4
+ session._nginx_429_retry_wait_time = 60
+ resp_429 = _mock_response(429, reason_phrase="Too Many Requests", headers={})
+ resp_200 = _mock_response(200)
+
+ session._client.request = MagicMock(side_effect=[resp_429, resp_429, resp_429, resp_200])
+
+ session.request(_metadata(), "GET", "/organizations")
+
+ sleep_calls = [call.args[0] for call in mock_sleep.call_args_list]
+ assert len(sleep_calls) == 3
+ # attempt 0: 2^0 * 1.5 = 1.5
+ # attempt 1: 2^1 * 1.5 = 3.0
+ # attempt 2: 2^2 * 1.5 = 6.0
+ assert sleep_calls[0] == pytest.approx(1.5)
+ assert sleep_calls[1] == pytest.approx(3.0)
+ assert sleep_calls[2] == pytest.approx(6.0)
+
+ @patch("time.sleep", return_value=None)
+ @patch("random.random", return_value=0.0)
+ def test_429_backoff_capped_at_nginx_wait_time(self, mock_random, mock_sleep, session):
+ """Backoff never exceeds nginx_429_retry_wait_time."""
+ session._maximum_retries = 10
+ session._nginx_429_retry_wait_time = 5
+ resp_429 = _mock_response(429, reason_phrase="Too Many Requests", headers={})
+ resp_200 = _mock_response(200)
+
+ session._client.request = MagicMock(side_effect=[resp_429] * 8 + [resp_200])
+
+ session.request(_metadata(), "GET", "/organizations")
+
+ sleep_calls = [call.args[0] for call in mock_sleep.call_args_list]
+ for wait in sleep_calls:
+ assert wait <= 5, f"Wait {wait} exceeds cap of 5"
+
+
+class TestEdgeCases:
+ @patch("time.sleep", return_value=None)
+ def test_json_decode_failure_retries(self, mock_sleep, session):
+ """Invalid JSON on GET triggers retry, eventual success returns response."""
+ bad_resp = _mock_response(200, content=b"not json")
+ bad_resp.json.side_effect = ValueError("No JSON")
+ good_resp = _mock_response(200, json_data={"id": "123"})
+
+ session._client.request = MagicMock(side_effect=[bad_resp, good_resp])
+ result = session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
+ assert session._client.request.call_count == 2
+
+ @patch("time.sleep", return_value=None)
+ def test_json_decode_failure_exhausts_retries(self, mock_sleep, session):
+ """Persistent invalid JSON exhausts retries and raises APIError."""
+ session._maximum_retries = 2
+ bad_resp = _mock_response(200, content=b"not json")
+ bad_resp.json.side_effect = ValueError("No JSON")
+
+ session._client.request = MagicMock(return_value=bad_resp)
+
+ with pytest.raises(APIError):
+ session.request(_metadata(), "GET", "/organizations")
+
+ assert session._client.request.call_count == 2
+
+ def test_simulate_mode_skips_post(self, session):
+ """Simulate mode returns None for POST without making HTTP call."""
+ session._simulate = True
+ session._client.request = MagicMock()
+
+ result = session.request(_metadata(), "POST", "/organizations")
+ assert result is None
+ session._client.request.assert_not_called()
+
+ def test_simulate_mode_allows_get(self, session):
+ """Simulate mode still executes GET requests."""
+ session._simulate = True
+ resp_200 = _mock_response(200)
+ session._client.request = MagicMock(return_value=resp_200)
+
+ result = session.request(_metadata(), "GET", "/organizations")
+ assert result.status_code == 200
+ session._client.request.assert_called_once()
+
+ def test_204_no_content_returns_response(self, session):
+ """204 No Content returns the response object (not None)."""
+ resp_204 = _mock_response(204, content=b"", reason_phrase="No Content")
+ resp_204.json.side_effect = ValueError("No JSON")
+ session._client.request = MagicMock(return_value=resp_204)
+
+ result = session.request(_metadata(), "DELETE", "/organizations/123")
+ assert result.status_code == 204
+
+
class TestPaginationIteratorExtended:
@patch("time.sleep", return_value=None)
def test_total_pages_numeric_string(self, mock_sleep, session):
resp = _mock_response(200, json_data=[{"id": "1"}], links={})
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
- results = list(
- session._get_pages_iterator(_metadata(), "/orgs", total_pages="3")
- )
+ results = list(session._get_pages_iterator(_metadata(), "/orgs", total_pages="3"))
assert results == [{"id": "1"}]
@patch("time.sleep", return_value=None)
def test_total_pages_invalid_raises(self, mock_sleep, session):
with pytest.raises(SessionInputError):
- list(
- session._get_pages_iterator(_metadata(), "/orgs", total_pages="invalid")
- )
+ list(session._get_pages_iterator(_metadata(), "/orgs", total_pages="invalid"))
@patch("time.sleep", return_value=None)
def test_prev_direction(self, mock_sleep, session):
resp1 = _mock_response(
200,
json_data=[{"id": "2"}],
- links={
- "prev": {"url": "https://api.meraki.com/api/v1/orgs?endingBefore=2"}
- },
+ links={"prev": {"url": "https://api.meraki.com/api/v1/orgs?endingBefore=2"}},
)
resp2 = _mock_response(200, json_data=[{"id": "1"}], links={})
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
- results = list(
- session._get_pages_iterator(_metadata(), "/orgs", direction="prev")
- )
+ results = list(session._get_pages_iterator(_metadata(), "/orgs", direction="prev"))
assert results == [{"id": "2"}, {"id": "1"}]
@patch("time.sleep", return_value=None)
@@ -771,12 +791,10 @@ def test_event_log_next_reverses(self, mock_sleep, session):
},
links={},
)
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
metadata = _metadata(operation="getNetworkEvents")
- results = list(
- session._get_pages_iterator(metadata, "/events", direction="next")
- )
+ results = list(session._get_pages_iterator(metadata, "/events", direction="next"))
assert results == [{"ts": "a"}, {"ts": "b"}]
@patch("time.sleep", return_value=None)
@@ -790,12 +808,10 @@ def test_event_log_prev_normal_order(self, mock_sleep, session):
},
links={},
)
- session._req_session.request = MagicMock(return_value=resp)
+ session._client.request = MagicMock(return_value=resp)
metadata = _metadata(operation="someOp")
- results = list(
- session._get_pages_iterator(metadata, "/events", direction="prev")
- )
+ results = list(session._get_pages_iterator(metadata, "/events", direction="prev"))
assert results == [{"ts": "a"}, {"ts": "b"}]
@patch("time.sleep", return_value=None)
@@ -810,11 +826,7 @@ def test_event_log_breaks_on_recent_starting_after(self, mock_sleep, session):
"pageStartAt": "2024-01-01",
"pageEndAt": "2024-01-02",
},
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/events?startingAfter=2014-06-01T00:00:00+00:00"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2014-06-01T00:00:00+00:00"}},
)
# Page 2: too recent, triggers break before yielding
resp2 = _mock_response(
@@ -824,13 +836,9 @@ def test_event_log_breaks_on_recent_starting_after(self, mock_sleep, session):
"pageStartAt": "2025-01-01",
"pageEndAt": "2025-01-02",
},
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/events?startingAfter=2025-01-01T23:58:00+00:00"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2025-01-01T23:58:00+00:00"}},
)
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
metadata = _metadata(operation="getNetworkEvents")
call_count = [0]
@@ -841,14 +849,10 @@ def fake_fromisoformat(s):
return datetime(2014, 6, 1, 0, 0, 0, tzinfo=timezone.utc)
return datetime(2025, 1, 1, 23, 58, 0, tzinfo=timezone.utc)
- with patch("meraki.rest_session.datetime") as mock_dt:
- mock_dt.now.return_value = datetime(
- 2025, 1, 2, 0, 0, 0, tzinfo=timezone.utc
- )
+ with patch("meraki.session.sync.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2025, 1, 2, 0, 0, 0, tzinfo=timezone.utc)
mock_dt.fromisoformat = fake_fromisoformat
- results = list(
- session._get_pages_iterator(metadata, "/events", direction="next")
- )
+ results = list(session._get_pages_iterator(metadata, "/events", direction="next"))
# Only page 1 yielded (reversed)
assert results == [{"ts": "a"}]
@@ -864,11 +868,7 @@ def test_event_log_breaks_on_end_time(self, mock_sleep, session):
"pageStartAt": "2024-01-01",
"pageEndAt": "2024-01-02",
},
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/events?startingAfter=2024-03-01T00:00:00+00:00"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2024-03-01T00:00:00+00:00"}},
)
# Page 2: past end_time, triggers break
resp2 = _mock_response(
@@ -878,13 +878,9 @@ def test_event_log_breaks_on_end_time(self, mock_sleep, session):
"pageStartAt": "2024-06-01",
"pageEndAt": "2024-06-02",
},
- links={
- "next": {
- "url": "https://api.meraki.com/api/v1/events?startingAfter=2024-07-01T00:00:00+00:00"
- }
- },
+ links={"next": {"url": "https://api.meraki.com/api/v1/events?startingAfter=2024-07-01T00:00:00+00:00"}},
)
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
metadata = _metadata(operation="getNetworkEvents")
call_count = [0]
@@ -895,10 +891,8 @@ def fake_fromisoformat(s):
return datetime(2024, 3, 1, 0, 0, 0, tzinfo=timezone.utc)
return datetime(2024, 7, 1, 0, 0, 0, tzinfo=timezone.utc)
- with patch("meraki.rest_session.datetime") as mock_dt:
- mock_dt.now.return_value = datetime(
- 2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc
- )
+ with patch("meraki.session.sync.datetime") as mock_dt:
+ mock_dt.now.return_value = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
mock_dt.fromisoformat = fake_fromisoformat
results = list(
session._get_pages_iterator(
@@ -920,11 +914,7 @@ def test_event_log_prev_breaks_before_2014(self, mock_sleep, session):
"pageStartAt": "2014-06-01",
"pageEndAt": "2014-06-02",
},
- links={
- "prev": {
- "url": "https://api.meraki.com/api/v1/events?endingBefore=2014-06-01T00:00:00Z"
- }
- },
+ links={"prev": {"url": "https://api.meraki.com/api/v1/events?endingBefore=2014-06-01T00:00:00Z"}},
)
# Page 2: endingBefore is before 2014, triggers break
resp2 = _mock_response(
@@ -934,17 +924,126 @@ def test_event_log_prev_breaks_before_2014(self, mock_sleep, session):
"pageStartAt": "2014-01-01",
"pageEndAt": "2014-01-02",
},
- links={
- "prev": {
- "url": "https://api.meraki.com/api/v1/events?endingBefore=2013-12-31T00:00:00Z"
- }
- },
+ links={"prev": {"url": "https://api.meraki.com/api/v1/events?endingBefore=2013-12-31T00:00:00Z"}},
)
- session._req_session.request = MagicMock(side_effect=[resp1, resp2])
+ session._client.request = MagicMock(side_effect=[resp1, resp2])
metadata = _metadata(operation="getNetworkEvents")
- results = list(
- session._get_pages_iterator(metadata, "/events", direction="prev")
- )
+ results = list(session._get_pages_iterator(metadata, "/events", direction="prev"))
# Only page 1 yielded
assert results == [{"ts": "a"}]
+
+
+# --- Fix #4: sync iterator 204 guard ---
+
+
+class TestSyncIterator204Guard:
+ @patch("time.sleep", return_value=None)
+ def test_204_page_terminates_iterator_cleanly(self, mock_sleep, session):
+ """A 204 No Content page must terminate the iterator without JSONDecodeError."""
+ import json as json_mod
+
+ resp_204 = _mock_response(204, reason_phrase="No Content", content=b"", links={})
+ # A 204 body would raise on .json() in the real client; ensure we never call it.
+ resp_204.json.side_effect = json_mod.decoder.JSONDecodeError("", "", 0)
+ session._client.request = MagicMock(return_value=resp_204)
+
+ results = list(session._get_pages_iterator(_metadata(), "/organizations"))
+ assert results == []
+ resp_204.json.assert_not_called()
+
+
+# --- Fix #15: Meraki array-of-objects param encoding on the wire ---
+
+
+class TestMerakiParamEncodingSync:
+ def test_list_of_dict_params_use_meraki_encoding(self, session):
+ """params dict with list-of-dict values is pre-encoded into the URL, params dropped."""
+ resp = _mock_response(200)
+ session._client.request = MagicMock(return_value=resp)
+
+ params = {"variables[]": [{"name": "n1", "value": "v1"}]}
+ session.request(_metadata(), "GET", "/things", params=params)
+
+ call = session._client.request.call_args
+ sent_url = call.args[1]
+ # array-of-objects: param[] keys concatenated with inner dict keys
+ assert "variables%5B%5Dname=n1" in sent_url
+ assert "variables%5B%5Dvalue=v1" in sent_url
+ # params must be dropped so httpx does not re-encode
+ assert call.kwargs.get("params") is None
+
+ def test_scalar_list_params_unchanged(self, session):
+ """Scalar-list params (networkIds[]=a&b) are left for httpx (repeated keys)."""
+ resp = _mock_response(200)
+ session._client.request = MagicMock(return_value=resp)
+
+ params = {"networkIds[]": ["a", "b"]}
+ session.request(_metadata(), "GET", "/things", params=params)
+
+ call = session._client.request.call_args
+ # Not folded into URL; passed through untouched to httpx
+ assert call.kwargs.get("params") == params
+ assert "networkIds" not in call.args[1]
+
+ def test_scalar_params_unchanged(self, session):
+ """Plain scalar params pass straight through to httpx."""
+ resp = _mock_response(200)
+ session._client.request = MagicMock(return_value=resp)
+
+ params = {"perPage": 10, "total_pages": "all"}
+ session.request(_metadata(), "GET", "/things", params=params)
+
+ call = session._client.request.call_args
+ assert call.kwargs.get("params") == params
+
+
+# --- Fix #12: internal resolver/hydrator GETs drain the global bucket ---
+
+
+class TestSyncGlobalBucketAccounting:
+ def _smart_flow_session(self):
+ from tests.unit.conftest import DEFAULT_SESSION_KWARGS, FAKE_API_KEY
+ from meraki.session.sync import RestSession
+
+ kwargs = {**DEFAULT_SESSION_KWARGS, "smart_flow_enabled": True}
+ with patch("meraki.session.base.check_python_version"):
+ with patch("httpx.Client") as mock_client:
+ mock_instance = MagicMock()
+ mock_instance.headers = MagicMock(spec=dict)
+ mock_client.return_value = mock_instance
+ s = RestSession(logger=None, api_key=FAKE_API_KEY, **kwargs)
+ return s
+
+ def test_resolver_acquires_global_bucket(self):
+ s = self._smart_flow_session()
+ s._smart_flow._global_bucket = MagicMock()
+ resp = _mock_response(200, json_data={"organizationId": "999"})
+ s._client.request = MagicMock(return_value=resp)
+
+ org = s._resolve_org_for_limiter("network", "N_1")
+ assert org == "999"
+ s._smart_flow._global_bucket.acquire.assert_called_once()
+
+ def test_hydrator_acquires_global_bucket_per_page(self):
+ s = self._smart_flow_session()
+ s._smart_flow._global_bucket = MagicMock()
+ page1 = _mock_response(
+ 200,
+ json_data=[{"id": "N_1"}],
+ links={"next": {"url": "https://api.meraki.com/api/v1/organizations/9/networks?page=2"}},
+ )
+ page2 = _mock_response(200, json_data=[{"id": "N_2"}], links={})
+ # _fetch_all_pages is called twice (networks then devices); supply enough responses
+ empty = _mock_response(200, json_data=[], links={})
+ s._client.request = MagicMock(side_effect=[page1, page2, empty])
+
+ s._hydrate_org_for_limiter("9")
+ # one acquire per internal GET: 2 network pages + 1 devices page = 3
+ assert s._smart_flow._global_bucket.acquire.call_count == 3
+
+ def test_acquire_global_bucket_defensive_no_smart_flow(self, session):
+ """Helper is a no-op when smart flow is disabled (no bucket present)."""
+ session._smart_flow = None
+ # Should not raise
+ session._acquire_global_bucket()
diff --git a/tests/unit/test_session_base.py b/tests/unit/test_session_base.py
new file mode 100644
index 00000000..93cacad2
--- /dev/null
+++ b/tests/unit/test_session_base.py
@@ -0,0 +1,364 @@
+"""Tests for SessionBase ABC contract and behavior."""
+
+import ast
+import json
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from meraki.exceptions import APIError
+from meraki.session.base import SessionBase
+from tests.unit.conftest import make_metadata as _metadata
+
+
+# ---------------------------------------------------------------------------
+# Test helper: concrete subclass
+# ---------------------------------------------------------------------------
+
+
+class ConcreteSession(SessionBase):
+ """Minimal concrete implementation for testing."""
+
+ def __init__(self, **kwargs):
+ self.sleeps: list[float] = []
+ self._mock_response = kwargs.pop("mock_response", None)
+ with patch("meraki.session.base.check_python_version"):
+ super().__init__(**kwargs)
+
+ def _send_request(self, method: str, url: str, **kwargs):
+ return self._mock_response
+
+ def _sleep(self, seconds: float) -> None:
+ self.sleeps.append(seconds)
+
+ def _transport_kwargs(self, kwargs):
+ return kwargs
+
+
+def _make_session(**overrides):
+ """Factory with sensible defaults."""
+ from tests.unit.conftest import DEFAULT_SESSION_KWARGS, FAKE_API_KEY
+
+ defaults = {"logger": None, "api_key": FAKE_API_KEY, **DEFAULT_SESSION_KWARGS}
+ defaults.update(overrides)
+ return ConcreteSession(**defaults)
+
+
+def _mock_response(
+ status_code=200,
+ json_data=None,
+ reason_phrase="OK",
+ headers=None,
+ content=b'{"ok":true}',
+):
+ """Create a mock httpx-like response."""
+ resp = MagicMock()
+ resp.status_code = status_code
+ resp.reason_phrase = reason_phrase
+ resp.headers = headers or {}
+ resp.content = content
+ if json_data is not None:
+ resp.json.return_value = json_data
+ else:
+ try:
+ resp.json.return_value = json.loads(content) if content.strip() else None
+ except (json.JSONDecodeError, ValueError):
+ resp.json.side_effect = ValueError("No JSON")
+ return resp
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+class TestABCEnforcement:
+ def test_abc_enforcement(self):
+ """SessionBase cannot be instantiated directly."""
+ with pytest.raises(TypeError):
+ SessionBase(
+ logger=None,
+ api_key="test_key_00000000000000000000000000000000000test",
+ caller="TestApp TestVendor",
+ )
+
+
+class TestConfigStorage:
+ def test_config_storage(self):
+ """Constructor stores all config attributes."""
+ session = _make_session()
+ assert session._api_key == "fake_api_key_1234567890123456789012345678901234567890"
+ assert session._base_url == "https://api.meraki.com/api/v1"
+ assert session._maximum_retries == 3
+ assert session._wait_on_rate_limit is True
+ assert session._single_request_timeout == 60
+ assert session._simulate is False
+ assert session._retry_4xx_error is False
+
+
+class TestBuildHeaders:
+ def test_build_headers(self):
+ """_build_headers produces correct Authorization, Content-Type, User-Agent."""
+ session = _make_session()
+ headers = session._build_headers()
+ assert headers["Authorization"] == "Bearer fake_api_key_1234567890123456789012345678901234567890"
+ assert headers["Content-Type"] == "application/json"
+ assert "python-meraki/" in headers["User-Agent"]
+
+
+class TestRequestSuccess:
+ def test_request_success_dispatch(self):
+ """200 response dispatches to _handle_success and returns response."""
+ resp = _mock_response(status_code=200, content=b'{"data": 1}')
+ session = _make_session(mock_response=resp)
+ result = session.request(_metadata(), "GET", "/test")
+ assert result is resp
+
+ def test_request_success_with_page(self):
+ """200 response with page metadata logs page counter."""
+ logger = MagicMock()
+ resp = _mock_response(status_code=200, content=b"[1,2,3]")
+ session = _make_session(mock_response=resp, logger=logger)
+ result = session.request(_metadata(page=2), "GET", "/test")
+ assert result is resp
+
+
+class TestRequestRateLimit:
+ def test_request_rate_limit(self):
+ """429 response triggers _handle_rate_limit with Retry-After."""
+ resp_429 = _mock_response(
+ status_code=429,
+ reason_phrase="Too Many Requests",
+ headers={"Retry-After": "3"},
+ content=b"rate limited",
+ )
+ resp_200 = _mock_response(status_code=200, content=b'{"ok":true}')
+
+ call_count = [0]
+ original_429 = resp_429
+ original_200 = resp_200
+
+ def side_effect(method, url, **kwargs):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return original_429
+ return original_200
+
+ session = _make_session()
+ session._send_request = side_effect
+ result = session.request(_metadata(), "GET", "/test")
+ assert result.status_code == 200
+ assert 3 in session.sleeps
+
+
+class TestRequestServerError:
+ def test_request_server_error(self):
+ """500 response triggers retry with 1s sleep."""
+ resp_500 = _mock_response(status_code=500, reason_phrase="Internal Server Error", content=b"error")
+ resp_200 = _mock_response(status_code=200, content=b'{"ok":true}')
+
+ call_count = [0]
+
+ def side_effect(method, url, **kwargs):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return resp_500
+ return resp_200
+
+ session = _make_session()
+ session._send_request = side_effect
+ result = session.request(_metadata(), "GET", "/test")
+ assert result.status_code == 200
+ assert 1 in session.sleeps
+
+
+class TestRequestRedirect:
+ def test_request_redirect(self):
+ """301 response updates URL via _handle_redirect."""
+ resp_301 = _mock_response(
+ status_code=301,
+ reason_phrase="Moved Permanently",
+ headers={"Location": "https://n123.meraki.com/api/v1/test"},
+ content=b"",
+ )
+ resp_200 = _mock_response(status_code=200, content=b'{"redirected":true}')
+
+ call_count = [0]
+
+ def side_effect(method, url, **kwargs):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return resp_301
+ return resp_200
+
+ session = _make_session()
+ session._send_request = side_effect
+ result = session.request(_metadata(), "GET", "/test")
+ assert result.status_code == 200
+
+
+class TestRetriesExhausted:
+ def test_request_raises_apierror_when_retries_exhausted(self):
+ """APIError raised after max retries on 500."""
+ resp_500 = _mock_response(status_code=500, reason_phrase="ISE", content=b"err")
+ session = _make_session(maximum_retries=1, mock_response=resp_500)
+ with pytest.raises(APIError):
+ session.request(_metadata(), "GET", "/test")
+
+
+class TestClientErrorNetworkDelete:
+ def test_handle_client_error_network_delete_concurrency(self):
+ """Network delete concurrency error triggers retry with random wait."""
+ resp_400 = _mock_response(
+ status_code=400,
+ reason_phrase="Bad Request",
+ content=b'{"errors":["concurrent network deletion in progress"]}',
+ json_data={"errors": ["concurrent network deletion in progress"]},
+ )
+ resp_200 = _mock_response(status_code=200, content=b'{"ok":true}')
+
+ call_count = [0]
+
+ def side_effect(method, url, **kwargs):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return resp_400
+ return resp_200
+
+ session = _make_session(network_delete_retry_wait_time=60)
+ session._send_request = side_effect
+ result = session.request(_metadata(operation="deleteNetwork"), "DELETE", "/test")
+ assert result.status_code == 200
+ assert len(session.sleeps) == 1
+ assert 30 <= session.sleeps[0] <= 60
+
+
+class TestClientErrorActionBatch:
+ def test_handle_client_error_action_batch_concurrency(self):
+ """Action batch concurrency error triggers retry."""
+ resp_400 = _mock_response(
+ status_code=400,
+ reason_phrase="Bad Request",
+ content=b'{"errors":["Currently executing batches. Try again later."]}',
+ json_data={"errors": ["Currently executing batches. Try again later."]},
+ )
+ resp_200 = _mock_response(status_code=200, content=b'{"ok":true}')
+
+ call_count = [0]
+
+ def side_effect(method, url, **kwargs):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return resp_400
+ return resp_200
+
+ session = _make_session(action_batch_retry_wait_time=5)
+ session._send_request = side_effect
+ result = session.request(_metadata(), "POST", "/test")
+ assert result.status_code == 200
+ assert 5 in session.sleeps
+
+
+class TestSimulateMode:
+ def test_simulate_mode_returns_none_for_non_get(self):
+ """Simulate mode returns None for POST without calling _send_request."""
+ session = _make_session(simulate=True)
+ call_tracker = []
+ session._send_request = lambda *a, **kw: call_tracker.append(1)
+ result = session.request(_metadata(), "POST", "/test")
+ assert result is None
+ assert len(call_tracker) == 0
+
+ def test_simulate_mode_allows_get(self):
+ """Simulate mode still performs GET requests."""
+ resp = _mock_response(status_code=200, content=b'{"data":1}')
+ session = _make_session(simulate=True, mock_response=resp)
+ result = session.request(_metadata(), "GET", "/test")
+ assert result is resp
+
+
+class TestComplexityAudit:
+ def test_complexity_audit(self):
+ """All handler methods have cyclomatic complexity under 10."""
+ base_path = Path(__file__).resolve().parent.parent.parent / "meraki" / "session" / "base.py"
+ source = base_path.read_text()
+ tree = ast.parse(source)
+
+ handler_methods = [
+ "_handle_success",
+ "_handle_redirect",
+ "_handle_rate_limit",
+ "_handle_server_error",
+ "_handle_client_error",
+ ]
+
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef) and node.name in handler_methods:
+ complexity = _compute_complexity(node)
+ assert complexity < 10, f"{node.name} has complexity {complexity} (must be < 10)"
+
+
+class TestMerakiParamEncodingHelpers:
+ """Fix #15: shared param-encoding helpers in base.py."""
+
+ def test_detects_list_of_dict(self):
+ from meraki.session.base import params_need_meraki_encoding
+
+ assert params_need_meraki_encoding({"variables[]": [{"name": "n"}]}) is True
+
+ def test_ignores_scalar_list(self):
+ from meraki.session.base import params_need_meraki_encoding
+
+ assert params_need_meraki_encoding({"networkIds[]": ["a", "b"]}) is False
+
+ def test_ignores_scalars_and_non_dict(self):
+ from meraki.session.base import params_need_meraki_encoding
+
+ assert params_need_meraki_encoding({"perPage": 10}) is False
+ assert params_need_meraki_encoding(None) is False
+ assert params_need_meraki_encoding("a=b") is False
+
+ def test_apply_folds_query_and_drops_params(self):
+ from meraki.session.base import apply_meraki_param_encoding
+
+ kwargs = {"params": {"variables[]": [{"name": "n1", "value": "v1"}]}}
+ url = apply_meraki_param_encoding("https://x/api/v1/things", kwargs)
+ assert "variables%5B%5Dname=n1" in url
+ assert "variables%5B%5Dvalue=v1" in url
+ assert url.count("?") == 1
+ assert kwargs["params"] is None
+
+ def test_apply_appends_with_ampersand_when_query_exists(self):
+ from meraki.session.base import apply_meraki_param_encoding
+
+ kwargs = {"params": {"variables[]": [{"name": "n1"}]}}
+ url = apply_meraki_param_encoding("https://x/api/v1/things?foo=bar", kwargs)
+ assert "?foo=bar&variables%5B%5Dname=n1" in url
+ assert kwargs["params"] is None
+
+ def test_apply_leaves_scalar_params_untouched(self):
+ from meraki.session.base import apply_meraki_param_encoding
+
+ kwargs = {"params": {"perPage": 10}}
+ url = apply_meraki_param_encoding("https://x/api/v1/things", kwargs)
+ assert url == "https://x/api/v1/things"
+ assert kwargs["params"] == {"perPage": 10}
+
+
+def _compute_complexity(func_node: ast.FunctionDef) -> int:
+ """Approximate McCabe cyclomatic complexity: count decision points + 1."""
+ complexity = 1
+ for node in ast.walk(func_node):
+ if isinstance(node, (ast.If, ast.IfExp)):
+ complexity += 1
+ elif isinstance(node, ast.For):
+ complexity += 1
+ elif isinstance(node, ast.While):
+ complexity += 1
+ elif isinstance(node, ast.ExceptHandler):
+ complexity += 1
+ elif isinstance(node, ast.BoolOp):
+ # Each and/or adds a branch
+ complexity += len(node.values) - 1
+ return complexity
diff --git a/tests/unit/test_smart_flow.py b/tests/unit/test_smart_flow.py
new file mode 100644
index 00000000..69aad6b2
--- /dev/null
+++ b/tests/unit/test_smart_flow.py
@@ -0,0 +1,1185 @@
+"""Tests for meraki.smart_flow module."""
+
+import asyncio
+import json
+import time
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from meraki.smart_flow import (
+ AsyncOrgRateLimiter,
+ AsyncTokenBucket,
+ OrgRateLimiter,
+ TokenBucket,
+ _parse_saved_at,
+)
+
+
+class TestParseSavedAt:
+ def test_valid_timestamp(self):
+ ts = _parse_saved_at("2026-01-15T12:30:00Z")
+ assert ts is not None
+ assert isinstance(ts, float)
+
+ def test_non_string_returns_none(self):
+ assert _parse_saved_at(None) is None
+ assert _parse_saved_at(12345) is None
+
+ def test_invalid_format_returns_none(self):
+ assert _parse_saved_at("not-a-date") is None
+ assert _parse_saved_at("2026-13-01T00:00:00Z") is None
+
+
+class TestTokenBucket:
+ def test_acquire_within_capacity_does_not_block(self):
+ bucket = TokenBucket(rate=10.0, capacity=10)
+ start = time.monotonic()
+ for _ in range(10):
+ bucket.acquire()
+ elapsed = time.monotonic() - start
+ assert elapsed < 0.1
+
+ def test_acquire_blocks_when_exhausted(self):
+ bucket = TokenBucket(rate=10.0, capacity=1)
+ bucket.acquire()
+ start = time.monotonic()
+ bucket.acquire()
+ elapsed = time.monotonic() - start
+ assert elapsed >= 0.05
+
+ def test_rate_setter_floors_at_half(self):
+ bucket = TokenBucket(rate=10.0, capacity=10)
+ bucket.rate = 0.1
+ assert bucket.rate == 0.5
+
+ def test_rate_setter_accepts_normal_values(self):
+ bucket = TokenBucket(rate=10.0, capacity=10)
+ bucket.rate = 5.0
+ assert bucket.rate == 5.0
+
+ def test_tokens_refill_over_time(self):
+ bucket = TokenBucket(rate=100.0, capacity=5)
+ for _ in range(5):
+ bucket.acquire()
+ time.sleep(0.06)
+ start = time.monotonic()
+ bucket.acquire()
+ elapsed = time.monotonic() - start
+ assert elapsed < 0.05
+
+
+class TestAsyncTokenBucket:
+ @pytest.mark.asyncio
+ async def test_acquire_within_capacity_does_not_block(self):
+ bucket = AsyncTokenBucket(rate=10.0, capacity=10)
+ start = asyncio.get_event_loop().time()
+ for _ in range(10):
+ await bucket.acquire()
+ elapsed = asyncio.get_event_loop().time() - start
+ assert elapsed < 0.1
+
+ @pytest.mark.asyncio
+ async def test_acquire_blocks_when_exhausted(self):
+ bucket = AsyncTokenBucket(rate=10.0, capacity=1)
+ await bucket.acquire()
+ start = asyncio.get_event_loop().time()
+ await bucket.acquire()
+ elapsed = asyncio.get_event_loop().time() - start
+ assert elapsed >= 0.05
+
+ @pytest.mark.asyncio
+ async def test_rate_setter_floors_at_half(self):
+ bucket = AsyncTokenBucket(rate=10.0, capacity=10)
+ bucket.rate = 0.1
+ assert bucket.rate == 0.5
+
+ @pytest.mark.asyncio
+ async def test_rate_setter_accepts_normal_values(self):
+ bucket = AsyncTokenBucket(rate=10.0, capacity=10)
+ bucket.rate = 7.0
+ assert bucket.rate == 7.0
+
+
+class TestOrgResolveOrg:
+ def test_resolves_org_from_url(self):
+ limiter = OrgRateLimiter()
+ assert limiter.resolve_org("/organizations/123/networks") == "123"
+
+ def test_resolves_org_via_network_cache(self):
+ limiter = OrgRateLimiter()
+ limiter.register_network("N_abc", "org_1")
+ assert limiter.resolve_org("/networks/N_abc/ssids") == "org_1"
+
+ def test_resolves_org_via_device_cache(self):
+ limiter = OrgRateLimiter()
+ limiter.register_device("Q2AB-CDE4-FGHI", "org_2")
+ assert limiter.resolve_org("/devices/Q2AB-CDE4-FGHI/clients") == "org_2"
+
+ def test_returns_none_for_unknown(self):
+ limiter = OrgRateLimiter()
+ assert limiter.resolve_org("/networks/N_unknown/ssids") is None
+
+ def test_returns_none_for_unrecognized_url(self):
+ limiter = OrgRateLimiter()
+ assert limiter.resolve_org("/admin/something") is None
+
+
+class TestOrgAIMD:
+ def test_on_rate_limited_decreases_rate(self):
+ limiter = OrgRateLimiter(rate=10.0)
+ limiter.register_org("org_1")
+ url = "/organizations/org_1/networks"
+ limiter.on_rate_limited(url)
+ bucket = limiter._org_buckets["org_1"]
+ assert bucket.rate == pytest.approx(7.0, abs=0.01)
+
+ def test_on_success_increases_rate(self):
+ limiter = OrgRateLimiter(rate=10.0)
+ limiter.register_org("org_1")
+ url = "/organizations/org_1/networks"
+ limiter.on_rate_limited(url)
+ limiter.on_success(url)
+ bucket = limiter._org_buckets["org_1"]
+ assert bucket.rate == pytest.approx(7.2, abs=0.01)
+
+ def test_on_success_caps_at_configured_rate(self):
+ limiter = OrgRateLimiter(rate=10.0)
+ limiter.register_org("org_1")
+ url = "/organizations/org_1/networks"
+ for _ in range(100):
+ limiter.on_success(url)
+ bucket = limiter._org_buckets["org_1"]
+ assert bucket.rate == 10.0
+
+ def test_on_rate_limited_unknown_org_decreases_global(self):
+ limiter = OrgRateLimiter(global_rate=100.0)
+ limiter.on_rate_limited("/organizations/ghost/networks")
+ assert limiter._global_bucket.rate == pytest.approx(70.0, abs=0.1)
+
+ def test_on_success_unknown_org_nudges_global(self):
+ limiter = OrgRateLimiter(global_rate=100.0)
+ limiter._global_bucket.rate = 90.0
+ limiter.on_success("/organizations/ghost/networks")
+ assert limiter._global_bucket.rate == pytest.approx(90.5, abs=0.01)
+
+ def test_on_success_unresolvable_url_nudges_global(self):
+ limiter = OrgRateLimiter(global_rate=100.0)
+ limiter._global_bucket.rate = 80.0
+ limiter.on_success("/admin/something")
+ assert limiter._global_bucket.rate == pytest.approx(80.5, abs=0.01)
+
+ def test_on_rate_limited_unresolvable_url_decreases_global(self):
+ limiter = OrgRateLimiter(global_rate=100.0)
+ limiter.on_rate_limited("/admin/something")
+ assert limiter._global_bucket.rate == pytest.approx(70.0, abs=0.1)
+
+ def test_on_success_global_caps_at_configured(self):
+ limiter = OrgRateLimiter(global_rate=100.0)
+ for _ in range(1000):
+ limiter.on_success("/admin/something")
+ assert limiter._global_bucket.rate == 100.0
+
+
+class TestOrgAcquire:
+ def test_acquire_with_org_url(self):
+ limiter = OrgRateLimiter(rate=10.0, global_rate=100.0)
+ limiter.acquire("/organizations/org_1/networks")
+ assert "org_1" in limiter._org_buckets
+
+ def test_acquire_org_url_deducts_from_both(self):
+ limiter = OrgRateLimiter(rate=10.0, global_rate=100.0)
+ limiter.acquire("/organizations/org_1/networks")
+ assert limiter._global_bucket._tokens < 100.0
+ assert limiter._org_buckets["org_1"]._tokens < 10.0
+
+ def test_acquire_with_cached_network(self):
+ limiter = OrgRateLimiter(rate=10.0)
+ limiter.register_network("N_1", "org_1")
+ limiter.acquire("/networks/N_1/ssids")
+ assert "org_1" in limiter._org_buckets
+
+ def test_acquire_unresolvable_url_uses_global_only(self):
+ limiter = OrgRateLimiter(rate=10.0, global_rate=100.0)
+ limiter.acquire("/admin/something")
+ assert len(limiter._org_buckets) == 0
+ assert limiter._global_bucket._tokens < 100.0
+
+ def test_acquire_unknown_network_with_resolver(self):
+ limiter = OrgRateLimiter(rate=10.0)
+ limiter.set_resolver(lambda id_type, ident: "org_resolved")
+ limiter.acquire("/networks/N_unknown/ssids")
+ assert limiter._network_to_org.get("N_unknown") == "org_resolved"
+
+ def test_acquire_unknown_device_with_resolver(self):
+ limiter = OrgRateLimiter(rate=10.0)
+ limiter.set_resolver(lambda id_type, ident: "org_dev")
+ limiter.acquire("/devices/QXYZ-1234-ABCD/clients")
+ assert limiter._serial_to_org.get("QXYZ-1234-ABCD") == "org_dev"
+
+ def test_acquire_resolver_returns_none_uses_global_only(self):
+ limiter = OrgRateLimiter(rate=10.0, global_rate=100.0)
+ limiter.set_resolver(lambda id_type, ident: None)
+ limiter.acquire("/networks/N_mystery/ssids")
+ assert "N_mystery" not in limiter._network_to_org
+ assert len(limiter._org_buckets) == 0
+
+ def test_acquire_resolver_exception_uses_global_only(self):
+ def bad_resolver(id_type, ident):
+ raise RuntimeError("boom")
+
+ limiter = OrgRateLimiter(rate=10.0, global_rate=100.0)
+ limiter.set_resolver(bad_resolver)
+ limiter.acquire("/networks/N_err/ssids")
+ assert len(limiter._org_buckets) == 0
+
+ def test_acquire_deduplicates_pending_lookups(self):
+ call_count = 0
+
+ def counting_resolver(id_type, ident):
+ nonlocal call_count
+ call_count += 1
+ return "org_1"
+
+ limiter = OrgRateLimiter(rate=10.0)
+ limiter.set_resolver(counting_resolver)
+ limiter._pending_lookups.add("N_dup")
+ limiter.acquire("/networks/N_dup/ssids")
+ assert call_count == 0
+
+
+class TestOrgResolverHydrator:
+ def test_resolver_triggers_hydrator_once(self):
+ hydrated = []
+
+ def mock_hydrator(org_id):
+ hydrated.append(org_id)
+
+ limiter = OrgRateLimiter(rate=10.0)
+ limiter.set_resolver(lambda id_type, ident: "org_h1")
+ limiter.set_hydrator(mock_hydrator)
+ limiter.acquire("/networks/N_1/ssids")
+ limiter.acquire("/networks/N_2/ssids")
+ assert hydrated == ["org_h1"]
+
+ def test_hydrator_not_called_without_resolver_result(self):
+ hydrated = []
+
+ def mock_hydrator(org_id):
+ hydrated.append(org_id)
+
+ limiter = OrgRateLimiter(rate=10.0)
+ limiter.set_resolver(lambda id_type, ident: None)
+ limiter.set_hydrator(mock_hydrator)
+ limiter.acquire("/networks/N_nope/ssids")
+ assert hydrated == []
+
+
+class TestOrgMaybeFlush:
+ def test_flush_at_threshold(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = OrgRateLimiter(cache_path=cache_file)
+ limiter._dirty = 50
+ limiter._maybe_flush()
+ assert limiter._dirty == 0
+ assert (tmp_path / "cache.json").exists()
+
+ def test_no_flush_below_threshold(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = OrgRateLimiter(cache_path=cache_file)
+ limiter._dirty = 49
+ limiter._maybe_flush()
+ assert limiter._dirty == 49
+
+
+class TestOrgLogger:
+ def test_log_with_logger(self):
+ logger = MagicMock()
+ limiter = OrgRateLimiter(logger=logger)
+ limiter._log("test message")
+ logger.debug.assert_called_once_with("smart_flow, test message")
+
+ def test_log_without_logger(self):
+ limiter = OrgRateLimiter()
+ limiter._log("no crash")
+
+
+class TestCacheTTL:
+ def test_save_and_load_fresh_cache(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = OrgRateLimiter(cache_path=cache_file)
+ limiter.register_network("N_1", "org_A")
+ limiter.register_device("SERIAL_1", "org_B")
+ limiter.save_cache()
+
+ limiter2 = OrgRateLimiter(cache_path=cache_file)
+ assert limiter2.resolve_org("/networks/N_1/ssids") == "org_A"
+ assert limiter2.resolve_org("/devices/SERIAL_1/clients") == "org_B"
+
+ def test_expired_cache_is_ignored(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = OrgRateLimiter(cache_path=cache_file, cache_ttl=60.0)
+ limiter.register_network("N_1", "org_A")
+ limiter.save_cache()
+
+ with patch("time.time", return_value=time.time() + 120):
+ limiter2 = OrgRateLimiter(cache_path=cache_file, cache_ttl=60.0)
+ assert limiter2.resolve_org("/networks/N_1/ssids") is None
+
+ def test_cache_without_timestamp_is_treated_as_expired(self, tmp_path):
+ cache_file = tmp_path / "cache.json"
+ cache_file.write_text(
+ json.dumps(
+ {
+ "networks": [{"id": "N_1", "organization": {"id": "org_A"}}],
+ "devices": [],
+ }
+ )
+ )
+
+ limiter = OrgRateLimiter(cache_path=str(cache_file), cache_ttl=60.0)
+ assert limiter.resolve_org("/networks/N_1/ssids") is None
+
+ def test_none_ttl_disables_expiration(self, tmp_path):
+ cache_file = tmp_path / "cache.json"
+ cache_file.write_text(
+ json.dumps(
+ {
+ "networks": [{"id": "N_1", "organization": {"id": "org_A"}}],
+ "devices": [],
+ }
+ )
+ )
+
+ limiter = OrgRateLimiter(cache_path=str(cache_file), cache_ttl=None)
+ assert limiter.resolve_org("/networks/N_1/ssids") == "org_A"
+
+ def test_corrupt_cache_is_ignored(self, tmp_path):
+ cache_file = tmp_path / "cache.json"
+ cache_file.write_text("not json{{{")
+
+ limiter = OrgRateLimiter(cache_path=str(cache_file))
+ assert limiter.resolve_org("/networks/N_1/ssids") is None
+
+ def test_cache_fresh_flag(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = OrgRateLimiter(cache_path=cache_file)
+ assert limiter.cache_fresh is False
+ limiter.register_network("N_1", "org_A")
+ limiter.save_cache()
+ limiter2 = OrgRateLimiter(cache_path=cache_file)
+ assert limiter2.cache_fresh is True
+
+ def test_save_without_cache_path_is_noop(self):
+ limiter = OrgRateLimiter()
+ limiter.save_cache()
+
+ def test_load_nonexistent_cache(self, tmp_path):
+ limiter = OrgRateLimiter(cache_path=str(tmp_path / "nope.json"))
+ assert limiter.cache_fresh is False
+
+ def test_cache_with_invalid_saved_at_format(self, tmp_path):
+ cache_file = tmp_path / "cache.json"
+ cache_file.write_text(
+ json.dumps(
+ {
+ "saved_at": "invalid-date",
+ "networks": [{"id": "N_1", "organization": {"id": "org_A"}}],
+ "devices": [],
+ }
+ )
+ )
+ limiter = OrgRateLimiter(cache_path=str(cache_file), cache_ttl=60.0)
+ assert limiter.resolve_org("/networks/N_1/ssids") is None
+
+
+class TestLearnFromResponse:
+ def test_learns_org_from_url(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response("/organizations/org_1/networks", [{"id": "N_1"}])
+ assert "org_1" in limiter._org_buckets
+
+ def test_learns_network_from_url(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response(
+ "/organizations/org_1/networks/N_1/ssids",
+ {"name": "test"},
+ )
+ assert limiter._network_to_org.get("N_1") == "org_1"
+
+ def test_learns_serial_from_url(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response(
+ "/organizations/org_1/devices/Q2AB-1234-ABCD/clients",
+ {},
+ )
+ assert limiter._serial_to_org.get("Q2AB-1234-ABCD") == "org_1"
+
+ def test_learns_network_id_from_body(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response(
+ "/organizations/org_1/something",
+ {"networkId": "N_99"},
+ )
+ assert limiter._network_to_org.get("N_99") == "org_1"
+
+ def test_learns_serial_from_body(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response(
+ "/organizations/org_1/something",
+ {"serial": "QXYZ-0000-1111"},
+ )
+ assert limiter._serial_to_org.get("QXYZ-0000-1111") == "org_1"
+
+ def test_learns_org_from_body_organizationId(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response(
+ "/networks/N_5/ssids",
+ {"organizationId": "org_7", "networkId": "N_5"},
+ )
+ assert limiter._network_to_org.get("N_5") == "org_7"
+
+ def test_learns_org_from_body_organization_dict(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response(
+ "/networks/N_6/clients",
+ {"organization": {"id": "org_8"}, "networkId": "N_6"},
+ )
+ assert limiter._network_to_org.get("N_6") == "org_8"
+
+ def test_learns_network_from_body_network_dict(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response(
+ "/organizations/org_1/something",
+ {"network": {"id": "N_nested"}},
+ )
+ assert limiter._network_to_org.get("N_nested") == "org_1"
+
+ def test_noop_when_no_org_resolvable(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response("/admin/page", {"foo": "bar"})
+ assert not limiter._network_to_org
+ assert not limiter._serial_to_org
+
+ def test_overwrites_stale_mapping_on_org_change(self):
+ limiter = OrgRateLimiter()
+ limiter.register_network("N_1", "org_original")
+ limiter.learn_from_response(
+ "/organizations/org_new/networks/N_1/ssids",
+ {},
+ )
+ assert limiter._network_to_org["N_1"] == "org_new"
+
+ def test_handles_non_dict_body(self):
+ limiter = OrgRateLimiter()
+ limiter.learn_from_response(
+ "/organizations/org_1/networks",
+ [{"id": "N_1"}, {"id": "N_2"}],
+ )
+ assert "org_1" in limiter._org_buckets
+
+ def test_no_change_does_not_increment_dirty(self):
+ limiter = OrgRateLimiter()
+ limiter.register_network("N_1", "org_1")
+ limiter._dirty = 0
+ limiter.learn_from_response(
+ "/organizations/org_1/networks/N_1/ssids",
+ {},
+ )
+ assert limiter._dirty == 0
+
+ def test_learn_triggers_flush_at_threshold(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = OrgRateLimiter(cache_path=cache_file)
+ limiter._dirty = 49
+ limiter.learn_from_response(
+ "/organizations/org_1/networks/N_new/ssids",
+ {},
+ )
+ assert (tmp_path / "cache.json").exists()
+
+ def test_learn_with_logger_logs_changes(self):
+ logger = MagicMock()
+ limiter = OrgRateLimiter(logger=logger)
+ limiter.learn_from_response(
+ "/organizations/org_1/networks/N_1/ssids",
+ {"serial": "QABC-1111-2222"},
+ )
+ assert logger.debug.call_count >= 1
+
+
+class TestAsyncTokenBucketRateSetter:
+ @pytest.mark.asyncio
+ async def test_rate_property(self):
+ bucket = AsyncTokenBucket(rate=10.0, capacity=10)
+ assert bucket.rate == 10.0
+
+
+class TestAsyncOrgRateLimiter:
+ @pytest.mark.asyncio
+ async def test_resolve_org_from_url(self):
+ limiter = AsyncOrgRateLimiter()
+ assert limiter.resolve_org("/organizations/org_1/networks") == "org_1"
+
+ @pytest.mark.asyncio
+ async def test_resolve_org_via_network_cache(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter.register_network("N_1", "org_1")
+ assert limiter.resolve_org("/networks/N_1/ssids") == "org_1"
+
+ @pytest.mark.asyncio
+ async def test_resolve_org_via_device_cache(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter.register_device("QABC-1234-5678", "org_2")
+ assert limiter.resolve_org("/devices/QABC-1234-5678/clients") == "org_2"
+
+ @pytest.mark.asyncio
+ async def test_resolve_org_returns_none(self):
+ limiter = AsyncOrgRateLimiter()
+ assert limiter.resolve_org("/admin/page") is None
+
+ @pytest.mark.asyncio
+ async def test_acquire_with_org_url(self):
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ await limiter.acquire("/organizations/org_1/networks")
+ assert "org_1" in limiter._org_buckets
+
+ @pytest.mark.asyncio
+ async def test_acquire_unknown_triggers_background_resolve(self):
+ resolved = []
+
+ async def mock_resolver(id_type, ident):
+ resolved.append((id_type, ident))
+ return "org_bg"
+
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.set_resolver(mock_resolver)
+ await limiter.acquire("/networks/N_unknown/ssids")
+ await asyncio.sleep(0.05)
+ assert resolved == [("network", "N_unknown")]
+ assert limiter._network_to_org.get("N_unknown") == "org_bg"
+
+ @pytest.mark.asyncio
+ async def test_acquire_unknown_device_triggers_resolve(self):
+ async def mock_resolver(id_type, ident):
+ return "org_dev"
+
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.set_resolver(mock_resolver)
+ await limiter.acquire("/devices/QABC-0000-1111/uplink")
+ await asyncio.sleep(0.05)
+ assert limiter._serial_to_org.get("QABC-0000-1111") == "org_dev"
+
+ @pytest.mark.asyncio
+ async def test_acquire_no_resolver_uses_global_only(self):
+ limiter = AsyncOrgRateLimiter(rate=10.0, global_rate=100.0)
+ await limiter.acquire("/networks/N_mystery/ssids")
+ assert len(limiter._org_buckets) == 0
+ assert limiter._global_bucket._tokens < 100.0
+
+ @pytest.mark.asyncio
+ async def test_background_resolve_deduplicates(self):
+ call_count = 0
+
+ async def mock_resolver(id_type, ident):
+ nonlocal call_count
+ call_count += 1
+ await asyncio.sleep(0.05)
+ return "org_1"
+
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.set_resolver(mock_resolver)
+ await limiter.acquire("/networks/N_dup/ssids")
+ await limiter.acquire("/networks/N_dup/ssids")
+ await asyncio.sleep(0.1)
+ assert call_count == 1
+
+ @pytest.mark.asyncio
+ async def test_background_resolve_with_hydrator(self):
+ hydrated = []
+
+ async def mock_resolver(id_type, ident):
+ return "org_h"
+
+ async def mock_hydrator(org_id):
+ hydrated.append(org_id)
+
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.set_resolver(mock_resolver)
+ limiter.set_hydrator(mock_hydrator)
+ await limiter.acquire("/networks/N_1/ssids")
+ await asyncio.sleep(0.05)
+ assert hydrated == ["org_h"]
+
+ @pytest.mark.asyncio
+ async def test_hydrator_only_called_once_per_org(self):
+ hydrated = []
+
+ async def mock_resolver(id_type, ident):
+ return "org_h"
+
+ async def mock_hydrator(org_id):
+ hydrated.append(org_id)
+
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.set_resolver(mock_resolver)
+ limiter.set_hydrator(mock_hydrator)
+ await limiter.acquire("/networks/N_1/ssids")
+ await asyncio.sleep(0.05)
+ await limiter.acquire("/networks/N_2/ssids")
+ await asyncio.sleep(0.05)
+ assert hydrated == ["org_h"]
+
+ @pytest.mark.asyncio
+ async def test_resolve_exception_is_swallowed(self):
+ async def bad_resolver(id_type, ident):
+ raise RuntimeError("boom")
+
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.set_resolver(bad_resolver)
+ await limiter.acquire("/networks/N_err/ssids")
+ await asyncio.sleep(0.05)
+
+ @pytest.mark.asyncio
+ async def test_resolver_returns_none(self):
+ async def none_resolver(id_type, ident):
+ return None
+
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.set_resolver(none_resolver)
+ await limiter.acquire("/networks/N_nope/ssids")
+ await asyncio.sleep(0.05)
+ assert "N_nope" not in limiter._network_to_org
+
+ @pytest.mark.asyncio
+ async def test_on_rate_limited(self):
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.register_org("org_1")
+ limiter.on_rate_limited("/organizations/org_1/networks")
+ bucket = limiter._org_buckets["org_1"]
+ assert bucket.rate == pytest.approx(7.0, abs=0.01)
+
+ @pytest.mark.asyncio
+ async def test_on_rate_limited_unknown_decreases_global(self):
+ limiter = AsyncOrgRateLimiter(rate=10.0, global_rate=100.0)
+ limiter.on_rate_limited("/organizations/ghost/x")
+ assert limiter._global_bucket.rate == pytest.approx(70.0, abs=0.1)
+
+ @pytest.mark.asyncio
+ async def test_on_success(self):
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.register_org("org_1")
+ limiter.on_rate_limited("/organizations/org_1/x")
+ limiter.on_success("/organizations/org_1/x")
+ bucket = limiter._org_buckets["org_1"]
+ assert bucket.rate == pytest.approx(7.2, abs=0.01)
+
+ @pytest.mark.asyncio
+ async def test_on_success_caps_at_rate(self):
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.register_org("org_1")
+ for _ in range(100):
+ limiter.on_success("/organizations/org_1/x")
+ bucket = limiter._org_buckets["org_1"]
+ assert bucket.rate == 10.0
+
+ @pytest.mark.asyncio
+ async def test_on_success_unknown_nudges_global(self):
+ limiter = AsyncOrgRateLimiter(rate=10.0, global_rate=100.0)
+ limiter._global_bucket.rate = 80.0
+ limiter.on_success("/organizations/ghost/x")
+ assert limiter._global_bucket.rate == pytest.approx(80.5, abs=0.01)
+
+ @pytest.mark.asyncio
+ async def test_register_org(self):
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.register_org("org_new")
+ assert "org_new" in limiter._org_buckets
+
+ @pytest.mark.asyncio
+ async def test_learn_from_response_network(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter.learn_from_response(
+ "/organizations/org_1/networks/N_1/ssids",
+ {"networkId": "N_1"},
+ )
+ assert limiter._network_to_org.get("N_1") == "org_1"
+
+ @pytest.mark.asyncio
+ async def test_learn_from_response_device(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter.learn_from_response(
+ "/organizations/org_1/devices/QABC-1234-5678/clients",
+ {"serial": "QABC-1234-5678"},
+ )
+ assert limiter._serial_to_org.get("QABC-1234-5678") == "org_1"
+
+ @pytest.mark.asyncio
+ async def test_learn_from_response_body_org_id(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter.learn_from_response(
+ "/networks/N_5/ssids",
+ {"organizationId": "org_7", "networkId": "N_5"},
+ )
+ assert limiter._network_to_org.get("N_5") == "org_7"
+
+ @pytest.mark.asyncio
+ async def test_learn_from_response_body_org_dict(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter.learn_from_response(
+ "/networks/N_6/clients",
+ {"organization": {"id": "org_8"}, "networkId": "N_6"},
+ )
+ assert limiter._network_to_org.get("N_6") == "org_8"
+
+ @pytest.mark.asyncio
+ async def test_learn_from_response_network_dict_in_body(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter.learn_from_response(
+ "/organizations/org_1/something",
+ {"network": {"id": "N_nested"}},
+ )
+ assert limiter._network_to_org.get("N_nested") == "org_1"
+
+ @pytest.mark.asyncio
+ async def test_learn_noop_no_org(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter.learn_from_response("/admin/x", {"foo": "bar"})
+ assert not limiter._network_to_org
+
+ @pytest.mark.asyncio
+ async def test_learn_no_change_no_dirty(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter.register_network("N_1", "org_1")
+ limiter._dirty = 0
+ limiter.learn_from_response("/organizations/org_1/networks/N_1/ssids", {})
+ assert limiter._dirty == 0
+
+ @pytest.mark.asyncio
+ async def test_learn_logs_changes(self):
+ logger = MagicMock()
+ limiter = AsyncOrgRateLimiter(logger=logger)
+ limiter.learn_from_response(
+ "/organizations/org_1/networks/N_1/ssids",
+ {"serial": "QABC-0000-1111"},
+ )
+ assert logger.debug.call_count >= 1
+
+ @pytest.mark.asyncio
+ async def test_maybe_flush_at_threshold(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = AsyncOrgRateLimiter(cache_path=cache_file)
+ limiter._dirty = 50
+ limiter._maybe_flush()
+ await asyncio.sleep(0.05)
+ assert (tmp_path / "cache.json").exists()
+
+ @pytest.mark.asyncio
+ async def test_maybe_flush_skips_if_task_running(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = AsyncOrgRateLimiter(cache_path=cache_file)
+ limiter._dirty = 50
+ limiter._flush_task = asyncio.ensure_future(asyncio.sleep(1.0))
+ limiter._maybe_flush()
+ assert limiter._dirty == 50
+ limiter._flush_task.cancel()
+ try:
+ await limiter._flush_task
+ except asyncio.CancelledError:
+ pass
+
+ @pytest.mark.asyncio
+ async def test_log_with_logger(self):
+ logger = MagicMock()
+ limiter = AsyncOrgRateLimiter(logger=logger)
+ limiter._log("hello")
+ logger.debug.assert_called_with("smart_flow, hello")
+
+ @pytest.mark.asyncio
+ async def test_log_without_logger(self):
+ limiter = AsyncOrgRateLimiter()
+ limiter._log("no crash")
+
+ @pytest.mark.asyncio
+ async def test_cache_fresh_property(self):
+ limiter = AsyncOrgRateLimiter()
+ assert limiter.cache_fresh is False
+
+
+class TestAsyncCacheTTL:
+ @pytest.mark.asyncio
+ async def test_save_and_load_fresh_cache(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = AsyncOrgRateLimiter(cache_path=cache_file)
+ limiter.register_network("N_1", "org_A")
+ await limiter.save_cache()
+
+ limiter2 = AsyncOrgRateLimiter(cache_path=cache_file)
+ assert limiter2.resolve_org("/networks/N_1/ssids") == "org_A"
+
+ @pytest.mark.asyncio
+ async def test_expired_cache_is_ignored(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = AsyncOrgRateLimiter(cache_path=cache_file, cache_ttl=60.0)
+ limiter.register_network("N_1", "org_A")
+ await limiter.save_cache()
+
+ with patch("time.time", return_value=time.time() + 120):
+ limiter2 = AsyncOrgRateLimiter(cache_path=cache_file, cache_ttl=60.0)
+ assert limiter2.resolve_org("/networks/N_1/ssids") is None
+
+ @pytest.mark.asyncio
+ async def test_save_without_cache_path(self):
+ limiter = AsyncOrgRateLimiter()
+ await limiter.save_cache()
+
+ @pytest.mark.asyncio
+ async def test_none_ttl_disables_expiration(self, tmp_path):
+ cache_file = tmp_path / "cache.json"
+ cache_file.write_text(
+ json.dumps(
+ {
+ "networks": [{"id": "N_1", "organization": {"id": "org_A"}}],
+ "devices": [],
+ }
+ )
+ )
+ limiter = AsyncOrgRateLimiter(cache_path=str(cache_file), cache_ttl=None)
+ assert limiter.resolve_org("/networks/N_1/ssids") == "org_A"
+
+ @pytest.mark.asyncio
+ async def test_corrupt_cache_ignored(self, tmp_path):
+ cache_file = tmp_path / "cache.json"
+ cache_file.write_text("not json{{{")
+ limiter = AsyncOrgRateLimiter(cache_path=str(cache_file))
+ assert limiter.cache_fresh is False
+
+ @pytest.mark.asyncio
+ async def test_cache_without_timestamp_expired(self, tmp_path):
+ cache_file = tmp_path / "cache.json"
+ cache_file.write_text(
+ json.dumps(
+ {
+ "networks": [{"id": "N_1", "organization": {"id": "org_A"}}],
+ "devices": [],
+ }
+ )
+ )
+ limiter = AsyncOrgRateLimiter(cache_path=str(cache_file), cache_ttl=60.0)
+ assert limiter.resolve_org("/networks/N_1/ssids") is None
+
+ @pytest.mark.asyncio
+ async def test_nonexistent_cache_path(self, tmp_path):
+ limiter = AsyncOrgRateLimiter(cache_path=str(tmp_path / "nope.json"))
+ assert limiter.cache_fresh is False
+
+ @pytest.mark.asyncio
+ async def test_invalid_saved_at_treated_as_expired(self, tmp_path):
+ cache_file = tmp_path / "cache.json"
+ cache_file.write_text(
+ json.dumps(
+ {
+ "saved_at": "bad-format",
+ "networks": [{"id": "N_1", "organization": {"id": "org_A"}}],
+ "devices": [],
+ }
+ )
+ )
+ limiter = AsyncOrgRateLimiter(cache_path=str(cache_file), cache_ttl=60.0)
+ assert limiter.resolve_org("/networks/N_1/ssids") is None
+
+
+class TestSyncTokenBucketThreadSafety:
+ """Fix #11: TokenBucket must have a real lock and not over-spend."""
+
+ def test_lock_exists_and_is_a_lock(self):
+ bucket = TokenBucket(rate=10.0, capacity=10)
+ # threading.Lock() returns a lock object exposing acquire/release.
+ assert hasattr(bucket, "_lock")
+ assert hasattr(bucket._lock, "acquire")
+ assert hasattr(bucket._lock, "release")
+
+ def test_concurrent_threads_do_not_overspend(self):
+ import threading
+
+ # Capacity covers the burst exactly; with a real lock, total wall time
+ # for capacity-many concurrent acquires stays near zero (no double-spend
+ # forcing a sleep). Without a lock, the read-modify-write races.
+ bucket = TokenBucket(rate=10.0, capacity=20)
+ barrier = threading.Barrier(20)
+
+ def worker():
+ barrier.wait()
+ bucket.acquire()
+
+ threads = [threading.Thread(target=worker) for _ in range(20)]
+ start = time.monotonic()
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ elapsed = time.monotonic() - start
+
+ # 20 acquires against capacity 20 should all be served from the bucket
+ # without anyone sleeping for a refill.
+ assert elapsed < 0.5
+ # Exactly 20 tokens were spent. With a real lock the deficit is bounded
+ # (~0, give or take tiny refill); without one, racing read-modify-writes
+ # would let tokens stay well above 0 (double-spend) or be inconsistent.
+ assert -0.05 <= bucket._tokens <= 0.05
+
+ def test_aggregate_rate_not_exceeded_when_drained(self):
+ # Drain the bucket, then 5 concurrent acquires must take ~ (5 / rate).
+ import threading
+
+ bucket = TokenBucket(rate=20.0, capacity=1)
+ bucket.acquire() # drain the single token
+
+ def worker():
+ bucket.acquire()
+
+ threads = [threading.Thread(target=worker) for _ in range(5)]
+ start = time.monotonic()
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ elapsed = time.monotonic() - start
+
+ # 5 tokens at 20/s with a drained bucket => >= ~0.20s minimum.
+ assert elapsed >= 0.20 - 0.03
+
+
+class TestAsyncTokenBucketConcurrency:
+ """Fix #10: acquire() must not serialize all coroutines behind one sleeper."""
+
+ @pytest.mark.asyncio
+ async def test_concurrent_acquirers_not_serialized(self):
+ # Drained bucket, 5 concurrent acquirers. If the lock were held across
+ # the sleep they would queue serially (~5 * per-token wait). With the
+ # reservation fix they each sleep concurrently against a shared deficit.
+ bucket = AsyncTokenBucket(rate=10.0, capacity=1)
+ await bucket.acquire() # drain
+
+ start = asyncio.get_event_loop().time()
+ await asyncio.gather(*(bucket.acquire() for _ in range(5)))
+ elapsed = asyncio.get_event_loop().time() - start
+
+ # Serialized-across-sleep behavior would be ~5 * 0.1 = 0.5s+ stacking.
+ # Concurrent reservation: the last reservation waits ~5/10 = 0.5s, but
+ # they overlap so total is bounded by the max single wait (~0.5s) and
+ # crucially is NOT additive blow-up. Assert it's not pathologically
+ # serialized (which would be each waiting then the next computing fresh
+ # after the previous slept -> well over the deficit window).
+ assert elapsed < 1.0
+
+ @pytest.mark.asyncio
+ async def test_aggregate_rate_not_exceeded(self):
+ # Across many acquires the steady-state dispatch must stay <= rate.
+ rate = 50.0
+ n = 25
+ bucket = AsyncTokenBucket(rate=rate, capacity=1)
+ await bucket.acquire() # drain
+
+ start = asyncio.get_event_loop().time()
+ await asyncio.gather(*(bucket.acquire() for _ in range(n)))
+ elapsed = asyncio.get_event_loop().time() - start
+
+ # n tokens at `rate`/s from a drained bucket cannot complete faster than
+ # n/rate seconds without exceeding the configured rate.
+ min_expected = n / rate
+ assert elapsed >= min_expected - 0.05
+
+
+class TestAsyncBgTaskRetention:
+ """Fix #6: background resolve tasks are held then discarded on completion."""
+
+ @pytest.mark.asyncio
+ async def test_bg_task_retained_then_discarded(self):
+ started = asyncio.Event()
+ release = asyncio.Event()
+
+ async def slow_resolver(id_type, ident):
+ started.set()
+ await release.wait()
+ return "org_bg"
+
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.set_resolver(slow_resolver)
+ await limiter.acquire("/networks/N_held/ssids")
+ await started.wait()
+
+ # While in flight, the limiter holds a strong ref.
+ assert len(limiter._bg_tasks) == 1
+
+ release.set()
+ await asyncio.sleep(0.02)
+
+ # Done-callback discards it.
+ assert len(limiter._bg_tasks) == 0
+ assert limiter._network_to_org.get("N_held") == "org_bg"
+
+ @pytest.mark.asyncio
+ async def test_resolver_exception_logged_not_swallowed(self):
+ logger = MagicMock()
+
+ async def bad_resolver(id_type, ident):
+ raise RuntimeError("boom")
+
+ limiter = AsyncOrgRateLimiter(rate=10.0, logger=logger)
+ limiter.set_resolver(bad_resolver)
+ await limiter.acquire("/networks/N_err/ssids")
+ await asyncio.sleep(0.02)
+
+ # The bare except now logs at debug instead of fully swallowing.
+ logged = " ".join(str(c) for c in logger.debug.call_args_list)
+ assert "failed" in logged
+ assert len(limiter._bg_tasks) == 0
+
+
+class TestAsyncFlushDirtyOnFailure:
+ """Fix #8: dirty count must survive a failed save."""
+
+ @pytest.mark.asyncio
+ async def test_dirty_not_zeroed_when_save_raises(self):
+ limiter = AsyncOrgRateLimiter(cache_path="ignored")
+ limiter._dirty = 50
+
+ async def failing_save():
+ raise OSError("disk full")
+
+ with patch.object(limiter, "save_cache", side_effect=failing_save):
+ limiter._maybe_flush()
+ await asyncio.sleep(0.02)
+
+ # Save failed -> dirty must be retained, not lost.
+ assert limiter._dirty == 50
+
+ @pytest.mark.asyncio
+ async def test_dirty_zeroed_on_successful_save(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = AsyncOrgRateLimiter(cache_path=cache_file)
+ limiter.register_network("N_1", "org_A")
+ limiter._dirty = 50
+ limiter._maybe_flush()
+ # Wait for the flush task and its done-callback to run.
+ if limiter._flush_task:
+ await limiter._flush_task
+ await asyncio.sleep(0.02)
+ assert limiter._dirty == 0
+
+
+class TestUnresolvedRateLimitGlobalProtection:
+ """Fix #13: a 429 on an unresolved network/device must not lower global."""
+
+ def test_sync_unresolved_network_does_not_lower_global(self):
+ limiter = OrgRateLimiter(global_rate=100.0)
+ before = limiter._global_bucket.rate
+ limiter.on_rate_limited("/networks/N_unknown/ssids")
+ assert limiter._global_bucket.rate == before
+
+ def test_sync_unresolved_device_does_not_lower_global(self):
+ limiter = OrgRateLimiter(global_rate=100.0)
+ before = limiter._global_bucket.rate
+ limiter.on_rate_limited("/devices/Q2AB-CDE4-FGHI/clients")
+ assert limiter._global_bucket.rate == before
+
+ def test_sync_explicit_org_url_still_lowers_global(self):
+ # An explicit, unbucketed org URL is a known org -> existing behavior.
+ limiter = OrgRateLimiter(global_rate=100.0)
+ limiter.on_rate_limited("/organizations/ghost/networks")
+ assert limiter._global_bucket.rate == pytest.approx(70.0, abs=0.1)
+
+ def test_sync_truly_unscoped_url_still_lowers_global(self):
+ limiter = OrgRateLimiter(global_rate=100.0)
+ limiter.on_rate_limited("/admin/something")
+ assert limiter._global_bucket.rate == pytest.approx(70.0, abs=0.1)
+
+ def test_sync_resolved_network_penalizes_org_not_global(self):
+ limiter = OrgRateLimiter(rate=10.0, global_rate=100.0)
+ limiter.register_network("N_known", "org_k")
+ limiter.register_org("org_k")
+ global_before = limiter._global_bucket.rate
+ limiter.on_rate_limited("/networks/N_known/ssids")
+ assert limiter._org_buckets["org_k"].rate == pytest.approx(7.0, abs=0.01)
+ assert limiter._global_bucket.rate == global_before
+
+ @pytest.mark.asyncio
+ async def test_async_unresolved_network_does_not_lower_global(self):
+ limiter = AsyncOrgRateLimiter(global_rate=100.0)
+ before = limiter._global_bucket.rate
+ limiter.on_rate_limited("/networks/N_unknown/ssids")
+ assert limiter._global_bucket.rate == before
+
+ @pytest.mark.asyncio
+ async def test_async_explicit_org_url_still_lowers_global(self):
+ limiter = AsyncOrgRateLimiter(global_rate=100.0)
+ limiter.on_rate_limited("/organizations/ghost/x")
+ assert limiter._global_bucket.rate == pytest.approx(70.0, abs=0.1)
+
+
+class TestAsyncShutdown:
+ """Fix #7-SUPPORT: shutdown() drains bg tasks, flush, and saves."""
+
+ @pytest.mark.asyncio
+ async def test_shutdown_awaits_pending_bg_tasks(self, tmp_path):
+ release = asyncio.Event()
+ finished = []
+
+ async def slow_resolver(id_type, ident):
+ await release.wait()
+ finished.append(ident)
+ return "org_s"
+
+ cache_file = str(tmp_path / "cache.json")
+ limiter = AsyncOrgRateLimiter(rate=10.0, cache_path=cache_file)
+ limiter.set_resolver(slow_resolver)
+ await limiter.acquire("/networks/N_shutdown/ssids")
+ assert len(limiter._bg_tasks) == 1
+
+ # Release the resolver, then shutdown should await it to completion.
+ release.set()
+ await limiter.shutdown()
+
+ assert finished == ["N_shutdown"]
+ assert len(limiter._bg_tasks) == 0
+ assert limiter._network_to_org.get("N_shutdown") == "org_s"
+ # Final save happened.
+ assert (tmp_path / "cache.json").exists()
+
+ @pytest.mark.asyncio
+ async def test_shutdown_idempotent_with_no_work(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = AsyncOrgRateLimiter(cache_path=cache_file)
+ # No bg tasks, no flush task -> safe to call, does a final save.
+ await limiter.shutdown()
+ await limiter.shutdown()
+ assert (tmp_path / "cache.json").exists()
+
+ @pytest.mark.asyncio
+ async def test_shutdown_awaits_pending_flush(self, tmp_path):
+ cache_file = str(tmp_path / "cache.json")
+ limiter = AsyncOrgRateLimiter(cache_path=cache_file)
+ limiter.register_network("N_1", "org_A")
+ limiter._dirty = 50
+ limiter._maybe_flush()
+ assert limiter._flush_task is not None
+ await limiter.shutdown()
+ # Flush completed without error -> dirty zeroed.
+ assert limiter._dirty == 0
+
+
+class TestAsyncBackgroundResolveEdgeCases:
+ @pytest.mark.asyncio
+ async def test_unrecognized_url_no_resolve(self):
+ called = []
+
+ async def mock_resolver(id_type, ident):
+ called.append(ident)
+ return "org_1"
+
+ limiter = AsyncOrgRateLimiter(rate=10.0)
+ limiter.set_resolver(mock_resolver)
+ await limiter.acquire("/admin/something")
+ await asyncio.sleep(0.05)
+ assert called == []
diff --git a/uv.lock b/uv.lock
index 14040f9a..a9e24b8a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,136 +3,16 @@ revision = 3
requires-python = ">=3.11"
[[package]]
-name = "aiohappyeyeballs"
-version = "2.6.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
-]
-
-[[package]]
-name = "aiohttp"
-version = "3.13.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "aiohappyeyeballs" },
- { name = "aiosignal" },
- { name = "attrs" },
- { name = "frozenlist" },
- { name = "multidict" },
- { name = "propcache" },
- { name = "yarl" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" },
- { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" },
- { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" },
- { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" },
- { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" },
- { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" },
- { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" },
- { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" },
- { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" },
- { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" },
- { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" },
- { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" },
- { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" },
- { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" },
- { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" },
- { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" },
- { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" },
- { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
- { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
- { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
- { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
- { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
- { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
- { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
- { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
- { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
- { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
- { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
- { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
- { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
- { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
- { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
- { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
- { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
- { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
- { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
- { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
- { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
- { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
- { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
- { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
- { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
- { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
- { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
- { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
- { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
- { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
- { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
- { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
- { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
- { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
- { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
- { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
- { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
- { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
- { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
- { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
- { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
- { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
- { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
- { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
- { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
- { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
- { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
- { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
- { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
- { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
- { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
- { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
- { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
- { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
- { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
- { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
- { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
- { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
- { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
- { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
- { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
- { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
- { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
- { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
- { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
- { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
- { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
- { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
-]
-
-[[package]]
-name = "aiosignal"
-version = "1.4.0"
+name = "anyio"
+version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "frozenlist" },
+ { name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
-]
-
-[[package]]
-name = "attrs"
-version = "25.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
@@ -154,51 +34,15 @@ wheels = [
]
[[package]]
-name = "charset-normalizer"
-version = "3.4.2"
+name = "click"
+version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" },
- { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" },
- { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" },
- { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" },
- { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" },
- { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" },
- { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" },
- { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" },
- { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" },
- { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" },
- { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" },
- { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" },
- { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" },
- { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
- { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
- { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
- { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
- { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
- { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
- { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
- { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
- { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
- { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
- { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
- { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
- { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
- { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
- { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
- { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
- { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
- { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
- { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
- { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
- { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
- { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
- { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
- { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
- { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
- { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
- { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]]
@@ -333,94 +177,92 @@ wheels = [
]
[[package]]
-name = "flake8"
-version = "7.3.0"
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "mccabe" },
- { name = "pycodestyle" },
- { name = "pyflakes" },
+ { name = "certifi" },
+ { name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
-name = "frozenlist"
-version = "1.6.0"
+name = "httpx"
+version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/f4/d744cba2da59b5c1d88823cf9e8a6c74e4659e2b27604ed973be2a0bf5ab/frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68", size = 42831, upload-time = "2025-04-17T22:38:53.099Z" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/53/b5/bc883b5296ec902115c00be161da93bf661199c465ec4c483feec6ea4c32/frozenlist-1.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae8337990e7a45683548ffb2fee1af2f1ed08169284cd829cdd9a7fa7470530d", size = 160912, upload-time = "2025-04-17T22:36:17.235Z" },
- { url = "https://files.pythonhosted.org/packages/6f/93/51b058b563d0704b39c56baa222828043aafcac17fd3734bec5dbeb619b1/frozenlist-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c952f69dd524558694818a461855f35d36cc7f5c0adddce37e962c85d06eac0", size = 124315, upload-time = "2025-04-17T22:36:18.735Z" },
- { url = "https://files.pythonhosted.org/packages/c9/e0/46cd35219428d350558b874d595e132d1c17a9471a1bd0d01d518a261e7c/frozenlist-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f5fef13136c4e2dee91bfb9a44e236fff78fc2cd9f838eddfc470c3d7d90afe", size = 122230, upload-time = "2025-04-17T22:36:20.6Z" },
- { url = "https://files.pythonhosted.org/packages/d1/0f/7ad2ce928ad06d6dd26a61812b959ded573d3e9d0ee6109d96c2be7172e9/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:716bbba09611b4663ecbb7cd022f640759af8259e12a6ca939c0a6acd49eedba", size = 314842, upload-time = "2025-04-17T22:36:22.088Z" },
- { url = "https://files.pythonhosted.org/packages/34/76/98cbbd8a20a5c3359a2004ae5e5b216af84a150ccbad67c8f8f30fb2ea91/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b8c4dc422c1a3ffc550b465090e53b0bf4839047f3e436a34172ac67c45d595", size = 304919, upload-time = "2025-04-17T22:36:24.247Z" },
- { url = "https://files.pythonhosted.org/packages/9a/fa/258e771ce3a44348c05e6b01dffc2bc67603fba95761458c238cd09a2c77/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b11534872256e1666116f6587a1592ef395a98b54476addb5e8d352925cb5d4a", size = 324074, upload-time = "2025-04-17T22:36:26.291Z" },
- { url = "https://files.pythonhosted.org/packages/d5/a4/047d861fd8c538210e12b208c0479912273f991356b6bdee7ea8356b07c9/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c6eceb88aaf7221f75be6ab498dc622a151f5f88d536661af3ffc486245a626", size = 321292, upload-time = "2025-04-17T22:36:27.909Z" },
- { url = "https://files.pythonhosted.org/packages/c0/25/cfec8af758b4525676cabd36efcaf7102c1348a776c0d1ad046b8a7cdc65/frozenlist-1.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62c828a5b195570eb4b37369fcbbd58e96c905768d53a44d13044355647838ff", size = 301569, upload-time = "2025-04-17T22:36:29.448Z" },
- { url = "https://files.pythonhosted.org/packages/87/2f/0c819372fa9f0c07b153124bf58683b8d0ca7bb73ea5ccde9b9ef1745beb/frozenlist-1.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1c6bd2c6399920c9622362ce95a7d74e7f9af9bfec05fff91b8ce4b9647845a", size = 313625, upload-time = "2025-04-17T22:36:31.55Z" },
- { url = "https://files.pythonhosted.org/packages/50/5f/f0cf8b0fdedffdb76b3745aa13d5dbe404d63493cc211ce8250f2025307f/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ba23817781e22fcbd45fd9ff2b9b8cdb7b16a42a4851ab8025cae7b22e96d0", size = 312523, upload-time = "2025-04-17T22:36:33.078Z" },
- { url = "https://files.pythonhosted.org/packages/e1/6c/38c49108491272d3e84125bbabf2c2d0b304899b52f49f0539deb26ad18d/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:431ef6937ae0f853143e2ca67d6da76c083e8b1fe3df0e96f3802fd37626e606", size = 322657, upload-time = "2025-04-17T22:36:34.688Z" },
- { url = "https://files.pythonhosted.org/packages/bd/4b/3bd3bad5be06a9d1b04b1c22be80b5fe65b502992d62fab4bdb25d9366ee/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9d124b38b3c299ca68433597ee26b7819209cb8a3a9ea761dfe9db3a04bba584", size = 303414, upload-time = "2025-04-17T22:36:36.363Z" },
- { url = "https://files.pythonhosted.org/packages/5b/89/7e225a30bef6e85dbfe22622c24afe932e9444de3b40d58b1ea589a14ef8/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:118e97556306402e2b010da1ef21ea70cb6d6122e580da64c056b96f524fbd6a", size = 320321, upload-time = "2025-04-17T22:36:38.16Z" },
- { url = "https://files.pythonhosted.org/packages/22/72/7e3acef4dd9e86366cb8f4d8f28e852c2b7e116927e9722b31a6f71ea4b0/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb3b309f1d4086b5533cf7bbcf3f956f0ae6469664522f1bde4feed26fba60f1", size = 323975, upload-time = "2025-04-17T22:36:40.289Z" },
- { url = "https://files.pythonhosted.org/packages/d8/85/e5da03d20507e13c66ce612c9792b76811b7a43e3320cce42d95b85ac755/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54dece0d21dce4fdb188a1ffc555926adf1d1c516e493c2914d7c370e454bc9e", size = 316553, upload-time = "2025-04-17T22:36:42.045Z" },
- { url = "https://files.pythonhosted.org/packages/ac/8e/6c609cbd0580ae8a0661c408149f196aade7d325b1ae7adc930501b81acb/frozenlist-1.6.0-cp311-cp311-win32.whl", hash = "sha256:654e4ba1d0b2154ca2f096bed27461cf6160bc7f504a7f9a9ef447c293caf860", size = 115511, upload-time = "2025-04-17T22:36:44.067Z" },
- { url = "https://files.pythonhosted.org/packages/f2/13/a84804cfde6de12d44ed48ecbf777ba62b12ff09e761f76cdd1ff9e14bb1/frozenlist-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e911391bffdb806001002c1f860787542f45916c3baf764264a52765d5a5603", size = 120863, upload-time = "2025-04-17T22:36:45.465Z" },
- { url = "https://files.pythonhosted.org/packages/9c/8a/289b7d0de2fbac832ea80944d809759976f661557a38bb8e77db5d9f79b7/frozenlist-1.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1", size = 160193, upload-time = "2025-04-17T22:36:47.382Z" },
- { url = "https://files.pythonhosted.org/packages/19/80/2fd17d322aec7f430549f0669f599997174f93ee17929ea5b92781ec902c/frozenlist-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29", size = 123831, upload-time = "2025-04-17T22:36:49.401Z" },
- { url = "https://files.pythonhosted.org/packages/99/06/f5812da431273f78c6543e0b2f7de67dfd65eb0a433978b2c9c63d2205e4/frozenlist-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25", size = 121862, upload-time = "2025-04-17T22:36:51.899Z" },
- { url = "https://files.pythonhosted.org/packages/d0/31/9e61c6b5fc493cf24d54881731204d27105234d09878be1a5983182cc4a5/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576", size = 316361, upload-time = "2025-04-17T22:36:53.402Z" },
- { url = "https://files.pythonhosted.org/packages/9d/55/22ca9362d4f0222324981470fd50192be200154d51509ee6eb9baa148e96/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8", size = 307115, upload-time = "2025-04-17T22:36:55.016Z" },
- { url = "https://files.pythonhosted.org/packages/ae/39/4fff42920a57794881e7bb3898dc7f5f539261711ea411b43bba3cde8b79/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9", size = 322505, upload-time = "2025-04-17T22:36:57.12Z" },
- { url = "https://files.pythonhosted.org/packages/55/f2/88c41f374c1e4cf0092a5459e5f3d6a1e17ed274c98087a76487783df90c/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e", size = 322666, upload-time = "2025-04-17T22:36:58.735Z" },
- { url = "https://files.pythonhosted.org/packages/75/51/034eeb75afdf3fd03997856195b500722c0b1a50716664cde64e28299c4b/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590", size = 302119, upload-time = "2025-04-17T22:37:00.512Z" },
- { url = "https://files.pythonhosted.org/packages/2b/a6/564ecde55ee633270a793999ef4fd1d2c2b32b5a7eec903b1012cb7c5143/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103", size = 316226, upload-time = "2025-04-17T22:37:02.102Z" },
- { url = "https://files.pythonhosted.org/packages/f1/c8/6c0682c32377f402b8a6174fb16378b683cf6379ab4d2827c580892ab3c7/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c", size = 312788, upload-time = "2025-04-17T22:37:03.578Z" },
- { url = "https://files.pythonhosted.org/packages/b6/b8/10fbec38f82c5d163ca1750bfff4ede69713badf236a016781cf1f10a0f0/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821", size = 325914, upload-time = "2025-04-17T22:37:05.213Z" },
- { url = "https://files.pythonhosted.org/packages/62/ca/2bf4f3a1bd40cdedd301e6ecfdbb291080d5afc5f9ce350c0739f773d6b9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70", size = 305283, upload-time = "2025-04-17T22:37:06.985Z" },
- { url = "https://files.pythonhosted.org/packages/09/64/20cc13ccf94abc2a1f482f74ad210703dc78a590d0b805af1c9aa67f76f9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f", size = 319264, upload-time = "2025-04-17T22:37:08.618Z" },
- { url = "https://files.pythonhosted.org/packages/20/ff/86c6a2bbe98cfc231519f5e6d712a0898488ceac804a917ce014f32e68f6/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046", size = 326482, upload-time = "2025-04-17T22:37:10.196Z" },
- { url = "https://files.pythonhosted.org/packages/2f/da/8e381f66367d79adca245d1d71527aac774e30e291d41ef161ce2d80c38e/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770", size = 318248, upload-time = "2025-04-17T22:37:12.284Z" },
- { url = "https://files.pythonhosted.org/packages/39/24/1a1976563fb476ab6f0fa9fefaac7616a4361dbe0461324f9fd7bf425dbe/frozenlist-1.6.0-cp312-cp312-win32.whl", hash = "sha256:853ac025092a24bb3bf09ae87f9127de9fe6e0c345614ac92536577cf956dfcc", size = 115161, upload-time = "2025-04-17T22:37:13.902Z" },
- { url = "https://files.pythonhosted.org/packages/80/2e/fb4ed62a65f8cd66044706b1013f0010930d8cbb0729a2219561ea075434/frozenlist-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bdfe2d7e6c9281c6e55523acd6c2bf77963cb422fdc7d142fb0cb6621b66878", size = 120548, upload-time = "2025-04-17T22:37:15.326Z" },
- { url = "https://files.pythonhosted.org/packages/6f/e5/04c7090c514d96ca00887932417f04343ab94904a56ab7f57861bf63652d/frozenlist-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e", size = 158182, upload-time = "2025-04-17T22:37:16.837Z" },
- { url = "https://files.pythonhosted.org/packages/e9/8f/60d0555c61eec855783a6356268314d204137f5e0c53b59ae2fc28938c99/frozenlist-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117", size = 122838, upload-time = "2025-04-17T22:37:18.352Z" },
- { url = "https://files.pythonhosted.org/packages/5a/a7/d0ec890e3665b4b3b7c05dc80e477ed8dc2e2e77719368e78e2cd9fec9c8/frozenlist-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4", size = 120980, upload-time = "2025-04-17T22:37:19.857Z" },
- { url = "https://files.pythonhosted.org/packages/cc/19/9b355a5e7a8eba903a008579964192c3e427444752f20b2144b10bb336df/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3", size = 305463, upload-time = "2025-04-17T22:37:21.328Z" },
- { url = "https://files.pythonhosted.org/packages/9c/8d/5b4c758c2550131d66935ef2fa700ada2461c08866aef4229ae1554b93ca/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1", size = 297985, upload-time = "2025-04-17T22:37:23.55Z" },
- { url = "https://files.pythonhosted.org/packages/48/2c/537ec09e032b5865715726b2d1d9813e6589b571d34d01550c7aeaad7e53/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c", size = 311188, upload-time = "2025-04-17T22:37:25.221Z" },
- { url = "https://files.pythonhosted.org/packages/31/2f/1aa74b33f74d54817055de9a4961eff798f066cdc6f67591905d4fc82a84/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45", size = 311874, upload-time = "2025-04-17T22:37:26.791Z" },
- { url = "https://files.pythonhosted.org/packages/bf/f0/cfec18838f13ebf4b37cfebc8649db5ea71a1b25dacd691444a10729776c/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f", size = 291897, upload-time = "2025-04-17T22:37:28.958Z" },
- { url = "https://files.pythonhosted.org/packages/ea/a5/deb39325cbbea6cd0a46db8ccd76150ae2fcbe60d63243d9df4a0b8c3205/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85", size = 305799, upload-time = "2025-04-17T22:37:30.889Z" },
- { url = "https://files.pythonhosted.org/packages/78/22/6ddec55c5243a59f605e4280f10cee8c95a449f81e40117163383829c241/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8", size = 302804, upload-time = "2025-04-17T22:37:32.489Z" },
- { url = "https://files.pythonhosted.org/packages/5d/b7/d9ca9bab87f28855063c4d202936800219e39db9e46f9fb004d521152623/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f", size = 316404, upload-time = "2025-04-17T22:37:34.59Z" },
- { url = "https://files.pythonhosted.org/packages/a6/3a/1255305db7874d0b9eddb4fe4a27469e1fb63720f1fc6d325a5118492d18/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f", size = 295572, upload-time = "2025-04-17T22:37:36.337Z" },
- { url = "https://files.pythonhosted.org/packages/2a/f2/8d38eeee39a0e3a91b75867cc102159ecccf441deb6ddf67be96d3410b84/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6", size = 307601, upload-time = "2025-04-17T22:37:37.923Z" },
- { url = "https://files.pythonhosted.org/packages/38/04/80ec8e6b92f61ef085422d7b196822820404f940950dde5b2e367bede8bc/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188", size = 314232, upload-time = "2025-04-17T22:37:39.669Z" },
- { url = "https://files.pythonhosted.org/packages/3a/58/93b41fb23e75f38f453ae92a2f987274c64637c450285577bd81c599b715/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e", size = 308187, upload-time = "2025-04-17T22:37:41.662Z" },
- { url = "https://files.pythonhosted.org/packages/6a/a2/e64df5c5aa36ab3dee5a40d254f3e471bb0603c225f81664267281c46a2d/frozenlist-1.6.0-cp313-cp313-win32.whl", hash = "sha256:4def87ef6d90429f777c9d9de3961679abf938cb6b7b63d4a7eb8a268babfce4", size = 114772, upload-time = "2025-04-17T22:37:43.132Z" },
- { url = "https://files.pythonhosted.org/packages/a0/77/fead27441e749b2d574bb73d693530d59d520d4b9e9679b8e3cb779d37f2/frozenlist-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:37a8a52c3dfff01515e9bbbee0e6063181362f9de3db2ccf9bc96189b557cbfd", size = 119847, upload-time = "2025-04-17T22:37:45.118Z" },
- { url = "https://files.pythonhosted.org/packages/df/bd/cc6d934991c1e5d9cafda83dfdc52f987c7b28343686aef2e58a9cf89f20/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64", size = 174937, upload-time = "2025-04-17T22:37:46.635Z" },
- { url = "https://files.pythonhosted.org/packages/f2/a2/daf945f335abdbfdd5993e9dc348ef4507436936ab3c26d7cfe72f4843bf/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91", size = 136029, upload-time = "2025-04-17T22:37:48.192Z" },
- { url = "https://files.pythonhosted.org/packages/51/65/4c3145f237a31247c3429e1c94c384d053f69b52110a0d04bfc8afc55fb2/frozenlist-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd", size = 134831, upload-time = "2025-04-17T22:37:50.485Z" },
- { url = "https://files.pythonhosted.org/packages/77/38/03d316507d8dea84dfb99bdd515ea245628af964b2bf57759e3c9205cc5e/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2", size = 392981, upload-time = "2025-04-17T22:37:52.558Z" },
- { url = "https://files.pythonhosted.org/packages/37/02/46285ef9828f318ba400a51d5bb616ded38db8466836a9cfa39f3903260b/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506", size = 371999, upload-time = "2025-04-17T22:37:54.092Z" },
- { url = "https://files.pythonhosted.org/packages/0d/64/1212fea37a112c3c5c05bfb5f0a81af4836ce349e69be75af93f99644da9/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0", size = 392200, upload-time = "2025-04-17T22:37:55.951Z" },
- { url = "https://files.pythonhosted.org/packages/81/ce/9a6ea1763e3366e44a5208f76bf37c76c5da570772375e4d0be85180e588/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0", size = 390134, upload-time = "2025-04-17T22:37:57.633Z" },
- { url = "https://files.pythonhosted.org/packages/bc/36/939738b0b495b2c6d0c39ba51563e453232813042a8d908b8f9544296c29/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e", size = 365208, upload-time = "2025-04-17T22:37:59.742Z" },
- { url = "https://files.pythonhosted.org/packages/b4/8b/939e62e93c63409949c25220d1ba8e88e3960f8ef6a8d9ede8f94b459d27/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c", size = 385548, upload-time = "2025-04-17T22:38:01.416Z" },
- { url = "https://files.pythonhosted.org/packages/62/38/22d2873c90102e06a7c5a3a5b82ca47e393c6079413e8a75c72bff067fa8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b", size = 391123, upload-time = "2025-04-17T22:38:03.049Z" },
- { url = "https://files.pythonhosted.org/packages/44/78/63aaaf533ee0701549500f6d819be092c6065cb5c577edb70c09df74d5d0/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad", size = 394199, upload-time = "2025-04-17T22:38:04.776Z" },
- { url = "https://files.pythonhosted.org/packages/54/45/71a6b48981d429e8fbcc08454dc99c4c2639865a646d549812883e9c9dd3/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215", size = 373854, upload-time = "2025-04-17T22:38:06.576Z" },
- { url = "https://files.pythonhosted.org/packages/3f/f3/dbf2a5e11736ea81a66e37288bf9f881143a7822b288a992579ba1b4204d/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2", size = 395412, upload-time = "2025-04-17T22:38:08.197Z" },
- { url = "https://files.pythonhosted.org/packages/b3/f1/c63166806b331f05104d8ea385c4acd511598568b1f3e4e8297ca54f2676/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911", size = 394936, upload-time = "2025-04-17T22:38:10.056Z" },
- { url = "https://files.pythonhosted.org/packages/ef/ea/4f3e69e179a430473eaa1a75ff986526571215fefc6b9281cdc1f09a4eb8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497", size = 391459, upload-time = "2025-04-17T22:38:11.826Z" },
- { url = "https://files.pythonhosted.org/packages/d3/c3/0fc2c97dea550df9afd072a37c1e95421652e3206bbeaa02378b24c2b480/frozenlist-1.6.0-cp313-cp313t-win32.whl", hash = "sha256:a4d96dc5bcdbd834ec6b0f91027817214216b5b30316494d2b1aebffb87c534f", size = 128797, upload-time = "2025-04-17T22:38:14.013Z" },
- { url = "https://files.pythonhosted.org/packages/ae/f5/79c9320c5656b1965634fe4be9c82b12a3305bdbc58ad9cb941131107b20/frozenlist-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e18036cb4caa17ea151fd5f3d70be9d354c99eb8cf817a3ccde8a7873b074348", size = 134709, upload-time = "2025-04-17T22:38:15.551Z" },
- { url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404, upload-time = "2025-04-17T22:38:51.668Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "hypothesis"
+version = "6.156.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/4b/d376c0382fc716878790177deb88cc8b73d3a0218ae74de6e14493f7dc74/hypothesis-6.156.3.tar.gz", hash = "sha256:0c68209d611a17d9092a74d4a7c945256b8c7288e4e2b8a8a3c3be716a284365", size = 476259, upload-time = "2026-07-08T11:27:23.758Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/f5/2838f39dcf725c766cee1c3f077f71c027279082ba8c91eab69c916d4e92/hypothesis-6.156.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:49b8a2ccad35987f536dab81cdee12ea53ab5e87fae079cfb2517f47bde11c59", size = 749193, upload-time = "2026-07-08T11:27:20.607Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/db/4a7661d29ea88d42e0e0544c8d0b54f56e4ccb391b1c0ad97421b3100889/hypothesis-6.156.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91a48e40a4fab58f454b42fcab21306488637ae3a6fbf757f7d044e29306119d", size = 743707, upload-time = "2026-07-08T11:26:18.443Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a9/ee98adf01d7d92b0cae5855eef1f9df7ae294154643cf29d64b4a1b65e83/hypothesis-6.156.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b23da0553cd12f448211d1e94274351978b0a1cfa62b603a97e289d8e3432b9a", size = 1070975, upload-time = "2026-07-08T11:26:29.681Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8f/cc6ddf1062e0ca3cb499582d25de6713521ef448d109526a81f27eb3eef7/hypothesis-6.156.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcc75a2bf4e250bcb67d74b2a423e149adf69b6730fd9de78d97494d7d4146d4", size = 1120657, upload-time = "2026-07-08T11:26:22.521Z" },
+ { url = "https://files.pythonhosted.org/packages/26/a0/e3478fea1242d4fcd1c5e20bf6fb8db80cfef27b4407995e7e9f7452148d/hypothesis-6.156.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5a453ae95e471e70de86fc07eda86d096c47fa9059b83b26316b5c0e54e6fcd", size = 1245183, upload-time = "2026-07-08T11:27:17.085Z" },
+ { url = "https://files.pythonhosted.org/packages/73/00/cb6fb0c2430960de30a3927432569aaba30db80b28c513abdabfd06fe8b0/hypothesis-6.156.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b5fb3bbf324030a850adc17c26c09ef9856bf73f19e27dfb3b2a7c72f6ee05c9", size = 1287894, upload-time = "2026-07-08T11:27:10.517Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ff/abc3922baf5f03864b3b9fab334be0a05702060ad0ca90ffeafbc890bb4d/hypothesis-6.156.3-cp311-cp311-win_amd64.whl", hash = "sha256:56a84fa7172c3166f4ceaa44ecb72715ed2be59f5b1ae49a27fff2c4cb6cc251", size = 640180, upload-time = "2026-07-08T11:26:45.899Z" },
+ { url = "https://files.pythonhosted.org/packages/52/37/8f1d7ed73656d56d94b8da1d0ee429bab70015e78c33933b1b41f41958a9/hypothesis-6.156.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f009db55dc3227ed1040dc69f446fa672a64d240408856a34419cd841cfc7b9b", size = 749042, upload-time = "2026-07-08T11:27:12.126Z" },
+ { url = "https://files.pythonhosted.org/packages/90/db/5be0f96e830e376d38d1cb2b9a0d55ce0e714aa4992692be5311f820a157/hypothesis-6.156.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e58baa63416d4ad5b5722f683dd85205b00d6b1aed42914eda5f3f5c36ec5c4", size = 741867, upload-time = "2026-07-08T11:27:08.89Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/70/4e96a883f1f71cdb4c626ce8e818279be97ab4cafcae7ec288d79a48028f/hypothesis-6.156.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3974fdbacfb37ea6a7c503e3e355992f1ae19658ac920661492d03863ffa0c58", size = 1069806, upload-time = "2026-07-08T11:26:52.58Z" },
+ { url = "https://files.pythonhosted.org/packages/69/a8/c1071a89cd981a0f9d22a5723a79253ec4ff68f60d196b6aaaaea38b5b74/hypothesis-6.156.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55948a48d348a448acd33b908cfb2c3e88a4560b3d5e6d38bfb2650ea389e69b", size = 1119589, upload-time = "2026-07-08T11:26:39.81Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/2c/27336f452eddeceaf173e7ea3b34383e6febe872958483253cb3e2989db2/hypothesis-6.156.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dfe4bce2bd2770f2d11ec2dee3469221939eb109ee28a91649fd7f27700ff263", size = 1243866, upload-time = "2026-07-08T11:26:12.252Z" },
+ { url = "https://files.pythonhosted.org/packages/27/22/b9b67469777fe1359d694b92903bd2d516b58bf85915045461aae84fa093/hypothesis-6.156.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:592c67d32ee58026556ad26ddbf76a873f5b0c9d0a0104f73db9db0dfba32f18", size = 1286420, upload-time = "2026-07-08T11:26:19.802Z" },
+ { url = "https://files.pythonhosted.org/packages/36/36/60f8451ac90b1550feb7f7f1a858742beb31746465dcc370cfb675d53b06/hypothesis-6.156.3-cp312-cp312-win_amd64.whl", hash = "sha256:d39c149673b9d86be6cb9df01d1a4e8081f26e8093eabc957d6769b8b3afa6d8", size = 637647, upload-time = "2026-07-08T11:27:15.438Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/b8/9d7dce42c9534bd9d3ddfa2bb95a8efa82fb0b9a7a89bc14fcd9fac10368/hypothesis-6.156.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:775d70e3cf5c0cd83239b62ab60d2ddbbddd026bba57aa6deeeaed6aa83f5002", size = 749455, upload-time = "2026-07-08T11:26:47.428Z" },
+ { url = "https://files.pythonhosted.org/packages/02/81/6b2d101825302b8200e88b0d6e4b0e0ca93a8966097669614c33d61265b7/hypothesis-6.156.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f0f2acd5b9a462e0d2589b7600c563c39808a8683c2d614d1caaabeb1c67c062", size = 742058, upload-time = "2026-07-08T11:26:23.942Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/5a/342f103a6ec6fb77897f2030f00075d4d84d4afbfb7944cddca334cf5a98/hypothesis-6.156.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a90fbe0888b4ab8253ca44b763faa56d37a10d602480ecc2f03a09d96b3a1bf8", size = 1069965, upload-time = "2026-07-08T11:26:31.233Z" },
+ { url = "https://files.pythonhosted.org/packages/84/cb/86a44d644c28251fc6f2799bc8c5d97358bf00f7417a18eb0783cc597d77/hypothesis-6.156.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d70eb849033695e5b81f0241019751d7b60ecea248c993f5e1536a1bd0caa6d7", size = 1119954, upload-time = "2026-07-08T11:26:55.917Z" },
+ { url = "https://files.pythonhosted.org/packages/68/99/ec84b29ec0f9bc646fb98655c76d4ced5e44309f26b86fc58b20c9d0634e/hypothesis-6.156.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0e2eb13d32f5dfeacd59979b9acf8e647d4d6f5b4b0a18b277db59b0ec7994eb", size = 1244030, upload-time = "2026-07-08T11:26:33.846Z" },
+ { url = "https://files.pythonhosted.org/packages/54/15/74a7c54493763707325e67be90aba13e144b6296224fb1a1907d22bf1452/hypothesis-6.156.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64b606f915bb84b880b479c31fed1b283aab8b0d5c59e6ba2c314e9a35c686f4", size = 1286706, upload-time = "2026-07-08T11:26:51.02Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/41/275cbcacc49c22e5e717a7217a0b1accfaae6fefefce8af39b4fae5a13a2/hypothesis-6.156.3-cp313-cp313-win_amd64.whl", hash = "sha256:a68d2d46442e48f90844d939fb372d96f96972017b4314c7418d82f20c9f1aa3", size = 637935, upload-time = "2026-07-08T11:26:13.557Z" },
+ { url = "https://files.pythonhosted.org/packages/52/d1/d106f75214f7abc28abfcc4ae5a87cc7b445dfe7dd738d1b86d079a61741/hypothesis-6.156.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d5883bd0e7e1a54a478553cc1c57dbd14b751562f5134abe4742ba1d7267a14b", size = 749486, upload-time = "2026-07-08T11:26:59.432Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/84/84d1cbe3b2812eba795b2137b579582f1189256d996d27ee42a974edb240/hypothesis-6.156.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5bc9b4258403ebdc01121c6cc9cb4a3967ca5a569a5168a80c34828b3d6ab264", size = 742264, upload-time = "2026-07-08T11:26:16.003Z" },
+ { url = "https://files.pythonhosted.org/packages/94/28/ede93daa4fe869aa42abcaaba9da14024d61ed313ac6c8373634b1359185/hypothesis-6.156.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d6d73e6f15ec5e412ab4aca148a2673033a1728072a4d33b600f8f5d84af819", size = 1070142, upload-time = "2026-07-08T11:27:22.149Z" },
+ { url = "https://files.pythonhosted.org/packages/55/59/8f595b2f2a594ab7834a588bbcd11ed053cb054a5bad399015f4df6be3ae/hypothesis-6.156.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4f7029436f51e98e6a5d78cb1ecab1ef5a0f3517dbab003429b8a65e47f9a41", size = 1120144, upload-time = "2026-07-08T11:26:38.368Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/de/3bbdf04a66e09d730048d5e82722df6fd1047187c02c2764dd9d19423353/hypothesis-6.156.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:67d1d3053d077b0715a50d92decf578742aa2ab4cec713a56e722abd33361cca", size = 1244300, upload-time = "2026-07-08T11:26:14.746Z" },
+ { url = "https://files.pythonhosted.org/packages/36/37/dd0fac3b7042a37a284f055461500018656a80bfcca246124f253772a99a/hypothesis-6.156.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:83389a53144ce61c6789cc0a43fefe26c257dcb8b23ccd9291db024b8ade3652", size = 1287218, upload-time = "2026-07-08T11:26:49.066Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/26/5310edd49abaa73a8fa41733fd9874781c9d4d94f03566231a2784de291e/hypothesis-6.156.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:0f3367fbaedc5527ef3ff717006f94db79a19473d7d0a5ecce0f0de926ed6143", size = 586422, upload-time = "2026-07-08T11:26:44.006Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/58/877f1c8aeaadced677f73f00ee00ffb22f0e93f052114d74c87cbb8742fd/hypothesis-6.156.3-cp314-cp314-win_amd64.whl", hash = "sha256:5ab613fde3d4324fae8c6f8cf217d5f463a82be0e9dee4f17d952230d0094612", size = 637731, upload-time = "2026-07-08T11:27:02.437Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/c1/0d31764d9370f32cdaa1eb565fe617cbfb4e223efd0bff8433bcd2f4666e/hypothesis-6.156.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:93d45959fc889a63a66fff0923dfd25dda53ee181eebec81003ffbf5cd7c86f8", size = 747516, upload-time = "2026-07-08T11:26:17.282Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e5/a10345872f80688545c625a7c93f529aba2a0dfa8a6a5d18a0995c61aaf2/hypothesis-6.156.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b58648a7f9705b98371ba6515da2f0415c440cb24fef34ee48857ae57740ef6", size = 740877, upload-time = "2026-07-08T11:26:41.19Z" },
+ { url = "https://files.pythonhosted.org/packages/33/1d/434962713972eeb3f643ffd77b39f18a9f6867d8f07424447bb651bb8934/hypothesis-6.156.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95bd12f151cb79d55b7b9d3cd64c3fe7506c27d3fd54fac8cba2e7d847292da1", size = 1069163, upload-time = "2026-07-08T11:27:05.683Z" },
+ { url = "https://files.pythonhosted.org/packages/96/d6/a9d52d6a830d0fc79af35ef46f678ed3f7fc0c8e186ec41c386aa9a1bff8/hypothesis-6.156.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e53f029e7c31471e6de06bcb0c05f8f08630817c14c49e99d0d3e198082cf0", size = 1119238, upload-time = "2026-07-08T11:26:36.96Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f7/c228a4228304243845883f2feeda46c2f7dacbe0dc20ffae758bc6bbf5ef/hypothesis-6.156.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2470f9745bbe7fe98c5eeaa2f5e74ebdfed46d401efc7d82f0cd6ab3da8d9408", size = 1243071, upload-time = "2026-07-08T11:27:13.763Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/4c/b6ab46567e501b6b74cc98557c65afc55f6388e5ea4939ec9cd5e3da3b0b/hypothesis-6.156.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:05370d67210ce822eea53060ea723d1b70cde8c2bab5a5b10d557a9b81f51944", size = 1285531, upload-time = "2026-07-08T11:26:09.812Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/c5/939fb46bd62082379c34321fe315808a5f4afd4b1d78e3719e8bb36dda53/hypothesis-6.156.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39cae9b690c49793bdce45daed45f9f37a38ab1360b74a328458ad56caac74f6", size = 637850, upload-time = "2026-07-08T11:26:54.49Z" },
+ { url = "https://files.pythonhosted.org/packages/52/35/9c5d61a968ace9853a8121d20126fc9c8bbe09ca050a06a427a6254152a3/hypothesis-6.156.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e62c0782e6cead74c0c931bd78526ed9b8114a14b16ad631cbe5a59b33e5628a", size = 749744, upload-time = "2026-07-08T11:27:07.376Z" },
+ { url = "https://files.pythonhosted.org/packages/70/51/937fcd17a9e56fc0f681f1ca822e3edeadcb2b984e86ff71218a6b26e647/hypothesis-6.156.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:06f8d858326bca887241145842da378423c4f6274e90fe2631a22fac107cc033", size = 744333, upload-time = "2026-07-08T11:26:26.963Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/c1/13ead61ebed8d0321fd8f5afb347e587cf738326e4feb8288803f799b467/hypothesis-6.156.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f815d099de262fbb11dee666534c7c808158aca5489808183253ffe5323657", size = 1071370, upload-time = "2026-07-08T11:26:25.363Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e4/5f0a6d8fda7e818893fccd35eb08c930c52251a36a461dedb886da56b5aa/hypothesis-6.156.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc45ee9649f0803cdd3258b2ab59f7a083670d21105eb73967237911d86f6820", size = 1121413, upload-time = "2026-07-08T11:27:19.01Z" },
+ { url = "https://files.pythonhosted.org/packages/34/c9/6249ed5dff9848c6b446b9e7cffc052b3c6882d05f7dda8c574874133218/hypothesis-6.156.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:838622aaebce6113111990014f9941bfb6d3481c135d93c4ae6dcc78a0d523c9", size = 640781, upload-time = "2026-07-08T11:27:04.058Z" },
]
[[package]]
@@ -510,131 +352,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
-[[package]]
-name = "mccabe"
-version = "0.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
-]
-
[[package]]
name = "meraki"
-version = "3.0.1"
+version = "4.3.0"
source = { editable = "." }
dependencies = [
- { name = "aiohttp" },
- { name = "requests" },
+ { name = "httpx" },
]
[package.dev-dependencies]
dev = [
- { name = "flake8" },
+ { name = "hypothesis" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
+ { name = "pytest-benchmark" },
{ name = "pytest-cov" },
- { name = "responses" },
+ { name = "pytest-json-report" },
+ { name = "respx" },
{ name = "ruff" },
+ { name = "towncrier" },
]
generator = [
+ { name = "httpx" },
{ name = "jinja2" },
]
[package.metadata]
-requires-dist = [
- { name = "aiohttp", specifier = ">=3.13.5,<4" },
- { name = "requests", specifier = ">=2.33.1,<3" },
-]
+requires-dist = [{ name = "httpx", specifier = ">=0.28,<1" }]
[package.metadata.requires-dev]
dev = [
- { name = "flake8", specifier = ">=7.0,<8" },
+ { name = "hypothesis", specifier = ">=6.122.0,<7" },
{ name = "pre-commit", specifier = ">=4.6.0" },
{ name = "pytest", specifier = ">=8.3.5,<10" },
{ name = "pytest-asyncio", specifier = ">=1.0,<2" },
+ { name = "pytest-benchmark", specifier = ">=2.0.0" },
{ name = "pytest-cov", specifier = ">=7.1.0,<8" },
- { name = "responses", specifier = ">=0.25,<1" },
+ { name = "pytest-json-report", specifier = ">=1.5.0" },
+ { name = "respx", specifier = ">=0.23.1,<1" },
{ name = "ruff", specifier = ">=0.15.12" },
+ { name = "towncrier", specifier = ">=24.8.0" },
]
-generator = [{ name = "jinja2", specifier = "==3.1.6" }]
-
-[[package]]
-name = "multidict"
-version = "6.4.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/91/2f/a3470242707058fe856fe59241eee5635d79087100b7042a867368863a27/multidict-6.4.4.tar.gz", hash = "sha256:69ee9e6ba214b5245031b76233dd95408a0fd57fdb019ddcc1ead4790932a8e8", size = 90183, upload-time = "2025-05-19T14:16:37.381Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/19/1b/4c6e638195851524a63972c5773c7737bea7e47b1ba402186a37773acee2/multidict-6.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f5f29794ac0e73d2a06ac03fd18870adc0135a9d384f4a306a951188ed02f95", size = 65515, upload-time = "2025-05-19T14:14:19.767Z" },
- { url = "https://files.pythonhosted.org/packages/25/d5/10e6bca9a44b8af3c7f920743e5fc0c2bcf8c11bf7a295d4cfe00b08fb46/multidict-6.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c04157266344158ebd57b7120d9b0b35812285d26d0e78193e17ef57bfe2979a", size = 38609, upload-time = "2025-05-19T14:14:21.538Z" },
- { url = "https://files.pythonhosted.org/packages/26/b4/91fead447ccff56247edc7f0535fbf140733ae25187a33621771ee598a18/multidict-6.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb61ffd3ab8310d93427e460f565322c44ef12769f51f77277b4abad7b6f7223", size = 37871, upload-time = "2025-05-19T14:14:22.666Z" },
- { url = "https://files.pythonhosted.org/packages/3b/37/cbc977cae59277e99d15bbda84cc53b5e0c4929ffd91d958347200a42ad0/multidict-6.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e0ba18a9afd495f17c351d08ebbc4284e9c9f7971d715f196b79636a4d0de44", size = 226661, upload-time = "2025-05-19T14:14:24.124Z" },
- { url = "https://files.pythonhosted.org/packages/15/cd/7e0b57fbd4dc2fc105169c4ecce5be1a63970f23bb4ec8c721b67e11953d/multidict-6.4.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9faf1b1dcaadf9f900d23a0e6d6c8eadd6a95795a0e57fcca73acce0eb912065", size = 223422, upload-time = "2025-05-19T14:14:25.437Z" },
- { url = "https://files.pythonhosted.org/packages/f1/01/1de268da121bac9f93242e30cd3286f6a819e5f0b8896511162d6ed4bf8d/multidict-6.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4d1cb1327c6082c4fce4e2a438483390964c02213bc6b8d782cf782c9b1471f", size = 235447, upload-time = "2025-05-19T14:14:26.793Z" },
- { url = "https://files.pythonhosted.org/packages/d2/8c/8b9a5e4aaaf4f2de14e86181a3a3d7b105077f668b6a06f043ec794f684c/multidict-6.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:941f1bec2f5dbd51feeb40aea654c2747f811ab01bdd3422a48a4e4576b7d76a", size = 231455, upload-time = "2025-05-19T14:14:28.149Z" },
- { url = "https://files.pythonhosted.org/packages/35/db/e1817dcbaa10b319c412769cf999b1016890849245d38905b73e9c286862/multidict-6.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5f8a146184da7ea12910a4cec51ef85e44f6268467fb489c3caf0cd512f29c2", size = 223666, upload-time = "2025-05-19T14:14:29.584Z" },
- { url = "https://files.pythonhosted.org/packages/4a/e1/66e8579290ade8a00e0126b3d9a93029033ffd84f0e697d457ed1814d0fc/multidict-6.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:232b7237e57ec3c09be97206bfb83a0aa1c5d7d377faa019c68a210fa35831f1", size = 217392, upload-time = "2025-05-19T14:14:30.961Z" },
- { url = "https://files.pythonhosted.org/packages/7b/6f/f8639326069c24a48c7747c2a5485d37847e142a3f741ff3340c88060a9a/multidict-6.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:55ae0721c1513e5e3210bca4fc98456b980b0c2c016679d3d723119b6b202c42", size = 228969, upload-time = "2025-05-19T14:14:32.672Z" },
- { url = "https://files.pythonhosted.org/packages/d2/c3/3d58182f76b960eeade51c89fcdce450f93379340457a328e132e2f8f9ed/multidict-6.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:51d662c072579f63137919d7bb8fc250655ce79f00c82ecf11cab678f335062e", size = 217433, upload-time = "2025-05-19T14:14:34.016Z" },
- { url = "https://files.pythonhosted.org/packages/e1/4b/f31a562906f3bd375f3d0e83ce314e4a660c01b16c2923e8229b53fba5d7/multidict-6.4.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0e05c39962baa0bb19a6b210e9b1422c35c093b651d64246b6c2e1a7e242d9fd", size = 225418, upload-time = "2025-05-19T14:14:35.376Z" },
- { url = "https://files.pythonhosted.org/packages/99/89/78bb95c89c496d64b5798434a3deee21996114d4d2c28dd65850bf3a691e/multidict-6.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5b1cc3ab8c31d9ebf0faa6e3540fb91257590da330ffe6d2393d4208e638925", size = 235042, upload-time = "2025-05-19T14:14:36.723Z" },
- { url = "https://files.pythonhosted.org/packages/74/91/8780a6e5885a8770442a8f80db86a0887c4becca0e5a2282ba2cae702bc4/multidict-6.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:93ec84488a384cd7b8a29c2c7f467137d8a73f6fe38bb810ecf29d1ade011a7c", size = 230280, upload-time = "2025-05-19T14:14:38.194Z" },
- { url = "https://files.pythonhosted.org/packages/68/c1/fcf69cabd542eb6f4b892469e033567ee6991d361d77abdc55e3a0f48349/multidict-6.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b308402608493638763abc95f9dc0030bbd6ac6aff784512e8ac3da73a88af08", size = 223322, upload-time = "2025-05-19T14:14:40.015Z" },
- { url = "https://files.pythonhosted.org/packages/b8/85/5b80bf4b83d8141bd763e1d99142a9cdfd0db83f0739b4797172a4508014/multidict-6.4.4-cp311-cp311-win32.whl", hash = "sha256:343892a27d1a04d6ae455ecece12904d242d299ada01633d94c4f431d68a8c49", size = 35070, upload-time = "2025-05-19T14:14:41.904Z" },
- { url = "https://files.pythonhosted.org/packages/09/66/0bed198ffd590ab86e001f7fa46b740d58cf8ff98c2f254e4a36bf8861ad/multidict-6.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:73484a94f55359780c0f458bbd3c39cb9cf9c182552177d2136e828269dee529", size = 38667, upload-time = "2025-05-19T14:14:43.534Z" },
- { url = "https://files.pythonhosted.org/packages/d2/b5/5675377da23d60875fe7dae6be841787755878e315e2f517235f22f59e18/multidict-6.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc388f75a1c00000824bf28b7633e40854f4127ede80512b44c3cfeeea1839a2", size = 64293, upload-time = "2025-05-19T14:14:44.724Z" },
- { url = "https://files.pythonhosted.org/packages/34/a7/be384a482754bb8c95d2bbe91717bf7ccce6dc38c18569997a11f95aa554/multidict-6.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:98af87593a666f739d9dba5d0ae86e01b0e1a9cfcd2e30d2d361fbbbd1a9162d", size = 38096, upload-time = "2025-05-19T14:14:45.95Z" },
- { url = "https://files.pythonhosted.org/packages/66/6d/d59854bb4352306145bdfd1704d210731c1bb2c890bfee31fb7bbc1c4c7f/multidict-6.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aff4cafea2d120327d55eadd6b7f1136a8e5a0ecf6fb3b6863e8aca32cd8e50a", size = 37214, upload-time = "2025-05-19T14:14:47.158Z" },
- { url = "https://files.pythonhosted.org/packages/99/e0/c29d9d462d7cfc5fc8f9bf24f9c6843b40e953c0b55e04eba2ad2cf54fba/multidict-6.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:169c4ba7858176b797fe551d6e99040c531c775d2d57b31bcf4de6d7a669847f", size = 224686, upload-time = "2025-05-19T14:14:48.366Z" },
- { url = "https://files.pythonhosted.org/packages/dc/4a/da99398d7fd8210d9de068f9a1b5f96dfaf67d51e3f2521f17cba4ee1012/multidict-6.4.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b9eb4c59c54421a32b3273d4239865cb14ead53a606db066d7130ac80cc8ec93", size = 231061, upload-time = "2025-05-19T14:14:49.952Z" },
- { url = "https://files.pythonhosted.org/packages/21/f5/ac11add39a0f447ac89353e6ca46666847051103649831c08a2800a14455/multidict-6.4.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cf3bd54c56aa16fdb40028d545eaa8d051402b61533c21e84046e05513d5780", size = 232412, upload-time = "2025-05-19T14:14:51.812Z" },
- { url = "https://files.pythonhosted.org/packages/d9/11/4b551e2110cded705a3c13a1d4b6a11f73891eb5a1c449f1b2b6259e58a6/multidict-6.4.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f682c42003c7264134bfe886376299db4cc0c6cd06a3295b41b347044bcb5482", size = 231563, upload-time = "2025-05-19T14:14:53.262Z" },
- { url = "https://files.pythonhosted.org/packages/4c/02/751530c19e78fe73b24c3da66618eda0aa0d7f6e7aa512e46483de6be210/multidict-6.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920f9cf2abdf6e493c519492d892c362007f113c94da4c239ae88429835bad1", size = 223811, upload-time = "2025-05-19T14:14:55.232Z" },
- { url = "https://files.pythonhosted.org/packages/c7/cb/2be8a214643056289e51ca356026c7b2ce7225373e7a1f8c8715efee8988/multidict-6.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:530d86827a2df6504526106b4c104ba19044594f8722d3e87714e847c74a0275", size = 216524, upload-time = "2025-05-19T14:14:57.226Z" },
- { url = "https://files.pythonhosted.org/packages/19/f3/6d5011ec375c09081f5250af58de85f172bfcaafebff286d8089243c4bd4/multidict-6.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecde56ea2439b96ed8a8d826b50c57364612ddac0438c39e473fafad7ae1c23b", size = 229012, upload-time = "2025-05-19T14:14:58.597Z" },
- { url = "https://files.pythonhosted.org/packages/67/9c/ca510785df5cf0eaf5b2a8132d7d04c1ce058dcf2c16233e596ce37a7f8e/multidict-6.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:dc8c9736d8574b560634775ac0def6bdc1661fc63fa27ffdfc7264c565bcb4f2", size = 226765, upload-time = "2025-05-19T14:15:00.048Z" },
- { url = "https://files.pythonhosted.org/packages/36/c8/ca86019994e92a0f11e642bda31265854e6ea7b235642f0477e8c2e25c1f/multidict-6.4.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7f3d3b3c34867579ea47cbd6c1f2ce23fbfd20a273b6f9e3177e256584f1eacc", size = 222888, upload-time = "2025-05-19T14:15:01.568Z" },
- { url = "https://files.pythonhosted.org/packages/c6/67/bc25a8e8bd522935379066950ec4e2277f9b236162a73548a2576d4b9587/multidict-6.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:87a728af265e08f96b6318ebe3c0f68b9335131f461efab2fc64cc84a44aa6ed", size = 234041, upload-time = "2025-05-19T14:15:03.759Z" },
- { url = "https://files.pythonhosted.org/packages/f1/a0/70c4c2d12857fccbe607b334b7ee28b6b5326c322ca8f73ee54e70d76484/multidict-6.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9f193eeda1857f8e8d3079a4abd258f42ef4a4bc87388452ed1e1c4d2b0c8740", size = 231046, upload-time = "2025-05-19T14:15:05.698Z" },
- { url = "https://files.pythonhosted.org/packages/c1/0f/52954601d02d39742aab01d6b92f53c1dd38b2392248154c50797b4df7f1/multidict-6.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be06e73c06415199200e9a2324a11252a3d62030319919cde5e6950ffeccf72e", size = 227106, upload-time = "2025-05-19T14:15:07.124Z" },
- { url = "https://files.pythonhosted.org/packages/af/24/679d83ec4379402d28721790dce818e5d6b9f94ce1323a556fb17fa9996c/multidict-6.4.4-cp312-cp312-win32.whl", hash = "sha256:622f26ea6a7e19b7c48dd9228071f571b2fbbd57a8cd71c061e848f281550e6b", size = 35351, upload-time = "2025-05-19T14:15:08.556Z" },
- { url = "https://files.pythonhosted.org/packages/52/ef/40d98bc5f986f61565f9b345f102409534e29da86a6454eb6b7c00225a13/multidict-6.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:5e2bcda30d5009996ff439e02a9f2b5c3d64a20151d34898c000a6281faa3781", size = 38791, upload-time = "2025-05-19T14:15:09.825Z" },
- { url = "https://files.pythonhosted.org/packages/df/2a/e166d2ffbf4b10131b2d5b0e458f7cee7d986661caceae0de8753042d4b2/multidict-6.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:82ffabefc8d84c2742ad19c37f02cde5ec2a1ee172d19944d380f920a340e4b9", size = 64123, upload-time = "2025-05-19T14:15:11.044Z" },
- { url = "https://files.pythonhosted.org/packages/8c/96/e200e379ae5b6f95cbae472e0199ea98913f03d8c9a709f42612a432932c/multidict-6.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a2f58a66fe2c22615ad26156354005391e26a2f3721c3621504cd87c1ea87bf", size = 38049, upload-time = "2025-05-19T14:15:12.902Z" },
- { url = "https://files.pythonhosted.org/packages/75/fb/47afd17b83f6a8c7fa863c6d23ac5ba6a0e6145ed8a6bcc8da20b2b2c1d2/multidict-6.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5883d6ee0fd9d8a48e9174df47540b7545909841ac82354c7ae4cbe9952603bd", size = 37078, upload-time = "2025-05-19T14:15:14.282Z" },
- { url = "https://files.pythonhosted.org/packages/fa/70/1af3143000eddfb19fd5ca5e78393985ed988ac493bb859800fe0914041f/multidict-6.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9abcf56a9511653fa1d052bfc55fbe53dbee8f34e68bd6a5a038731b0ca42d15", size = 224097, upload-time = "2025-05-19T14:15:15.566Z" },
- { url = "https://files.pythonhosted.org/packages/b1/39/d570c62b53d4fba844e0378ffbcd02ac25ca423d3235047013ba2f6f60f8/multidict-6.4.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6ed5ae5605d4ad5a049fad2a28bb7193400700ce2f4ae484ab702d1e3749c3f9", size = 230768, upload-time = "2025-05-19T14:15:17.308Z" },
- { url = "https://files.pythonhosted.org/packages/fd/f8/ed88f2c4d06f752b015933055eb291d9bc184936903752c66f68fb3c95a7/multidict-6.4.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbfcb60396f9bcfa63e017a180c3105b8c123a63e9d1428a36544e7d37ca9e20", size = 231331, upload-time = "2025-05-19T14:15:18.73Z" },
- { url = "https://files.pythonhosted.org/packages/9c/6f/8e07cffa32f483ab887b0d56bbd8747ac2c1acd00dc0af6fcf265f4a121e/multidict-6.4.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0f1987787f5f1e2076b59692352ab29a955b09ccc433c1f6b8e8e18666f608b", size = 230169, upload-time = "2025-05-19T14:15:20.179Z" },
- { url = "https://files.pythonhosted.org/packages/e6/2b/5dcf173be15e42f330110875a2668ddfc208afc4229097312212dc9c1236/multidict-6.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d0121ccce8c812047d8d43d691a1ad7641f72c4f730474878a5aeae1b8ead8c", size = 222947, upload-time = "2025-05-19T14:15:21.714Z" },
- { url = "https://files.pythonhosted.org/packages/39/75/4ddcbcebe5ebcd6faa770b629260d15840a5fc07ce8ad295a32e14993726/multidict-6.4.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83ec4967114295b8afd120a8eec579920c882831a3e4c3331d591a8e5bfbbc0f", size = 215761, upload-time = "2025-05-19T14:15:23.242Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c9/55e998ae45ff15c5608e384206aa71a11e1b7f48b64d166db400b14a3433/multidict-6.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:995f985e2e268deaf17867801b859a282e0448633f1310e3704b30616d269d69", size = 227605, upload-time = "2025-05-19T14:15:24.763Z" },
- { url = "https://files.pythonhosted.org/packages/04/49/c2404eac74497503c77071bd2e6f88c7e94092b8a07601536b8dbe99be50/multidict-6.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d832c608f94b9f92a0ec8b7e949be7792a642b6e535fcf32f3e28fab69eeb046", size = 226144, upload-time = "2025-05-19T14:15:26.249Z" },
- { url = "https://files.pythonhosted.org/packages/62/c5/0cd0c3c6f18864c40846aa2252cd69d308699cb163e1c0d989ca301684da/multidict-6.4.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d21c1212171cf7da703c5b0b7a0e85be23b720818aef502ad187d627316d5645", size = 221100, upload-time = "2025-05-19T14:15:28.303Z" },
- { url = "https://files.pythonhosted.org/packages/71/7b/f2f3887bea71739a046d601ef10e689528d4f911d84da873b6be9194ffea/multidict-6.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cbebaa076aaecad3d4bb4c008ecc73b09274c952cf6a1b78ccfd689e51f5a5b0", size = 232731, upload-time = "2025-05-19T14:15:30.263Z" },
- { url = "https://files.pythonhosted.org/packages/e5/b3/d9de808349df97fa75ec1372758701b5800ebad3c46ae377ad63058fbcc6/multidict-6.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c93a6fb06cc8e5d3628b2b5fda215a5db01e8f08fc15fadd65662d9b857acbe4", size = 229637, upload-time = "2025-05-19T14:15:33.337Z" },
- { url = "https://files.pythonhosted.org/packages/5e/57/13207c16b615eb4f1745b44806a96026ef8e1b694008a58226c2d8f5f0a5/multidict-6.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8cd8f81f1310182362fb0c7898145ea9c9b08a71081c5963b40ee3e3cac589b1", size = 225594, upload-time = "2025-05-19T14:15:34.832Z" },
- { url = "https://files.pythonhosted.org/packages/3a/e4/d23bec2f70221604f5565000632c305fc8f25ba953e8ce2d8a18842b9841/multidict-6.4.4-cp313-cp313-win32.whl", hash = "sha256:3e9f1cd61a0ab857154205fb0b1f3d3ace88d27ebd1409ab7af5096e409614cd", size = 35359, upload-time = "2025-05-19T14:15:36.246Z" },
- { url = "https://files.pythonhosted.org/packages/a7/7a/cfe1a47632be861b627f46f642c1d031704cc1c0f5c0efbde2ad44aa34bd/multidict-6.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:8ffb40b74400e4455785c2fa37eba434269149ec525fc8329858c862e4b35373", size = 38903, upload-time = "2025-05-19T14:15:37.507Z" },
- { url = "https://files.pythonhosted.org/packages/68/7b/15c259b0ab49938a0a1c8f3188572802704a779ddb294edc1b2a72252e7c/multidict-6.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6a602151dbf177be2450ef38966f4be3467d41a86c6a845070d12e17c858a156", size = 68895, upload-time = "2025-05-19T14:15:38.856Z" },
- { url = "https://files.pythonhosted.org/packages/f1/7d/168b5b822bccd88142e0a3ce985858fea612404edd228698f5af691020c9/multidict-6.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d2b9712211b860d123815a80b859075d86a4d54787e247d7fbee9db6832cf1c", size = 40183, upload-time = "2025-05-19T14:15:40.197Z" },
- { url = "https://files.pythonhosted.org/packages/e0/b7/d4b8d98eb850ef28a4922ba508c31d90715fd9b9da3801a30cea2967130b/multidict-6.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d2fa86af59f8fc1972e121ade052145f6da22758f6996a197d69bb52f8204e7e", size = 39592, upload-time = "2025-05-19T14:15:41.508Z" },
- { url = "https://files.pythonhosted.org/packages/18/28/a554678898a19583548e742080cf55d169733baf57efc48c2f0273a08583/multidict-6.4.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50855d03e9e4d66eab6947ba688ffb714616f985838077bc4b490e769e48da51", size = 226071, upload-time = "2025-05-19T14:15:42.877Z" },
- { url = "https://files.pythonhosted.org/packages/ee/dc/7ba6c789d05c310e294f85329efac1bf5b450338d2542498db1491a264df/multidict-6.4.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5bce06b83be23225be1905dcdb6b789064fae92499fbc458f59a8c0e68718601", size = 222597, upload-time = "2025-05-19T14:15:44.412Z" },
- { url = "https://files.pythonhosted.org/packages/24/4f/34eadbbf401b03768dba439be0fb94b0d187facae9142821a3d5599ccb3b/multidict-6.4.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66ed0731f8e5dfd8369a883b6e564aca085fb9289aacabd9decd70568b9a30de", size = 228253, upload-time = "2025-05-19T14:15:46.474Z" },
- { url = "https://files.pythonhosted.org/packages/c0/e6/493225a3cdb0d8d80d43a94503fc313536a07dae54a3f030d279e629a2bc/multidict-6.4.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:329ae97fc2f56f44d91bc47fe0972b1f52d21c4b7a2ac97040da02577e2daca2", size = 226146, upload-time = "2025-05-19T14:15:48.003Z" },
- { url = "https://files.pythonhosted.org/packages/2f/70/e411a7254dc3bff6f7e6e004303b1b0591358e9f0b7c08639941e0de8bd6/multidict-6.4.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c27e5dcf520923d6474d98b96749e6805f7677e93aaaf62656005b8643f907ab", size = 220585, upload-time = "2025-05-19T14:15:49.546Z" },
- { url = "https://files.pythonhosted.org/packages/08/8f/beb3ae7406a619100d2b1fb0022c3bb55a8225ab53c5663648ba50dfcd56/multidict-6.4.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:058cc59b9e9b143cc56715e59e22941a5d868c322242278d28123a5d09cdf6b0", size = 212080, upload-time = "2025-05-19T14:15:51.151Z" },
- { url = "https://files.pythonhosted.org/packages/9c/ec/355124e9d3d01cf8edb072fd14947220f357e1c5bc79c88dff89297e9342/multidict-6.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:69133376bc9a03f8c47343d33f91f74a99c339e8b58cea90433d8e24bb298031", size = 226558, upload-time = "2025-05-19T14:15:52.665Z" },
- { url = "https://files.pythonhosted.org/packages/fd/22/d2b95cbebbc2ada3be3812ea9287dcc9712d7f1a012fad041770afddb2ad/multidict-6.4.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d6b15c55721b1b115c5ba178c77104123745b1417527ad9641a4c5e2047450f0", size = 212168, upload-time = "2025-05-19T14:15:55.279Z" },
- { url = "https://files.pythonhosted.org/packages/4d/c5/62bfc0b2f9ce88326dbe7179f9824a939c6c7775b23b95de777267b9725c/multidict-6.4.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a887b77f51d3d41e6e1a63cf3bc7ddf24de5939d9ff69441387dfefa58ac2e26", size = 217970, upload-time = "2025-05-19T14:15:56.806Z" },
- { url = "https://files.pythonhosted.org/packages/79/74/977cea1aadc43ff1c75d23bd5bc4768a8fac98c14e5878d6ee8d6bab743c/multidict-6.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:632a3bf8f1787f7ef7d3c2f68a7bde5be2f702906f8b5842ad6da9d974d0aab3", size = 226980, upload-time = "2025-05-19T14:15:58.313Z" },
- { url = "https://files.pythonhosted.org/packages/48/fc/cc4a1a2049df2eb84006607dc428ff237af38e0fcecfdb8a29ca47b1566c/multidict-6.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a145c550900deb7540973c5cdb183b0d24bed6b80bf7bddf33ed8f569082535e", size = 220641, upload-time = "2025-05-19T14:15:59.866Z" },
- { url = "https://files.pythonhosted.org/packages/3b/6a/a7444d113ab918701988d4abdde373dbdfd2def7bd647207e2bf645c7eac/multidict-6.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc5d83c6619ca5c9672cb78b39ed8542f1975a803dee2cda114ff73cbb076edd", size = 221728, upload-time = "2025-05-19T14:16:01.535Z" },
- { url = "https://files.pythonhosted.org/packages/2b/b0/fdf4c73ad1c55e0f4dbbf2aa59dd37037334091f9a4961646d2b7ac91a86/multidict-6.4.4-cp313-cp313t-win32.whl", hash = "sha256:3312f63261b9df49be9d57aaa6abf53a6ad96d93b24f9cc16cf979956355ce6e", size = 41913, upload-time = "2025-05-19T14:16:03.199Z" },
- { url = "https://files.pythonhosted.org/packages/8e/92/27989ecca97e542c0d01d05a98a5ae12198a243a9ee12563a0313291511f/multidict-6.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:ba852168d814b2c73333073e1c7116d9395bea69575a01b0b3c89d2d5a87c8fb", size = 46112, upload-time = "2025-05-19T14:16:04.909Z" },
- { url = "https://files.pythonhosted.org/packages/84/5d/e17845bb0fa76334477d5de38654d27946d5b5d3695443987a094a71b440/multidict-6.4.4-py3-none-any.whl", hash = "sha256:bd4557071b561a8b3b6075c3ce93cf9bfb6182cb241805c3d66ced3b75eff4ac", size = 10481, upload-time = "2025-05-19T14:16:36.024Z" },
+generator = [
+ { name = "httpx", specifier = ">=0.28,<1" },
+ { name = "jinja2", specifier = "==3.1.6" },
]
[[package]]
@@ -690,94 +452,12 @@ wheels = [
]
[[package]]
-name = "propcache"
-version = "0.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/07/c8/fdc6686a986feae3541ea23dcaa661bd93972d3940460646c6bb96e21c40/propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", size = 43651, upload-time = "2025-03-26T03:06:12.05Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/90/0f/5a5319ee83bd651f75311fcb0c492c21322a7fc8f788e4eef23f44243427/propcache-0.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f30241577d2fef2602113b70ef7231bf4c69a97e04693bde08ddab913ba0ce5", size = 80243, upload-time = "2025-03-26T03:04:01.912Z" },
- { url = "https://files.pythonhosted.org/packages/ce/84/3db5537e0879942783e2256616ff15d870a11d7ac26541336fe1b673c818/propcache-0.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43593c6772aa12abc3af7784bff4a41ffa921608dd38b77cf1dfd7f5c4e71371", size = 46503, upload-time = "2025-03-26T03:04:03.704Z" },
- { url = "https://files.pythonhosted.org/packages/e2/c8/b649ed972433c3f0d827d7f0cf9ea47162f4ef8f4fe98c5f3641a0bc63ff/propcache-0.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a75801768bbe65499495660b777e018cbe90c7980f07f8aa57d6be79ea6f71da", size = 45934, upload-time = "2025-03-26T03:04:05.257Z" },
- { url = "https://files.pythonhosted.org/packages/59/f9/4c0a5cf6974c2c43b1a6810c40d889769cc8f84cea676cbe1e62766a45f8/propcache-0.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6f1324db48f001c2ca26a25fa25af60711e09b9aaf4b28488602776f4f9a744", size = 233633, upload-time = "2025-03-26T03:04:07.044Z" },
- { url = "https://files.pythonhosted.org/packages/e7/64/66f2f4d1b4f0007c6e9078bd95b609b633d3957fe6dd23eac33ebde4b584/propcache-0.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cdb0f3e1eb6dfc9965d19734d8f9c481b294b5274337a8cb5cb01b462dcb7e0", size = 241124, upload-time = "2025-03-26T03:04:08.676Z" },
- { url = "https://files.pythonhosted.org/packages/aa/bf/7b8c9fd097d511638fa9b6af3d986adbdf567598a567b46338c925144c1b/propcache-0.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eb34d90aac9bfbced9a58b266f8946cb5935869ff01b164573a7634d39fbcb5", size = 240283, upload-time = "2025-03-26T03:04:10.172Z" },
- { url = "https://files.pythonhosted.org/packages/fa/c9/e85aeeeaae83358e2a1ef32d6ff50a483a5d5248bc38510d030a6f4e2816/propcache-0.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35c7070eeec2cdaac6fd3fe245226ed2a6292d3ee8c938e5bb645b434c5f256", size = 232498, upload-time = "2025-03-26T03:04:11.616Z" },
- { url = "https://files.pythonhosted.org/packages/8e/66/acb88e1f30ef5536d785c283af2e62931cb934a56a3ecf39105887aa8905/propcache-0.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b23c11c2c9e6d4e7300c92e022046ad09b91fd00e36e83c44483df4afa990073", size = 221486, upload-time = "2025-03-26T03:04:13.102Z" },
- { url = "https://files.pythonhosted.org/packages/f5/f9/233ddb05ffdcaee4448508ee1d70aa7deff21bb41469ccdfcc339f871427/propcache-0.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3e19ea4ea0bf46179f8a3652ac1426e6dcbaf577ce4b4f65be581e237340420d", size = 222675, upload-time = "2025-03-26T03:04:14.658Z" },
- { url = "https://files.pythonhosted.org/packages/98/b8/eb977e28138f9e22a5a789daf608d36e05ed93093ef12a12441030da800a/propcache-0.3.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd39c92e4c8f6cbf5f08257d6360123af72af9f4da75a690bef50da77362d25f", size = 215727, upload-time = "2025-03-26T03:04:16.207Z" },
- { url = "https://files.pythonhosted.org/packages/89/2d/5f52d9c579f67b8ee1edd9ec073c91b23cc5b7ff7951a1e449e04ed8fdf3/propcache-0.3.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0313e8b923b3814d1c4a524c93dfecea5f39fa95601f6a9b1ac96cd66f89ea0", size = 217878, upload-time = "2025-03-26T03:04:18.11Z" },
- { url = "https://files.pythonhosted.org/packages/7a/fd/5283e5ed8a82b00c7a989b99bb6ea173db1ad750bf0bf8dff08d3f4a4e28/propcache-0.3.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e861ad82892408487be144906a368ddbe2dc6297074ade2d892341b35c59844a", size = 230558, upload-time = "2025-03-26T03:04:19.562Z" },
- { url = "https://files.pythonhosted.org/packages/90/38/ab17d75938ef7ac87332c588857422ae126b1c76253f0f5b1242032923ca/propcache-0.3.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:61014615c1274df8da5991a1e5da85a3ccb00c2d4701ac6f3383afd3ca47ab0a", size = 233754, upload-time = "2025-03-26T03:04:21.065Z" },
- { url = "https://files.pythonhosted.org/packages/06/5d/3b921b9c60659ae464137508d3b4c2b3f52f592ceb1964aa2533b32fcf0b/propcache-0.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:71ebe3fe42656a2328ab08933d420df5f3ab121772eef78f2dc63624157f0ed9", size = 226088, upload-time = "2025-03-26T03:04:22.718Z" },
- { url = "https://files.pythonhosted.org/packages/54/6e/30a11f4417d9266b5a464ac5a8c5164ddc9dd153dfa77bf57918165eb4ae/propcache-0.3.1-cp311-cp311-win32.whl", hash = "sha256:58aa11f4ca8b60113d4b8e32d37e7e78bd8af4d1a5b5cb4979ed856a45e62005", size = 40859, upload-time = "2025-03-26T03:04:24.039Z" },
- { url = "https://files.pythonhosted.org/packages/1d/3a/8a68dd867da9ca2ee9dfd361093e9cb08cb0f37e5ddb2276f1b5177d7731/propcache-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:9532ea0b26a401264b1365146c440a6d78269ed41f83f23818d4b79497aeabe7", size = 45153, upload-time = "2025-03-26T03:04:25.211Z" },
- { url = "https://files.pythonhosted.org/packages/41/aa/ca78d9be314d1e15ff517b992bebbed3bdfef5b8919e85bf4940e57b6137/propcache-0.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723", size = 80430, upload-time = "2025-03-26T03:04:26.436Z" },
- { url = "https://files.pythonhosted.org/packages/1a/d8/f0c17c44d1cda0ad1979af2e593ea290defdde9eaeb89b08abbe02a5e8e1/propcache-0.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976", size = 46637, upload-time = "2025-03-26T03:04:27.932Z" },
- { url = "https://files.pythonhosted.org/packages/ae/bd/c1e37265910752e6e5e8a4c1605d0129e5b7933c3dc3cf1b9b48ed83b364/propcache-0.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b", size = 46123, upload-time = "2025-03-26T03:04:30.659Z" },
- { url = "https://files.pythonhosted.org/packages/d4/b0/911eda0865f90c0c7e9f0415d40a5bf681204da5fd7ca089361a64c16b28/propcache-0.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f", size = 243031, upload-time = "2025-03-26T03:04:31.977Z" },
- { url = "https://files.pythonhosted.org/packages/0a/06/0da53397c76a74271621807265b6eb61fb011451b1ddebf43213df763669/propcache-0.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70", size = 249100, upload-time = "2025-03-26T03:04:33.45Z" },
- { url = "https://files.pythonhosted.org/packages/f1/eb/13090e05bf6b963fc1653cdc922133ced467cb4b8dab53158db5a37aa21e/propcache-0.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7", size = 250170, upload-time = "2025-03-26T03:04:35.542Z" },
- { url = "https://files.pythonhosted.org/packages/3b/4c/f72c9e1022b3b043ec7dc475a0f405d4c3e10b9b1d378a7330fecf0652da/propcache-0.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25", size = 245000, upload-time = "2025-03-26T03:04:37.501Z" },
- { url = "https://files.pythonhosted.org/packages/e8/fd/970ca0e22acc829f1adf5de3724085e778c1ad8a75bec010049502cb3a86/propcache-0.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277", size = 230262, upload-time = "2025-03-26T03:04:39.532Z" },
- { url = "https://files.pythonhosted.org/packages/c4/42/817289120c6b9194a44f6c3e6b2c3277c5b70bbad39e7df648f177cc3634/propcache-0.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8", size = 236772, upload-time = "2025-03-26T03:04:41.109Z" },
- { url = "https://files.pythonhosted.org/packages/7c/9c/3b3942b302badd589ad6b672da3ca7b660a6c2f505cafd058133ddc73918/propcache-0.3.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e", size = 231133, upload-time = "2025-03-26T03:04:42.544Z" },
- { url = "https://files.pythonhosted.org/packages/98/a1/75f6355f9ad039108ff000dfc2e19962c8dea0430da9a1428e7975cf24b2/propcache-0.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee", size = 230741, upload-time = "2025-03-26T03:04:44.06Z" },
- { url = "https://files.pythonhosted.org/packages/67/0c/3e82563af77d1f8731132166da69fdfd95e71210e31f18edce08a1eb11ea/propcache-0.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815", size = 244047, upload-time = "2025-03-26T03:04:45.983Z" },
- { url = "https://files.pythonhosted.org/packages/f7/50/9fb7cca01532a08c4d5186d7bb2da6c4c587825c0ae134b89b47c7d62628/propcache-0.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5", size = 246467, upload-time = "2025-03-26T03:04:47.699Z" },
- { url = "https://files.pythonhosted.org/packages/a9/02/ccbcf3e1c604c16cc525309161d57412c23cf2351523aedbb280eb7c9094/propcache-0.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7", size = 241022, upload-time = "2025-03-26T03:04:49.195Z" },
- { url = "https://files.pythonhosted.org/packages/db/19/e777227545e09ca1e77a6e21274ae9ec45de0f589f0ce3eca2a41f366220/propcache-0.3.1-cp312-cp312-win32.whl", hash = "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b", size = 40647, upload-time = "2025-03-26T03:04:50.595Z" },
- { url = "https://files.pythonhosted.org/packages/24/bb/3b1b01da5dd04c77a204c84e538ff11f624e31431cfde7201d9110b092b1/propcache-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3", size = 44784, upload-time = "2025-03-26T03:04:51.791Z" },
- { url = "https://files.pythonhosted.org/packages/58/60/f645cc8b570f99be3cf46714170c2de4b4c9d6b827b912811eff1eb8a412/propcache-0.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8", size = 77865, upload-time = "2025-03-26T03:04:53.406Z" },
- { url = "https://files.pythonhosted.org/packages/6f/d4/c1adbf3901537582e65cf90fd9c26fde1298fde5a2c593f987112c0d0798/propcache-0.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f", size = 45452, upload-time = "2025-03-26T03:04:54.624Z" },
- { url = "https://files.pythonhosted.org/packages/d1/b5/fe752b2e63f49f727c6c1c224175d21b7d1727ce1d4873ef1c24c9216830/propcache-0.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111", size = 44800, upload-time = "2025-03-26T03:04:55.844Z" },
- { url = "https://files.pythonhosted.org/packages/62/37/fc357e345bc1971e21f76597028b059c3d795c5ca7690d7a8d9a03c9708a/propcache-0.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5", size = 225804, upload-time = "2025-03-26T03:04:57.158Z" },
- { url = "https://files.pythonhosted.org/packages/0d/f1/16e12c33e3dbe7f8b737809bad05719cff1dccb8df4dafbcff5575002c0e/propcache-0.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb", size = 230650, upload-time = "2025-03-26T03:04:58.61Z" },
- { url = "https://files.pythonhosted.org/packages/3e/a2/018b9f2ed876bf5091e60153f727e8f9073d97573f790ff7cdf6bc1d1fb8/propcache-0.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7", size = 234235, upload-time = "2025-03-26T03:05:00.599Z" },
- { url = "https://files.pythonhosted.org/packages/45/5f/3faee66fc930dfb5da509e34c6ac7128870631c0e3582987fad161fcb4b1/propcache-0.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120", size = 228249, upload-time = "2025-03-26T03:05:02.11Z" },
- { url = "https://files.pythonhosted.org/packages/62/1e/a0d5ebda5da7ff34d2f5259a3e171a94be83c41eb1e7cd21a2105a84a02e/propcache-0.3.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654", size = 214964, upload-time = "2025-03-26T03:05:03.599Z" },
- { url = "https://files.pythonhosted.org/packages/db/a0/d72da3f61ceab126e9be1f3bc7844b4e98c6e61c985097474668e7e52152/propcache-0.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e", size = 222501, upload-time = "2025-03-26T03:05:05.107Z" },
- { url = "https://files.pythonhosted.org/packages/18/6d/a008e07ad7b905011253adbbd97e5b5375c33f0b961355ca0a30377504ac/propcache-0.3.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b", size = 217917, upload-time = "2025-03-26T03:05:06.59Z" },
- { url = "https://files.pythonhosted.org/packages/98/37/02c9343ffe59e590e0e56dc5c97d0da2b8b19fa747ebacf158310f97a79a/propcache-0.3.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53", size = 217089, upload-time = "2025-03-26T03:05:08.1Z" },
- { url = "https://files.pythonhosted.org/packages/53/1b/d3406629a2c8a5666d4674c50f757a77be119b113eedd47b0375afdf1b42/propcache-0.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5", size = 228102, upload-time = "2025-03-26T03:05:09.982Z" },
- { url = "https://files.pythonhosted.org/packages/cd/a7/3664756cf50ce739e5f3abd48febc0be1a713b1f389a502ca819791a6b69/propcache-0.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7", size = 230122, upload-time = "2025-03-26T03:05:11.408Z" },
- { url = "https://files.pythonhosted.org/packages/35/36/0bbabaacdcc26dac4f8139625e930f4311864251276033a52fd52ff2a274/propcache-0.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef", size = 226818, upload-time = "2025-03-26T03:05:12.909Z" },
- { url = "https://files.pythonhosted.org/packages/cc/27/4e0ef21084b53bd35d4dae1634b6d0bad35e9c58ed4f032511acca9d4d26/propcache-0.3.1-cp313-cp313-win32.whl", hash = "sha256:3a02a28095b5e63128bcae98eb59025924f121f048a62393db682f049bf4ac24", size = 40112, upload-time = "2025-03-26T03:05:14.289Z" },
- { url = "https://files.pythonhosted.org/packages/a6/2c/a54614d61895ba6dd7ac8f107e2b2a0347259ab29cbf2ecc7b94fa38c4dc/propcache-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:813fbb8b6aea2fc9659815e585e548fe706d6f663fa73dff59a1677d4595a037", size = 44034, upload-time = "2025-03-26T03:05:15.616Z" },
- { url = "https://files.pythonhosted.org/packages/5a/a8/0a4fd2f664fc6acc66438370905124ce62e84e2e860f2557015ee4a61c7e/propcache-0.3.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f", size = 82613, upload-time = "2025-03-26T03:05:16.913Z" },
- { url = "https://files.pythonhosted.org/packages/4d/e5/5ef30eb2cd81576256d7b6caaa0ce33cd1d2c2c92c8903cccb1af1a4ff2f/propcache-0.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c", size = 47763, upload-time = "2025-03-26T03:05:18.607Z" },
- { url = "https://files.pythonhosted.org/packages/87/9a/87091ceb048efeba4d28e903c0b15bcc84b7c0bf27dc0261e62335d9b7b8/propcache-0.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc", size = 47175, upload-time = "2025-03-26T03:05:19.85Z" },
- { url = "https://files.pythonhosted.org/packages/3e/2f/854e653c96ad1161f96194c6678a41bbb38c7947d17768e8811a77635a08/propcache-0.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de", size = 292265, upload-time = "2025-03-26T03:05:21.654Z" },
- { url = "https://files.pythonhosted.org/packages/40/8d/090955e13ed06bc3496ba4a9fb26c62e209ac41973cb0d6222de20c6868f/propcache-0.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6", size = 294412, upload-time = "2025-03-26T03:05:23.147Z" },
- { url = "https://files.pythonhosted.org/packages/39/e6/d51601342e53cc7582449e6a3c14a0479fab2f0750c1f4d22302e34219c6/propcache-0.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7", size = 294290, upload-time = "2025-03-26T03:05:24.577Z" },
- { url = "https://files.pythonhosted.org/packages/3b/4d/be5f1a90abc1881884aa5878989a1acdafd379a91d9c7e5e12cef37ec0d7/propcache-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458", size = 282926, upload-time = "2025-03-26T03:05:26.459Z" },
- { url = "https://files.pythonhosted.org/packages/57/2b/8f61b998c7ea93a2b7eca79e53f3e903db1787fca9373af9e2cf8dc22f9d/propcache-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11", size = 267808, upload-time = "2025-03-26T03:05:28.188Z" },
- { url = "https://files.pythonhosted.org/packages/11/1c/311326c3dfce59c58a6098388ba984b0e5fb0381ef2279ec458ef99bd547/propcache-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c", size = 290916, upload-time = "2025-03-26T03:05:29.757Z" },
- { url = "https://files.pythonhosted.org/packages/4b/74/91939924b0385e54dc48eb2e4edd1e4903ffd053cf1916ebc5347ac227f7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf", size = 262661, upload-time = "2025-03-26T03:05:31.472Z" },
- { url = "https://files.pythonhosted.org/packages/c2/d7/e6079af45136ad325c5337f5dd9ef97ab5dc349e0ff362fe5c5db95e2454/propcache-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27", size = 264384, upload-time = "2025-03-26T03:05:32.984Z" },
- { url = "https://files.pythonhosted.org/packages/b7/d5/ba91702207ac61ae6f1c2da81c5d0d6bf6ce89e08a2b4d44e411c0bbe867/propcache-0.3.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757", size = 291420, upload-time = "2025-03-26T03:05:34.496Z" },
- { url = "https://files.pythonhosted.org/packages/58/70/2117780ed7edcd7ba6b8134cb7802aada90b894a9810ec56b7bb6018bee7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18", size = 290880, upload-time = "2025-03-26T03:05:36.256Z" },
- { url = "https://files.pythonhosted.org/packages/4a/1f/ecd9ce27710021ae623631c0146719280a929d895a095f6d85efb6a0be2e/propcache-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a", size = 287407, upload-time = "2025-03-26T03:05:37.799Z" },
- { url = "https://files.pythonhosted.org/packages/3e/66/2e90547d6b60180fb29e23dc87bd8c116517d4255240ec6d3f7dc23d1926/propcache-0.3.1-cp313-cp313t-win32.whl", hash = "sha256:359e81a949a7619802eb601d66d37072b79b79c2505e6d3fd8b945538411400d", size = 42573, upload-time = "2025-03-26T03:05:39.193Z" },
- { url = "https://files.pythonhosted.org/packages/cb/8f/50ad8599399d1861b4d2b6b45271f0ef6af1b09b0a2386a46dbaf19c9535/propcache-0.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e7fb9a84c9abbf2b2683fa3e7b0d7da4d8ecf139a1c635732a8bda29c5214b0e", size = 46757, upload-time = "2025-03-26T03:05:40.811Z" },
- { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376, upload-time = "2025-03-26T03:06:10.5Z" },
-]
-
-[[package]]
-name = "pycodestyle"
-version = "2.14.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" },
-]
-
-[[package]]
-name = "pyflakes"
-version = "3.4.0"
+name = "py-cpuinfo"
+version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" },
]
[[package]]
@@ -791,7 +471,7 @@ wheels = [
[[package]]
name = "pytest"
-version = "9.0.3"
+version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -800,22 +480,35 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
-version = "1.3.0"
+version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
+]
+
+[[package]]
+name = "pytest-benchmark"
+version = "5.2.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "py-cpuinfo" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+ { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" },
]
[[package]]
@@ -832,6 +525,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
+[[package]]
+name = "pytest-json-report"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "pytest-metadata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4f/d3/765dae9712fcd68d820338908c1337e077d5fdadccd5cacf95b9b0bea278/pytest-json-report-1.5.0.tar.gz", hash = "sha256:2dde3c647851a19b5f3700729e8310a6e66efb2077d674f27ddea3d34dc615de", size = 21241, upload-time = "2022-03-15T21:03:10.2Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/35/d07400c715bf8a88aa0c1ee9c9eb6050ca7fe5b39981f0eea773feeb0681/pytest_json_report-1.5.0-py3-none-any.whl", hash = "sha256:9897b68c910b12a2e48dd849f9a284b2c79a732a8a9cb398452ddd23d3c8c325", size = 13222, upload-time = "2022-03-15T21:03:08.65Z" },
+]
+
+[[package]]
+name = "pytest-metadata"
+version = "3.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a6/85/8c969f8bec4e559f8f2b958a15229a35495f5b4ce499f6b865eac54b878d/pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8", size = 9952, upload-time = "2024-02-12T19:38:44.887Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" },
+]
+
[[package]]
name = "python-discovery"
version = "1.2.2"
@@ -901,57 +619,49 @@ wheels = [
]
[[package]]
-name = "requests"
-version = "2.33.1"
+name = "respx"
+version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
+ { name = "httpx" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
]
[[package]]
-name = "responses"
-version = "0.26.0"
+name = "ruff"
+version = "0.15.20"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pyyaml" },
- { name = "requests" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" },
+ { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
+ { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
+ { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
+ { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
+ { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
+ { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
+ { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
+ { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
+ { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
]
[[package]]
-name = "ruff"
-version = "0.15.12"
+name = "sortedcontainers"
+version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
- { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
- { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
- { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
- { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
- { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
- { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
- { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
- { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
- { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
- { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
- { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
- { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
- { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
- { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
- { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
- { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
@@ -1009,21 +719,25 @@ wheels = [
]
[[package]]
-name = "typing-extensions"
-version = "4.13.2"
+name = "towncrier"
+version = "25.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
+dependencies = [
+ { name = "click" },
+ { name = "jinja2" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
+ { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" },
]
[[package]]
-name = "urllib3"
-version = "2.6.3"
+name = "typing-extensions"
+version = "4.13.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
]
[[package]]
@@ -1040,85 +754,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c3
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" },
]
-
-[[package]]
-name = "yarl"
-version = "1.20.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "idna" },
- { name = "multidict" },
- { name = "propcache" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/62/51/c0edba5219027f6eab262e139f73e2417b0f4efffa23bf562f6e18f76ca5/yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", size = 185258, upload-time = "2025-04-17T00:45:14.661Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/60/82/a59d8e21b20ffc836775fa7daedac51d16bb8f3010c4fcb495c4496aa922/yarl-1.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fdb5204d17cb32b2de2d1e21c7461cabfacf17f3645e4b9039f210c5d3378bf3", size = 145178, upload-time = "2025-04-17T00:42:04.511Z" },
- { url = "https://files.pythonhosted.org/packages/ba/81/315a3f6f95947cfbf37c92d6fbce42a1a6207b6c38e8c2b452499ec7d449/yarl-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eaddd7804d8e77d67c28d154ae5fab203163bd0998769569861258e525039d2a", size = 96859, upload-time = "2025-04-17T00:42:06.43Z" },
- { url = "https://files.pythonhosted.org/packages/ad/17/9b64e575583158551b72272a1023cdbd65af54fe13421d856b2850a6ddb7/yarl-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:634b7ba6b4a85cf67e9df7c13a7fb2e44fa37b5d34501038d174a63eaac25ee2", size = 94647, upload-time = "2025-04-17T00:42:07.976Z" },
- { url = "https://files.pythonhosted.org/packages/2c/29/8f291e7922a58a21349683f6120a85701aeefaa02e9f7c8a2dc24fe3f431/yarl-1.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d409e321e4addf7d97ee84162538c7258e53792eb7c6defd0c33647d754172e", size = 355788, upload-time = "2025-04-17T00:42:09.902Z" },
- { url = "https://files.pythonhosted.org/packages/26/6d/b4892c80b805c42c228c6d11e03cafabf81662d371b0853e7f0f513837d5/yarl-1.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ea52f7328a36960ba3231c6677380fa67811b414798a6e071c7085c57b6d20a9", size = 344613, upload-time = "2025-04-17T00:42:11.768Z" },
- { url = "https://files.pythonhosted.org/packages/d7/0e/517aa28d3f848589bae9593717b063a544b86ba0a807d943c70f48fcf3bb/yarl-1.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8703517b924463994c344dcdf99a2d5ce9eca2b6882bb640aa555fb5efc706a", size = 370953, upload-time = "2025-04-17T00:42:13.983Z" },
- { url = "https://files.pythonhosted.org/packages/5f/9b/5bd09d2f1ad6e6f7c2beae9e50db78edd2cca4d194d227b958955573e240/yarl-1.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:077989b09ffd2f48fb2d8f6a86c5fef02f63ffe6b1dd4824c76de7bb01e4f2e2", size = 369204, upload-time = "2025-04-17T00:42:16.386Z" },
- { url = "https://files.pythonhosted.org/packages/9c/85/d793a703cf4bd0d4cd04e4b13cc3d44149470f790230430331a0c1f52df5/yarl-1.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0acfaf1da020253f3533526e8b7dd212838fdc4109959a2c53cafc6db611bff2", size = 358108, upload-time = "2025-04-17T00:42:18.622Z" },
- { url = "https://files.pythonhosted.org/packages/6f/54/b6c71e13549c1f6048fbc14ce8d930ac5fb8bafe4f1a252e621a24f3f1f9/yarl-1.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4230ac0b97ec5eeb91d96b324d66060a43fd0d2a9b603e3327ed65f084e41f8", size = 346610, upload-time = "2025-04-17T00:42:20.9Z" },
- { url = "https://files.pythonhosted.org/packages/a0/1a/d6087d58bdd0d8a2a37bbcdffac9d9721af6ebe50d85304d9f9b57dfd862/yarl-1.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6a1e6ae21cdd84011c24c78d7a126425148b24d437b5702328e4ba640a8902", size = 365378, upload-time = "2025-04-17T00:42:22.926Z" },
- { url = "https://files.pythonhosted.org/packages/02/84/e25ddff4cbc001dbc4af76f8d41a3e23818212dd1f0a52044cbc60568872/yarl-1.20.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:86de313371ec04dd2531f30bc41a5a1a96f25a02823558ee0f2af0beaa7ca791", size = 356919, upload-time = "2025-04-17T00:42:25.145Z" },
- { url = "https://files.pythonhosted.org/packages/04/76/898ae362353bf8f64636495d222c8014c8e5267df39b1a9fe1e1572fb7d0/yarl-1.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dd59c9dd58ae16eaa0f48c3d0cbe6be8ab4dc7247c3ff7db678edecbaf59327f", size = 364248, upload-time = "2025-04-17T00:42:27.475Z" },
- { url = "https://files.pythonhosted.org/packages/1b/b0/9d9198d83a622f1c40fdbf7bd13b224a6979f2e1fc2cf50bfb1d8773c495/yarl-1.20.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a0bc5e05f457b7c1994cc29e83b58f540b76234ba6b9648a4971ddc7f6aa52da", size = 378418, upload-time = "2025-04-17T00:42:29.333Z" },
- { url = "https://files.pythonhosted.org/packages/c7/ce/1f50c1cc594cf5d3f5bf4a9b616fca68680deaec8ad349d928445ac52eb8/yarl-1.20.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c9471ca18e6aeb0e03276b5e9b27b14a54c052d370a9c0c04a68cefbd1455eb4", size = 383850, upload-time = "2025-04-17T00:42:31.668Z" },
- { url = "https://files.pythonhosted.org/packages/89/1e/a59253a87b35bfec1a25bb5801fb69943330b67cfd266278eb07e0609012/yarl-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40ed574b4df723583a26c04b298b283ff171bcc387bc34c2683235e2487a65a5", size = 381218, upload-time = "2025-04-17T00:42:33.523Z" },
- { url = "https://files.pythonhosted.org/packages/85/b0/26f87df2b3044b0ef1a7cf66d321102bdca091db64c5ae853fcb2171c031/yarl-1.20.0-cp311-cp311-win32.whl", hash = "sha256:db243357c6c2bf3cd7e17080034ade668d54ce304d820c2a58514a4e51d0cfd6", size = 86606, upload-time = "2025-04-17T00:42:35.873Z" },
- { url = "https://files.pythonhosted.org/packages/33/46/ca335c2e1f90446a77640a45eeb1cd8f6934f2c6e4df7db0f0f36ef9f025/yarl-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:8c12cd754d9dbd14204c328915e23b0c361b88f3cffd124129955e60a4fbfcfb", size = 93374, upload-time = "2025-04-17T00:42:37.586Z" },
- { url = "https://files.pythonhosted.org/packages/c3/e8/3efdcb83073df978bb5b1a9cc0360ce596680e6c3fac01f2a994ccbb8939/yarl-1.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f", size = 147089, upload-time = "2025-04-17T00:42:39.602Z" },
- { url = "https://files.pythonhosted.org/packages/60/c3/9e776e98ea350f76f94dd80b408eaa54e5092643dbf65fd9babcffb60509/yarl-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e", size = 97706, upload-time = "2025-04-17T00:42:41.469Z" },
- { url = "https://files.pythonhosted.org/packages/0c/5b/45cdfb64a3b855ce074ae607b9fc40bc82e7613b94e7612b030255c93a09/yarl-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e", size = 95719, upload-time = "2025-04-17T00:42:43.666Z" },
- { url = "https://files.pythonhosted.org/packages/2d/4e/929633b249611eeed04e2f861a14ed001acca3ef9ec2a984a757b1515889/yarl-1.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33", size = 343972, upload-time = "2025-04-17T00:42:45.391Z" },
- { url = "https://files.pythonhosted.org/packages/49/fd/047535d326c913f1a90407a3baf7ff535b10098611eaef2c527e32e81ca1/yarl-1.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58", size = 339639, upload-time = "2025-04-17T00:42:47.552Z" },
- { url = "https://files.pythonhosted.org/packages/48/2f/11566f1176a78f4bafb0937c0072410b1b0d3640b297944a6a7a556e1d0b/yarl-1.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f", size = 353745, upload-time = "2025-04-17T00:42:49.406Z" },
- { url = "https://files.pythonhosted.org/packages/26/17/07dfcf034d6ae8837b33988be66045dd52f878dfb1c4e8f80a7343f677be/yarl-1.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae", size = 354178, upload-time = "2025-04-17T00:42:51.588Z" },
- { url = "https://files.pythonhosted.org/packages/15/45/212604d3142d84b4065d5f8cab6582ed3d78e4cc250568ef2a36fe1cf0a5/yarl-1.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018", size = 349219, upload-time = "2025-04-17T00:42:53.674Z" },
- { url = "https://files.pythonhosted.org/packages/e6/e0/a10b30f294111c5f1c682461e9459935c17d467a760c21e1f7db400ff499/yarl-1.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672", size = 337266, upload-time = "2025-04-17T00:42:55.49Z" },
- { url = "https://files.pythonhosted.org/packages/33/a6/6efa1d85a675d25a46a167f9f3e80104cde317dfdf7f53f112ae6b16a60a/yarl-1.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8", size = 360873, upload-time = "2025-04-17T00:42:57.895Z" },
- { url = "https://files.pythonhosted.org/packages/77/67/c8ab718cb98dfa2ae9ba0f97bf3cbb7d45d37f13fe1fbad25ac92940954e/yarl-1.20.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7", size = 360524, upload-time = "2025-04-17T00:43:00.094Z" },
- { url = "https://files.pythonhosted.org/packages/bd/e8/c3f18660cea1bc73d9f8a2b3ef423def8dadbbae6c4afabdb920b73e0ead/yarl-1.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594", size = 365370, upload-time = "2025-04-17T00:43:02.242Z" },
- { url = "https://files.pythonhosted.org/packages/c9/99/33f3b97b065e62ff2d52817155a89cfa030a1a9b43fee7843ef560ad9603/yarl-1.20.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6", size = 373297, upload-time = "2025-04-17T00:43:04.189Z" },
- { url = "https://files.pythonhosted.org/packages/3d/89/7519e79e264a5f08653d2446b26d4724b01198a93a74d2e259291d538ab1/yarl-1.20.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1", size = 378771, upload-time = "2025-04-17T00:43:06.609Z" },
- { url = "https://files.pythonhosted.org/packages/3a/58/6c460bbb884abd2917c3eef6f663a4a873f8dc6f498561fc0ad92231c113/yarl-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b", size = 375000, upload-time = "2025-04-17T00:43:09.01Z" },
- { url = "https://files.pythonhosted.org/packages/3b/2a/dd7ed1aa23fea996834278d7ff178f215b24324ee527df53d45e34d21d28/yarl-1.20.0-cp312-cp312-win32.whl", hash = "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64", size = 86355, upload-time = "2025-04-17T00:43:11.311Z" },
- { url = "https://files.pythonhosted.org/packages/ca/c6/333fe0338305c0ac1c16d5aa7cc4841208d3252bbe62172e0051006b5445/yarl-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c", size = 92904, upload-time = "2025-04-17T00:43:13.087Z" },
- { url = "https://files.pythonhosted.org/packages/0f/6f/514c9bff2900c22a4f10e06297714dbaf98707143b37ff0bcba65a956221/yarl-1.20.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f", size = 145030, upload-time = "2025-04-17T00:43:15.083Z" },
- { url = "https://files.pythonhosted.org/packages/4e/9d/f88da3fa319b8c9c813389bfb3463e8d777c62654c7168e580a13fadff05/yarl-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3", size = 96894, upload-time = "2025-04-17T00:43:17.372Z" },
- { url = "https://files.pythonhosted.org/packages/cd/57/92e83538580a6968b2451d6c89c5579938a7309d4785748e8ad42ddafdce/yarl-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d", size = 94457, upload-time = "2025-04-17T00:43:19.431Z" },
- { url = "https://files.pythonhosted.org/packages/e9/ee/7ee43bd4cf82dddd5da97fcaddb6fa541ab81f3ed564c42f146c83ae17ce/yarl-1.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0", size = 343070, upload-time = "2025-04-17T00:43:21.426Z" },
- { url = "https://files.pythonhosted.org/packages/4a/12/b5eccd1109e2097bcc494ba7dc5de156e41cf8309fab437ebb7c2b296ce3/yarl-1.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501", size = 337739, upload-time = "2025-04-17T00:43:23.634Z" },
- { url = "https://files.pythonhosted.org/packages/7d/6b/0eade8e49af9fc2585552f63c76fa59ef469c724cc05b29519b19aa3a6d5/yarl-1.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc", size = 351338, upload-time = "2025-04-17T00:43:25.695Z" },
- { url = "https://files.pythonhosted.org/packages/45/cb/aaaa75d30087b5183c7b8a07b4fb16ae0682dd149a1719b3a28f54061754/yarl-1.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d", size = 353636, upload-time = "2025-04-17T00:43:27.876Z" },
- { url = "https://files.pythonhosted.org/packages/98/9d/d9cb39ec68a91ba6e66fa86d97003f58570327d6713833edf7ad6ce9dde5/yarl-1.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0", size = 348061, upload-time = "2025-04-17T00:43:29.788Z" },
- { url = "https://files.pythonhosted.org/packages/72/6b/103940aae893d0cc770b4c36ce80e2ed86fcb863d48ea80a752b8bda9303/yarl-1.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a", size = 334150, upload-time = "2025-04-17T00:43:31.742Z" },
- { url = "https://files.pythonhosted.org/packages/ef/b2/986bd82aa222c3e6b211a69c9081ba46484cffa9fab2a5235e8d18ca7a27/yarl-1.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2", size = 362207, upload-time = "2025-04-17T00:43:34.099Z" },
- { url = "https://files.pythonhosted.org/packages/14/7c/63f5922437b873795d9422cbe7eb2509d4b540c37ae5548a4bb68fd2c546/yarl-1.20.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9", size = 361277, upload-time = "2025-04-17T00:43:36.202Z" },
- { url = "https://files.pythonhosted.org/packages/81/83/450938cccf732466953406570bdb42c62b5ffb0ac7ac75a1f267773ab5c8/yarl-1.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5", size = 364990, upload-time = "2025-04-17T00:43:38.551Z" },
- { url = "https://files.pythonhosted.org/packages/b4/de/af47d3a47e4a833693b9ec8e87debb20f09d9fdc9139b207b09a3e6cbd5a/yarl-1.20.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877", size = 374684, upload-time = "2025-04-17T00:43:40.481Z" },
- { url = "https://files.pythonhosted.org/packages/62/0b/078bcc2d539f1faffdc7d32cb29a2d7caa65f1a6f7e40795d8485db21851/yarl-1.20.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e", size = 382599, upload-time = "2025-04-17T00:43:42.463Z" },
- { url = "https://files.pythonhosted.org/packages/74/a9/4fdb1a7899f1fb47fd1371e7ba9e94bff73439ce87099d5dd26d285fffe0/yarl-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384", size = 378573, upload-time = "2025-04-17T00:43:44.797Z" },
- { url = "https://files.pythonhosted.org/packages/fd/be/29f5156b7a319e4d2e5b51ce622b4dfb3aa8d8204cd2a8a339340fbfad40/yarl-1.20.0-cp313-cp313-win32.whl", hash = "sha256:70e0c580a0292c7414a1cead1e076c9786f685c1fc4757573d2967689b370e62", size = 86051, upload-time = "2025-04-17T00:43:47.076Z" },
- { url = "https://files.pythonhosted.org/packages/52/56/05fa52c32c301da77ec0b5f63d2d9605946fe29defacb2a7ebd473c23b81/yarl-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:4c43030e4b0af775a85be1fa0433119b1565673266a70bf87ef68a9d5ba3174c", size = 92742, upload-time = "2025-04-17T00:43:49.193Z" },
- { url = "https://files.pythonhosted.org/packages/d4/2f/422546794196519152fc2e2f475f0e1d4d094a11995c81a465faf5673ffd/yarl-1.20.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051", size = 163575, upload-time = "2025-04-17T00:43:51.533Z" },
- { url = "https://files.pythonhosted.org/packages/90/fc/67c64ddab6c0b4a169d03c637fb2d2a212b536e1989dec8e7e2c92211b7f/yarl-1.20.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d", size = 106121, upload-time = "2025-04-17T00:43:53.506Z" },
- { url = "https://files.pythonhosted.org/packages/6d/00/29366b9eba7b6f6baed7d749f12add209b987c4cfbfa418404dbadc0f97c/yarl-1.20.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229", size = 103815, upload-time = "2025-04-17T00:43:55.41Z" },
- { url = "https://files.pythonhosted.org/packages/28/f4/a2a4c967c8323c03689383dff73396281ced3b35d0ed140580825c826af7/yarl-1.20.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1", size = 408231, upload-time = "2025-04-17T00:43:57.825Z" },
- { url = "https://files.pythonhosted.org/packages/0f/a1/66f7ffc0915877d726b70cc7a896ac30b6ac5d1d2760613603b022173635/yarl-1.20.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb", size = 390221, upload-time = "2025-04-17T00:44:00.526Z" },
- { url = "https://files.pythonhosted.org/packages/41/15/cc248f0504610283271615e85bf38bc014224122498c2016d13a3a1b8426/yarl-1.20.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00", size = 411400, upload-time = "2025-04-17T00:44:02.853Z" },
- { url = "https://files.pythonhosted.org/packages/5c/af/f0823d7e092bfb97d24fce6c7269d67fcd1aefade97d0a8189c4452e4d5e/yarl-1.20.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de", size = 411714, upload-time = "2025-04-17T00:44:04.904Z" },
- { url = "https://files.pythonhosted.org/packages/83/70/be418329eae64b9f1b20ecdaac75d53aef098797d4c2299d82ae6f8e4663/yarl-1.20.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5", size = 404279, upload-time = "2025-04-17T00:44:07.721Z" },
- { url = "https://files.pythonhosted.org/packages/19/f5/52e02f0075f65b4914eb890eea1ba97e6fd91dd821cc33a623aa707b2f67/yarl-1.20.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a", size = 384044, upload-time = "2025-04-17T00:44:09.708Z" },
- { url = "https://files.pythonhosted.org/packages/6a/36/b0fa25226b03d3f769c68d46170b3e92b00ab3853d73127273ba22474697/yarl-1.20.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9", size = 416236, upload-time = "2025-04-17T00:44:11.734Z" },
- { url = "https://files.pythonhosted.org/packages/cb/3a/54c828dd35f6831dfdd5a79e6c6b4302ae2c5feca24232a83cb75132b205/yarl-1.20.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145", size = 402034, upload-time = "2025-04-17T00:44:13.975Z" },
- { url = "https://files.pythonhosted.org/packages/10/97/c7bf5fba488f7e049f9ad69c1b8fdfe3daa2e8916b3d321aa049e361a55a/yarl-1.20.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda", size = 407943, upload-time = "2025-04-17T00:44:16.052Z" },
- { url = "https://files.pythonhosted.org/packages/fd/a4/022d2555c1e8fcff08ad7f0f43e4df3aba34f135bff04dd35d5526ce54ab/yarl-1.20.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f", size = 423058, upload-time = "2025-04-17T00:44:18.547Z" },
- { url = "https://files.pythonhosted.org/packages/4c/f6/0873a05563e5df29ccf35345a6ae0ac9e66588b41fdb7043a65848f03139/yarl-1.20.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd", size = 423792, upload-time = "2025-04-17T00:44:20.639Z" },
- { url = "https://files.pythonhosted.org/packages/9e/35/43fbbd082708fa42e923f314c24f8277a28483d219e049552e5007a9aaca/yarl-1.20.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f", size = 422242, upload-time = "2025-04-17T00:44:22.851Z" },
- { url = "https://files.pythonhosted.org/packages/ed/f7/f0f2500cf0c469beb2050b522c7815c575811627e6d3eb9ec7550ddd0bfe/yarl-1.20.0-cp313-cp313t-win32.whl", hash = "sha256:65a4053580fe88a63e8e4056b427224cd01edfb5f951498bfefca4052f0ce0ac", size = 93816, upload-time = "2025-04-17T00:44:25.491Z" },
- { url = "https://files.pythonhosted.org/packages/3f/93/f73b61353b2a699d489e782c3f5998b59f974ec3156a2050a52dfd7e8946/yarl-1.20.0-cp313-cp313t-win_amd64.whl", hash = "sha256:53b2da3a6ca0a541c1ae799c349788d480e5144cac47dba0266c7cb6c76151fe", size = 101093, upload-time = "2025-04-17T00:44:27.418Z" },
- { url = "https://files.pythonhosted.org/packages/ea/1f/70c57b3d7278e94ed22d85e09685d3f0a38ebdd8c5c73b65ba4c0d0fe002/yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", size = 46124, upload-time = "2025-04-17T00:45:12.199Z" },
-]