diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..3d0c2ee7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(rm -f ~/.claude/hooks/gsd-check-update-worker.js)", + "Read(//c/Users/jkuchta/.claude/hooks/**)" + ] + }, + "enabledMcpjsonServers": [ + "atlassian", + "figma-remote-mcp", + "slack", + "playwright", + "github", + "mcp-server-snowflake" + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..9967a76f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +tests/generator/golden/** text eol=lf diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index fc5af691..28941a36 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,9 @@ blank_issues_enabled: false contact_links: + - name: I noticed an API operation is missing from the library + url: https://github.com/meraki/dashboard-api-python/tree/main/generator + about: > + The official library includes GA operations and excludes those that are Early Access (e.g., beta). To generate a local version of the library that includes Early Access operations, please follow these instructions. - name: Meraki Interactive API documentation url: https://developer.cisco.com/meraki/api-v1/#!api-reference-overview about: > diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 47e332ed..deac9726 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,28 @@ version: 2 updates: - - package-ecosystem: "poetry" - directory: "/" # Adjust if your files are in a subdirectory + - 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/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index f2bbe83f..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,70 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '29 15 * * 6' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 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/lint-library-generator.yml b/.github/workflows/lint-library-generator.yml deleted file mode 100644 index 35aa80bc..00000000 --- a/.github/workflows/lint-library-generator.yml +++ /dev/null @@ -1,60 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python -name: Lint Python library generator - -on: - push: - branches: - - 'main' - - 'release' - paths-ignore: - - '.github/**' - - 'examples/**' - - 'meraki/aio/**' - - 'notebooks/**' - - 'README.md' - - 'LICENSE' - - 'setup.py' - pull_request: - branches: - - 'main' - - 'release' - paths-ignore: - - '.github/**' - - 'examples/**' - - 'meraki/aio/**' - - 'notebooks/**' - - 'README.md' - - 'LICENSE' - - 'setup.py' - workflow_dispatch: - -jobs: - - build: - - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install poetry - poetry lock - poetry install --no-root - poetry add flake8 - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - poetry run flake8 ./generator/generate_library.py --count --select=E9,F63,F7,F82 --ignore=F405,W391,W291,C901,E501,E303,W293 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - poetry run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 8bed6fbd..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 @@ -10,22 +15,20 @@ jobs: build: runs-on: ubuntu-latest steps: - - name: Set release name as environment variable - run: echo "RELEASE_NAME=${{ github.event.release.tag_name }}" >> $GITHUB_ENV - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.event.release.tag_name }} + + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install poetry - poetry install --no-root - poetry add build - poetry add wheel - - name: Build a binary wheel and a source tarball - run: | - poetry run python setup.py sdist bdist_wheel + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Build package + run: uv build + - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/regenerate-library.yml b/.github/workflows/regenerate-library.yml index d579ad03..09cf332d 100644 --- a/.github/workflows/regenerate-library.yml +++ b/.github/workflows/regenerate-library.yml @@ -8,36 +8,138 @@ 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: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 + - 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@v7 with: - python-version: '3.12' + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + - name: Install dependencies + run: uv sync --group generator + + # 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: | - python -m pip install --upgrade pip - pip install poetry - poetry lock - poetry install --no-root - - name: Delete folder - 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 }} + 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: | - poetry run python generator/generate_library.py -g true -v ${{ github.event.inputs.library_version }} -a ${{ github.event.inputs.api_version }} -o ${{ secrets.TEST_ORG_ID }} -k ${{ secrets.TEST_ORG_API_KEY }} - - name: Set new version for Poetry + 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: + LIBRARY_VERSION: ${{ github.event.inputs.library_version }} + run: | + 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: | - sed -i "s/^version = \".*\"/version = \"${{ github.event.inputs.library_version }}\"/" pyproject.toml - poetry lock + 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@v9 + 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 new file mode 100644 index 00000000..43242b38 --- /dev/null +++ b/.github/workflows/test-library-generator.yml @@ -0,0 +1,64 @@ +name: Test Python library generator + +on: + push: + branches: ['main', 'release'] + paths: + - 'generator/**' + - 'tests/generator/**' + - 'pyproject.toml' + - 'uv.lock' + pull_request: + branches: ['main', 'release'] + paths: + - 'generator/**' + - 'tests/generator/**' + - 'pyproject.toml' + - 'uv.lock' + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + 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: Lint with ruff + run: | + uv run ruff check generator/ + uv run ruff format --check generator/ + + test: + 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 --group generator + + - name: Test generator + run: uv run pytest tests/generator/ -v diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index 0fc6c894..0b5a0e97 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -1,69 +1,210 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python name: Test Python library on: - push: - branches: - - 'main' - - 'release' - - 'dev_rest_session' - paths-ignore: - - '.github/**' - - 'examples/**' - - 'meraki/aio/**' - - 'notebooks/**' - - 'generator/**' - - 'README.md' - - 'LICENSE' - - 'setup.py' - pull_request: - branches: - - 'main' - - 'release' - - 'dev_rest_session' - paths-ignore: - - '.github/**' - - 'examples/**' - - 'meraki/aio/**' - - 'notebooks/**' - - 'generator/**' - - 'README.md' - - 'LICENSE' - - 'setup.py' + workflow_call: workflow_dispatch: +permissions: + contents: read jobs: - build: + lint: + 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: Lint with ruff + run: | + 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.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --python ${{ matrix.python-version }} + + - 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: 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: - python-version: ${{ matrix.python-version }} + 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: Integration tests + 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: | - python -m pip install --upgrade pip - pip install poetry - poetry lock - poetry install --no-root - poetry add flake8 - poetry add pytest - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - poetry run flake8 . --count --select=E9,F63,F7,F82 --ignore=F405,W391,W291,C901,E501,E303,W293 --exclude=examples,generator --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - poetry run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest + 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: | - poetry run pytest --apikey ${{ secrets.TEST_ORG_API_KEY }} --o ${{ secrets.TEST_ORG_ID }} + 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/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 d2a785e7..9a530e89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,26 @@ -.DS_Store -# No PyCharm config files -.idea/ -venv/ -__pycache__ -/.pytest_cache/ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ + +# Virtual environments +venv/ +.venv/ + +# Testing +.pytest_cache/ +.coverage + +# IDEs +.idea/ +.vscode/ + +# OS +.DS_Store + +# Project +.scratch/ + +# Git worktrees +.worktrees/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..67a0a637 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.2 + hooks: + - id: ruff-format + exclude: ^(meraki/(aio/api|api/batch|api)/|notebooks/|generator/generate_snippets\.py) + - id: ruff + args: [--fix] + exclude: ^(meraki/(aio/api|api/batch|api)/|notebooks/|generator/generate_snippets\.py) 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 new file mode 100644 index 00000000..f208d604 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contributing + +Meraki welcomes constructive pull requests that maintain backwards compatibility with prior versions. + +## Setup + +```bash +# Clone and install dev dependencies +git clone https://github.com/meraki/dashboard-api-python.git +cd dashboard-api-python +uv sync +``` + +## Development Workflow + +1. Create a feature branch from `main` +2. Make your changes +3. Run tests: `uv run pytest tests/unit` +4. Run linting: `uv run ruff check . && uv run ruff format --check .` +5. Open a pull request against `main` + +## Code Standards + +- Line length: 127 characters +- Formatter: ruff format +- Linter: ruff + flake8 +- Test coverage floor: 90% (core, non-generated code) +- Python versions: 3.11+ + +## What to Contribute + +- Bug fixes with regression tests +- Documentation improvements +- Test coverage improvements for non-generated code +- Performance improvements with benchmarks + +## What Not to Modify + +- 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 +uv sync --group generator +uv run python generator/generate_library.py +``` + +## Questions + +- GitHub Issues: bug reports and feature requests +- [Meraki Community](https://community.meraki.com/): general discussion +- [api-feedback@meraki.net](mailto:api-feedback@meraki.net): direct contact diff --git a/README.md b/README.md index 5f0029c4..c7f5a2ab 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,56 @@ # Meraki Dashboard API Python Library -The Meraki Dashboard API Python library provides all current -Meraki [dashboard API](https://developer.cisco.com/meraki/api-v1/) calls to interface with the Cisco Meraki -cloud-managed platform. Meraki generates the library based on dashboard API's OpenAPI spec to keep it up to date with -the latest API releases, and provides the full source code for the library including the tools used to generate the -library, if you are participating in the Early Access program or would like to contribute to the development of the -library. Meraki welcomes constructive pull requests that maintain backwards compatibility with prior versions. The -library requires Python 3.10+, receives support from the community, and you can install it -via [PyPI](https://pypi.org/project/meraki/): +A Python client for the Cisco Meraki [dashboard API](https://developer.cisco.com/meraki/api-v1/), covering every +current operation. It's generated from the API's OpenAPI spec, so it tracks the latest releases automatically. The full +source, including the generator, is here for Early Access participants and contributors; pull requests that maintain +backwards compatibility are welcome. Requires Python 3.10+, community-supported, installable via +[PyPI](https://pypi.org/project/meraki/): pip install --upgrade meraki +Or with [uv](https://docs.astral.sh/uv/): + + uv pip install --upgrade meraki + If you participate in [our Early Access program](https://community.meraki.com/t5/Developers-APIs/UPDATED-Beta-testing-with-the-Meraki-Developer-Early-Access/m-p/145344#M5808) -and would like to use early access features via the library, -you'll [find instructions in the generator readme](https://github.com/meraki/dashboard-api-python/tree/main/generator#readme). +and would like to use early access features via the library, you have two options: install a published +[beta release](#releases) from PyPI (`pip install meraki==bN`), or, if you need a library matched to your own +org's spec, [generate one yourself](https://github.com/meraki/dashboard-api-python/tree/main/generator#readme). -## Features +### [Features](#features) ¡ [Releases](#releases) ¡ [Usage](#usage) ¡ [Smart flow](#smart-flow-rate-limiting) ¡ [AsyncIO](#asyncio) ¡ [Versioning](VERSIONING.md) ¡ [Changelog](CHANGELOG.md) ¡ [Contributing](CONTRIBUTING.md) -While you can make direct HTTP requests to dashboard API in any programming language or REST API client, using a client -library can make it easier for you to focus on your specific use case, without the overhead of having to write functions -to handle the dashboard API calls. The Python library can also take care of error handling, logging, retries, and other -convenient processes and options for you automatically. +## Features -* Support for all API endpoints, as it uses the [OpenAPI specification](https://api.meraki.com/api/v1/openapiSpec) to - generate source code -* Log all API requests made to a local file as well as on-screen console -* Automatic retries upon 429 rate limit errors, using - the [`Retry-After` field](https://developer.cisco.com/meraki/api-v1/#!rate-limit) within response headers -* Get all (or a specified number of) pages of data with built-in pagination control -* Tweak settings such as maximum retries, certificate path, suppress logging, and other options -* Simulate POST/PUT/DELETE calls to preview first, so that network configuration does not get changed +You could hit the dashboard API with raw HTTP in any language. But then you own the rate-limit math, the retry loops, +the pagination bookkeeping, the auth headers, and the error handling, for every one of the hundreds of operations, and +you re-own it every time the API changes. This library does all of that for you, and stays current automatically because +it's generated from the API's own OpenAPI spec. + +What you get out of the box: + +- **Complete, always-current operation coverage** — every dashboard API operation, generated straight from the + [OpenAPI specification](https://api.meraki.com/api/v1/openapiSpec), so new operations land as the API ships them +- **Proactive rate limiting ("smart flow")** — per-org token buckets keep you under Meraki's 10 req/s org limit and + 100 req/s source-IP limit _before_ you get throttled, turning 429-and-retry churn into steady throughput. On by + default, zero config. [Details below.](#smart-flow-rate-limiting) +- **Automatic retries** — 429s honor the [`Retry-After`](https://developer.cisco.com/meraki/api-v1/#!rate-limit) + header; transient 5xx and select 4xx (network-delete/action-batch concurrency) back off and retry so your script + doesn't fall over on a blip +- **Built-in pagination** — pull all pages, or a specific number, with one call; no manual Link-header walking +- **Sync and async** — a synchronous client and a fully `async`/`await` client + ([AsyncIO](#asyncio)) sharing the same interface, so you scale up concurrency without rewriting your logic +- **Modern HTTP stack** — built on [httpx](https://www.python-httpx.org/) (not `requests`/`aiohttp`), a unified, + type-annotated, HTTP/2-capable backend powering both the sync and async clients +- **Early access via beta releases** — opt into beta builds to use API operations before they reach GA, in step with + Meraki's [Early Access program](https://community.meraki.com/t5/Developers-APIs/UPDATED-Beta-testing-with-the-Meraki-Developer-Early-Access/m-p/145344#M5808). [Details below.](#releases) +- **Dry-run mode** — simulate POST/PUT/DELETE calls to preview changes without touching your network configuration +- **Logging you can trust** — every request logged to file and console, with X-Request-Id captured on failures for + fast support escalation to Meraki +- **Kwarg validation** — optional typo protection that warns when an unrecognized keyword argument would otherwise be + silently ignored, catching bugs before they ship +- **Tunable everything** — retries, timeouts, certificate path, proxy, logging verbosity, and more, all configurable + per client or via environment ## Setup @@ -40,145 +60,163 @@ convenient processes and options for you automatically. 2. Keep your API key safe and secure, as it is similar to a password for your dashboard. If publishing your Python code to a wider audience, please research secure handling of API keys. -3. Install the latest version of [Python 3](ttps://wiki.python.org/moin/BeginnersGuide/NonProgrammers) +3. Install [Python 3.10 or later](https://wiki.python.org/moin/BeginnersGuide/NonProgrammers) -4. Use _pip_ (or an alternative such as _easy_install_) to install the library from the +4. Use _pip_ to install the library from the Python [Package Index](https://pypi.org/project/meraki/): - * `pip install meraki` - * If you have both Python3 and Python2 installed, you may need to use `pip3` (so `pip3 install meraki`) along - with `python3` on your system - * If _meraki_ was previously installed, you can upgrade to the latest non-beta release - with `pip install --upgrade meraki` - -5. The library supports Meraki dashboard API v1. You can also specify the version of the library when installing with - _pip_: - * See the full [release history](https://pypi.org/project/meraki/#history) to pick the version you want, or - use `pip install meraki==` without including a version number to display the list of available versions - * Versions begin with _1_ (1.0.0b**z** for beta) - * Specify the version you want with the install command; for example: `pip install meraki==1.34.0` - * You can also see the version currently installed with `pip show meraki` - * End-of-life v0 versions of the Python library begin with _0_ (0.**x**.**y**) and are not supported nor - recommended. + - `pip install meraki` + - If _meraki_ was previously installed, you can upgrade to the latest non-beta release + with `pip install --upgrade meraki` + +5. The library supports Meraki dashboard API v1. To install or check a specific version: + - `pip install meraki==4.3.0` installs that version (see the full [release history](https://pypi.org/project/meraki/#history)) + - `pip show meraki` reports the version currently installed + - Picking between stable and beta releases is covered under [Releases](#releases) below + +## Releases + +`pip install --upgrade meraki` gets the latest **stable (GA)** release, which contains only GA operations from the +upstream dashboard API. Stable releases track upstream API minor versions and are published automatically when a new +OpenAPI spec ships. + +**Beta releases** (PEP 440 suffix, e.g. `4.3.0b1`) may also include beta API operations that haven't graduated to GA. +They are published to PyPI but never installed by default; opt in explicitly: + +```shell +pip install meraki==4.3.0b1 +``` + +Both the beta API operations they expose and the SDK surfaces themselves may change in breaking ways between beta releases, without notice. Beta API operations are subject to unannounced change or removal upstream, so upgrading from a beta to a stable release can _remove_ operations that never reached GA. Use a beta release only if you need early-access API operations or unreleased SDK features and accept that trade-off. + +For the full versioning scheme, cadence, and SDK-to-API version mapping, see [VERSIONING.md](VERSIONING.md). ## Usage 1. Export your API key as an [environment variable](https://www.twilio.com/blog/2017/01/how-to-set-environment-variables.html), for example: - ```shell - export MERAKI_DASHBOARD_API_KEY=YOUR_KEY_HERE - ``` + ```shell + export MERAKI_DASHBOARD_API_KEY=YOUR_KEY_HERE + ``` -2. Alternatively, define your API key as a variable in your source code; this method is not recommended due to its - inherent insecurity. +2. Alternatively, define it as a variable in your source code (not recommended: it's insecure). -3. Single line of code to import and use the library goes at the top of your script: +3. Import the library at the top of your script: - ```python - import meraki - ``` + ```python + import meraki + ``` 4. Instantiate the client (API consumer class), optionally specifying any of the parameters available to set: - ```python - dashboard = meraki.DashboardAPI() - ``` + ```python + dashboard = meraki.DashboardAPI() + ``` 5. Make dashboard API calls in your source code, using the format _client.scope.operation_, where _client_ is the name you defined in the previous step (**dashboard** above), _scope_ is the corresponding scope that represents the first - tag from the OpenAPI spec, and _operation_ is the operation of the API endpoint. For example, to make a call to get - the list of organizations accessible by the API key defined in step 1, use this function call: + tag from the OpenAPI spec, and _operation_ is the operation ID from the OpenAPI spec. For example, to make a call to + get the list of organizations accessible by the API key defined in step 1, use this function call: - ```python - my_orgs = dashboard.organizations.getOrganizations() - ``` + ```python + my_orgs = dashboard.organizations.getOrganizations() + ``` ### Examples You can find fully working example scripts in the **examples** folder. | Script | Purpose | -|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **org_wide_clients.py** | That code collects the clients of all networks, in all orgs to which the key has access. No changes are made, since only GET endpoints are called, and the data is written to local CSV output files. | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **org_wide_clients.py** | That code collects the clients of all networks, in all orgs to which the key has access. No changes are made, since only GET operations are called, and the data is written to local CSV output files. | -## AsyncIO +### Keyword argument validation -**asyncio** is a library to write concurrent code using the **async/await** syntax. Special thanks to Heimo -Stieg ([@coreGreenberet](https://github.com/coreGreenberet)) who has ported the API to asyncio. +All optional parameters are passed as keyword arguments (`**kwargs`). By default, if you pass a misspelled or +unsupported kwarg, it is silently ignored. Enable `validate_kwargs` to log a warning when this happens: -### Installation on macOS +```python +dashboard = meraki.DashboardAPI(validate_kwargs=True) -If you use a Mac, then you may need to take -[additional Python installation steps](https://bugs.python.org/issue43404) that aren't required on other platforms. This -is [a limitation of macOS and not the library](https://github.com/meraki/dashboard-api-python/issues/226). This step is -not required on Windows. +# This will log a warning: "updateNetwork: ignoring unrecognized kwargs: ['nme']" +dashboard.networks.updateNetwork(networkId, nme="HQ") +``` -### Usage +This is off by default for backwards compatibility and zero performance overhead in production. -The usage is similiar to the sequential version above. However it has has some differences. +## Smart flow rate limiting -1. Export your API key as - an [environment variable](https://www.twilio.com/blog/2017/01/how-to-set-environment-variables.html), for example: +The Meraki API enforces two rate limits: **10 requests/second per organization** and **100 requests/second per source +IP**. Exceed either and you get `429` responses. The traditional approach is reactive: send too fast, get a 429, wait +for `Retry-After`, retry. That wastes round-trips, and every 429 you generate also eats into the budget shared by other +applications hitting the same org. - ```shell - export MERAKI_DASHBOARD_API_KEY=YOUR_KEY_HERE - ``` +Smart flow is **proactive**. Each org gets its own token bucket, so the SDK paces requests to stay under the limit +_before_ sending, turning 429-and-retry churn into steady throughput. It's **enabled by default** with no code changes +required. -2. Alternatively, define your API key as a variable in your source code; this method is not recommended due to its - inherent insecurity. +Benefits: -3. Single line of code to import and use the library goes at the top of your script: +- **Fewer 429s** — requests are throttled client-side instead of bouncing off the server +- **Faster overall** — no `Retry-After` wait cycles wasted on avoidable rate-limit errors +- **Fairer** — reserves headroom (default 9 of 10 req/s per org) so you don't starve other apps on the same org +- **Zero-config** — org membership is learned automatically from the URLs you already call, and cached to disk + (`~/.meraki/.cache/`) so subsequent runs skip the lookup - ```python - import meraki.aio - ``` +Tune it via kwargs on the client (all optional): -4. Instantiate the client (API consumer class), optionally specifying any of the parameters available to set: +```python +dashboard = meraki.DashboardAPI( + smart_flow_enabled=True, # default; set False to fall back to 429-retry only + smart_flow_org_rate=9, # max req/s per org (leaves 1 for other apps) + smart_flow_global_rate=100, # max req/s across all orgs (source-IP limit) + smart_flow_cache_mode="lazy", # "lazy" learns as you go; "eager" pre-fetches at init +) +``` - ```python - async with meraki.aio.AsyncDashboardAPI() as aiomeraki: - ``` - The **async with** statement is important here to make sure, that the client sessions will be closed after using the - api. +See [config.py](https://github.com/meraki/dashboard-api-python/blob/main/meraki/config.py) for the full set of smart +flow options and their defaults. + +## AsyncIO -5. Make dashboard API calls in your source code, using the format await _client.section.operation_, where _client_ is - the name you defined in the previous step (**aiomeraki** above), _section_ is the corresponding group (or tag from - the OpenAPI spec) from the [API docs](https://developer.cisco.com/meraki/api/#/rest), and _operation_ is the name (or - operation ID from OpenAPI) of the API endpoint. For example, to make a call to get the list of organizations - accessible by the API key defined in step 1, use this function call: +The library ships a fully async client (`meraki.aio.AsyncDashboardAPI`) using **async/await**, alongside the +synchronous client. Original async port by Heimo Stieg ([@coreGreenberet](https://github.com/coreGreenberet)). + +### Usage - ```python - my_orgs = await aiomeraki.organizations.getOrganizations() - ``` -6. Run everything inside an event loop. +Same as the synchronous client, with four differences: import `meraki.aio`, instantiate inside `async with`, `await` +each call, and run it all in an event loop. ```python import asyncio +import meraki.aio + + +async def main(): + # `async with` ensures the client's sessions are closed on exit + async with meraki.aio.AsyncDashboardAPI() as aiomeraki: + my_orgs = await aiomeraki.organizations.getOrganizations() + if __name__ == "__main__": - # replace my_async_entry_point with the name of your entry point method - asyncio.run(my_async_entry_point()) + asyncio.run(main()) ``` ### Examples You can find fully working example scripts in the **examples** folder. -| Script | Purpose | -|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **aio_org_wide_clients.py** | That code is a asyncio port from org_wide_clients.py and collects the clients of all -networks, in all orgs to which the key has access. No changes are made, since only GET endpoints are called, and the -data is written to local CSV output files. | -| **aio_ips2firewall.py** | That code will collect the source IP of security events and creates L7 firewall rules to -block them. `usage: aio_ips2firewall.py [-h] -o ORGANIZATIONS [ORGANIZATIONS ...] [-f FILTER] [-s] [-d DAYS]` | +| Script | Purpose | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **aio_org_wide_clients.py** | An asyncio port of org_wide_clients.py: collects the clients of all networks, in all orgs to which the key has access. No changes are made, since only GET operations are called, and data is written to local CSVs. | +| **aio_ips2firewall.py** | Collects the source IP of security events and creates L7 firewall rules to block them. `usage: aio_ips2firewall.py [-h] -o ORGANIZATIONS [ORGANIZATIONS ...] [-f FILTER] [-s] [-d DAYS]` | ## Note for application developers and ecosystem partners -We're so glad that you're leveraging our Python library. It's best practice to identify your application with every API -request that you make. You can easily do this automatically just by following the format defined -in [config.py](https://github.com/meraki/dashboard-api-python/blob/master/meraki/config.py) and passing the session +Identify your application with every API request by following the format defined +in [config.py](https://github.com/meraki/dashboard-api-python/blob/main/meraki/config.py) and passing the session kwarg: -``` Python +```Python MERAKI_PYTHON_SDK_CALLER ``` @@ -187,3 +225,32 @@ Unless you are an ecosystem partner, this identifier is optional. 1. If you are an ecosystem partner and you have questions about this requirement, please reach out to your ecosystem rep. 2. If you have any questions about the formatting, please ask your question by opening an issue in this repo. + +## Development + +This project uses [uv](https://docs.astral.sh/uv/) for dependency management and builds with +[Hatchling](https://hatch.pypa.io/). + +1. [Install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already. + +2. Install dev dependencies: + + uv sync + +3. Run tests: + + uv run pytest + +4. If you're working with the generator, install its additional dependencies: + + uv sync --group generator + +## Further documentation + +| Doc | Covers | +| ------------------------------------------ | ------------------------------------------------- | +| [CHANGELOG.md](CHANGELOG.md) | Release notes per version | +| [VERSIONING.md](VERSIONING.md) | Versioning scheme, GA vs beta, SDK-to-API mapping | +| [CONTRIBUTING.md](CONTRIBUTING.md) | How to contribute and add changelog fragments | +| [SECURITY.md](SECURITY.md) | Reporting security issues | +| [generator/README.md](generator/README.md) | Regenerating the library and Early Access usage | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..19b02ae4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 3.x | Yes | +| 2.x | No | +| < 2.0 | No | + +## Reporting a Vulnerability + +If you discover a security vulnerability in this library, please report it responsibly. + +**Do not open a public GitHub issue for security vulnerabilities.** + +Instead, use [GitHub's private vulnerability reporting](https://github.com/meraki/dashboard-api-python/security/advisories/new). This sends a private notification to the maintainers and lets us collaborate on a fix before public disclosure. + +Include: + +- Description of the vulnerability +- Steps to reproduce +- Affected versions +- Any potential mitigations you've identified + +## Scope + +This policy covers the `meraki` Python package published to PyPI. It does not cover the Meraki Dashboard API itself; for API-level security concerns, contact Cisco Meraki support directly. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..da075370 --- /dev/null +++ b/TODO.md @@ -0,0 +1,78 @@ +# TODO: Code Maturity Recommendations + +## Summary + +| Metric | Value | +|--------|-------| +| Core lines (non-generated) | ~1,180 | +| Unit tests | 211 | +| Coverage (core) | 95.8% | +| Cyclomatic complexity ceiling | 42 (async `_request`) | +| Python versions | 3.11-3.14 | + +The project is production-grade. Generated API stubs are correctly excluded from coverage. The remaining issues are concentrated in `rest_session.py` and its async mirror. + +--- + +## Parallel Work (DONE) + +All 8 parallel tasks completed. 211 tests pass, 95.8% coverage, no warnings. + +- [x] **Fix RuntimeWarning in async tests** (replaced `AsyncMock` with `_AwaitableValue` wrapper) +- [x] **Raise coverage floor from 60% to 90%** (`pyproject.toml`) +- [x] **Fix bare `print()` in production code** (now uses `self._logger.warning(...)`) +- [x] **Remove dead `get_pages` pass-through method** (confirmed dead, removed) +- [x] **Remove unused F401 import** (added `# noqa: F401`) +- [x] **Consolidate line-length standard** (ruff now uses 127 via `pyproject.toml`) +- [x] **Dependabot config updated** (changed to `"uv"` ecosystem) +- [x] **Add integration test for async client** (`tests/integration/test_async_dashboard_api.py`) + +--- + +## Linear Work (sequential, each step builds on the previous) + +Order matters. Later items depend on or conflict with earlier ones. + +### Stream 1: Complexity Reduction (do in order) + +1. [ ] **Reduce `AsyncRestSession._request` complexity (42)** + - File: `meraki/aio/rest_session.py:140` + - Extract status-code handlers into discrete methods (match 429, match 5xx, etc.) + - Sync version (`rest_session.py:212`, complexity 12) already partially does this with `handle_4xx_errors`; apply same pattern to async + +2. [ ] **Reduce `_get_pages_legacy` complexity (24 sync, 19 async)** + - Files: `rest_session.py:526`, `aio/rest_session.py:432` + - Split event-log-specific pagination logic into a helper or strategy + - The "append results depending on endpoint type" block (lines 607-630) is doing three unrelated things + +3. [ ] **Eliminate sync/async code duplication** + - `rest_session.py` (670 lines) and `aio/rest_session.py` (547 lines) share ~80% identical logic + - Do this AFTER complexity reduction so you're deduplicating clean code, not spaghetti + - Consider a shared base or code-generation approach (the generator already exists for API stubs) + +### Stream 2: Type Safety (do in order) + +1. [ ] **Add type annotations to core modules** + - `rest_session.py`, `aio/rest_session.py`, `__init__.py`, `exceptions.py` have zero type hints + - Enables `mypy --strict` or `pyright` in CI + +2. [ ] **Add `py.typed` marker** + - Signals to downstream consumers that the package supports type checking + - Only meaningful after annotations exist + +### Stream 3: Coverage Gap Fill (do after Stream 1) + +1. [ ] **Cover the 36 missing lines** + - `rest_session.py` (25 lines): mostly logger-guarded branches and simulate path + - `aio/rest_session.py` (6 lines): similar pattern + - `__init__.py` (5 lines): lines 146-151 (logging edge case) + - Wait until after complexity refactors so you're not writing tests for code that's about to move + +--- + +## Future (next major version consideration) + +- [ ] **Consider `httpx` to unify sync/async** + - `httpx` provides both sync and async from one client, eliminating the dual-session architecture + - Breaking change; only worth it at next major version + - Would obsolete Stream 1 step 3 (deduplication), so decide before investing there 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/docs/HTTPX-MIGRATION.md b/docs/HTTPX-MIGRATION.md new file mode 100644 index 00000000..9ee139e4 --- /dev/null +++ b/docs/HTTPX-MIGRATION.md @@ -0,0 +1,350 @@ +# HTTPX Migration Plan + +## Why Migrate? + +### The core problem: two libraries, one SDK + +This library maintains two HTTP backends (`requests` for sync, `aiohttp` for async) with ~80% duplicated logic across `rest_session.py` (670 lines) and `aio/rest_session.py` (547 lines). This duplication is the root cause of multiple concerns documented in `.planning/codebase/CONCERNS.md`: + +**Tech debt it directly eliminates:** + +- Sync/async code duplication (bugs must be fixed twice, inconsistencies accumulate) +- High cyclomatic complexity in `AsyncRestSession._request` (42), which exists partly because async required a full reimplementation +- Two pinned dependencies at risk of breaking changes (`requests<3`, `aiohttp<4`) + +**Bugs it fixes by replacement:** + +- Bare `except Exception` in async handler (replaces with typed `httpx.HTTPError`) +- Inconsistent error handling between sync (catches `requests.exceptions.RequestException`) and async (catches everything) +- Blocking `time.sleep()` call in async 4xx handler (`aio/rest_session.py:268`), which blocks the event loop during network-delete retry waits + +**Quality gaps it creates the opportunity to close:** + +- No type annotations in core modules (rewrite is the natural time to add them) +- Missing error path test coverage (new code gets new tests) +- Test mocking uses `responses` library (requests-only); migration to `respx` modernizes the test infra + +### Why httpx specifically? + +- Provides `httpx.Client` (sync) and `httpx.AsyncClient` (async) with an identical API surface +- After `await client.request()`, response body is already buffered; `.json()` is synchronous even on the async client (simplifies pagination logic) +- Same `verify=`, `timeout=` semantics as requests (minimal learning curve for contributors) +- `proxy=` as a simple string (matches current config model) +- `response.links` property parses Link headers identically to requests (same `{'next': {'url': '...'}}` dict format) +- Actively maintained, type-annotated from the start, HTTP/2 capable +- Industry momentum: FastAPI, Starlette, and most modern Python HTTP tooling default to or recommend httpx + +### What it does NOT solve + +These concerns remain and require separate work: + +- Adaptive retry strategy (app logic, not library choice) +- Pagination memory buffering (iterator pattern already exists) +- API key exposure risk (logging concern, unrelated to transport) +- OASv3 generator migration +- Request cancellation/OpenTelemetry integration (httpx has better primitives, but wiring them up is separate scope) + +--- + +## Phase 0: Integration Test Baseline + +Before touching HTTP code, capture a passing integration test run against the Meraki sandbox. This becomes the regression gate for all subsequent phases. + +- Run existing integration tests, record pass/fail state +- Document which endpoints are exercised +- This baseline validates that Phases 2-3 produce identical external behavior + +--- + +## Phase 1: Shared Utilities (additive, no breaking changes) + +**Create `meraki/http_utils.py`** with one library-agnostic function: + +### `encode_meraki_params(data) -> str | None` + +Replaces the monkey-patched `requests.models.RequestEncodingMixin._encode_params` (rest_session.py:41-107). Reimplements the custom array-of-objects encoding as a pure function using only `urllib.parse.urlencode`. + +Strategy: pre-encode params into a query string and append to the URL before passing to httpx (httpx has no monkey-patch hook for param encoding). + +Current behavior: +- Input: `{"param[]": [{"key_1": "value_1"}]}` +- Output: `param%5B%5Dkey_1=value_1` + +The existing impl uses `requests.utils.to_key_val_list` (just `.items()` on dicts) and `requests.compat.basestring` (just `str` in Python 3). Both are trivially replaceable. + +Note: `response.links` does NOT need a replacement utility. httpx provides `.links` with the same dict format as requests. + +--- + +## Phase 2: Session Base Class + +**Create `meraki/_session_base.py`** extracting shared logic from both session files: + +- All configuration storage (api_key, base_url, timeouts, retries, proxy, cert) +- Header construction +- URL resolution and validation +- Retry decision logic (`_should_retry_4xx`, `_get_retry_wait`) +- Param encoding dispatch (`_apply_params` calls `encode_meraki_params`) + +The two concrete session classes become thin I/O layers over this base. + +**Design decision for sync/async split:** The base class holds all decision logic (should we retry? how long to wait? what error to raise?) but does NOT hold the retry loop itself, because the loop calls `time.sleep()` (sync) vs `await asyncio.sleep()` (async). Each concrete class implements `_execute_with_retry` using the base's decision methods. This keeps the base simple and avoids abstract-method overhead. + +--- + +## Phase 3: Rewrite Sync Session + +**Rewrite `meraki/rest_session.py`** to use `httpx.Client`: + +| requests | httpx | +|----------|-------| +| `requests.session()` | `httpx.Client(headers=..., verify=..., proxy=..., timeout=..., follow_redirects=False)` | +| `session.request(method, url, allow_redirects=False, **kwargs)` | `self._client.request(method, url, **kwargs)` | +| `requests.exceptions.RequestException` | `httpx.HTTPError` | +| `response.reason` | `response.reason_phrase` | +| `response.links` | `response.links` (same API) | +| `verify=path` | `verify=path` (same) | +| `proxies={"https": url}` | `proxy=url` | +| `timeout=60` | `timeout=60` (same) | + +Key: params are pre-encoded into the URL via `_apply_params()`, so httpx never sees `params=`. + +**Important:** Remove the monkey-patch (`requests.models.RequestEncodingMixin._encode_params = encode_params` at line 107) in this same phase. If requests remains importable (e.g., generator scripts), the monkey-patch must not fire at SDK import time. + +--- + +## Phase 4: Rewrite Async Session + +**Rewrite `meraki/aio/rest_session.py`** to use `httpx.AsyncClient`: + +| aiohttp | httpx | +|---------|-------| +| `aiohttp.ClientSession(headers=..., timeout=aiohttp.ClientTimeout(...))` | `httpx.AsyncClient(headers=..., verify=..., proxy=..., timeout=..., follow_redirects=False)` | +| `response.status` | `response.status_code` | +| `await response.json(content_type=None)` | `response.json()` (sync after await on request) | +| `ssl=ssl_context` | `verify=path` (httpx handles SSLContext internally) | +| `proxy=url` (singular) | `proxy=url` (same) | +| `response.release()` | (delete, body already buffered) | +| `async with await self.request(...) as response:` | `response = await self.request(...)` | +| `response.links` | `response.links` (same API) | + +**Structural changes beyond the table:** + +- All 6 `async with await self.request(...) as response:` patterns (get, post, put, delete, _get_pages_legacy x2) become simple assignment. This is a pervasive rewrite, not a find-replace. +- `response.release()` calls in the async iterator are deleted (httpx buffers fully on await). +- `content_type=None` in `response.json()` calls (~10 occurrences) is dropped silently; httpx doesn't validate MIME type by default. + +The `asyncio.Semaphore` for concurrency control and `asyncio.create_task` for page pre-fetching remain unchanged. + +--- + +## Phase 5: Update Exceptions + +**Modify `meraki/exceptions.py`:** + +Current state: +- `APIError.__init__(metadata, response)` uses `response.status_code`, `response.reason` +- `AsyncAPIError.__init__(metadata, response, message)` uses `response.status`, `response.reason`, separate `message` param + +These have **different signatures and different attribute sources**. Unifying requires: + +1. Change `APIError`: + - `response.reason` -> `response.reason_phrase` + - `response.content` -> `response.content` (same in httpx) + +2. Change `AsyncAPIError`: + - `response.status` -> `response.status_code` + - `response.reason` -> `response.reason_phrase` + +3. Deprecation path for `AsyncAPIError`: + - Keep the class but make it a subclass of `APIError` with a compatibility `__init__` that accepts the old 3-arg signature + - Add a deprecation warning when instantiated directly + - Document in CHANGELOG that users should catch `APIError` for both sync and async in future versions + +--- + +## 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`:** + +```toml +dependencies = [ + "httpx>=0.28,<1", +] + +[dependency-groups] +dev = [ + "respx>=0.22,<1", # replaces 'responses' + # ... rest unchanged +] +``` + +Remove: `requests`, `aiohttp`, `responses` + +Note on upper pin: `<1` guards against httpx 1.0 API breaks (they've discussed backwards-incompatible changes for 1.0). Re-evaluate when 1.0 ships. + +**This phase MUST be atomic with Phases 3-4.** If `requests` remains installed while the new code is live, the old monkey-patch import path could still fire from stale .pyc caches or editable installs. + +--- + +## Phase 7: Update Tests + +| Test file | Change | +|-----------|--------| +| `tests/unit/test_rest_session.py` | Mock `httpx.Response` instead of `requests.Response`; `.reason` -> `.reason_phrase`; `.links` remains same API | +| `tests/unit/test_aio_rest_session.py` | Replace aiohttp mocks with httpx mocks; `.status` -> `.status_code`; remove `__aenter__`/`__aexit__` patterns; `.json()` no longer awaitable | +| `tests/unit/test_mock_integration.py` | Replace `responses` library with `respx` | +| Integration tests | Re-run baseline from Phase 0, confirm identical pass/fail | + +--- + +## Phase 8: Backwards Compatibility + +| Concern | Resolution | +|---------|------------| +| `requests_proxy` parameter name | Keep it. Pass through as `proxy=` internally. | +| `REQUESTS_PROXY` config constant | Keep it. Just a default value string. | +| `AsyncAPIError` class | Keep as deprecated subclass of `APIError`. | +| `_req_session` internal attribute | Add deprecation property mapping to `_client`. | + +--- + +## Phase 9: Decompose Request Logic + +The current `AsyncRestSession._request` has complexity 40 (4x industry ceiling). During rewrite, decompose into: + +| Method | Responsibility | +|--------|---------------| +| `_execute_with_retry` | Retry loop, attempt counting, backoff timing | +| `_handle_rate_limit` | 429 detection, Retry-After parsing, wait logic | +| `_handle_error_response` | 4xx/5xx classification, exception raising | +| `_log_request` | Request/response debug logging | + +Each method stays under complexity 10. Decision logic lives in the base class; sync/async layers differ only in sleep/request calls. + +--- + +## Phase 10: Type Annotations + +Type-annotate the new unified session base class and both thin I/O layers. This is the natural place to introduce typing since the code is being rewritten anyway, and the shared base class gives ~80% coverage for free. + +- All public methods get full signatures (params and return types) +- Use `httpx.Response` directly (no wrapper) +- Add `py.typed` marker (PEP 561) in the same commit + +--- + +## Phase 11: Property-Based Tests + +Add `hypothesis` property-based tests for the param encoding utility: + +| Function | Properties to verify | +|----------|---------------------| +| `encode_meraki_params` | Roundtrip: parsed output matches input structure; never produces bare `=` without key; handles empty dicts/lists; output is valid URL query string | + +Add `hypothesis` to dev dependencies in `pyproject.toml`. + +--- + +## Phase 12: Generator Scripts (optional, low priority) + +`generator/generate_library.py` and siblings use `requests.get()` at build-time to fetch OpenAPI specs. Not shipped to users. Can migrate separately or leave as dev-only dependency. + +--- + +## TODO.md Items Made Obsolete + +These items from TODO.md are eliminated or simplified by this migration: + +- **Stream 1, Step 3** (Eliminate sync/async duplication) - obsolete +- **Stream 1, Step 1** (Async `_request` complexity 42) - rewrite > refactor +- **Stream 1, Step 2** (`_get_pages_legacy` complexity) - rewrite > refactor +- **Stream 3** (Cover 36 missing lines) - those lines get replaced +- **Add integration test for async client** - one client = one test surface + +--- + +## Risk Mitigation + +- httpx sync responses are fully buffered (like requests). No behavior change. +- httpx async responses: body already read after `await client.request()`. Simplifies async code. +- Connection pooling: `Client`/`AsyncClient` maintain pools like `requests.Session`. +- Timeout: httpx default is 5s, but SDK explicitly sets 60s. No issue. +- Certificate verification: `verify="/path/to/cert.pem"` works identically. +- Proxy: `proxy="http://host:port"` as string. Direct pass-through. +- Monkey-patch removal: must happen atomically with requests removal to avoid import-time side effects on other packages. + +--- + +## Execution Order + +| Step | Risk | Validation | +|------|------|------------| +| Phase 0 (integration baseline) | None | Record current pass/fail state | +| Phase 1 (utilities) | None | Unit tests for encode function | +| Phase 2 (base class) | None | Unit tests for shared logic | +| Phase 3 (sync rewrite) | **High** | Full sync test suite passes | +| Phase 4 (async rewrite) | **High** | Full async test suite passes | +| Phase 5 (exceptions) | Medium | Error formatting matches expected output | +| Phase 6 (dependencies) | Medium | `pip install -e .` succeeds; atomic with 3-4 | +| Phase 7 (tests) | Medium | All tests green | +| Phase 8 (compat) | Low | Existing user code still works | +| Phase 9 (decompose) | Low | Complexity scores under 10 per method | +| Phase 10 (types) | Low | mypy passes | +| Phase 11 (property tests) | Low | hypothesis finds no violations | +| Phase 12 (generator) | None | Optional, dev-only | +| Integration gate | **Critical** | Live API tests pass against Meraki sandbox | diff --git a/docs/OASV3-MIGRATION.md b/docs/OASV3-MIGRATION.md new file mode 100644 index 00000000..6acc00c0 --- /dev/null +++ b/docs/OASV3-MIGRATION.md @@ -0,0 +1,215 @@ +# OASv3 Generator: Plan + +## Context + +The Meraki Dashboard API offers both OASv2 (`/openapiSpec`) and OASv3 (`/openapiSpec?version=3`) specs. The v3 spec is OpenAPI 3.0.1 and has features not expressible in v2. The current production generator (`generate_library.py`) only handles v2. An abandoned attempt (`generate_library_oasv3.py`) exists but is monolithic, missing key features, and never finished. Goal: build a proper v3 generator following the v2 generator's modular architecture. + +## OASv3 Features Not in v2 + +| Feature | v2 | v3 | Code-gen impact | +|---------|----|----|-----------------| +| `requestBody` separate from `parameters` | Body params in `parameters` with `"in": "body"` | Dedicated `requestBody.content["application/json"].schema` | Need separate parsing path, merge into same dict format | +| `$ref` references | Inline schemas only | `$ref` to `#/components/schemas/...` | Must resolve at parse time | +| `oneOf` query params | N/A | e.g. `startDate` can be string OR object with `lt/gt/lte/gte/neq` | New param type to handle | +| `nullable: true` | N/A | Properties can be nullable | Informational for docstrings | +| `parameter.schema` structure | `parameter.type` directly OR `parameter.schema.properties` for body | Always `parameter.schema.type` for path/query | Different extraction path | +| `components/schemas` | `definitions` (but Meraki v2 doesn't use it) | Reusable schemas referenced via `$ref` | Need resolver | +| `servers` array | `host` + `basePath` + `schemes` | `servers[].url` with variables | Informational only | + +## Approach + +Replace `generator/generate_library_oasv3.py` with a proper modular implementation following the v2 generator's structure. Reuse `common.py`, all jinja2 templates, and produce identical output format. Port useful parsing logic from the abandoned oasv3 file. + +## Deprecation Plan + +Once the v3 generator is fully vetted: + +1. **Parity gate**: CI drift detection (step 6 in Verification) must show zero semantic differences on the live spec for 2+ consecutive API releases +2. **Rename**: `generate_library.py` becomes `generate_library_oasv2.py` (deprecated, retained for rollback) +3. **Promote**: `generate_library_oasv3.py` becomes `generate_library.py` (new default) +4. **Update CI/automation**: all workflows, Makefile targets, and docs point to the new default +5. **Deprecation notice**: `generate_library_oasv2.py` gets a warning on import and a docstring noting it is unmaintained +6. **Removal**: delete `generate_library_oasv2.py` after one minor version cycle with no rollbacks + +## File Changes + +| Action | File | +|--------|------| +| CREATE | `generator/generate_library_oasv3.py` (replace existing abandoned file) | +| CREATE | `tests/generator/fixtures/synthetic_spec_oasv3.json` | +| CREATE | `tests/generator/test_generate_library_oasv3_golden.py` | +| CREATE | `tests/generator/golden_oasv3/meraki/api/networks.py` | +| CREATE | `tests/generator/golden_oasv3/meraki/aio/api/networks.py` | +| CREATE | `tests/generator/golden_oasv3/meraki/api/batch/networks.py` | + +## Implementation + +### 1. Core v3 Parsing Functions + +Port from abandoned oasv3 + enhance: + +```python +def resolve_ref(spec: dict, ref: str, _visiting: set | None = None) -> dict | None + # Follow #/components/schemas/... JSON pointer + # Circular ref protection: track visited refs, return None on cycle + # Cache resolved refs on spec["_ref_cache"] to avoid repeated traversal + +def get_schema_from_item(item: dict, spec: dict) -> dict | None + # Extract schema, resolve $ref if present + +def parse_request_body(operation: str, request_body: dict, spec: dict) -> dict + # Parse requestBody.content by content type: + # - application/json: parse schema.properties + # - multipart/form-data: parse schema.properties, mark file fields (format: binary) + # - application/octet-stream: single binary body param + # Warn and skip unsupported content types + # Return dict with "in": "body" markers (same format v2 produces) + +def resolve_oneof_type(schema: dict) -> tuple[str, str] + # Returns (type, extra_description) for oneOf schemas + # Prefers string over object; appends object property names to description +``` + +### 2. Unified `parse_params` (v3 signature) + +```python +def parse_params( + operation: str, + parameters: list | None, + request_body: dict | None, + spec: dict, + param_filters=None +) -> dict +``` + +Internally: + +1. Collect path-level parameters, then merge operation-level parameters (operation wins on name+in collision) +2. Parse path/query params from merged `parameters` list (using `param.schema.type`) +3. For array params, respect `style`/`explode` (default: `style: form`, `explode: true` per OAS3 spec) +4. Parse body params from `requestBody` via `parse_request_body()` +5. Merge into single dict +6. Add pagination params if `perPage` present +7. Filter via `return_params()` (ported unchanged from v2) + +### 3. HTTP-Method Parsers + +Same signatures as v2 but with `request_body` and `spec` threaded through: + +```python +def parse_get_params(operation, parameters, request_body, spec) +def parse_post_and_put_params(method, operation, parameters, request_body, spec) +def parse_delete_params(operation, parameters, request_body, spec) +``` + +### 4. Module Generation + +Reuse v2 structure exactly: + +- `generate_library()` - top-level orchestrator +- `generate_modules()` - iterate scopes, create files +- `generate_standard_and_async_functions()` - render function templates +- `generate_action_batch_functions()` - render batch templates +- `render_class_template()` - render class headers +- Use `common.organize_spec()` for scope organization +- Use `jinja_env.filters["to_double_quote_list"]` for tag rendering +- Use `ruff` for formatting at end + +### 5. `x-batchable-actions` Handling + +Present in v3 spec (298 entries). Same structure as v2: `{ group, summary, resource, operation }`. Matching logic unchanged (match `endpoint["description"]` against `action["summary"]`). + +```python +batchable_actions = spec["x-batchable-actions"] +``` + +Look up operation type from spec (not hardcoded), matching v2's behavior. + +### 6. `oneOf` Query Parameter Strategy + +These are optional query params, so they land in `**kwargs` (not the typed signature). For docstrings: + +- Report type as `"string or object"` (accurate, not lossy) +- Document the object sub-properties (e.g., `lt`, `gt`, `lte`, `gte`, `neq`) +- If one were ever required, use no type annotation (bare param name) rather than lying with `str` +- The `rest_session.py` `encode_params` already handles dict values passed as query params + +### 7. CLI and Spec Fetching + +Same args as v2: `-h`, `-o`, `-k`, `-v`, `-a`, `-g` + +```python +requests.get("https://api.meraki.com/api/v1/openapiSpec", params={"version": 3}) +``` + +### 8. Test Fixture (`synthetic_spec_oasv3.json`) + +v3 equivalent of the existing `synthetic_spec.json`: + +- Same endpoints (getNetworkClients, updateNetworkSettings, deleteNetwork) converted to OASv3 structure (requestBody, parameter.schema, etc.) +- Add one `oneOf` query param to exercise object-type query param handling +- Add one `$ref` usage in requestBody to exercise reference resolution +- Add a circular `$ref` to exercise cycle detection (should resolve to None gracefully) +- Add a `nullable: true` property to exercise nullable annotation +- Add one endpoint with `multipart/form-data` requestBody containing a `format: binary` field +- Add path-level parameters on one path to exercise inheritance/override logic +- Same `x-batchable-actions` + +Golden files will NOT match v2 golden output. They will reflect v3-specific output differences (richer docstrings from `oneOf` descriptions, nullable annotations, etc.). The golden files validate that the v3 generator produces correct output for v3 features, not that it matches v2 byte-for-byte. + +## Key Design Decisions + +1. **Resolve `$ref` at parse time with cycle protection** - downstream code gets normalized dicts, no template changes needed. Visited-set guard prevents infinite loops on circular refs; cache avoids redundant traversal. +2. **`oneOf` reported accurately** - type shown as `"string or object"` in docstrings; if ever required, no type annotation rather than a lie +3. **`nullable` affects type hints** - nullable params get `| None` in the function signature (e.g., `name: str | None`); also noted in docstring. Only v3.0 `nullable: true` style supported (not v3.1 `type: [string, null]`). +4. **New file, not edit of abandoned one** - cleaner modular structure, easier to maintain +5. **Thread `spec` param through all functions** - needed for `$ref` resolution anywhere in the tree +6. **Content-type awareness** - `parse_request_body` handles `application/json`, `multipart/form-data`, and `application/octet-stream`. Unsupported types emit a warning and are skipped (not silently dropped). +7. **Path-level parameter inheritance** - operation params override path-level params on name+in match, per OAS3 spec +8. **Array serialization** - respect `style`/`explode` attributes; default to `form`+`explode:true` per OAS3 spec +9. **Preserve vendor extensions** - carry all `x-` fields through (not just `x-batchable-actions`); downstream templates can access them + +## Code Generation Quality Improvements + +### Replace `kwargs.update(locals())` + +The current v2 generator emits `kwargs.update(locals())` in every method, which is implicit and leaks internal variables. The v3 generator should emit explicit parameter construction: + +```python +# Current (v2) - implicit, fragile +def getOrganizationNetworks(self, organizationId, total_pages=1, direction="next", **kwargs): + kwargs.update(locals()) + # ... + +# Target (v3) - explicit, type-safe +def getOrganizationNetworks(self, organizationId, total_pages=1, direction="next", **kwargs): + params = {k: v for k, v in kwargs.items() if k in query_params} + body = {k: v for k, v in kwargs.items() if k in body_params} + # ... +``` + +Update the Jinja2 templates (`api_function_template.jinja2`, `batch_function_template.jinja2`) to emit the explicit form. This eliminates the `locals()` antipattern and makes generated code compatible with static analysis tools. + +### Generate Type Stubs + +Produce `.pyi` stub files alongside each generated scope module: + +- `meraki/api/organizations.pyi` with full signatures (param names, types from OAS3 schema, return types) +- Enables downstream type checking without runtime cost +- OAS3 schema types map directly: `string` -> `str`, `integer` -> `int`, `boolean` -> `bool`, `array` -> `list`, `object` -> `dict` +- `oneOf` params get `Union[str, dict]` +- `nullable: true` params get `| None` +- Return types: `dict` for single-object, `list[dict]` for arrays, `Generator` for paginated iterators + +Add a `--stubs` flag to the generator CLI. Template: `api_stub_template.jinja2`. + +--- + +## Verification + +1. Run `python generator/generate_library_oasv3.py` against live v3 spec +2. Diff output against v2-generated library (should be structurally identical, minor description differences from nullable/oneOf annotations) +3. Run golden-file tests: `pytest tests/generator/test_generate_library_oasv3_golden.py` +4. Run existing test suite to confirm no regressions +5. Spot-check generated functions for correct param handling (especially endpoints with requestBody, arrays, enums) +6. **CI drift detection**: automated diff of v2 vs v3 generator output on the live spec (catches divergence between generators over time) diff --git a/docs/SMARTFLOW.md b/docs/SMARTFLOW.md new file mode 100644 index 00000000..ba0797c4 --- /dev/null +++ b/docs/SMARTFLOW.md @@ -0,0 +1,217 @@ +# Smart Flow — Rate Limiter Specification + +Language-agnostic spec for the Meraki proactive rate limiter. The Python +reference implementation is `meraki/smart_flow.py`; this document defines the +*behavior* so any implementation (Python, PowerShell, .NET, â€Ļ) can be verified +against the same conformance vectors in `smartflow-vectors.json`. + +Goal: prevent 429s before they happen by throttling per-organization request +rates locally, plus a global per-source-IP ceiling, learning which org each +network/device belongs to as traffic flows. + +--- + +## 1. Token bucket + +The primitive. One bucket = one rate limit. + +**State:** `rate` (tokens/sec), `capacity` (max tokens), `tokens` (current, float), +`last` (monotonic timestamp of last refill). + +**Init:** `tokens = capacity`, `last =` monotonic now at creation. +(The async reference lazily sets `last` on the first `acquire`; equivalent to +setting it at creation when no time passes before the first call.) + +**`acquire()`** — the whole algorithm, executed atomically per bucket: + +``` +now = monotonic() +elapsed = now - last +tokens = min(capacity, tokens + elapsed * rate) # refill +last = now +tokens = tokens - 1 # deduct unconditionally +wait = (-tokens / rate) if tokens < 0 else 0 # deficit -> sleep +if wait > 0: sleep(wait) +``` + +Critical properties, all load-bearing for parity: + +- **Deduct unconditionally; tokens may go negative.** The negative value *is* the + reservation. Concurrent callers each compute their own `wait` against the + accumulated deficit instead of colliding on the same instant. Do **not** clamp + tokens at zero. +- **Reserve under lock, sleep outside it.** Compute `wait` while holding the + per-bucket lock, release, then sleep. Serializing the sleeps destroys burst + parallelism. +- **`rate` has a floor of 0.5.** Setting `rate` to anything lower clamps to `0.5` + (prevents AIMD decrease from stalling a bucket to zero throughput). + +--- + +## 2. Rate defaults + +| Bucket | rate | capacity | +| --- | --- | --- | +| Per-org | `10.0` (SDK config default surfaces `9`) | `10` | +| Global (per source IP) | `100.0` | `100` (`int(global_rate)`) | + +Meraki enforces ~10 req/s per org and ~100 req/s per source IP. Per-org default +is set below the ceiling to reserve headroom for other apps. + +--- + +## 3. URL → identifier extraction + +Three regexes, searched (not full-match) against the request URL: + +| Identifier | Pattern | Capture | +| --- | --- | --- | +| org | `/organizations/([^/]+)` | org id | +| network | `/networks/([^/]+)` | network id | +| device | `/devices/([^/]+)` | serial | + +**`resolve_org(url)`** returns the first that resolves: + +1. org id directly from URL → return it +2. else network id from URL → look up `network_to_org[net]` (may be `None`) +3. else serial from URL → look up `serial_to_org[serial]` (may be `None`) +4. else `None` + +--- + +## 4. acquire(url) + +``` +global_bucket.acquire() # always, first +org = resolve_org(url) +if org: + bucket(org).acquire() # get-or-create org bucket +else: + resolve_inline(url) # sync ref; async fires a background task + org = resolve_org(url) + if org: bucket(org).acquire() +``` + +Global bucket is charged on **every** request regardless of org resolution. +An unresolved request pays only the global cost until its org is learned. + +**Sync vs async divergence (the one intentional difference):** +- Sync `resolve_inline` calls the resolver **inline and blocks**, so a freshly + resolved org's bucket is acquired *in the same call*. +- Async `_trigger_background_resolve` fires a one-shot background task and does + **not** block; the org bucket is acquired on *subsequent* calls once learned. + +An implementation without async (e.g. PowerShell sync) follows the sync path. + +--- + +## 5. AIMD rate adaptation + +Additive-increase / multiplicative-decrease on top of the static limits. + +**`on_rate_limited(url)`** (called on a 429): + +``` +org = resolve_org(url) +if org has a bucket: + bucket.rate *= 0.7 # multiplicative decrease +elif url is an "unresolved scoped url": + skip # do NOT penalize global +else: + global_bucket.rate *= 0.7 +``` + +**Unresolved scoped URL** = URL has a network or device component but **no** +explicit `/organizations/`. The offending org is specific but unknown; +penalizing the global bucket would punish every other org for one org's 429, so +skip and let resolution catch up. + +**`on_success(url)`** (called on a 2xx): + +``` +org = resolve_org(url) +if org has a bucket and bucket.rate < configured_org_rate: + bucket.rate = min(configured_org_rate, bucket.rate + 0.2) # additive increase +if global_bucket.rate < configured_global_rate: + global_bucket.rate = min(configured_global_rate, global_bucket.rate + 0.5) +``` + +Increments: **+0.2** per-org, **+0.5** global. Decrease factor: **×0.7** both. +Remember the `0.5` rate floor from §1 bounds the decrease. + +--- + +## 6. Learning org mappings + +**`learn_from_response(url, body)`** — called on successful GET responses to +populate the net→org / serial→org caches. + +Resolve the governing org first: +1. org id from URL (`/organizations/`), else +2. org id from body: `body.organizationId`, else `body.organization.id` +3. if neither → return (nothing learned) + +Then record mappings (only counting ones that *change* an existing value): + +- network id from **URL** → `network_to_org` +- serial from **URL** → `serial_to_org` +- from **body**: `body.networkId`, `body.serial`, `body.network.id` + +Each changed mapping increments a `dirty` counter (see §7). + +Explicit registration API (used by eager hydration / resolver callbacks): +`register_org(org)`, `register_network(net, org)`, `register_device(serial, org)`. + +--- + +## 7. Disk cache + +Persists learned mappings across sessions so warm runs skip re-learning. + +**Format** (`rate_limit_cache.json`, default `~/.meraki/.cache/`): + +```json +{ + "saved_at": "2026-07-08T12:00:00Z", + "networks": [{ "id": "N_1", "organization": { "id": "O_1" } }], + "devices": [{ "serial": "Q2XX-XXXX-XXXX", "organization": { "id": "O_1" } }] +} +``` + +**`saved_at`** is UTC, format `%Y-%m-%dT%H:%M:%SZ` (trailing `Z`, no offset). + +**Load / freshness:** +- No file → start empty. +- TTL default `604800` s (7 days). `None` = never expire. +- Expired if `saved_at` missing, unparseable, or `now - saved_at > ttl` → + ignore file, rebuild. `cache_fresh` stays false. +- On fresh load, populate both maps; set `cache_fresh = true`. +- Malformed JSON / missing keys / IO error → swallow, start empty. + +**Flush cadence:** when `dirty >= 50`, save and reset the counter. Async saves on +a background thread and only subtracts the flushed count on success (unsaved +mappings are retained, not lost, if the write fails). Async `shutdown()` drains +in-flight resolves + pending flush, then does a final save. + +--- + +## 8. Resolver & hydrator callbacks + +Hooks the SDK wires in so the limiter can fill its cache without knowing about +HTTP: + +- **resolver** `(id_type, identifier) -> org_id | None`, `id_type ∈ {"network","device"}`. + Called for an unresolved id. Guarded by a `pending_lookups` set so the same id + isn't resolved twice concurrently. +- **hydrator** `(org_id) -> void`. Called **once per org** (tracked in + `hydrated_orgs`) after first resolution, to bulk-register all of that org's + networks/devices via the register APIs. + +--- + +## Conformance + +`smartflow-vectors.json` encodes deterministic cases for each section above +(bucket math with injected timestamps, URL resolution, AIMD, learning, cache +freshness). An implementation is conformant if it reproduces every vector's +expected output. See that file's `notes` field per case. diff --git a/docs/generation-report.md b/docs/generation-report.md new file mode 100644 index 00000000..3196bdd3 --- /dev/null +++ b/docs/generation-report.md @@ -0,0 +1,80 @@ +# Generation Report + +## 2026-07-16 | Library v4.3.1 | 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-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/smartflow-vectors.json b/docs/smartflow-vectors.json new file mode 100644 index 00000000..f2245270 --- /dev/null +++ b/docs/smartflow-vectors.json @@ -0,0 +1,230 @@ +{ + "$comment": "Conformance vectors for the Smart Flow rate limiter. See SMARTFLOW.md. All time is injected (monotonic seconds / epoch seconds) so cases are deterministic; no wall-clock is read. An implementation is conformant if it reproduces every expected output. Floats compared with tolerance 1e-9.", + "version": 1, + + "token_bucket": { + "$comment": "rate in tokens/sec, capacity in tokens. Each step calls acquire() at the given monotonic 'now'. tokens_after and wait are the post-acquire state. See SMARTFLOW.md section 1.", + "cases": [ + { + "name": "single_acquire_full_bucket", + "rate": 10.0, "capacity": 10.0, "init_tokens": 10.0, "init_last": 0.0, + "steps": [ + { "now": 0.0, "tokens_after": 9.0, "wait": 0.0 } + ], + "notes": "elapsed 0, no refill, deduct 1 from full bucket, no wait." + }, + { + "name": "burst_beyond_capacity_goes_negative", + "rate": 10.0, "capacity": 10.0, "init_tokens": 10.0, "init_last": 0.0, + "steps": [ + { "now": 0.0, "tokens_after": 9.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 8.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 7.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 6.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 5.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 4.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 3.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 2.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 1.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": 0.0, "wait": 0.0 }, + { "now": 0.0, "tokens_after": -1.0, "wait": 0.1 }, + { "now": 0.0, "tokens_after": -2.0, "wait": 0.2 }, + { "now": 0.0, "tokens_after": -3.0, "wait": 0.3 }, + { "now": 0.0, "tokens_after": -4.0, "wait": 0.4 }, + { "now": 0.0, "tokens_after": -5.0, "wait": 0.5 } + ], + "notes": "Deduct unconditionally; deficit is the reservation. 11th call waits 0.1s, each subsequent +0.1s. Do NOT clamp at zero." + }, + { + "name": "refill_after_elapsed_time", + "rate": 10.0, "capacity": 10.0, "init_tokens": -5.0, "init_last": 0.0, + "steps": [ + { "now": 1.0, "tokens_after": 4.0, "wait": 0.0 } + ], + "notes": "1s elapsed refills 1*10=10 tokens: -5+10=5, capped at capacity 10 (not exceeded here), deduct 1 -> 4." + }, + { + "name": "refill_clamped_to_capacity", + "rate": 10.0, "capacity": 10.0, "init_tokens": 5.0, "init_last": 0.0, + "steps": [ + { "now": 100.0, "tokens_after": 9.0, "wait": 0.0 } + ], + "notes": "Huge elapsed would add 1000 tokens but refill is min(capacity, ...) = 10, then deduct 1 -> 9." + }, + { + "name": "global_bucket_defaults", + "rate": 100.0, "capacity": 100.0, "init_tokens": 100.0, "init_last": 0.0, + "steps": [ + { "now": 0.0, "tokens_after": 99.0, "wait": 0.0 } + ], + "notes": "Global per-source-IP bucket: rate 100, capacity int(100)=100." + } + ] + }, + + "rate_setter_floor": { + "$comment": "Setting rate clamps to a floor of 0.5. SMARTFLOW.md section 1.", + "cases": [ + { "name": "above_floor", "set_rate": 3.0, "expected_rate": 3.0 }, + { "name": "at_floor", "set_rate": 0.5, "expected_rate": 0.5 }, + { "name": "below_floor", "set_rate": 0.2, "expected_rate": 0.5 }, + { "name": "zero_clamped", "set_rate": 0.0, "expected_rate": 0.5 } + ] + }, + + "resolve_org": { + "$comment": "resolve_org(url) given a cache of network_to_org / serial_to_org. SMARTFLOW.md section 3.", + "network_to_org": { "N_1": "O_2" }, + "serial_to_org": { "Q2XX-XXXX-XXXX": "O_3" }, + "cases": [ + { "name": "explicit_org", "url": "/organizations/O_1/networks", "expected_org": "O_1" }, + { "name": "network_cached", "url": "/networks/N_1/clients", "expected_org": "O_2" }, + { "name": "network_uncached", "url": "/networks/N_9/clients", "expected_org": null }, + { "name": "device_cached", "url": "/devices/Q2XX-XXXX-XXXX/lldpCdp", "expected_org": "O_3" }, + { "name": "device_uncached", "url": "/devices/Q2ZZ-ZZZZ-ZZZZ/clients", "expected_org": null }, + { "name": "org_precedence_over_net", "url": "/organizations/O_1/networks/N_1", "expected_org": "O_1" }, + { "name": "no_identifier", "url": "/administered/identities/me", "expected_org": null } + ] + }, + + "is_unresolved_scoped_url": { + "$comment": "Purely URL-structural: true if a network/device component is present AND no explicit /organizations/. Independent of cache. SMARTFLOW.md section 5.", + "cases": [ + { "name": "org_url", "url": "/organizations/O_1/networks", "expected": false }, + { "name": "org_and_network", "url": "/organizations/O_1/networks/N_1", "expected": false }, + { "name": "network_only", "url": "/networks/N_9/clients", "expected": true }, + { "name": "device_only", "url": "/devices/Q2ZZ-ZZZZ-ZZZZ/clients", "expected": true }, + { "name": "no_scope", "url": "/administered/identities/me", "expected": false } + ] + }, + + "aimd": { + "$comment": "on_rate_limited multiplies rate by 0.7; on_success adds 0.2 (org) / 0.5 (global) capped at configured rate. Rate floor 0.5 applies via setter. SMARTFLOW.md section 5.", + "cases": [ + { + "name": "org_decrease", + "op": "on_rate_limited", "target": "org", "start_rate": 10.0, "expected_rate": 7.0, + "notes": "10 * 0.7 = 7.0" + }, + { + "name": "org_increase_below_cap", + "op": "on_success", "target": "org", "configured_rate": 10.0, "start_rate": 7.0, "expected_rate": 7.2, + "notes": "min(10, 7 + 0.2) = 7.2" + }, + { + "name": "org_increase_hits_cap", + "op": "on_success", "target": "org", "configured_rate": 10.0, "start_rate": 9.95, "expected_rate": 10.0, + "notes": "min(10, 9.95 + 0.2) = 10.0 (clamped)" + }, + { + "name": "org_increase_noop_at_cap", + "op": "on_success", "target": "org", "configured_rate": 10.0, "start_rate": 10.0, "expected_rate": 10.0, + "notes": "rate not < configured, no change" + }, + { + "name": "global_decrease", + "op": "on_rate_limited", "target": "global", "start_rate": 100.0, "expected_rate": 70.0, + "notes": "resolves to no known org and not an unresolved-scoped url -> global penalized. 100 * 0.7 = 70." + }, + { + "name": "global_increase_below_cap", + "op": "on_success", "target": "global", "configured_rate": 100.0, "start_rate": 70.0, "expected_rate": 70.5, + "notes": "min(100, 70 + 0.5) = 70.5" + }, + { + "name": "decrease_respects_floor", + "op": "on_rate_limited", "target": "org", "start_rate": 0.6, "expected_rate": 0.5, + "notes": "0.6 * 0.7 = 0.42, clamped up to floor 0.5 by the rate setter." + }, + { + "name": "rate_limited_unresolved_scoped_skips_global", + "op": "on_rate_limited", "target": "global", "url": "/networks/N_9/clients", + "org_resolvable": false, "start_rate": 100.0, "expected_rate": 100.0, + "notes": "Unresolved network/device url: org unknown but specific; global bucket must NOT be penalized. No change." + } + ] + }, + + "learn_from_response": { + "$comment": "Given url + body, which mappings change. org resolved from url first, else body.organizationId, else body.organization.id. Only changed mappings count. SMARTFLOW.md section 6.", + "cases": [ + { + "name": "org_and_network_from_url", + "url": "/organizations/O_1/networks/N_1", "body": {}, + "expected_network_to_org": { "N_1": "O_1" }, "expected_serial_to_org": {}, + "changed_networks": 1, "changed_devices": 0 + }, + { + "name": "org_and_device_from_url", + "url": "/organizations/O_1/devices/Q2AA-AAAA-AAAA", "body": {}, + "expected_network_to_org": {}, "expected_serial_to_org": { "Q2AA-AAAA-AAAA": "O_1" }, + "changed_networks": 0, "changed_devices": 1 + }, + { + "name": "org_from_body_organizationId", + "url": "/networks/N_1/clients", "body": { "organizationId": "O_5" }, + "expected_network_to_org": { "N_1": "O_5" }, "expected_serial_to_org": {}, + "changed_networks": 1, "changed_devices": 0 + }, + { + "name": "org_from_body_organization_id", + "url": "/networks/N_2/clients", "body": { "organization": { "id": "O_6" } }, + "expected_network_to_org": { "N_2": "O_6" }, "expected_serial_to_org": {}, + "changed_networks": 1, "changed_devices": 0 + }, + { + "name": "nested_network_id_from_body", + "url": "/organizations/O_1/somethingElse", "body": { "network": { "id": "N_3" } }, + "expected_network_to_org": { "N_3": "O_1" }, "expected_serial_to_org": {}, + "changed_networks": 1, "changed_devices": 0 + }, + { + "name": "body_serial_and_networkId", + "url": "/organizations/O_1/x", "body": { "networkId": "N_4", "serial": "Q2BB-BBBB-BBBB" }, + "expected_network_to_org": { "N_4": "O_1" }, "expected_serial_to_org": { "Q2BB-BBBB-BBBB": "O_1" }, + "changed_networks": 1, "changed_devices": 1 + }, + { + "name": "no_org_anywhere_noop", + "url": "/administered/identities/me", "body": {}, + "expected_network_to_org": {}, "expected_serial_to_org": {}, + "changed_networks": 0, "changed_devices": 0 + }, + { + "name": "unchanged_mapping_not_counted", + "url": "/organizations/O_1/networks/N_1", "body": {}, + "preexisting_network_to_org": { "N_1": "O_1" }, + "expected_network_to_org": { "N_1": "O_1" }, "expected_serial_to_org": {}, + "changed_networks": 0, "changed_devices": 0, + "notes": "Mapping already equals O_1, so no change is counted (dirty not incremented)." + } + ] + }, + + "cache_freshness": { + "$comment": "Load-time freshness decision. now and saved_ts are epoch seconds (injected). ttl in seconds; null = never expire. SMARTFLOW.md section 7.", + "cases": [ + { "name": "fresh_within_ttl", "now": 1000000.0, "saved_ts": 999000.0, "ttl": 604800.0, "expected_fresh": true, + "notes": "age 1000s < ttl -> load, cache_fresh true." }, + { "name": "expired_past_ttl", "now": 2000000.0, "saved_ts": 1000000.0, "ttl": 604800.0, "expected_fresh": false, + "notes": "age 1,000,000s > 604800 -> ignore file, rebuild." }, + { "name": "saved_at_missing", "now": 1000000.0, "saved_ts": null, "ttl": 604800.0, "expected_fresh": false, + "notes": "No/unparseable saved_at -> expired." }, + { "name": "ttl_none_never_expires", "now": 9999999999.0, "saved_ts": 1.0, "ttl": null, "expected_fresh": true, + "notes": "ttl None disables expiry regardless of age." }, + { "name": "exactly_at_ttl_boundary", "now": 604800.0, "saved_ts": 0.0, "ttl": 604800.0, "expected_fresh": true, + "notes": "age == ttl is NOT > ttl, so still fresh (boundary inclusive)." } + ] + }, + + "saved_at_format": { + "$comment": "saved_at is UTC strftime %Y-%m-%dT%H:%M:%SZ. Parser returns epoch seconds or null. SMARTFLOW.md section 7.", + "cases": [ + { "name": "valid", "value": "2026-07-08T12:00:00Z", "expected_epoch": 1783512000.0 }, + { "name": "missing_z", "value": "2026-07-08T12:00:00", "expected_epoch": null }, + { "name": "with_offset", "value": "2026-07-08T12:00:00+00:00", "expected_epoch": null }, + { "name": "not_a_string", "value": 12345, "expected_epoch": null }, + { "name": "garbage", "value": "not-a-date", "expected_epoch": null } + ] + } +} 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/docs/superpowers/plans/2026-07-15-write-query-parameters.md b/docs/superpowers/plans/2026-07-15-write-query-parameters.md new file mode 100644 index 00000000..ef79be22 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-write-query-parameters.md @@ -0,0 +1,218 @@ +# Write-operation Query Parameters Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve OpenAPI query parameters on generated POST, PUT, and PATCH methods, including action-batch methods. + +**Architecture:** The generator will collect query parameters independently of the HTTP method. Standard and asynchronous methods will pass them through trailing-compatible session helper arguments; batch methods will encode them into the action resource with the already-installed `httpx` dependency. + +**Tech Stack:** Python 3.11+, Jinja2, httpx, pytest + +## Global Constraints + +- Add exactly one regression test. +- Cover standard, asynchronous, and action-batch generation. +- Do not add endpoint-specific exceptions or regenerate generated API modules. +- Do not add a dependency or perform unrelated refactoring. + +--- + +### Task 1: Generate and transport query parameters on write operations + +**Files:** +- Modify: `tests/generator/test_generator_audit.py` +- Modify: `generator/generate_library.py` +- Modify: `generator/batch_function_template.jinja2` +- Modify: `generator/batch_class_template.jinja2` +- Modify: `meraki/session/sync.py` +- Modify: `meraki/session/async_.py` + +**Interfaces:** +- Consumes: OpenAPI parameters where `in == "query"`, existing Jinja templates, and existing `SessionBase.request(..., **kwargs)` support for `params`. +- Produces: `RestSession.post/put/patch(metadata, url, json=None, params=None)`, matching async signatures, generated write calls using `params=params`, and batch action resources containing the encoded query string. + +- [ ] **Step 1: Add the single failing regression test** + +Append this synthetic spec and test before the module's `if __name__ == "__main__"` block in `tests/generator/test_generator_audit.py`: + +```python +def _write_query_spec(): + return { + "paths": { + "/things": { + "post": { + "operationId": "claimThings", + "tags": ["organizations"], + "summary": "Claim things", + "parameters": [ + { + "name": "validate", + "in": "query", + "required": False, + "schema": {"type": "boolean"}, + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "description": "Thing name"} + }, + } + } + } + }, + } + } + } + } + + +def test_write_operations_generate_query_params_for_all_clients(): + clear_cache() + spec = _write_query_spec() + section = {"/things": {"post": spec["paths"]["/things"]["post"]}} + output = io.StringIO() + async_output = io.StringIO() + batch_output = io.StringIO() + + generate_library.generate_standard_and_async_functions( + _jinja_env(), TEMPLATE_DIR, section, output, async_output, spec + ) + generate_library.generate_action_batch_functions( + _jinja_env(), + TEMPLATE_DIR, + section, + batch_output, + [{"summary": "Claim things", "operation": "create"}], + spec, + ) + + for rendered in (output.getvalue(), async_output.getvalue()): + assert 'query_params = ["validate", ]' in rendered + assert "self._session.post(metadata, resource, payload, params=params)" in rendered + + batch_rendered = batch_output.getvalue() + assert 'query_params = ["validate", ]' in batch_rendered + assert 'resource += f"?{httpx.QueryParams(params)}"' in batch_rendered +``` + +- [ ] **Step 2: Run the regression test and verify RED** + +Run: + +```powershell +uv run pytest tests/generator/test_generator_audit.py::test_write_operations_generate_query_params_for_all_clients -q +``` + +Expected: FAIL because generated POST methods do not define query parameters or pass `params`, and batch resources do not contain a query string. + +- [ ] **Step 3: Collect query parameters for all generated methods** + +In `generate_standard_and_async_functions`, initialize query parameters before method dispatch and restrict array handling to query arrays: + +```python +query_params = return_params(operation, all_params, ["query"]) +array_params = {k: v for k, v in query_params.items() if v["type"] == "array"} +body_params = path_params = {} +``` + +Leave each method branch responsible only for `body_params` and `path_params`. For POST, PUT, and PATCH, build the call without changing body-only output: + +```python +args = "metadata, resource" +if body_params: + args += ", payload" +if query_params: + args += ", params=params" +call_line = f"return self._session.{method}({args})" +``` + +Apply the same query and query-array initialization in `generate_action_batch_functions` before its method-specific body handling. + +- [ ] **Step 4: Encode batch query parameters in the resource** + +Add the existing runtime dependency to `generator/batch_class_template.jinja2`: + +```python +import urllib + +import httpx +``` + +After query-array normalization in `generator/batch_function_template.jinja2`, append query parameters to the batch resource: + +```jinja2 +{% if query_params|length > 0 %} +resource += f"?{httpx.QueryParams(params)}" + +{% endif %} +``` + +- [ ] **Step 5: Pass query parameters through synchronous session helpers** + +Change POST, PUT, and PATCH in `meraki/session/sync.py` without breaking existing positional JSON callers: + +```python +def post(self, metadata, url, json=None, params=None): + metadata["method"] = "POST" + metadata["url"] = url + metadata["params"] = params + metadata["json"] = json + response = self.request(metadata, "POST", url, params=params, json=json) +``` + +Apply the same trailing `params=None`, metadata assignment, and request keyword to `put` and `patch`. + +- [ ] **Step 6: Pass query parameters through asynchronous session helpers** + +Make the equivalent changes in `meraki/session/async_.py`: + +```python +async def post(self, metadata, url, json=None, params=None): + metadata["method"] = "POST" + metadata["url"] = url + metadata["params"] = params + metadata["json"] = json + response = await self.request(metadata, "POST", url, params=params, json=json) +``` + +Apply the same trailing argument and propagation to async `put` and `patch`. + +- [ ] **Step 7: Run focused tests and verify GREEN** + +Run: + +```powershell +uv run pytest tests/generator/test_generator_audit.py tests/unit/test_rest_session.py tests/unit/test_aio_rest_session.py -q +``` + +Expected: all tests pass. + +- [ ] **Step 8: Run formatting and the full test suite** + +Run: + +```powershell +uv run ruff format --check generator meraki tests +uv run ruff check generator meraki tests +uv run pytest -q +``` + +Expected: all commands exit 0 with no formatting, lint, or test failures. + +- [ ] **Step 9: Review and commit** + +Run: + +```powershell +git diff --check +git diff --stat +git add generator/generate_library.py generator/batch_function_template.jinja2 generator/batch_class_template.jinja2 meraki/session/sync.py meraki/session/async_.py tests/generator/test_generator_audit.py docs/superpowers/plans/2026-07-15-write-query-parameters.md +git commit -m "fix: preserve query params on write operations" +``` + +Expected: one implementation commit containing the generator, runtime transport, regression test, and plan. diff --git a/docs/superpowers/specs/2026-07-15-write-query-parameters-design.md b/docs/superpowers/specs/2026-07-15-write-query-parameters-design.md new file mode 100644 index 00000000..f29b5f9d --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-write-query-parameters-design.md @@ -0,0 +1,21 @@ +# Write-operation query parameters + +## Problem + +The generator documents query parameters on POST, PUT, and PATCH operations but only serializes request-body parameters. Those query parameters are silently dropped. Action-batch methods have the same omission because their action resource never receives a query string. + +## Design + +- Collect query parameters for every generated HTTP method. +- Pass both `params` and `json` through synchronous and asynchronous POST, PUT, and PATCH session helpers. +- For action batches, append encoded query parameters to the action `resource`, because the action format has no separate query-parameter field. +- Keep existing GET and DELETE behavior unchanged. +- Keep keyword validation aware of both query and body parameters. + +## Test + +Add one hermetic generator regression test using a synthetic POST operation with a boolean query parameter and a JSON body property. The test will verify that standard and asynchronous generated clients pass both `params` and `payload`, and that the batch client includes the encoded query parameter in its resource. + +## Scope + +No endpoint-specific exceptions, generated API regeneration, new dependency, or unrelated refactoring is included. diff --git a/examples/aio_get_pages_iterator.py b/examples/aio_get_pages_iterator.py index 1f731cf1..d4bd6659 100644 --- a/examples/aio_get_pages_iterator.py +++ b/examples/aio_get_pages_iterator.py @@ -15,21 +15,21 @@ def timeit(func): async def process(func, *args, **params): if asyncio.iscoroutinefunction(func): - print('this function is a coroutine: {}'.format(func.__name__)) + print("this function is a coroutine: {}".format(func.__name__)) return await func(*args, **params) else: - print('this is not a coroutine') + print("this is not a coroutine") return func(*args, **params) async def helper(*args, **params): - print('{}.time'.format(func.__name__)) + print("{}.time".format(func.__name__)) start = time.time() result = await process(func, *args, **params) # Test normal function route... # result = await process(lambda *a, **p: print(*a, **p), *args, **params) - print('>>>', time.time() - start) + print(">>>", time.time() - start) return result return helper @@ -38,8 +38,9 @@ async def helper(*args, **params): @timeit async def getNetworksLegacy(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5): count = 0 - for x in await aiomeraki.organizations.getOrganizationNetworks(organizationId=ORGANIZATION_ID, perPage=perPage, - total_pages=-1): + for x in await aiomeraki.organizations.getOrganizationNetworks( + organizationId=ORGANIZATION_ID, perPage=perPage, total_pages=-1 + ): print(f"{x['id']} - {x['name']}") count = count + 1 print(f"Found {count} networks") @@ -48,8 +49,9 @@ async def getNetworksLegacy(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5): @timeit async def getNetworksIterator(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5): count = 0 - async for x in aiomeraki.organizations.getOrganizationNetworks(organizationId=ORGANIZATION_ID, perPage=perPage, - total_pages=-1): + async for x in aiomeraki.organizations.getOrganizationNetworks( + organizationId=ORGANIZATION_ID, perPage=perPage, total_pages=-1 + ): print(f"{x['id']} - {x['name']}") count = count + 1 print(f"Found {count} networks") @@ -58,8 +60,9 @@ async def getNetworksIterator(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5 @timeit async def getNetworkEventsLegacy(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5): count = 0 - result = await aiomeraki.networks.getNetworkEvents(networkId=NETWORK_ID, perPage=perPage, total_pages=50, - productType="wireless") + result = await aiomeraki.networks.getNetworkEvents( + networkId=NETWORK_ID, perPage=perPage, total_pages=50, productType="wireless" + ) for x in result["events"]: print(f"{x['occurredAt']}") count = count + 1 @@ -69,31 +72,34 @@ async def getNetworkEventsLegacy(aiomeraki: meraki.aio.AsyncDashboardAPI, perPag @timeit async def getNetworkEventsIterator(aiomeraki: meraki.aio.AsyncDashboardAPI, perPage=5): count = 0 - async for x in aiomeraki.networks.getNetworkEvents(networkId=NETWORK_ID, perPage=perPage, total_pages=50, - productType="wireless"): + async for x in aiomeraki.networks.getNetworkEvents( + networkId=NETWORK_ID, perPage=perPage, total_pages=50, productType="wireless" + ): print(f"{x['occurredAt']}") count = count + 1 print(f"Found {count} events") async def main(): - parser = argparse.ArgumentParser(description='Example for demonstrating the use_iterator_for_get_pages parameter') + argparse.ArgumentParser( + description="Example for demonstrating the use_iterator_for_get_pages parameter" + ) # Instantiate a Meraki dashboard API session # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage async with meraki.aio.AsyncDashboardAPI( + api_key, + base_url="https://api.meraki.com/api/v1", + log_file_prefix=__file__[:-3], + print_console=True, + use_iterator_for_get_pages=True, + ) as aiomeraki_iterator: + async with meraki.aio.AsyncDashboardAPI( api_key, base_url="https://api.meraki.com/api/v1", log_file_prefix=__file__[:-3], - print_console=True, - use_iterator_for_get_pages=True - ) as aiomeraki_iterator: - async with meraki.aio.AsyncDashboardAPI( - api_key, - base_url="https://api.meraki.com/api/v1", - log_file_prefix=__file__[:-3], - print_console=False, - use_iterator_for_get_pages=False + print_console=False, + use_iterator_for_get_pages=False, ) as aiomeraki_legacy: pass @@ -105,10 +111,18 @@ async def main(): print("Test iterator") await getNetworksIterator(aiomeraki_iterator) - print("-----------------------------------------------------------------------") - print("-----------------------------------------------------------------------") - print("-----------------------------------------------------------------------") - print("-----------------------------------------------------------------------") + print( + "-----------------------------------------------------------------------" + ) + print( + "-----------------------------------------------------------------------" + ) + print( + "-----------------------------------------------------------------------" + ) + print( + "-----------------------------------------------------------------------" + ) print("Test legacy") await getNetworkEventsLegacy(aiomeraki_legacy) diff --git a/examples/aio_ips2firewall_v1.py b/examples/aio_ips2firewall_v1.py index 51d27eb5..10448033 100644 --- a/examples/aio_ips2firewall_v1.py +++ b/examples/aio_ips2firewall_v1.py @@ -20,21 +20,31 @@ def removeSmallAmounts(ip_counts: Dict[str, int], filter: int): return ret -async def analyzeOrganization(aiomeraki: meraki.aio.AsyncDashboardAPI, orgId: str, days: int) -> Dict[str, int]: +async def analyzeOrganization( + aiomeraki: meraki.aio.AsyncDashboardAPI, orgId: str, days: int +) -> Dict[str, int]: ret = {} timespan = days * 24 * 60 * 60 - events = await aiomeraki.appliance.getOrganizationApplianceSecurityEvents(orgId, timespan=timespan, total_pages=-1) + events = await aiomeraki.appliance.getOrganizationApplianceSecurityEvents( + orgId, timespan=timespan, total_pages=-1 + ) for e in events: ip, port = e["srcIp"].rsplit(":", 1) ip = ip.strip("[]") # remove brackets in case of ipv6 - if not ipaddress.ip_address(ip).is_private: # don't block private ip addresses on the public ip of the firewall + if ( + not ipaddress.ip_address(ip).is_private + ): # don't block private ip addresses on the public ip of the firewall ret[ip] = ret.get(ip, 0) + 1 return ret -async def updateFirewallrules(aiomeraki: meraki.aio.AsyncDashboardAPI, networkId: str, ip_list: List[str]): - rules = await aiomeraki.appliance.getNetworkApplianceFirewallL7FirewallRules(networkId) +async def updateFirewallrules( + aiomeraki: meraki.aio.AsyncDashboardAPI, networkId: str, ip_list: List[str] +): + rules = await aiomeraki.appliance.getNetworkApplianceFirewallL7FirewallRules( + networkId + ) rules = rules["rules"] # get the currently blocked ip ranges @@ -44,26 +54,56 @@ async def updateFirewallrules(aiomeraki: meraki.aio.AsyncDashboardAPI, networkId new_blocks = sorted(new_blocks) # generate new rules based on the list of total ip ranges to block - rules_to_add = [{"policy": "deny", "type": "ipRange", "value": x} for x in new_blocks] + rules_to_add = [ + {"policy": "deny", "type": "ipRange", "value": x} for x in new_blocks + ] # remove all currently blocked ip ranges rules = [x for x in rules if x["type"] != "ipRange"] rules = rules + rules_to_add - await aiomeraki.appliance.updateNetworkApplianceFirewallL7FirewallRules(networkId, rules=rules) + await aiomeraki.appliance.updateNetworkApplianceFirewallL7FirewallRules( + networkId, rules=rules + ) async def main(): - parser = argparse.ArgumentParser(description='Block IP Addresses based on security events') - parser.add_argument('-o', '--organization', type=str, nargs='+', dest="organizations", required=True, - help='the name/id of the organization(s) you want to analyze/secure') - parser.add_argument("-f", '--filter', dest='filter', type=int, default=5, - help='how often must an attack be listed before it gets blocked') - parser.add_argument("-s", '--save', dest='save', action='store_true', - help='write the blocklist to all networks in the organization.') - parser.add_argument("-d", '--days', dest='days', default=31, type=int, - help='How many days should be analyzed.') + parser = argparse.ArgumentParser( + description="Block IP Addresses based on security events" + ) + parser.add_argument( + "-o", + "--organization", + type=str, + nargs="+", + dest="organizations", + required=True, + help="the name/id of the organization(s) you want to analyze/secure", + ) + parser.add_argument( + "-f", + "--filter", + dest="filter", + type=int, + default=5, + help="how often must an attack be listed before it gets blocked", + ) + parser.add_argument( + "-s", + "--save", + dest="save", + action="store_true", + help="write the blocklist to all networks in the organization.", + ) + parser.add_argument( + "-d", + "--days", + dest="days", + default=31, + type=int, + help="How many days should be analyzed.", + ) if len(sys.argv) < 3: parser.print_help() @@ -77,7 +117,7 @@ async def main(): return except SystemExit: return - except: + except Exception: print("could not parse arguments") parser.print_help() return @@ -85,10 +125,10 @@ async def main(): # Instantiate a Meraki dashboard API session # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage async with meraki.aio.AsyncDashboardAPI( - api_key, - base_url="https://api.meraki.com/api/v1", - log_file_prefix=__file__[:-3], - print_console=False, + api_key, + base_url="https://api.meraki.com/api/v1", + log_file_prefix=__file__[:-3], + print_console=False, ) as aiomeraki: # Get list of organizations to which API key has access organizations = await aiomeraki.organizations.getOrganizations() @@ -106,7 +146,9 @@ async def main(): # apply the found ip ranges to the firewall if args.save: - for n in await aiomeraki.organizations.getOrganizationNetworks(x["id"]): + for n in await aiomeraki.organizations.getOrganizationNetworks( + x["id"] + ): print(f"Updating Network {n['name']}") await updateFirewallrules(aiomeraki, n["id"], result.keys()) diff --git a/examples/aio_org_wide_clients_v1.py b/examples/aio_org_wide_clients_v1.py index e8f10c6e..ffa79f53 100644 --- a/examples/aio_org_wide_clients_v1.py +++ b/examples/aio_org_wide_clients_v1.py @@ -10,8 +10,10 @@ api_key = "" -async def listNetworkClients(aiomeraki: meraki.aio.AsyncDashboardAPI, folder_name, network): - print(f'Finding clients in network {network["name"]}') +async def listNetworkClients( + aiomeraki: meraki.aio.AsyncDashboardAPI, folder_name, network +): + print(f"Finding clients in network {network['name']}") try: # Get list of clients on network, filtering on timespan of last 14 days clients = await aiomeraki.networks.getNetworkClients( @@ -27,10 +29,8 @@ async def listNetworkClients(aiomeraki: meraki.aio.AsyncDashboardAPI, folder_nam else: if clients: # Write to file - file_name = f'{network["name"]}.csv' - output_file = open( - f"{folder_name}/{file_name}", mode="w", newline="\n" - ) + file_name = f"{network['name']}.csv" + output_file = open(f"{folder_name}/{file_name}", mode="w", newline="\n") field_names = clients[0].keys() csv_writer = csv.DictWriter( output_file, @@ -50,7 +50,7 @@ async def listNetworkClients(aiomeraki: meraki.aio.AsyncDashboardAPI, folder_nam async def listOrganization(aiomeraki: meraki.aio.AsyncDashboardAPI, org): - print(f'Analyzing organization {org["name"]}:') + print(f"Analyzing organization {org['name']}:") org_id = org["id"] # Get list of networks in organization @@ -74,16 +74,39 @@ async def listOrganization(aiomeraki: meraki.aio.AsyncDashboardAPI, org): print(f"Iterating through {total} networks in organization {org_id}") # create a list of all networks in the organization so we can call them all concurrently - networkClientsTasks = [listNetworkClients(aiomeraki, folder_name, net) for net in networks] + networkClientsTasks = [ + listNetworkClients(aiomeraki, folder_name, net) for net in networks + ] for task in asyncio.as_completed(networkClientsTasks): networkname, field_names = await task print(f"finished network: {networkname}") # Stitch together one consolidated CSV per org output_file = open(f"{folder_name}.csv", mode="w", newline="\n") - field_names = ['id', 'mac', 'description', 'ip', 'ip6', 'ip6Local', 'user', 'firstSeen', 'lastSeen', 'manufacturer', - 'os', 'recentDeviceSerial', 'recentDeviceName', 'recentDeviceMac', 'ssid', 'vlan', 'switchport', - 'usage', 'status', 'notes', 'smInstalled', 'groupPolicy8021x'] + field_names = [ + "id", + "mac", + "description", + "ip", + "ip6", + "ip6Local", + "user", + "firstSeen", + "lastSeen", + "manufacturer", + "os", + "recentDeviceSerial", + "recentDeviceName", + "recentDeviceMac", + "ssid", + "vlan", + "switchport", + "usage", + "status", + "notes", + "smInstalled", + "groupPolicy8021x", + ] field_names.insert(0, "Network Name") field_names.insert(1, "Network ID") @@ -96,7 +119,7 @@ async def listOrganization(aiomeraki: meraki.aio.AsyncDashboardAPI, org): ) csv_writer.writeheader() for net in networks: - file_name = f'{net["name"]}.csv' + file_name = f"{net['name']}.csv" if file_name in os.listdir(folder_name): with open(f"{folder_name}/{file_name}") as input_file: csv_reader = csv.DictReader( @@ -117,10 +140,10 @@ async def main(): # Instantiate a Meraki dashboard API session # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage async with meraki.aio.AsyncDashboardAPI( - api_key, - base_url="https://api.meraki.com/api/v1", - log_file_prefix=__file__[:-3], - print_console=False, + api_key, + base_url="https://api.meraki.com/api/v1", + log_file_prefix=__file__[:-3], + print_console=False, ) as aiomeraki: # Get list of organizations to which API key has access organizations = await aiomeraki.organizations.getOrganizations() diff --git a/examples/apiData2CSV_v1.py b/examples/apiData2CSV_v1.py index 34069dc6..f97f3907 100644 --- a/examples/apiData2CSV_v1.py +++ b/examples/apiData2CSV_v1.py @@ -28,100 +28,114 @@ def main(org_id, timespan): # Instantiate a Meraki dashboard API session dashboard = meraki.DashboardAPI( - base_url='https://api.meraki.com/api/v1/', + base_url="https://api.meraki.com/api/v1/", print_console=False, output_log=False, ) # Get list of API usage data and start the output csv string - apiUsage = dashboard.organizations.getOrganizationApiRequests(org_id, timespan=timespan, total_pages=-1) - csvString = 'method,host,path,queryString,tsDate,tsTime,responseCode,sourceIp,userAgent,' - csvString += 'implementation,implementationVersion,distro,distroVersion,system,systemRelease,' - csvString += 'cpu,be_geo_id,caller\r\n' - cumulativeAPIcalls = 0; + apiUsage = dashboard.organizations.getOrganizationApiRequests( + org_id, timespan=timespan, total_pages=-1 + ) + csvString = ( + "method,host,path,queryString,tsDate,tsTime,responseCode,sourceIp,userAgent," + ) + csvString += "implementation,implementationVersion,distro,distroVersion,system,systemRelease," + csvString += "cpu,be_geo_id,caller\r\n" for use in apiUsage: - csvString += use['method'] + ',' - csvString += use['host'] + ',' - csvString += use['path'] + ',' - csvString += use['queryString'] + ',' - csvString += use['ts'].split('T')[0] + ',' - csvString += use['ts'].split('T')[1].replace('Z', '') + ',' - csvString += str(use['responseCode']) + ',' - csvString += use['sourceIp'] + ',' + csvString += use["method"] + "," + csvString += use["host"] + "," + csvString += use["path"] + "," + csvString += use["queryString"] + "," + csvString += use["ts"].split("T")[0] + "," + csvString += use["ts"].split("T")[1].replace("Z", "") + "," + csvString += str(use["responseCode"]) + "," + csvString += use["sourceIp"] + "," # Special User-Agent processing - if 'python-meraki' in use['userAgent']: - print(use['userAgent']) - userAgent = use['userAgent'].split(' ') - csvString += userAgent[0] + ',' + if "python-meraki" in use["userAgent"]: + print(use["userAgent"]) + userAgent = use["userAgent"].split(" ") + csvString += userAgent[0] + "," if len(userAgent) > 1: if "implementation" in userAgent[1]: userAgentDict = json.loads(urllib.parse.unquote(userAgent[1])) if "implementation" in userAgentDict: - csvString += userAgentDict['implementation']['name'] + ',' - csvString += userAgentDict['implementation']['version'] + ',' + csvString += userAgentDict["implementation"]["name"] + "," + csvString += userAgentDict["implementation"]["version"] + "," else: - csvString += ',,' + csvString += ",," if "distro" in userAgentDict: - csvString += userAgentDict['distro']['name'] + ',' - csvString += userAgentDict['distro']['version'] + ',' + csvString += userAgentDict["distro"]["name"] + "," + csvString += userAgentDict["distro"]["version"] + "," else: - csvString += ',,' + csvString += ",," if "system" in userAgentDict: - csvString += userAgentDict['system']['name'] + ',' - csvString += userAgentDict['system']['release'] + ',' + csvString += userAgentDict["system"]["name"] + "," + csvString += userAgentDict["system"]["release"] + "," else: - csvString += ',,' + csvString += ",," if "cpu" in userAgentDict: - csvString += userAgentDict['cpu'] + ',' + csvString += userAgentDict["cpu"] + "," else: - csvString += ',' + csvString += "," if "be_geo_id" in userAgentDict: - csvString += userAgentDict['be_geo_id'] + ',' + csvString += userAgentDict["be_geo_id"] + "," else: - csvString += ',' + csvString += "," if "application" in userAgentDict: - csvString += userAgentDict['application'] + ',' + csvString += userAgentDict["application"] + "," elif "caller" in userAgentDict: - csvString += userAgentDict['caller'] + ',' + csvString += userAgentDict["caller"] + "," else: - csvString += ',' + csvString += "," else: - csvString += ',,,,,,,,,' + csvString += ",,,,,,,,," else: - csvString += ',,,,,,,,,' + csvString += ",,,,,,,,," else: - csvString += use['userAgent'] + ',' - csvString += ',,,,,,,,,' + csvString += use["userAgent"] + "," + csvString += ",,,,,,,,," - csvString += '\r\n' + csvString += "\r\n" # Output the file now = datetime.now() dt_string = now.strftime("%Y-%m-%d_%H-%M-%S") - filename = org_id + '_' + str(timespan) + '_' + dt_string + '.csv' - file = open(filename, 'w') + filename = org_id + "_" + str(timespan) + "_" + dt_string + ".csv" + file = open(filename, "w") file.write(csvString) file.close() - print('Results written to ' + filename) + print("Results written to " + filename) -if __name__ == '__main__': +if __name__ == "__main__": # First check for API key if "MERAKI_DASHBOARD_API_KEY" not in os.environ: - print('You must set the MERAKI_DASHBOARD_API_KEY variable') + print("You must set the MERAKI_DASHBOARD_API_KEY variable") sys.exit() # Now check arguments - parser = argparse.ArgumentParser(description='Generate a CSV file of Meraki API activity for an organization.') - parser.add_argument('org_id', help='Organization id to pull API activity from') - parser.add_argument('--timespan', type=int, default=900, - help='The timespan (in seconds) for which the information will be fetched. Default = 900 (15 mins)') + parser = argparse.ArgumentParser( + description="Generate a CSV file of Meraki API activity for an organization." + ) + parser.add_argument("org_id", help="Organization id to pull API activity from") + parser.add_argument( + "--timespan", + type=int, + default=900, + help="The timespan (in seconds) for which the information will be fetched. Default = 900 (15 mins)", + ) args = parser.parse_args() - print('About to run with org_id: ' + args.org_id + ' and timespan: ' + str(args.timespan)) + print( + "About to run with org_id: " + + args.org_id + + " and timespan: " + + str(args.timespan) + ) # Finally, let's roll start_time = datetime.now() main(args.org_id, args.timespan) end_time = datetime.now() - print(f'\nScript complete, total runtime {end_time - start_time}') + print(f"\nScript complete, total runtime {end_time - start_time}") 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/bulk_firmware_upgrade_manager.py b/examples/bulk_firmware_upgrade_manager.py index 04487f32..3ad341f2 100644 --- a/examples/bulk_firmware_upgrade_manager.py +++ b/examples/bulk_firmware_upgrade_manager.py @@ -3,7 +3,7 @@ import meraki -''' +""" Cisco Meraki Bulk Firmware Upgrade Manager John M. Kuchta .:|:.:|:. https://github.com/TKIPisalegacycipher This script will pull network IDs from an org and then create asynchronous action batches. Each batch will contain, for @@ -21,18 +21,20 @@ NB: When the final batch has been submitted, depending on the batch size, it may take a few minutes to finish. Feeling creative? Then try extending this script (using existing code, for the most part) to confirm when the batches are complete. Feeling super creative? Wrap this behind a Flask frontend and have yourself a merry little GUI. -''' +""" # init Meraki Python SDK session dashboard = meraki.DashboardAPI(suppress_logging=True, single_request_timeout=120) # Configurable options # Organization ID. Replace this with your actual organization ID. -organization_id = 'YOUR ORG ID HERE' # Use your own organization ID. -product_type = 'appliance' +organization_id = "YOUR ORG ID HERE" # Use your own organization ID. +product_type = "appliance" time_delta_in_days = 30 # Max is 1 month per the firmware upgrades endpoint docs actions_per_batch = 100 # Max number of actions to submit in a batch. 100 is the maximum. Bigger batches take longer. -wait_factor = 0.33 # Wait factor for action batches when the action batch queue is full. +wait_factor = ( + 0.33 # Wait factor for action batches when the action batch queue is full. +) # Firmware IDs; not needed for rescheduling, only for upgrading. If you plan to use this for upgrading, then you should # first GET the availableVersions IDs and use those here instead, since they have probably changed from the time this @@ -43,7 +45,7 @@ def time_formatter(date_time_stamp): # Basic time formatter to return strings that the API requires - formatted_date_time_stamp = date_time_stamp.replace(microsecond=0).isoformat() + 'Z' + formatted_date_time_stamp = date_time_stamp.replace(microsecond=0).isoformat() + "Z" return formatted_date_time_stamp @@ -54,29 +56,19 @@ def time_formatter(date_time_stamp): utc_future_formatted = time_formatter(utc_future) action_reschedule_existing = { - "products": { - f"{product_type}": - { - "nextUpgrade": { - "time": utc_future_formatted - } - } - } + "products": {f"{product_type}": {"nextUpgrade": {"time": utc_future_formatted}}} } # Use this action to schedule a new upgrade. If you do not provide a time param (as shown above), it will execute # immediately. IMPORTANT: See API docs for more info before using this. action_schedule_new_upgrade = { "products": { - f"{product_type}": - { - "nextUpgrade": { - "time": utc_future_formatted, - "toVersion": { - "id": new_firmware_id - } - } + f"{product_type}": { + "nextUpgrade": { + "time": utc_future_formatted, + "toVersion": {"id": new_firmware_id}, } + } } } @@ -88,11 +80,7 @@ def time_formatter(date_time_stamp): def format_single_action(resource, operation, body): # Combine a single set of batch components into an action - action = { - "resource": resource, - "operation": operation, - "body": body - } + action = {"resource": resource, "operation": operation, "body": body} return action @@ -100,12 +88,14 @@ def format_single_action(resource, operation, body): def create_single_upgrade_action(network_id): # Create a single upgrade action # AB component parts, rename action - action_resource = f'/networks/{network_id}/firmwareUpgrades' - action_operation = 'update' + action_resource = f"/networks/{network_id}/firmwareUpgrades" + action_operation = "update" # Choose whether to reschedule an existing or start a new upgrade action_body = action_reschedule_existing - upgrade_action = format_single_action(action_resource, action_operation, action_body) + upgrade_action = format_single_action( + action_resource, action_operation, action_body + ) return upgrade_action @@ -116,7 +106,7 @@ def run_an_action_batch(org_id, actions_list, synchronous=False): organizationId=org_id, actions=actions_list, confirmed=True, - synchronous=synchronous + synchronous=synchronous, ) return batch_response @@ -129,7 +119,7 @@ def create_action_list(net_list): for network in net_list: # Create the action - single_action = create_single_upgrade_action(network['id']) + single_action = create_single_upgrade_action(network["id"]) list_of_actions.append(single_action) return list_of_actions @@ -140,7 +130,7 @@ def batch_actions_splitter(batch_actions): # For each ID in range length of network_ids for i in range(0, len(batch_actions), actions_per_batch): # Create an index range for network_ids of 100 items: - yield batch_actions[i:i + actions_per_batch] + yield batch_actions[i : i + actions_per_batch] def action_batch_runner(batch_actions_lists, org_id): @@ -158,37 +148,49 @@ def action_batch_runner(batch_actions_lists, org_id): number_of_batches_submitted += 1 # Inform user of progress. - print(f'Submitted batch {number_of_batches_submitted} of {number_of_batches}.') + print(f"Submitted batch {number_of_batches_submitted} of {number_of_batches}.") return responses def action_batch_queue_checker(org_id): - all_action_batches = dashboard.organizations.getOrganizationActionBatches(organizationId=org_id) - running_action_batches = [batch for batch in all_action_batches if - batch['status']['completed'] is False and batch['status']['failed'] is False] + all_action_batches = dashboard.organizations.getOrganizationActionBatches( + organizationId=org_id + ) + running_action_batches = [ + batch + for batch in all_action_batches + if batch["status"]["completed"] is False and batch["status"]["failed"] is False + ] total_running_actions = 0 for batch in running_action_batches: - batch_actions = len(batch['actions']) + batch_actions = len(batch["actions"]) total_running_actions += batch_actions wait_seconds = total_running_actions * wait_factor while len(running_action_batches) > 4: print( - f'There are already five action batches in progress with a total of {total_running_actions} running ' - f'actions. Waiting {wait_seconds} seconds.') + f"There are already five action batches in progress with a total of {total_running_actions} running " + f"actions. Waiting {wait_seconds} seconds." + ) time.sleep(wait_seconds) - print('Checking again.') - - all_action_batches = dashboard.organizations.getOrganizationActionBatches(organizationId=org_id) - running_action_batches = [batch for batch in all_action_batches if - batch['status']['completed'] is False and batch['status']['failed'] is False] + print("Checking again.") + + all_action_batches = dashboard.organizations.getOrganizationActionBatches( + organizationId=org_id + ) + running_action_batches = [ + batch + for batch in all_action_batches + if batch["status"]["completed"] is False + and batch["status"]["failed"] is False + ] total_running_actions = 0 for batch in running_action_batches: - batch_actions = len(batch['actions']) + batch_actions = len(batch["actions"]) total_running_actions += batch_actions wait_seconds = total_running_actions * wait_factor @@ -201,4 +203,6 @@ def action_batch_queue_checker(org_id): upgrade_actions_lists = list(batch_actions_splitter(upgrade_actions_list)) # Run the action batches to clone the networks -upgraded_networks_responses = action_batch_runner(upgrade_actions_lists, organization_id) +upgraded_networks_responses = action_batch_runner( + upgrade_actions_lists, organization_id +) diff --git a/examples/ci/enable_all_early_access.py b/examples/ci/enable_all_early_access.py new file mode 100644 index 00000000..19a7b5ed --- /dev/null +++ b/examples/ci/enable_all_early_access.py @@ -0,0 +1,51 @@ +"""Opt in to all available early access features for Meraki organizations. + +Reads EA_ORG_0 through EA_ORG_7 from the environment, and for each org +fetches the feature list, creates org-wide opt-ins for any features not +already enabled, and prints a summary report. + +WARNING: This will subject the organizations to unannounced breaking API +changes. +""" + +import asyncio +import os +import sys + +import meraki.aio + +ORG_IDS: list[str] = [v for i in range(8) if (v := os.environ.get(f"EA_ORG_{i}", ""))] +if not ORG_IDS: + sys.exit("No EA_ORG_* environment variables are set") + + +async def enable_early_access(dashboard, org_id: str): + features = await dashboard.organizations.getOrganizationEarlyAccessFeatures(org_id) + opt_ins = await dashboard.organizations.getOrganizationEarlyAccessFeaturesOptIns(org_id) + + opted_in_short_names = {oi["shortName"] for oi in opt_ins} + + tasks = [] + for feature in features: + if feature["shortName"] not in opted_in_short_names: + tasks.append(dashboard.organizations.createOrganizationEarlyAccessFeaturesOptIn(org_id, feature["shortName"])) + + new_opt_ins = await asyncio.gather(*tasks) if tasks else [] + + all_opt_ins = opt_ins + list(new_opt_ins) + print(f"\nOrg {org_id}: enabled {len(new_opt_ins)} new features ({len(opt_ins)} already opted in)") + print(f" All early access features ({len(all_opt_ins)} total):") + for oi in all_opt_ins: + print(f" - {oi['shortName']}") + + return all_opt_ins + + +async def main(): + async with meraki.aio.AsyncDashboardAPI(suppress_logging=True) as dashboard: + results = await asyncio.gather(*(enable_early_access(dashboard, org_id) for org_id in ORG_IDS)) + return results + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/find_early_access_operations.py b/examples/find_early_access_operations.py index e80c8275..2cfe1c96 100644 --- a/examples/find_early_access_operations.py +++ b/examples/find_early_access_operations.py @@ -40,11 +40,11 @@ # How many operations in each channel? for channel in channels: print( - f'There are {len([operation for operation in operations if operation["x-release-stage"] == channel])}' + f"There are {len([operation for operation in operations if operation['x-release-stage'] == channel])}" f" operations in the {channel} channel." ) -if input(f"Would you like to see the operations? y/N") == 'y': +if input("Would you like to see the operations? y/N") == "y": print(operations) else: - print(f"Goodbye.") + print("Goodbye.") diff --git a/examples/get_pages_iterator.py b/examples/get_pages_iterator.py index 8616b4c6..95f1308a 100644 --- a/examples/get_pages_iterator.py +++ b/examples/get_pages_iterator.py @@ -13,8 +13,9 @@ def getNetworksLegacy(meraki: meraki.DashboardAPI, perPage=5): count = 0 - for x in meraki.organizations.getOrganizationNetworks(organizationId=ORGANIZATION_ID, perPage=perPage, - total_pages=-1): + for x in meraki.organizations.getOrganizationNetworks( + organizationId=ORGANIZATION_ID, perPage=perPage, total_pages=-1 + ): print(f"{x['id']} - {x['name']}") count = count + 1 print(f"Found {count} networks") @@ -22,8 +23,9 @@ def getNetworksLegacy(meraki: meraki.DashboardAPI, perPage=5): def getNetworksIterator(meraki: meraki.DashboardAPI, perPage=5): count = 0 - for x in meraki.organizations.getOrganizationNetworks(organizationId=ORGANIZATION_ID, perPage=perPage, - total_pages=-1): + for x in meraki.organizations.getOrganizationNetworks( + organizationId=ORGANIZATION_ID, perPage=perPage, total_pages=-1 + ): print(f"{x['id']} - {x['name']}") count = count + 1 print(f"Found {count} networks") @@ -31,8 +33,9 @@ def getNetworksIterator(meraki: meraki.DashboardAPI, perPage=5): def getNetworkEventsLegacy(meraki: meraki.DashboardAPI, perPage=5): count = 0 - result = meraki.networks.getNetworkEvents(networkId=NETWORK_ID, perPage=perPage, total_pages=50, - productType="wireless") + result = meraki.networks.getNetworkEvents( + networkId=NETWORK_ID, perPage=perPage, total_pages=50, productType="wireless" + ) for x in result["events"]: print(f"{x['occurredAt']}") count = count + 1 @@ -41,15 +44,18 @@ def getNetworkEventsLegacy(meraki: meraki.DashboardAPI, perPage=5): def getNetworkEventsIterator(meraki: meraki.DashboardAPI, perPage=5): count = 0 - for x in meraki.networks.getNetworkEvents(networkId=NETWORK_ID, perPage=perPage, total_pages=50, - productType="wireless"): + for x in meraki.networks.getNetworkEvents( + networkId=NETWORK_ID, perPage=perPage, total_pages=50, productType="wireless" + ): print(f"{x['occurredAt']}") count = count + 1 print(f"Found {count} events") async def main(): - parser = argparse.ArgumentParser(description='Example for demonstrating the use_iterator_for_get_pages parameter') + argparse.ArgumentParser( + description="Example for demonstrating the use_iterator_for_get_pages parameter" + ) # Instantiate a Meraki dashboard API session # NOTE: you have to use "async with" so that the session will be closed correctly at the end of the usage @@ -58,7 +64,7 @@ async def main(): base_url="https://api.meraki.com/api/v1", log_file_prefix=__file__[:-3], print_console=True, - use_iterator_for_get_pages=True + use_iterator_for_get_pages=True, ) meraki_legacy = meraki.DashboardAPI( @@ -66,7 +72,7 @@ async def main(): base_url="https://api.meraki.com/api/v1", log_file_prefix=__file__[:-3], print_console=False, - use_iterator_for_get_pages=False + use_iterator_for_get_pages=False, ) print("Test legacy") diff --git a/examples/local_subnet_dumper.py b/examples/local_subnet_dumper.py index 5a4c2874..c03da80c 100644 --- a/examples/local_subnet_dumper.py +++ b/examples/local_subnet_dumper.py @@ -28,17 +28,15 @@ my_orgs = d.organizations.getOrganizations() my_orgs = [org for org in my_orgs if org["id"] not in excluded_org_ids] -print(f"done gathering organizations") +print("done gathering organizations") # gather networks my_networks = [ - d.organizations.getOrganizationNetworks( - organization["id"], total_pages=all - ) + d.organizations.getOrganizationNetworks(organization["id"], total_pages=all) for organization in my_orgs ] -print(f"done gathering networks") +print("done gathering networks") my_appliance_networks = [ network @@ -48,7 +46,7 @@ and "appliance" in network["productTypes"] ] -print(f"done gathering appliance networks") +print("done gathering appliance networks") # gather routed networks -- appliances in passthrough mode don't have local subnets my_appliance_routed_networks = [ @@ -58,7 +56,7 @@ == "routed" ] -print(f"done gathering routed appliance networks") +print("done gathering routed appliance networks") my_appliance_networks_with_vlans = [ network @@ -66,7 +64,7 @@ if d.appliance.getNetworkApplianceVlansSettings(network["id"])["vlansEnabled"] ] -print(f"done gathering appliance network vlan settings") +print("done gathering appliance network vlan settings") my_appliance_networks_without_vlans = [ network @@ -83,7 +81,7 @@ for network in my_appliance_networks_with_vlans ] -print(f"done gathering appliance network vlans") +print("done gathering appliance network vlans") my_lans = [ { @@ -94,7 +92,7 @@ for network in my_appliance_networks_without_vlans ] -print(f"done gathering appliance network lans") +print("done gathering appliance network lans") # unpack the subnets vlan_subnets = list() diff --git a/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/merakiApplianceVlanToL3SwitchInterfaceMigrator.py b/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/merakiApplianceVlanToL3SwitchInterfaceMigrator.py index 987023bb..e6c66f3b 100644 --- a/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/merakiApplianceVlanToL3SwitchInterfaceMigrator.py +++ b/examples/merakiApplianceVlanToL3SwitchInterfaceMigrator/merakiApplianceVlanToL3SwitchInterfaceMigrator.py @@ -10,32 +10,32 @@ # API_KEY = os.getenv('MERAKI_DASHBOARD_API_KEY') # CUSTOMIZABLE STRINGS -SETTINGS_FILENAME = 'settings.json' -MAPPINGS_FILENAME = 'mappings.json' -CONFIRM = 'Would you like to proceed?' -CHOICES = '(y/n)' -STATIC_SETTINGS_REVIEW = 'Please review these static settings before proceeding.' -APPLIANCE_SETTINGS_REVIEW = 'Please review these appliance settings before proceeding.' -VLAN_ERROR = 'The appliance has a VLAN for which settings.json does not have interface information. Add the interface information to continue.' -GOODBYE = 'Now go check your L3 switch\'s routing settings.' +SETTINGS_FILENAME = "settings.json" +MAPPINGS_FILENAME = "mappings.json" +CONFIRM = "Would you like to proceed?" +CHOICES = "(y/n)" +STATIC_SETTINGS_REVIEW = "Please review these static settings before proceeding." +APPLIANCE_SETTINGS_REVIEW = "Please review these appliance settings before proceeding." +VLAN_ERROR = "The appliance has a VLAN for which settings.json does not have interface information. Add the interface information to continue." +GOODBYE = "Now go check your L3 switch's routing settings." # DEFINE a method that will ingest settings and param mappings def ingest(settings_filename: str, mappings_filename: str): # INGEST SETTINGS, AND PARAM MAPPINGS # READ the settings and mappings - with open(settings_filename, 'r') as settings_json: + with open(settings_filename, "r") as settings_json: settings = json.load(settings_json) - with open(mappings_filename, 'r') as mappings_json: + with open(mappings_filename, "r") as mappings_json: mappings = json.load(mappings_json) # CREATE a list of all the tagged VLAN IDs in settings.json - tagged_vlan_ids = [vlan['id'] for vlan in settings['vlans']['others']] + tagged_vlan_ids = [vlan["id"] for vlan in settings["vlans"]["others"]] # CREATE a list of all the interface IPs in settings.json - interface_ips = [vlan['interfaceIp'] for vlan in settings['vlans']['others']] - interface_ips.append(settings['vlans']['native']['interfaceIp']) + interface_ips = [vlan["interfaceIp"] for vlan in settings["vlans"]["others"]] + interface_ips.append(settings["vlans"]["native"]["interfaceIp"]) return (settings, mappings, tagged_vlan_ids, interface_ips) @@ -43,9 +43,9 @@ def ingest(settings_filename: str, mappings_filename: str): # DEFINE a function to build working configs that we can manipulate. In this example, it will be useful to separate the interfaces with # DHCP enabled vice disabled, because interfaces with DHCP enabled have options that need to be PUT to two different endpoints. def build_working_configs( - starting_configs: list, - feature_old_toggle_param: str = 'dhcpHandling', - feature_old_toggle_disabled_mode: str = 'Do not respond to DHCP requests' + starting_configs: list, + feature_old_toggle_param: str = "dhcpHandling", + feature_old_toggle_disabled_mode: str = "Do not respond to DHCP requests", ): # DEEPCOPY the starting config. We'll manipulate a separate working config. DEEPCOPY is important because direct assignment creates a # reference to the original object, whereas we want to modify this one without modifying the original, in case we want to debug and @@ -54,8 +54,11 @@ def build_working_configs( # CREATE a reference to the working config that only contains items with the feature enabled. # Anything we modify here will also be reflected in the working_config array. - working_configs_with_feature = [config for config in working_configs if - config[feature_old_toggle_param] != feature_old_toggle_disabled_mode] + working_configs_with_feature = [ + config + for config in working_configs + if config[feature_old_toggle_param] != feature_old_toggle_disabled_mode + ] return (working_configs, working_configs_with_feature) @@ -67,7 +70,7 @@ def printj(ugly_json_object: list): print(pretty_json_string) -# DEFINE a method that will check each param against the knownParams in mappings.json and choose the appropriate action for that param, +# DEFINE a method that will check each param against the knownParams in mappings.json and choose the appropriate action for that param, # and then return a list of tasks per config item. def build_task_list(*, old_configs: list, mappings: list): # Make an empty to-do list. @@ -76,38 +79,65 @@ def build_task_list(*, old_configs: list, mappings: list): for old_config in old_configs: # Search every parameter in the old config for a matching one in the mappings file, # and make a list out of it. - old_config_matched_params = [param for param in mappings['knownParams'] if param['names']['old'] in old_config] + old_config_matched_params = [ + param + for param in mappings["knownParams"] + if param["names"]["old"] in old_config + ] # Make an empty dict() for each old_config where we'll store the lists of params needing action params_to = dict() # REMOVE # Check each matched param's mapping status and assign to an appropriate array based on the required action. - params_to['remove'] = [param for param in old_config_matched_params if param['status'] == 'deprecated'] - remaining_params = [param for param in old_config_matched_params if param not in params_to['remove']] + params_to["remove"] = [ + param + for param in old_config_matched_params + if param["status"] == "deprecated" + ] + remaining_params = [ + param + for param in old_config_matched_params + if param not in params_to["remove"] + ] # REUSE - params_to['reuse'] = [param for param in old_config_matched_params if param['status'] == 'reused'] - remaining_params = [param for param in remaining_params if param not in params_to['reuse']] + params_to["reuse"] = [ + param for param in old_config_matched_params if param["status"] == "reused" + ] + remaining_params = [ + param for param in remaining_params if param not in params_to["reuse"] + ] # RENAME - params_to['rename'] = [param for param in old_config_matched_params if param['status'] == 'renamed'] - remaining_params = [param for param in remaining_params if param not in params_to['rename']] + params_to["rename"] = [ + param for param in old_config_matched_params if param["status"] == "renamed" + ] + remaining_params = [ + param for param in remaining_params if param not in params_to["rename"] + ] # TRANSFORM - params_to['transform'] = [param for param in old_config_matched_params if param['status'] == 'transformed'] - remaining_params = [param for param in remaining_params if param not in params_to['transform']] - - if (len(remaining_params) != 0): + params_to["transform"] = [ + param + for param in old_config_matched_params + if param["status"] == "transformed" + ] + remaining_params = [ + param for param in remaining_params if param not in params_to["transform"] + ] + + if len(remaining_params) != 0: print( - f"I found params in the source that aren't mapped in the mappings file. These are: {remaining_params}") + f"I found params in the source that aren't mapped in the mappings file. These are: {remaining_params}" + ) print("I will now quit so you can map those params.") sys.exit() # Add this compiled dict to our to-do list. to_do.append(params_to) - return (to_do) + return to_do # DEFINE a method that will remove params @@ -115,21 +145,20 @@ def remove_params(task_list: list, old_configs: list): # Operate on each task in the list modified_params = set() modified_configs = set() - past_tense_verb = 'Removed' + past_tense_verb = "Removed" # Iterate through the tasks and configs at the same time. for task, config in zip(task_list, old_configs): - # Iterate through each param in the task for param in task: - modified_params.add(param['names']['old']) - modified_configs.add(config['name']) + modified_params.add(param["names"]["old"]) + modified_configs.add(config["name"]) # Remove the param - config.pop(param['names']['old']) + config.pop(param["names"]["old"]) print(f"{past_tense_verb} {modified_params} from {modified_configs}.\n") - return (old_configs) + return old_configs # DEFINE a method that will rename params @@ -137,20 +166,19 @@ def rename_params(task_list: list, old_configs: list): # Operate on each task in the list modified_params = set() modified_configs = set() - past_tense_verb = 'Renamed' + past_tense_verb = "Renamed" # Iterate through the tasks and configs at the same time. for task, config in zip(task_list, old_configs): - # Iterate through each param in the task for param in task: - modified_params.add(param['names']['old']) - modified_configs.add(config['name']) + modified_params.add(param["names"]["old"]) + modified_configs.add(config["name"]) # Add a param with the new name, and assign it the old value while removing it from the list - config[param['names']['new']] = config.pop(param['names']['old']) + config[param["names"]["new"]] = config.pop(param["names"]["old"]) print(f"{past_tense_verb} {modified_params} from {modified_configs}.\n") - return (old_configs) + return old_configs # DEFINE a method that will replace null values in subkeys with blank strings @@ -158,9 +186,9 @@ def transform_replace_none(*, param, config, transform): modified_params = set() modified_configs = set() # Check each parameter for null values - for subparam in config[param['names']['new']]: + for subparam in config[param["names"]["new"]]: if None in subparam.values(): - subparam[transform['action']] = '' + subparam[transform["action"]] = "" return (modified_params, modified_configs) @@ -170,12 +198,12 @@ def transform_rename_mode(*, param, config): modified_params = set() modified_configs = set() # Check each mode for the current param - for mode in param['modes']: + for mode in param["modes"]: # If the current mode corresponds to a new one, replace it with the new one - if config[param['names']['new']] == mode['old']: - config[param['names']['new']] = mode['new'] - modified_params.add(param['names']['new']) - modified_configs.add(config['name']) + if config[param["names"]["new"]] == mode["old"]: + config[param["names"]["new"]] = mode["new"] + modified_params.add(param["names"]["new"]) + modified_configs.add(config["name"]) return (modified_params, modified_configs) @@ -185,10 +213,12 @@ def transform_split_mode(*, param, config, transform): modified_params = set() modified_configs = set() # Split the given param's mode by the delimiter specified in the mappings if the delimter is there - # if transform['action'] in config[param['names']['new']]: - config[param['names']['new']] = config[param['names']['new']].split(transform['action']) - modified_params.add(param['names']['new']) - modified_configs.add(config['name']) + # if transform['action'] in config[param['names']['new']]: + config[param["names"]["new"]] = config[param["names"]["new"]].split( + transform["action"] + ) + modified_params.add(param["names"]["new"]) + modified_configs.add(config["name"]) return (modified_params, modified_configs) @@ -198,13 +228,13 @@ def transform_add_param(*, param, config, transform): modified_params = set() modified_configs = set() # Add to the config a param with the new name from the transform, and assign it the old value - config[transform['action']] = config[param['names']['new']] + config[transform["action"]] = config[param["names"]["new"]] # Assign the original param with the fallback mode - config[param['names']['new']] = transform['fallback'] + config[param["names"]["new"]] = transform["fallback"] - modified_params.add(param['names']['new']) - modified_params.add(transform['action']) - modified_configs.add(config['name']) + modified_params.add(param["names"]["new"]) + modified_params.add(transform["action"]) + modified_configs.add(config["name"]) return (modified_params, modified_configs) @@ -213,33 +243,33 @@ def transform_add_param(*, param, config, transform): def transform_demote_dynamic_key(*, param, config, **kwargs: dict): modified_params = set() modified_configs = set() - interface_ips = kwargs['interface_ips'] + interface_ips = kwargs["interface_ips"] # Make a new list that will contain all the new dicts new_param_list = [] # For each instance of a dynamic key in this particular vlan config - for dynamic_key in config[param['names']['new']]: + for dynamic_key in config[param["names"]["new"]]: # Make a new dict that lists the key as a value, and its nested key/value pairs as additional top-level key/value pairs new_param = dict() # Make the dynamic key the value of the new key name per mappings - new_param[param['names']['newSubParam']] = dynamic_key + new_param[param["names"]["newSubParam"]] = dynamic_key # Now add all the subkeys - new_param.update(config[param['names']['new']][dynamic_key]) + new_param.update(config[param["names"]["new"]][dynamic_key]) # LET'S TALK INTERFACE ADDRESSES # If the IP address in the reservation matches a new interface IP for the switch, then the two settings will # interfere. We'll simply drop the DHCP reservation for that IP address if it's the same as the IP address for # the interface. - if new_param['ip'] not in interface_ips: + if new_param["ip"] not in interface_ips: # Add the new param to the new param list new_param_list.append(new_param) # Assign the new param list to the original key - config[param['names']['new']] = new_param_list + config[param["names"]["new"]] = new_param_list - modified_params.add(param['names']['new']) - modified_configs.add(config['name']) + modified_params.add(param["names"]["new"]) + modified_configs.add(config["name"]) return (modified_params, modified_configs) @@ -248,46 +278,60 @@ def transform_demote_dynamic_key(*, param, config, **kwargs: dict): def transform_coordinate(*, param, config, transform, **kwargs: dict): modified_params = set() modified_configs = set() - interface_ips = kwargs['interface_ips'] + interface_ips = kwargs["interface_ips"] # Perform each transform if called for in mappings - if transform['type'] == 'rename None': + if transform["type"] == "rename None": # Hand off the transform to a single-purpose method - transformed_params, transformed_configs = transform_replace_none(param=param, config=config, - transform=transform) + transformed_params, transformed_configs = transform_replace_none( + param=param, config=config, transform=transform + ) # Merge the modification sets from that method - modified_params |= (transformed_params) - modified_configs |= (transformed_configs) + modified_params |= transformed_params + modified_configs |= transformed_configs - if transform['type'] == 'rename mode': + if transform["type"] == "rename mode": # Hand off the transform to a single-purpose method - transformed_params, transformed_configs = transform_rename_mode(param=param, config=config) + transformed_params, transformed_configs = transform_rename_mode( + param=param, config=config + ) # Merge the modification sets from that method - modified_params |= (transformed_params) - modified_configs |= (transformed_configs) + modified_params |= transformed_params + modified_configs |= transformed_configs - if transform['type'] == 'split delimited strings' and transform['action'] in config[param['names']['new']]: + if ( + transform["type"] == "split delimited strings" + and transform["action"] in config[param["names"]["new"]] + ): # Hand off the transform to a single-purpose method - transformed_params, transformed_configs = transform_split_mode(param=param, config=config, transform=transform) + transformed_params, transformed_configs = transform_split_mode( + param=param, config=config, transform=transform + ) # Merge the modification sets from that method - modified_params |= (transformed_params) - modified_configs |= (transformed_configs) + modified_params |= transformed_params + modified_configs |= transformed_configs - if transform['type'] == 'add param' and isinstance(config[param['names']['new']], list): + if transform["type"] == "add param" and isinstance( + config[param["names"]["new"]], list + ): # Hand off the transform to a single-purpose method - transformed_params, transformed_configs = transform_add_param(param=param, config=config, transform=transform) + transformed_params, transformed_configs = transform_add_param( + param=param, config=config, transform=transform + ) # Merge the modification sets from that method - modified_params |= (transformed_params) - modified_configs |= (transformed_configs) + modified_params |= transformed_params + modified_configs |= transformed_configs - if transform['type'] == 'demote dynamic key' and isinstance(config[param['names']['new']], dict): + if transform["type"] == "demote dynamic key" and isinstance( + config[param["names"]["new"]], dict + ): # Hand off the transform to a single-purpose method - transformed_params, transformed_configs = transform_demote_dynamic_key(param=param, config=config, - transform=transform, - interface_ips=interface_ips) + transformed_params, transformed_configs = transform_demote_dynamic_key( + param=param, config=config, transform=transform, interface_ips=interface_ips + ) # Merge the modification sets from that method - modified_params |= (transformed_params) - modified_configs |= (transformed_configs) + modified_params |= transformed_params + modified_configs |= transformed_configs return (modified_params, modified_configs) @@ -297,30 +341,32 @@ def transform_params(task_list: list, old_configs: list, **kwargs: dict): # Operate on each task in the list modified_params = set() modified_configs = set() - past_tense_verb = 'Transformed' - interface_ips = kwargs['interface_ips'] + past_tense_verb = "Transformed" + interface_ips = kwargs["interface_ips"] # Iterate through the tasks and configs at the same time. for task, config in zip(task_list, old_configs): - # Iterate through each param in the task for param in task: - modified_params.add(param['names']['old']) - modified_configs.add(config['name']) + modified_params.add(param["names"]["old"]) + modified_configs.add(config["name"]) # Add a param with the new name, and assign it the old value while removing it from the list - config[param['names']['new']] = config.pop(param['names']['old']) + config[param["names"]["new"]] = config.pop(param["names"]["old"]) # Iterate through each transformation - for transform in param['transforms']: - transformed_params, transformed_configs = transform_coordinate(param=param, config=config, - transform=transform, - interface_ips=interface_ips) + for transform in param["transforms"]: + transformed_params, transformed_configs = transform_coordinate( + param=param, + config=config, + transform=transform, + interface_ips=interface_ips, + ) # Merge the modification sets from that method - modified_params |= (transformed_params) - modified_configs |= (transformed_configs) + modified_params |= transformed_params + modified_configs |= transformed_configs print(f"{past_tense_verb} {modified_params} from {modified_configs}.\n") - return (old_configs) + return old_configs # DEFINE a method that adds the static configuration information from settings.json to each interface @@ -328,20 +374,22 @@ def assign_statics(tagged_vlan_ids: list, settings, old_configs: list): for interface in old_configs: # ASSIGN the static information that isn't derived from the appliance config # Set the native VLAN info - if interface['vlanId'] == settings['vlans']['native']['id']: - interface['defaultGateway'] = settings['vlans']['native']['defaultGateway'] - interface['interfaceIp'] = settings['vlans']['native']['interfaceIp'] + if interface["vlanId"] == settings["vlans"]["native"]["id"]: + interface["defaultGateway"] = settings["vlans"]["native"]["defaultGateway"] + interface["interfaceIp"] = settings["vlans"]["native"]["interfaceIp"] # Set the tagged VLAN info - elif interface['vlanId'] in tagged_vlan_ids: + elif interface["vlanId"] in tagged_vlan_ids: # Use a list comprehension, then pop it, to get the interface IP - interface['interfaceIp'] = [tagged_vlan['interfaceIp'] for tagged_vlan \ - in settings['vlans']['others'] if - tagged_vlan['id'] == interface['vlanId']].pop() + interface["interfaceIp"] = [ + tagged_vlan["interfaceIp"] + for tagged_vlan in settings["vlans"]["others"] + if tagged_vlan["id"] == interface["vlanId"] + ].pop() # We need to have this static information for each VLAN in the appliance config. If we don't find it, then we'll quit so you can fix settings.json. else: print(VLAN_ERROR) sys.exit() - return (old_configs) + return old_configs # DEFINE a function to create the interfaces @@ -350,48 +398,61 @@ def create_interfaces(dashboard, settings, switch_interfaces: list): responses = [] # Create each interface. The native VLAN will need special params from settings.json. for interface in switch_interfaces: - if interface['vlanId'] != settings['vlans']['native']['id']: + if interface["vlanId"] != settings["vlans"]["native"]["id"]: response = dashboard.switch.createDeviceSwitchRoutingInterface( - settings['switchSerial'], interface['name'], interface['interfaceIp'], interface['vlanId'], - subnet=interface['subnet'] + settings["switchSerial"], + interface["name"], + interface["interfaceIp"], + interface["vlanId"], + subnet=interface["subnet"], ) else: response = dashboard.switch.createDeviceSwitchRoutingInterface( - settings['switchSerial'], interface['name'], interface['interfaceIp'], interface['vlanId'], - subnet=interface['subnet'], - defaultGateway=interface['defaultGateway'] + settings["switchSerial"], + interface["name"], + interface["interfaceIp"], + interface["vlanId"], + subnet=interface["subnet"], + defaultGateway=interface["defaultGateway"], ) responses.append(response) return responses # DEFINE a function to update the DHCP config for each interface -def configure_interface_dhcp(dashboard, serial, switch_interfaces_with_dhcp, created_interfaces): +def configure_interface_dhcp( + dashboard, serial, switch_interfaces_with_dhcp, created_interfaces +): responses = [] # Iterate through all the for interface in switch_interfaces_with_dhcp: - created_dhcp_interface = [created_interface for created_interface in created_interfaces if - created_interface['vlanId'] == interface['vlanId']].pop() - if 'dnsCustomNameservers' in interface: + created_dhcp_interface = [ + created_interface + for created_interface in created_interfaces + if created_interface["vlanId"] == interface["vlanId"] + ].pop() + if "dnsCustomNameservers" in interface: response = dashboard.switch.updateDeviceSwitchRoutingInterfaceDhcp( - serial, created_dhcp_interface['interfaceId'], - dhcpMode=interface['dhcpMode'], - dhcpLeaseTime=interface['dhcpLeaseTime'], - dnsNameserversOption=interface['dnsNameserversOption'], - dnsCustomNameservers=interface['dnsCustomNameservers'], - dhcpOptions=interface['dhcpOptions'], - reservedIpRanges=interface['reservedIpRanges'], - fixedIpAssignments=interface['fixedIpAssignments'] + serial, + created_dhcp_interface["interfaceId"], + dhcpMode=interface["dhcpMode"], + dhcpLeaseTime=interface["dhcpLeaseTime"], + dnsNameserversOption=interface["dnsNameserversOption"], + dnsCustomNameservers=interface["dnsCustomNameservers"], + dhcpOptions=interface["dhcpOptions"], + reservedIpRanges=interface["reservedIpRanges"], + fixedIpAssignments=interface["fixedIpAssignments"], ) else: response = dashboard.switch.updateDeviceSwitchRoutingInterfaceDhcp( - serial, created_dhcp_interface['interfaceId'], - dhcpMode=interface['dhcpMode'], - dhcpLeaseTime=interface['dhcpLeaseTime'], - dnsNameserversOption=interface['dnsNameserversOption'], - dhcpOptions=interface['dhcpOptions'], - reservedIpRanges=interface['reservedIpRanges'], - fixedIpAssignments=interface['fixedIpAssignments'] + serial, + created_dhcp_interface["interfaceId"], + dhcpMode=interface["dhcpMode"], + dhcpLeaseTime=interface["dhcpLeaseTime"], + dnsNameserversOption=interface["dnsNameserversOption"], + dhcpOptions=interface["dhcpOptions"], + reservedIpRanges=interface["reservedIpRanges"], + fixedIpAssignments=interface["fixedIpAssignments"], ) responses.append(response) return responses @@ -400,7 +461,9 @@ def configure_interface_dhcp(dashboard, serial, switch_interfaces_with_dhcp, cre # DEFINE a main method that will drive the config through all necessary param and mode changes, and push the change to Dashboard. def main(): # INGEST settings and mappings--we need some of these to start the connection - settings, mappings, tagged_vlan_ids, interface_ips = ingest(SETTINGS_FILENAME, MAPPINGS_FILENAME) + settings, mappings, tagged_vlan_ids, interface_ips = ingest( + SETTINGS_FILENAME, MAPPINGS_FILENAME + ) # START A MERAKI DASHBOARD API SESSION # Initialize the Dashboard connection. @@ -409,78 +472,64 @@ def main(): # SHOW the user the ingested settings print(STATIC_SETTINGS_REVIEW) for key, value in settings.items(): - print(f'The setting {key} has the following value(s):') + print(f"The setting {key} has the following value(s):") printj(value) # CONFIRM the operation - if (input(f'{CONFIRM} {CHOICES}') != 'y'): + if input(f"{CONFIRM} {CHOICES}") != "y": sys.exit() # GET appliance VLANs from Meraki Dashboard appliance_vlans = dashboard.appliance.getNetworkApplianceVlans( - networkId=settings['networkId'] + networkId=settings["networkId"] ) # BUILD working configs that we can manipulate. - switch_interfaces, switch_interfaces_with_dhcp = build_working_configs(appliance_vlans) + switch_interfaces, switch_interfaces_with_dhcp = build_working_configs( + appliance_vlans + ) # REMOVE, RENAME, REUSE, or TRANSFORM each param task_list = build_task_list(old_configs=switch_interfaces, mappings=mappings) # MIGRATE the settings task_list_types = dict() - task_list_types['remove'] = [task['remove'] for task in task_list] - task_list_types['rename'] = [task['rename'] for task in task_list] - task_list_types['transform'] = [task['transform'] for task in task_list] + task_list_types["remove"] = [task["remove"] for task in task_list] + task_list_types["rename"] = [task["rename"] for task in task_list] + task_list_types["transform"] = [task["transform"] for task in task_list] # REMOVE params - switch_interfaces = remove_params( - task_list_types['remove'], - switch_interfaces - ) + switch_interfaces = remove_params(task_list_types["remove"], switch_interfaces) # RENAME params - switch_interfaces = rename_params( - task_list_types['rename'], - switch_interfaces - ) + switch_interfaces = rename_params(task_list_types["rename"], switch_interfaces) # TRANSFORM params. One of these transformations requires the list of interface IPs. switch_interfaces = transform_params( - task_list_types['transform'], - switch_interfaces, - interface_ips=interface_ips + task_list_types["transform"], switch_interfaces, interface_ips=interface_ips ) # ASSIGN statics (see settings.json) - switch_interfaces = assign_statics( - tagged_vlan_ids, - settings, - switch_interfaces - ) + switch_interfaces = assign_statics(tagged_vlan_ids, settings, switch_interfaces) # We've now replaced all the params and modes necessary. However, unlike appliances, switches # offer different endpoints for interface settings and interface DHCP settings. Therefore we # will push some settings to the interface endpoint, and the DHCP settings to the DHCP endpoint. # Let's review what we've done: - print('I created these new configs:') + print("I created these new configs:") for interface in switch_interfaces: printj(interface) # CREATE the interfaces - created_interfaces = create_interfaces( - dashboard, - settings, - switch_interfaces - ) + created_interfaces = create_interfaces(dashboard, settings, switch_interfaces) # CONFIGURE DHCP on the DHCP interfaces configured_dhcp = configure_interface_dhcp( dashboard, - settings['switchSerial'], + settings["switchSerial"], switch_interfaces_with_dhcp, - created_interfaces + created_interfaces, ) # CONFIRM diff --git a/examples/org_wide_clients_v1.py b/examples/org_wide_clients_v1.py index 7be31f5b..b5f41642 100644 --- a/examples/org_wide_clients_v1.py +++ b/examples/org_wide_clients_v1.py @@ -14,12 +14,12 @@ def main(): # Instantiate a Meraki dashboard API session dashboard = meraki.DashboardAPI( - api_key='', - base_url='https://api.meraki.com/api/v1/', + api_key="", + base_url="https://api.meraki.com/api/v1/", output_log=True, log_file_prefix=os.path.basename(__file__)[:-3], - log_path='', - print_console=False + log_path="", + print_console=False, ) # Get list of organizations to which API key has access @@ -27,84 +27,126 @@ def main(): # Iterate through list of orgs for org in organizations: - print(f'\nAnalyzing organization {org["name"]}:') - org_id = org['id'] + print(f"\nAnalyzing organization {org['name']}:") + org_id = org["id"] # Get list of networks in organization try: networks = dashboard.organizations.getOrganizationNetworks(org_id) except meraki.APIError as e: - print(f'Meraki API error: {e}') - print(f'status code = {e.status}') - print(f'reason = {e.reason}') - print(f'error = {e.message}') + print(f"Meraki API error: {e}") + print(f"status code = {e.status}") + print(f"reason = {e.reason}") + print(f"error = {e.message}") continue except Exception as e: - print(f'some other error: {e}') + print(f"some other error: {e}") continue # Create local folder - todays_date = f'{datetime.now():%Y-%m-%d}' - folder_name = f'Org {org_id} clients {todays_date}' + todays_date = f"{datetime.now():%Y-%m-%d}" + folder_name = f"Org {org_id} clients {todays_date}" if folder_name not in os.listdir(): os.mkdir(folder_name) # Iterate through networks total = len(networks) counter = 1 - print(f' - iterating through {total} networks in organization {org_id}') + print(f" - iterating through {total} networks in organization {org_id}") for net in networks: - print(f'Finding clients in network {net["name"]} ({counter} of {total})') + print(f"Finding clients in network {net['name']} ({counter} of {total})") try: # Get list of clients on network, filtering on timespan of last 14 days - clients = dashboard.networks.getNetworkClients(net['id'], timespan=60 * 60 * 24 * 14, perPage=1000, - total_pages='all') + clients = dashboard.networks.getNetworkClients( + net["id"], + timespan=60 * 60 * 24 * 14, + perPage=1000, + total_pages="all", + ) except meraki.APIError as e: - print(f'Meraki API error: {e}') - print(f'status code = {e.status}') - print(f'reason = {e.reason}') - print(f'error = {e.message}') + print(f"Meraki API error: {e}") + print(f"status code = {e.status}") + print(f"reason = {e.reason}") + print(f"error = {e.message}") except Exception as e: - print(f'some other error: {e}') + print(f"some other error: {e}") else: if clients: # Write to file - file_name = f'{net["name"]}.csv' - output_file = open(f'{folder_name}/{file_name}', mode='w', newline='\n') + file_name = f"{net['name']}.csv" + output_file = open( + f"{folder_name}/{file_name}", mode="w", newline="\n" + ) field_names = clients[0].keys() - csv_writer = csv.DictWriter(output_file, field_names, delimiter=',', quotechar='"', - quoting=csv.QUOTE_ALL) + csv_writer = csv.DictWriter( + output_file, + field_names, + delimiter=",", + quotechar='"', + quoting=csv.QUOTE_ALL, + extrasaction="ignore", + ) csv_writer.writeheader() csv_writer.writerows(clients) output_file.close() - print(f' - found {len(clients)}') + print(f" - found {len(clients)}") counter += 1 # Stitch together one consolidated CSV per org - output_file = open(f'{folder_name}.csv', mode='w', newline='\n') - field_names = ['id', 'mac', 'description', 'ip', 'ip6', 'ip6Local', 'user', 'firstSeen', 'lastSeen', - 'manufacturer', 'os', 'recentDeviceSerial', 'recentDeviceName', 'recentDeviceMac', 'ssid', - 'vlan', 'switchport', 'usage', 'status', 'notes', 'smInstalled', 'groupPolicy8021x'] + output_file = open(f"{folder_name}.csv", mode="w", newline="\n") + field_names = [ + "id", + "mac", + "description", + "ip", + "ip6", + "ip6Local", + "user", + "firstSeen", + "lastSeen", + "manufacturer", + "os", + "recentDeviceSerial", + "recentDeviceName", + "recentDeviceMac", + "ssid", + "vlan", + "switchport", + "usage", + "status", + "notes", + "smInstalled", + "groupPolicy8021x", + ] field_names.insert(0, "Network Name") field_names.insert(1, "Network ID") - csv_writer = csv.DictWriter(output_file, field_names, delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL) + csv_writer = csv.DictWriter( + output_file, + field_names, + delimiter=",", + quotechar='"', + quoting=csv.QUOTE_ALL, + extrasaction="ignore", + ) csv_writer.writeheader() for net in networks: - file_name = f'{net["name"]}.csv' + file_name = f"{net['name']}.csv" if file_name in os.listdir(folder_name): - with open(f'{folder_name}/{file_name}') as input_file: - csv_reader = csv.DictReader(input_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL) + with open(f"{folder_name}/{file_name}") as input_file: + csv_reader = csv.DictReader( + input_file, delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL + ) next(csv_reader) for row in csv_reader: - row['Network Name'] = net['name'] - row['Network ID'] = net['id'] + row["Network Name"] = net["name"] + row["Network ID"] = net["id"] csv_writer.writerow(row) -if __name__ == '__main__': +if __name__ == "__main__": start_time = datetime.now() main() end_time = datetime.now() - print(f'\nScript complete, total runtime {end_time - start_time}') + print(f"\nScript complete, total runtime {end_time - start_time}") diff --git a/examples/organization_deleter.py b/examples/organization_deleter.py index b365132a..27fbc18a 100644 --- a/examples/organization_deleter.py +++ b/examples/organization_deleter.py @@ -22,7 +22,7 @@ print( f"This script will delete all orgs in this list: {LIST_OF_ORGANIZATIONS_TO_DELETE}" ) -confirmed = input(f"Are you sure you'd like to proceed? (yes/N)") +confirmed = input("Are you sure you'd like to proceed? (yes/N)") # User will need to type yes to continue if confirmed != "yes": @@ -44,7 +44,7 @@ delete_network = d.networks.deleteNetwork(network["id"]) count_networks -= 1 print(f"{count_networks} remaining.") - print(f"Done deleting networks.") + print("Done deleting networks.") # get config templates org_templates = d.organizations.getOrganizationConfigTemplates(organization) @@ -56,7 +56,7 @@ ) count_templates -= 1 print(f"{count_templates} remaining.") - print(f"Done deleting config templates.") + print("Done deleting config templates.") # get org inventory devices org_devices = d.organizations.getOrganizationInventoryDevices(organization) @@ -68,7 +68,7 @@ organization, serials=device_serials ) print(f"Released {count_devices} devices from inventory.") - print(f"Done releasing devices.") + print("Done releasing devices.") # get org admins org_admins = d.organizations.getOrganizationAdmins(organization) @@ -80,7 +80,7 @@ print(f"Done deleting networks and admins in organization (id: {organization}).") -confirmed = input(f"Would you like to proceed with deleting the organizations? (yes/N)") +confirmed = input("Would you like to proceed with deleting the organizations? (yes/N)") if confirmed != "yes": print("Aborting") @@ -90,4 +90,4 @@ delete_organization = d.organizations.deleteOrganization(organization) print(f"Deleted organization (id: {organization}).") -print(f"Done deleting organizations.") +print("Done deleting organizations.") 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/examples/wireless_rf_profiles_overview.py b/examples/wireless_rf_profiles_overview.py index 401438ae..f9ef555d 100644 --- a/examples/wireless_rf_profiles_overview.py +++ b/examples/wireless_rf_profiles_overview.py @@ -3,7 +3,7 @@ ### work in progress print( - f"To use this tool, you must supply your organization ID. Your organization ID should be an integer." + "To use this tool, you must supply your organization ID. Your organization ID should be an integer." ) organization_id = "" # organization_id = input(f"Enter your organization ID:") @@ -13,7 +13,7 @@ d = meraki.DashboardAPI(suppress_logging=True) # fetch RF profiles assignments by device -print(f"Fetching RF profile assignments for the organization") +print("Fetching RF profile assignments for the organization") rf_profiles_assignments = ( d.wireless.getOrganizationWirelessRfProfilesAssignmentsByDevice( organization_id, total_pages=all @@ -47,7 +47,7 @@ # fetch wireless networks -print(f"Fetching wireless networks") +print("Fetching wireless networks") networks = d.organizations.getOrganizationNetworks( organization_id, productTypes=["wireless"], total_pages=all ) @@ -55,7 +55,7 @@ network for network in networks if "wireless" in network["productTypes"] ] -print(f"Fetching RF profiles per network") +print("Fetching RF profiles per network") rf_profiles_by_network = [ d.wireless.getNetworkWirelessRfProfiles(network["id"]) for network in wireless_networks @@ -66,7 +66,7 @@ for profile in network: all_rf_profiles.append(profile) -print(f"Fetching RF profiles per network") -print(f"Fetching RF profiles per network") +print("Fetching RF profiles per network") +print("Fetching RF profiles per network") print(1) diff --git a/generator/async_class_template.jinja2 b/generator/async_class_template.jinja2 index e9007920..8dc9b9dc 100644 --- a/generator/async_class_template.jinja2 +++ b/generator/async_class_template.jinja2 @@ -5,4 +5,3 @@ class Async{{ class_name }}: def __init__(self, session): super().__init__() self._session = session - diff --git a/generator/async_function_template.jinja2 b/generator/async_function_template.jinja2 index 7fd18e35..99a30c84 100644 --- a/generator/async_function_template.jinja2 +++ b/generator/async_function_template.jinja2 @@ -11,41 +11,58 @@ {% 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}''' + assert kwargs["{{ param }}"] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}''' {% endfor %} {% endif %} metadata = { - 'tags': {{ tags }}, - 'operation': '{{ operation }}' + "tags": {{ tags|to_double_quote_list }}, + "operation": "{{ operation }}", } {% for param in path_params %} - {{ param }} = urllib.parse.quote(str({{ param }}), safe='') + {{ param }} = urllib.parse.quote(str({{ param }}), safe="") {% endfor %} - resource = f'{{ resource }}' + resource = f"{{ resource }}" {% if query_params|length > 0 %} - query_params = [{% for param in query_params %}'{{ param }}', {% endfor %}] + query_params = [{% for param in query_params %}"{{ param }}", {% endfor %}] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} {% endif %} {% if array_params|length > 0 %} - array_params = [{% for param in array_params %}'{{ param }}', {% endfor %}] + array_params = [{% for param in array_params %}"{{ param }}", {% endfor %}] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) {% endif %} {% if body_params|length > 0 %} - body_params = [{% for param in body_params %}'{{ param }}', {% endfor %}] + body_params = [{% for param in body_params %}"{{ param }}", {% endfor %}] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + {% endif %} + {% if query_params|length > 0 or body_params|length > 0 %} + if self._session._validate_kwargs: + all_params = {% if query_params|length > 0 %}query_params{% else %}[]{% endif %}{% if array_params|length > 0 %} + array_params{% endif %}{% if body_params|length > 0 %} + body_params{% endif %} + + 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"{{ operation }}: ignoring unrecognized kwargs: {invalid}" + ) + {% endif %} {{ call_line }} - diff --git a/generator/batch_class_template.jinja2 b/generator/batch_class_template.jinja2 index 681e6d2c..e60c320d 100644 --- a/generator/batch_class_template.jinja2 +++ b/generator/batch_class_template.jinja2 @@ -1,7 +1,8 @@ import urllib +import httpx + class ActionBatch{{ class_name }}(object): def __init__(self): super(ActionBatch{{ class_name }}, self).__init__() - diff --git a/generator/batch_function_template.jinja2 b/generator/batch_function_template.jinja2 index f08b67f8..d53d1ce3 100644 --- a/generator/batch_function_template.jinja2 +++ b/generator/batch_function_template.jinja2 @@ -11,52 +11,54 @@ {% 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}''' + assert kwargs["{{ param }}"] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}''' {% endfor %} {% endif %} - metadata = { - 'tags': {{ tags }}, - 'operation': '{{ operation }}' - } {% for param in path_params %} - {{ param }} = urllib.parse.quote({{ param }}, safe='') + {{ param }} = urllib.parse.quote(str({{ param }}), safe="") {% endfor %} - resource = f'{{ resource }}' + resource = f"{{ resource }}" {% if query_params|length > 0 %} - query_params = [{% for param in query_params %}'{{ param }}', {% endfor %}] + query_params = [{% for param in query_params %}"{{ param }}", {% endfor %}] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} {% endif %} {% if array_params|length > 0 %} - array_params = [{% for param in array_params %}'{{ param }}', {% endfor %}] + array_params = [{% for param in array_params %}"{{ param }}", {% endfor %}] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) {% endif %} - {% if body_params|length > 0 %} - body_params = [{% for param in body_params %}'{{ param }}', {% endfor %}] - {% if batch_operation != 'destroy'%} - payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + {% if query_params|length > 0 %} + if params: + resource += f"?{httpx.QueryParams(params)}" + {% endif %} + {% if body_params|length > 0 and batch_operation != 'destroy' %} + body_params = [{% for param in body_params %}"{{ param }}", {% endfor %}] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} {% endif %} action = { "resource": resource, "operation": "{{ batch_operation }}", {% if body_params|length > 0 and batch_operation != 'destroy'%} - "body": payload + "body": payload, {% endif %} } {{ call_line }} - - - - diff --git a/generator/class_template.jinja2 b/generator/class_template.jinja2 index 983ff759..2d29baaf 100644 --- a/generator/class_template.jinja2 +++ b/generator/class_template.jinja2 @@ -5,4 +5,3 @@ class {{ class_name }}(object): def __init__(self, session): super({{ class_name }}, self).__init__() self._session = session - diff --git a/generator/common.py b/generator/common.py index a5df130b..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(): @@ -20,8 +141,8 @@ def organize_spec(paths, scopes): # This helps ensure they are scoped to the correct module if len(tags) > 2: match tags[2]: - case 'spaces': - scope = 'spaces' + case "spaces": + scope = "spaces" case _: scope = tags[0] else: diff --git a/generator/function_template.jinja2 b/generator/function_template.jinja2 index 730f8a1f..14b51eb9 100644 --- a/generator/function_template.jinja2 +++ b/generator/function_template.jinja2 @@ -11,41 +11,58 @@ {% 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}''' + assert kwargs["{{ param }}"] in options, f'''"{{ param }}" cannot be "{kwargs['{{ param }}']}", & must be set to one of: {options}''' {% endfor %} {% endif %} metadata = { - 'tags': {{ tags }}, - 'operation': '{{ operation }}' + "tags": {{ tags|to_double_quote_list }}, + "operation": "{{ operation }}", } {% for param in path_params %} - {{ param }} = urllib.parse.quote(str({{ param }}), safe='') + {{ param }} = urllib.parse.quote(str({{ param }}), safe="") {% endfor %} - resource = f'{{ resource }}' + resource = f"{{ resource }}" {% if query_params|length > 0 %} - query_params = [{% for param in query_params %}'{{ param }}', {% endfor %}] + query_params = [{% for param in query_params %}"{{ param }}", {% endfor %}] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} {% endif %} {% if array_params|length > 0 %} - array_params = [{% for param in array_params %}'{{ param }}', {% endfor %}] + array_params = [{% for param in array_params %}"{{ param }}", {% endfor %}] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) {% endif %} {% if body_params|length > 0 %} - body_params = [{% for param in body_params %}'{{ param }}', {% endfor %}] + body_params = [{% for param in body_params %}"{{ param }}", {% endfor %}] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} + {% endif %} + {% if query_params|length > 0 or body_params|length > 0 %} + if self._session._validate_kwargs: + all_params = {% if query_params|length > 0 %}query_params{% else %}[]{% endif %}{% if array_params|length > 0 %} + array_params{% endif %}{% if body_params|length > 0 %} + body_params{% endif %} + + 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"{{ operation }}: ignoring unrecognized kwargs: {invalid}" + ) + {% endif %} {{ call_line }} - diff --git a/generator/generate_library.py b/generator/generate_library.py index 3cfa6178..f210a446 100644 --- a/generator/generate_library.py +++ b/generator/generate_library.py @@ -1,234 +1,107 @@ import getopt +import json +import keyword import os -import platform +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_stubs import generate_stub_modules -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 +_keyword_param_violations = [] -# 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 safe_param_name(name: str) -> str: + if keyword.iskeyword(name): + return name + "_" + return name -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] +def _write_generation_report(version_number: str, api_version_number: str, is_github_action: bool): + from datetime import date - # Capture the enum if available - if "enum" in this_param: - all_params[name]["enum"] = this_param["enum"] + report_path = "docs/generation-report.md" - # Assign the description to the parameter if it's available - if "description" in this_param: - all_params[name]["description"] = this_param["description"] + 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) - # Fall back to required if there is no description - elif is_required: - all_params[name]["description"] = "(required)" + lines = [] + lines.append(f"## {date.today().isoformat()} | Library v{version_number} | API {api_version_number}\n") + lines.append("") - # Fall back to whatever the description is otherwise + 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: - 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] + lines.append("No Python keyword parameter conflicts detected.\n") + lines.append("") - return all_params + 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") -def unpack_params(operation: str, parameters: dict, param_filters): - # Create dict with information on endpoint's parameters - unpacked_params = dict() + 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) - # Iterate through the endpoint's parameters - for p in parameters: - # Name the parameter - name = p["name"] + print(f"Generation report written to {report_path}") - # Consult the schema if there is one - if "schema" in p: - unpacked_params.update(unpack_param_with_schema(unpacked_params, p)) - # 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) - ) +READ_ME = """ +=== PREREQUISITES === +Include the jinja2 files in same directory as this script, and install Python httpx +pip[3] install httpx - # Add custom library parameters to handle pagination - if "perPage" in unpacked_params: - unpacked_params.update(generate_pagination_parameters(operation)) +=== DESCRIPTION === +This script generates the Meraki Python library from the OpenAPI v3 specification. - # Return parameters based on matching input filters - return return_params(operation, unpacked_params, param_filters) +=== USAGE === +python[3] generate_library_v3.py [-o ] [-k ] [-v ] [-a ] [-g ] [-s] +-s generates .pyi type stub files for static analysis -# 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 {} +API key can, and is recommended to, be set as an environment variable named MERAKI_DASHBOARD_API_KEY.""" - return unpack_params(operation, parameters, param_filters) +def generate_library( + 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() -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", @@ -247,15 +120,31 @@ def generate_library(spec: dict, version_number: str, api_version_number: str, i "secureConnect", "wirelessController", "campusGateway", - "spaces" + "spaces", + "nac", + "users", + "support", + "assistant", ] - # 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} + 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 @@ -267,11 +156,11 @@ def generate_library(spec: dict, version_number: str, api_version_number: str, i # Check paths and create directories if needed directories = [ "meraki", + "meraki/session", "meraki/api", "meraki/api/batch", "meraki/aio", "meraki/aio/api", - "meraki/api/batch", ] for directory in directories: if not os.path.isdir(directory): @@ -280,70 +169,96 @@ def generate_library(spec: dict, version_number: str, api_version_number: str, i # Files that are not generated non_generated = [ "__init__.py", + "_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 - if file == "__init__.py": - # replace library version + with open(f"meraki/{file}", "w+", encoding="utf-8", newline=None) as fp: + if file == "_version.py": start = contents.find("__version__ = ") end = contents.find("\n", start) contents = f"{contents[:start]}__version__ = '{version_number}'{contents[end:]}" - # replace API version + elif file == "__init__.py": start = contents.find("__api_version__ = ") end = contents.find("\n", start) contents = f"{contents[:start]}__api_version__ = '{api_version_number}'{contents[end:]}" fp.write(contents) + # Filter paths to remove "parameters" key before passing to organize_spec + # organize_spec expects only HTTP method keys (get, post, put, delete) + 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) # 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 = 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) + generate_modules(spec, batchable_actions, jinja_env, scopes, template_dir) + + # Generate type stubs if requested + if generate_stubs: + print("Generating .pyi type stubs...") + generate_stub_modules(spec, scopes, jinja_env, template_dir) + # 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.") + # 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): + # 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: 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 - ) - + 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}, { @@ -367,9 +282,7 @@ def generate_modules(batchable_actions, jinja_env, scopes, template_dir): ) # Generate API & Asyncio API functions - generate_standard_and_async_functions( - jinja_env, template_dir, section, output, async_output - ) + generate_standard_and_async_functions(jinja_env, template_dir, section, output, async_output, spec) # Generate API action batch functions generate_action_batch_functions( @@ -378,97 +291,211 @@ def generate_modules(batchable_actions, jinja_env, scopes, template_dir): section, batch_output, batchable_actions, + spec, ) +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:], + ) + ) + + def generate_standard_and_async_functions( jinja_env: jinja2.Environment, template_dir: str, section: dict, output: open, async_output: open, + spec: dict, ): for path, methods in section.items(): for method, endpoint in methods.items(): # Get metadata tags = endpoint["tags"] operation = endpoint["operationId"] - description = endpoint["summary"] + description = endpoint.get("summary", endpoint.get("description", "")) - # will need updating for OASv3 - parameters = endpoint["parameters"] if "parameters" in endpoint else None + # Get path_item from spec for parse_params_v3 + path_item = spec["paths"][path] + + # Parse params using v3 parser + all_params, metadata = parse_params_v3(endpoint, path_item, spec) # Function definition definition = "" - if parameters: - for p, values in parse_params( - operation, parameters, "required" - ).items(): + 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", {safe_p}: list" + elif values["type"] == "number": + definition += f", {safe_p}: float" + elif values["type"] == "integer": + definition += f", {safe_p}: int" + elif values["type"] == "boolean": + definition += f", {safe_p}: bool" + elif values["type"] == "object": + definition += f", {safe_p}: dict" + elif values["type"] == "string": + 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) + 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: + 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" + + # 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 + + query_params = return_params(operation, all_params, ["query"]) + array_params = {k: v for k, v in query_params.items() if v["type"] == "array"} + body_params = path_params = {} - 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" + # Function body for GET endpoints + if method == "get": + path_params = return_params(operation, all_params, ["path"]) - if parse_params(operation, parameters, ["optional"]): - definition += ", **kwargs" + # 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"]) + + # Function body for DELETE endpoints + elif method == "delete": + path_params = return_params(operation, all_params, ["path"]) + + # Add **kwargs if optional params OR body/query/array params exist (templates use kwargs.items()) + if return_params(operation, all_params, ["optional"]) or body_params or query_params or array_params: + 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"]}' - ) + all_params_for_doc = return_params(operation, all_params, ["required", "pagination", "optional"]) + if all_params_for_doc: + for p, values in all_params_for_doc.items(): + param_descriptions.append(f"{p} ({values['type']}): {values['description']}") - # Combine keyword args with locals kwarg_line = "" - if parse_params(operation, parameters, ["optional"]): + if return_params(operation, all_params, ["optional"]): kwarg_line = "kwargs.update(locals())" - elif parse_params(operation, parameters, ["query", "array", "body"]): + elif return_params(operation, all_params, ["query", "array", "body"]): kwarg_line = "kwargs = locals()" # Assert valid values for enum - enum_params = parse_params(operation, parameters, ["enum"]) + enum_params = return_params(operation, all_params, ["enum"]) 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 body for GET endpoints - query_params = array_params = body_params = path_params = {} + # Generate call_line based on method 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 - ) + pagination_params = return_params(operation, all_params, ["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)" + + elif method == "post" or method == "put" or method == "patch": + args = "metadata, resource" + if body_params: + args += ", payload" + if query_params: + args += ", params=params" + call_line = f"return self._session.{method}({args})" - # Function body for DELETE endpoints elif method == "delete": - call_line, path_params, query_params = parse_delete_params(operation, parameters) + if query_params: + call_line = "return self._session.delete(metadata, resource, params)" + 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( @@ -478,91 +505,26 @@ def generate_standard_and_async_functions( ) 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, - ) + rendered = 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_for_doc.keys()) if all_params_for_doc else [], + assert_blocks=assert_blocks, + tags=tags, + resource=safe_resource, + query_params=query_params, + array_params=array_params, + body_params=body_params, + path_params=safe_path_params, + call_line=call_line, + renamed_params=renamed_params, ) - 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 + output.write("\n\n" + rendered) + async_output.write("\n\n" + rendered) def generate_action_batch_functions( @@ -571,104 +533,169 @@ def generate_action_batch_functions( section: dict, batch_output: open, batchable_actions: list, + spec: dict, ): + # Build list of batchable action summaries for matching + batchable_action_summaries = [action["summary"] for action in batchable_actions] + 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: + # Match by description OR summary (v3 inconsistency) + endpoint_desc = endpoint.get("description", "") + endpoint_summary = endpoint.get("summary", "") + + if endpoint_desc in batchable_action_summaries or endpoint_summary in batchable_action_summaries: # Get metadata tags = endpoint["tags"] operation = endpoint["operationId"] - description = endpoint["description"] - summary = endpoint["summary"] + description = endpoint.get("description", endpoint_summary) + summary = endpoint.get("summary", endpoint_desc) - this_action = [ - action - for action in batchable_actions - if action["summary"] == description or action["summary"] == summary - ][0] + # Find matching action + this_action = None + for action in batchable_actions: + if action["summary"] == description or action["summary"] == summary: + this_action = action + break + + if not this_action: + continue batch_operation = this_action["operation"] - # May need update for OASv3 - parameters = ( - endpoint["parameters"] if "parameters" in endpoint else None - ) + # Get path_item from spec for parse_params_v3 + path_item = spec["paths"][path] - # Function body for GET endpoints - query_params = array_params = body_params = {} + # Parse params using v3 parser + all_params, metadata = parse_params_v3(endpoint, path_item, spec) - # Function body for POST/PUT endpoints - if method == "post" or method == "put": - # will need update for OASv3 - body_params = parse_params(operation, parameters, "body") + # Initialize param collections + query_params = return_params(operation, all_params, ["query"]) + array_params = {k: v for k, v in query_params.items() if v["type"] == "array"} + body_params = {} + path_params = return_params(operation, all_params, ["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 DELETE endpoints is empty (HTTP 204) + # 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 = "" - if parameters: - for p, values in parse_params( - operation, parameters, "required" - ).items(): - # Match OAS schema types to Python types + defined_params = set() + renamed_params = {} + + 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 + match values["type"]: + case "array": + definition += f", {safe_p}: list" + case "number": + definition += f", {safe_p}: float" + case "integer": + definition += f", {safe_p}: int" + case "boolean": + definition += f", {safe_p}: bool" + case "object": + definition += f", {safe_p}: dict" + case "string": + 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) + 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: + 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 "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" + # Function body for POST/PUT endpoints + if method == "post" or method == "put": + body_params = return_params(operation, all_params, ["body"]) - if parse_params(operation, parameters, ["optional"]): - definition += ", **kwargs" + # Add **kwargs if optional params OR body/query/array params exist (batch template uses kwargs.items()) + if return_params(operation, all_params, ["optional"]) or body_params or query_params or array_params: + 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"]}' - ) + all_params_for_doc = return_params(operation, all_params, ["required", "pagination", "optional"]) + if all_params_for_doc: + for p, values in all_params_for_doc.items(): + param_descriptions.append(f"{p} ({values['type']}): {values['description']}") - # Combine keyword args with locals kwarg_line = "" - if parse_params(operation, parameters, ["optional"]): + if return_params(operation, all_params, ["optional"]): kwarg_line = "kwargs.update(locals())" - - # will need update for OASv3 - elif parse_params(operation, parameters, ["query", "array", "body"]): + elif return_params(operation, all_params, ["query", "array", "body"]): kwarg_line = "kwargs = locals()" # Assert valid values for enum - enum_params = parse_params(operation, parameters, ["enum"]) + enum_params = return_params(operation, all_params, ["enum"]) 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", @@ -686,36 +713,21 @@ def generate_action_batch_functions( doc_url=docs_url(operation), descriptions=param_descriptions, kwarg_line=kwarg_line, - all_params=list(all_params.keys()), + 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=safe_path_params, call_line=call_line, batch_operation=batch_operation, + renamed_params=renamed_params, ) ) -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") @@ -730,9 +742,12 @@ def main(inputs): version_number = "custom" 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:") + opts, args = getopt.getopt(inputs, "ho:k:v:a:g:slb:") except getopt.GetoptError: print_help() sys.exit(2) @@ -751,39 +766,52 @@ def main(inputs): elif opt == "-g": if arg.lower() == "true": 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() - # Retrieve the latest OpenAPI specification + # Retrieve the latest OpenAPI v3 specification if org_id: if not api_key: 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") - # Validate that the spec pulled successfully before trying to generate the library. - 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 spec.") + print("Successfully pulled Meraki dashboard API OpenAPI v3 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.' + "There was an HTTP error pulling the OpenAPI v3 specification. Please try again in a few minutes. " + "If this continues for more than an hour, please contact Meraki support." ) - generate_library(spec, version_number, api_version_number, is_github_action) + 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 c2fb01ea..00000000 --- a/generator/generate_library_oasv2.py +++ /dev/null @@ -1,582 +0,0 @@ -import getopt -import os -import sys - -import jinja2 -import requests - -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_oasv2.py [-o ] [-k ] [-v ] [-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): - 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 - - -# Returns full link to endpoint's documentation on Developer Hub -def docs_url(operation): - 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, params, 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 - - -# Helper function to return parameters within OAS spec, optionally based on list of input filters -def parse_params(operation, parameters, param_filters=None): - if param_filters is None: - param_filters = [] - if parameters is None: - return {} - - # Create dict with information on endpoint's parameters - params = {} - for p in parameters: - name = p['name'] - - # consult the schema if there is one - if 'schema' in p: - # 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 = p['schema']['properties'] - - # parse the properties and assign types and descriptions - for k in keys: - # if required, set required true - if 'required' in p['schema'] and k in p['schema']['required']: - params[k] = {'required': True} - else: - params[k] = {'required': False} - - # identify whether the parameter is in the path or query, or for OASv2, in the body - params[k]['in'] = p['in'] - - # assign the right data type to the parameter per the schema - params[k]['type'] = keys[k]['type'] - - # assign the description to the parameter per the schema - params[k]['description'] = keys[k]['description'] - - # capture schema enum if available - if 'enum' in keys[k]: - params[k]['enum'] = keys[k]['enum'] - - # capture schema example if available - if 'example' in p['schema'] and k in p['schema']['example']: - params[k]['example'] = p['schema']['example'][k] - - # if there is no schema, then consult the required attribute - elif 'required' in p and p['required']: - params[name] = {'required': True} - - # identify whether the parameter is in the path or query, or for OASv2, in the body - params[name]['in'] = p['in'] - - # assign the right data type to the parameter - params[name]['type'] = p['type'] - - # assign the description to the parameter if it's available - if 'description' in p: - params[name]['description'] = p['description'] - - # fall back to required if there is no description - else: - params[name]['description'] = '(required)' - - # capture the enum if available - if 'enum' in p: - params[name]['enum'] = p['enum'] - - # if there is no schema and no required attribute, then the parameter is not required - 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 - return return_params(operation, params, param_filters) - - -def generate_library(spec, version_number, is_github_action): - # 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'] - # 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_action_summaries = [action['summary'] for action in 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 sub-directories if needed - subdirs = ['meraki', 'meraki/api', 'meraki/api/batch', 'meraki/aio', 'meraki/aio/api', 'meraki/api/batch'] - for dir in subdirs: - if not os.path.isdir(dir): - os.mkdir(dir) - - # Files that are not generated - non_generated = ['__init__.py', 'config.py', 'exceptions.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 == '__init__.py': - start = contents.find('__version__ = ') - end = contents.find('\n', start) - contents = f'{contents[:start]}__version__ = \'{version_number}\'{contents[end:]}' - fp.write(contents) - - # Organize data from OpenAPI specification - operations = list() # list of operation IDs - for path, methods in paths.items(): - # method is the HTTP action, e.g. get, put, etc. - for method in methods: - - # endpoint is the method for that specific path - endpoint = paths[path][method] - - # the endpoint has tags - tags = endpoint['tags'] - - # the endpoint has an operationId - operation = endpoint['operationId'] - - # add the operation ID to the list - operations.append(operation) - - # the endpoint has a scope defined by the first tag - scope = tags[0] - - # Needs documentation - if path not in scopes[scope]: - scopes[scope][path] = {method: endpoint} - # Needs documentation - else: - scopes[scope][path][method] = endpoint - - # Inform the user of the number of operations 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) - - # Iterate through the scopes creating standard, asyncio and batch modules for each - 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: - with open(f'{template_dir}class_template.jinja2', 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:], - ) - ) - - # Generate Asyncio API libraries - async_output = open(f'meraki/aio/api/{scope}.py', 'w', encoding='utf-8', newline=None) - with open(f'{template_dir}async_class_template.jinja2', encoding='utf-8', newline=None) as fp: - class_template = fp.read() - template = jinja_env.from_string(class_template) - async_output.write( - template.render( - class_name=scope[0].upper() + scope[1:], - ) - ) - - # Generate Action Batch API libraries - batch_output = open(f'meraki/api/batch/{scope}.py', 'w', encoding='utf-8', newline=None) - with open(f'{template_dir}batch_class_template.jinja2', encoding='utf-8', newline=None) as fp: - class_template = fp.read() - template = jinja_env.from_string(class_template) - batch_output.write( - template.render( - class_name=scope[0].upper() + scope[1:], - ) - ) - - # Generate API & Asyncio API functions - 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 = '' - if parameters: - for p, values in parse_params(operation, parameters, 'required').items(): - 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' - - 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 += f', **kwargs' - - # Docstring - param_descriptions = [] - 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 = [] - 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': - 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)' - - # Function body for POST/PUT endpoints - elif method == 'post' or method == 'put': - 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)' - - # Function body for DELETE endpoints - elif method == 'delete': - path_params = parse_params(operation, parameters, 'path') - call_line = 'return self._session.delete(metadata, resource)' - - # 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 - ) - ) - - # Generate API action batch functions - for path, methods in section.items(): - for method, endpoint in methods.items(): - if endpoint['description'] in batchable_action_summaries: - # Get metadata - tags = endpoint['tags'] - operation = endpoint['operationId'] - description = endpoint['summary'] - - # May need update for OASv3 - parameters = endpoint['parameters'] if 'parameters' in endpoint else None - - # Function definition - definition = '' - if parameters: - for p, values in parse_params(operation, parameters, 'required').items(): - 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' - - 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 += f', **kwargs' - - # Docstring - param_descriptions = [] - 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 body for GET endpoints - query_params = array_params = body_params = {} - - # Function body for POST/PUT endpoints - if method == 'post' or method == 'put': - - # will need update for OASv3 - body_params = parse_params(operation, parameters, 'body') - if method == 'post': - batch_operation = 'create' - else: - batch_operation = 'update' - - # Function body for DELETE endpoints - elif method == 'delete': - batch_operation = 'destroy' - - # 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, - call_line=call_line, - batch_operation=batch_operation - ) - ) - - -# Prints 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' - is_github_action = False - - try: - opts, args = getopt.getopt(inputs, 'ho:k:v: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 == '-g': - if arg.lower() == 'true': - is_github_action = True - - # Retrieve 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: - spec = requests.get('https://api.meraki.com/api/v1/openapiSpec').json() - - generate_library(spec, 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 5f23475d..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 = [ @@ -196,7 +108,8 @@ def main(): "secureConnect", "wirelessController", "campusGateway", - "spaces" + "spaces", + "nac", ] # legacy scopes = ['organizations', 'networks', 'devices', # 'appliance', 'camera', 'cellularGateway', 'insight', 'sm', 'switch', 'wireless'] @@ -205,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: @@ -218,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": @@ -260,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: @@ -268,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"] @@ -294,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 new file mode 100644 index 00000000..2619410e --- /dev/null +++ b/generator/generate_stubs.py @@ -0,0 +1,141 @@ +""" +Type stub (.pyi) generation for Meraki Dashboard API SDK. + +Produces .pyi files with typed method signatures from parsed OASv3 params. +""" + +import keyword +import re +import jinja2 +from parser_v3 import parse_params_v3 +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: + """ + Convert param dict to Python type annotation string. + + Args: + param_dict: Param entry with keys: type, nullable, required + + Returns: + Type annotation string (e.g., "str", "str | None", "str | dict | None") + """ + param_type = param_dict.get("type", "object") + + # Map OAS type to Python type + type_map = { + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", + "array": "list", + "object": "dict", + } + + # Handle oneOf case (type = "string or object") + if " or " in param_type: + parts = param_type.split(" or ") + python_types = sorted([type_map.get(p.strip(), "Any") for p in parts]) + base_type = " | ".join(python_types) + else: + base_type = type_map.get(param_type, "Any") + + # Apply nullable annotation + if param_dict.get("nullable", False): + return f"{base_type} | None" + + # Apply optional annotation (not required) + if not param_dict.get("required", False): + return f"{base_type} | None" + + return base_type + + +def generate_stub_modules(spec: dict, scopes: dict, jinja_env: jinja2.Environment, template_dir: str): + """ + Generate .pyi stub modules for each scope. + + Args: + spec: Full OpenAPI spec dict + scopes: Dict of scope name -> {path: {method: endpoint}} + jinja_env: Jinja2 environment + template_dir: Directory containing templates (empty string if cwd) + """ + for scope in scopes: + section = scopes[scope] + + # 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() + template = jinja_env.from_string(class_template) + stub_output.write( + template.render( + class_name=scope[0].upper() + scope[1:], + ) + ) + + # Generate method signatures + for path, methods in section.items(): + for method, endpoint in methods.items(): + operation = endpoint["operationId"] + + # Get path_item from spec + path_item = spec["paths"][path] + + # Parse params using v3 parser + all_params, metadata = parse_params_v3(endpoint, path_item, spec) + + # Build method signature + signature_parts = ["self"] + defined_params = set() + + # Add required params + for p, values in return_params(operation, all_params, ["required"]).items(): + defined_params.add(p) + annotation = _python_type_annotation(values) + 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"{_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"{_safe_param_name(p)}: str") + + # Add pagination params if perPage exists + if "perPage" in all_params: + if operation in REVERSE_PAGINATION: + signature_parts.append("total_pages: int = 1") + signature_parts.append("direction: str = 'prev'") + else: + signature_parts.append("total_pages: int = 1") + signature_parts.append("direction: str = 'next'") + if operation == "getNetworkEvents": + signature_parts.append("event_log_end_time: Any | None = None") + + # Add optional params explicitly (stubs need full signatures, not **kwargs) + optional_params = return_params(operation, all_params, ["optional"]) + for p, values in optional_params.items(): + if p not in defined_params: + annotation = _python_type_annotation(values) + signature_parts.append(f"{_safe_param_name(p)}: {annotation} = None") + + signature = ", ".join(signature_parts) + + # Write method stub + stub_output.write(f"\n def {operation}({signature}) -> Any: ...\n") diff --git a/generator/parser_v3.py b/generator/parser_v3.py new file mode 100644 index 00000000..7bb7bbd5 --- /dev/null +++ b/generator/parser_v3.py @@ -0,0 +1,283 @@ +""" +OpenAPI v3 parser for Meraki Dashboard API SDK. + +Provides $ref resolution (with caching and cycle detection) and requestBody +parsing that normalizes v3 structures into v2-compatible param dicts. + +Module-level functions (not class-based) per project convention. +Spec passed as argument to all functions. +""" + +from common import generate_pagination_parameters, return_params + +# Module-level cache for resolved $ref pointers (D-01) +_ref_cache: dict[str, dict] = {} + + +def clear_cache() -> None: + """Clear ref cache between generator runs. Call at generator entry point.""" + _ref_cache.clear() + + +def resolve_ref(spec: dict, ref: str, _visited: set | None = None) -> dict: + """ + Resolve a JSON pointer ($ref) within an OpenAPI spec. + + Uses module-level cache (D-01) and visited-set cycle detection (D-02). + Raises on unresolvable refs (D-03). + + Args: + spec: Full OpenAPI spec dict. + ref: JSON pointer string (must start with "#/"). + _visited: Internal; tracks pointers in current resolution chain. + + Returns: + Resolved schema dict. Empty dict {} if circular reference detected. + + Raises: + ValueError: If ref is not an internal reference (doesn't start with "#/"). + KeyError: If pointer path cannot be resolved in spec. + """ + # Reject external refs + if not ref.startswith("#/"): + raise ValueError(f"Only internal refs supported: {ref}") + + # Check cache first (D-01) + if ref in _ref_cache: + return _ref_cache[ref] + + # Cycle detection (D-02) + if _visited is None: + _visited = set() + + if ref in _visited: + return {} # Sentinel for circular reference + + _visited.add(ref) + + # Parse JSON pointer (RFC 6901) + parts = ref[2:].split("/") + result = spec + for part in parts: + # Unescape JSON pointer tokens per RFC 6901 + part = part.replace("~1", "/").replace("~0", "~") + if isinstance(result, dict) and part in result: + result = result[part] + else: + raise KeyError(f"Unresolvable $ref: {ref}") + + # If resolved value itself contains $ref, resolve recursively + if isinstance(result, dict) and "$ref" in result: + result = resolve_ref(spec, result["$ref"], _visited) + + # Cache resolved value (D-01) + _ref_cache[ref] = result + return result + + +def parse_request_body(operation: dict, spec: dict) -> tuple[dict, str | None]: + """ + Parse requestBody into normalized param dict and content type. + + Handles application/json, multipart/form-data, and application/octet-stream. + Output matches v2 param dict format with added nullable field (D-04, D-05, D-06, D-10). + + Args: + operation: Single operation dict from OpenAPI spec (e.g., paths["/foo"]["post"]). + spec: Full OpenAPI spec dict (for $ref resolution). + + Returns: + Tuple of (params_dict, content_type). + params_dict: {param_name: {required, in, type, description, nullable, ...}} + content_type: String like "application/json" or None if no requestBody. + """ + if "requestBody" not in operation: + return {}, None + + request_body = operation["requestBody"] + content = request_body.get("content", {}) + + # Priority order: json > multipart > octet-stream (D-04) + if "application/json" in content: + content_type = "application/json" + schema = content["application/json"].get("schema", {}) + elif "multipart/form-data" in content: + content_type = "multipart/form-data" + schema = content["multipart/form-data"].get("schema", {}) + elif "application/octet-stream" in content: + # Octet-stream: single 'file' param (D-05) + return { + "file": { + "required": True, + "in": "body", + "type": "file", + "description": "Binary file content", + "nullable": False, + } + }, "application/octet-stream" + else: + return {}, None + + if not schema: + return {}, content_type + + # Resolve $ref if schema itself is a reference + if "$ref" in schema: + schema = resolve_ref(spec, schema["$ref"]) + + # Extract properties and required list + properties = schema.get("properties", {}) + required_list = schema.get("required", []) + + params = {} + for prop_name, prop_schema in properties.items(): + # Resolve nested $ref in property + if "$ref" in prop_schema: + prop_schema = resolve_ref(spec, prop_schema["$ref"]) + + entry = { + "required": prop_name in required_list, + "in": "body", + "type": prop_schema.get("type", "object"), + "description": prop_schema.get("description", ""), + "nullable": prop_schema.get("nullable", False), # D-10 + } + + # Include enum if present + if "enum" in prop_schema: + entry["enum"] = prop_schema["enum"] + + # Include array items if present + if prop_schema.get("type") == "array" and "items" in prop_schema: + entry["items"] = prop_schema["items"] + + params[prop_name] = entry + + return params, content_type + + +def _document_oneof(schema: dict) -> tuple[str, str]: + """Document oneOf schema as type string with property details.""" + if "oneOf" not in schema: + return schema.get("type", "object"), "" + + oneof_types = [s.get("type") for s in schema["oneOf"] if "type" in s] + + # Extract object properties + object_props = [] + for s in schema["oneOf"]: + if s.get("type") == "object" and "properties" in s: + object_props = sorted(s["properties"].keys()) + break + + type_str = " or ".join(sorted(set(oneof_types))) + + desc_add = "" + if object_props: + desc_add = f" (object supports: {', '.join(object_props)})" + + return type_str, desc_add + + +def _extract_param_entry(p: dict, spec: dict) -> dict: + """Convert OASv3 parameter to v2-compatible param dict entry.""" + schema = p.get("schema", {}) + if "$ref" in schema: + schema = resolve_ref(spec, schema["$ref"]) + + # Handle oneOf schemas + if "oneOf" in schema: + type_str, desc_add = _document_oneof(schema) + elif schema.get("type") == "array": + type_str = "array" + else: + type_str = schema.get("type", "string") + + description = p.get("description", schema.get("description", "")) + if "oneOf" in schema: + _, desc_add = _document_oneof(schema) + if desc_add: + description = description + desc_add if description else desc_add + + entry = { + "required": p.get("required", False), + "in": p.get("in", "query"), + "type": type_str, + "description": description, + "nullable": schema.get("nullable", False), + } + + # Include enum if present + if "enum" in schema: + entry["enum"] = schema["enum"] + + # Array params: include items and style/explode defaults + if schema.get("type") == "array": + if "items" in schema: + entry["items"] = schema["items"] + # OASv3 defaults for query arrays: style=form, explode=true + if p.get("in", "query") == "query": + entry["style"] = schema.get("style", "form") + entry["explode"] = schema.get("explode", True) + + return entry + + +def parse_params_v3(operation: dict, path_item: dict, spec: dict, param_filters=None) -> tuple[dict, dict]: + """ + Parse and merge parameters from path-level and operation-level sources. + + Merges path_item.parameters + operation.parameters (op overrides on name+in match), + then merges requestBody params via parse_request_body. Detects perPage for pagination injection. + Applies param_filters via return_params if provided (per CONTEXT.md: reuse v2 filter logic). + + Args: + operation: Operation dict (e.g., paths["/foo"]["get"]). + path_item: PathItem dict (e.g., paths["/foo"]) for path-level params. + spec: Full OpenAPI spec dict (for $ref resolution). + param_filters: Optional list of filter strings (e.g., ["required", "query"]). + Passed to return_params() from generate_library.py. None = return all. + + Returns: + Tuple of (params_dict, metadata_dict). + params_dict: {param_name: {required, in, type, description, nullable, ...}} + metadata_dict: {"content_type": str | None} + """ + if param_filters is None: + param_filters = [] + + merged_params = {} + + # Step 1: Inherit path-level parameters + for p in path_item.get("parameters", []): + if "$ref" in p: + p = resolve_ref(spec, p["$ref"]) + key = (p["name"], p.get("in", "query")) + merged_params[key] = p + + # Step 2: Add/override with operation-level parameters + for p in operation.get("parameters", []): + if "$ref" in p: + p = resolve_ref(spec, p["$ref"]) + key = (p["name"], p.get("in", "query")) + merged_params[key] = p # Overrides path-level on name+in match + + # Step 3: Convert to v2-compatible param dict format + params = {} + for (name, location), p in merged_params.items(): + params[name] = _extract_param_entry(p, spec) + + # Step 4: Merge requestBody params + body_params, content_type = parse_request_body(operation, spec) + params.update(body_params) + + # Step 5: Detect perPage and inject pagination params + operation_id = operation.get("operationId", "") + if "perPage" in params: + params.update(generate_pagination_parameters(operation_id)) + + # Step 6: Apply param_filters via return_params (per CONTEXT.md decision) + params = return_params(operation_id, params, param_filters) + + metadata = {"content_type": content_type} + return params, metadata diff --git a/generator/readme.md b/generator/readme.md index eb2c24ef..8689ef6a 100644 --- a/generator/readme.md +++ b/generator/readme.md @@ -1,28 +1,35 @@ # Generating the Meraki Dashboard API Python Library -Generally speaking, you will not need to generate this yourself. Simply use the official PyPI package -via `pip install --update meraki`. +Most users don't need this: just `pip install --upgrade meraki`, or a [published beta +release](https://github.com/meraki/dashboard-api-python#releases) for early-access operations. -However, if you participate in Early Access features, you may want to generate a library to match your org's spec. In -which case, follow along. +Generate the library yourself only when you need it matched to your own org's spec. Follow along below. -> **NB:** The generator requires Python 3.10 or later. +> **NB:** The generator requires Python 3.11 or later. 1. Clone this repo locally. 2. Open a terminal in this `generator` folder. -3. *Optional:* If you want to work with beta endpoints, then - first [review the warnings, and then opt one of your orgs into the Early API Access program](https://community.meraki.com/t5/Developers-APIs/UPDATED-Beta-testing-with-the-Meraki-Developer-Early-Access/m-p/145344#M5808). -4. Run `python generate_library.py -v locally_generated -o YOUR_ORG_ID -k YOUR_API_KEY` making these replacements: - -* Replace `YOUR_ORG_ID` with the org ID you want to use as reference. Use the one opted into Early API access if you - want the beta endpoints. -* Replace `YOUR_API_KEY` with an API key that has org admin privileges on that org. -* NB: Your local system may require minor syntax tweaks (e.g. Windows may require you prepend `generate_library.py` - with `.\`) - -5. You will now have a `meraki` module folder inside `generator`, which you can locally reference in your scripts. - Simply copy the `meraki` folder to those projects which require it. -6. In some cases, if you've already installed the official library, your scripts may prefer that one over the local - folder. If that happens, then calls to early access endpoints will fail. So, if necessary, uninstall any instances of - the meraki package that may have been installed in your venv or system, or replace the version installed in your venv - with that which you generated here. +3. Install dependencies using [uv](https://docs.astral.sh/uv/): + + ```shell + uv sync --group generator + ``` + +4. *Optional:* To work with beta operations, first [review the warnings and opt one of your orgs into the Early API + Access program](https://community.meraki.com/t5/Developers-APIs/UPDATED-Beta-testing-with-the-Meraki-Developer-Early-Access/m-p/145344#M5808). +5. Run the generator: + + ```shell + uv run --group generator python generate_library.py -v locally_generated -o YOUR_ORG_ID -k YOUR_API_KEY + ``` + + Making these replacements: + * Replace `YOUR_ORG_ID` with the org ID you want to use as reference. Use the one opted into Early API access if you + want the beta operations. + * Replace `YOUR_API_KEY` with an API key that has org admin privileges on that org. + * NB: Your local system may require minor syntax tweaks (e.g. Windows may require you prepend `generate_library.py` + with `.\`) + +6. You now have a `meraki` module folder inside `generator`. Copy it into any project that needs it. +7. If the official `meraki` package is also installed, your scripts may import it instead, and early-access calls will + fail. Uninstall the official package (or replace it with the one you generated) to avoid this. diff --git a/generator/stub_template.jinja2 b/generator/stub_template.jinja2 new file mode 100644 index 00000000..09364395 --- /dev/null +++ b/generator/stub_template.jinja2 @@ -0,0 +1,4 @@ +from typing import Any + +class {{ class_name }}: + def __init__(self, session: Any) -> None: ... diff --git a/meraki/__init__.py b/meraki/__init__.py index 2e24db57..114dcfe0 100644 --- a/meraki/__init__.py +++ b/meraki/__init__.py @@ -3,9 +3,11 @@ from meraki.api.administered import Administered from meraki.api.appliance import Appliance + # Batch class imports from meraki.api.batch import Batch from meraki.api.camera import Camera +from meraki.api.campusGateway import CampusGateway from meraki.api.cellularGateway import CellularGateway from meraki.api.devices import Devices from meraki.api.insight import Insight @@ -14,11 +16,15 @@ from meraki.api.organizations import Organizations from meraki.api.sensor import Sensor from meraki.api.sm import Sm +from meraki.api.spaces import Spaces from meraki.api.switch import Switch from meraki.api.wireless import Wireless +from meraki.api.wirelessController import WirelessController + # Config import from meraki.config import ( API_KEY_ENVIRONMENT_VARIABLE, + CUSTOM_HEADERS, DEFAULT_BASE_URL, SINGLE_REQUEST_TIMEOUT, CERTIFICATE_PATH, @@ -40,11 +46,29 @@ BE_GEO_ID, 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 * +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.72.0" -__version__ = '2.0.3' -__api_version__ = '1.58.0' +__all__ = [ + "APIError", + "APIKeyError", + "APIResponseError", + "AsyncAPIError", + "DashboardAPI", +] class DashboardAPI(object): @@ -52,6 +76,7 @@ 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 + - custom_headers (dict): optional extra HTTP headers appended to every request; SDK-managed headers (Authorization, Content-Type, User-Agent) take precedence on collision - 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 @@ -73,46 +98,58 @@ class DashboardAPI(object): - be_geo_id (string): optional partner identifier for API usage tracking; can also be set as an environment variable BE_GEO_ID - 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, - 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, - output_log=OUTPUT_LOG, - log_path=LOG_PATH, - log_file_prefix=LOG_FILE_PREFIX, - print_console=PRINT_TO_CONSOLE, - suppress_logging=SUPPRESS_LOGGING, - 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, - inherit_logging_config=INHERIT_LOGGING_CONFIG, - ): - + def __init__( + self, + api_key=None, + custom_headers=CUSTOM_HEADERS, + 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, + output_log=OUTPUT_LOG, + log_path=LOG_PATH, + log_file_prefix=LOG_FILE_PREFIX, + print_console=PRINT_TO_CONSOLE, + suppress_logging=SUPPRESS_LOGGING, + 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, + 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 BE GEO ID from an environment variable if present - be_geo_id = be_geo_id or os.environ.get('BE_GEO_ID') + 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 + caller = caller or os.environ.get("MERAKI_PYTHON_SDK_CALLER") # Configure logging if not suppress_logging: @@ -122,19 +159,17 @@ def __init__(self, self._logger.setLevel(logging.DEBUG) formatter = logging.Formatter( - fmt='%(asctime)s %(name)12s: %(levelname)8s > %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + fmt="%(asctime)s %(name)12s: %(levelname)8s > %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) handler_console = logging.StreamHandler() handler_console.setFormatter(formatter) if output_log: - if log_path and log_path[-1] != '/': - log_path += '/' - self._log_file = f'{log_path}{log_file_prefix}_log__{datetime.now():%Y-%m-%d_%H-%M-%S}.log' - handler_log = logging.FileHandler( - filename=self._log_file - ) + if log_path and log_path[-1] != "/": + log_path += "/" + self._log_file = f"{log_path}{log_file_prefix}_log__{datetime.now():%Y-%m-%d_%H-%M-%S}.log" + handler_log = logging.FileHandler(filename=self._log_file) handler_log.setFormatter(formatter) if output_log and not self._logger.hasHandlers(): @@ -151,6 +186,7 @@ def __init__(self, self._session = RestSession( logger=self._logger, api_key=api_key, + custom_headers=custom_headers, base_url=base_url, single_request_timeout=single_request_timeout, certificate_path=certificate_path, @@ -166,6 +202,14 @@ def __init__(self, be_geo_id=be_geo_id, 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 @@ -182,6 +226,47 @@ def __init__(self, self.sm = Sm(self._session) self.switch = Switch(self._session) self.wireless = Wireless(self._session) + self.spaces = Spaces(self._session) + self.wirelessController = WirelessController(self._session) + self.campusGateway = CampusGateway(self._session) # 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 new file mode 100644 index 00000000..ed48cdab --- /dev/null +++ b/meraki/_version.py @@ -0,0 +1 @@ +__version__ = "4.3.1" diff --git a/meraki/aio/__init__.py b/meraki/aio/__init__.py index c0c5cbac..3ac462b6 100644 --- a/meraki/aio/__init__.py +++ b/meraki/aio/__init__.py @@ -4,6 +4,7 @@ from meraki.aio.api.administered import AsyncAdministered from meraki.aio.api.appliance import AsyncAppliance from meraki.aio.api.camera import AsyncCamera +from meraki.aio.api.campusGateway import AsyncCampusGateway from meraki.aio.api.cellularGateway import AsyncCellularGateway from meraki.aio.api.devices import AsyncDevices from meraki.aio.api.insight import AsyncInsight @@ -12,14 +13,21 @@ from meraki.aio.api.organizations import AsyncOrganizations from meraki.aio.api.sensor import AsyncSensor from meraki.aio.api.sm import AsyncSm +from meraki.aio.api.spaces import AsyncSpaces from meraki.aio.api.switch import AsyncSwitch from meraki.aio.api.wireless import AsyncWireless -from meraki.aio.rest_session import * +from meraki.aio.api.wirelessController import AsyncWirelessController +from meraki.session.async_ import AsyncRestSession +from meraki.exceptions import APIKeyError +from datetime import datetime + # Batch class imports from meraki.api.batch import Batch + # Config import from meraki.config import ( API_KEY_ENVIRONMENT_VARIABLE, + CUSTOM_HEADERS, DEFAULT_BASE_URL, SINGLE_REQUEST_TIMEOUT, CERTIFICATE_PATH, @@ -42,6 +50,14 @@ MERAKI_PYTHON_SDK_CALLER, 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, ) @@ -50,6 +66,7 @@ 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 + - custom_headers (dict): optional extra HTTP headers appended to every request; SDK-managed headers (Authorization, Content-Type, User-Agent) take precedence on collision - 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 @@ -72,47 +89,59 @@ class AsyncDashboardAPI: - be_geo_id (string): optional partner identifier for API usage tracking; can also be set as an environment variable BE_GEO_ID - 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, - 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, - output_log=OUTPUT_LOG, - log_path=LOG_PATH, - log_file_prefix=LOG_FILE_PREFIX, - print_console=PRINT_TO_CONSOLE, - suppress_logging=SUPPRESS_LOGGING, - 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, - inherit_logging_config=INHERIT_LOGGING_CONFIG, - maximum_concurrent_requests=AIO_MAXIMUM_CONCURRENT_REQUESTS, - ): - + def __init__( + self, + api_key=None, + custom_headers=CUSTOM_HEADERS, + 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, + output_log=OUTPUT_LOG, + log_path=LOG_PATH, + log_file_prefix=LOG_FILE_PREFIX, + print_console=PRINT_TO_CONSOLE, + suppress_logging=SUPPRESS_LOGGING, + 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, + 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 BE GEO ID from an environment variable if present - be_geo_id = be_geo_id or os.environ.get('BE_GEO_ID') + 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 + caller = caller or os.environ.get("MERAKI_PYTHON_SDK_CALLER") # Configure logging if not suppress_logging: @@ -149,6 +178,7 @@ def __init__(self, self._session = AsyncRestSession( logger=self._logger, api_key=api_key, + custom_headers=custom_headers, base_url=base_url, single_request_timeout=single_request_timeout, certificate_path=certificate_path, @@ -165,8 +195,20 @@ def __init__(self, caller=caller, 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) @@ -181,12 +223,58 @@ def __init__(self, self.switch = AsyncSwitch(self._session) self.sm = AsyncSm(self._session) self.wireless = AsyncWireless(self._session) + self.spaces = AsyncSpaces(self._session) + self.wirelessController = AsyncWirelessController(self._session) + self.campusGateway = AsyncCampusGateway(self._session) # Batch definitions 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/administered.py b/meraki/aio/api/administered.py index 4568a92e..86a44296 100644 --- a/meraki/aio/api/administered.py +++ b/meraki/aio/api/administered.py @@ -5,8 +5,6 @@ class AsyncAdministered: def __init__(self, session): super().__init__() self._session = session - - def getAdministeredIdentitiesMe(self): """ @@ -16,14 +14,12 @@ def getAdministeredIdentitiesMe(self): """ metadata = { - 'tags': ['administered', 'monitor', 'identities', 'me'], - 'operation': 'getAdministeredIdentitiesMe' + "tags": ["administered", "monitor", "identities", "me"], + "operation": "getAdministeredIdentitiesMe", } - resource = f'/administered/identities/me' + resource = "/administered/identities/me" return self._session.get(metadata, resource) - - def getAdministeredIdentitiesMeApiKeys(self): """ @@ -33,14 +29,12 @@ def getAdministeredIdentitiesMeApiKeys(self): """ metadata = { - 'tags': ['administered', 'configure', 'identities', 'me', 'api', 'keys'], - 'operation': 'getAdministeredIdentitiesMeApiKeys' + "tags": ["administered", "configure", "identities", "me", "api", "keys"], + "operation": "getAdministeredIdentitiesMeApiKeys", } - resource = f'/administered/identities/me/api/keys' + resource = "/administered/identities/me/api/keys" return self._session.get(metadata, resource) - - def generateAdministeredIdentitiesMeApiKeys(self): """ @@ -50,14 +44,12 @@ def generateAdministeredIdentitiesMeApiKeys(self): """ metadata = { - 'tags': ['administered', 'configure', 'identities', 'me', 'api', 'keys'], - 'operation': 'generateAdministeredIdentitiesMeApiKeys' + "tags": ["administered", "configure", "identities", "me", "api", "keys"], + "operation": "generateAdministeredIdentitiesMeApiKeys", } - resource = f'/administered/identities/me/api/keys/generate' + resource = "/administered/identities/me/api/keys/generate" return self._session.post(metadata, resource) - - def revokeAdministeredIdentitiesMeApiKeys(self, suffix: str): """ @@ -68,11 +60,10 @@ def revokeAdministeredIdentitiesMeApiKeys(self, suffix: str): """ metadata = { - 'tags': ['administered', 'configure', 'identities', 'me', 'api', 'keys'], - 'operation': 'revokeAdministeredIdentitiesMeApiKeys' + "tags": ["administered", "configure", "identities", "me", "api", "keys"], + "operation": "revokeAdministeredIdentitiesMeApiKeys", } - suffix = urllib.parse.quote(str(suffix), safe='') - resource = f'/administered/identities/me/api/keys/{suffix}/revoke' + suffix = urllib.parse.quote(str(suffix), safe="") + resource = f"/administered/identities/me/api/keys/{suffix}/revoke" return self._session.post(metadata, resource) - diff --git a/meraki/aio/api/appliance.py b/meraki/aio/api/appliance.py index 78a3b6cc..09c98051 100644 --- a/meraki/aio/api/appliance.py +++ b/meraki/aio/api/appliance.py @@ -5,8 +5,6 @@ class AsyncAppliance: def __init__(self, session): super().__init__() self._session = session - - def getDeviceApplianceDhcpSubnets(self, serial: str): """ @@ -17,19 +15,58 @@ def getDeviceApplianceDhcpSubnets(self, serial: str): """ metadata = { - 'tags': ['appliance', 'monitor', 'dhcp', 'subnets'], - 'operation': 'getDeviceApplianceDhcpSubnets' + "tags": ["appliance", "monitor", "dhcp", "subnets"], + "operation": "getDeviceApplianceDhcpSubnets", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/appliance/dhcp/subnets' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/dhcp/subnets" 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 @@ -41,18 +78,26 @@ def getDeviceAppliancePerformance(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'monitor', 'performance'], - 'operation': 'getDeviceAppliancePerformance' + "tags": ["appliance", "monitor", "performance"], + "operation": "getDeviceAppliancePerformance", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/appliance/performance' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/performance" - query_params = ['t0', 't1', 'timespan', ] + query_params = [ + "t0", + "t1", + "timespan", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get(metadata, resource, params) - + if self._session._validate_kwargs: + all_params = query_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"getDeviceAppliancePerformance: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) def getDeviceAppliancePrefixesDelegated(self, serial: str): """ @@ -63,15 +108,13 @@ def getDeviceAppliancePrefixesDelegated(self, serial: str): """ metadata = { - 'tags': ['appliance', 'monitor', 'prefixes', 'delegated'], - 'operation': 'getDeviceAppliancePrefixesDelegated' + "tags": ["appliance", "monitor", "prefixes", "delegated"], + "operation": "getDeviceAppliancePrefixesDelegated", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/appliance/prefixes/delegated' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/prefixes/delegated" return self._session.get(metadata, resource) - - def getDeviceAppliancePrefixesDelegatedVlanAssignments(self, serial: str): """ @@ -82,15 +125,13 @@ def getDeviceAppliancePrefixesDelegatedVlanAssignments(self, serial: str): """ metadata = { - 'tags': ['appliance', 'monitor', 'prefixes', 'delegated', 'vlanAssignments'], - 'operation': 'getDeviceAppliancePrefixesDelegatedVlanAssignments' + "tags": ["appliance", "monitor", "prefixes", "delegated", "vlanAssignments"], + "operation": "getDeviceAppliancePrefixesDelegatedVlanAssignments", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/appliance/prefixes/delegated/vlanAssignments' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/prefixes/delegated/vlanAssignments" return self._session.get(metadata, resource) - - def getDeviceApplianceRadioSettings(self, serial: str): """ @@ -101,15 +142,13 @@ def getDeviceApplianceRadioSettings(self, serial: str): """ metadata = { - 'tags': ['appliance', 'configure', 'radio', 'settings'], - 'operation': 'getDeviceApplianceRadioSettings' + "tags": ["appliance", "configure", "radio", "settings"], + "operation": "getDeviceApplianceRadioSettings", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/appliance/radio/settings' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/radio/settings" return self._session.get(metadata, resource) - - def updateDeviceApplianceRadioSettings(self, serial: str, **kwargs): """ @@ -125,41 +164,47 @@ def updateDeviceApplianceRadioSettings(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'radio', 'settings'], - 'operation': 'updateDeviceApplianceRadioSettings' + "tags": ["appliance", "configure", "radio", "settings"], + "operation": "updateDeviceApplianceRadioSettings", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/appliance/radio/settings' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/radio/settings" - body_params = ['rfProfileId', 'twoFourGhzSettings', 'fiveGhzSettings', ] + body_params = [ + "rfProfileId", + "twoFourGhzSettings", + "fiveGhzSettings", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceApplianceRadioSettings: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getDeviceApplianceUplinksSettings(self, serial: str): """ - **Return the uplink settings for an MX appliance** + **Return the uplink settings for a secure router or security appliance** https://developer.cisco.com/meraki/api-v1/#!get-device-appliance-uplinks-settings - serial (string): Serial """ metadata = { - 'tags': ['appliance', 'configure', 'uplinks', 'settings'], - 'operation': 'getDeviceApplianceUplinksSettings' + "tags": ["appliance", "configure", "uplinks", "settings"], + "operation": "getDeviceApplianceUplinksSettings", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/appliance/uplinks/settings' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/uplinks/settings" return self._session.get(metadata, resource) - - - def updateDeviceApplianceUplinksSettings(self, serial: str, interfaces: dict): + def updateDeviceApplianceUplinksSettings(self, serial: str, interfaces: dict, **kwargs): """ - **Update the uplink settings for an MX appliance** + **Update the uplink settings for a secure router or security appliance** https://developer.cisco.com/meraki/api-v1/#!update-device-appliance-uplinks-settings - serial (string): Serial @@ -169,18 +214,24 @@ def updateDeviceApplianceUplinksSettings(self, serial: str, interfaces: dict): kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'uplinks', 'settings'], - 'operation': 'updateDeviceApplianceUplinksSettings' + "tags": ["appliance", "configure", "uplinks", "settings"], + "operation": "updateDeviceApplianceUplinksSettings", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/appliance/uplinks/settings' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/uplinks/settings" - body_params = ['interfaces', ] + body_params = [ + "interfaces", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceApplianceUplinksSettings: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def createDeviceApplianceVmxAuthenticationToken(self, serial: str): """ @@ -191,17 +242,17 @@ def createDeviceApplianceVmxAuthenticationToken(self, serial: str): """ metadata = { - 'tags': ['appliance', 'configure', 'vmx', 'authenticationToken'], - 'operation': 'createDeviceApplianceVmxAuthenticationToken' + "tags": ["appliance", "configure", "vmx", "authenticationToken"], + "operation": "createDeviceApplianceVmxAuthenticationToken", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/appliance/vmx/authenticationToken' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/appliance/vmx/authenticationToken" return self._session.post(metadata, resource) - - - def getNetworkApplianceClientSecurityEvents(self, networkId: str, clientId: str, total_pages=1, direction='next', **kwargs): + def getNetworkApplianceClientSecurityEvents( + self, networkId: str, clientId: str, total_pages=1, direction="next", **kwargs + ): """ **List the security events for a client** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-client-security-events @@ -221,24 +272,40 @@ def getNetworkApplianceClientSecurityEvents(self, networkId: str, clientId: str, kwargs.update(locals()) - if 'sortOrder' in kwargs: - options = ['ascending', 'descending'] - assert kwargs['sortOrder'] in options, f'''"sortOrder" cannot be "{kwargs['sortOrder']}", & must be set to one of: {options}''' - - metadata = { - 'tags': ['appliance', 'monitor', 'clients', 'security', 'events'], - 'operation': 'getNetworkApplianceClientSecurityEvents' - } - networkId = urllib.parse.quote(str(networkId), safe='') - clientId = urllib.parse.quote(str(clientId), safe='') - resource = f'/networks/{networkId}/appliance/clients/{clientId}/security/events' - - query_params = ['t0', 't1', 'timespan', 'perPage', 'startingAfter', 'endingBefore', 'sortOrder', ] + if "sortOrder" in kwargs: + options = ["ascending", "descending"] + assert kwargs["sortOrder"] in options, ( + f'''"sortOrder" cannot be "{kwargs["sortOrder"]}", & must be set to one of: {options}''' + ) + + metadata = { + "tags": ["appliance", "monitor", "clients", "security", "events"], + "operation": "getNetworkApplianceClientSecurityEvents", + } + networkId = urllib.parse.quote(str(networkId), safe="") + clientId = urllib.parse.quote(str(clientId), safe="") + resource = f"/networks/{networkId}/appliance/clients/{clientId}/security/events" + + query_params = [ + "t0", + "t1", + "timespan", + "perPage", + "startingAfter", + "endingBefore", + "sortOrder", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get_pages(metadata, resource, params, total_pages, direction) - + if self._session._validate_kwargs: + all_params = query_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"getNetworkApplianceClientSecurityEvents: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) def getNetworkApplianceConnectivityMonitoringDestinations(self, networkId: str): """ @@ -249,15 +316,13 @@ def getNetworkApplianceConnectivityMonitoringDestinations(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'connectivityMonitoringDestinations'], - 'operation': 'getNetworkApplianceConnectivityMonitoringDestinations' + "tags": ["appliance", "configure", "connectivityMonitoringDestinations"], + "operation": "getNetworkApplianceConnectivityMonitoringDestinations", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/connectivityMonitoringDestinations' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/connectivityMonitoringDestinations" return self._session.get(metadata, resource) - - def updateNetworkApplianceConnectivityMonitoringDestinations(self, networkId: str, **kwargs): """ @@ -271,18 +336,26 @@ def updateNetworkApplianceConnectivityMonitoringDestinations(self, networkId: st kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'connectivityMonitoringDestinations'], - 'operation': 'updateNetworkApplianceConnectivityMonitoringDestinations' + "tags": ["appliance", "configure", "connectivityMonitoringDestinations"], + "operation": "updateNetworkApplianceConnectivityMonitoringDestinations", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/connectivityMonitoringDestinations' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/connectivityMonitoringDestinations" - body_params = ['destinations', ] + body_params = [ + "destinations", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceConnectivityMonitoringDestinations: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceContentFiltering(self, networkId: str): """ @@ -293,15 +366,13 @@ def getNetworkApplianceContentFiltering(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'contentFiltering'], - 'operation': 'getNetworkApplianceContentFiltering' + "tags": ["appliance", "configure", "contentFiltering"], + "operation": "getNetworkApplianceContentFiltering", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/contentFiltering' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/contentFiltering" return self._session.get(metadata, resource) - - def updateNetworkApplianceContentFiltering(self, networkId: str, **kwargs): """ @@ -317,23 +388,36 @@ def updateNetworkApplianceContentFiltering(self, networkId: str, **kwargs): kwargs.update(locals()) - if 'urlCategoryListSize' in kwargs: - options = ['fullList', 'topSites'] - assert kwargs['urlCategoryListSize'] in options, f'''"urlCategoryListSize" cannot be "{kwargs['urlCategoryListSize']}", & must be set to one of: {options}''' + if "urlCategoryListSize" in kwargs: + options = ["fullList", "topSites"] + assert kwargs["urlCategoryListSize"] in options, ( + f'''"urlCategoryListSize" cannot be "{kwargs["urlCategoryListSize"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['appliance', 'configure', 'contentFiltering'], - 'operation': 'updateNetworkApplianceContentFiltering' + "tags": ["appliance", "configure", "contentFiltering"], + "operation": "updateNetworkApplianceContentFiltering", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/contentFiltering' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/contentFiltering" - body_params = ['allowedUrlPatterns', 'blockedUrlPatterns', 'blockedUrlCategories', 'urlCategoryListSize', ] + body_params = [ + "allowedUrlPatterns", + "blockedUrlPatterns", + "blockedUrlCategories", + "urlCategoryListSize", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceContentFiltering: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceContentFilteringCategories(self, networkId: str): """ @@ -344,15 +428,73 @@ def getNetworkApplianceContentFilteringCategories(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'contentFiltering', 'categories'], - 'operation': 'getNetworkApplianceContentFilteringCategories' + "tags": ["appliance", "configure", "contentFiltering", "categories"], + "operation": "getNetworkApplianceContentFilteringCategories", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/contentFiltering/categories' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/contentFiltering/categories" return self._session.get(metadata, resource) - + def updateNetworkApplianceDevicesRedundancy(self, networkId: str, enabled: bool, **kwargs): + """ + **Update MX warm spare settings** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-devices-redundancy + + - networkId (string): Network ID + - enabled (boolean): Enable warm spare + - mode (string): HA mode (disabled|active-passive|active-active) + - designations (array): Ordered warm spare roles + - uplink (object): Uplink configuration + """ + + kwargs.update(locals()) + + if "mode" in kwargs: + options = ["active-active", "active-passive", "disabled"] + assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}''' + + metadata = { + "tags": ["appliance", "configure", "devices", "redundancy"], + "operation": "updateNetworkApplianceDevicesRedundancy", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/devices/redundancy" + + body_params = [ + "enabled", + "mode", + "designations", + "uplink", + ] + 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"updateNetworkApplianceDevicesRedundancy: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) + + def createNetworkApplianceDevicesRedundancySwap(self, networkId: str): + """ + **Swap MX primary and warm spare appliances** + https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-devices-redundancy-swap + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "devices", "redundancy", "swap"], + "operation": "createNetworkApplianceDevicesRedundancySwap", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/devices/redundancy/swap" + + return self._session.post(metadata, resource) def getNetworkApplianceFirewallCellularFirewallRules(self, networkId: str): """ @@ -363,15 +505,13 @@ def getNetworkApplianceFirewallCellularFirewallRules(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'cellularFirewallRules'], - 'operation': 'getNetworkApplianceFirewallCellularFirewallRules' + "tags": ["appliance", "configure", "firewall", "cellularFirewallRules"], + "operation": "getNetworkApplianceFirewallCellularFirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/cellularFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/cellularFirewallRules" return self._session.get(metadata, resource) - - def updateNetworkApplianceFirewallCellularFirewallRules(self, networkId: str, **kwargs): """ @@ -385,18 +525,26 @@ def updateNetworkApplianceFirewallCellularFirewallRules(self, networkId: str, ** kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'cellularFirewallRules'], - 'operation': 'updateNetworkApplianceFirewallCellularFirewallRules' + "tags": ["appliance", "configure", "firewall", "cellularFirewallRules"], + "operation": "updateNetworkApplianceFirewallCellularFirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/cellularFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/cellularFirewallRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallCellularFirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallFirewalledServices(self, networkId: str): """ @@ -407,15 +555,13 @@ def getNetworkApplianceFirewallFirewalledServices(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'firewalledServices'], - 'operation': 'getNetworkApplianceFirewallFirewalledServices' + "tags": ["appliance", "configure", "firewall", "firewalledServices"], + "operation": "getNetworkApplianceFirewallFirewalledServices", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/firewalledServices' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/firewalledServices" return self._session.get(metadata, resource) - - def getNetworkApplianceFirewallFirewalledService(self, networkId: str, service: str): """ @@ -427,16 +573,14 @@ def getNetworkApplianceFirewallFirewalledService(self, networkId: str, service: """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'firewalledServices'], - 'operation': 'getNetworkApplianceFirewallFirewalledService' + "tags": ["appliance", "configure", "firewall", "firewalledServices"], + "operation": "getNetworkApplianceFirewallFirewalledService", } - networkId = urllib.parse.quote(str(networkId), safe='') - service = urllib.parse.quote(str(service), safe='') - resource = f'/networks/{networkId}/appliance/firewall/firewalledServices/{service}' + networkId = urllib.parse.quote(str(networkId), safe="") + service = urllib.parse.quote(str(service), safe="") + resource = f"/networks/{networkId}/appliance/firewall/firewalledServices/{service}" return self._session.get(metadata, resource) - - def updateNetworkApplianceFirewallFirewalledService(self, networkId: str, service: str, access: str, **kwargs): """ @@ -446,29 +590,40 @@ def updateNetworkApplianceFirewallFirewalledService(self, networkId: str, servic - networkId (string): Network ID - service (string): Service - access (string): A string indicating the rule for which IPs are allowed to use the specified service. Acceptable values are "blocked" (no remote IPs can access the service), "restricted" (only allowed IPs can access the service), and "unrestriced" (any remote IP can access the service). This field is required - - allowedIps (array): An array of allowed IPs that can access the service. This field is required if "access" is set to "restricted". Otherwise this field is ignored + - allowedIps (array): An array of allowed CIDRs that can access the service. This field is required if "access" is set to "restricted". Otherwise this field is ignored """ kwargs.update(locals()) - if 'access' in kwargs: - options = ['blocked', 'restricted', 'unrestricted'] - assert kwargs['access'] in options, f'''"access" cannot be "{kwargs['access']}", & must be set to one of: {options}''' + if "access" in kwargs: + options = ["blocked", "restricted", "unrestricted"] + assert kwargs["access"] in options, ( + f'''"access" cannot be "{kwargs["access"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'firewalledServices'], - 'operation': 'updateNetworkApplianceFirewallFirewalledService' + "tags": ["appliance", "configure", "firewall", "firewalledServices"], + "operation": "updateNetworkApplianceFirewallFirewalledService", } - networkId = urllib.parse.quote(str(networkId), safe='') - service = urllib.parse.quote(str(service), safe='') - resource = f'/networks/{networkId}/appliance/firewall/firewalledServices/{service}' + networkId = urllib.parse.quote(str(networkId), safe="") + service = urllib.parse.quote(str(service), safe="") + resource = f"/networks/{networkId}/appliance/firewall/firewalledServices/{service}" - body_params = ['access', 'allowedIps', ] + body_params = [ + "access", + "allowedIps", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallFirewalledService: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallInboundCellularFirewallRules(self, networkId: str): """ @@ -479,15 +634,13 @@ def getNetworkApplianceFirewallInboundCellularFirewallRules(self, networkId: str """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'inboundCellularFirewallRules'], - 'operation': 'getNetworkApplianceFirewallInboundCellularFirewallRules' + "tags": ["appliance", "configure", "firewall", "inboundCellularFirewallRules"], + "operation": "getNetworkApplianceFirewallInboundCellularFirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/inboundCellularFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/inboundCellularFirewallRules" return self._session.get(metadata, resource) - - def updateNetworkApplianceFirewallInboundCellularFirewallRules(self, networkId: str, **kwargs): """ @@ -501,18 +654,26 @@ def updateNetworkApplianceFirewallInboundCellularFirewallRules(self, networkId: kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'inboundCellularFirewallRules'], - 'operation': 'updateNetworkApplianceFirewallInboundCellularFirewallRules' + "tags": ["appliance", "configure", "firewall", "inboundCellularFirewallRules"], + "operation": "updateNetworkApplianceFirewallInboundCellularFirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/inboundCellularFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/inboundCellularFirewallRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallInboundCellularFirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallInboundFirewallRules(self, networkId: str): """ @@ -523,15 +684,13 @@ def getNetworkApplianceFirewallInboundFirewallRules(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'inboundFirewallRules'], - 'operation': 'getNetworkApplianceFirewallInboundFirewallRules' + "tags": ["appliance", "configure", "firewall", "inboundFirewallRules"], + "operation": "getNetworkApplianceFirewallInboundFirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/inboundFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/inboundFirewallRules" return self._session.get(metadata, resource) - - def updateNetworkApplianceFirewallInboundFirewallRules(self, networkId: str, **kwargs): """ @@ -546,18 +705,27 @@ def updateNetworkApplianceFirewallInboundFirewallRules(self, networkId: str, **k kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'inboundFirewallRules'], - 'operation': 'updateNetworkApplianceFirewallInboundFirewallRules' + "tags": ["appliance", "configure", "firewall", "inboundFirewallRules"], + "operation": "updateNetworkApplianceFirewallInboundFirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/inboundFirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/inboundFirewallRules" - body_params = ['rules', 'syslogDefaultRule', ] + body_params = [ + "rules", + "syslogDefaultRule", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallInboundFirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallL3FirewallRules(self, networkId: str): """ @@ -568,15 +736,13 @@ def getNetworkApplianceFirewallL3FirewallRules(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l3FirewallRules'], - 'operation': 'getNetworkApplianceFirewallL3FirewallRules' + "tags": ["appliance", "configure", "firewall", "l3FirewallRules"], + "operation": "getNetworkApplianceFirewallL3FirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/l3FirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l3FirewallRules" return self._session.get(metadata, resource) - - def updateNetworkApplianceFirewallL3FirewallRules(self, networkId: str, **kwargs): """ @@ -591,18 +757,27 @@ def updateNetworkApplianceFirewallL3FirewallRules(self, networkId: str, **kwargs kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l3FirewallRules'], - 'operation': 'updateNetworkApplianceFirewallL3FirewallRules' + "tags": ["appliance", "configure", "firewall", "l3FirewallRules"], + "operation": "updateNetworkApplianceFirewallL3FirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/l3FirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l3FirewallRules" - body_params = ['rules', 'syslogDefaultRule', ] + body_params = [ + "rules", + "syslogDefaultRule", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallL3FirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallL7FirewallRules(self, networkId: str): """ @@ -613,15 +788,13 @@ def getNetworkApplianceFirewallL7FirewallRules(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l7FirewallRules'], - 'operation': 'getNetworkApplianceFirewallL7FirewallRules' + "tags": ["appliance", "configure", "firewall", "l7FirewallRules"], + "operation": "getNetworkApplianceFirewallL7FirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/l7FirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l7FirewallRules" return self._session.get(metadata, resource) - - def updateNetworkApplianceFirewallL7FirewallRules(self, networkId: str, **kwargs): """ @@ -629,24 +802,32 @@ def updateNetworkApplianceFirewallL7FirewallRules(self, networkId: str, **kwargs https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-l-7-firewall-rules - networkId (string): Network ID - - rules (array): An ordered array of the MX L7 firewall rules + - rules (array): An ordered array of the MX L7 firewall rules. Each rule is an object with 'policy', 'type', and 'value'. The 'value' shape depends on 'type': object for application/applicationCategory, string for host/port/ipRange, and an array of 2-letter ISO 3166-1 alpha-2 country codes for allowedCountries/blockedCountries. For backward compatibility, request types also accept whitelistedCountries/blacklistedCountries. """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l7FirewallRules'], - 'operation': 'updateNetworkApplianceFirewallL7FirewallRules' + "tags": ["appliance", "configure", "firewall", "l7FirewallRules"], + "operation": "updateNetworkApplianceFirewallL7FirewallRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/l7FirewallRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l7FirewallRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallL7FirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallL7FirewallRulesApplicationCategories(self, networkId: str): """ @@ -657,17 +838,15 @@ def getNetworkApplianceFirewallL7FirewallRulesApplicationCategories(self, networ """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'l7FirewallRules', 'applicationCategories'], - 'operation': 'getNetworkApplianceFirewallL7FirewallRulesApplicationCategories' + "tags": ["appliance", "configure", "firewall", "l7FirewallRules", "applicationCategories"], + "operation": "getNetworkApplianceFirewallL7FirewallRulesApplicationCategories", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/l7FirewallRules/applicationCategories' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/l7FirewallRules/applicationCategories" return self._session.get(metadata, resource) - - - def updateNetworkApplianceFirewallMulticastForwarding(self, networkId: str, rules: list): + def updateNetworkApplianceFirewallMulticastForwarding(self, networkId: str, rules: list, **kwargs): """ **Update static multicast forward rules for a network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-multicast-forwarding @@ -679,18 +858,26 @@ def updateNetworkApplianceFirewallMulticastForwarding(self, networkId: str, rule kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'multicastForwarding'], - 'operation': 'updateNetworkApplianceFirewallMulticastForwarding' + "tags": ["appliance", "configure", "firewall", "multicastForwarding"], + "operation": "updateNetworkApplianceFirewallMulticastForwarding", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/multicastForwarding' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/multicastForwarding" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallMulticastForwarding: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallOneToManyNatRules(self, networkId: str): """ @@ -701,17 +888,15 @@ def getNetworkApplianceFirewallOneToManyNatRules(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'oneToManyNatRules'], - 'operation': 'getNetworkApplianceFirewallOneToManyNatRules' + "tags": ["appliance", "configure", "firewall", "oneToManyNatRules"], + "operation": "getNetworkApplianceFirewallOneToManyNatRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/oneToManyNatRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/oneToManyNatRules" return self._session.get(metadata, resource) - - - def updateNetworkApplianceFirewallOneToManyNatRules(self, networkId: str, rules: list): + def updateNetworkApplianceFirewallOneToManyNatRules(self, networkId: str, rules: list, **kwargs): """ **Set the 1:Many NAT mapping rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-one-to-many-nat-rules @@ -723,18 +908,26 @@ def updateNetworkApplianceFirewallOneToManyNatRules(self, networkId: str, rules: kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'oneToManyNatRules'], - 'operation': 'updateNetworkApplianceFirewallOneToManyNatRules' + "tags": ["appliance", "configure", "firewall", "oneToManyNatRules"], + "operation": "updateNetworkApplianceFirewallOneToManyNatRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/oneToManyNatRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/oneToManyNatRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallOneToManyNatRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallOneToOneNatRules(self, networkId: str): """ @@ -745,17 +938,15 @@ def getNetworkApplianceFirewallOneToOneNatRules(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'oneToOneNatRules'], - 'operation': 'getNetworkApplianceFirewallOneToOneNatRules' + "tags": ["appliance", "configure", "firewall", "oneToOneNatRules"], + "operation": "getNetworkApplianceFirewallOneToOneNatRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/oneToOneNatRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/oneToOneNatRules" return self._session.get(metadata, resource) - - - def updateNetworkApplianceFirewallOneToOneNatRules(self, networkId: str, rules: list): + def updateNetworkApplianceFirewallOneToOneNatRules(self, networkId: str, rules: list, **kwargs): """ **Set the 1:1 NAT mapping rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-one-to-one-nat-rules @@ -767,18 +958,26 @@ def updateNetworkApplianceFirewallOneToOneNatRules(self, networkId: str, rules: kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'oneToOneNatRules'], - 'operation': 'updateNetworkApplianceFirewallOneToOneNatRules' + "tags": ["appliance", "configure", "firewall", "oneToOneNatRules"], + "operation": "updateNetworkApplianceFirewallOneToOneNatRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/oneToOneNatRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/oneToOneNatRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallOneToOneNatRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallPortForwardingRules(self, networkId: str): """ @@ -789,17 +988,15 @@ def getNetworkApplianceFirewallPortForwardingRules(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'portForwardingRules'], - 'operation': 'getNetworkApplianceFirewallPortForwardingRules' + "tags": ["appliance", "configure", "firewall", "portForwardingRules"], + "operation": "getNetworkApplianceFirewallPortForwardingRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/portForwardingRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/portForwardingRules" return self._session.get(metadata, resource) - - - def updateNetworkApplianceFirewallPortForwardingRules(self, networkId: str, rules: list): + def updateNetworkApplianceFirewallPortForwardingRules(self, networkId: str, rules: list, **kwargs): """ **Update the port forwarding rules for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-firewall-port-forwarding-rules @@ -811,18 +1008,26 @@ def updateNetworkApplianceFirewallPortForwardingRules(self, networkId: str, rule kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'portForwardingRules'], - 'operation': 'updateNetworkApplianceFirewallPortForwardingRules' + "tags": ["appliance", "configure", "firewall", "portForwardingRules"], + "operation": "updateNetworkApplianceFirewallPortForwardingRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/portForwardingRules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/portForwardingRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceFirewallPortForwardingRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceFirewallSettings(self, networkId: str): """ @@ -833,15 +1038,13 @@ def getNetworkApplianceFirewallSettings(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'settings'], - 'operation': 'getNetworkApplianceFirewallSettings' + "tags": ["appliance", "configure", "firewall", "settings"], + "operation": "getNetworkApplianceFirewallSettings", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/firewall/settings' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/settings" return self._session.get(metadata, resource) - - def updateNetworkApplianceFirewallSettings(self, networkId: str, **kwargs): """ @@ -855,41 +1058,134 @@ def updateNetworkApplianceFirewallSettings(self, networkId: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'settings'], - 'operation': 'updateNetworkApplianceFirewallSettings' + "tags": ["appliance", "configure", "firewall", "settings"], + "operation": "updateNetworkApplianceFirewallSettings", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/firewall/settings" + + body_params = [ + "spoofingProtection", + ] + 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"updateNetworkApplianceFirewallSettings: ignoring unrecognized kwargs: {invalid}" + ) + + 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='') - resource = f'/networks/{networkId}/appliance/firewall/settings' + networkId = urllib.parse.quote(str(networkId), safe="") + interfaceId = urllib.parse.quote(str(interfaceId), safe="") + resource = f"/networks/{networkId}/appliance/interfaces/l3/{interfaceId}" - body_params = ['spoofingProtection', ] + 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 MX.** + **List per-port VLAN settings for all ports of a secure router or security appliance.** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-ports - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'ports'], - 'operation': 'getNetworkAppliancePorts' + "tags": ["appliance", "configure", "ports"], + "operation": "getNetworkAppliancePorts", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/ports' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/ports" return self._session.get(metadata, resource) - - def getNetworkAppliancePort(self, networkId: str, portId: str): """ - **Return per-port VLAN settings for a single MX port.** + **Return per-port VLAN settings for a single secure router or security appliance port.** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-port - networkId (string): Network ID @@ -897,20 +1193,18 @@ def getNetworkAppliancePort(self, networkId: str, portId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'ports'], - 'operation': 'getNetworkAppliancePort' + "tags": ["appliance", "configure", "ports"], + "operation": "getNetworkAppliancePort", } - networkId = urllib.parse.quote(str(networkId), safe='') - portId = urllib.parse.quote(str(portId), safe='') - resource = f'/networks/{networkId}/appliance/ports/{portId}' + networkId = urllib.parse.quote(str(networkId), safe="") + portId = urllib.parse.quote(str(portId), safe="") + resource = f"/networks/{networkId}/appliance/ports/{portId}" return self._session.get(metadata, resource) - - def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs): """ - **Update the per-port VLAN settings for a single MX port.** + **Update the per-port VLAN settings for a single secure router or security appliance port.** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-port - networkId (string): Network ID @@ -919,26 +1213,39 @@ def updateNetworkAppliancePort(self, networkId: str, portId: str, **kwargs): - dropUntaggedTraffic (boolean): Trunk port can Drop all Untagged traffic. When true, no VLAN is required. Access ports cannot have dropUntaggedTraffic set to true. - type (string): The type of the port: 'access' or 'trunk'. - 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 the VLAN ID's allowed on the port, or 'all' to permit all VLAN's on the port. + - 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()) metadata = { - 'tags': ['appliance', 'configure', 'ports'], - 'operation': 'updateNetworkAppliancePort' + "tags": ["appliance", "configure", "ports"], + "operation": "updateNetworkAppliancePort", } - networkId = urllib.parse.quote(str(networkId), safe='') - portId = urllib.parse.quote(str(portId), safe='') - resource = f'/networks/{networkId}/appliance/ports/{portId}' + networkId = urllib.parse.quote(str(networkId), safe="") + portId = urllib.parse.quote(str(portId), safe="") + resource = f"/networks/{networkId}/appliance/ports/{portId}" - body_params = ['enabled', 'dropUntaggedTraffic', 'type', 'vlan', 'allowedVlans', 'accessPolicy', ] + body_params = [ + "enabled", + "dropUntaggedTraffic", + "type", + "vlan", + "allowedVlans", + "accessPolicy", + "sgt", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkAppliancePort: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkAppliancePrefixesDelegatedStatics(self, networkId: str): """ @@ -949,15 +1256,13 @@ def getNetworkAppliancePrefixesDelegatedStatics(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'prefixes', 'delegated', 'statics'], - 'operation': 'getNetworkAppliancePrefixesDelegatedStatics' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "getNetworkAppliancePrefixesDelegatedStatics", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/prefixes/delegated/statics' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics" return self._session.get(metadata, resource) - - def createNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, prefix: str, origin: dict, **kwargs): """ @@ -973,18 +1278,28 @@ def createNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, prefix: kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'prefixes', 'delegated', 'statics'], - 'operation': 'createNetworkAppliancePrefixesDelegatedStatic' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "createNetworkAppliancePrefixesDelegatedStatic", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/prefixes/delegated/statics' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics" - body_params = ['prefix', 'origin', 'description', ] + body_params = [ + "prefix", + "origin", + "description", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createNetworkAppliancePrefixesDelegatedStatic: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDelegatedPrefixId: str): """ @@ -996,16 +1311,14 @@ def getNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDeleg """ metadata = { - 'tags': ['appliance', 'configure', 'prefixes', 'delegated', 'statics'], - 'operation': 'getNetworkAppliancePrefixesDelegatedStatic' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "getNetworkAppliancePrefixesDelegatedStatic", } - networkId = urllib.parse.quote(str(networkId), safe='') - staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe='') - resource = f'/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}' + networkId = urllib.parse.quote(str(networkId), safe="") + staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe="") + resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}" return self._session.get(metadata, resource) - - def updateNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDelegatedPrefixId: str, **kwargs): """ @@ -1022,19 +1335,29 @@ def updateNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDe kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'prefixes', 'delegated', 'statics'], - 'operation': 'updateNetworkAppliancePrefixesDelegatedStatic' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "updateNetworkAppliancePrefixesDelegatedStatic", } - networkId = urllib.parse.quote(str(networkId), safe='') - staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe='') - resource = f'/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}' + 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 = ['prefix', 'origin', 'description', ] + body_params = [ + "prefix", + "origin", + "description", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkAppliancePrefixesDelegatedStatic: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDelegatedPrefixId: str): """ @@ -1046,16 +1369,14 @@ def deleteNetworkAppliancePrefixesDelegatedStatic(self, networkId: str, staticDe """ metadata = { - 'tags': ['appliance', 'configure', 'prefixes', 'delegated', 'statics'], - 'operation': 'deleteNetworkAppliancePrefixesDelegatedStatic' + "tags": ["appliance", "configure", "prefixes", "delegated", "statics"], + "operation": "deleteNetworkAppliancePrefixesDelegatedStatic", } - networkId = urllib.parse.quote(str(networkId), safe='') - staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe='') - resource = f'/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}' + networkId = urllib.parse.quote(str(networkId), safe="") + staticDelegatedPrefixId = urllib.parse.quote(str(staticDelegatedPrefixId), safe="") + resource = f"/networks/{networkId}/appliance/prefixes/delegated/statics/{staticDelegatedPrefixId}" return self._session.delete(metadata, resource) - - def getNetworkApplianceRfProfiles(self, networkId: str): """ @@ -1066,15 +1387,13 @@ def getNetworkApplianceRfProfiles(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'rfProfiles'], - 'operation': 'getNetworkApplianceRfProfiles' + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "getNetworkApplianceRfProfiles", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/rfProfiles' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles" return self._session.get(metadata, resource) - - def createNetworkApplianceRfProfile(self, networkId: str, name: str, **kwargs): """ @@ -1091,18 +1410,27 @@ def createNetworkApplianceRfProfile(self, networkId: str, name: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'rfProfiles'], - 'operation': 'createNetworkApplianceRfProfile' + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "createNetworkApplianceRfProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/rfProfiles' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles" - body_params = ['name', 'twoFourGhzSettings', 'fiveGhzSettings', 'perSsidSettings', ] + body_params = [ + "name", + "twoFourGhzSettings", + "fiveGhzSettings", + "perSsidSettings", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createNetworkApplianceRfProfile: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def updateNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str, **kwargs): """ @@ -1120,19 +1448,28 @@ def updateNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str, **kw kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'rfProfiles'], - 'operation': 'updateNetworkApplianceRfProfile' + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "updateNetworkApplianceRfProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - rfProfileId = urllib.parse.quote(str(rfProfileId), safe='') - resource = f'/networks/{networkId}/appliance/rfProfiles/{rfProfileId}' + networkId = urllib.parse.quote(str(networkId), safe="") + rfProfileId = urllib.parse.quote(str(rfProfileId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles/{rfProfileId}" - body_params = ['name', 'twoFourGhzSettings', 'fiveGhzSettings', 'perSsidSettings', ] + body_params = [ + "name", + "twoFourGhzSettings", + "fiveGhzSettings", + "perSsidSettings", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceRfProfile: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def deleteNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str): """ @@ -1144,16 +1481,14 @@ def deleteNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'rfProfiles'], - 'operation': 'deleteNetworkApplianceRfProfile' + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "deleteNetworkApplianceRfProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - rfProfileId = urllib.parse.quote(str(rfProfileId), safe='') - resource = f'/networks/{networkId}/appliance/rfProfiles/{rfProfileId}' + networkId = urllib.parse.quote(str(networkId), safe="") + rfProfileId = urllib.parse.quote(str(rfProfileId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles/{rfProfileId}" return self._session.delete(metadata, resource) - - def getNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str): """ @@ -1165,16 +1500,14 @@ def getNetworkApplianceRfProfile(self, networkId: str, rfProfileId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'rfProfiles'], - 'operation': 'getNetworkApplianceRfProfile' + "tags": ["appliance", "configure", "rfProfiles"], + "operation": "getNetworkApplianceRfProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - rfProfileId = urllib.parse.quote(str(rfProfileId), safe='') - resource = f'/networks/{networkId}/appliance/rfProfiles/{rfProfileId}' + networkId = urllib.parse.quote(str(networkId), safe="") + rfProfileId = urllib.parse.quote(str(rfProfileId), safe="") + resource = f"/networks/{networkId}/appliance/rfProfiles/{rfProfileId}" return self._session.get(metadata, resource) - - def updateNetworkApplianceSdwanInternetPolicies(self, networkId: str, **kwargs): """ @@ -1188,20 +1521,28 @@ def updateNetworkApplianceSdwanInternetPolicies(self, networkId: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'sdwan', 'internetPolicies'], - 'operation': 'updateNetworkApplianceSdwanInternetPolicies' + "tags": ["appliance", "configure", "sdwan", "internetPolicies"], + "operation": "updateNetworkApplianceSdwanInternetPolicies", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/sdwan/internetPolicies' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/sdwan/internetPolicies" - body_params = ['wanTrafficUplinkPreferences', ] + body_params = [ + "wanTrafficUplinkPreferences", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceSdwanInternetPolicies: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) - def getNetworkApplianceSecurityEvents(self, networkId: str, total_pages=1, direction='next', **kwargs): + def getNetworkApplianceSecurityEvents(self, networkId: str, total_pages=1, direction="next", **kwargs): """ **List the security events for a network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-security-events @@ -1220,23 +1561,37 @@ def getNetworkApplianceSecurityEvents(self, networkId: str, total_pages=1, direc kwargs.update(locals()) - if 'sortOrder' in kwargs: - options = ['ascending', 'descending'] - assert kwargs['sortOrder'] in options, f'''"sortOrder" cannot be "{kwargs['sortOrder']}", & must be set to one of: {options}''' + if "sortOrder" in kwargs: + options = ["ascending", "descending"] + assert kwargs["sortOrder"] in options, ( + f'''"sortOrder" cannot be "{kwargs["sortOrder"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['appliance', 'monitor', 'security', 'events'], - 'operation': 'getNetworkApplianceSecurityEvents' + "tags": ["appliance", "monitor", "security", "events"], + "operation": "getNetworkApplianceSecurityEvents", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/security/events' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/events" - query_params = ['t0', 't1', 'timespan', 'perPage', 'startingAfter', 'endingBefore', 'sortOrder', ] + query_params = [ + "t0", + "t1", + "timespan", + "perPage", + "startingAfter", + "endingBefore", + "sortOrder", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get_pages(metadata, resource, params, total_pages, direction) - + if self._session._validate_kwargs: + all_params = query_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"getNetworkApplianceSecurityEvents: ignoring unrecognized kwargs: {invalid}") + return self._session.get_pages(metadata, resource, params, total_pages, direction) def getNetworkApplianceSecurityIntrusion(self, networkId: str): """ @@ -1247,15 +1602,13 @@ def getNetworkApplianceSecurityIntrusion(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'security', 'intrusion'], - 'operation': 'getNetworkApplianceSecurityIntrusion' + "tags": ["appliance", "configure", "security", "intrusion"], + "operation": "getNetworkApplianceSecurityIntrusion", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/security/intrusion' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/intrusion" return self._session.get(metadata, resource) - - def updateNetworkApplianceSecurityIntrusion(self, networkId: str, **kwargs): """ @@ -1270,26 +1623,38 @@ def updateNetworkApplianceSecurityIntrusion(self, networkId: str, **kwargs): kwargs.update(locals()) - if 'mode' in kwargs: - options = ['detection', 'disabled', 'prevention'] - assert kwargs['mode'] in options, f'''"mode" cannot be "{kwargs['mode']}", & must be set to one of: {options}''' - if 'idsRulesets' in kwargs: - options = ['balanced', 'connectivity', 'security'] - assert kwargs['idsRulesets'] in options, f'''"idsRulesets" cannot be "{kwargs['idsRulesets']}", & must be set to one of: {options}''' + if "mode" in kwargs: + options = ["detection", "disabled", "prevention"] + assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}''' + if "idsRulesets" in kwargs: + options = ["balanced", "connectivity", "security"] + assert kwargs["idsRulesets"] in options, ( + f'''"idsRulesets" cannot be "{kwargs["idsRulesets"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['appliance', 'configure', 'security', 'intrusion'], - 'operation': 'updateNetworkApplianceSecurityIntrusion' + "tags": ["appliance", "configure", "security", "intrusion"], + "operation": "updateNetworkApplianceSecurityIntrusion", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/security/intrusion' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/intrusion" - body_params = ['mode', 'idsRulesets', 'protectedNetworks', ] + body_params = [ + "mode", + "idsRulesets", + "protectedNetworks", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceSecurityIntrusion: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceSecurityMalware(self, networkId: str): """ @@ -1300,15 +1665,13 @@ def getNetworkApplianceSecurityMalware(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'security', 'malware'], - 'operation': 'getNetworkApplianceSecurityMalware' + "tags": ["appliance", "configure", "security", "malware"], + "operation": "getNetworkApplianceSecurityMalware", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/security/malware' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/malware" return self._session.get(metadata, resource) - - def updateNetworkApplianceSecurityMalware(self, networkId: str, mode: str, **kwargs): """ @@ -1323,23 +1686,33 @@ def updateNetworkApplianceSecurityMalware(self, networkId: str, mode: str, **kwa kwargs.update(locals()) - if 'mode' in kwargs: - options = ['disabled', 'enabled'] - assert kwargs['mode'] in options, f'''"mode" cannot be "{kwargs['mode']}", & must be set to one of: {options}''' + if "mode" in kwargs: + options = ["disabled", "enabled"] + assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}''' metadata = { - 'tags': ['appliance', 'configure', 'security', 'malware'], - 'operation': 'updateNetworkApplianceSecurityMalware' + "tags": ["appliance", "configure", "security", "malware"], + "operation": "updateNetworkApplianceSecurityMalware", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/security/malware' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/security/malware" - body_params = ['mode', 'allowedUrls', 'allowedFiles', ] + body_params = [ + "mode", + "allowedUrls", + "allowedFiles", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceSecurityMalware: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceSettings(self, networkId: str): """ @@ -1350,15 +1723,13 @@ def getNetworkApplianceSettings(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'settings'], - 'operation': 'getNetworkApplianceSettings' + "tags": ["appliance", "configure", "settings"], + "operation": "getNetworkApplianceSettings", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/settings' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/settings" return self._session.get(metadata, resource) - - def updateNetworkApplianceSettings(self, networkId: str, **kwargs): """ @@ -1373,26 +1744,38 @@ def updateNetworkApplianceSettings(self, networkId: str, **kwargs): kwargs.update(locals()) - if 'clientTrackingMethod' in kwargs: - options = ['IP address', 'MAC address', 'Unique client identifier'] - assert kwargs['clientTrackingMethod'] in options, f'''"clientTrackingMethod" cannot be "{kwargs['clientTrackingMethod']}", & must be set to one of: {options}''' - if 'deploymentMode' in kwargs: - options = ['passthrough', 'routed'] - assert kwargs['deploymentMode'] in options, f'''"deploymentMode" cannot be "{kwargs['deploymentMode']}", & must be set to one of: {options}''' - - metadata = { - 'tags': ['appliance', 'configure', 'settings'], - 'operation': 'updateNetworkApplianceSettings' - } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/settings' - - body_params = ['clientTrackingMethod', 'deploymentMode', 'dynamicDns', ] + if "clientTrackingMethod" in kwargs: + options = ["IP address", "MAC address", "Unique client identifier"] + assert kwargs["clientTrackingMethod"] in options, ( + f'''"clientTrackingMethod" cannot be "{kwargs["clientTrackingMethod"]}", & must be set to one of: {options}''' + ) + if "deploymentMode" in kwargs: + options = ["passthrough", "routed"] + assert kwargs["deploymentMode"] in options, ( + f'''"deploymentMode" cannot be "{kwargs["deploymentMode"]}", & must be set to one of: {options}''' + ) + + metadata = { + "tags": ["appliance", "configure", "settings"], + "operation": "updateNetworkApplianceSettings", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/settings" + + body_params = [ + "clientTrackingMethod", + "deploymentMode", + "dynamicDns", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceSettings: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkApplianceSingleLan(self, networkId: str): """ @@ -1403,15 +1786,13 @@ def getNetworkApplianceSingleLan(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'singleLan'], - 'operation': 'getNetworkApplianceSingleLan' + "tags": ["appliance", "configure", "singleLan"], + "operation": "getNetworkApplianceSingleLan", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/singleLan' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/singleLan" return self._session.get(metadata, resource) - - def updateNetworkApplianceSingleLan(self, networkId: str, **kwargs): """ @@ -1428,18 +1809,27 @@ def updateNetworkApplianceSingleLan(self, networkId: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'singleLan'], - 'operation': 'updateNetworkApplianceSingleLan' + "tags": ["appliance", "configure", "singleLan"], + "operation": "updateNetworkApplianceSingleLan", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/singleLan' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/singleLan" - body_params = ['subnet', 'applianceIp', 'ipv6', 'mandatoryDhcp', ] + body_params = [ + "subnet", + "applianceIp", + "ipv6", + "mandatoryDhcp", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceSingleLan: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkApplianceSsids(self, networkId: str): """ @@ -1450,15 +1840,13 @@ def getNetworkApplianceSsids(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'ssids'], - 'operation': 'getNetworkApplianceSsids' + "tags": ["appliance", "configure", "ssids"], + "operation": "getNetworkApplianceSsids", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/ssids' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/ssids" return self._session.get(metadata, resource) - - def getNetworkApplianceSsid(self, networkId: str, number: str): """ @@ -1470,16 +1858,14 @@ def getNetworkApplianceSsid(self, networkId: str, number: str): """ metadata = { - 'tags': ['appliance', 'configure', 'ssids'], - 'operation': 'getNetworkApplianceSsid' + "tags": ["appliance", "configure", "ssids"], + "operation": "getNetworkApplianceSsid", } - networkId = urllib.parse.quote(str(networkId), safe='') - number = urllib.parse.quote(str(number), safe='') - resource = f'/networks/{networkId}/appliance/ssids/{number}' + networkId = urllib.parse.quote(str(networkId), safe="") + number = urllib.parse.quote(str(number), safe="") + resource = f"/networks/{networkId}/appliance/ssids/{number}" return self._session.get(metadata, resource) - - def updateNetworkApplianceSsid(self, networkId: str, number: str, **kwargs): """ @@ -1497,36 +1883,58 @@ def updateNetworkApplianceSsid(self, networkId: str, number: str, **kwargs): - encryptionMode (string): The psk encryption mode for the SSID ('wep' or 'wpa'). This param is only valid if the authMode is 'psk'. - wpaEncryptionMode (string): The types of WPA encryption. ('WPA1 and WPA2', 'WPA2 only', 'WPA3 Transition Mode' or 'WPA3 only'). This param is only valid if (1) the authMode is 'psk' & the encryptionMode is 'wpa' OR (2) the authMode is '8021x-meraki' OR (3) the authMode is '8021x-radius' - visible (boolean): Boolean indicating whether the MX should advertise or hide this SSID. - - dhcpEnforcedDeauthentication (object): DHCP Enforced Deauthentication enables the disassociation of wireless clients in addition to Mandatory DHCP. This param is only valid on firmware versions >= MX 17.0 where the associated LAN has Mandatory DHCP Enabled + - dhcpEnforcedDeauthentication (object): DHCP Enforced Deauthentication enables the disassociation of wireless clients in addition to Mandatory DHCP. This param is only valid on firmware versions >= MX 17.0 where the associated LAN has Mandatory DHCP Enabled - dot11w (object): The current setting for Protected Management Frames (802.11w). """ kwargs.update(locals()) - if 'authMode' in kwargs: - options = ['8021x-meraki', '8021x-radius', 'open', 'psk'] - assert kwargs['authMode'] in options, f'''"authMode" cannot be "{kwargs['authMode']}", & must be set to one of: {options}''' - if 'encryptionMode' in kwargs: - options = ['wep', 'wpa'] - assert kwargs['encryptionMode'] in options, f'''"encryptionMode" cannot be "{kwargs['encryptionMode']}", & must be set to one of: {options}''' - if 'wpaEncryptionMode' in kwargs: - options = ['WPA1 and WPA2', 'WPA2 only', 'WPA3 Transition Mode', 'WPA3 only'] - assert kwargs['wpaEncryptionMode'] in options, f'''"wpaEncryptionMode" cannot be "{kwargs['wpaEncryptionMode']}", & must be set to one of: {options}''' - - metadata = { - 'tags': ['appliance', 'configure', 'ssids'], - 'operation': 'updateNetworkApplianceSsid' - } - networkId = urllib.parse.quote(str(networkId), safe='') - number = urllib.parse.quote(str(number), safe='') - resource = f'/networks/{networkId}/appliance/ssids/{number}' - - body_params = ['name', 'enabled', 'defaultVlanId', 'authMode', 'psk', 'radiusServers', 'encryptionMode', 'wpaEncryptionMode', 'visible', 'dhcpEnforcedDeauthentication', 'dot11w', ] + if "authMode" in kwargs: + options = ["8021x-meraki", "8021x-radius", "open", "psk"] + assert kwargs["authMode"] in options, ( + f'''"authMode" cannot be "{kwargs["authMode"]}", & must be set to one of: {options}''' + ) + if "encryptionMode" in kwargs: + options = ["wep", "wpa"] + assert kwargs["encryptionMode"] in options, ( + f'''"encryptionMode" cannot be "{kwargs["encryptionMode"]}", & must be set to one of: {options}''' + ) + if "wpaEncryptionMode" in kwargs: + options = ["WPA1 and WPA2", "WPA2 only", "WPA3 Transition Mode", "WPA3 only"] + assert kwargs["wpaEncryptionMode"] in options, ( + f'''"wpaEncryptionMode" cannot be "{kwargs["wpaEncryptionMode"]}", & must be set to one of: {options}''' + ) + + metadata = { + "tags": ["appliance", "configure", "ssids"], + "operation": "updateNetworkApplianceSsid", + } + networkId = urllib.parse.quote(str(networkId), safe="") + number = urllib.parse.quote(str(number), safe="") + resource = f"/networks/{networkId}/appliance/ssids/{number}" + + body_params = [ + "name", + "enabled", + "defaultVlanId", + "authMode", + "psk", + "radiusServers", + "encryptionMode", + "wpaEncryptionMode", + "visible", + "dhcpEnforcedDeauthentication", + "dot11w", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceSsid: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkApplianceStaticRoutes(self, networkId: str): """ @@ -1537,15 +1945,13 @@ def getNetworkApplianceStaticRoutes(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'getNetworkApplianceStaticRoutes' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "getNetworkApplianceStaticRoutes", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/staticRoutes' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes" return self._session.get(metadata, resource) - - def createNetworkApplianceStaticRoute(self, networkId: str, name: str, subnet: str, gatewayIp: str, **kwargs): """ @@ -1556,24 +1962,33 @@ def createNetworkApplianceStaticRoute(self, networkId: str, name: str, subnet: s - name (string): Name of the route - subnet (string): Subnet of the route - gatewayIp (string): Gateway IP address (next hop) - - gatewayVlanId (string): Gateway VLAN ID + - gatewayVlanId (integer): Gateway VLAN ID """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'createNetworkApplianceStaticRoute' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "createNetworkApplianceStaticRoute", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/staticRoutes' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes" - body_params = ['name', 'subnet', 'gatewayIp', 'gatewayVlanId', ] + body_params = [ + "name", + "subnet", + "gatewayIp", + "gatewayVlanId", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createNetworkApplianceStaticRoute: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str): """ @@ -1585,16 +2000,14 @@ def getNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'getNetworkApplianceStaticRoute' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "getNetworkApplianceStaticRoute", } - networkId = urllib.parse.quote(str(networkId), safe='') - staticRouteId = urllib.parse.quote(str(staticRouteId), safe='') - resource = f'/networks/{networkId}/appliance/staticRoutes/{staticRouteId}' + networkId = urllib.parse.quote(str(networkId), safe="") + staticRouteId = urllib.parse.quote(str(staticRouteId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes/{staticRouteId}" return self._session.get(metadata, resource) - - def updateNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str, **kwargs): """ @@ -1606,7 +2019,7 @@ def updateNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str, - name (string): Name of the route - subnet (string): Subnet of the route - gatewayIp (string): Gateway IP address (next hop) - - gatewayVlanId (string): Gateway VLAN ID + - gatewayVlanId (integer): Gateway VLAN ID - enabled (boolean): Whether the route should be enabled or not - fixedIpAssignments (object): Fixed DHCP IP assignments on the route - reservedIpRanges (array): DHCP reserved IP ranges @@ -1615,19 +2028,31 @@ def updateNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str, kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'updateNetworkApplianceStaticRoute' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "updateNetworkApplianceStaticRoute", } - networkId = urllib.parse.quote(str(networkId), safe='') - staticRouteId = urllib.parse.quote(str(staticRouteId), safe='') - resource = f'/networks/{networkId}/appliance/staticRoutes/{staticRouteId}' + networkId = urllib.parse.quote(str(networkId), safe="") + staticRouteId = urllib.parse.quote(str(staticRouteId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes/{staticRouteId}" - body_params = ['name', 'subnet', 'gatewayIp', 'gatewayVlanId', 'enabled', 'fixedIpAssignments', 'reservedIpRanges', ] + body_params = [ + "name", + "subnet", + "gatewayIp", + "gatewayVlanId", + "enabled", + "fixedIpAssignments", + "reservedIpRanges", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceStaticRoute: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def deleteNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str): """ @@ -1639,16 +2064,14 @@ def deleteNetworkApplianceStaticRoute(self, networkId: str, staticRouteId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'staticRoutes'], - 'operation': 'deleteNetworkApplianceStaticRoute' + "tags": ["appliance", "configure", "staticRoutes"], + "operation": "deleteNetworkApplianceStaticRoute", } - networkId = urllib.parse.quote(str(networkId), safe='') - staticRouteId = urllib.parse.quote(str(staticRouteId), safe='') - resource = f'/networks/{networkId}/appliance/staticRoutes/{staticRouteId}' + networkId = urllib.parse.quote(str(networkId), safe="") + staticRouteId = urllib.parse.quote(str(staticRouteId), safe="") + resource = f"/networks/{networkId}/appliance/staticRoutes/{staticRouteId}" return self._session.delete(metadata, resource) - - def getNetworkApplianceTrafficShaping(self, networkId: str): """ @@ -1659,17 +2082,15 @@ def getNetworkApplianceTrafficShaping(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping'], - 'operation': 'getNetworkApplianceTrafficShaping' + "tags": ["appliance", "configure", "trafficShaping"], + "operation": "getNetworkApplianceTrafficShaping", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping" return self._session.get(metadata, resource) - - - def updateNetworkApplianceTrafficShaping(self, networkId: str, **kwargs): + def updateNetworkApplianceTrafficShaping(self, networkId: str, globalBandwidthLimits: dict, **kwargs): """ **Update the traffic shaping settings for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping @@ -1678,21 +2099,27 @@ def updateNetworkApplianceTrafficShaping(self, networkId: str, **kwargs): - globalBandwidthLimits (object): Global per-client bandwidth limit """ - kwargs.update(locals()) + kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping'], - 'operation': 'updateNetworkApplianceTrafficShaping' + "tags": ["appliance", "configure", "trafficShaping"], + "operation": "updateNetworkApplianceTrafficShaping", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping" - body_params = ['globalBandwidthLimits', ] + body_params = [ + "globalBandwidthLimits", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceTrafficShaping: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkApplianceTrafficShapingCustomPerformanceClasses(self, networkId: str): """ @@ -1703,15 +2130,13 @@ def getNetworkApplianceTrafficShapingCustomPerformanceClasses(self, networkId: s """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'getNetworkApplianceTrafficShapingCustomPerformanceClasses' + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "getNetworkApplianceTrafficShapingCustomPerformanceClasses", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses" return self._session.get(metadata, resource) - - def createNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, name: str, **kwargs): """ @@ -1728,18 +2153,29 @@ def createNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'createNetworkApplianceTrafficShapingCustomPerformanceClass' + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "createNetworkApplianceTrafficShapingCustomPerformanceClass", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses" - body_params = ['name', 'maxLatency', 'maxJitter', 'maxLossPercentage', ] + body_params = [ + "name", + "maxLatency", + "maxJitter", + "maxLossPercentage", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createNetworkApplianceTrafficShapingCustomPerformanceClass: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, customPerformanceClassId: str): """ @@ -1751,18 +2187,18 @@ def getNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'getNetworkApplianceTrafficShapingCustomPerformanceClass' + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "getNetworkApplianceTrafficShapingCustomPerformanceClass", } - networkId = urllib.parse.quote(str(networkId), safe='') - customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}' + networkId = urllib.parse.quote(str(networkId), safe="") + customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}" return self._session.get(metadata, resource) - - - def updateNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, customPerformanceClassId: str, **kwargs): + def updateNetworkApplianceTrafficShapingCustomPerformanceClass( + self, networkId: str, customPerformanceClassId: str, **kwargs + ): """ **Update a custom performance class for an MX network** https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-custom-performance-class @@ -1778,19 +2214,30 @@ def updateNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'updateNetworkApplianceTrafficShapingCustomPerformanceClass' + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "updateNetworkApplianceTrafficShapingCustomPerformanceClass", } - networkId = urllib.parse.quote(str(networkId), safe='') - customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}' + networkId = urllib.parse.quote(str(networkId), safe="") + customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}" - body_params = ['name', 'maxLatency', 'maxJitter', 'maxLossPercentage', ] + body_params = [ + "name", + "maxLatency", + "maxJitter", + "maxLossPercentage", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceTrafficShapingCustomPerformanceClass: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: str, customPerformanceClassId: str): """ @@ -1802,45 +2249,52 @@ def deleteNetworkApplianceTrafficShapingCustomPerformanceClass(self, networkId: """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'customPerformanceClasses'], - 'operation': 'deleteNetworkApplianceTrafficShapingCustomPerformanceClass' + "tags": ["appliance", "configure", "trafficShaping", "customPerformanceClasses"], + "operation": "deleteNetworkApplianceTrafficShapingCustomPerformanceClass", } - networkId = urllib.parse.quote(str(networkId), safe='') - customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}' + networkId = urllib.parse.quote(str(networkId), safe="") + customPerformanceClassId = urllib.parse.quote(str(customPerformanceClassId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/customPerformanceClasses/{customPerformanceClassId}" return self._session.delete(metadata, resource) - - def updateNetworkApplianceTrafficShapingRules(self, networkId: str, **kwargs): """ - **Update the traffic shaping settings rules for an MX network** - https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-rules + **Update the traffic shaping settings rules for an MX network** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-traffic-shaping-rules - - networkId (string): Network ID - - defaultRulesEnabled (boolean): Whether default traffic shaping rules are enabled (true) or disabled (false). There are 4 default rules, which can be seen on your network's traffic shaping page. Note that default rules count against the rule limit of 8. - - rules (array): An array of traffic shaping rules. Rules are applied in the order that - they are specified in. An empty list (or null) means no rules. Note that - you are allowed a maximum of 8 rules. + - networkId (string): Network ID + - defaultRulesEnabled (boolean): Whether default traffic shaping rules are enabled (true) or disabled (false). There are 4 default rules, which can be seen on your network's traffic shaping page. Note that default rules count against the rule limit of 8. + - rules (array): An array of traffic shaping rules. Rules are applied in the order that + they are specified in. An empty list (or null) means no rules. Note that + you are allowed a maximum of 8 rules. """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'rules'], - 'operation': 'updateNetworkApplianceTrafficShapingRules' + "tags": ["appliance", "configure", "trafficShaping", "rules"], + "operation": "updateNetworkApplianceTrafficShapingRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/rules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/rules" - body_params = ['defaultRulesEnabled', 'rules', ] + body_params = [ + "defaultRulesEnabled", + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceTrafficShapingRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceTrafficShapingRules(self, networkId: str): """ @@ -1851,15 +2305,13 @@ def getNetworkApplianceTrafficShapingRules(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'rules'], - 'operation': 'getNetworkApplianceTrafficShapingRules' + "tags": ["appliance", "configure", "trafficShaping", "rules"], + "operation": "getNetworkApplianceTrafficShapingRules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/rules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/rules" return self._session.get(metadata, resource) - - def getNetworkApplianceTrafficShapingUplinkBandwidth(self, networkId: str): """ @@ -1870,15 +2322,13 @@ def getNetworkApplianceTrafficShapingUplinkBandwidth(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'uplinkBandwidth'], - 'operation': 'getNetworkApplianceTrafficShapingUplinkBandwidth' + "tags": ["appliance", "configure", "trafficShaping", "uplinkBandwidth"], + "operation": "getNetworkApplianceTrafficShapingUplinkBandwidth", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/uplinkBandwidth' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkBandwidth" return self._session.get(metadata, resource) - - def updateNetworkApplianceTrafficShapingUplinkBandwidth(self, networkId: str, **kwargs): """ @@ -1892,18 +2342,26 @@ def updateNetworkApplianceTrafficShapingUplinkBandwidth(self, networkId: str, ** kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'uplinkBandwidth'], - 'operation': 'updateNetworkApplianceTrafficShapingUplinkBandwidth' + "tags": ["appliance", "configure", "trafficShaping", "uplinkBandwidth"], + "operation": "updateNetworkApplianceTrafficShapingUplinkBandwidth", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/uplinkBandwidth' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkBandwidth" - body_params = ['bandwidthLimits', ] + body_params = [ + "bandwidthLimits", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceTrafficShapingUplinkBandwidth: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str): """ @@ -1914,15 +2372,13 @@ def getNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'uplinkSelection'], - 'operation': 'getNetworkApplianceTrafficShapingUplinkSelection' + "tags": ["appliance", "configure", "trafficShaping", "uplinkSelection"], + "operation": "getNetworkApplianceTrafficShapingUplinkSelection", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/uplinkSelection' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkSelection" return self._session.get(metadata, resource) - - def updateNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str, **kwargs): """ @@ -1931,7 +2387,7 @@ def updateNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str, ** - networkId (string): Network ID - activeActiveAutoVpnEnabled (boolean): Toggle for enabling or disabling active-active AutoVPN - - defaultUplink (string): The default uplink. Must be one of: 'wan1' or 'wan2' + - defaultUplink (string): The default uplink. Must be a WAN interface 'wanX' - loadBalancingEnabled (boolean): Toggle for enabling or disabling load balancing - failoverAndFailback (object): WAN failover and failback behavior - wanTrafficUplinkPreferences (array): Array of uplink preference rules for WAN traffic @@ -1940,23 +2396,32 @@ def updateNetworkApplianceTrafficShapingUplinkSelection(self, networkId: str, ** kwargs.update(locals()) - if 'defaultUplink' in kwargs: - options = ['wan1', 'wan2'] - assert kwargs['defaultUplink'] in options, f'''"defaultUplink" cannot be "{kwargs['defaultUplink']}", & must be set to one of: {options}''' - metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'uplinkSelection'], - 'operation': 'updateNetworkApplianceTrafficShapingUplinkSelection' + "tags": ["appliance", "configure", "trafficShaping", "uplinkSelection"], + "operation": "updateNetworkApplianceTrafficShapingUplinkSelection", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/uplinkSelection' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/uplinkSelection" - body_params = ['activeActiveAutoVpnEnabled', 'defaultUplink', 'loadBalancingEnabled', 'failoverAndFailback', 'wanTrafficUplinkPreferences', 'vpnTrafficUplinkPreferences', ] + body_params = [ + "activeActiveAutoVpnEnabled", + "defaultUplink", + "loadBalancingEnabled", + "failoverAndFailback", + "wanTrafficUplinkPreferences", + "vpnTrafficUplinkPreferences", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceTrafficShapingUplinkSelection: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def updateNetworkApplianceTrafficShapingVpnExclusions(self, networkId: str, **kwargs): """ @@ -1971,115 +2436,379 @@ def updateNetworkApplianceTrafficShapingVpnExclusions(self, networkId: str, **kw kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'vpnExclusions'], - 'operation': 'updateNetworkApplianceTrafficShapingVpnExclusions' + "tags": ["appliance", "configure", "trafficShaping", "vpnExclusions"], + "operation": "updateNetworkApplianceTrafficShapingVpnExclusions", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/trafficShaping/vpnExclusions' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/trafficShaping/vpnExclusions" - body_params = ['custom', 'majorApplications', ] + body_params = [ + "custom", + "majorApplications", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceTrafficShapingVpnExclusions: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) - def getNetworkApplianceUplinksUsageHistory(self, networkId: str, **kwargs): + def connectNetworkApplianceUmbrellaAccount(self, networkId: str, api: dict, **kwargs): """ - **Get the sent and received bytes for each uplink of a network.** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-uplinks-usage-history + **Connect a Cisco Umbrella account to this network** + https://developer.cisco.com/meraki/api-v1/#!connect-network-appliance-umbrella-account - networkId (string): Network ID - - t0 (string): The beginning of the timespan for the data. The maximum lookback period is 365 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. + - api (object): Umbrella API credentials """ - kwargs.update(locals()) + kwargs = locals() metadata = { - 'tags': ['appliance', 'monitor', 'uplinks', 'usageHistory'], - 'operation': 'getNetworkApplianceUplinksUsageHistory' + "tags": ["appliance", "configure", "umbrella", "account"], + "operation": "connectNetworkApplianceUmbrellaAccount", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/uplinks/usageHistory' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/umbrella/account/connect" - query_params = ['t0', 't1', 'timespan', 'resolution', ] - params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + body_params = [ + "api", + ] + payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.get(metadata, resource, 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"connectNetworkApplianceUmbrellaAccount: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) - def getNetworkApplianceVlans(self, networkId: str): + def disconnectNetworkApplianceUmbrellaAccount(self, networkId: str): """ - **List the VLANs for an MX network** - https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vlans + **Disconnect Umbrella account from this network** + https://developer.cisco.com/meraki/api-v1/#!disconnect-network-appliance-umbrella-account - networkId (string): Network ID """ metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'getNetworkApplianceVlans' + "tags": ["appliance", "configure", "umbrella", "account"], + "operation": "disconnectNetworkApplianceUmbrellaAccount", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/vlans' - - return self._session.get(metadata, resource) - + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/umbrella/account/disconnect" + return self._session.post(metadata, resource) - def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwargs): + def exclusionsNetworkApplianceUmbrellaDomains(self, networkId: str, domains: list, **kwargs): """ - **Add a VLAN** - https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-vlan + **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 - - id (string): The VLAN ID of the new VLAN (must be between 1 and 4094) - - name (string): The name of the new VLAN - - subnet (string): The subnet of the VLAN - - applianceIp (string): The local IP of the appliance on the VLAN - - groupPolicyId (string): The id of the desired group policy to apply to the VLAN - - templateVlanType (string): Type of subnetting of the VLAN. Applicable only for template network. - - cidr (string): CIDR of the pool of subnets. Applicable only for template network. Each network bound to the template will automatically pick a subnet from this pool to build its own VLAN. - - 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 - - dhcpHandling (string): The appliance's handling of DHCP requests on this VLAN. One of: 'Run a DHCP server', 'Relay DHCP to another server' or 'Do not respond to DHCP requests' - - dhcpLeaseTime (string): The term of DHCP leases if the appliance is running a DHCP server on this VLAN. One of: '30 minutes', '1 hour', '4 hours', '12 hours', '1 day' or '1 week' - - 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 - - dhcpBootOptionsEnabled (boolean): Use DHCP boot options specified in other properties - - 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. + - 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.update(locals()) - - if 'templateVlanType' in kwargs: - options = ['same', 'unique'] - assert kwargs['templateVlanType'] in options, f'''"templateVlanType" cannot be "{kwargs['templateVlanType']}", & must be set to one of: {options}''' - if 'dhcpHandling' in kwargs: - options = ['Do not respond to DHCP requests', 'Relay DHCP to another server', 'Run a DHCP server'] - assert kwargs['dhcpHandling'] in options, f'''"dhcpHandling" cannot be "{kwargs['dhcpHandling']}", & must be set to one of: {options}''' - if 'dhcpLeaseTime' in kwargs: - options = ['1 day', '1 hour', '1 week', '12 hours', '30 minutes', '4 hours'] - assert kwargs['dhcpLeaseTime'] in options, f'''"dhcpLeaseTime" cannot be "{kwargs['dhcpLeaseTime']}", & must be set to one of: {options}''' + kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'createNetworkApplianceVlan' + "tags": ["appliance", "configure", "umbrella", "domains"], + "operation": "exclusionsNetworkApplianceUmbrellaDomains", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/vlans' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/umbrella/domains/exclusions" - body_params = ['id', 'name', 'subnet', 'applianceIp', 'groupPolicyId', 'templateVlanType', 'cidr', 'mask', 'ipv6', 'dhcpHandling', 'dhcpLeaseTime', 'mandatoryDhcp', 'dhcpBootOptionsEnabled', 'dhcpOptions', ] + body_params = [ + "domains", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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 getNetworkApplianceVlansSettings(self, networkId: str): + 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** + https://developer.cisco.com/meraki/api-v1/#!update-network-appliance-uplinks-nat + + - networkId (string): Network ID + - uplinks (array): Per-uplink NAT exception configuration on the network. + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "uplinks", "nat"], + "operation": "updateNetworkApplianceUplinksNat", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/uplinks/nat" + + body_params = [ + "uplinks", + ] + 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"updateNetworkApplianceUplinksNat: ignoring unrecognized kwargs: {invalid}") + + return self._session.put(metadata, resource, payload) + + def getNetworkApplianceUplinksUsageHistory(self, networkId: str, **kwargs): + """ + **Get the sent and received bytes for each uplink of a network.** + 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 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. + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "monitor", "uplinks", "usageHistory"], + "operation": "getNetworkApplianceUplinksUsageHistory", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/uplinks/usageHistory" + + query_params = [ + "t0", + "t1", + "timespan", + "resolution", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_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"getNetworkApplianceUplinksUsageHistory: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get(metadata, resource, params) + + def getNetworkApplianceVlans(self, networkId: str): + """ + **List the VLANs for a Security Appliance network** + https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vlans + + - networkId (string): Network ID + """ + + metadata = { + "tags": ["appliance", "configure", "vlans"], + "operation": "getNetworkApplianceVlans", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vlans" + + return self._session.get(metadata, resource) + + def createNetworkApplianceVlan(self, networkId: str, id: str, name: str, **kwargs): + """ + **Add a VLAN** + https://developer.cisco.com/meraki/api-v1/#!create-network-appliance-vlan + + - networkId (string): Network ID + - id (string): The VLAN ID of the new VLAN (must be between 1 and 4094) + - name (string): The name of the new VLAN + - subnet (string): The subnet of the VLAN + - applianceIp (string): The local IP of the appliance on the VLAN + - groupPolicyId (string): The id of the desired group policy to apply to the VLAN + - templateVlanType (string): Type of subnetting of the VLAN. Applicable only for template network. + - cidr (string): CIDR of the pool of subnets. Applicable only for template network. Each network bound to the template will automatically pick a subnet from this pool to build its own VLAN. + - 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 + - dhcpHandling (string): The appliance's handling of DHCP requests on this VLAN. One of: 'Run a DHCP server', 'Relay DHCP to another server' or 'Do not respond to DHCP requests' + - dhcpRelayServerIps (array): The IPs (IPv4) of the DHCP servers that DHCP requests should be relayed to. CIDR/subnet notation and hostnames are not supported. + - dhcpLeaseTime (string): The term of DHCP leases if the appliance is running a DHCP server on this VLAN. One of: '30 minutes', '1 hour', '4 hours', '12 hours', '1 day' or '1 week' + - 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 + - dhcpBootOptionsEnabled (boolean): Use DHCP boot options specified in other properties + - 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. + """ + + kwargs.update(locals()) + + if "templateVlanType" in kwargs: + options = ["same", "unique"] + assert kwargs["templateVlanType"] in options, ( + f'''"templateVlanType" cannot be "{kwargs["templateVlanType"]}", & must be set to one of: {options}''' + ) + if "dhcpHandling" in kwargs: + options = ["Do not respond to DHCP requests", "Relay DHCP to another server", "Run a DHCP server"] + assert kwargs["dhcpHandling"] in options, ( + f'''"dhcpHandling" cannot be "{kwargs["dhcpHandling"]}", & must be set to one of: {options}''' + ) + if "dhcpLeaseTime" in kwargs: + options = ["1 day", "1 hour", "1 week", "12 hours", "30 minutes", "4 hours"] + assert kwargs["dhcpLeaseTime"] in options, ( + f'''"dhcpLeaseTime" cannot be "{kwargs["dhcpLeaseTime"]}", & must be set to one of: {options}''' + ) + + metadata = { + "tags": ["appliance", "configure", "vlans"], + "operation": "createNetworkApplianceVlan", + } + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vlans" + + body_params = [ + "id", + "name", + "subnet", + "applianceIp", + "groupPolicyId", + "templateVlanType", + "cidr", + "mask", + "ipv6", + "dhcpHandling", + "dhcpRelayServerIps", + "dhcpLeaseTime", + "mandatoryDhcp", + "dhcpBootOptionsEnabled", + "dhcpBootNextServer", + "dhcpBootFilename", + "dhcpOptions", + "sgt", + "vrf", + "uplinks", + ] + 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"createNetworkApplianceVlan: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getNetworkApplianceVlansSettings(self, networkId: str): """ **Returns the enabled status of VLANs for the network** https://developer.cisco.com/meraki/api-v1/#!get-network-appliance-vlans-settings @@ -2088,15 +2817,13 @@ def getNetworkApplianceVlansSettings(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'vlans', 'settings'], - 'operation': 'getNetworkApplianceVlansSettings' + "tags": ["appliance", "configure", "vlans", "settings"], + "operation": "getNetworkApplianceVlansSettings", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/vlans/settings' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/settings" return self._session.get(metadata, resource) - - def updateNetworkApplianceVlansSettings(self, networkId: str, **kwargs): """ @@ -2110,18 +2837,24 @@ def updateNetworkApplianceVlansSettings(self, networkId: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'vlans', 'settings'], - 'operation': 'updateNetworkApplianceVlansSettings' + "tags": ["appliance", "configure", "vlans", "settings"], + "operation": "updateNetworkApplianceVlansSettings", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/vlans/settings' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/settings" - body_params = ['vlansEnabled', ] + body_params = [ + "vlansEnabled", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceVlansSettings: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkApplianceVlan(self, networkId: str, vlanId: str): """ @@ -2133,16 +2866,14 @@ def getNetworkApplianceVlan(self, networkId: str, vlanId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'getNetworkApplianceVlan' + "tags": ["appliance", "configure", "vlans"], + "operation": "getNetworkApplianceVlan", } - networkId = urllib.parse.quote(str(networkId), safe='') - vlanId = urllib.parse.quote(str(vlanId), safe='') - resource = f'/networks/{networkId}/appliance/vlans/{vlanId}' + networkId = urllib.parse.quote(str(networkId), safe="") + vlanId = urllib.parse.quote(str(vlanId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/{vlanId}" return self._session.get(metadata, resource) - - def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs): """ @@ -2157,7 +2888,7 @@ def updateNetworkApplianceVlan(self, networkId: str, vlanId: str, **kwargs): - groupPolicyId (string): The id of the desired group policy to apply to the VLAN - vpnNatSubnet (string): The translated VPN subnet if VPN and VPN subnet translation are enabled on the VLAN - dhcpHandling (string): The appliance's handling of DHCP requests on this VLAN. One of: 'Run a DHCP server', 'Relay DHCP to another server' or 'Do not respond to DHCP requests' - - dhcpRelayServerIps (array): The IPs of the DHCP servers that DHCP requests should be relayed to + - dhcpRelayServerIps (array): The IPs (IPv4) of the DHCP servers that DHCP requests should be relayed to. CIDR/subnet notation and hostnames are not supported. - dhcpLeaseTime (string): The term of DHCP leases if the appliance is running a DHCP server on this VLAN. One of: '30 minutes', '1 hour', '4 hours', '12 hours', '1 day' or '1 week' - dhcpBootOptionsEnabled (boolean): Use DHCP boot options specified in other properties - dhcpBootNextServer (string): DHCP boot option to direct boot clients to the server to load the boot file from @@ -2171,34 +2902,71 @@ 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. """ kwargs.update(locals()) - if 'dhcpHandling' in kwargs: - options = ['Do not respond to DHCP requests', 'Relay DHCP to another server', 'Run a DHCP server'] - assert kwargs['dhcpHandling'] in options, f'''"dhcpHandling" cannot be "{kwargs['dhcpHandling']}", & must be set to one of: {options}''' - if 'dhcpLeaseTime' in kwargs: - options = ['1 day', '1 hour', '1 week', '12 hours', '30 minutes', '4 hours'] - assert kwargs['dhcpLeaseTime'] in options, f'''"dhcpLeaseTime" cannot be "{kwargs['dhcpLeaseTime']}", & must be set to one of: {options}''' - if 'templateVlanType' in kwargs: - options = ['same', 'unique'] - assert kwargs['templateVlanType'] in options, f'''"templateVlanType" cannot be "{kwargs['templateVlanType']}", & must be set to one of: {options}''' - - metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'updateNetworkApplianceVlan' - } - networkId = urllib.parse.quote(str(networkId), safe='') - vlanId = urllib.parse.quote(str(vlanId), safe='') - resource = f'/networks/{networkId}/appliance/vlans/{vlanId}' - - body_params = ['name', 'subnet', 'applianceIp', 'groupPolicyId', 'vpnNatSubnet', 'dhcpHandling', 'dhcpRelayServerIps', 'dhcpLeaseTime', 'dhcpBootOptionsEnabled', 'dhcpBootNextServer', 'dhcpBootFilename', 'fixedIpAssignments', 'reservedIpRanges', 'dnsNameservers', 'dhcpOptions', 'templateVlanType', 'cidr', 'mask', 'ipv6', 'mandatoryDhcp', ] + if "dhcpHandling" in kwargs: + options = ["Do not respond to DHCP requests", "Relay DHCP to another server", "Run a DHCP server"] + assert kwargs["dhcpHandling"] in options, ( + f'''"dhcpHandling" cannot be "{kwargs["dhcpHandling"]}", & must be set to one of: {options}''' + ) + if "dhcpLeaseTime" in kwargs: + options = ["1 day", "1 hour", "1 week", "12 hours", "30 minutes", "4 hours"] + assert kwargs["dhcpLeaseTime"] in options, ( + f'''"dhcpLeaseTime" cannot be "{kwargs["dhcpLeaseTime"]}", & must be set to one of: {options}''' + ) + if "templateVlanType" in kwargs: + options = ["same", "unique"] + assert kwargs["templateVlanType"] in options, ( + f'''"templateVlanType" cannot be "{kwargs["templateVlanType"]}", & must be set to one of: {options}''' + ) + + metadata = { + "tags": ["appliance", "configure", "vlans"], + "operation": "updateNetworkApplianceVlan", + } + networkId = urllib.parse.quote(str(networkId), safe="") + vlanId = urllib.parse.quote(str(vlanId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/{vlanId}" + + body_params = [ + "name", + "subnet", + "applianceIp", + "groupPolicyId", + "vpnNatSubnet", + "dhcpHandling", + "dhcpRelayServerIps", + "dhcpLeaseTime", + "dhcpBootOptionsEnabled", + "dhcpBootNextServer", + "dhcpBootFilename", + "fixedIpAssignments", + "reservedIpRanges", + "dnsNameservers", + "dhcpOptions", + "templateVlanType", + "cidr", + "mask", + "ipv6", + "mandatoryDhcp", + "sgt", + "vrf", + "uplinks", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceVlan: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def deleteNetworkApplianceVlan(self, networkId: str, vlanId: str): """ @@ -2210,16 +2978,14 @@ def deleteNetworkApplianceVlan(self, networkId: str, vlanId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'vlans'], - 'operation': 'deleteNetworkApplianceVlan' + "tags": ["appliance", "configure", "vlans"], + "operation": "deleteNetworkApplianceVlan", } - networkId = urllib.parse.quote(str(networkId), safe='') - vlanId = urllib.parse.quote(str(vlanId), safe='') - resource = f'/networks/{networkId}/appliance/vlans/{vlanId}' + networkId = urllib.parse.quote(str(networkId), safe="") + vlanId = urllib.parse.quote(str(vlanId), safe="") + resource = f"/networks/{networkId}/appliance/vlans/{vlanId}" return self._session.delete(metadata, resource) - - def getNetworkApplianceVpnBgp(self, networkId: str): """ @@ -2230,15 +2996,13 @@ def getNetworkApplianceVpnBgp(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'bgp'], - 'operation': 'getNetworkApplianceVpnBgp' + "tags": ["appliance", "configure", "vpn", "bgp"], + "operation": "getNetworkApplianceVpnBgp", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/vpn/bgp' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vpn/bgp" return self._session.get(metadata, resource) - - def updateNetworkApplianceVpnBgp(self, networkId: str, enabled: bool, **kwargs): """ @@ -2247,26 +3011,37 @@ 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. """ kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'bgp'], - 'operation': 'updateNetworkApplianceVpnBgp' + "tags": ["appliance", "configure", "vpn", "bgp"], + "operation": "updateNetworkApplianceVpnBgp", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/vpn/bgp' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vpn/bgp" - body_params = ['enabled', 'asNumber', 'ibgpHoldTimer', 'neighbors', ] + body_params = [ + "enabled", + "asNumber", + "ibgpHoldTimer", + "routerId", + "neighbors", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceVpnBgp: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkApplianceVpnSiteToSiteVpn(self, networkId: str): """ @@ -2277,15 +3052,13 @@ def getNetworkApplianceVpnSiteToSiteVpn(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'siteToSiteVpn'], - 'operation': 'getNetworkApplianceVpnSiteToSiteVpn' + "tags": ["appliance", "configure", "vpn", "siteToSiteVpn"], + "operation": "getNetworkApplianceVpnSiteToSiteVpn", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/vpn/siteToSiteVpn' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vpn/siteToSiteVpn" return self._session.get(metadata, resource) - - def updateNetworkApplianceVpnSiteToSiteVpn(self, networkId: str, mode: str, **kwargs): """ @@ -2296,28 +3069,43 @@ 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 """ kwargs.update(locals()) - if 'mode' in kwargs: - options = ['hub', 'none', 'spoke'] - assert kwargs['mode'] in options, f'''"mode" cannot be "{kwargs['mode']}", & must be set to one of: {options}''' + if "mode" in kwargs: + options = ["hub", "none", "spoke"] + assert kwargs["mode"] in options, f'''"mode" cannot be "{kwargs["mode"]}", & must be set to one of: {options}''' metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'siteToSiteVpn'], - 'operation': 'updateNetworkApplianceVpnSiteToSiteVpn' + "tags": ["appliance", "configure", "vpn", "siteToSiteVpn"], + "operation": "updateNetworkApplianceVpnSiteToSiteVpn", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/vpn/siteToSiteVpn' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/vpn/siteToSiteVpn" - body_params = ['mode', 'hubs', 'subnets', 'subnet', ] + body_params = [ + "mode", + "hubs", + "subnets", + "sgt", + "subnet", + "hostTranslations", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceVpnSiteToSiteVpn: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkApplianceWarmSpare(self, networkId: str): """ @@ -2328,15 +3116,13 @@ def getNetworkApplianceWarmSpare(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'warmSpare'], - 'operation': 'getNetworkApplianceWarmSpare' + "tags": ["appliance", "configure", "warmSpare"], + "operation": "getNetworkApplianceWarmSpare", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/warmSpare' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/warmSpare" return self._session.get(metadata, resource) - - def updateNetworkApplianceWarmSpare(self, networkId: str, enabled: bool, **kwargs): """ @@ -2354,18 +3140,28 @@ def updateNetworkApplianceWarmSpare(self, networkId: str, enabled: bool, **kwarg kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'warmSpare'], - 'operation': 'updateNetworkApplianceWarmSpare' + "tags": ["appliance", "configure", "warmSpare"], + "operation": "updateNetworkApplianceWarmSpare", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/warmSpare' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/warmSpare" - body_params = ['enabled', 'spareSerial', 'uplinkMode', 'virtualIp1', 'virtualIp2', ] + body_params = [ + "enabled", + "spareSerial", + "uplinkMode", + "virtualIp1", + "virtualIp2", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkApplianceWarmSpare: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def swapNetworkApplianceWarmSpare(self, networkId: str): """ @@ -2376,15 +3172,215 @@ def swapNetworkApplianceWarmSpare(self, networkId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'warmSpare'], - 'operation': 'swapNetworkApplianceWarmSpare' + "tags": ["appliance", "configure", "warmSpare"], + "operation": "swapNetworkApplianceWarmSpare", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/appliance/warmSpare/swap' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/appliance/warmSpare/swap" 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 + ): + """ + **Return MX warm spare settings** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-devices-redundancy-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 5 - 1000. Default is 50. + - 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", "redundancy", "byNetwork"], + "operation": "getOrganizationApplianceDevicesRedundancyByNetwork", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/devices/redundancy/byNetwork" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_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"getOrganizationApplianceDevicesRedundancyByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) def getOrganizationApplianceDnsLocalProfiles(self, organizationId: str, **kwargs): """ @@ -2398,26 +3394,36 @@ def getOrganizationApplianceDnsLocalProfiles(self, organizationId: str, **kwargs kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'profiles'], - 'operation': 'getOrganizationApplianceDnsLocalProfiles' + "tags": ["appliance", "configure", "dns", "local", "profiles"], + "operation": "getOrganizationApplianceDnsLocalProfiles", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/profiles' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles" - query_params = ['profileIds', ] + query_params = [ + "profileIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['profileIds', ] + array_params = [ + "profileIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationApplianceDnsLocalProfiles: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) - def createOrganizationApplianceDnsLocalProfile(self, organizationId: str, name: str): + def createOrganizationApplianceDnsLocalProfile(self, organizationId: str, name: str, **kwargs): """ **Create a new local DNS profile** https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-local-profile @@ -2429,18 +3435,26 @@ def createOrganizationApplianceDnsLocalProfile(self, organizationId: str, name: kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'profiles'], - 'operation': 'createOrganizationApplianceDnsLocalProfile' + "tags": ["appliance", "configure", "dns", "local", "profiles"], + "operation": "createOrganizationApplianceDnsLocalProfile", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/profiles' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles" - body_params = ['name', ] + body_params = [ + "name", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationApplianceDnsLocalProfile: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getOrganizationApplianceDnsLocalProfilesAssignments(self, organizationId: str, **kwargs): """ @@ -2455,26 +3469,38 @@ def getOrganizationApplianceDnsLocalProfilesAssignments(self, organizationId: st kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'profiles', 'assignments'], - 'operation': 'getOrganizationApplianceDnsLocalProfilesAssignments' + "tags": ["appliance", "configure", "dns", "local", "profiles", "assignments"], + "operation": "getOrganizationApplianceDnsLocalProfilesAssignments", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/profiles/assignments' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/assignments" - query_params = ['profileIds', 'networkIds', ] + query_params = [ + "profileIds", + "networkIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['profileIds', 'networkIds', ] + array_params = [ + "profileIds", + "networkIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationApplianceDnsLocalProfilesAssignments: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) - def bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate(self, organizationId: str, items: list): + def bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate(self, organizationId: str, items: list, **kwargs): """ **Assign the local DNS profile to networks in the organization** https://developer.cisco.com/meraki/api-v1/#!bulk-organization-appliance-dns-local-profiles-assignments-create @@ -2486,20 +3512,28 @@ def bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate(self, organizatio kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'profiles', 'assignments'], - 'operation': 'bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate' + "tags": ["appliance", "configure", "dns", "local", "profiles", "assignments"], + "operation": "bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/profiles/assignments/bulkCreate' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/assignments/bulkCreate" - body_params = ['items', ] + body_params = [ + "items", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) - def createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete(self, organizationId: str, items: list): + def createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete(self, organizationId: str, items: list, **kwargs): """ **Unassign the local DNS profile to networks in the organization** https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-local-profiles-assignments-bulk-delete @@ -2511,20 +3545,28 @@ def createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete(self, organ kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'profiles', 'assignments', 'bulkDelete'], - 'operation': 'createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete' + "tags": ["appliance", "configure", "dns", "local", "profiles", "assignments", "bulkDelete"], + "operation": "createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/profiles/assignments/bulkDelete' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/assignments/bulkDelete" - body_params = ['items', ] + body_params = [ + "items", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationApplianceDnsLocalProfilesAssignmentsBulkDelete: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) - def updateOrganizationApplianceDnsLocalProfile(self, organizationId: str, profileId: str, name: str): + def updateOrganizationApplianceDnsLocalProfile(self, organizationId: str, profileId: str, name: str, **kwargs): """ **Update a local DNS profile** https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-dns-local-profile @@ -2537,19 +3579,27 @@ def updateOrganizationApplianceDnsLocalProfile(self, organizationId: str, profil kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'profiles'], - 'operation': 'updateOrganizationApplianceDnsLocalProfile' + "tags": ["appliance", "configure", "dns", "local", "profiles"], + "operation": "updateOrganizationApplianceDnsLocalProfile", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - profileId = urllib.parse.quote(str(profileId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/profiles/{profileId}' + 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 = ['name', ] + body_params = [ + "name", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateOrganizationApplianceDnsLocalProfile: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteOrganizationApplianceDnsLocalProfile(self, organizationId: str, profileId: str): """ @@ -2561,16 +3611,14 @@ def deleteOrganizationApplianceDnsLocalProfile(self, organizationId: str, profil """ metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'profiles'], - 'operation': 'deleteOrganizationApplianceDnsLocalProfile' + "tags": ["appliance", "configure", "dns", "local", "profiles"], + "operation": "deleteOrganizationApplianceDnsLocalProfile", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - profileId = urllib.parse.quote(str(profileId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/profiles/{profileId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + profileId = urllib.parse.quote(str(profileId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/profiles/{profileId}" return self._session.delete(metadata, resource) - - def getOrganizationApplianceDnsLocalRecords(self, organizationId: str, **kwargs): """ @@ -2584,26 +3632,38 @@ def getOrganizationApplianceDnsLocalRecords(self, organizationId: str, **kwargs) kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'records'], - 'operation': 'getOrganizationApplianceDnsLocalRecords' + "tags": ["appliance", "configure", "dns", "local", "records"], + "operation": "getOrganizationApplianceDnsLocalRecords", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/records' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/records" - query_params = ['profileIds', ] + query_params = [ + "profileIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['profileIds', ] + array_params = [ + "profileIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationApplianceDnsLocalRecords: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) - def createOrganizationApplianceDnsLocalRecord(self, organizationId: str, hostname: str, address: str, profile: dict): + def createOrganizationApplianceDnsLocalRecord( + self, organizationId: str, hostname: str, address: str, profile: dict, **kwargs + ): """ **Create a new local DNS record** https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-local-record @@ -2617,18 +3677,28 @@ def createOrganizationApplianceDnsLocalRecord(self, organizationId: str, hostnam kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'records'], - 'operation': 'createOrganizationApplianceDnsLocalRecord' + "tags": ["appliance", "configure", "dns", "local", "records"], + "operation": "createOrganizationApplianceDnsLocalRecord", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/records' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/records" - body_params = ['hostname', 'address', 'profile', ] + body_params = [ + "hostname", + "address", + "profile", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationApplianceDnsLocalRecord: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def updateOrganizationApplianceDnsLocalRecord(self, organizationId: str, recordId: str, **kwargs): """ @@ -2645,19 +3715,29 @@ def updateOrganizationApplianceDnsLocalRecord(self, organizationId: str, recordI kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'records'], - 'operation': 'updateOrganizationApplianceDnsLocalRecord' + "tags": ["appliance", "configure", "dns", "local", "records"], + "operation": "updateOrganizationApplianceDnsLocalRecord", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - recordId = urllib.parse.quote(str(recordId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/records/{recordId}' + 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 = ['hostname', 'address', 'profile', ] + body_params = [ + "hostname", + "address", + "profile", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateOrganizationApplianceDnsLocalRecord: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteOrganizationApplianceDnsLocalRecord(self, organizationId: str, recordId: str): """ @@ -2669,16 +3749,14 @@ def deleteOrganizationApplianceDnsLocalRecord(self, organizationId: str, recordI """ metadata = { - 'tags': ['appliance', 'configure', 'dns', 'local', 'records'], - 'operation': 'deleteOrganizationApplianceDnsLocalRecord' + "tags": ["appliance", "configure", "dns", "local", "records"], + "operation": "deleteOrganizationApplianceDnsLocalRecord", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - recordId = urllib.parse.quote(str(recordId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/local/records/{recordId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + recordId = urllib.parse.quote(str(recordId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/local/records/{recordId}" return self._session.delete(metadata, resource) - - def getOrganizationApplianceDnsSplitProfiles(self, organizationId: str, **kwargs): """ @@ -2692,26 +3770,38 @@ def getOrganizationApplianceDnsSplitProfiles(self, organizationId: str, **kwargs kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'dns', 'split', 'profiles'], - 'operation': 'getOrganizationApplianceDnsSplitProfiles' + "tags": ["appliance", "configure", "dns", "split", "profiles"], + "operation": "getOrganizationApplianceDnsSplitProfiles", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/split/profiles' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles" - query_params = ['profileIds', ] + query_params = [ + "profileIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['profileIds', ] + array_params = [ + "profileIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationApplianceDnsSplitProfiles: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) - def createOrganizationApplianceDnsSplitProfile(self, organizationId: str, name: str, hostnames: list, nameservers: dict): + def createOrganizationApplianceDnsSplitProfile( + self, organizationId: str, name: str, hostnames: list, nameservers: dict, **kwargs + ): """ **Create a new split DNS profile** https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-split-profile @@ -2725,18 +3815,28 @@ def createOrganizationApplianceDnsSplitProfile(self, organizationId: str, name: kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'dns', 'split', 'profiles'], - 'operation': 'createOrganizationApplianceDnsSplitProfile' + "tags": ["appliance", "configure", "dns", "split", "profiles"], + "operation": "createOrganizationApplianceDnsSplitProfile", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/split/profiles' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles" - body_params = ['name', 'hostnames', 'nameservers', ] + body_params = [ + "name", + "hostnames", + "nameservers", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationApplianceDnsSplitProfile: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getOrganizationApplianceDnsSplitProfilesAssignments(self, organizationId: str, **kwargs): """ @@ -2751,26 +3851,38 @@ def getOrganizationApplianceDnsSplitProfilesAssignments(self, organizationId: st kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'dns', 'split', 'profiles', 'assignments'], - 'operation': 'getOrganizationApplianceDnsSplitProfilesAssignments' + "tags": ["appliance", "configure", "dns", "split", "profiles", "assignments"], + "operation": "getOrganizationApplianceDnsSplitProfilesAssignments", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/split/profiles/assignments' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/assignments" - query_params = ['profileIds', 'networkIds', ] + query_params = [ + "profileIds", + "networkIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['profileIds', 'networkIds', ] + array_params = [ + "profileIds", + "networkIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationApplianceDnsSplitProfilesAssignments: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) - def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate(self, organizationId: str, items: list): + def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate(self, organizationId: str, items: list, **kwargs): """ **Assign the split DNS profile to networks in the organization** https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-split-profiles-assignments-bulk-create @@ -2782,20 +3894,28 @@ def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate(self, organ kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'dns', 'split', 'profiles', 'assignments', 'bulkCreate'], - 'operation': 'createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate' + "tags": ["appliance", "configure", "dns", "split", "profiles", "assignments", "bulkCreate"], + "operation": "createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/split/profiles/assignments/bulkCreate' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/assignments/bulkCreate" - body_params = ['items', ] + body_params = [ + "items", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationApplianceDnsSplitProfilesAssignmentsBulkCreate: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) - def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete(self, organizationId: str, items: list): + def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete(self, organizationId: str, items: list, **kwargs): """ **Unassign the split DNS profile to networks in the organization** https://developer.cisco.com/meraki/api-v1/#!create-organization-appliance-dns-split-profiles-assignments-bulk-delete @@ -2807,18 +3927,26 @@ def createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete(self, organ kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'dns', 'split', 'profiles', 'assignments', 'bulkDelete'], - 'operation': 'createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete' + "tags": ["appliance", "configure", "dns", "split", "profiles", "assignments", "bulkDelete"], + "operation": "createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/split/profiles/assignments/bulkDelete' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/assignments/bulkDelete" - body_params = ['items', ] + body_params = [ + "items", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationApplianceDnsSplitProfilesAssignmentsBulkDelete: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def updateOrganizationApplianceDnsSplitProfile(self, organizationId: str, profileId: str, **kwargs): """ @@ -2835,19 +3963,29 @@ def updateOrganizationApplianceDnsSplitProfile(self, organizationId: str, profil kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'dns', 'split', 'profiles'], - 'operation': 'updateOrganizationApplianceDnsSplitProfile' + "tags": ["appliance", "configure", "dns", "split", "profiles"], + "operation": "updateOrganizationApplianceDnsSplitProfile", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - profileId = urllib.parse.quote(str(profileId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/split/profiles/{profileId}' + 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 = ['name', 'hostnames', 'nameservers', ] + body_params = [ + "name", + "hostnames", + "nameservers", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateOrganizationApplianceDnsSplitProfile: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteOrganizationApplianceDnsSplitProfile(self, organizationId: str, profileId: str): """ @@ -2859,18 +3997,18 @@ def deleteOrganizationApplianceDnsSplitProfile(self, organizationId: str, profil """ metadata = { - 'tags': ['appliance', 'configure', 'dns', 'split', 'profiles'], - 'operation': 'deleteOrganizationApplianceDnsSplitProfile' + "tags": ["appliance", "configure", "dns", "split", "profiles"], + "operation": "deleteOrganizationApplianceDnsSplitProfile", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - profileId = urllib.parse.quote(str(profileId), safe='') - resource = f'/organizations/{organizationId}/appliance/dns/split/profiles/{profileId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + profileId = urllib.parse.quote(str(profileId), safe="") + resource = f"/organizations/{organizationId}/appliance/dns/split/profiles/{profileId}" return self._session.delete(metadata, resource) - - - def getOrganizationApplianceFirewallMulticastForwardingByNetwork(self, organizationId: str, total_pages=1, direction='next', **kwargs): + def getOrganizationApplianceFirewallMulticastForwardingByNetwork( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): """ **List Static Multicasting forwarding settings for MX networks** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-firewall-multicast-forwarding-by-network @@ -2887,26 +4025,149 @@ def getOrganizationApplianceFirewallMulticastForwardingByNetwork(self, organizat kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'firewall', 'multicastForwarding', 'byNetwork'], - 'operation': 'getOrganizationApplianceFirewallMulticastForwardingByNetwork' + "tags": ["appliance", "configure", "firewall", "multicastForwarding", "byNetwork"], + "operation": "getOrganizationApplianceFirewallMulticastForwardingByNetwork", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/firewall/multicastForwarding/byNetwork' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/firewall/multicastForwarding/byNetwork" - query_params = ['perPage', 'startingAfter', 'endingBefore', 'networkIds', ] + 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', ] + array_params = [ + "networkIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + 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"getOrganizationApplianceFirewallMulticastForwardingByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + 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 getOrganizationApplianceSecurityEvents(self, organizationId: str, total_pages=1, direction='next', **kwargs): + 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** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-security-events @@ -2925,23 +4186,39 @@ def getOrganizationApplianceSecurityEvents(self, organizationId: str, total_page kwargs.update(locals()) - if 'sortOrder' in kwargs: - options = ['ascending', 'descending'] - assert kwargs['sortOrder'] in options, f'''"sortOrder" cannot be "{kwargs['sortOrder']}", & must be set to one of: {options}''' + if "sortOrder" in kwargs: + options = ["ascending", "descending"] + assert kwargs["sortOrder"] in options, ( + f'''"sortOrder" cannot be "{kwargs["sortOrder"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['appliance', 'monitor', 'security', 'events'], - 'operation': 'getOrganizationApplianceSecurityEvents' + "tags": ["appliance", "monitor", "security", "events"], + "operation": "getOrganizationApplianceSecurityEvents", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/security/events' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/security/events" - query_params = ['t0', 't1', 'timespan', 'perPage', 'startingAfter', 'endingBefore', 'sortOrder', ] + query_params = [ + "t0", + "t1", + "timespan", + "perPage", + "startingAfter", + "endingBefore", + "sortOrder", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get_pages(metadata, resource, params, total_pages, direction) - + if self._session._validate_kwargs: + all_params = query_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"getOrganizationApplianceSecurityEvents: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) def getOrganizationApplianceSecurityIntrusion(self, organizationId: str): """ @@ -2952,17 +4229,15 @@ def getOrganizationApplianceSecurityIntrusion(self, organizationId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'security', 'intrusion'], - 'operation': 'getOrganizationApplianceSecurityIntrusion' + "tags": ["appliance", "configure", "security", "intrusion"], + "operation": "getOrganizationApplianceSecurityIntrusion", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/security/intrusion' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/security/intrusion" return self._session.get(metadata, resource) - - - def updateOrganizationApplianceSecurityIntrusion(self, organizationId: str, allowedRules: list): + def updateOrganizationApplianceSecurityIntrusion(self, organizationId: str, allowedRules: list, **kwargs): """ **Sets supported intrusion settings for an organization** https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-security-intrusion @@ -2974,20 +4249,30 @@ def updateOrganizationApplianceSecurityIntrusion(self, organizationId: str, allo kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'security', 'intrusion'], - 'operation': 'updateOrganizationApplianceSecurityIntrusion' + "tags": ["appliance", "configure", "security", "intrusion"], + "operation": "updateOrganizationApplianceSecurityIntrusion", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/security/intrusion' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/security/intrusion" - body_params = ['allowedRules', ] + body_params = [ + "allowedRules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateOrganizationApplianceSecurityIntrusion: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) - def getOrganizationApplianceTrafficShapingVpnExclusionsByNetwork(self, organizationId: str, total_pages=1, direction='next', **kwargs): + def getOrganizationApplianceTrafficShapingVpnExclusionsByNetwork( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): """ **Display VPN exclusion rules for MX networks.** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-traffic-shaping-vpn-exclusions-by-network @@ -3004,26 +4289,39 @@ def getOrganizationApplianceTrafficShapingVpnExclusionsByNetwork(self, organizat kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'trafficShaping', 'vpnExclusions', 'byNetwork'], - 'operation': 'getOrganizationApplianceTrafficShapingVpnExclusionsByNetwork' + "tags": ["appliance", "configure", "trafficShaping", "vpnExclusions", "byNetwork"], + "operation": "getOrganizationApplianceTrafficShapingVpnExclusionsByNetwork", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/trafficShaping/vpnExclusions/byNetwork' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/trafficShaping/vpnExclusions/byNetwork" - query_params = ['perPage', 'startingAfter', 'endingBefore', 'networkIds', ] + 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', ] + array_params = [ + "networkIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get_pages(metadata, resource, params, total_pages, direction) - + 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"getOrganizationApplianceTrafficShapingVpnExclusionsByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) - def getOrganizationApplianceUplinkStatuses(self, organizationId: str, total_pages=1, direction='next', **kwargs): + def getOrganizationApplianceUplinkStatuses(self, organizationId: str, total_pages=1, direction="next", **kwargs): """ **List the uplink status of every Meraki MX and Z series appliances in the organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-uplink-statuses @@ -3042,43 +4340,134 @@ def getOrganizationApplianceUplinkStatuses(self, organizationId: str, total_page kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'monitor', 'uplinks', 'statuses'], - 'operation': 'getOrganizationApplianceUplinkStatuses' + "tags": ["appliance", "monitor", "uplinks", "statuses"], + "operation": "getOrganizationApplianceUplinkStatuses", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/uplink/statuses' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/uplink/statuses" - query_params = ['perPage', 'startingAfter', 'endingBefore', 'networkIds', 'serials', 'iccids', ] + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + "serials", + "iccids", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['networkIds', 'serials', 'iccids', ] + array_params = [ + "networkIds", + "serials", + "iccids", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + 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"getOrganizationApplianceUplinkStatuses: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) - + def getOrganizationApplianceUplinksNatByNetwork(self, organizationId: str, total_pages=1, direction="next", **kwargs): + """ + **Fetch uplink NAT settings of each network in the organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-uplinks-nat-by-network - def getOrganizationApplianceUplinksStatusesOverview(self, organizationId: str): + - 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 parameter to filter the results by the included set of network IDs + - interfaces (array): Optional parameter to filter the results by the included set of interfaces + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 100000. Default is 1000. + - 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", "uplinks", "nat", "byNetwork"], + "operation": "getOrganizationApplianceUplinksNatByNetwork", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/uplinks/nat/byNetwork" + + query_params = [ + "networkIds", + "interfaces", + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "networkIds", + "interfaces", + ] + 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"getOrganizationApplianceUplinksNatByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationApplianceUplinksStatusesOverview(self, organizationId: str, **kwargs): """ **Returns an overview of uplink statuses** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-uplinks-statuses-overview - organizationId (string): Organization ID + - networkIds (array): A list of network IDs. The returned devices will be filtered to only include these networks. """ + kwargs.update(locals()) + metadata = { - 'tags': ['appliance', 'monitor', 'uplinks', 'statuses', 'overview'], - 'operation': 'getOrganizationApplianceUplinksStatusesOverview' + "tags": ["appliance", "monitor", "uplinks", "statuses", "overview"], + "operation": "getOrganizationApplianceUplinksStatusesOverview", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/uplinks/statuses/overview' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/uplinks/statuses/overview" - return self._session.get(metadata, resource) - + query_params = [ + "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"getOrganizationApplianceUplinksStatusesOverview: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) def getOrganizationApplianceUplinksUsageByNetwork(self, organizationId: str, **kwargs): """ @@ -3094,20 +4483,80 @@ def getOrganizationApplianceUplinksUsageByNetwork(self, organizationId: str, **k kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'monitor', 'uplinks', 'usage', 'byNetwork'], - 'operation': 'getOrganizationApplianceUplinksUsageByNetwork' + "tags": ["appliance", "monitor", "uplinks", "usage", "byNetwork"], + "operation": "getOrganizationApplianceUplinksUsageByNetwork", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/uplinks/usage/byNetwork' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/uplinks/usage/byNetwork" - query_params = ['t0', 't1', 'timespan', ] + query_params = [ + "t0", + "t1", + "timespan", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_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"getOrganizationApplianceUplinksUsageByNetwork: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) - + def getOrganizationApplianceVpnSiteToSiteIpsecPeersSlas(self, organizationId: str): + """ + **Get the list of available IPsec SLA policies for an organization** + https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-vpn-site-to-site-ipsec-peers-slas + + - organizationId (string): Organization ID + """ + + metadata = { + "tags": ["appliance", "configure", "vpn", "siteToSite", "ipsec", "peers", "slas"], + "operation": "getOrganizationApplianceVpnSiteToSiteIpsecPeersSlas", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/siteToSite/ipsec/peers/slas" + + return self._session.get(metadata, resource) + + def updateOrganizationApplianceVpnSiteToSiteIpsecPeersSlas(self, organizationId: str, **kwargs): + """ + **Update the IPsec SLA policies for an organization** + https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-vpn-site-to-site-ipsec-peers-slas + + - organizationId (string): Organization ID + - items (array): List of IPsec SLA policies + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["appliance", "configure", "vpn", "siteToSite", "ipsec", "peers", "slas"], + "operation": "updateOrganizationApplianceVpnSiteToSiteIpsecPeersSlas", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/siteToSite/ipsec/peers/slas" + + 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"updateOrganizationApplianceVpnSiteToSiteIpsecPeersSlas: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.put(metadata, resource, payload) - def getOrganizationApplianceVpnStats(self, organizationId: str, total_pages=1, direction='next', **kwargs): + def getOrganizationApplianceVpnStats(self, organizationId: str, total_pages=1, direction="next", **kwargs): """ **Show VPN history stat for networks in an organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-vpn-stats @@ -3127,26 +4576,40 @@ def getOrganizationApplianceVpnStats(self, organizationId: str, total_pages=1, d kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'monitor', 'vpn', 'stats'], - 'operation': 'getOrganizationApplianceVpnStats' + "tags": ["appliance", "monitor", "vpn", "stats"], + "operation": "getOrganizationApplianceVpnStats", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/vpn/stats' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/stats" - query_params = ['perPage', 'startingAfter', 'endingBefore', 'networkIds', 't0', 't1', 'timespan', ] + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + "t0", + "t1", + "timespan", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['networkIds', ] + array_params = [ + "networkIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get_pages(metadata, resource, params, total_pages, direction) - + 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"getOrganizationApplianceVpnStats: ignoring unrecognized kwargs: {invalid}") + return self._session.get_pages(metadata, resource, params, total_pages, direction) - def getOrganizationApplianceVpnStatuses(self, organizationId: str, total_pages=1, direction='next', **kwargs): + def getOrganizationApplianceVpnStatuses(self, organizationId: str, total_pages=1, direction="next", **kwargs): """ **Show VPN status for networks in an organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-appliance-vpn-statuses @@ -3163,24 +4626,35 @@ def getOrganizationApplianceVpnStatuses(self, organizationId: str, total_pages=1 kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'monitor', 'vpn', 'statuses'], - 'operation': 'getOrganizationApplianceVpnStatuses' + "tags": ["appliance", "monitor", "vpn", "statuses"], + "operation": "getOrganizationApplianceVpnStatuses", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/vpn/statuses' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/statuses" - query_params = ['perPage', 'startingAfter', 'endingBefore', 'networkIds', ] + 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', ] + array_params = [ + "networkIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get_pages(metadata, resource, params, total_pages, direction) - + 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"getOrganizationApplianceVpnStatuses: ignoring unrecognized kwargs: {invalid}") + return self._session.get_pages(metadata, resource, params, total_pages, direction) def getOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str): """ @@ -3191,17 +4665,15 @@ def getOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'thirdPartyVPNPeers'], - 'operation': 'getOrganizationApplianceVpnThirdPartyVPNPeers' + "tags": ["appliance", "configure", "vpn", "thirdPartyVPNPeers"], + "operation": "getOrganizationApplianceVpnThirdPartyVPNPeers", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/vpn/thirdPartyVPNPeers' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/thirdPartyVPNPeers" return self._session.get(metadata, resource) - - - def updateOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str, peers: list): + def updateOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str, peers: list, **kwargs): """ **Update the third party VPN peers for an organization** https://developer.cisco.com/meraki/api-v1/#!update-organization-appliance-vpn-third-party-v-p-n-peers @@ -3213,18 +4685,26 @@ def updateOrganizationApplianceVpnThirdPartyVPNPeers(self, organizationId: str, kwargs = locals() metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'thirdPartyVPNPeers'], - 'operation': 'updateOrganizationApplianceVpnThirdPartyVPNPeers' + "tags": ["appliance", "configure", "vpn", "thirdPartyVPNPeers"], + "operation": "updateOrganizationApplianceVpnThirdPartyVPNPeers", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/vpn/thirdPartyVPNPeers' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/thirdPartyVPNPeers" - body_params = ['peers', ] + body_params = [ + "peers", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateOrganizationApplianceVpnThirdPartyVPNPeers: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getOrganizationApplianceVpnVpnFirewallRules(self, organizationId: str): """ @@ -3235,15 +4715,13 @@ def getOrganizationApplianceVpnVpnFirewallRules(self, organizationId: str): """ metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'vpnFirewallRules'], - 'operation': 'getOrganizationApplianceVpnVpnFirewallRules' + "tags": ["appliance", "configure", "vpn", "vpnFirewallRules"], + "operation": "getOrganizationApplianceVpnVpnFirewallRules", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/vpn/vpnFirewallRules' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/vpnFirewallRules" return self._session.get(metadata, resource) - - def updateOrganizationApplianceVpnVpnFirewallRules(self, organizationId: str, **kwargs): """ @@ -3258,14 +4736,211 @@ def updateOrganizationApplianceVpnVpnFirewallRules(self, organizationId: str, ** kwargs.update(locals()) metadata = { - 'tags': ['appliance', 'configure', 'vpn', 'vpnFirewallRules'], - 'operation': 'updateOrganizationApplianceVpnVpnFirewallRules' + "tags": ["appliance", "configure", "vpn", "vpnFirewallRules"], + "operation": "updateOrganizationApplianceVpnVpnFirewallRules", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/appliance/vpn/vpnFirewallRules' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/appliance/vpn/vpnFirewallRules" - body_params = ['rules', 'syslogDefaultRule', ] + body_params = [ + "rules", + "syslogDefaultRule", + ] 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"updateOrganizationApplianceVpnVpnFirewallRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) - + + def assignOrganizationPoliciesGlobalGroupPoliciesApplianceVlans( + self, organizationId: str, policy: dict, vlans: list, **kwargs + ): + """ + **Assign VLANs to a policy** + https://developer.cisco.com/meraki/api-v1/#!assign-organization-policies-global-group-policies-appliance-vlans + + - organizationId (string): Organization ID + - policy (object): Policy to assign VLANs to + - vlans (array): VLANs to assign + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "policies", "global", "group", "vlans"], + "operation": "assignOrganizationPoliciesGlobalGroupPoliciesApplianceVlans", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/assign" + + body_params = [ + "policy", + "vlans", + ] + 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"assignOrganizationPoliciesGlobalGroupPoliciesApplianceVlans: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignments( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): + """ + **List appliance VLAN policy assignments** + https://developer.cisco.com/meraki/api-v1/#!get-organization-policies-global-group-policies-appliance-vlans-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 + - assignmentIds (array): Filter assignments by assignment IDs + - policyIds (array): Filter assignments by policy IDs + - interfaceIds (array): Filter assignments by interface IDs + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 1000. Default is 1000. + - 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", "policies", "global", "group", "vlans", "assignments"], + "operation": "getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignments", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/assignments" + + query_params = [ + "assignmentIds", + "policyIds", + "interfaceIds", + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "assignmentIds", + "policyIds", + "interfaceIds", + ] + 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"getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignments: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignmentsByVlan( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): + """ + **List policies by appliance VLANs** + https://developer.cisco.com/meraki/api-v1/#!get-organization-policies-global-group-policies-appliance-vlans-assignments-by-vlan + + - 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 + - search (string): Search term for filtering policies + - vlanIds (array): Filter by VLAN IDs + - interfaceIds (array): Filter by interface IDs + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 100. 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", "policies", "global", "group", "vlans", "assignments", "byVlan"], + "operation": "getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignmentsByVlan", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/assignments/byVlan" + + query_params = [ + "search", + "vlanIds", + "interfaceIds", + "perPage", + "startingAfter", + "endingBefore", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + array_params = [ + "vlanIds", + "interfaceIds", + ] + 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"getOrganizationPoliciesGlobalGroupPoliciesApplianceVlansAssignmentsByVlan: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def removeOrganizationPoliciesGlobalGroupPoliciesApplianceVlans( + self, organizationId: str, policy: dict, vlans: list, **kwargs + ): + """ + **Remove VLANs from a policy** + https://developer.cisco.com/meraki/api-v1/#!remove-organization-policies-global-group-policies-appliance-vlans + + - organizationId (string): Organization ID + - policy (object): Policy to remove VLANs from + - vlans (array): VLANs to remove + """ + + kwargs = locals() + + metadata = { + "tags": ["appliance", "configure", "policies", "global", "group", "vlans"], + "operation": "removeOrganizationPoliciesGlobalGroupPoliciesApplianceVlans", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/policies/global/group/policies/appliance/vlans/remove" + + body_params = [ + "policy", + "vlans", + ] + 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"removeOrganizationPoliciesGlobalGroupPoliciesApplianceVlans: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) diff --git a/meraki/aio/api/camera.py b/meraki/aio/api/camera.py index b99563c9..98e5c8ac 100644 --- a/meraki/aio/api/camera.py +++ b/meraki/aio/api/camera.py @@ -5,8 +5,6 @@ class AsyncCamera: def __init__(self, session): super().__init__() self._session = session - - def getDeviceCameraAnalyticsLive(self, serial: str): """ @@ -17,15 +15,13 @@ def getDeviceCameraAnalyticsLive(self, serial: str): """ metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'live'], - 'operation': 'getDeviceCameraAnalyticsLive' + "tags": ["camera", "monitor", "analytics", "live"], + "operation": "getDeviceCameraAnalyticsLive", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/analytics/live' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/analytics/live" return self._session.get(metadata, resource) - - def getDeviceCameraAnalyticsOverview(self, serial: str, **kwargs): """ @@ -41,23 +37,34 @@ def getDeviceCameraAnalyticsOverview(self, serial: str, **kwargs): kwargs.update(locals()) - if 'objectType' in kwargs: - options = ['person', 'vehicle'] - assert kwargs['objectType'] in options, f'''"objectType" cannot be "{kwargs['objectType']}", & must be set to one of: {options}''' + if "objectType" in kwargs: + options = ["person", "vehicle"] + assert kwargs["objectType"] in options, ( + f'''"objectType" cannot be "{kwargs["objectType"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'overview'], - 'operation': 'getDeviceCameraAnalyticsOverview' + "tags": ["camera", "monitor", "analytics", "overview"], + "operation": "getDeviceCameraAnalyticsOverview", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/analytics/overview' - - query_params = ['t0', 't1', 'timespan', 'objectType', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/analytics/overview" + + query_params = [ + "t0", + "t1", + "timespan", + "objectType", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get(metadata, resource, params) - + if self._session._validate_kwargs: + all_params = query_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"getDeviceCameraAnalyticsOverview: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) def getDeviceCameraAnalyticsRecent(self, serial: str, **kwargs): """ @@ -70,23 +77,31 @@ def getDeviceCameraAnalyticsRecent(self, serial: str, **kwargs): kwargs.update(locals()) - if 'objectType' in kwargs: - options = ['person', 'vehicle'] - assert kwargs['objectType'] in options, f'''"objectType" cannot be "{kwargs['objectType']}", & must be set to one of: {options}''' + if "objectType" in kwargs: + options = ["person", "vehicle"] + assert kwargs["objectType"] in options, ( + f'''"objectType" cannot be "{kwargs["objectType"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'recent'], - 'operation': 'getDeviceCameraAnalyticsRecent' + "tags": ["camera", "monitor", "analytics", "recent"], + "operation": "getDeviceCameraAnalyticsRecent", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/analytics/recent' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/analytics/recent" - query_params = ['objectType', ] + query_params = [ + "objectType", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get(metadata, resource, params) - + if self._session._validate_kwargs: + all_params = query_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"getDeviceCameraAnalyticsRecent: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) def getDeviceCameraAnalyticsZones(self, serial: str): """ @@ -97,15 +112,13 @@ def getDeviceCameraAnalyticsZones(self, serial: str): """ metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'zones'], - 'operation': 'getDeviceCameraAnalyticsZones' + "tags": ["camera", "monitor", "analytics", "zones"], + "operation": "getDeviceCameraAnalyticsZones", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/analytics/zones' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/analytics/zones" return self._session.get(metadata, resource) - - def getDeviceCameraAnalyticsZoneHistory(self, serial: str, zoneId: str, **kwargs): """ @@ -123,24 +136,71 @@ def getDeviceCameraAnalyticsZoneHistory(self, serial: str, zoneId: str, **kwargs kwargs.update(locals()) - if 'objectType' in kwargs: - options = ['person', 'vehicle'] - assert kwargs['objectType'] in options, f'''"objectType" cannot be "{kwargs['objectType']}", & must be set to one of: {options}''' + if "objectType" in kwargs: + options = ["person", "vehicle"] + assert kwargs["objectType"] in options, ( + f'''"objectType" cannot be "{kwargs["objectType"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['camera', 'monitor', 'analytics', 'zones', 'history'], - 'operation': 'getDeviceCameraAnalyticsZoneHistory' + "tags": ["camera", "monitor", "analytics", "zones", "history"], + "operation": "getDeviceCameraAnalyticsZoneHistory", } - serial = urllib.parse.quote(str(serial), safe='') - zoneId = urllib.parse.quote(str(zoneId), safe='') - resource = f'/devices/{serial}/camera/analytics/zones/{zoneId}/history' - - query_params = ['t0', 't1', 'timespan', 'resolution', 'objectType', ] + serial = urllib.parse.quote(str(serial), safe="") + zoneId = urllib.parse.quote(str(zoneId), safe="") + resource = f"/devices/{serial}/camera/analytics/zones/{zoneId}/history" + + query_params = [ + "t0", + "t1", + "timespan", + "resolution", + "objectType", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + if self._session._validate_kwargs: + all_params = query_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"getDeviceCameraAnalyticsZoneHistory: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) - + def clipDeviceCamera(self, serial: str, startTimestamp: str, endTimestamp: str, **kwargs): + """ + **Generate a video clip of up to 5 minutes long.** + https://developer.cisco.com/meraki/api-v1/#!clip-device-camera + + - 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): 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()) + + metadata = { + "tags": ["camera", "monitor"], + "operation": "clipDeviceCamera", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/clip" + + query_params = [ + "startTimestamp", + "endTimestamp", + "imagerId", + ] + params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} + + if self._session._validate_kwargs: + all_params = query_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"clipDeviceCamera: ignoring unrecognized kwargs: {invalid}") + + return self._session.get(metadata, resource, params) def getDeviceCameraCustomAnalytics(self, serial: str): """ @@ -151,15 +211,13 @@ def getDeviceCameraCustomAnalytics(self, serial: str): """ metadata = { - 'tags': ['camera', 'configure', 'customAnalytics'], - 'operation': 'getDeviceCameraCustomAnalytics' + "tags": ["camera", "configure", "customAnalytics"], + "operation": "getDeviceCameraCustomAnalytics", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/customAnalytics' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/customAnalytics" return self._session.get(metadata, resource) - - def updateDeviceCameraCustomAnalytics(self, serial: str, **kwargs): """ @@ -175,18 +233,26 @@ def updateDeviceCameraCustomAnalytics(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'customAnalytics'], - 'operation': 'updateDeviceCameraCustomAnalytics' + "tags": ["camera", "configure", "customAnalytics"], + "operation": "updateDeviceCameraCustomAnalytics", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/customAnalytics' - - body_params = ['enabled', 'artifactId', 'parameters', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/customAnalytics" + + body_params = [ + "enabled", + "artifactId", + "parameters", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceCameraCustomAnalytics: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def generateDeviceCameraSnapshot(self, serial: str, **kwargs): """ @@ -201,18 +267,25 @@ def generateDeviceCameraSnapshot(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['camera', 'monitor'], - 'operation': 'generateDeviceCameraSnapshot' + "tags": ["camera", "monitor"], + "operation": "generateDeviceCameraSnapshot", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/generateSnapshot' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/generateSnapshot" - body_params = ['timestamp', 'fullframe', ] + body_params = [ + "timestamp", + "fullframe", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"generateDeviceCameraSnapshot: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceCameraQualityAndRetention(self, serial: str): """ @@ -223,15 +296,13 @@ def getDeviceCameraQualityAndRetention(self, serial: str): """ metadata = { - 'tags': ['camera', 'configure', 'qualityAndRetention'], - 'operation': 'getDeviceCameraQualityAndRetention' + "tags": ["camera", "configure", "qualityAndRetention"], + "operation": "getDeviceCameraQualityAndRetention", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/qualityAndRetention' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/qualityAndRetention" return self._session.get(metadata, resource) - - def updateDeviceCameraQualityAndRetention(self, serial: str, **kwargs): """ @@ -250,29 +321,49 @@ def updateDeviceCameraQualityAndRetention(self, serial: str, **kwargs): kwargs.update(locals()) - if 'quality' in kwargs: - options = ['Enhanced', 'High', 'Standard', 'Ultra'] - assert kwargs['quality'] in options, f'''"quality" cannot be "{kwargs['quality']}", & must be set to one of: {options}''' - if 'resolution' in kwargs: - options = ['1080x1080', '1280x720', '1920x1080', '2112x2112', '2688x1512', '2880x2880', '3840x2160'] - assert kwargs['resolution'] in options, f'''"resolution" cannot be "{kwargs['resolution']}", & must be set to one of: {options}''' - if 'motionDetectorVersion' in kwargs: + if "quality" in kwargs: + options = ["Enhanced", "High", "Standard", "Ultra"] + assert kwargs["quality"] in options, ( + f'''"quality" cannot be "{kwargs["quality"]}", & must be set to one of: {options}''' + ) + if "resolution" in kwargs: + options = ["1080x1080", "1280x720", "1920x1080", "2112x2112", "2688x1512", "2880x2880", "3840x2160"] + assert kwargs["resolution"] in options, ( + f'''"resolution" cannot be "{kwargs["resolution"]}", & must be set to one of: {options}''' + ) + if "motionDetectorVersion" in kwargs: options = [1, 2] - assert kwargs['motionDetectorVersion'] in options, f'''"motionDetectorVersion" cannot be "{kwargs['motionDetectorVersion']}", & must be set to one of: {options}''' + assert kwargs["motionDetectorVersion"] in options, ( + f'''"motionDetectorVersion" cannot be "{kwargs["motionDetectorVersion"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['camera', 'configure', 'qualityAndRetention'], - 'operation': 'updateDeviceCameraQualityAndRetention' + "tags": ["camera", "configure", "qualityAndRetention"], + "operation": "updateDeviceCameraQualityAndRetention", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/qualityAndRetention' - - body_params = ['profileId', 'motionBasedRetentionEnabled', 'audioRecordingEnabled', 'restrictedBandwidthModeEnabled', 'quality', 'resolution', 'motionDetectorVersion', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/qualityAndRetention" + + body_params = [ + "profileId", + "motionBasedRetentionEnabled", + "audioRecordingEnabled", + "restrictedBandwidthModeEnabled", + "quality", + "resolution", + "motionDetectorVersion", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceCameraQualityAndRetention: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getDeviceCameraSense(self, serial: str): """ @@ -283,15 +374,13 @@ def getDeviceCameraSense(self, serial: str): """ metadata = { - 'tags': ['camera', 'configure', 'sense'], - 'operation': 'getDeviceCameraSense' + "tags": ["camera", "configure", "sense"], + "operation": "getDeviceCameraSense", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/sense' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/sense" return self._session.get(metadata, resource) - - def updateDeviceCameraSense(self, serial: str, **kwargs): """ @@ -308,18 +397,27 @@ def updateDeviceCameraSense(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'sense'], - 'operation': 'updateDeviceCameraSense' + "tags": ["camera", "configure", "sense"], + "operation": "updateDeviceCameraSense", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/sense' - - body_params = ['senseEnabled', 'mqttBrokerId', 'audioDetection', 'detectionModelId', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/sense" + + body_params = [ + "senseEnabled", + "mqttBrokerId", + "audioDetection", + "detectionModelId", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceCameraSense: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getDeviceCameraSenseObjectDetectionModels(self, serial: str): """ @@ -330,15 +428,13 @@ def getDeviceCameraSenseObjectDetectionModels(self, serial: str): """ metadata = { - 'tags': ['camera', 'configure', 'sense', 'objectDetectionModels'], - 'operation': 'getDeviceCameraSenseObjectDetectionModels' + "tags": ["camera", "configure", "sense", "objectDetectionModels"], + "operation": "getDeviceCameraSenseObjectDetectionModels", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/sense/objectDetectionModels' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/sense/objectDetectionModels" return self._session.get(metadata, resource) - - def getDeviceCameraVideoSettings(self, serial: str): """ @@ -349,15 +445,13 @@ def getDeviceCameraVideoSettings(self, serial: str): """ metadata = { - 'tags': ['camera', 'configure', 'video', 'settings'], - 'operation': 'getDeviceCameraVideoSettings' + "tags": ["camera", "configure", "video", "settings"], + "operation": "getDeviceCameraVideoSettings", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/video/settings' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/video/settings" return self._session.get(metadata, resource) - - def updateDeviceCameraVideoSettings(self, serial: str, **kwargs): """ @@ -371,18 +465,24 @@ def updateDeviceCameraVideoSettings(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'video', 'settings'], - 'operation': 'updateDeviceCameraVideoSettings' + "tags": ["camera", "configure", "video", "settings"], + "operation": "updateDeviceCameraVideoSettings", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/video/settings' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/video/settings" - body_params = ['externalRtspEnabled', ] + body_params = [ + "externalRtspEnabled", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceCameraVideoSettings: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getDeviceCameraVideoLink(self, serial: str, **kwargs): """ @@ -396,18 +496,24 @@ def getDeviceCameraVideoLink(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'videoLink'], - 'operation': 'getDeviceCameraVideoLink' + "tags": ["camera", "configure", "videoLink"], + "operation": "getDeviceCameraVideoLink", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/videoLink' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/videoLink" - query_params = ['timestamp', ] + query_params = [ + "timestamp", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get(metadata, resource, params) - + if self._session._validate_kwargs: + all_params = query_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"getDeviceCameraVideoLink: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) def getDeviceCameraWirelessProfiles(self, serial: str): """ @@ -418,17 +524,15 @@ def getDeviceCameraWirelessProfiles(self, serial: str): """ metadata = { - 'tags': ['camera', 'configure', 'wirelessProfiles'], - 'operation': 'getDeviceCameraWirelessProfiles' + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "getDeviceCameraWirelessProfiles", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/wirelessProfiles' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/wirelessProfiles" return self._session.get(metadata, resource) - - - def updateDeviceCameraWirelessProfiles(self, serial: str, ids: dict): + def updateDeviceCameraWirelessProfiles(self, serial: str, ids: dict, **kwargs): """ **Assign wireless profiles to the given camera** https://developer.cisco.com/meraki/api-v1/#!update-device-camera-wireless-profiles @@ -440,18 +544,24 @@ def updateDeviceCameraWirelessProfiles(self, serial: str, ids: dict): kwargs = locals() metadata = { - 'tags': ['camera', 'configure', 'wirelessProfiles'], - 'operation': 'updateDeviceCameraWirelessProfiles' + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "updateDeviceCameraWirelessProfiles", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/camera/wirelessProfiles' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/camera/wirelessProfiles" - body_params = ['ids', ] + body_params = [ + "ids", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceCameraWirelessProfiles: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkCameraQualityRetentionProfiles(self, networkId: str): """ @@ -462,15 +572,13 @@ def getNetworkCameraQualityRetentionProfiles(self, networkId: str): """ metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'getNetworkCameraQualityRetentionProfiles' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "getNetworkCameraQualityRetentionProfiles", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles" return self._session.get(metadata, resource) - - def createNetworkCameraQualityRetentionProfile(self, networkId: str, name: str, **kwargs): """ @@ -493,18 +601,35 @@ def createNetworkCameraQualityRetentionProfile(self, networkId: str, name: str, kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'createNetworkCameraQualityRetentionProfile' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "createNetworkCameraQualityRetentionProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles' - - body_params = ['name', 'motionBasedRetentionEnabled', 'restrictedBandwidthModeEnabled', 'audioRecordingEnabled', 'cloudArchiveEnabled', 'motionDetectorVersion', 'smartRetention', 'scheduleId', 'maxRetentionDays', 'videoSettings', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles" + + body_params = [ + "name", + "motionBasedRetentionEnabled", + "restrictedBandwidthModeEnabled", + "audioRecordingEnabled", + "cloudArchiveEnabled", + "motionDetectorVersion", + "smartRetention", + "scheduleId", + "maxRetentionDays", + "videoSettings", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createNetworkCameraQualityRetentionProfile: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRetentionProfileId: str): """ @@ -516,16 +641,14 @@ def getNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRetenti """ metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'getNetworkCameraQualityRetentionProfile' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "getNetworkCameraQualityRetentionProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - qualityRetentionProfileId = urllib.parse.quote(str(qualityRetentionProfileId), safe='') - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}' + networkId = urllib.parse.quote(str(networkId), safe="") + qualityRetentionProfileId = urllib.parse.quote(str(qualityRetentionProfileId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}" return self._session.get(metadata, resource) - - def updateNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRetentionProfileId: str, **kwargs): """ @@ -549,19 +672,36 @@ def updateNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRete kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'updateNetworkCameraQualityRetentionProfile' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "updateNetworkCameraQualityRetentionProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - qualityRetentionProfileId = urllib.parse.quote(str(qualityRetentionProfileId), safe='') - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}' - - body_params = ['name', 'motionBasedRetentionEnabled', 'restrictedBandwidthModeEnabled', 'audioRecordingEnabled', 'cloudArchiveEnabled', 'motionDetectorVersion', 'smartRetention', 'scheduleId', 'maxRetentionDays', 'videoSettings', ] + networkId = urllib.parse.quote(str(networkId), safe="") + qualityRetentionProfileId = urllib.parse.quote(str(qualityRetentionProfileId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}" + + body_params = [ + "name", + "motionBasedRetentionEnabled", + "restrictedBandwidthModeEnabled", + "audioRecordingEnabled", + "cloudArchiveEnabled", + "motionDetectorVersion", + "smartRetention", + "scheduleId", + "maxRetentionDays", + "videoSettings", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkCameraQualityRetentionProfile: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRetentionProfileId: str): """ @@ -573,16 +713,14 @@ def deleteNetworkCameraQualityRetentionProfile(self, networkId: str, qualityRete """ metadata = { - 'tags': ['camera', 'configure', 'qualityRetentionProfiles'], - 'operation': 'deleteNetworkCameraQualityRetentionProfile' + "tags": ["camera", "configure", "qualityRetentionProfiles"], + "operation": "deleteNetworkCameraQualityRetentionProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - qualityRetentionProfileId = urllib.parse.quote(str(qualityRetentionProfileId), safe='') - resource = f'/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}' + networkId = urllib.parse.quote(str(networkId), safe="") + qualityRetentionProfileId = urllib.parse.quote(str(qualityRetentionProfileId), safe="") + resource = f"/networks/{networkId}/camera/qualityRetentionProfiles/{qualityRetentionProfileId}" return self._session.delete(metadata, resource) - - def getNetworkCameraSchedules(self, networkId: str): """ @@ -593,15 +731,13 @@ def getNetworkCameraSchedules(self, networkId: str): """ metadata = { - 'tags': ['camera', 'configure', 'schedules'], - 'operation': 'getNetworkCameraSchedules' + "tags": ["camera", "configure", "schedules"], + "operation": "getNetworkCameraSchedules", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/camera/schedules' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/schedules" return self._session.get(metadata, resource) - - def createNetworkCameraWirelessProfile(self, networkId: str, name: str, ssid: dict, **kwargs): """ @@ -617,18 +753,26 @@ def createNetworkCameraWirelessProfile(self, networkId: str, name: str, ssid: di kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'wirelessProfiles'], - 'operation': 'createNetworkCameraWirelessProfile' + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "createNetworkCameraWirelessProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/camera/wirelessProfiles' - - body_params = ['name', 'ssid', 'identity', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles" + + body_params = [ + "name", + "ssid", + "identity", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createNetworkCameraWirelessProfile: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getNetworkCameraWirelessProfiles(self, networkId: str): """ @@ -639,15 +783,13 @@ def getNetworkCameraWirelessProfiles(self, networkId: str): """ metadata = { - 'tags': ['camera', 'configure', 'wirelessProfiles'], - 'operation': 'getNetworkCameraWirelessProfiles' + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "getNetworkCameraWirelessProfiles", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/camera/wirelessProfiles' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles" return self._session.get(metadata, resource) - - def getNetworkCameraWirelessProfile(self, networkId: str, wirelessProfileId: str): """ @@ -659,16 +801,14 @@ def getNetworkCameraWirelessProfile(self, networkId: str, wirelessProfileId: str """ metadata = { - 'tags': ['camera', 'configure', 'wirelessProfiles'], - 'operation': 'getNetworkCameraWirelessProfile' + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "getNetworkCameraWirelessProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - wirelessProfileId = urllib.parse.quote(str(wirelessProfileId), safe='') - resource = f'/networks/{networkId}/camera/wirelessProfiles/{wirelessProfileId}' + networkId = urllib.parse.quote(str(networkId), safe="") + wirelessProfileId = urllib.parse.quote(str(wirelessProfileId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles/{wirelessProfileId}" return self._session.get(metadata, resource) - - def updateNetworkCameraWirelessProfile(self, networkId: str, wirelessProfileId: str, **kwargs): """ @@ -685,19 +825,27 @@ def updateNetworkCameraWirelessProfile(self, networkId: str, wirelessProfileId: kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'wirelessProfiles'], - 'operation': 'updateNetworkCameraWirelessProfile' + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "updateNetworkCameraWirelessProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - wirelessProfileId = urllib.parse.quote(str(wirelessProfileId), safe='') - resource = f'/networks/{networkId}/camera/wirelessProfiles/{wirelessProfileId}' - - body_params = ['name', 'ssid', 'identity', ] + networkId = urllib.parse.quote(str(networkId), safe="") + wirelessProfileId = urllib.parse.quote(str(wirelessProfileId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles/{wirelessProfileId}" + + body_params = [ + "name", + "ssid", + "identity", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkCameraWirelessProfile: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def deleteNetworkCameraWirelessProfile(self, networkId: str, wirelessProfileId: str): """ @@ -709,16 +857,14 @@ def deleteNetworkCameraWirelessProfile(self, networkId: str, wirelessProfileId: """ metadata = { - 'tags': ['camera', 'configure', 'wirelessProfiles'], - 'operation': 'deleteNetworkCameraWirelessProfile' + "tags": ["camera", "configure", "wirelessProfiles"], + "operation": "deleteNetworkCameraWirelessProfile", } - networkId = urllib.parse.quote(str(networkId), safe='') - wirelessProfileId = urllib.parse.quote(str(wirelessProfileId), safe='') - resource = f'/networks/{networkId}/camera/wirelessProfiles/{wirelessProfileId}' + networkId = urllib.parse.quote(str(networkId), safe="") + wirelessProfileId = urllib.parse.quote(str(wirelessProfileId), safe="") + resource = f"/networks/{networkId}/camera/wirelessProfiles/{wirelessProfileId}" return self._session.delete(metadata, resource) - - def getOrganizationCameraBoundariesAreasByDevice(self, organizationId: str, **kwargs): """ @@ -732,24 +878,34 @@ def getOrganizationCameraBoundariesAreasByDevice(self, organizationId: str, **kw kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'boundaries', 'areas', 'byDevice'], - 'operation': 'getOrganizationCameraBoundariesAreasByDevice' + "tags": ["camera", "configure", "boundaries", "areas", "byDevice"], + "operation": "getOrganizationCameraBoundariesAreasByDevice", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/boundaries/areas/byDevice' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/boundaries/areas/byDevice" - query_params = ['serials', ] + query_params = [ + "serials", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['serials', ] + array_params = [ + "serials", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationCameraBoundariesAreasByDevice: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) def getOrganizationCameraBoundariesLinesByDevice(self, organizationId: str, **kwargs): """ @@ -763,24 +919,34 @@ def getOrganizationCameraBoundariesLinesByDevice(self, organizationId: str, **kw kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'boundaries', 'lines', 'byDevice'], - 'operation': 'getOrganizationCameraBoundariesLinesByDevice' + "tags": ["camera", "configure", "boundaries", "lines", "byDevice"], + "operation": "getOrganizationCameraBoundariesLinesByDevice", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/boundaries/lines/byDevice' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/boundaries/lines/byDevice" - query_params = ['serials', ] + query_params = [ + "serials", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['serials', ] + array_params = [ + "serials", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationCameraBoundariesLinesByDevice: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) def getOrganizationCameraCustomAnalyticsArtifacts(self, organizationId: str): """ @@ -791,15 +957,13 @@ def getOrganizationCameraCustomAnalyticsArtifacts(self, organizationId: str): """ metadata = { - 'tags': ['camera', 'configure', 'customAnalytics', 'artifacts'], - 'operation': 'getOrganizationCameraCustomAnalyticsArtifacts' + "tags": ["camera", "configure", "customAnalytics", "artifacts"], + "operation": "getOrganizationCameraCustomAnalyticsArtifacts", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/customAnalytics/artifacts' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/customAnalytics/artifacts" return self._session.get(metadata, resource) - - def createOrganizationCameraCustomAnalyticsArtifact(self, organizationId: str, **kwargs): """ @@ -813,18 +977,26 @@ def createOrganizationCameraCustomAnalyticsArtifact(self, organizationId: str, * kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'customAnalytics', 'artifacts'], - 'operation': 'createOrganizationCameraCustomAnalyticsArtifact' + "tags": ["camera", "configure", "customAnalytics", "artifacts"], + "operation": "createOrganizationCameraCustomAnalyticsArtifact", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/customAnalytics/artifacts' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/customAnalytics/artifacts" - body_params = ['name', ] + body_params = [ + "name", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationCameraCustomAnalyticsArtifact: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getOrganizationCameraCustomAnalyticsArtifact(self, organizationId: str, artifactId: str): """ @@ -836,16 +1008,14 @@ def getOrganizationCameraCustomAnalyticsArtifact(self, organizationId: str, arti """ metadata = { - 'tags': ['camera', 'configure', 'customAnalytics', 'artifacts'], - 'operation': 'getOrganizationCameraCustomAnalyticsArtifact' + "tags": ["camera", "configure", "customAnalytics", "artifacts"], + "operation": "getOrganizationCameraCustomAnalyticsArtifact", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - artifactId = urllib.parse.quote(str(artifactId), safe='') - resource = f'/organizations/{organizationId}/camera/customAnalytics/artifacts/{artifactId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + artifactId = urllib.parse.quote(str(artifactId), safe="") + resource = f"/organizations/{organizationId}/camera/customAnalytics/artifacts/{artifactId}" return self._session.get(metadata, resource) - - def deleteOrganizationCameraCustomAnalyticsArtifact(self, organizationId: str, artifactId: str): """ @@ -857,24 +1027,25 @@ def deleteOrganizationCameraCustomAnalyticsArtifact(self, organizationId: str, a """ metadata = { - 'tags': ['camera', 'configure', 'customAnalytics', 'artifacts'], - 'operation': 'deleteOrganizationCameraCustomAnalyticsArtifact' + "tags": ["camera", "configure", "customAnalytics", "artifacts"], + "operation": "deleteOrganizationCameraCustomAnalyticsArtifact", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - artifactId = urllib.parse.quote(str(artifactId), safe='') - resource = f'/organizations/{organizationId}/camera/customAnalytics/artifacts/{artifactId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + artifactId = urllib.parse.quote(str(artifactId), safe="") + resource = f"/organizations/{organizationId}/camera/customAnalytics/artifacts/{artifactId}" return self._session.delete(metadata, resource) - - - def getOrganizationCameraDetectionsHistoryByBoundaryByInterval(self, organizationId: str, boundaryIds: list, total_pages=1, direction='next', **kwargs): + def getOrganizationCameraDetectionsHistoryByBoundaryByInterval( + self, organizationId: str, boundaryIds: list, ranges: list, total_pages=1, direction="next", **kwargs + ): """ **Returns analytics data for timespans** https://developer.cisco.com/meraki/api-v1/#!get-organization-camera-detections-history-by-boundary-by-interval - organizationId (string): Organization ID - boundaryIds (array): A list of boundary ids. The returned cameras will be filtered to only include these ids. + - ranges (array): A list of time ranges with intervals - 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 - duration (integer): The minimum time, in seconds, that the person or car remains in the area to be counted. Defaults to boundary configuration or 60. @@ -885,24 +1056,40 @@ def getOrganizationCameraDetectionsHistoryByBoundaryByInterval(self, organizatio kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'detections', 'history', 'byBoundary', 'byInterval'], - 'operation': 'getOrganizationCameraDetectionsHistoryByBoundaryByInterval' + "tags": ["camera", "configure", "detections", "history", "byBoundary", "byInterval"], + "operation": "getOrganizationCameraDetectionsHistoryByBoundaryByInterval", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/detections/history/byBoundary/byInterval' - - query_params = ['boundaryIds', 'duration', 'perPage', 'boundaryTypes', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/detections/history/byBoundary/byInterval" + + query_params = [ + "boundaryIds", + "ranges", + "duration", + "perPage", + "boundaryTypes", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['boundaryIds', 'boundaryTypes', ] + array_params = [ + "boundaryIds", + "ranges", + "boundaryTypes", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get_pages(metadata, resource, params, total_pages, direction) - + 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"getOrganizationCameraDetectionsHistoryByBoundaryByInterval: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) def getOrganizationCameraOnboardingStatuses(self, organizationId: str, **kwargs): """ @@ -917,24 +1104,36 @@ def getOrganizationCameraOnboardingStatuses(self, organizationId: str, **kwargs) kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'onboarding', 'statuses'], - 'operation': 'getOrganizationCameraOnboardingStatuses' + "tags": ["camera", "configure", "onboarding", "statuses"], + "operation": "getOrganizationCameraOnboardingStatuses", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/onboarding/statuses' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/onboarding/statuses" - query_params = ['serials', 'networkIds', ] + query_params = [ + "serials", + "networkIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['serials', 'networkIds', ] + array_params = [ + "serials", + "networkIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationCameraOnboardingStatuses: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) def updateOrganizationCameraOnboardingStatuses(self, organizationId: str, **kwargs): """ @@ -949,18 +1148,27 @@ def updateOrganizationCameraOnboardingStatuses(self, organizationId: str, **kwar kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'onboarding', 'statuses'], - 'operation': 'updateOrganizationCameraOnboardingStatuses' + "tags": ["camera", "configure", "onboarding", "statuses"], + "operation": "updateOrganizationCameraOnboardingStatuses", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/onboarding/statuses' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/onboarding/statuses" - body_params = ['serial', 'wirelessCredentialsSent', ] + body_params = [ + "serial", + "wirelessCredentialsSent", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateOrganizationCameraOnboardingStatuses: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getOrganizationCameraPermissions(self, organizationId: str): """ @@ -971,15 +1179,13 @@ def getOrganizationCameraPermissions(self, organizationId: str): """ metadata = { - 'tags': ['camera', 'configure', 'permissions'], - 'operation': 'getOrganizationCameraPermissions' + "tags": ["camera", "configure", "permissions"], + "operation": "getOrganizationCameraPermissions", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/permissions' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/permissions" return self._session.get(metadata, resource) - - def getOrganizationCameraPermission(self, organizationId: str, permissionScopeId: str): """ @@ -991,16 +1197,14 @@ def getOrganizationCameraPermission(self, organizationId: str, permissionScopeId """ metadata = { - 'tags': ['camera', 'configure', 'permissions'], - 'operation': 'getOrganizationCameraPermission' + "tags": ["camera", "configure", "permissions"], + "operation": "getOrganizationCameraPermission", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - permissionScopeId = urllib.parse.quote(str(permissionScopeId), safe='') - resource = f'/organizations/{organizationId}/camera/permissions/{permissionScopeId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + permissionScopeId = urllib.parse.quote(str(permissionScopeId), safe="") + resource = f"/organizations/{organizationId}/camera/permissions/{permissionScopeId}" return self._session.get(metadata, resource) - - def getOrganizationCameraRoles(self, organizationId: str): """ @@ -1011,15 +1215,13 @@ def getOrganizationCameraRoles(self, organizationId: str): """ metadata = { - 'tags': ['camera', 'configure', 'roles'], - 'operation': 'getOrganizationCameraRoles' + "tags": ["camera", "configure", "roles"], + "operation": "getOrganizationCameraRoles", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/roles' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/roles" return self._session.get(metadata, resource) - - def createOrganizationCameraRole(self, organizationId: str, name: str, **kwargs): """ @@ -1036,18 +1238,27 @@ def createOrganizationCameraRole(self, organizationId: str, name: str, **kwargs) kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'roles'], - 'operation': 'createOrganizationCameraRole' + "tags": ["camera", "configure", "roles"], + "operation": "createOrganizationCameraRole", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/camera/roles' - - body_params = ['name', 'appliedOnDevices', 'appliedOnNetworks', 'appliedOrgWide', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/camera/roles" + + body_params = [ + "name", + "appliedOnDevices", + "appliedOnNetworks", + "appliedOrgWide", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationCameraRole: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getOrganizationCameraRole(self, organizationId: str, roleId: str): """ @@ -1059,16 +1270,14 @@ def getOrganizationCameraRole(self, organizationId: str, roleId: str): """ metadata = { - 'tags': ['camera', 'configure', 'roles'], - 'operation': 'getOrganizationCameraRole' + "tags": ["camera", "configure", "roles"], + "operation": "getOrganizationCameraRole", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - roleId = urllib.parse.quote(str(roleId), safe='') - resource = f'/organizations/{organizationId}/camera/roles/{roleId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + roleId = urllib.parse.quote(str(roleId), safe="") + resource = f"/organizations/{organizationId}/camera/roles/{roleId}" return self._session.get(metadata, resource) - - def deleteOrganizationCameraRole(self, organizationId: str, roleId: str): """ @@ -1080,16 +1289,14 @@ def deleteOrganizationCameraRole(self, organizationId: str, roleId: str): """ metadata = { - 'tags': ['camera', 'configure', 'roles'], - 'operation': 'deleteOrganizationCameraRole' + "tags": ["camera", "configure", "roles"], + "operation": "deleteOrganizationCameraRole", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - roleId = urllib.parse.quote(str(roleId), safe='') - resource = f'/organizations/{organizationId}/camera/roles/{roleId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + roleId = urllib.parse.quote(str(roleId), safe="") + resource = f"/organizations/{organizationId}/camera/roles/{roleId}" return self._session.delete(metadata, resource) - - def updateOrganizationCameraRole(self, organizationId: str, roleId: str, **kwargs): """ @@ -1107,15 +1314,25 @@ def updateOrganizationCameraRole(self, organizationId: str, roleId: str, **kwarg kwargs.update(locals()) metadata = { - 'tags': ['camera', 'configure', 'roles'], - 'operation': 'updateOrganizationCameraRole' + "tags": ["camera", "configure", "roles"], + "operation": "updateOrganizationCameraRole", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - roleId = urllib.parse.quote(str(roleId), safe='') - resource = f'/organizations/{organizationId}/camera/roles/{roleId}' - - body_params = ['name', 'appliedOnDevices', 'appliedOnNetworks', 'appliedOrgWide', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + roleId = urllib.parse.quote(str(roleId), safe="") + resource = f"/organizations/{organizationId}/camera/roles/{roleId}" + + body_params = [ + "name", + "appliedOnDevices", + "appliedOnNetworks", + "appliedOrgWide", + ] 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"updateOrganizationCameraRole: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) - diff --git a/meraki/aio/api/campusGateway.py b/meraki/aio/api/campusGateway.py index 1687ac90..ed7cee55 100644 --- a/meraki/aio/api/campusGateway.py +++ b/meraki/aio/api/campusGateway.py @@ -5,10 +5,10 @@ class AsyncCampusGateway: def __init__(self, session): super().__init__() self._session = session - - - def createNetworkCampusGatewayCluster(self, networkId: str, name: str, uplinks: list, tunnels: list, nameservers: dict, portChannels: list, **kwargs): + def createNetworkCampusGatewayCluster( + self, networkId: str, name: str, uplinks: list, tunnels: list, nameservers: dict, portChannels: list, **kwargs + ): """ **Create a cluster and add campus gateways to it** https://developer.cisco.com/meraki/api-v1/#!create-network-campus-gateway-cluster @@ -26,18 +26,30 @@ def createNetworkCampusGatewayCluster(self, networkId: str, name: str, uplinks: kwargs.update(locals()) metadata = { - 'tags': ['campusGateway', 'configure', 'clusters'], - 'operation': 'createNetworkCampusGatewayCluster' + "tags": ["campusGateway", "configure", "clusters"], + "operation": "createNetworkCampusGatewayCluster", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/campusGateway/clusters' - - body_params = ['name', 'uplinks', 'tunnels', 'nameservers', 'portChannels', 'devices', 'notes', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/campusGateway/clusters" + + body_params = [ + "name", + "uplinks", + "tunnels", + "nameservers", + "portChannels", + "devices", + "notes", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createNetworkCampusGatewayCluster: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def updateNetworkCampusGatewayCluster(self, networkId: str, clusterId: str, **kwargs): """ @@ -58,21 +70,82 @@ def updateNetworkCampusGatewayCluster(self, networkId: str, clusterId: str, **kw kwargs.update(locals()) metadata = { - 'tags': ['campusGateway', 'configure', 'clusters'], - 'operation': 'updateNetworkCampusGatewayCluster' + "tags": ["campusGateway", "configure", "clusters"], + "operation": "updateNetworkCampusGatewayCluster", } - networkId = urllib.parse.quote(str(networkId), safe='') - clusterId = urllib.parse.quote(str(clusterId), safe='') - resource = f'/networks/{networkId}/campusGateway/clusters/{clusterId}' - - body_params = ['name', 'uplinks', 'tunnels', 'nameservers', 'portChannels', 'devices', 'notes', ] + networkId = urllib.parse.quote(str(networkId), safe="") + clusterId = urllib.parse.quote(str(clusterId), safe="") + resource = f"/networks/{networkId}/campusGateway/clusters/{clusterId}" + + body_params = [ + "name", + "uplinks", + "tunnels", + "nameservers", + "portChannels", + "devices", + "notes", + ] 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"updateNetworkCampusGatewayCluster: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) - + def getOrganizationCampusGatewayClusters(self, organizationId: str, total_pages=1, direction="next", **kwargs): + """ + **Get the details of campus gateway clusters** + https://developer.cisco.com/meraki/api-v1/#!get-organization-campus-gateway-clusters - def getOrganizationCampusGatewayDevicesUplinksLocalOverridesByDevice(self, organizationId: str, total_pages=1, direction='next', **kwargs): + - 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): Networks for which information should be gathered. + - perPage (integer): The number of entries per page returned. Acceptable range is 3 - 100. Default is 50. + - 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": ["campusGateway", "configure", "clusters"], + "operation": "getOrganizationCampusGatewayClusters", + } + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/campusGateway/clusters" + + 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"getOrganizationCampusGatewayClusters: ignoring unrecognized kwargs: {invalid}") + + return self._session.get_pages(metadata, resource, params, total_pages, direction) + + def getOrganizationCampusGatewayDevicesUplinksLocalOverridesByDevice( + self, organizationId: str, total_pages=1, direction="next", **kwargs + ): """ **Uplink overrides configured locally on Campus Gateway devices in an organization.** https://developer.cisco.com/meraki/api-v1/#!get-organization-campus-gateway-devices-uplinks-local-overrides-by-device @@ -89,20 +162,34 @@ def getOrganizationCampusGatewayDevicesUplinksLocalOverridesByDevice(self, organ kwargs.update(locals()) metadata = { - 'tags': ['campusGateway', 'configure', 'devices', 'uplinks', 'localOverrides', 'byDevice'], - 'operation': 'getOrganizationCampusGatewayDevicesUplinksLocalOverridesByDevice' + "tags": ["campusGateway", "configure", "devices", "uplinks", "localOverrides", "byDevice"], + "operation": "getOrganizationCampusGatewayDevicesUplinksLocalOverridesByDevice", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/campusGateway/devices/uplinks/localOverrides/byDevice' - - query_params = ['serials', 'perPage', 'startingAfter', 'endingBefore', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/campusGateway/devices/uplinks/localOverrides/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', ] + array_params = [ + "serials", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + 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"getOrganizationCampusGatewayDevicesUplinksLocalOverridesByDevice: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) - diff --git a/meraki/aio/api/cellularGateway.py b/meraki/aio/api/cellularGateway.py index 22188a5f..8dd32f04 100644 --- a/meraki/aio/api/cellularGateway.py +++ b/meraki/aio/api/cellularGateway.py @@ -5,8 +5,6 @@ class AsyncCellularGateway: def __init__(self, session): super().__init__() self._session = session - - def getDeviceCellularGatewayLan(self, serial: str): """ @@ -17,15 +15,13 @@ def getDeviceCellularGatewayLan(self, serial: str): """ metadata = { - 'tags': ['cellularGateway', 'configure', 'lan'], - 'operation': 'getDeviceCellularGatewayLan' + "tags": ["cellularGateway", "configure", "lan"], + "operation": "getDeviceCellularGatewayLan", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/cellularGateway/lan' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellularGateway/lan" return self._session.get(metadata, resource) - - def updateDeviceCellularGatewayLan(self, serial: str, **kwargs): """ @@ -40,18 +36,25 @@ def updateDeviceCellularGatewayLan(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'lan'], - 'operation': 'updateDeviceCellularGatewayLan' + "tags": ["cellularGateway", "configure", "lan"], + "operation": "updateDeviceCellularGatewayLan", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/cellularGateway/lan' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellularGateway/lan" - body_params = ['reservedIpRanges', 'fixedIpAssignments', ] + body_params = [ + "reservedIpRanges", + "fixedIpAssignments", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceCellularGatewayLan: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getDeviceCellularGatewayPortForwardingRules(self, serial: str): """ @@ -62,15 +65,13 @@ def getDeviceCellularGatewayPortForwardingRules(self, serial: str): """ metadata = { - 'tags': ['cellularGateway', 'configure', 'portForwardingRules'], - 'operation': 'getDeviceCellularGatewayPortForwardingRules' + "tags": ["cellularGateway", "configure", "portForwardingRules"], + "operation": "getDeviceCellularGatewayPortForwardingRules", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/cellularGateway/portForwardingRules' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellularGateway/portForwardingRules" return self._session.get(metadata, resource) - - def updateDeviceCellularGatewayPortForwardingRules(self, serial: str, **kwargs): """ @@ -84,18 +85,26 @@ def updateDeviceCellularGatewayPortForwardingRules(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'portForwardingRules'], - 'operation': 'updateDeviceCellularGatewayPortForwardingRules' + "tags": ["cellularGateway", "configure", "portForwardingRules"], + "operation": "updateDeviceCellularGatewayPortForwardingRules", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/cellularGateway/portForwardingRules' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellularGateway/portForwardingRules" - body_params = ['rules', ] + body_params = [ + "rules", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceCellularGatewayPortForwardingRules: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkCellularGatewayConnectivityMonitoringDestinations(self, networkId: str): """ @@ -106,15 +115,13 @@ def getNetworkCellularGatewayConnectivityMonitoringDestinations(self, networkId: """ metadata = { - 'tags': ['cellularGateway', 'configure', 'connectivityMonitoringDestinations'], - 'operation': 'getNetworkCellularGatewayConnectivityMonitoringDestinations' + "tags": ["cellularGateway", "configure", "connectivityMonitoringDestinations"], + "operation": "getNetworkCellularGatewayConnectivityMonitoringDestinations", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/cellularGateway/connectivityMonitoringDestinations' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/connectivityMonitoringDestinations" return self._session.get(metadata, resource) - - def updateNetworkCellularGatewayConnectivityMonitoringDestinations(self, networkId: str, **kwargs): """ @@ -128,18 +135,26 @@ def updateNetworkCellularGatewayConnectivityMonitoringDestinations(self, network kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'connectivityMonitoringDestinations'], - 'operation': 'updateNetworkCellularGatewayConnectivityMonitoringDestinations' + "tags": ["cellularGateway", "configure", "connectivityMonitoringDestinations"], + "operation": "updateNetworkCellularGatewayConnectivityMonitoringDestinations", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/cellularGateway/connectivityMonitoringDestinations' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/connectivityMonitoringDestinations" - body_params = ['destinations', ] + body_params = [ + "destinations", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkCellularGatewayConnectivityMonitoringDestinations: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkCellularGatewayDhcp(self, networkId: str): """ @@ -150,15 +165,13 @@ def getNetworkCellularGatewayDhcp(self, networkId: str): """ metadata = { - 'tags': ['cellularGateway', 'configure', 'dhcp'], - 'operation': 'getNetworkCellularGatewayDhcp' + "tags": ["cellularGateway", "configure", "dhcp"], + "operation": "getNetworkCellularGatewayDhcp", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/cellularGateway/dhcp' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/dhcp" return self._session.get(metadata, resource) - - def updateNetworkCellularGatewayDhcp(self, networkId: str, **kwargs): """ @@ -174,18 +187,26 @@ def updateNetworkCellularGatewayDhcp(self, networkId: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'dhcp'], - 'operation': 'updateNetworkCellularGatewayDhcp' + "tags": ["cellularGateway", "configure", "dhcp"], + "operation": "updateNetworkCellularGatewayDhcp", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/cellularGateway/dhcp' - - body_params = ['dhcpLeaseTime', 'dnsNameservers', 'dnsCustomNameservers', ] + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/dhcp" + + body_params = [ + "dhcpLeaseTime", + "dnsNameservers", + "dnsCustomNameservers", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkCellularGatewayDhcp: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getNetworkCellularGatewaySubnetPool(self, networkId: str): """ @@ -196,15 +217,13 @@ def getNetworkCellularGatewaySubnetPool(self, networkId: str): """ metadata = { - 'tags': ['cellularGateway', 'configure', 'subnetPool'], - 'operation': 'getNetworkCellularGatewaySubnetPool' + "tags": ["cellularGateway", "configure", "subnetPool"], + "operation": "getNetworkCellularGatewaySubnetPool", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/cellularGateway/subnetPool' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/subnetPool" return self._session.get(metadata, resource) - - def updateNetworkCellularGatewaySubnetPool(self, networkId: str, **kwargs): """ @@ -219,18 +238,27 @@ def updateNetworkCellularGatewaySubnetPool(self, networkId: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'subnetPool'], - 'operation': 'updateNetworkCellularGatewaySubnetPool' + "tags": ["cellularGateway", "configure", "subnetPool"], + "operation": "updateNetworkCellularGatewaySubnetPool", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/cellularGateway/subnetPool' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/subnetPool" - body_params = ['mask', 'cidr', ] + body_params = [ + "mask", + "cidr", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkCellularGatewaySubnetPool: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getNetworkCellularGatewayUplink(self, networkId: str): """ @@ -241,15 +269,13 @@ def getNetworkCellularGatewayUplink(self, networkId: str): """ metadata = { - 'tags': ['cellularGateway', 'configure', 'uplink'], - 'operation': 'getNetworkCellularGatewayUplink' + "tags": ["cellularGateway", "configure", "uplink"], + "operation": "getNetworkCellularGatewayUplink", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/cellularGateway/uplink' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/uplink" return self._session.get(metadata, resource) - - def updateNetworkCellularGatewayUplink(self, networkId: str, **kwargs): """ @@ -263,18 +289,24 @@ def updateNetworkCellularGatewayUplink(self, networkId: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'uplink'], - 'operation': 'updateNetworkCellularGatewayUplink' + "tags": ["cellularGateway", "configure", "uplink"], + "operation": "updateNetworkCellularGatewayUplink", } - networkId = urllib.parse.quote(str(networkId), safe='') - resource = f'/networks/{networkId}/cellularGateway/uplink' + networkId = urllib.parse.quote(str(networkId), safe="") + resource = f"/networks/{networkId}/cellularGateway/uplink" - body_params = ['bandwidthLimits', ] + body_params = [ + "bandwidthLimits", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateNetworkCellularGatewayUplink: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def getOrganizationCellularGatewayEsimsInventory(self, organizationId: str, **kwargs): """ @@ -288,24 +320,34 @@ def getOrganizationCellularGatewayEsimsInventory(self, organizationId: str, **kw kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'inventory'], - 'operation': 'getOrganizationCellularGatewayEsimsInventory' + "tags": ["cellularGateway", "configure", "esims", "inventory"], + "operation": "getOrganizationCellularGatewayEsimsInventory", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/inventory' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/inventory" - query_params = ['eids', ] + query_params = [ + "eids", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['eids', ] + array_params = [ + "eids", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationCellularGatewayEsimsInventory: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) def updateOrganizationCellularGatewayEsimsInventory(self, organizationId: str, id: str, **kwargs): """ @@ -320,19 +362,27 @@ def updateOrganizationCellularGatewayEsimsInventory(self, organizationId: str, i kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'inventory'], - 'operation': 'updateOrganizationCellularGatewayEsimsInventory' + "tags": ["cellularGateway", "configure", "esims", "inventory"], + "operation": "updateOrganizationCellularGatewayEsimsInventory", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - id = urllib.parse.quote(str(id), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/inventory/{id}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/inventory/{id}" - body_params = ['status', ] + body_params = [ + "status", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateOrganizationCellularGatewayEsimsInventory: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def getOrganizationCellularGatewayEsimsServiceProviders(self, organizationId: str): """ @@ -343,15 +393,13 @@ def getOrganizationCellularGatewayEsimsServiceProviders(self, organizationId: st """ metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'serviceProviders'], - 'operation': 'getOrganizationCellularGatewayEsimsServiceProviders' + "tags": ["cellularGateway", "configure", "esims", "serviceProviders"], + "operation": "getOrganizationCellularGatewayEsimsServiceProviders", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/serviceProviders' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders" return self._session.get(metadata, resource) - - def getOrganizationCellularGatewayEsimsServiceProvidersAccounts(self, organizationId: str, **kwargs): """ @@ -365,26 +413,38 @@ def getOrganizationCellularGatewayEsimsServiceProvidersAccounts(self, organizati kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'serviceProviders', 'accounts'], - 'operation': 'getOrganizationCellularGatewayEsimsServiceProvidersAccounts' + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts"], + "operation": "getOrganizationCellularGatewayEsimsServiceProvidersAccounts", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts" - query_params = ['accountIds', ] + query_params = [ + "accountIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['accountIds', ] + array_params = [ + "accountIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationCellularGatewayEsimsServiceProvidersAccounts: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) - def createOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organizationId: str, accountId: str, apiKey: str, serviceProvider: dict, title: str, username: str): + def createOrganizationCellularGatewayEsimsServiceProvidersAccount( + self, organizationId: str, accountId: str, apiKey: str, serviceProvider: dict, title: str, username: str, **kwargs + ): """ **Add a service provider account.** https://developer.cisco.com/meraki/api-v1/#!create-organization-cellular-gateway-esims-service-providers-account @@ -400,20 +460,34 @@ def createOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organiza kwargs = locals() metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'serviceProviders', 'accounts'], - 'operation': 'createOrganizationCellularGatewayEsimsServiceProvidersAccount' + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts"], + "operation": "createOrganizationCellularGatewayEsimsServiceProvidersAccount", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts' - - body_params = ['accountId', 'apiKey', 'serviceProvider', 'title', 'username', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts" + + body_params = [ + "accountId", + "apiKey", + "serviceProvider", + "title", + "username", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationCellularGatewayEsimsServiceProvidersAccount: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) - def getOrganizationCellularGatewayEsimsServiceProvidersAccountsCommunicationPlans(self, organizationId: str, accountIds: list): + def getOrganizationCellularGatewayEsimsServiceProvidersAccountsCommunicationPlans( + self, organizationId: str, accountIds: list, **kwargs + ): """ **The communication plans available for a given provider.** https://developer.cisco.com/meraki/api-v1/#!get-organization-cellular-gateway-esims-service-providers-accounts-communication-plans @@ -425,26 +499,38 @@ def getOrganizationCellularGatewayEsimsServiceProvidersAccountsCommunicationPlan kwargs = locals() metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'serviceProviders', 'accounts', 'communicationPlans'], - 'operation': 'getOrganizationCellularGatewayEsimsServiceProvidersAccountsCommunicationPlans' + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts", "communicationPlans"], + "operation": "getOrganizationCellularGatewayEsimsServiceProvidersAccountsCommunicationPlans", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/communicationPlans' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/communicationPlans" - query_params = ['accountIds', ] + query_params = [ + "accountIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['accountIds', ] + array_params = [ + "accountIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationCellularGatewayEsimsServiceProvidersAccountsCommunicationPlans: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) - def getOrganizationCellularGatewayEsimsServiceProvidersAccountsRatePlans(self, organizationId: str, accountIds: list): + def getOrganizationCellularGatewayEsimsServiceProvidersAccountsRatePlans( + self, organizationId: str, accountIds: list, **kwargs + ): """ **The rate plans available for a given provider.** https://developer.cisco.com/meraki/api-v1/#!get-organization-cellular-gateway-esims-service-providers-accounts-rate-plans @@ -456,24 +542,34 @@ def getOrganizationCellularGatewayEsimsServiceProvidersAccountsRatePlans(self, o kwargs = locals() metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'serviceProviders', 'accounts', 'ratePlans'], - 'operation': 'getOrganizationCellularGatewayEsimsServiceProvidersAccountsRatePlans' + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts", "ratePlans"], + "operation": "getOrganizationCellularGatewayEsimsServiceProvidersAccountsRatePlans", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/ratePlans' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/ratePlans" - query_params = ['accountIds', ] + query_params = [ + "accountIds", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['accountIds', ] + array_params = [ + "accountIds", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getOrganizationCellularGatewayEsimsServiceProvidersAccountsRatePlans: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) def updateOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organizationId: str, accountId: str, **kwargs): """ @@ -489,19 +585,28 @@ def updateOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organiza kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'serviceProviders', 'accounts'], - 'operation': 'updateOrganizationCellularGatewayEsimsServiceProvidersAccount' + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts"], + "operation": "updateOrganizationCellularGatewayEsimsServiceProvidersAccount", } - 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 = ['title', 'apiKey', ] + 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 = [ + "title", + "apiKey", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateOrganizationCellularGatewayEsimsServiceProvidersAccount: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organizationId: str, accountId: str): """ @@ -513,18 +618,16 @@ def deleteOrganizationCellularGatewayEsimsServiceProvidersAccount(self, organiza """ metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'serviceProviders', 'accounts'], - 'operation': 'deleteOrganizationCellularGatewayEsimsServiceProvidersAccount' + "tags": ["cellularGateway", "configure", "esims", "serviceProviders", "accounts"], + "operation": "deleteOrganizationCellularGatewayEsimsServiceProvidersAccount", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - accountId = urllib.parse.quote(str(accountId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/{accountId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + accountId = urllib.parse.quote(str(accountId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/serviceProviders/accounts/{accountId}" return self._session.delete(metadata, resource) - - - def createOrganizationCellularGatewayEsimsSwap(self, organizationId: str, swaps: list): + def createOrganizationCellularGatewayEsimsSwap(self, organizationId: str, swaps: list, **kwargs): """ **Swap which profile an eSIM uses.** https://developer.cisco.com/meraki/api-v1/#!create-organization-cellular-gateway-esims-swap @@ -536,18 +639,26 @@ def createOrganizationCellularGatewayEsimsSwap(self, organizationId: str, swaps: kwargs = locals() metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'swap'], - 'operation': 'createOrganizationCellularGatewayEsimsSwap' + "tags": ["cellularGateway", "configure", "esims", "swap"], + "operation": "createOrganizationCellularGatewayEsimsSwap", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/swap' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/swap" - body_params = ['swaps', ] + body_params = [ + "swaps", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationCellularGatewayEsimsSwap: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def updateOrganizationCellularGatewayEsimsSwap(self, id: str, organizationId: str): """ @@ -559,18 +670,16 @@ def updateOrganizationCellularGatewayEsimsSwap(self, id: str, organizationId: st """ metadata = { - 'tags': ['cellularGateway', 'configure', 'esims', 'swap'], - 'operation': 'updateOrganizationCellularGatewayEsimsSwap' + "tags": ["cellularGateway", "configure", "esims", "swap"], + "operation": "updateOrganizationCellularGatewayEsimsSwap", } - id = urllib.parse.quote(str(id), safe='') - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/esims/swap/{id}' + id = urllib.parse.quote(str(id), safe="") + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/esims/swap/{id}" return self._session.put(metadata, resource) - - - def getOrganizationCellularGatewayUplinkStatuses(self, organizationId: str, total_pages=1, direction='next', **kwargs): + def getOrganizationCellularGatewayUplinkStatuses(self, organizationId: str, total_pages=1, direction="next", **kwargs): """ **List the uplink status of every Meraki MG cellular gateway in the organization** https://developer.cisco.com/meraki/api-v1/#!get-organization-cellular-gateway-uplink-statuses @@ -589,20 +698,38 @@ def getOrganizationCellularGatewayUplinkStatuses(self, organizationId: str, tota kwargs.update(locals()) metadata = { - 'tags': ['cellularGateway', 'monitor', 'uplink', 'statuses'], - 'operation': 'getOrganizationCellularGatewayUplinkStatuses' + "tags": ["cellularGateway", "monitor", "uplink", "statuses"], + "operation": "getOrganizationCellularGatewayUplinkStatuses", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/cellularGateway/uplink/statuses' - - query_params = ['perPage', 'startingAfter', 'endingBefore', 'networkIds', 'serials', 'iccids', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/cellularGateway/uplink/statuses" + + query_params = [ + "perPage", + "startingAfter", + "endingBefore", + "networkIds", + "serials", + "iccids", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['networkIds', 'serials', 'iccids', ] + array_params = [ + "networkIds", + "serials", + "iccids", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + 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"getOrganizationCellularGatewayUplinkStatuses: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get_pages(metadata, resource, params, total_pages, direction) - diff --git a/meraki/aio/api/devices.py b/meraki/aio/api/devices.py index 4c1ec031..1c02af9c 100644 --- a/meraki/aio/api/devices.py +++ b/meraki/aio/api/devices.py @@ -5,8 +5,6 @@ class AsyncDevices: def __init__(self, session): super().__init__() self._session = session - - def getDevice(self, serial: str): """ @@ -17,15 +15,13 @@ def getDevice(self, serial: str): """ metadata = { - 'tags': ['devices', 'configure'], - 'operation': 'getDevice' + "tags": ["devices", "configure"], + "operation": "getDevice", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}" return self._session.get(metadata, resource) - - def updateDevice(self, serial: str, **kwargs): """ @@ -47,18 +43,32 @@ def updateDevice(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'configure'], - 'operation': 'updateDevice' + "tags": ["devices", "configure"], + "operation": "updateDevice", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}' - - body_params = ['name', 'tags', 'lat', 'lng', 'address', 'notes', 'moveMapMarker', 'switchProfileId', 'floorPlanId', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}" + + body_params = [ + "name", + "tags", + "lat", + "lng", + "address", + "notes", + "moveMapMarker", + "switchProfileId", + "floorPlanId", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDevice: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def blinkDeviceLeds(self, serial: str, **kwargs): """ @@ -74,18 +84,57 @@ def blinkDeviceLeds(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools'], - 'operation': 'blinkDeviceLeds' + "tags": ["devices", "liveTools"], + "operation": "blinkDeviceLeds", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/blinkLeds' - - body_params = ['duration', 'period', 'duty', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/blinkLeds" + + body_params = [ + "duration", + "period", + "duty", + ] 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"blinkDeviceLeds: ignoring unrecognized kwargs: {invalid}") + 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): """ @@ -96,15 +145,13 @@ def getDeviceCellularSims(self, serial: str): """ metadata = { - 'tags': ['devices', 'configure', 'cellular', 'sims'], - 'operation': 'getDeviceCellularSims' + "tags": ["devices", "configure", "cellular", "sims"], + "operation": "getDeviceCellularSims", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/cellular/sims' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellular/sims" return self._session.get(metadata, resource) - - def updateDeviceCellularSims(self, serial: str, **kwargs): """ @@ -113,25 +160,77 @@ 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. """ kwargs.update(locals()) metadata = { - 'tags': ['devices', 'configure', 'cellular', 'sims'], - 'operation': 'updateDeviceCellularSims' + "tags": ["devices", "configure", "cellular", "sims"], + "operation": "updateDeviceCellularSims", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/cellular/sims' - - body_params = ['sims', 'simOrdering', 'simFailover', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/cellular/sims" + + body_params = [ + "sims", + "simOrdering", + "simFailover", + ] 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"updateDeviceCellularSims: ignoring unrecognized kwargs: {invalid}") + 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): """ @@ -146,18 +245,25 @@ def getDeviceClients(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'monitor', 'clients'], - 'operation': 'getDeviceClients' + "tags": ["devices", "monitor", "clients"], + "operation": "getDeviceClients", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/clients' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/clients" - query_params = ['t0', 'timespan', ] + query_params = [ + "t0", + "timespan", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get(metadata, resource, params) - + if self._session._validate_kwargs: + all_params = query_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"getDeviceClients: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) def createDeviceLiveToolsArpTable(self, serial: str, **kwargs): """ @@ -171,18 +277,24 @@ def createDeviceLiveToolsArpTable(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools', 'arpTable'], - 'operation': 'createDeviceLiveToolsArpTable' + "tags": ["devices", "liveTools", "arpTable"], + "operation": "createDeviceLiveToolsArpTable", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/liveTools/arpTable' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/arpTable" - body_params = ['callback', ] + body_params = [ + "callback", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createDeviceLiveToolsArpTable: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceLiveToolsArpTable(self, serial: str, arpTableId: str): """ @@ -194,16 +306,14 @@ def getDeviceLiveToolsArpTable(self, serial: str, arpTableId: str): """ metadata = { - 'tags': ['devices', 'liveTools', 'arpTable'], - 'operation': 'getDeviceLiveToolsArpTable' + "tags": ["devices", "liveTools", "arpTable"], + "operation": "getDeviceLiveToolsArpTable", } - serial = urllib.parse.quote(str(serial), safe='') - arpTableId = urllib.parse.quote(str(arpTableId), safe='') - resource = f'/devices/{serial}/liveTools/arpTable/{arpTableId}' + serial = urllib.parse.quote(str(serial), safe="") + arpTableId = urllib.parse.quote(str(arpTableId), safe="") + resource = f"/devices/{serial}/liveTools/arpTable/{arpTableId}" return self._session.get(metadata, resource) - - def createDeviceLiveToolsCableTest(self, serial: str, ports: list, **kwargs): """ @@ -218,18 +328,25 @@ def createDeviceLiveToolsCableTest(self, serial: str, ports: list, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools', 'cableTest'], - 'operation': 'createDeviceLiveToolsCableTest' + "tags": ["devices", "liveTools", "cableTest"], + "operation": "createDeviceLiveToolsCableTest", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/liveTools/cableTest' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/cableTest" - body_params = ['ports', 'callback', ] + body_params = [ + "ports", + "callback", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createDeviceLiveToolsCableTest: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceLiveToolsCableTest(self, serial: str, id: str): """ @@ -241,16 +358,14 @@ def getDeviceLiveToolsCableTest(self, serial: str, id: str): """ metadata = { - 'tags': ['devices', 'liveTools', 'cableTest'], - 'operation': 'getDeviceLiveToolsCableTest' + "tags": ["devices", "liveTools", "cableTest"], + "operation": "getDeviceLiveToolsCableTest", } - serial = urllib.parse.quote(str(serial), safe='') - id = urllib.parse.quote(str(id), safe='') - resource = f'/devices/{serial}/liveTools/cableTest/{id}' + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/cableTest/{id}" return self._session.get(metadata, resource) - - def createDeviceLiveToolsLedsBlink(self, serial: str, duration: int, **kwargs): """ @@ -265,18 +380,25 @@ def createDeviceLiveToolsLedsBlink(self, serial: str, duration: int, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools', 'leds', 'blink'], - 'operation': 'createDeviceLiveToolsLedsBlink' + "tags": ["devices", "liveTools", "leds", "blink"], + "operation": "createDeviceLiveToolsLedsBlink", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/liveTools/leds/blink' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/leds/blink" - body_params = ['duration', 'callback', ] + body_params = [ + "duration", + "callback", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createDeviceLiveToolsLedsBlink: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceLiveToolsLedsBlink(self, serial: str, ledsBlinkId: str): """ @@ -288,16 +410,14 @@ def getDeviceLiveToolsLedsBlink(self, serial: str, ledsBlinkId: str): """ metadata = { - 'tags': ['devices', 'liveTools', 'leds', 'blink'], - 'operation': 'getDeviceLiveToolsLedsBlink' + "tags": ["devices", "liveTools", "leds", "blink"], + "operation": "getDeviceLiveToolsLedsBlink", } - serial = urllib.parse.quote(str(serial), safe='') - ledsBlinkId = urllib.parse.quote(str(ledsBlinkId), safe='') - resource = f'/devices/{serial}/liveTools/leds/blink/{ledsBlinkId}' + serial = urllib.parse.quote(str(serial), safe="") + ledsBlinkId = urllib.parse.quote(str(ledsBlinkId), safe="") + resource = f"/devices/{serial}/liveTools/leds/blink/{ledsBlinkId}" return self._session.get(metadata, resource) - - def createDeviceLiveToolsMacTable(self, serial: str, **kwargs): """ @@ -305,24 +425,32 @@ 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 """ kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools', 'macTable'], - 'operation': 'createDeviceLiveToolsMacTable' + "tags": ["devices", "liveTools", "macTable"], + "operation": "createDeviceLiveToolsMacTable", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/liveTools/macTable' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/macTable" - body_params = ['callback', ] + body_params = [ + "mac", + "callback", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createDeviceLiveToolsMacTable: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceLiveToolsMacTable(self, serial: str, macTableId: str): """ @@ -334,16 +462,66 @@ def getDeviceLiveToolsMacTable(self, serial: str, macTableId: str): """ metadata = { - 'tags': ['devices', 'liveTools', 'macTable'], - 'operation': 'getDeviceLiveToolsMacTable' + "tags": ["devices", "liveTools", "macTable"], + "operation": "getDeviceLiveToolsMacTable", } - serial = urllib.parse.quote(str(serial), safe='') - macTableId = urllib.parse.quote(str(macTableId), safe='') - resource = f'/devices/{serial}/liveTools/macTable/{macTableId}' + serial = urllib.parse.quote(str(serial), safe="") + macTableId = urllib.parse.quote(str(macTableId), safe="") + resource = f"/devices/{serial}/liveTools/macTable/{macTableId}" return self._session.get(metadata, resource) - + def createDeviceLiveToolsMulticastRouting(self, serial: str, **kwargs): + """ + **Enqueue a job to perform a Multicast routing request for the device** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-multicast-routing + + - 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", "multicastRouting"], + "operation": "createDeviceLiveToolsMulticastRouting", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/multicastRouting" + + 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"createDeviceLiveToolsMulticastRouting: ignoring unrecognized kwargs: {invalid}" + ) + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsMulticastRouting(self, serial: str, multicastRoutingId: str): + """ + **Return a Multicast routing live tool job.** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-multicast-routing + + - serial (string): Serial + - multicastRoutingId (string): Multicast routing ID + """ + + metadata = { + "tags": ["devices", "liveTools", "multicastRouting"], + "operation": "getDeviceLiveToolsMulticastRouting", + } + serial = urllib.parse.quote(str(serial), safe="") + multicastRoutingId = urllib.parse.quote(str(multicastRoutingId), safe="") + resource = f"/devices/{serial}/liveTools/multicastRouting/{multicastRoutingId}" + + return self._session.get(metadata, resource) def createDeviceLiveToolsPing(self, serial: str, target: str, **kwargs): """ @@ -359,18 +537,26 @@ def createDeviceLiveToolsPing(self, serial: str, target: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools', 'ping'], - 'operation': 'createDeviceLiveToolsPing' + "tags": ["devices", "liveTools", "ping"], + "operation": "createDeviceLiveToolsPing", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/liveTools/ping' - - body_params = ['target', 'count', 'callback', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/ping" + + body_params = [ + "target", + "count", + "callback", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createDeviceLiveToolsPing: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceLiveToolsPing(self, serial: str, id: str): """ @@ -382,16 +568,14 @@ def getDeviceLiveToolsPing(self, serial: str, id: str): """ metadata = { - 'tags': ['devices', 'liveTools', 'ping'], - 'operation': 'getDeviceLiveToolsPing' + "tags": ["devices", "liveTools", "ping"], + "operation": "getDeviceLiveToolsPing", } - serial = urllib.parse.quote(str(serial), safe='') - id = urllib.parse.quote(str(id), safe='') - resource = f'/devices/{serial}/liveTools/ping/{id}' + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/ping/{id}" return self._session.get(metadata, resource) - - def createDeviceLiveToolsPingDevice(self, serial: str, **kwargs): """ @@ -406,18 +590,25 @@ def createDeviceLiveToolsPingDevice(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools', 'pingDevice'], - 'operation': 'createDeviceLiveToolsPingDevice' + "tags": ["devices", "liveTools", "pingDevice"], + "operation": "createDeviceLiveToolsPingDevice", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/liveTools/pingDevice' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/pingDevice" - body_params = ['count', 'callback', ] + body_params = [ + "count", + "callback", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createDeviceLiveToolsPingDevice: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceLiveToolsPingDevice(self, serial: str, id: str): """ @@ -429,16 +620,298 @@ def getDeviceLiveToolsPingDevice(self, serial: str, id: str): """ metadata = { - 'tags': ['devices', 'liveTools', 'pingDevice'], - 'operation': 'getDeviceLiveToolsPingDevice' + "tags": ["devices", "liveTools", "pingDevice"], + "operation": "getDeviceLiveToolsPingDevice", + } + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/pingDevice/{id}" + + return self._session.get(metadata, resource) + + def createDeviceLiveToolsPortsCycle(self, serial: str, ports: list, **kwargs): + """ + **Enqueue a job to perform a cycle port for the device on the specified ports** + https://developer.cisco.com/meraki/api-v1/#!create-device-live-tools-ports-cycle + + - serial (string): Serial + - ports (array): A list of ports to cycle. For Catalyst switches, IOS interface names are also supported, such as "GigabitEthernet1/0/8", "Gi1/0/8", or even "1/0/8". + - callback (object): Details for the callback. Please include either an httpServerId OR url and sharedSecret + """ + + kwargs.update(locals()) + + metadata = { + "tags": ["devices", "liveTools", "ports", "cycle"], + "operation": "createDeviceLiveToolsPortsCycle", + } + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/ports/cycle" + + body_params = [ + "ports", + "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"createDeviceLiveToolsPortsCycle: ignoring unrecognized kwargs: {invalid}") + + return self._session.post(metadata, resource, payload) + + def getDeviceLiveToolsPortsCycle(self, serial: str, id: str): + """ + **Return a cycle port live tool job.** + https://developer.cisco.com/meraki/api-v1/#!get-device-live-tools-ports-cycle + + - serial (string): Serial + - id (string): ID + """ + + metadata = { + "tags": ["devices", "liveTools", "ports", "cycle"], + "operation": "getDeviceLiveToolsPortsCycle", + } + serial = urllib.parse.quote(str(serial), safe="") + id = urllib.parse.quote(str(id), safe="") + resource = f"/devices/{serial}/liveTools/ports/cycle/{id}" + + 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/pingDevice/{id}' + 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): """ @@ -452,18 +925,24 @@ def createDeviceLiveToolsThroughputTest(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools', 'throughputTest'], - 'operation': 'createDeviceLiveToolsThroughputTest' + "tags": ["devices", "liveTools", "throughputTest"], + "operation": "createDeviceLiveToolsThroughputTest", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/liveTools/throughputTest' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/throughputTest" - body_params = ['callback', ] + body_params = [ + "callback", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createDeviceLiveToolsThroughputTest: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceLiveToolsThroughputTest(self, serial: str, throughputTestId: str): """ @@ -475,16 +954,14 @@ def getDeviceLiveToolsThroughputTest(self, serial: str, throughputTestId: str): """ metadata = { - 'tags': ['devices', 'liveTools', 'throughputTest'], - 'operation': 'getDeviceLiveToolsThroughputTest' + "tags": ["devices", "liveTools", "throughputTest"], + "operation": "getDeviceLiveToolsThroughputTest", } - serial = urllib.parse.quote(str(serial), safe='') - throughputTestId = urllib.parse.quote(str(throughputTestId), safe='') - resource = f'/devices/{serial}/liveTools/throughputTest/{throughputTestId}' + serial = urllib.parse.quote(str(serial), safe="") + throughputTestId = urllib.parse.quote(str(throughputTestId), safe="") + resource = f"/devices/{serial}/liveTools/throughputTest/{throughputTestId}" return self._session.get(metadata, resource) - - def createDeviceLiveToolsWakeOnLan(self, serial: str, vlanId: int, mac: str, **kwargs): """ @@ -500,18 +977,26 @@ def createDeviceLiveToolsWakeOnLan(self, serial: str, vlanId: int, mac: str, **k kwargs.update(locals()) metadata = { - 'tags': ['devices', 'liveTools', 'wakeOnLan'], - 'operation': 'createDeviceLiveToolsWakeOnLan' + "tags": ["devices", "liveTools", "wakeOnLan"], + "operation": "createDeviceLiveToolsWakeOnLan", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/liveTools/wakeOnLan' - - body_params = ['vlanId', 'mac', 'callback', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/liveTools/wakeOnLan" + + body_params = [ + "vlanId", + "mac", + "callback", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createDeviceLiveToolsWakeOnLan: ignoring unrecognized kwargs: {invalid}") + return self._session.post(metadata, resource, payload) def getDeviceLiveToolsWakeOnLan(self, serial: str, wakeOnLanId: str): """ @@ -523,16 +1008,14 @@ def getDeviceLiveToolsWakeOnLan(self, serial: str, wakeOnLanId: str): """ metadata = { - 'tags': ['devices', 'liveTools', 'wakeOnLan'], - 'operation': 'getDeviceLiveToolsWakeOnLan' + "tags": ["devices", "liveTools", "wakeOnLan"], + "operation": "getDeviceLiveToolsWakeOnLan", } - serial = urllib.parse.quote(str(serial), safe='') - wakeOnLanId = urllib.parse.quote(str(wakeOnLanId), safe='') - resource = f'/devices/{serial}/liveTools/wakeOnLan/{wakeOnLanId}' + serial = urllib.parse.quote(str(serial), safe="") + wakeOnLanId = urllib.parse.quote(str(wakeOnLanId), safe="") + resource = f"/devices/{serial}/liveTools/wakeOnLan/{wakeOnLanId}" return self._session.get(metadata, resource) - - def getDeviceLldpCdp(self, serial: str): """ @@ -543,15 +1026,13 @@ def getDeviceLldpCdp(self, serial: str): """ metadata = { - 'tags': ['devices', 'monitor', 'lldpCdp'], - 'operation': 'getDeviceLldpCdp' + "tags": ["devices", "monitor", "lldpCdp"], + "operation": "getDeviceLldpCdp", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/lldpCdp' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/lldpCdp" return self._session.get(metadata, resource) - - def getDeviceLossAndLatencyHistory(self, serial: str, ip: str, **kwargs): """ @@ -564,28 +1045,41 @@ def getDeviceLossAndLatencyHistory(self, serial: str, ip: str, **kwargs): - 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. - resolution (integer): The time resolution in seconds for returned data. The valid resolutions are: 60, 600, 3600, 86400. The default is 60. - - uplink (string): The WAN uplink used to obtain the requested stats. Valid uplinks are wan1, wan2, wan3, cellular. The default is wan1. + - uplink (string): The WAN uplink used to obtain the requested stats. Valid uplinks are wan1, wan2, wan3, cellular, wan4. The default is wan1. """ kwargs.update(locals()) - if 'uplink' in kwargs: - options = ['cellular', 'wan1', 'wan2', 'wan3'] - assert kwargs['uplink'] in options, f'''"uplink" cannot be "{kwargs['uplink']}", & must be set to one of: {options}''' + if "uplink" in kwargs: + options = ["cellular", "wan1", "wan2", "wan3", "wan4"] + assert kwargs["uplink"] in options, ( + f'''"uplink" cannot be "{kwargs["uplink"]}", & must be set to one of: {options}''' + ) metadata = { - 'tags': ['devices', 'monitor', 'uplinks', 'lossAndLatencyHistory'], - 'operation': 'getDeviceLossAndLatencyHistory' + "tags": ["devices", "monitor", "uplinks", "lossAndLatencyHistory"], + "operation": "getDeviceLossAndLatencyHistory", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/lossAndLatencyHistory' - - query_params = ['t0', 't1', 'timespan', 'resolution', 'uplink', 'ip', ] + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/lossAndLatencyHistory" + + query_params = [ + "t0", + "t1", + "timespan", + "resolution", + "uplink", + "ip", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get(metadata, resource, params) - + if self._session._validate_kwargs: + all_params = query_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"getDeviceLossAndLatencyHistory: ignoring unrecognized kwargs: {invalid}") + return self._session.get(metadata, resource, params) def getDeviceManagementInterface(self, serial: str): """ @@ -596,15 +1090,13 @@ def getDeviceManagementInterface(self, serial: str): """ metadata = { - 'tags': ['devices', 'configure', 'managementInterface'], - 'operation': 'getDeviceManagementInterface' + "tags": ["devices", "configure", "managementInterface"], + "operation": "getDeviceManagementInterface", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/managementInterface' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/managementInterface" return self._session.get(metadata, resource) - - def updateDeviceManagementInterface(self, serial: str, **kwargs): """ @@ -619,18 +1111,25 @@ def updateDeviceManagementInterface(self, serial: str, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['devices', 'configure', 'managementInterface'], - 'operation': 'updateDeviceManagementInterface' + "tags": ["devices", "configure", "managementInterface"], + "operation": "updateDeviceManagementInterface", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/managementInterface' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/managementInterface" - body_params = ['wan1', 'wan2', ] + body_params = [ + "wan1", + "wan2", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateDeviceManagementInterface: ignoring unrecognized kwargs: {invalid}") + return self._session.put(metadata, resource, payload) def rebootDevice(self, serial: str): """ @@ -641,11 +1140,10 @@ def rebootDevice(self, serial: str): """ metadata = { - 'tags': ['devices', 'liveTools'], - 'operation': 'rebootDevice' + "tags": ["devices", "liveTools"], + "operation": "rebootDevice", } - serial = urllib.parse.quote(str(serial), safe='') - resource = f'/devices/{serial}/reboot' + serial = urllib.parse.quote(str(serial), safe="") + resource = f"/devices/{serial}/reboot" return self._session.post(metadata, resource) - diff --git a/meraki/aio/api/insight.py b/meraki/aio/api/insight.py index 025fd834..98345d24 100644 --- a/meraki/aio/api/insight.py +++ b/meraki/aio/api/insight.py @@ -5,8 +5,6 @@ class AsyncInsight: def __init__(self, session): super().__init__() self._session = session - - def getNetworkInsightApplicationHealthByTime(self, networkId: str, applicationId: str, **kwargs): """ @@ -24,19 +22,30 @@ def getNetworkInsightApplicationHealthByTime(self, networkId: str, applicationId kwargs.update(locals()) metadata = { - 'tags': ['insight', 'monitor', 'applications', 'healthByTime'], - 'operation': 'getNetworkInsightApplicationHealthByTime' + "tags": ["insight", "monitor", "applications", "healthByTime"], + "operation": "getNetworkInsightApplicationHealthByTime", } - networkId = urllib.parse.quote(str(networkId), safe='') - applicationId = urllib.parse.quote(str(applicationId), safe='') - resource = f'/networks/{networkId}/insight/applications/{applicationId}/healthByTime' - - query_params = ['t0', 't1', 'timespan', 'resolution', ] + networkId = urllib.parse.quote(str(networkId), safe="") + applicationId = urllib.parse.quote(str(applicationId), safe="") + resource = f"/networks/{networkId}/insight/applications/{applicationId}/healthByTime" + + query_params = [ + "t0", + "t1", + "timespan", + "resolution", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - return self._session.get(metadata, resource, params) - + if self._session._validate_kwargs: + all_params = query_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"getNetworkInsightApplicationHealthByTime: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) def getOrganizationInsightApplications(self, organizationId: str): """ @@ -47,15 +56,13 @@ def getOrganizationInsightApplications(self, organizationId: str): """ metadata = { - 'tags': ['insight', 'configure', 'applications'], - 'operation': 'getOrganizationInsightApplications' + "tags": ["insight", "configure", "applications"], + "operation": "getOrganizationInsightApplications", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/insight/applications' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/insight/applications" return self._session.get(metadata, resource) - - def getOrganizationInsightMonitoredMediaServers(self, organizationId: str): """ @@ -66,15 +73,13 @@ def getOrganizationInsightMonitoredMediaServers(self, organizationId: str): """ metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'getOrganizationInsightMonitoredMediaServers' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "getOrganizationInsightMonitoredMediaServers", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers' + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers" return self._session.get(metadata, resource) - - def createOrganizationInsightMonitoredMediaServer(self, organizationId: str, name: str, address: str, **kwargs): """ @@ -90,18 +95,28 @@ def createOrganizationInsightMonitoredMediaServer(self, organizationId: str, nam kwargs.update(locals()) metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'createOrganizationInsightMonitoredMediaServer' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "createOrganizationInsightMonitoredMediaServer", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers' - - body_params = ['name', 'address', 'bestEffortMonitoringEnabled', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers" + + body_params = [ + "name", + "address", + "bestEffortMonitoringEnabled", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.post(metadata, resource, payload) - + 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"createOrganizationInsightMonitoredMediaServer: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.post(metadata, resource, payload) def getOrganizationInsightMonitoredMediaServer(self, organizationId: str, monitoredMediaServerId: str): """ @@ -113,16 +128,14 @@ def getOrganizationInsightMonitoredMediaServer(self, organizationId: str, monito """ metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'getOrganizationInsightMonitoredMediaServer' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "getOrganizationInsightMonitoredMediaServer", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe='') - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}" return self._session.get(metadata, resource) - - def updateOrganizationInsightMonitoredMediaServer(self, organizationId: str, monitoredMediaServerId: str, **kwargs): """ @@ -139,19 +152,29 @@ def updateOrganizationInsightMonitoredMediaServer(self, organizationId: str, mon kwargs.update(locals()) metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'updateOrganizationInsightMonitoredMediaServer' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "updateOrganizationInsightMonitoredMediaServer", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe='') - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}' - - body_params = ['name', 'address', 'bestEffortMonitoringEnabled', ] + organizationId = urllib.parse.quote(str(organizationId), safe="") + monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}" + + body_params = [ + "name", + "address", + "bestEffortMonitoringEnabled", + ] payload = {k.strip(): v for k, v in kwargs.items() if k.strip() in body_params} - return self._session.put(metadata, resource, payload) - + 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"updateOrganizationInsightMonitoredMediaServer: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.put(metadata, resource, payload) def deleteOrganizationInsightMonitoredMediaServer(self, organizationId: str, monitoredMediaServerId: str): """ @@ -163,12 +186,11 @@ def deleteOrganizationInsightMonitoredMediaServer(self, organizationId: str, mon """ metadata = { - 'tags': ['insight', 'configure', 'monitoredMediaServers'], - 'operation': 'deleteOrganizationInsightMonitoredMediaServer' + "tags": ["insight", "configure", "monitoredMediaServers"], + "operation": "deleteOrganizationInsightMonitoredMediaServer", } - organizationId = urllib.parse.quote(str(organizationId), safe='') - monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe='') - resource = f'/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}' + organizationId = urllib.parse.quote(str(organizationId), safe="") + monitoredMediaServerId = urllib.parse.quote(str(monitoredMediaServerId), safe="") + resource = f"/organizations/{organizationId}/insight/monitoredMediaServers/{monitoredMediaServerId}" return self._session.delete(metadata, resource) - diff --git a/meraki/aio/api/licensing.py b/meraki/aio/api/licensing.py index b5adc49c..553f47ea 100644 --- a/meraki/aio/api/licensing.py +++ b/meraki/aio/api/licensing.py @@ -5,8 +5,6 @@ class AsyncLicensing: def __init__(self, session): super().__init__() self._session = session - - def getAdministeredLicensingSubscriptionEntitlements(self, **kwargs): """ @@ -19,25 +17,37 @@ def getAdministeredLicensingSubscriptionEntitlements(self, **kwargs): kwargs.update(locals()) metadata = { - 'tags': ['licensing', 'configure', 'subscription', 'entitlements'], - 'operation': 'getAdministeredLicensingSubscriptionEntitlements' + "tags": ["licensing", "configure", "subscription", "entitlements"], + "operation": "getAdministeredLicensingSubscriptionEntitlements", } - resource = f'/administered/licensing/subscription/entitlements' + resource = "/administered/licensing/subscription/entitlements" - query_params = ['skus', ] + query_params = [ + "skus", + ] params = {k.strip(): v for k, v in kwargs.items() if k.strip() in query_params} - array_params = ['skus', ] + array_params = [ + "skus", + ] for k, v in kwargs.items(): if k.strip() in array_params: - params[f'{k.strip()}[]'] = kwargs[f'{k}'] + params[f"{k.strip()}[]"] = kwargs[f"{k}"] params.pop(k.strip()) - return self._session.get(metadata, resource, params) - + 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"getAdministeredLicensingSubscriptionEntitlements: ignoring unrecognized kwargs: {invalid}" + ) + return self._session.get(metadata, resource, params) - def getAdministeredLicensingSubscriptionSubscriptions(self, organizationIds: list, total_pages=1, direction='next', **kwargs): + def getAdministeredLicensingSubscriptionSubscriptions( + self, organizationIds: list, total_pages=1, direction="next", **kwargs + ): """ **List available subscriptions** https://developer.cisco.com/meraki/api-v1/#!get-administered-licensing-subscription-subscriptions @@ -49,34 +59,58 @@ def getAdministeredLicensingSubscriptionSubscriptions(self, organizationIds: lis - 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. - subscriptionIds (array): List of subscription ids to fetch - - startDate (string): Filter subscriptions by start date, ISO 8601 format. To filter with a range of dates, use 'startDate[