diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..e46640bc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + cooldown: + default-days: 10 + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" + cooldown: + default-days: 10 diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index ae14d3a2..94f285ce 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -26,8 +26,6 @@ jobs: exclude: - os: ubuntu-22.04 mode: onedir - - os: windows-2022 - mode: onedir runs-on: ${{ matrix.os }} @@ -38,7 +36,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-22.04' - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -50,7 +48,7 @@ jobs: uploads.github.com - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -60,22 +58,23 @@ jobs: LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) git checkout $LATEST_TAG echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - - - name: Set up Python 3.12 - uses: actions/setup-python@v4 + + - name: Set up Python 3.13 + id: setup-python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.12' + python-version: '3.13' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local - key: poetry-${{ matrix.os }}-2 # increment to reset cache + key: poetry-${{ matrix.os }}-${{ steps.setup-python.outputs.python-version }}-2 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 @@ -125,7 +124,37 @@ jobs: echo "PATH_TO_CYCODE_CLI_EXECUTABLE=dist/cycode-cli/cycode-cli" >> $GITHUB_ENV - name: Test executable - run: time $PATH_TO_CYCODE_CLI_EXECUTABLE version + run: time $PATH_TO_CYCODE_CLI_EXECUTABLE status + + - name: Codesign onedir binaries + if: runner.os == 'macOS' && matrix.mode == 'onedir' + env: + APPLE_CERT_NAME: ${{ secrets.APPLE_CERT_NAME }} + run: | + # The standalone _internal/Python fails codesign --verify --strict because it was + # extracted from Python.framework without Info.plist context. + # Fix: remove the bare copy and replace with the framework version's binary, + # then delete the framework directory (it's redundant). + if [ -d dist/cycode-cli/_internal/Python.framework ]; then + FRAMEWORK_PYTHON=$(find dist/cycode-cli/_internal/Python.framework/Versions -name "Python" -type f | head -1) + if [ -n "$FRAMEWORK_PYTHON" ]; then + echo "Replacing _internal/Python with framework binary" + rm dist/cycode-cli/_internal/Python + cp "$FRAMEWORK_PYTHON" dist/cycode-cli/_internal/Python + fi + rm -rf dist/cycode-cli/_internal/Python.framework + fi + + # Sign all Mach-O binaries (excluding the main executable) + while IFS= read -r file; do + if file -b "$file" | grep -q "Mach-O"; then + echo "Signing: $file" + codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime "$file" + fi + done < <(find dist/cycode-cli -type f ! -name "cycode-cli") + + # Re-sign the main executable with entitlements (must be last) + codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime --entitlements entitlements.plist dist/cycode-cli/cycode-cli - name: Notarize macOS executable if: runner.os == 'macOS' @@ -137,23 +166,58 @@ jobs: # create keychain profile xcrun notarytool store-credentials "notarytool-profile" --apple-id "$APPLE_NOTARIZATION_EMAIL" --team-id "$APPLE_NOTARIZATION_TEAM_ID" --password "$APPLE_NOTARIZATION_PWD" - # create zip file (notarization does not support binaries) + # create zip file (notarization does not support bare binaries) ditto -c -k --keepParent dist/cycode-cli notarization.zip # notarize app (this will take a while) - xcrun notarytool submit notarization.zip --keychain-profile "notarytool-profile" --wait + NOTARIZE_OUTPUT=$(xcrun notarytool submit notarization.zip --keychain-profile "notarytool-profile" --wait 2>&1) || true + echo "$NOTARIZE_OUTPUT" + + # extract submission ID for log retrieval + SUBMISSION_ID=$(echo "$NOTARIZE_OUTPUT" | grep " id:" | head -1 | awk '{print $2}') + + # check notarization status explicitly + if echo "$NOTARIZE_OUTPUT" | grep -q "status: Accepted"; then + echo "Notarization succeeded!" + else + echo "Notarization failed! Fetching log for details..." + if [ -n "$SUBMISSION_ID" ]; then + xcrun notarytool log "$SUBMISSION_ID" --keychain-profile "notarytool-profile" || true + fi + exit 1 + fi # we can't staple the app because it's executable - - name: Test macOS signed executable + - name: Verify macOS code signatures if: runner.os == 'macOS' run: | - file -b $PATH_TO_CYCODE_CLI_EXECUTABLE - time $PATH_TO_CYCODE_CLI_EXECUTABLE version + FAILED=false + while IFS= read -r file; do + if file -b "$file" | grep -q "Mach-O"; then + if ! codesign --verify "$file" 2>&1; then + echo "INVALID: $file" + codesign -dv "$file" 2>&1 || true + FAILED=true + else + echo "OK: $file" + fi + fi + done < <(find dist/cycode-cli -type f) + + if [ "$FAILED" = true ]; then + echo "Found binaries with invalid signatures!" + exit 1 + fi - # verify signature codesign -dv --verbose=4 $PATH_TO_CYCODE_CLI_EXECUTABLE + - name: Test macOS signed executable + if: runner.os == 'macOS' + run: | + file -b $PATH_TO_CYCODE_CLI_EXECUTABLE + time $PATH_TO_CYCODE_CLI_EXECUTABLE status + - name: Import cert for Windows and setup envs if: runner.os == 'Windows' env: @@ -183,31 +247,100 @@ jobs: C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user smctl windows certsync --keypair-alias=%SM_KEYPAIR_ALIAS% - :: sign executable - signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ".\dist\cycode-cli.exe" + :: sign executable (in onedir mode the exe lives inside the collected directory) + set "EXE_PATH=.\dist\cycode-cli.exe" + if "${{ matrix.mode }}"=="onedir" set "EXE_PATH=.\dist\cycode-cli\cycode-cli.exe" + signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 "%EXE_PATH%" + + - name: Sign unsigned onedir binaries (Windows) + if: runner.os == 'Windows' && matrix.mode == 'onedir' + shell: powershell + env: + SM_HOST: ${{ secrets.SM_HOST }} + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} + run: | + # Vendor binaries (PSF-signed stdlib .pyds, python3xx.dll, Microsoft VC runtime) already + # carry valid signatures; re-signing would replace them with ours. Sign only the unsigned + # ones (PyInstaller-generated and third-party wheel binaries). + $files = Get-ChildItem -Path dist\cycode-cli\_internal -Recurse -Include *.dll,*.pyd,*.exe | + Where-Object { (Get-AuthenticodeSignature $_.FullName).Status -eq 'NotSigned' } + if (-not $files) { + Write-Host 'No unsigned binaries found' + exit 0 + } + Write-Host "Signing $($files.Count) unsigned binaries:" + $files.FullName | Write-Host + signtool.exe sign /sha1 $env:SM_CODE_SIGNING_CERT_SHA1_HASH /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 @($files.FullName) + exit $LASTEXITCODE - name: Test Windows signed executable if: runner.os == 'Windows' shell: cmd run: | + set "EXE_PATH=.\dist\cycode-cli.exe" + if "${{ matrix.mode }}"=="onedir" set "EXE_PATH=.\dist\cycode-cli\cycode-cli.exe" + :: call executable and expect correct output - .\dist\cycode-cli.exe version + "%EXE_PATH%" status :: verify signature - signtool.exe verify /v /pa ".\dist\cycode-cli.exe" + signtool.exe verify /v /pa "%EXE_PATH%" - name: Prepare files for artifact and release (rename and calculate sha256) run: echo "ARTIFACT_NAME=$(./process_executable_file.py dist/cycode-cli)" >> $GITHUB_ENV - name: Upload files as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ env.ARTIFACT_NAME }} path: dist + - name: Verify macOS artifact end-to-end + if: runner.os == 'macOS' && matrix.mode == 'onedir' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ env.ARTIFACT_NAME }} + path: /tmp/artifact-verify + + - name: Verify macOS artifact signatures and run with quarantine + if: runner.os == 'macOS' && matrix.mode == 'onedir' + run: | + # extract the onedir zip exactly as an end user would + ARCHIVE=$(find /tmp/artifact-verify -name "*.zip" | head -1) + echo "Verifying archive: $ARCHIVE" + unzip "$ARCHIVE" -d /tmp/artifact-extracted + + # verify all Mach-O code signatures + FAILED=false + while IFS= read -r file; do + if file -b "$file" | grep -q "Mach-O"; then + if ! codesign --verify "$file" 2>&1; then + echo "INVALID: $file" + codesign -dv "$file" 2>&1 || true + FAILED=true + else + echo "OK: $file" + fi + fi + done < <(find /tmp/artifact-extracted -type f) + + if [ "$FAILED" = true ]; then + echo "Artifact contains binaries with invalid signatures!" + exit 1 + fi + + # simulate download quarantine and test execution + # this is the definitive test — it triggers the same dlopen checks end users experience + find /tmp/artifact-extracted -type f -exec xattr -w com.apple.quarantine "0081;$(printf '%x' $(date +%s));CI;$(uuidgen)" {} \; + EXECUTABLE=$(find /tmp/artifact-extracted -name "cycode-cli" -type f | head -1) + echo "Testing quarantined executable: $EXECUTABLE" + time "$EXECUTABLE" status + - name: Upload files to release if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} - uses: svenstaro/upload-release-action@v2 + uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2 with: file: dist/* tag: ${{ env.LATEST_TAG }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ae668a3a..16782d7b 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -6,13 +6,16 @@ on: push: tags: [ 'v*.*.*' ] +permissions: + contents: read + jobs: docker: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -28,20 +31,20 @@ jobs: git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached_poetry - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 @@ -58,14 +61,14 @@ jobs: echo "CLI_VERSION=$(poetry version --short)" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/login-action@v3 + uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_PASSWORD }} @@ -73,7 +76,7 @@ jobs: - name: Build and push id: docker_build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@v6 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -83,7 +86,7 @@ jobs: - name: Verify build id: docker_verify_build if: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@v6 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 8847499a..35649497 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -28,25 +28,25 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 @@ -74,4 +74,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14ddbe77..cc5cbe21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -27,25 +27,25 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 @@ -73,4 +73,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index eb32b58e..41cade54 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -2,12 +2,15 @@ name: Ruff (linter and code formatter) on: [ pull_request, push ] +permissions: + contents: read + jobs: ruff: runs-on: ubuntu-latest steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -18,23 +21,23 @@ jobs: pypi.org - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.9 - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e2ebf709..c97318f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -23,23 +23,23 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index b8d1fc2c..7e4de5ef 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-latest' - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -36,25 +36,25 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-3 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 @@ -66,8 +66,7 @@ jobs: - name: Run executable test # we care about the one Python version that will be used to build the executable - # TODO(MarshalX): upgrade to Python 3.13 - if: matrix.python-version == '3.12' + if: matrix.python-version == '3.13' run: | poetry run pyinstaller pyinstaller.spec ./dist/cycode-cli version diff --git a/CODEOWNERS b/CODEOWNERS index f05ffdb9..9a3abb16 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @elsapet @gotbadger @mateusz-sterczewski +* @avishaiamiel @omer-roth diff --git a/Dockerfile b/Dockerfile index 40d6fad3..574d38ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM base AS builder ENV POETRY_VERSION=2.2.1 # deps are required to build cffi -RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.7-r0 musl-dev=1.2.5-r9 && \ +RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.7-r0 musl-dev=1.2.5-r11 && \ pip install --no-cache-dir "poetry==$POETRY_VERSION" "poetry-dynamic-versioning[plugin]" && \ apk del .build-deps gcc libffi-dev musl-dev diff --git a/README.md b/README.md index 991ba56c..7ea39e36 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Cycode CLI User Guide +[![MCP Toplist](https://mcptoplist.com/badge/glama%2Fcycodehq%2Fcycode-cli.svg)](https://mcptoplist.com/server/glama%2Fcycodehq%2Fcycode-cli) + The Cycode Command Line Interface (CLI) is an application you can install locally to scan your repositories for secrets, infrastructure as code misconfigurations, software composition analysis vulnerabilities, and static application security testing issues. This guide walks you through both installation and usage. @@ -21,7 +23,14 @@ This guide walks you through both installation and usage. 2. [Available Options](#available-options) 3. [MCP Tools](#mcp-tools) 4. [Usage Examples](#usage-examples) -5. [Scan Command](#scan-command) + 5. [Advanced Configuration](#advanced-configuration) +5. [Platform Command](#platform-command-beta) + 1. [Discovering Commands](#discovering-commands) + 2. [Examples](#platform-examples) + 3. [Notes & Limitations](#platform-notes--limitations) +6. [AI Guardrails](#ai-guardrails-beta) + 1. [Data Collected by AI Guardrails](#data-collected-by-ai-guardrails) +7. [Scan Command](#scan-command) 1. [Running a Scan](#running-a-scan) 1. [Options](#options) 1. [Severity Threshold](#severity-option) @@ -30,6 +39,7 @@ This guide walks you through both installation and usage. 4. [Package Vulnerabilities](#package-vulnerabilities-option) 5. [License Compliance](#license-compliance-option) 6. [Lock Restore](#lock-restore-option) + 7. [Stop on Error](#stop-on-error-option) 2. [Repository Scan](#repository-scan) 1. [Branch Option](#branch-option) 3. [Path Scan](#path-scan) @@ -62,7 +72,7 @@ This guide walks you through both installation and usage. # Prerequisites -- The Cycode CLI application requires Python version 3.9 or later. +- The Cycode CLI application requires Python version 3.9 or later. The MCP command is available only for Python 3.10 and above. If you're using an earlier Python version, this command will not be available. - Use the [`cycode auth` command](#using-the-auth-command) to authenticate to Cycode with the CLI - Alternatively, you can get a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/docs/en/service-accounts) and [Personal Access Token](https://docs.cycode.com/v1/docs/managing-personal-access-tokens) pages, which contain details on getting these values. @@ -384,12 +394,22 @@ The MCP server provides the following tools that AI systems can use: | Tool Name | Description | |----------------------|---------------------------------------------------------------------------------------------| -| `cycode_secret_scan` | Scan files for hardcoded secrets | -| `cycode_sca_scan` | Scan files for Software Composition Analysis (SCA) - vulnerabilities and license issues | -| `cycode_iac_scan` | Scan files for Infrastructure as Code (IaC) misconfigurations | -| `cycode_sast_scan` | Scan files for Static Application Security Testing (SAST) - code quality and security flaws | +| `cycode_secret_scan` | Scan for hardcoded secrets | +| `cycode_sca_scan` | Scan for Software Composition Analysis (SCA) - vulnerabilities and license issues | +| `cycode_iac_scan` | Scan for Infrastructure as Code (IaC) misconfigurations | +| `cycode_sast_scan` | Scan for Static Application Security Testing (SAST) - code quality and security flaws | | `cycode_status` | Get Cycode CLI version, authentication status, and configuration information | +Each scan tool accepts two mutually exclusive input modes: + +- **`paths`** *(preferred)* — one or more file or directory paths that exist on disk. Directories are scanned recursively. The Cycode engine handles file discovery and filtering, just as `cycode scan -t path ./src` does from the CLI. +- **`files`** *(fallback)* — a dictionary mapping file paths to their full content as strings. Use this only when the files are not available on disk (e.g. in-memory edits not yet saved). + +> [!TIP] +> Use `paths` whenever possible. Passing large files (like `package-lock.json`) as inline content can exceed token limits and slow down the AI client. With `paths`, the Cycode engine reads files directly from disk. + +All scan tools return a JSON object that includes a `"summary"` field with a human-readable violation count (e.g. `"Cycode found 3 violations: 1 CRITICAL, 2 HIGH."`) in addition to the full `"detections"` array. + ### Usage Examples #### Basic Command Examples @@ -544,9 +564,61 @@ cycode mcp -t streamable-http -H 127.0.0.2 -p 9000 & } ``` +### Advanced Configuration +##### Custom Certificates and Timeouts (Proxy Environments) + +If your organization uses a corporate proxy or a custom CA bundle for HTTPS inspection, you need to tell Cycode CLI (and the underlying Python TLS stack) where to find the trusted certificate bundle. You can also increase the MCP tool call timeout if scans are being cut short. + +| Environment Variable | Description | +|----------------------|-------------| +| `REQUESTS_CA_BUNDLE` | Path to a custom CA bundle file (`.pem` or `.crt`). Used by the `requests` library for all HTTPS calls made by Cycode CLI. | +| `SSL_CERT_FILE` | Path to a custom CA bundle file. Used by Python's low-level `ssl` module. Set this alongside `REQUESTS_CA_BUNDLE` for full coverage. | +| `MCP_TOOL_TIMEOUT` | Timeout (in seconds) that MCP clients such as Claude and GitHub Copilot wait for a tool call to complete. Increase this if long-running scans are being cut off before they finish. | + +> [!TIP] +> Set both `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` to the same CA bundle path. `REQUESTS_CA_BUNDLE` covers the HTTP layer; `SSL_CERT_FILE` covers the lower-level TLS layer. Using only one may still cause certificate errors in some environments. + +Example `mcp.json` configuration with custom certificates and a longer timeout: + +```json +{ + "mcpServers": { + "cycode": { + "command": "cycode", + "args": ["mcp"], + "env": { + "REQUESTS_CA_BUNDLE": "/path/to/your/corporate-ca-bundle.pem", + "SSL_CERT_FILE": "/path/to/your/corporate-ca-bundle.pem", + "MCP_TOOL_TIMEOUT": "1800" + } + } + } +} +``` + > [!NOTE] > The MCP server requires proper Cycode CLI authentication to function. Make sure you have authenticated using `cycode auth` or configured your credentials before starting the MCP server. +### Pre-authorizing Tools for Subagents (Claude Code) + +When Claude Code delegates work to background subagents (e.g. to run scans in parallel), those subagents cannot display interactive permission prompts. If the Cycode tools have not been pre-approved, scans will fail silently in subagent contexts. + +To pre-authorize the Cycode MCP tools so they work in all contexts including subagents, add them to the `allowedTools` list in your Claude Code settings (`~/.claude/settings.json`): + +```json +{ + "allowedTools": [ + "mcp__cycode__cycode_secret_scan", + "mcp__cycode__cycode_sca_scan", + "mcp__cycode__cycode_iac_scan", + "mcp__cycode__cycode_sast_scan", + "mcp__cycode__cycode_status" + ] +} +``` + +Once added, Claude Code will not prompt for approval when these tools are called, and they will work correctly inside subagents. + ### Troubleshooting MCP If you encounter issues with the MCP server, you can enable debug logging to get more detailed information about what's happening. There are two ways to enable debug logging: @@ -573,6 +645,92 @@ This information can be helpful when: - Identifying authentication problems - Debugging transport-specific issues +### MCP Configuration + + +# Platform Command \[BETA\] + +> [!WARNING] +> The `platform` command is in **beta**. Commands, arguments, and output formats are generated dynamically from the Cycode API spec and may change between releases without notice. Do not rely on them in production automation yet. + +The `cycode platform` command exposes the Cycode platform's read APIs as CLI commands. It groups endpoints by resource (e.g. `projects`, `violations`, `workflows`) and turns each endpoint's parameters into typed CLI arguments and `--option` flags. + +```bash +cycode platform projects list --page-size 50 +cycode platform violations count +cycode platform workflows view +``` + +The OpenAPI spec is fetched from the Cycode API on first use and cached at `~/.cycode/openapi-spec.json` for 24 hours. Unrelated commands (`cycode scan`, `cycode status`, etc.) do not trigger a fetch. + +> [!NOTE] +> You must be authenticated (`cycode auth` or `CYCODE_CLIENT_ID` / `CYCODE_CLIENT_SECRET` environment variables) for `cycode platform` to discover and run commands. Other Cycode CLI commands work without authentication. + +## Discovering Commands + +Because commands are generated from the spec, the source of truth for what's available is `--help`: + +```bash +cycode platform --help # list all resource groups +cycode platform projects --help # list actions on a resource +cycode platform projects list --help # list options/arguments for an action +``` + +## Platform Examples + +```bash +# List projects with pagination +cycode platform projects list --page-size 25 + +# View a single project by ID +cycode platform projects view + +# Count violations across the tenant +cycode platform violations count + +# Filter using query parameters (see `--help` for what each endpoint supports) +cycode platform violations list --severity CRITICAL +``` + +All output is JSON by default — pipe it through `jq` for ad-hoc filtering: + +```bash +cycode platform projects list --page-size 100 | jq '.items[].name' +``` + +## Platform Notes & Limitations + +- **Read-only today.** Only `GET` endpoints are exposed in this beta. +- **Spec-driven.** Adding a new endpoint to the API surfaces it automatically the next time the cache is refreshed. +- **No bundled spec.** The first `cycode platform` invocation after install (or after the 24h cache expires) performs a network fetch. On slow connections this first call may take a few seconds; subsequent calls are near-instant until the cache expires. +- **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=`. + + +# AI Guardrails \[BETA\] + +AI Guardrails installs hooks into supported AI coding agents (Claude Code, Cursor, Copilot, Codex) so that +prompts, files the agent reads, and MCP tool arguments are scanned for secrets before they reach the model. + +## Data Collected by AI Guardrails + +Scanning happens server-side, so the scanned content leaves the machine: the prompt text, the contents of +files the agent reads, and MCP tool arguments are sent to your Cycode tenant to be checked for secrets. + +Each event is also reported with context about the developer and the machine, so a finding can be attributed +to the device and user it came from. Some of this is personal data: + +- **Device identifiers** — the machine's hostname and hardware serial number. +- **User identifiers** — the email address of the user signed in to the AI coding agent, and the local + operating-system username. +- **Environment details** — operating system and version, the AI agent, its version and the model in use, + the contents of the agent's MCP configuration files, and its enabled plugins. + +The hardware serial number is cached in a local temporary file, readable only by the user who ran the +command, so repeated hook invocations don't re-query the hardware. + +If collecting this data is not acceptable in your environment, do not install the guardrails hooks +(`cycode ai-guardrails uninstall` removes hooks that are already installed). + # Scan Command @@ -590,6 +748,7 @@ The Cycode CLI application offers several types of scans so that you can choose | `--monitor` | When specified, the scan results will be recorded in Cycode. | | `--cycode-report` | Display a link to the scan report in the Cycode platform in the console output. | | `--no-restore` | When specified, Cycode will not run the restore command. This will scan direct dependencies ONLY! | +| `--stop-on-error` | Abort the scan if any file collection or dependency restore failure occurs, instead of skipping the failed file and continuing. | | `--gradle-all-sub-projects` | Run gradle restore command for all sub projects. This should be run from | | `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when scanning for dependencies | | `--help` | Show options for given command. | @@ -668,15 +827,54 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co > [!NOTE] > This option is only available to SCA scans. -We use the sbt-dependency-lock plugin to restore the lock file for SBT projects. -To disable lock restore in use `--no-restore` option. +When running an SCA scan, Cycode CLI automatically attempts to restore (generate) a dependency lockfile for each supported manifest file it finds. This allows scanning transitive dependencies, not just the ones listed directly in the manifest. To skip this step and scan only direct dependencies, use the `--no-restore` flag. + +The following ecosystems support automatic lockfile restoration: + +| Ecosystem | Manifest file | Lockfile generated | Tool invoked (when lockfile is absent) | +|---|---|---|---| +| npm | `package.json` | `package-lock.json` | `npm install --package-lock-only --ignore-scripts --no-audit` | +| Yarn | `package.json` | `yarn.lock` | `yarn install --ignore-scripts` | +| pnpm | `package.json` | `pnpm-lock.yaml` | `pnpm install --ignore-scripts` | +| Deno | `deno.json` / `deno.jsonc` | `deno.lock` | *(read existing lockfile only)* | +| Go | `go.mod` | `go.mod.graph` | `go list -m -json all` + `go mod graph` | +| Maven | `pom.xml` | `bcde.mvndeps` | `mvn dependency:tree` | +| Gradle | `build.gradle` / `build.gradle.kts` | `gradle-dependencies-generated.txt` | `gradle dependencies -q --console plain` | +| SBT | `build.sbt` | `build.sbt.lock` | `sbt dependencyLockWrite` | +| NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` | +| Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` | +| Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` | +| pip | `pyproject.toml` / `requirements.txt` | `pylock.toml` | `pip lock .` / `pip lock -r requirements.txt -o pylock.toml` | +| Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` | +| PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` | + +If a lockfile already exists alongside the manifest, Cycode reads it directly without running any install command. + +**SBT prerequisite:** The `sbt-dependency-lock` plugin must be installed. Add the following line to `project/plugins.sbt`: + +```text +addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") +``` + +#### Stop on Error Option + +By default, Cycode continues scanning even if a file cannot be read (e.g. due to a permission error) or a dependency lockfile cannot be generated during an SCA scan. The failed item is skipped with a warning and the scan proceeds with the remaining files. + +Use `--stop-on-error` to change this behaviour: the scan aborts immediately on the first such failure and reports the error. + +```bash +cycode scan -t sca --stop-on-error path ~/home/git/codebase +``` + +This is useful in CI pipelines where a silent failure would produce an incomplete scan result. When `--stop-on-error` is triggered you can either fix the underlying issue or, for SCA restore failures specifically, add `--no-restore` to skip lockfile generation and scan direct dependencies only. -Prerequisites: -* `sbt-dependency-lock` plugin: Install the plugin by adding the following line to `project/plugins.sbt`: +When `--stop-on-error` is used, the CLI distinguishes between scan errors and policy violations via exit codes: - ```text - addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") - ``` +| Exit code | Meaning | +|-----------|---------| +| `0` | Scan completed with no violations | +| `1` | Scan completed and violations were found | +| `2` | Scan aborted due to an error (only when `--stop-on-error` is set) | ### Repository Scan @@ -1307,6 +1505,14 @@ To create an SBOM report for a path:\ For example:\ `cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies path /path/to/local/project` +The `path` subcommand supports the following additional options: + +| Option | Description | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `--no-restore` | Skip lockfile restoration and scan direct dependencies only. See [Lock Restore Option](#lock-restore-option) for details. | +| `--gradle-all-sub-projects` | Run the Gradle restore command for all sub-projects (use from the root of a multi-project Gradle build). | +| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree. | + # Import Command ## Importing SBOM diff --git a/cycode/__init__.py b/cycode/__init__.py index 4ce71ef1..63ae25e0 100644 --- a/cycode/__init__.py +++ b/cycode/__init__.py @@ -1 +1,8 @@ +import time as _time + +# Unix-epoch wall clock captured at the earliest possible moment of CLI +# startup. Sent as `scan_parameters.cli_start_time` so the server can compute +# end-to-end scan duration from the moment the user actually triggered it. +_BOOT_WALL: float = _time.time() + __version__ = '0.0.0' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 3ef0b322..82f7f41b 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -1,7 +1,9 @@ +import importlib import logging import sys from typing import Annotated, Optional +import click import typer from typer import rich_utils from typer._completion_classes import completion_init @@ -9,17 +11,12 @@ from typer.completion import install_callback, show_callback from cycode import __version__ -from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, report_import, scan, status - -if sys.version_info >= (3, 10): - from cycode.cli.apps import mcp - +from cycode.cli.apps.api import get_platform_group from cycode.cli.cli_types import OutputTypeOption from cycode.cli.consts import CLI_CONTEXT_SETTINGS from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar -from cycode.cli.utils.sentry import add_breadcrumb, init_sentry from cycode.cli.utils.version_checker import version_checker from cycode.cyclient.cycode_client_base import CycodeClientBase from cycode.cyclient.models import UserAgentOptionScheme @@ -45,19 +42,118 @@ add_completion=False, # we add it manually to control the rich help panel ) -app.add_typer(ai_remediation.app) -app.add_typer(auth.app) -app.add_typer(configure.app) -app.add_typer(ignore.app) -app.add_typer(report.app) -app.add_typer(report_import.app) -app.add_typer(scan.app) -app.add_typer(status.app) +# Top-level subcommand → module providing its Typer app. Peeking at sys.argv +# lets us import only the invoked subapp on the hot path (e.g. +# `cycode ai-guardrails scan`), skipping ~300ms of unrelated imports. +_SUBAPP_MODULES: dict[str, str] = { + 'ai-guardrails': 'cycode.cli.apps.ai_guardrails', + 'ai-remediation': 'cycode.cli.apps.ai_remediation', + 'auth': 'cycode.cli.apps.auth', + 'configure': 'cycode.cli.apps.configure', + 'ignore': 'cycode.cli.apps.ignore', + 'report': 'cycode.cli.apps.report', + 'import': 'cycode.cli.apps.report_import', + 'scan': 'cycode.cli.apps.scan', + 'status': 'cycode.cli.apps.status', +} if sys.version_info >= (3, 10): - app.add_typer(mcp.app) + _SUBAPP_MODULES['mcp'] = 'cycode.cli.apps.mcp' + +# Aliases: alternate spellings that resolve to a primary subcommand key. +_SUBAPP_ALIASES: dict[str, str] = { + 'ai_remediation': 'ai-remediation', # backward-compat underscore form + 'version': 'status', +} + +# Root-level options that consume a following value; argv-peek must skip past +# both the option and its value when scanning for the first positional arg. +_ROOT_OPTS_WITH_VALUE = frozenset( + { + '--output', + '-o', + '--user-agent', + '--client-secret', + '--client-id', + '--id-token', + '--show-completion', + } +) + + +def _detect_invocation() -> tuple[Optional[str], Optional[str]]: + """Return (top-level-subapp, second-level-subcommand) parsed from sys.argv. + + Both values may be None: when no positional arg matches a known subapp, + or when the user only provided a top-level subcommand. + """ + positionals = [] + args = sys.argv[1:] + i = 0 + while i < len(args): + arg = args[i] + if arg in _ROOT_OPTS_WITH_VALUE: + i += 2 + elif arg.startswith('-'): + # Any flag form: short, long, --key=value, or '--' marker. Skip the token only. + i += 1 + else: + positionals.append(arg) + if len(positionals) >= 2: + break + i += 1 + subapp = positionals[0] if positionals else None + subapp = _SUBAPP_ALIASES.get(subapp, subapp) + if subapp not in _SUBAPP_MODULES: + return None, None + subcommand = positionals[1] if len(positionals) >= 2 else None + return subapp, subcommand + + +# Computed once at import; reused by lazy registration and the version-checker skip. +_INVOKED_SUBAPP, _INVOKED_SUBCOMMAND = _detect_invocation() + + +def _register_subapps(only: Optional[str]) -> None: + if only is not None: + app.add_typer(importlib.import_module(_SUBAPP_MODULES[only]).app) + return + # Cold path (--help, completion, unknown subcommand): load all modules so + # root help lists everything. Deduplicate since aliases share modules. + for module_path in dict.fromkeys(_SUBAPP_MODULES.values()): + app.add_typer(importlib.import_module(module_path).app) + + +_register_subapps(_INVOKED_SUBAPP) + +# Register the `platform` command group (dynamically built from the OpenAPI spec). +# The group itself is constructed cheaply at import time; the spec is only fetched +# when the user actually invokes `cycode platform ...`. Unrelated commands like +# `cycode scan` and `cycode status` never trigger a spec fetch. +# +# Typer doesn't support adding native Click groups directly, so we monkey-patch +# typer.main.get_group to inject our `platform` group into the resolved Click group. +# The `app_typer is app` guard ensures we only modify our own app. +_platform_group = get_platform_group() +_original_get_group = typer.main.get_group + + +def _get_group_with_platform(app_typer: typer.Typer) -> click.Group: + group = _original_get_group(app_typer) + if app_typer is app and _platform_group.name not in group.commands: + group.add_command(_platform_group, _platform_group.name) + return group + + +typer.main.get_group = _get_group_with_platform def check_latest_version_on_close(ctx: typer.Context) -> None: + # Skip on `cycode ai-guardrails scan` — it emits JSON to stdout, so an + # upgrade notice would corrupt the response. Human-driven sibling commands + # (install, uninstall, status, session-start) still get the notice. + if (_INVOKED_SUBAPP, _INVOKED_SUBCOMMAND) == ('ai-guardrails', 'scan'): + return + output = ctx.obj.get('output') # don't print anything if the output is JSON if output == OutputTypeOption.JSON: @@ -142,9 +238,6 @@ def app_callback( ] = None, ) -> None: """[bold cyan]Cycode CLI - Command Line Interface for Cycode.[/]""" - init_sentry() - add_breadcrumb('cycode') - ctx.ensure_object(dict) configuration_manager = ConfigurationManager() @@ -169,6 +262,8 @@ def app_callback( if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) + ctx.obj['plugin_app_name'] = user_agent_option.app_name + ctx.obj['plugin_app_version'] = user_agent_option.app_version if not no_update_notifier: ctx.call_on_close(lambda: check_latest_version_on_close(ctx)) diff --git a/cycode/cli/apps/activation_manager.py b/cycode/cli/apps/activation_manager.py new file mode 100644 index 00000000..8eed3caa --- /dev/null +++ b/cycode/cli/apps/activation_manager.py @@ -0,0 +1,46 @@ +from typing import TYPE_CHECKING, Optional + +from cycode import __version__ +from cycode.cli.config import configuration_manager +from cycode.cyclient.cli_activation_client import CliActivationClient +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +logger = get_logger('Activation Manager') + +_CLI_CLIENT_NAME = 'cli' + + +def _get_client_and_version(plugin_app_name: Optional[str], plugin_app_version: Optional[str]) -> tuple[str, str]: + return plugin_app_name or _CLI_CLIENT_NAME, plugin_app_version or __version__ + + +def should_report_cli_activation( + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> bool: + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) + return configuration_manager.get_last_reported_activation_version(client) != version + + +def report_cli_activation( + cycode_client: 'CycodeClientBase', + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> None: + """Report CLI/IDE activation to the backend if the (client, version) pair is new. + + Failures are swallowed — activation tracking is non-critical. + """ + try: + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) + + if configuration_manager.get_last_reported_activation_version(client) == version: + return + + CliActivationClient(cycode_client).report_activation() + configuration_manager.update_last_reported_activation_version(client, version) + except Exception: + logger.debug('Failed to report CLI activation', exc_info=True) diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py new file mode 100644 index 00000000..1443008d --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -0,0 +1,24 @@ +import typer + +from cycode.cli.apps.ai_guardrails.install_command import install_command as _install_command +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command as _scan_command +from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command as _session_start_command +from cycode.cli.apps.ai_guardrails.status_command import status_command as _status_command +from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command as _uninstall_command + +app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True) + +app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(_install_command) +app.command(hidden=True, name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')( + _uninstall_command +) +app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(_status_command) +app.command( + hidden=True, + name='scan', + short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).', +)(_scan_command) +app.command(hidden=True, name='session-start', short_help='Handle session start: auth, conversation, session context.')( + _session_start_command +) +app.command(hidden=True, name='ensure-auth', short_help='[Deprecated] Alias for session-start.')(_session_start_command) diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py new file mode 100644 index 00000000..291fabcf --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -0,0 +1,25 @@ +"""Common utilities for AI guardrails commands.""" + +import os +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console + +console = Console() + + +def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None: + """Validate scope parameter.""" + if scope not in allowed_scopes: + scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes) + console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red') + raise typer.Exit(1) + + +def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]: + """Default repo_path to cwd for 'repo' scope; leave None for 'user' scope.""" + if scope == 'repo' and repo_path is None: + return Path(os.getcwd()) + return repo_path diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py new file mode 100644 index 00000000..4c962767 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -0,0 +1,28 @@ +"""Shared constants and policy/mode enums for AI guardrails.""" + +from enum import Enum + + +class PolicyMode(str, Enum): + """Policy enforcement mode for global mode and per-feature actions.""" + + BLOCK = 'block' + WARN = 'warn' + + +class GuardrailsMode(str, Enum): + """Guardrails enforcement mode. + + Used both as the ai-guardrails install-command mode and as the per-event + effective mode reported to the server (the ai_guardrails scan parameter's + `mode` field) + """ + + REPORT = 'report' + BLOCK = 'block' + + +# Base CLI commands invoked from installed hooks. IDE classes append --ide flags +# (and any other suffix) on top of these. +CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' +CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start' diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py new file mode 100644 index 00000000..b7e55b86 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -0,0 +1,260 @@ +"""Hooks manager for AI guardrails. + +Generic install/uninstall/status logic. All IDE-specific concerns (settings +paths, hooks template shape) live on the `IDE` instance; this module is +agent-agnostic. +""" + +import copy +import json +from pathlib import Path +from typing import Optional + +import yaml + +from cycode.cli.apps.ai_guardrails.consts import PolicyMode +from cycode.cli.apps.ai_guardrails.ides.base import IDE +from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Hooks') + + +_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) + +# Command-carrying fields of a flat hook entry. Copilot entries use per-OS +# `bash`/`powershell` fields instead of `command`. +_COMMAND_FIELDS = ('command', 'bash', 'powershell') + + +def _is_cycode_command(command: str) -> bool: + return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) + + +def _has_cycode_command_field(entry: dict) -> bool: + return any(_is_cycode_command(entry.get(field, '')) for field in _COMMAND_FIELDS) + + +def is_cycode_hook_entry(entry: dict) -> bool: + """True if any hook inside ``entry`` is owned by Cycode.""" + if _has_cycode_command_field(entry): + return True + + for hook in entry.get('hooks', []): + if isinstance(hook, dict) and _is_cycode_command(hook.get('command', '')): + return True + + return False + + +def _strip_cycode_from_entry(entry: dict) -> Optional[dict]: + """Remove Cycode hooks from ``entry`` and return the remainder. + + Returns ``None`` when nothing useful remains (Cursor-flat Cycode entry, or + every nested hook was Cycode). Non-Cycode hooks co-located in the same + entry are preserved. + """ + # Cursor/Copilot format: the entry itself IS a single hook command. + if 'hooks' not in entry and any(field in entry for field in _COMMAND_FIELDS): + return None if _has_cycode_command_field(entry) else entry + + # Claude Code / Codex format: nested `hooks` list inside the entry. + nested = entry.get('hooks') + if isinstance(nested, list): + kept = [h for h in nested if not (isinstance(h, dict) and _is_cycode_command(h.get('command', '')))] + if not kept: + return None + if len(kept) == len(nested): + return entry # nothing Cycode-shaped inside; preserve identity + return {**entry, 'hooks': kept} + + # Entry has neither shape we recognize — leave it alone defensively. + return entry + + +def _load_hooks_file(hooks_path: Path) -> Optional[dict]: + if not hooks_path.exists(): + return None + try: + return json.loads(hooks_path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load hooks file', exc_info=e) + return None + + +def _save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: + try: + hooks_path.parent.mkdir(parents=True, exist_ok=True) + hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8') + return True + except Exception as e: + logger.error('Failed to save hooks file', exc_info=e) + return False + + +def _load_policy_dict(policy_path: Path) -> dict: + if not policy_path.exists(): + return copy.deepcopy(DEFAULT_POLICY) + try: + existing = yaml.safe_load(policy_path.read_text(encoding='utf-8')) or {} + except Exception: + existing = {} + return {**copy.deepcopy(DEFAULT_POLICY), **existing} + + +def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Create or update the ai-guardrails.yaml policy file. + + If the file already exists, only the mode field is updated; otherwise a new + file is created from the default policy. + """ + config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode' + policy_path = config_dir / POLICY_FILE_NAME + + policy = _load_policy_dict(policy_path) + policy['mode'] = mode.value + + try: + config_dir.mkdir(parents=True, exist_ok=True) + policy_path.write_text(yaml.dump(policy, default_flow_style=False, sort_keys=False), encoding='utf-8') + return True, f'AI guardrails policy ({mode.value} mode) set: {policy_path}' + except Exception as e: + logger.error('Failed to create policy file', exc_info=e) + return False, f'Failed to create policy file: {policy_path}' + + +def install_hooks( + ide: IDE, + scope: str = 'user', + repo_path: Optional[Path] = None, + report_mode: bool = False, +) -> tuple[bool, str]: + """Install Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) + + existing = _load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}} + existing.setdefault('version', 1) + existing.setdefault('hooks', {}) + + rendered = ide.render_hooks_config(async_mode=report_mode) + + for event, entries in rendered['hooks'].items(): + existing['hooks'].setdefault(event, []) + existing['hooks'][event] = [ + stripped for e in existing['hooks'][event] if (stripped := _strip_cycode_from_entry(e)) is not None + ] + for entry in entries: + existing['hooks'][event].append(entry) + + if not _save_hooks_file(hooks_path, existing): + return False, f'Failed to install hooks to {hooks_path}' + + message = f'AI guardrails hooks installed: {hooks_path}' + + # IDE-specific extras (e.g. Codex enables a TOML feature flag). + extra_ok, extra_message = ide.post_install(scope, repo_path) + if not extra_ok: + return False, extra_message + if extra_message: + message = f'{message}\n {extra_message}' + + return True, message + + +def _strip_cycode_entries(existing: dict) -> bool: + """Mutate ``existing`` to drop Cycode hooks (surgically). Return True if anything changed.""" + modified = False + for event in list(existing.get('hooks', {}).keys()): + before = existing['hooks'][event] + after: list = [] + for e in before: + stripped = _strip_cycode_from_entry(e) + if stripped is None: + modified = True + continue + if stripped is not e: + modified = True + after.append(stripped) + if not after: + del existing['hooks'][event] + else: + existing['hooks'][event] = after + return modified + + +def _persist_uninstall(hooks_path: Path, existing: dict, modified: bool) -> tuple[bool, str]: + """Apply the uninstall result to disk and return ``(success, message)``.""" + if not modified: + return True, 'No Cycode hooks found to remove' + if not existing.get('hooks'): + try: + hooks_path.unlink() + except Exception as e: + logger.debug('Failed to delete hooks file', exc_info=e) + return False, f'Failed to remove hooks file: {hooks_path}' + return True, f'Removed hooks file: {hooks_path}' + if not _save_hooks_file(hooks_path, existing): + return False, f'Failed to update hooks file: {hooks_path}' + return True, f'Cycode hooks removed from: {hooks_path}' + + +def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Remove Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) + + existing = _load_hooks_file(hooks_path) + if existing is None: + return True, f'No hooks file found at {hooks_path}' + + modified = _strip_cycode_entries(existing) + file_ok, message = _persist_uninstall(hooks_path, existing, modified) + if not file_ok: + return False, message + + extra_ok, extra_message = ide.post_uninstall(scope, repo_path) + if not extra_ok: + return False, extra_message + if extra_message: + message = f'{message}\n {extra_message}' + return True, message + + +def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict: + """Return installation status of Cycode hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) + + status: dict = { + 'scope': scope, + 'ide': ide.name, + 'ide_name': ide.display_name, + 'hooks_path': str(hooks_path), + 'file_exists': hooks_path.exists(), + 'cycode_installed': False, + 'hooks': {}, + } + + existing = _load_hooks_file(hooks_path) + if existing is None: + return status + + has_cycode_hooks = False + for event in ide.hook_events: + # ':' filters entries to a specific tool/matcher. + if ':' in event: + actual_event, matcher_prefix = event.split(':', 1) + all_entries = existing.get('hooks', {}).get(actual_event, []) + entries = [e for e in all_entries if e.get('matcher', '').startswith(matcher_prefix)] + else: + entries = existing.get('hooks', {}).get(event, []) + + cycode_entries = [e for e in entries if is_cycode_hook_entry(e)] + if cycode_entries: + has_cycode_hooks = True + status['hooks'][event] = { + 'total_entries': len(entries), + 'cycode_entries': len(cycode_entries), + 'enabled': len(cycode_entries) > 0, + } + + status['cycode_installed'] = has_cycode_hooks + return status diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py new file mode 100644 index 00000000..396074da --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -0,0 +1,65 @@ +"""Registry of supported AI guardrails IDE integrations. + +Adding a new IDE: create `ides/.py` with a subclass of `IDE`, import it +here, and include an instance in the `IDES` tuple. Nothing else in the package +needs to change. +""" + +import typer + +from cycode.cli.apps.ai_guardrails.ides.base import IDE +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.codex import Codex +from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor + +# Single source of truth: name → singleton instance. +# `--ide` choices and install/uninstall/status iteration both derive from this. +IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex(), Copilot())} + +# Default IDE used when `--ide` is omitted. Kept here so the value is colocated +# with the registry; no module outside `ides/` needs to know which IDE wins. +DEFAULT_IDE_NAME = 'cursor' + + +def get_ide(name: str) -> IDE: + """Look up the IDE integration registered under ``name``. + + Raises ``typer.BadParameter`` when the name is unknown — surfaces as a + user-friendly CLI error rather than a KeyError stack trace. + """ + ide = IDES.get(name.lower()) + if ide is None: + valid = ', '.join(IDES.keys()) + raise typer.BadParameter(f'Unknown IDE "{name}". Supported: {valid}.') + return ide + + +def collect_all_session_contexts() -> tuple[dict[str, dict], dict]: + """Sweep every registered IDE's session context, regardless of which IDE triggered the hook. + + Returns ``(config_files_by_ide, plugins)``: the global MCP config file of each IDE that has + one (keyed by IDE name), and the enabled plugins merged across IDEs (first registered IDE + wins on a duplicate plugin key - plugins are IDE-agnostic marketplace artifacts). + """ + config_files_by_ide: dict[str, dict] = {} + plugins: dict = {} + for ide in IDES.values(): + global_config_file, enabled_plugins = ide.get_session_context() + if global_config_file: + config_files_by_ide[ide.name] = global_config_file + for plugin_key, plugin in (enabled_plugins or {}).items(): + plugins.setdefault(plugin_key, plugin) + + return config_files_by_ide, plugins + + +def resolve_ides(name: str) -> list[IDE]: + """Resolve an ``--ide`` argument to one or all IDE instances. + + ``"all"`` returns every registered IDE; anything else returns a single + matching IDE (raising ``typer.BadParameter`` for unknown names). + """ + if name.lower() == 'all': + return list(IDES.values()) + return [get_ide(name)] diff --git a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py new file mode 100644 index 00000000..6f7d8917 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py @@ -0,0 +1,101 @@ +"""Shared plugin-resolution helpers for IDE integrations. + +Both Claude Code and Codex use the same ``@`` key convention +and emit the same telemetry shape — only the marketplace layout and manifest +location differ. ``walk_enabled_plugins`` is the IDE-agnostic loop; each IDE +supplies the two callables that vary (``locate_dir`` + ``read_plugin``). +""" + +import json +from pathlib import Path +from typing import Any, Callable, Optional + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Plugins') + + +def resolve_cached_plugin_dir(cache_root: Path, marketplace: str, plugin_name: str) -> Optional[Path]: + """Find ``////``. + + Both Claude Code and Codex cache installed plugin content in this layout (the trailing + segment is a version for Claude, a content hash for Codex). If multiple are cached, pick + the most recently modified (name as a deterministic tie-breaker). + """ + base = cache_root / marketplace / plugin_name + if not base.is_dir(): + return None + candidates = [d for d in base.iterdir() if d.is_dir()] + if not candidates: + return None + return max(candidates, key=lambda d: (d.stat().st_mtime, d.name)) + + +def load_plugin_json(path: Path) -> Optional[dict]: + """Load a JSON file inside a plugin directory; None if missing or invalid.""" + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load plugin file, %s', {'path': str(path)}, exc_info=e) + return None + + +def build_global_config_file(path: Path, mcp_servers: Optional[dict]) -> Optional[dict]: + """Wrap a global (non-plugin) MCP config into the session-context file shape. + + Returns ``{"path": , "content": <{"mcpServers": ...} JSON>}`` when + there are servers, else ``None``. ``content`` is normalized to the canonical + ``{"mcpServers": {...}}`` shape, dropping everything else in the source file. + """ + servers = mcp_servers or {} + if not servers: + return None + return {'path': str(path), 'content': json.dumps({'mcpServers': servers})} + + +def walk_enabled_plugins( + plugin_entries: dict[str, Any], + is_enabled: Callable[[Any], bool], + locate_dir: Callable[[str, str], Optional[Path]], + read_plugin: Callable[[Path], tuple[dict, dict]], +) -> dict: + """Iterate enabled plugins and build their inventory metadata. + + Args: + plugin_entries: ``{@: settings}`` map from the IDE config. + is_enabled: returns True if ``settings`` indicates the plugin is on + (e.g. ``bool(settings)`` for Claude, ``settings.get('enabled')`` for Codex). + locate_dir: given ``(plugin_name, marketplace)``, returns the plugin's + filesystem path or None if it can't be resolved. + read_plugin: given the plugin path, returns ``(entry_fields, servers)``: + ``entry_fields`` are extra metadata to attach to the inventory entry + (name/version/description/...); ``servers`` are the plugin's MCP + servers, which ``read_plugin`` uses to derive that metadata. + + Returns ``enriched_plugins``. Plugin keys without ``@`` (or that fail to + resolve to a directory) still appear in the inventory with just + ``{'enabled': True}`` so we don't silently drop them. + """ + enriched: dict = {} + + for plugin_key, settings in plugin_entries.items(): + if not is_enabled(settings): + continue + + entry: dict = {'enabled': True} + enriched[plugin_key] = entry + + if '@' not in plugin_key: + continue + plugin_name, marketplace = plugin_key.split('@', 1) + + plugin_dir = locate_dir(plugin_name, marketplace) + if plugin_dir is None: + continue + + plugin_fields, _ = read_plugin(plugin_dir) + entry.update(plugin_fields) + + return enriched diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py new file mode 100644 index 00000000..29b4b200 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -0,0 +1,210 @@ +"""Base abstractions for AI guardrails IDE integrations. + +Each AI IDE (Cursor, Claude Code, …) is represented by a subclass of `IDE` +that consolidates every IDE-specific concern in a single module: settings file +paths, hooks template rendering, payload parsing, response building, and any +IDE-specific session-context lookup. + +Adding a new IDE is a matter of: + 1. Subclassing `IDE` and implementing the abstract methods. + 2. Registering the instance in `cycode/cli/apps/ai_guardrails/ides/__init__.py`. + +The `HookDecision` dataclass is the canonical, IDE-agnostic return type for +event handlers; `IDE.build_hook_response` translates it into the IDE-specific +JSON response shape that the IDE expects on stdout. +""" + +import platform +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def shell_background_suffix(async_mode: bool) -> str: + """`' &'` when backgrounding is requested and the platform's shell supports it. + + Only valid for hooks whose runner is stdin-safe under backgrounding (zsh keeps + a backgrounded command's stdin; verified for Cursor/Codex). bash/sh reattach it + to /dev/null, silently emptying the payload — hooks that run under bash (e.g. + Copilot's `bash` field) must add an explicit `<&0` redirect instead. + + Windows gets no suffix: depending on the IDE, hooks may run under cmd (where a + trailing `&` is a no-op separator) or Windows PowerShell (where it's a parse + error that would fail the hook). Until the CLI can self-detach in report mode, + Windows hooks run synchronously. + """ + if not async_mode or platform.system() == 'Windows': + return '' + return ' &' + + +class DecisionAction(str, Enum): + """Canonical decision action returned by event handlers.""" + + ALLOW = 'allow' + DENY = 'deny' + ASK = 'ask' + + +@dataclass(frozen=True) +class HookDecision: + """Canonical, IDE-agnostic decision returned by event handlers. + + Carries the event type so `IDE.build_hook_response` can pick the right + IDE-specific response shape (Cursor's "permission" style for tool events + vs. "continue" style for prompts; Claude Code's "hookSpecificOutput" + vs. "decision: block"). + """ + + action: DecisionAction + event_type: AiHookEventType + user_message: Optional[str] = None + agent_message: Optional[str] = None + + @classmethod + def allow(cls, event_type: AiHookEventType) -> 'HookDecision': + return cls(action=DecisionAction.ALLOW, event_type=event_type) + + @classmethod + def deny( + cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None + ) -> 'HookDecision': + return cls( + action=DecisionAction.DENY, + event_type=event_type, + user_message=user_message, + agent_message=agent_message, + ) + + @classmethod + def ask(cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None) -> 'HookDecision': + return cls( + action=DecisionAction.ASK, + event_type=event_type, + user_message=user_message, + agent_message=agent_message, + ) + + +class IDE(ABC): + """Per-IDE integration. Owns every IDE-specific concern in a single module. + + Subclasses declare identity via class attributes and implement the abstract + methods. Defaults are provided for `get_user_email` and `get_session_context` + so IDEs without those capabilities (e.g. no plugin system, no local + account file) can skip them. + """ + + # CLI value passed to --ide (e.g. 'cursor', 'claude-code'). + name: ClassVar[str] + # Human-friendly name for output ('Cursor', 'Claude Code'). + display_name: ClassVar[str] + # Event names for status display. Use ':' for IDEs that + # qualify a single hook by a sub-matcher (e.g. Claude Code's PreToolUse:Read). + hook_events: ClassVar[list[str]] + + # --- install / status --- + + @abstractmethod + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + """Return the hooks/settings file path for the given scope. + + `scope` is 'user' or 'repo'. `repo_path` is required when scope == 'repo'. + """ + + @abstractmethod + def render_hooks_config(self, async_mode: bool = False) -> dict: + """Return the settings blob to merge into the IDE's settings file. + + Shape is IDE-specific (Cursor uses a flat ``{event: [{command}]}`` dict; + Claude Code uses a nested ``{event: [{hooks: [{type, command}]}]}`` + dict). Both share the outer ``{"hooks": ...}`` wrapper so + ``hooks_manager`` can treat them uniformly. + """ + + def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Run IDE-specific actions after the hooks file is written. + + Default: no-op success. Override to perform extra setup that doesn't + belong in the hooks file itself — e.g. Codex enables a + ``[features] codex_hooks = true`` flag in its TOML config. + + Returns ``(success, message)``. If ``success`` is False, the overall + install is considered failed. + """ + return True, '' + + def post_uninstall(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Run IDE-specific cleanup after the hooks file is removed. + + Default: no-op success. Override to undo whatever ``post_install`` + wrote outside the hooks file. + """ + return True, '' + + # --- runtime scan --- + + @abstractmethod + def matches_payload(self, raw_payload: dict) -> bool: + """Return True if ``raw_payload`` originated from this IDE. + + Prevents double-processing when an IDE forwards another IDE's hook + event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json). + """ + + def is_synthetic_prompt(self, raw_payload: dict) -> bool: + """Return True when a prompt event carries IDE/harness-generated content + rather than text the user typed. + + Synthetic prompts are skipped without scanning or telemetry. + Default: False. Override for IDEs that inject synthetic user turns. + """ + return False + + @abstractmethod + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + """Normalize a raw stdin payload into the canonical ``AIHookPayload``.""" + + @abstractmethod + def build_hook_response(self, decision: HookDecision) -> dict: + """Translate a canonical ``HookDecision`` into the IDE-specific JSON. + + The result is what ``scan_command`` writes to stdout for the IDE to + act on. + """ + + # --- session lifecycle (optional; sensible defaults) --- + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + """Build a session-start payload from the raw stdin payload. + + Default: a minimal payload tagged with this IDE's ``name``. IDEs + that need to enrich with transcript/version info should override. + """ + return AIHookPayload(ide_provider=self.name) + + def get_user_email(self) -> Optional[str]: + """Best-effort read of the user's email from IDE-specific config. + + Default: None. Override if the IDE stores a usable account locally. + """ + return None + + def get_session_context(self) -> tuple[Optional[dict], dict]: + """Return ``(global_config_file, enabled_plugins)`` for session-context reporting. + + ``global_config_file`` is the IDE's global (non-plugin) MCP config as + ``{"path": , "content": }``, + or ``None`` when there is no global MCP config. ``enabled_plugins`` maps each + enabled plugin key to its metadata (including its own ``mcp_config_file`` + content and ``mcp_config_file_path``). + + Default: ``(None, {})`` (no plugin system, no discoverable MCP config). + Override to surface MCP/plugin inventory. + """ + return None, {} diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py new file mode 100644 index 00000000..131e8e1a --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -0,0 +1,393 @@ +"""Claude Code IDE integration for AI guardrails.""" + +import json +from collections.abc import Iterator +from copy import deepcopy +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + resolve_cached_plugin_dir, + walk_enabled_plugins, +) +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Claude Code') + +_CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) + +# When a fork/subagent completes, the harness injects its result into the parent +# session as a synthetic user turn, which fires UserPromptSubmit. +_SYNTHETIC_PROMPT_PREFIXES = ('',) + +_USER_HOOKS_DIR = Path.home() / '.claude' +_HOOKS_FILE_NAME = 'settings.json' +_REPO_SUBDIR = '.claude' +_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'] + +_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' +_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' + +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide claude-code' + + +# --- transcript JSONL parsing ------------------------------------------------- + + +def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: + """Yield lines of `path` from end to start without loading the file. + + The Claude Code transcript can be very large; reading from the tail keeps + memory bounded since we only care about the most recent entries. + """ + with path.open('rb') as f: + f.seek(0, 2) + file_size = f.tell() + if file_size == 0: + return + + remaining = file_size + buffer = b'' + + while remaining > 0: + read_size = min(buf_size, remaining) + remaining -= read_size + f.seek(remaining) + chunk = f.read(read_size) + buffer = chunk + buffer + + while b'\n' in buffer: + newline_pos = buffer.rfind(b'\n') + if newline_pos == len(buffer) - 1: + newline_pos = buffer.rfind(b'\n', 0, newline_pos) + if newline_pos == -1: + break + line = buffer[newline_pos + 1 :] + buffer = buffer[: newline_pos + 1] + if line.strip(): + yield line.decode('utf-8', errors='replace') + + if buffer.strip(): + yield buffer.decode('utf-8', errors='replace') + + +def _extract_model(entry: dict) -> Optional[str]: + """Extract model from a transcript entry (top level or nested in message).""" + return entry.get('model') or (entry.get('message') or {}).get('model') + + +def _extract_generation_id(entry: dict) -> Optional[str]: + """Extract generation ID from a user-type transcript entry.""" + if entry.get('type') == 'user': + return entry.get('uuid') + return None + + +def extract_from_claude_transcript( + transcript_path: str, +) -> tuple[Optional[str], Optional[str], Optional[str]]: + """Extract ``(ide_version, model, generation_id)`` from a transcript. + + The transcript is a JSONL file scanned from end → start so the most recent + entries are read first. Any field may come back ``None`` if not found. + """ + if not transcript_path: + return None, None, None + + path = Path(transcript_path) + if not path.exists(): + return None, None, None + + ide_version = None + model = None + generation_id = None + + try: + for line in _reverse_readline(path): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + ide_version = ide_version or entry.get('version') + model = model or _extract_model(entry) + generation_id = generation_id or _extract_generation_id(entry) + + if ide_version and model and generation_id: + break + except json.JSONDecodeError: + continue + except OSError: + pass + + return ide_version, model, generation_id + + +# --- ~/.claude.json + ~/.claude/settings.json parsing ------------------------- + + +def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.claude.json`. Returns None if missing/invalid.""" + path = config_path or _CLAUDE_CONFIG_PATH + if not path.exists(): + logger.debug('Claude config file not found, %s', {'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Claude config file', exc_info=e) + return None + + +def _email_from_config(config: dict) -> Optional[str]: + """Read ``oauthAccount.emailAddress`` from a parsed Claude config.""" + return config.get('oauthAccount', {}).get('emailAddress') + + +def get_mcp_servers(config: dict) -> Optional[dict]: + """Read ``mcpServers`` from a parsed Claude config.""" + return config.get('mcpServers') + + +def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid.""" + path = settings_path or _CLAUDE_SETTINGS_PATH + if not path.exists(): + logger.debug('Claude settings file not found, %s', {'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Claude settings file', exc_info=e) + return None + + +def _plugins_cache_dir() -> Path: + """Claude Code's local plugin content cache: ``~/.claude/plugins/cache////``.""" + return Path.home() / '.claude' / 'plugins' / 'cache' + + +def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: + """Resolve filesystem path for a directory-type marketplace.""" + source = marketplace.get('source', {}) + if source.get('source') != 'directory': + return None + raw = source.get('path') + if not raw: + return None + path = Path(raw) + return path if path.is_dir() else None + + +def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Claude Code plugin's manifest + MCP servers. + + Claude hardcodes the MCP file at ``/.mcp.json`` and always + wraps it as ``{"mcpServers": {...}}``. + """ + manifest = load_plugin_json(plugin_dir / '.claude-plugin' / 'plugin.json') or {} + entry: dict = {} + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + + mcp_config_path = plugin_dir / '.mcp.json' + mcp_config = load_plugin_json(mcp_config_path) or {} + servers: dict = mcp_config.get('mcpServers') or {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) + return entry, servers + + +def resolve_plugins(settings: dict) -> dict: + """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker. + + Directory-type marketplaces resolve through ``extraKnownMarketplaces``; all + other source types (git, github, ...) resolve through the local plugin cache. + The rest of the work (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``. + """ + enabled = settings.get('enabledPlugins') or {} + marketplaces = settings.get('extraKnownMarketplaces') or {} + + def _locate(plugin_name: str, marketplace_name: str) -> Optional[Path]: + # Directory-type marketplaces point straight at the plugin source; every other source + # type (git, github, ...) is cloned into the local plugin cache. + marketplace = marketplaces.get(marketplace_name) + if marketplace: + marketplace_path = _resolve_marketplace_path(marketplace) + if marketplace_path is not None: + return marketplace_path + return resolve_cached_plugin_dir(_plugins_cache_dir(), marketplace_name, plugin_name) + + return walk_enabled_plugins( + plugin_entries=enabled, + is_enabled=bool, + locate_dir=_locate, + read_plugin=_read_claude_plugin, + ) + + +# --- IDE integration ---------------------------------------------------------- + + +class ClaudeCode(IDE): + name: ClassVar[str] = 'claude-code' + display_name: ClassVar[str] = 'Claude Code' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME + return _USER_HOOKS_DIR / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + # Claude Code uses a nested hook structure with optional async/timeout. + hook_entry: dict = {'type': 'command', 'command': _SCAN_COMMAND} + if async_mode: + hook_entry['async'] = True + hook_entry['timeout'] = 20 + + return { + 'hooks': { + 'SessionStart': [ + { + 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + } + ], + 'UserPromptSubmit': [ + { + 'hooks': [deepcopy(hook_entry)], + } + ], + 'PreToolUse': [ + { + 'matcher': 'Read', + 'hooks': [deepcopy(hook_entry)], + }, + { + 'matcher': 'mcp__.*', + 'hooks': [deepcopy(hook_entry)], + }, + ], + }, + } + + def matches_payload(self, raw_payload: dict) -> bool: + # transcript_path is a documented Claude Code field, present on every hook event. + # Positive test by design: an absence check breaks silently when a vendor adds a field. + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload + + def is_synthetic_prompt(self, raw_payload: dict) -> bool: + if raw_payload.get('hook_event_name') != 'UserPromptSubmit': + return False + prompt = raw_payload.get('prompt') or '' + return prompt.lstrip().startswith(_SYNTHETIC_PROMPT_PREFIXES) + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event: AiHookEventType | str = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse': + canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION + else: + canonical_event = hook_event_name + + # Extract file_path from tool_input for the Read tool. + file_path = None + if tool_name == 'Read' and isinstance(tool_input, dict): + file_path = tool_input.get('file_path') + + # For MCP tools, the entire tool_input is the arguments. + mcp_arguments = tool_input if tool_name.startswith('mcp__') else None + + # MCP tool name format: mcp____ + mcp_server_name = None + mcp_tool_name = None + if tool_name.startswith('mcp__'): + parts = tool_name.split('__') + if len(parts) >= 2: + mcp_server_name = parts[1] + if len(parts) >= 3: + mcp_tool_name = parts[2] + + ide_version, model, generation_id = extract_from_claude_transcript(raw_payload.get('transcript_path')) + + config = load_claude_config() + ide_user_email = _email_from_config(config) if config else None + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + generation_id=generation_id, + ide_user_email=ide_user_email, + model=model, + ide_provider=self.name, + ide_version=ide_version, + prompt=raw_payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {} + # Both DENY and (unexpected) ASK on prompts collapse to a block. + return {'decision': 'block', 'reason': decision.user_message or ''} + + # FILE_READ / MCP_EXECUTION → hookSpecificOutput shape. + if decision.action == DecisionAction.ALLOW: + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + config = load_claude_config() + ide_user_email = _email_from_config(config) if config else None + ide_version, _, _ = extract_from_claude_transcript(raw_payload.get('transcript_path')) + + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + ide_user_email=ide_user_email, + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=ide_version, + source=raw_payload.get('source'), + ) + + def get_user_email(self) -> Optional[str]: + config = load_claude_config() + return _email_from_config(config) if config else None + + def get_session_context(self) -> tuple[Optional[dict], dict]: + config = load_claude_config() + global_config_file = build_global_config_file(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else None + + settings = load_claude_settings() + enriched_plugins = resolve_plugins(settings) if settings else {} + + return global_config_file, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py new file mode 100644 index 00000000..c9e48393 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -0,0 +1,307 @@ +"""Codex CLI IDE integration for AI guardrails.""" + +import json +import os +import sys +from pathlib import Path +from typing import ClassVar, Optional + +import tomli_w + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - py<3.11 fallback + import tomli as tomllib + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + resolve_cached_plugin_dir, + walk_enabled_plugins, +) +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.cli.utils.jwt_utils import decode_jwt_unverified +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Codex') + +_CONFIG_DIR_NAME = '.codex' +_HOOKS_FILE_NAME = 'hooks.json' +_CONFIG_TOML_NAME = 'config.toml' +_AUTH_JSON_NAME = 'auth.json' +_CODEX_HOME_ENV_VAR = 'CODEX_HOME' + +_HOOK_EVENTS = ('UserPromptSubmit', 'PreToolUse:mcp') +_CODEX_EVENT_NAMES = frozenset(e.split(':', 1)[0] for e in _HOOK_EVENTS) + +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide codex' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide codex' + + +def _codex_home() -> Path: + """Resolve Codex's user-scope home directory. + + Honors ``$CODEX_HOME`` per Codex's documented override; falls back to + ``~/.codex``. + """ + override = os.environ.get(_CODEX_HOME_ENV_VAR) + if override: + return Path(override) + return Path.home() / _CONFIG_DIR_NAME + + +def _codex_config_toml_path(scope: str, repo_path: Optional[Path] = None) -> Path: + """Return the Codex ``config.toml`` path for the given scope.""" + if scope == 'repo' and repo_path: + return repo_path / _CONFIG_DIR_NAME / _CONFIG_TOML_NAME + return _codex_home() / _CONFIG_TOML_NAME + + +def _load_codex_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse Codex's ``config.toml``. Returns None on missing/invalid.""" + path = config_path or (_codex_home() / _CONFIG_TOML_NAME) + if not path.exists(): + logger.debug('Codex config file not found, %s', {'path': str(path)}) + return None + try: + with path.open('rb') as f: + return tomllib.load(f) + except Exception as e: + logger.debug('Failed to load Codex config file, %s', {'path': str(path)}, exc_info=e) + return None + + +def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]: + """Best-effort extraction of the signed-in Codex user's email. + + Reads ``~/.codex/auth.json`` and decodes the JWT in ``tokens.id_token`` + to pull the ``email`` claim. Returns None if auth.json is missing + (``OPENAI_API_KEY``-only setups, OS keychain credentials) or unreadable. + """ + path = auth_path or (_codex_home() / _AUTH_JSON_NAME) + if not path.exists(): + logger.debug('Codex auth file not found, %s', {'path': str(path)}) + return None + try: + auth = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as e: + logger.debug('Failed to load Codex auth file, %s', {'path': str(path)}, exc_info=e) + return None + + token = (auth.get('tokens') or {}).get('id_token') + if not token: + return None + claims = decode_jwt_unverified(token) + if not claims: + return None + return claims.get('email') + + +def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]: + """Find ``~/.codex/plugins/cache////``.""" + return resolve_cached_plugin_dir(_codex_home() / 'plugins' / 'cache', marketplace, plugin_name) + + +def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Codex plugin's manifest + MCP servers. + + Codex's manifest references the MCP file via a path string in the + ``mcpServers`` field (default ``./.mcp.json``); the target file is either + a bare ``{name: cfg}`` map or wrapped in ``{"mcpServers": {...}}``. + """ + manifest = load_plugin_json(plugin_dir / '.codex-plugin' / 'plugin.json') + entry: dict = {} + if not manifest: + return entry, {} + + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + + mcp_ref = manifest.get('mcpServers') + if not mcp_ref: + return entry, {} + mcp_config_path = plugin_dir / mcp_ref + mcp_doc = load_plugin_json(mcp_config_path) or {} + servers = mcp_doc.get('mcpServers', mcp_doc) + if not isinstance(servers, dict): + servers = {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) + return entry, servers + + +def _resolve_codex_plugins(config: dict) -> dict: + """Walk enabled ``[plugins."@"]`` entries.""" + return walk_enabled_plugins( + plugin_entries=config.get('plugins') or {}, + is_enabled=lambda s: isinstance(s, dict) and bool(s.get('enabled')), + locate_dir=_resolve_codex_plugin_dir, + read_plugin=_read_codex_plugin, + ) + + +def _enable_codex_hooks_feature(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Set ``[features] hooks = true`` in Codex's ``config.toml``. + + Codex's hook scripts are gated behind this feature flag. We preserve any + existing keys and create the file (+ parent dir) when missing. + """ + config_path = _codex_config_toml_path(scope, repo_path) + + config: dict = {} + if config_path.exists(): + try: + with config_path.open('rb') as f: + config = tomllib.load(f) + except Exception as e: + logger.error('Failed to parse Codex config.toml, %s', {'path': str(config_path)}, exc_info=e) + return False, f'Failed to parse existing Codex config at {config_path}' + + features = config.get('features') + if not isinstance(features, dict): + features = {} + features['hooks'] = True + config['features'] = features + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + with config_path.open('wb') as f: + tomli_w.dump(config, f) + return True, f'Enabled hooks feature in {config_path}' + except Exception as e: + logger.error('Failed to write Codex config.toml, %s', {'path': str(config_path)}, exc_info=e) + return False, f'Failed to write Codex config at {config_path}' + + +class Codex(IDE): + name: ClassVar[str] = 'codex' + display_name: ClassVar[str] = 'Codex' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _CONFIG_DIR_NAME / _HOOKS_FILE_NAME + return _codex_home() / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + # Codex's TOML `async: true` flag is unimplemented; shell-background via + # `&` is the working mechanism (unix only). SessionStart stays sync so + # the conversation context is registered before any scan hook fires. + scan_cmd = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' + return { + 'hooks': { + 'SessionStart': [ + { + 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + } + ], + 'UserPromptSubmit': [ + { + 'hooks': [{'type': 'command', 'command': scan_cmd}], + } + ], + 'PreToolUse': [ + { + 'matcher': 'mcp__.*', + 'hooks': [{'type': 'command', 'command': scan_cmd}], + }, + ], + }, + } + + def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + return _enable_codex_hooks_feature(scope, repo_path) + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CODEX_EVENT_NAMES + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event: AiHookEventType | str = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse' and tool_name.startswith('mcp__'): + canonical_event = AiHookEventType.MCP_EXECUTION + else: + canonical_event = hook_event_name + + mcp_server_name = None + mcp_tool_name = None + mcp_arguments = None + if tool_name.startswith('mcp__'): + parts = tool_name.split('__') + if len(parts) >= 2: + mcp_server_name = parts[1] + if len(parts) >= 3: + mcp_tool_name = parts[2] + mcp_arguments = tool_input + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + generation_id=raw_payload.get('turn_id'), + ide_user_email=_email_from_auth(), + model=raw_payload.get('model'), + ide_provider=self.name, + prompt=raw_payload.get('prompt', ''), + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + # Codex accepts the same hook response shapes as Claude Code: + # - PROMPT: empty for allow, {"decision": "block", "reason": ...} for deny + # - PreToolUse: hookSpecificOutput.permissionDecision + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {} + return {'decision': 'block', 'reason': decision.user_message or ''} + + if decision.action == DecisionAction.ALLOW: + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + ide_user_email=_email_from_auth(), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('codex_version'), + source=raw_payload.get('source'), + ) + + def get_user_email(self) -> Optional[str]: + return _email_from_auth() + + def get_session_context(self) -> tuple[Optional[dict], dict]: + config = _load_codex_config() + if not config: + return None, {} + # Codex stores MCP servers under `[mcp_servers.]`; the global config + # file becomes its own session-context file. Plugins (via + # `[plugins."@"]`) carry their own config files. + config_path = _codex_config_toml_path('user') + global_config_file = build_global_config_file(config_path, config.get('mcp_servers')) + enriched_plugins = _resolve_codex_plugins(config) + return global_config_file, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py new file mode 100644 index 00000000..5f3bc76a --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -0,0 +1,498 @@ +"""GitHub Copilot integration for AI guardrails. + +Hooks are installed in Copilot's native format to ``~/.copilot/hooks/cycode.json`` +(user scope) or ``/.github/hooks/cycode.json`` (repo scope). One file, but +two runtimes are known to execute it: VS Code's own chat runtime, and the Copilot +agent runtime (Copilot CLI, and VS Code agent sessions). The repo-scope location +is also read by the Copilot cloud coding agent, whose dialect is untested here. + +Both deliver Claude-style payloads (``hook_event_name``, ``tool_name``, +``tool_input``) when the event keys are registered in PascalCase; the agent runtime +answers camelCase keys with its own dialect (``sessionId``, no event name) instead. +Copilot payloads are told apart from Claude Code's by the one field Claude Code +never sends, a top-level ``timestamp``; ``transcript_path`` cannot discriminate, +since VS Code sends one of its own whenever a folder is open. + +The tool vocabulary still differs by runtime — VS Code reads files with +``read_file``/``filePath`` and names MCP tools ``mcp__``, the agent +runtime uses ``Read``/``path`` and ``-`` — so both are accepted. +Copilot hooks have no matchers, so ``PreToolUse`` fires for every tool; tools we +don't scan pass through as raw event names, which match no handler and allow +immediately. +""" + +import json +import os +import platform +import re +from collections.abc import Iterable +from pathlib import Path +from typing import ClassVar, Optional, Union +from urllib.parse import urlparse +from urllib.request import url2pathname + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Copilot') + +# Payload dialect (Claude-style PascalCase event names). +_COPILOT_SCAN_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) + +# Two tool vocabularies reach us through one hooks file: VS Code's own runtime +# names file reads `read_file` with a `filePath` argument, while the Copilot agent +# runtime (Copilot CLI, and VS Code agent sessions) names them `Read` with `path`. +# The names are disjoint, so both are accepted rather than switched between. +_READ_FILE_TOOLS = frozenset({'read_file', 'Read'}) +_READ_PATH_KEYS = ('path', 'filePath') + +# VS Code names MCP tools `mcp__` (single underscores); the agent +# runtime uses `-` with no prefix (its SDK documents that wire form), +# leaving a hyphen as the only marker of an MCP call there. Every built-in agent +# tool observed is lower snake_case (`view`, `glob`, `str_replace`, `ask_user`) or +# PascalCase (`Read`), so this holds for them — but SDK- or custom-agent-registered +# tools may be named freely. A hyphenated custom tool would be scanned as an MCP +# call with no resolvable server: an extra scan, never a missed one, which is the +# safe direction to err for a guardrail. +_MCP_TOOL_PREFIX = 'mcp_' +_MCP_AGENT_SEPARATOR = '-' + +# Hooks-file event keys. Their case selects the agent runtime's payload dialect. +_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse'] + +_COPILOT_HOME_ENV_VAR = 'COPILOT_HOME' +_HOOKS_FILE_NAME = 'cycode.json' +_REPO_HOOKS_SUBDIR = Path('.github') / 'hooks' +_HOOK_TIMEOUT_SEC = 20 +_MCP_CONFIG_FILENAME = 'mcp.json' +_AGENT_MCP_CONFIG_FILENAME = 'mcp-config.json' + +# Plugin sources. CLI installs register in ~/.copilot/config.json and auto-surface +# in VS Code; VS Code UI installs register in ~/.vscode/agent-plugins/installed.json; +# local-directory plugins are declared via the chat.pluginLocations setting. +_VSCODE_PLUGINS_REGISTRY_NAME = 'installed.json' +_PLUGIN_LOCATIONS_SETTING = 'chat.pluginLocations' +_LOCAL_PLUGINS_MARKETPLACE = 'local' + +# Manifest locations in VS Code's documented detection order. Plugins may ship +# several manifest dialects at once — first hit wins, matching VS Code's probing. +_PLUGIN_MANIFEST_LOCATIONS = ( + Path('.plugin') / 'plugin.json', + Path('plugin.json'), + Path('.github') / 'plugin' / 'plugin.json', + Path('.claude-plugin') / 'plugin.json', +) + +# One command for both events: every runtime self-describes via hook_event_name once +# the events are registered in PascalCase, so --event is no longer passed. +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide copilot' + + +def _copilot_home() -> Path: + """Resolve Copilot's user-scope home directory (honors ``$COPILOT_HOME``).""" + override = os.environ.get(_COPILOT_HOME_ENV_VAR) + if override: + return Path(override) + return Path.home() / '.copilot' + + +def _vscode_agent_plugins_dir() -> Path: + # Resolved at call time (not a module-level Path constant): on py<=3.10 a Path + # instance binds its filesystem accessor at creation, which breaks fake-fs tests + # and ignores home changes. + return Path.home() / '.vscode' / 'agent-plugins' + + +def _vscode_user_dir() -> Path: + """Per-platform VS Code user settings directory.""" + if platform.system() == 'Darwin': + return Path.home() / 'Library' / 'Application Support' / 'Code' / 'User' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Code' / 'User' + return Path.home() / '.config' / 'Code' / 'User' + + +def _vscode_mcp_config_path() -> Path: + return _vscode_user_dir() / _MCP_CONFIG_FILENAME + + +def _load_vscode_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse VS Code's user-level ``mcp.json``. Returns None if missing/invalid.""" + path = config_path or _vscode_mcp_config_path() + if not path.exists(): + logger.debug('VS Code MCP config file not found, %s', {'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load VS Code MCP config file', exc_info=e) + return None + + +def _load_jsonc(path: Path) -> Optional[dict]: + """Parse a JSON file tolerating //-comment lines (Copilot's config.json ships + with a comment header; VS Code's settings.json is JSONC). + + Best-effort: JSONC constructs beyond full-line comments (trailing commas, + inline comments) read as a missing file. + """ + if not path.exists(): + logger.debug('Config file not found, %s', {'path': str(path)}) + return None + try: + text = path.read_text(encoding='utf-8') + stripped = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('//')) + return json.loads(stripped) + except Exception as e: + logger.debug('Failed to load config file, %s', {'path': str(path)}, exc_info=e) + return None + + +# --- plugins inventory ---------------------------------------------------------- + + +def _read_copilot_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Copilot plugin's manifest + MCP servers. + + The manifest's ``mcpServers`` field, when present, is a path string to the MCP + file; otherwise the root ``.mcp.json`` convention applies (same as Claude + plugins). Both forms exist in marketplace plugins. + """ + manifest: dict = {} + for location in _PLUGIN_MANIFEST_LOCATIONS: + manifest = load_plugin_json(plugin_dir / location) or {} + if manifest: + break + + entry: dict = {} + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + + mcp_ref = manifest.get('mcpServers') + mcp_config_path = plugin_dir / mcp_ref if isinstance(mcp_ref, str) else plugin_dir / '.mcp.json' + mcp_doc = load_plugin_json(mcp_config_path) or {} + servers = mcp_doc.get('mcpServers') + if not isinstance(servers, dict): + servers = {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) + return entry, servers + + +def _walk_registry_plugins(entries: dict[str, dict], dirs: dict[str, Path], is_enabled: bool = True) -> dict: + """Walk plugins whose directories are known up front (registry-provided).""" + return walk_enabled_plugins( + plugin_entries=entries, + is_enabled=lambda p: p.get('enabled', True) if is_enabled else True, + locate_dir=lambda name, marketplace: dirs.get(f'{name}@{marketplace}'), + read_plugin=_read_copilot_plugin, + ) + + +def _cli_registry_plugins() -> dict: + """Plugins installed via Copilot CLI: ``~/.copilot/config.json`` → ``installedPlugins``.""" + config = _load_jsonc(_copilot_home() / 'config.json') or {} + entries: dict[str, dict] = {} + dirs: dict[str, Path] = {} + for plugin in config.get('installedPlugins') or []: + if not isinstance(plugin, dict) or not plugin.get('name'): + continue + key = f'{plugin["name"]}@{plugin.get("marketplace", "")}' + entries[key] = plugin + if plugin.get('cache_path'): + dirs[key] = Path(plugin['cache_path']) + return _walk_registry_plugins(entries, dirs) + + +def _vscode_registry_plugins() -> dict: + """Plugins installed via the VS Code UI (@agentPlugins): ``~/.vscode/agent-plugins/installed.json``. + + Registry-driven only — the directory also holds marketplace clones that are + not installed. ``pluginUri`` is the authoritative location (the registry's + ``marketplace`` label is unreliable); presence in the registry means enabled. + """ + registry = load_plugin_json(_vscode_agent_plugins_dir() / _VSCODE_PLUGINS_REGISTRY_NAME) or {} + entries: dict[str, dict] = {} + dirs: dict[str, Path] = {} + for plugin in registry.get('installed') or []: + if not isinstance(plugin, dict) or not plugin.get('name'): + continue + key = f'{plugin["name"]}@{plugin.get("marketplace", "")}' + entries[key] = plugin + uri = plugin.get('pluginUri', '') + if uri.startswith('file://'): + # url2pathname unquotes and handles Windows drive-letter URIs (file:///C:/...). + dirs[key] = Path(url2pathname(urlparse(uri).path)) + return _walk_registry_plugins(entries, dirs, is_enabled=False) + + +def _local_dir_plugins() -> dict: + """Local-directory plugins declared via the ``chat.pluginLocations`` setting.""" + settings = _load_jsonc(_vscode_user_dir() / 'settings.json') or {} + locations = settings.get(_PLUGIN_LOCATIONS_SETTING) + if not isinstance(locations, dict): + return {} + entries: dict[str, bool] = {} + dirs: dict[str, Path] = {} + for raw_path, enabled in locations.items(): + path = Path(raw_path).expanduser() + key = f'{path.name}@{_LOCAL_PLUGINS_MARKETPLACE}' + entries[key] = bool(enabled) + dirs[key] = path + return walk_enabled_plugins( + plugin_entries=entries, + is_enabled=bool, + locate_dir=lambda name, marketplace: dirs.get(f'{name}@{marketplace}'), + read_plugin=_read_copilot_plugin, + ) + + +def _collect_installed_plugins() -> dict: + """Merge the three plugin sources (first source wins on a duplicate key).""" + plugins: dict = {} + for source in (_cli_registry_plugins, _vscode_registry_plugins, _local_dir_plugins): + for key, entry in source().items(): + plugins.setdefault(key, entry) + return plugins + + +# --- MCP tool-name splitting ------------------------------------------------------ + + +def _known_mcp_server_names() -> list[str]: + """Config-declared MCP server names, across both runtimes' config files. + + VS Code declares them in its user-level ``mcp.json`` under ``servers``; the + agent runtime uses ``~/.copilot/mcp-config.json`` under ``mcpServers``. Both are + read because one hooks file serves both, and plugin configs contribute to either. + + Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery`` + imports, dev containers, or non-default profiles are not discoverable from disk. + """ + config = _load_vscode_mcp_config() + servers = (config or {}).get('servers') + names = list(servers.keys()) if isinstance(servers, dict) else [] + + agent_config = _load_jsonc(_copilot_home() / _AGENT_MCP_CONFIG_FILENAME) or {} + agent_servers = agent_config.get('mcpServers') + if isinstance(agent_servers, dict): + names.extend(agent_servers.keys()) + + for plugin in _collect_installed_plugins().values(): + names.extend(plugin.get('mcp_server_names') or []) + return names + + +def _server_name_variants(server_name: str) -> set[str]: + """Normalized forms a config name may take inside a VS Code tool-name prefix. + + The prefix derives from the server's self-reported handshake name, which often + resembles the config name modulo case and separators (a server configured as + ``dummy-tracker`` self-reporting ``DummyTracker`` yields prefix ``dummytracker``). + """ + lowered = server_name.lower() + underscored = re.sub(r'[^a-z0-9]+', '_', lowered).strip('_') + collapsed = re.sub(r'[^a-z0-9]', '', lowered) + return {v for v in (server_name, underscored, collapsed) if v} + + +def _read_file_path(tool_name: str, tool_input: object) -> Optional[str]: + """Path of a file-read tool call, or None when this isn't one. + + The agent runtime reuses its read tool for directory listings, with a payload + identical to a file read, so the path has to be stat-ed to tell them apart — + VS Code has no such ambiguity (`read_file` vs `list_dir`). A path that isn't an + existing file (a directory, or already deleted) has nothing to scan. + """ + if tool_name not in _READ_FILE_TOOLS or not isinstance(tool_input, dict): + return None + + raw_path = next((tool_input[key] for key in _READ_PATH_KEYS if tool_input.get(key)), None) + if not isinstance(raw_path, str): + return None + + try: + if not Path(raw_path).is_file(): + return None + except OSError as e: + logger.debug('Failed to stat read path, %s', {'path': raw_path}, exc_info=e) + return None + return raw_path + + +def is_mcp_tool_name(tool_name: str) -> bool: + """Whether a tool name is an MCP call in either runtime's naming scheme.""" + return tool_name.startswith(_MCP_TOOL_PREFIX) or _MCP_AGENT_SEPARATOR in tool_name + + +def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[Optional[str], Optional[str]]: + """Split an MCP tool name into ``(server, tool)``. + + Handles both naming schemes: VS Code's ``mcp__`` and the agent + runtime's prefix-less ``-``. In the VS Code form the ```` + part is a sanitized (and possibly truncated) form of the server's SELF-REPORTED + handshake name rather than the config key, so matching against known config + names (and their normalized variants) is best-effort. Server names may + themselves contain the separator, hence the longest-match. When nothing + matches, return the unsplit remainder as the tool rather than fabricating a + server from a guessed split. + """ + if tool_name.startswith(_MCP_TOOL_PREFIX): + rest, separator = tool_name[len(_MCP_TOOL_PREFIX) :], '_' + else: + rest, separator = tool_name, _MCP_AGENT_SEPARATOR + + best_server = None + best_variant_len = -1 + for server in server_names: + for variant in _server_name_variants(server): + if (rest == variant or rest.startswith(f'{variant}{separator}')) and len(variant) > best_variant_len: + best_server = server + best_variant_len = len(variant) + if best_server is not None: + return best_server, rest[best_variant_len + 1 :] or None + + return None, rest or None + + +class Copilot(IDE): + name: ClassVar[str] = 'copilot' + display_name: ClassVar[str] = 'GitHub Copilot' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + # Dedicated Cycode-owned file (Copilot reads every *.json in the hooks + # dir), unlike the shared settings files of other IDEs. + if scope == 'repo' and repo_path: + return repo_path / _REPO_HOOKS_SUBDIR / _HOOKS_FILE_NAME + return _copilot_home() / 'hooks' / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + def entry(command: str) -> dict: + if async_mode: + # Copilot has no async hook flag; background via shell on unix. Both + # redirects are load-bearing. `<&0` keeps the payload flowing: a bare + # `cmd &` gets its stdin reattached to /dev/null by the shell (job + # control is off in hooks), so the scan reads nothing and allows. The + # stdout redirect is what actually makes it async: the backgrounded + # child inherits the hook's stdout and the runner waits on that pipe + # for EOF, so without it the scan blocks the response it was meant to + # run behind. Windows PowerShell has no trailing-&, so it stays sync. + return { + 'type': 'command', + 'bash': f'{command} <&0 >/dev/null 2>&1 &', + 'powershell': command, + 'timeoutSec': _HOOK_TIMEOUT_SEC, + } + # Single cross-platform `command` field, copied to both shells by Copilot. + return {'type': 'command', 'command': command, 'timeoutSec': _HOOK_TIMEOUT_SEC} + + return { + 'version': 1, + 'hooks': { + 'SessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + 'UserPromptSubmit': [entry(_SCAN_COMMAND)], + 'PreToolUse': [entry(_SCAN_COMMAND)], + }, + } + + def matches_payload(self, raw_payload: dict) -> bool: + # Structural discrimination, no magic strings: Copilot events carry a top-level + # timestamp, Claude Code events never do. + return raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES and 'timestamp' in raw_payload + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + read_path = _read_file_path(tool_name, tool_input) + + if hook_event_name == 'UserPromptSubmit': + canonical_event: Union[AiHookEventType, str] = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse' and read_path is not None: + canonical_event = AiHookEventType.FILE_READ + elif hook_event_name == 'PreToolUse' and is_mcp_tool_name(tool_name): + canonical_event = AiHookEventType.MCP_EXECUTION + else: + # No matchers in Copilot hooks: PreToolUse fires for every tool. Pass + # the raw tool name through — it matches no handler, so scan_command + # answers with a neutral allow before any policy/network work. + canonical_event = tool_name or hook_event_name + + file_path = read_path if canonical_event == AiHookEventType.FILE_READ else None + + mcp_server_name = None + mcp_tool_name = None + mcp_arguments = None + if canonical_event == AiHookEventType.MCP_EXECUTION: + mcp_server_name, mcp_tool_name = split_mcp_tool_name(tool_name, _known_mcp_server_names()) + mcp_arguments = tool_input if isinstance(tool_input, dict) else None + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + ide_provider=self.name, + prompt=raw_payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.action == DecisionAction.ALLOW: + # Neutral allow: {} means "no objection", leaving VS Code's own + # permission flow intact. An explicit permissionDecision "allow" would + # pre-approve the tool past the user's confirmation prompts — and with + # no matchers that would cover every tool, not just scanned ones. + return {} + + if decision.event_type == AiHookEventType.PROMPT: + reason = decision.user_message or '' + # decision/reason is what VS Code acts on; continue/stopReason/systemMessage + # are the generic top-level fields — the combo is what was verified live. + return { + 'decision': 'block', + 'reason': reason, + 'continue': False, + 'stopReason': reason, + 'systemMessage': reason, + } + + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + model=raw_payload.get('model'), + ide_provider=self.name, + source=raw_payload.get('source'), + ) + + def get_session_context(self) -> tuple[Optional[dict], dict]: + # VS Code's mcp.json uses `servers` as its top-level key; normalized to the + # canonical mcpServers shape by build_global_config_file. + config = _load_vscode_mcp_config() + global_config_file = ( + build_global_config_file(_vscode_mcp_config_path(), config.get('servers')) if config else None + ) + return global_config_file, _collect_installed_plugins() diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py new file mode 100644 index 00000000..01c65edb --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -0,0 +1,128 @@ +"""Cursor IDE integration for AI guardrails.""" + +import json +import platform +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Cursor') + +_CURSOR_EVENT_MAPPING: dict[str, AiHookEventType] = { + 'beforeSubmitPrompt': AiHookEventType.PROMPT, + 'beforeReadFile': AiHookEventType.FILE_READ, + 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, +} + +_HOOKS_FILE_NAME = 'hooks.json' +_REPO_SUBDIR = '.cursor' +_MCP_CONFIG_FILENAME = 'mcp.json' + +# Cursor was the original default IDE — its scan command omits --ide to stay +# byte-identical with already-installed hooks.json files. Session-start is +# always explicit because it was introduced after Claude Code support. +_SCAN_COMMAND = CYCODE_SCAN_PROMPT_COMMAND +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide cursor' + + +def _user_hooks_dir() -> Path: + """Per-platform Cursor user-scope settings directory.""" + if platform.system() == 'Darwin': + return Path.home() / '.cursor' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Cursor' + return Path.home() / '.config' / 'Cursor' + + +def _cursor_mcp_config_path() -> Path: + """User-scope Cursor MCP config path (``~/.cursor/mcp.json``, all platforms).""" + return Path.home() / '.cursor' / _MCP_CONFIG_FILENAME + + +def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid.""" + path = config_path or _cursor_mcp_config_path() + if not path.exists(): + logger.debug('Cursor MCP config file not found, %s', {'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Cursor MCP config file', exc_info=e) + return None + + +class Cursor(IDE): + name: ClassVar[str] = 'cursor' + display_name: ClassVar[str] = 'Cursor' + hook_events: ClassVar[list[str]] = list(_CURSOR_EVENT_MAPPING) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME + return _user_hooks_dir() / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + command = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' + hooks = {event: [{'command': command}] for event in self.hook_events} + hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}] + return {'version': 1, 'hooks': hooks} + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CURSOR_EVENT_MAPPING + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + cursor_event_name = raw_payload.get('hook_event_name', '') + canonical_event = _CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name) + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('conversation_id'), + generation_id=raw_payload.get('generation_id'), + ide_user_email=raw_payload.get('user_email'), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('cursor_version'), + prompt=raw_payload.get('prompt', ''), + file_path=raw_payload.get('file_path') or raw_payload.get('path'), + mcp_server_name=raw_payload.get('command'), + mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'), + mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')), + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {'continue': True} + return {'continue': False, 'user_message': decision.user_message or ''} + + # FILE_READ / MCP_EXECUTION → permission shape + if decision.action == DecisionAction.ALLOW: + return {'permission': 'allow'} + return { + 'permission': decision.action.value, # 'deny' or 'ask' + 'user_message': decision.user_message or '', + 'agent_message': decision.agent_message or '', + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('conversation_id'), + ide_user_email=raw_payload.get('user_email'), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('cursor_version'), + ) + + def get_session_context(self) -> tuple[Optional[dict], dict]: + config = _load_cursor_mcp_config() + if not config: + return None, {} + config_path = _cursor_mcp_config_path() + global_config_file = build_global_config_file(config_path, config.get('mcpServers')) + return global_config_file, {} diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py new file mode 100644 index 00000000..155cf83a --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -0,0 +1,113 @@ +"""Install command for AI guardrails hooks.""" + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode +from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides + + +def install_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Installation scope: "user" for all projects, "repo" for current repository only.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help=f'IDE to install hooks for ({", ".join(IDES)}, or "all" for every supported IDE).', + ), + ] = DEFAULT_IDE_NAME, + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped installation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, + mode: Annotated[ + GuardrailsMode, + typer.Option( + '--mode', + '-m', + help='Installation mode: "report" for async non-blocking hooks with warn policy, ' + '"block" for sync blocking hooks.', + ), + ] = GuardrailsMode.REPORT, +) -> None: + """Install AI guardrails hooks for supported IDEs. + + Configures the specified IDE to use Cycode for scanning prompts, file reads, + and MCP tool calls for secrets before they reach the AI model. + + Examples: + cycode ai-guardrails install # Install in report mode (default) + cycode ai-guardrails install --mode block # Install in block mode + cycode ai-guardrails install --scope repo # Install for current repo only + cycode ai-guardrails install --ide claude-code # Install for a specific IDE + cycode ai-guardrails install --ide all # Install for every supported IDE + """ + validate_scope(scope) + repo_path = resolve_repo_path(scope, repo_path) + ides_to_install = resolve_ides(ide) + + report_mode = mode == GuardrailsMode.REPORT + + results: list[tuple[str, bool, str]] = [] + for current_ide in ides_to_install: + success, message = install_hooks(current_ide, scope, repo_path, report_mode=report_mode) + results.append((current_ide.display_name, success, message)) + + any_success = False + all_success = True + for _name, success, message in results: + if success: + console.print(f'[green]✓[/] {message}') + any_success = True + else: + console.print(f'[red]✗[/] {message}', style='bold red') + all_success = False + + if any_success: + policy_mode = PolicyMode.WARN if mode == GuardrailsMode.REPORT else PolicyMode.BLOCK + _install_policy(scope, repo_path, policy_mode) + _print_next_steps(results, mode) + + if not all_success: + raise typer.Exit(1) + + +def _install_policy(scope: str, repo_path: Optional[Path], policy_mode: PolicyMode) -> None: + policy_success, policy_message = create_policy_file(scope, policy_mode, repo_path) + if policy_success: + console.print(f'[green]✓[/] {policy_message}') + else: + console.print(f'[red]✗[/] {policy_message}', style='bold red') + + +def _print_next_steps(results: list[tuple[str, bool, str]], mode: GuardrailsMode) -> None: + console.print() + console.print('[bold]Next steps:[/]') + successful_ides = [name for name, success, _ in results if success] + ide_list = ', '.join(successful_ides) + console.print(f'1. Restart {ide_list} to activate the hooks') + console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') + console.print() + if mode == GuardrailsMode.REPORT: + console.print('[dim]Report mode: hooks run async (non-blocking) and policy is set to warn.[/]') + else: + console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') diff --git a/cycode/cli/apps/ai_guardrails/scan/__init__.py b/cycode/cli/apps/ai_guardrails/scan/__init__.py new file mode 100644 index 00000000..47349e78 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/__init__.py @@ -0,0 +1 @@ +# Prompt scan command for AI guardrails (hooks) diff --git a/cycode/cli/apps/ai_guardrails/scan/consts.py b/cycode/cli/apps/ai_guardrails/scan/consts.py new file mode 100644 index 00000000..007892a8 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/consts.py @@ -0,0 +1,48 @@ +""" +Constants and default configuration for AI guardrails. + +These defaults can be overridden by: +1. User-level config: ~/.cycode/ai-guardrails.yaml +2. Repo-level config: /.cycode/ai-guardrails.yaml +""" + +# Policy file name +POLICY_FILE_NAME = 'ai-guardrails.yaml' + +# Default policy configuration +DEFAULT_POLICY = { + 'version': 1, + 'mode': 'block', # block | warn + 'fail_open': True, # allow if scan fails/timeouts + 'secrets': { + 'scan_type': 'secret', + 'timeout_ms': 30000, + 'max_bytes': 200000, + }, + 'prompt': { + 'enabled': True, + 'action': 'block', + }, + 'file_read': { + 'enabled': True, + 'action': 'block', + 'deny_globs': [ + '.env', + '.env.*', + '*.pem', + '*.p12', + '*.key', + '.aws/**', + '.ssh/**', + '*kubeconfig*', + '.npmrc', + '.netrc', + ], + 'scan_content': True, + }, + 'mcp': { + 'enabled': True, + 'action': 'block', + 'scan_arguments': True, + }, +} diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py new file mode 100644 index 00000000..e82c61d7 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -0,0 +1,441 @@ +"""Hook handlers for AI IDE events. + +Each handler receives a unified payload and policy, applies the scan + policy +logic, and returns a canonical ``HookDecision``. ``scan_command`` translates +that decision into the IDE-specific JSON response via ``IDE.build_hook_response``. + +Handlers are agent-agnostic by design — adding a new IDE doesn't require +touching any handler in this module. +""" + +import json +import os +from dataclasses import dataclass +from multiprocessing.pool import ThreadPool +from multiprocessing.pool import TimeoutError as PoolTimeoutError +from typing import Callable, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value +from cycode.cli.apps.ai_guardrails.scan.types import ( + SECRETS_BLOCK_REASON_BY_EVENT_TYPE, + AiHookEventType, + AIHookOutcome, + BlockReason, +) +from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8 +from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func +from cycode.cli.apps.scan.scan_parameters import get_scan_parameters +from cycode.cli.cli_types import ScanTypeOption, SeverityOption +from cycode.cli.files_collector.file_excluder import is_path_configured_in_exclusions +from cycode.cli.models import Document +from cycode.cli.utils.host_info import get_hostname, get_serial_number +from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection +from cycode.cli.utils.scan_utils import build_violation_summary +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails') + +HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision] + + +def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan prompt text for secrets before it's sent to the AI model.""" + ai_client = ctx.obj['ai_security_client'] + + prompt_config = get_policy_value(policy, 'prompt', default={}) + if not get_policy_value(prompt_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) + return HookDecision.allow(AiHookEventType.PROMPT) + + effective_mode = get_effective_mode(policy, prompt_config) + prompt = payload.prompt or '' + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + clipped = truncate_utf8(prompt, max_bytes) + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + error_message = None + + try: + violation_summary, scan_id = _scan_text_for_secrets( + ctx, + clipped, + timeout_ms, + payload=payload, + event_type=AiHookEventType.PROMPT, + effective_mode=effective_mode, + ) + + if violation_summary: + block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT] + if effective_mode == GuardrailsMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'{violation_summary}. Remove secrets before sending.' + return HookDecision.deny(AiHookEventType.PROMPT, user_message) + outcome = AIHookOutcome.WARNED + return HookDecision.allow(AiHookEventType.PROMPT) + except Exception as e: + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) + block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.PROMPT, + outcome, + scan_id=scan_id, + block_reason=block_reason, + error_message=error_message, + ) + + +def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Block sensitive paths and scan file content for secrets.""" + ai_client = ctx.obj['ai_security_client'] + + file_read_config = get_policy_value(policy, 'file_read', default={}) + if not get_policy_value(file_read_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) + return HookDecision.allow(AiHookEventType.FILE_READ) + + file_path = payload.file_path or '' + effective_mode = get_effective_mode(policy, file_read_config) + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + error_message = None + + try: + is_sensitive_path = is_denied_path(file_path, policy) + if is_sensitive_path: + block_reason = BlockReason.SENSITIVE_PATH + if effective_mode == GuardrailsMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' + return HookDecision.deny( + AiHookEventType.FILE_READ, + user_message, + 'This file path is classified as sensitive; do not read/send it to the model.', + ) + # Warn mode: if content scan is enabled, emit a separate event for the + # sensitive path so the finally block can independently track the scan result. + outcome = AIHookOutcome.WARNED + if get_policy_value(file_read_config, 'scan_content', default=True): + ai_client.create_event( + payload, + AiHookEventType.FILE_READ, + outcome, + block_reason=BlockReason.SENSITIVE_PATH, + file_path=payload.file_path, + ) + block_reason = None + outcome = AIHookOutcome.ALLOWED + + if get_policy_value(file_read_config, 'scan_content', default=True): + violation_summary, scan_id = _scan_path_for_secrets( + ctx, file_path, policy, payload=payload, effective_mode=effective_mode + ) + if violation_summary: + block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.FILE_READ] + if effective_mode == GuardrailsMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'Cycode blocked reading {file_path}. {violation_summary}' + return HookDecision.deny( + AiHookEventType.FILE_READ, + user_message, + 'Secrets detected; do not send this file to the model.', + ) + outcome = AIHookOutcome.WARNED + user_message = f'Cycode detected secrets in {file_path}. {violation_summary}' + return HookDecision.ask( + AiHookEventType.FILE_READ, + user_message, + 'Possible secrets detected; proceed with caution.', + ) + + if is_sensitive_path: + user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' + return HookDecision.ask( + AiHookEventType.FILE_READ, + user_message, + 'This file path is classified as sensitive; proceed with caution.', + ) + + return HookDecision.allow(AiHookEventType.FILE_READ) + except Exception as e: + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) + block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.FILE_READ, + outcome, + scan_id=scan_id, + block_reason=block_reason, + error_message=error_message, + file_path=payload.file_path, + ) + + +@dataclass(frozen=True) +class _ArgScanFeature: + """Configuration for a "scan some text and decide" event. + + MCP execution and command exec share identical scan-and-decide logic; + only the policy key, event type, and user-facing messages differ. + """ + + policy_key: str # 'mcp' or 'command_exec' + scan_key: str # 'scan_arguments' or 'scan_command' + event_type: AiHookEventType + deny_message: Callable[[str], str] + deny_agent_message: str + ask_message: Callable[[str], str] + ask_agent_message: str + + +def _handle_arg_scan( + ctx: typer.Context, + payload: AIHookPayload, + policy: dict, + feature: _ArgScanFeature, + scan_text: str, +) -> HookDecision: + """Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events.""" + ai_client = ctx.obj['ai_security_client'] + + feature_config = get_policy_value(policy, feature.policy_key, default={}) + if not get_policy_value(feature_config, 'enabled', default=True): + ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED) + return HookDecision.allow(feature.event_type) + + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + clipped = truncate_utf8(scan_text, max_bytes) + effective_mode = get_effective_mode(policy, feature_config) + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + error_message = None + + try: + if get_policy_value(feature_config, feature.scan_key, default=True): + violation_summary, scan_id = _scan_text_for_secrets( + ctx, + clipped, + timeout_ms, + payload=payload, + event_type=feature.event_type, + effective_mode=effective_mode, + ) + if violation_summary: + block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type] + if effective_mode == GuardrailsMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + return HookDecision.deny( + feature.event_type, + feature.deny_message(violation_summary), + feature.deny_agent_message, + ) + outcome = AIHookOutcome.WARNED + return HookDecision.ask( + feature.event_type, + feature.ask_message(violation_summary), + feature.ask_agent_message, + ) + + return HookDecision.allow(feature.event_type) + except Exception as e: + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) + block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) + raise e + finally: + ai_client.create_event( + payload, + feature.event_type, + outcome, + scan_id=scan_id, + block_reason=block_reason, + error_message=error_message, + ) + + +def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan MCP tool arguments for secrets before execution.""" + tool = payload.mcp_tool_name or 'unknown' + args = payload.mcp_arguments or {} + args_text = args if isinstance(args, str) else json.dumps(args) + return _handle_arg_scan( + ctx, + payload, + policy, + _ArgScanFeature( + policy_key='mcp', + scan_key='scan_arguments', + event_type=AiHookEventType.MCP_EXECUTION, + deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}', + deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.', + ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?', + ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.', + ), + scan_text=args_text, + ) + + +def get_handler_for_event(event_type: str) -> Optional[HandlerFn]: + """Look up the handler for a canonical event type.""" + handlers: dict[str, HandlerFn] = { + AiHookEventType.PROMPT.value: handle_before_submit_prompt, + AiHookEventType.FILE_READ.value: handle_before_read_file, + AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution, + } + return handlers.get(event_type) + + +def get_effective_mode(policy: dict, feature_config: dict) -> GuardrailsMode: + """The event only blocks when both the global mode and the per-guardrail action are block.""" + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) + action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK) + return GuardrailsMode.BLOCK if (mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK) else GuardrailsMode.REPORT + + +def build_ai_guardrails_scan_parameters( + ctx: typer.Context, + paths: Optional[tuple[str, ...]], + payload: AIHookPayload, + event_type: AiHookEventType, + effective_mode: GuardrailsMode, +) -> dict: + scan_parameters = get_scan_parameters(ctx, paths) + scan_parameters.setdefault('metadata', {})['ai_guardrails'] = { + 'mode': effective_mode.value, + 'ide_provider': payload.ide_provider, + 'detection_source': SECRETS_BLOCK_REASON_BY_EVENT_TYPE[event_type].value, + 'device_id': get_serial_number(), + 'device_hostname': get_hostname(), + 'conversation_id': payload.conversation_id, + 'generation_id': payload.generation_id, + 'ide_user_email': payload.ide_user_email, + 'mcp_server_name': payload.mcp_server_name, + 'mcp_tool_name': payload.mcp_tool_name, + } + return scan_parameters + + +def _setup_scan_context(ctx: typer.Context) -> typer.Context: + """Set up minimal context for scan_documents without progress bars or printing.""" + ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection]) + ctx.obj['sync'] = True + ctx.obj['scan_type'] = ScanTypeOption.SECRET + ctx.obj['severity_threshold'] = SeverityOption.INFO + ctx.info_name = 'ai_guardrails' + return ctx + + +def _perform_scan( + ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float +) -> tuple[Optional[str], Optional[str]]: + """Run a scan on documents, returning (violation_summary, scan_id). + + Raises on scan failure / timeout so the fail-open policy can take over. + """ + if not documents: + return None, None + + scan_batch_thread_func = _get_scan_documents_thread_func( + ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters + ) + + # Use ThreadPool.apply_async with timeout to abort if scan takes too long + # This uses the same ThreadPool mechanism as run_parallel_batched_scan but with timeout support + with ThreadPool(processes=1) as pool: + result = pool.apply_async(scan_batch_thread_func, (documents,)) + try: + scan_id, error, local_scan_result = result.get(timeout=timeout_seconds) + except PoolTimeoutError: + logger.debug('Scan timed out after %s seconds', timeout_seconds) + raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds') from None + + # Check if scan failed - raise exception to trigger fail_open policy + if error: + raise RuntimeError(error.message) + + if not local_scan_result: + return None, None + + scan_id = local_scan_result.scan_id + + if local_scan_result.issue_detected: + violation_summary = build_violation_summary([local_scan_result]) + return violation_summary, scan_id + + return None, scan_id + + +def _scan_text_for_secrets( + ctx: typer.Context, + text: str, + timeout_ms: int, + payload: AIHookPayload, + event_type: AiHookEventType, + effective_mode: GuardrailsMode, +) -> tuple[Optional[str], Optional[str]]: + """Scan text content for secrets using Cycode CLI.""" + if not text: + return None, None + + document = Document(path='prompt-content.txt', content=text, is_git_diff_format=False) + scan_ctx = _setup_scan_context(ctx) + timeout_seconds = timeout_ms / 1000.0 + scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, None, payload, event_type, effective_mode) + return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds) + + +def _scan_path_for_secrets( + ctx: typer.Context, + file_path: str, + policy: dict, + payload: AIHookPayload, + effective_mode: GuardrailsMode, +) -> tuple[Optional[str], Optional[str]]: + """Scan a file path for secrets.""" + if not file_path or not os.path.isfile(file_path): + return None, None + + if is_path_configured_in_exclusions(str(ScanTypeOption.SECRET), os.path.abspath(file_path)): + logger.debug('Skipping scan; the path is in the ignore paths list, %s', {'file_path': file_path}) + return None, None + + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + + with open(file_path, encoding='utf-8', errors='replace') as f: + content = f.read(max_bytes) + + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + timeout_seconds = timeout_ms / 1000.0 + + document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False) + scan_ctx = _setup_scan_context(ctx) + scan_parameters = build_ai_guardrails_scan_parameters( + scan_ctx, (file_path,), payload, AiHookEventType.FILE_READ, effective_mode + ) + return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds) diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py new file mode 100644 index 00000000..19845601 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -0,0 +1,34 @@ +"""Canonical AI hook payload shared across IDE integrations. + +The dataclass is populated by `IDE.parse_hook_payload` (see +`cycode/cli/apps/ai_guardrails/ides/`). Per-IDE parsing logic lives on the +respective IDE class. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class AIHookPayload: + """Unified payload that normalizes field names across IDEs.""" + + # Event identification + event_name: Optional[str] = None # Canonical event type from AiHookEventType + conversation_id: Optional[str] = None + generation_id: Optional[str] = None + + # User and IDE information + ide_user_email: Optional[str] = None + model: Optional[str] = None + ide_provider: Optional[str] = None # Matches IDE.name (e.g. 'cursor', 'claude-code') + ide_version: Optional[str] = None + + source: Optional[str] = None + + # Event-specific data + prompt: Optional[str] = None # PROMPT events + file_path: Optional[str] = None # FILE_READ events + mcp_server_name: Optional[str] = None # MCP_EXECUTION events + mcp_tool_name: Optional[str] = None + mcp_arguments: Optional[dict] = None diff --git a/cycode/cli/apps/ai_guardrails/scan/policy.py b/cycode/cli/apps/ai_guardrails/scan/policy.py new file mode 100644 index 00000000..96c45574 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/policy.py @@ -0,0 +1,103 @@ +""" +Policy loading and configuration management for AI guardrails. + +Policies are loaded and merged in order (later overrides earlier): +1. Built-in defaults (consts.DEFAULT_POLICY) +2. Machine-wide config (admin/MDM-provisioned; see get_machine_policy_path) +3. User-level config (~/.cycode/ai-guardrails.yaml) +4. Repo-level config (/.cycode/ai-guardrails.yaml) +""" + +import json +import os +import sys +from pathlib import Path +from typing import Any, Optional + +import yaml + +from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME + + +def get_machine_policy_path() -> Path: + """Machine-wide (admin/MDM-provisioned) policy path, by platform.""" + if sys.platform == 'darwin': + return Path('/Library/Application Support/Cycode') / POLICY_FILE_NAME + if sys.platform == 'win32': + program_data = os.environ.get('PROGRAMDATA', 'C:\\ProgramData') + return Path(program_data) / 'Cycode' / POLICY_FILE_NAME + return Path('/etc/cycode') / POLICY_FILE_NAME + + +def deep_merge(base: dict, override: dict) -> dict: + """Deep merge two dictionaries, with override taking precedence.""" + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +def load_yaml_file(path: Path) -> Optional[dict]: + """Load a YAML or JSON config file.""" + if not path.exists(): + return None + try: + content = path.read_text(encoding='utf-8') + if path.suffix in ('.yaml', '.yml'): + return yaml.safe_load(content) + return json.loads(content) + except Exception: + return None + + +def load_defaults() -> dict: + """Load built-in defaults.""" + return DEFAULT_POLICY.copy() + + +def get_policy_value(policy: dict, *keys: str, default: Any = None) -> Any: + """Get a nested value from the policy dict.""" + current = policy + for key in keys: + if not isinstance(current, dict): + return default + current = current.get(key) + if current is None: + return default + return current + + +def load_policy(workspace_root: Optional[str] = None) -> dict: + """ + Load policy by merging configs in order of precedence. + + Merge order: defaults <- machine <- user config <- repo config + + Args: + workspace_root: Workspace root path for repo-level config lookup. + """ + # Start with defaults + policy = load_defaults() + + # Merge machine-wide config (admin/MDM-provisioned) - overrides defaults, below user/repo. + machine_config = load_yaml_file(get_machine_policy_path()) + if machine_config: + policy = deep_merge(policy, machine_config) + + # Merge user-level config (if exists) + user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME + user_config = load_yaml_file(user_policy_path) + if user_config: + policy = deep_merge(policy, user_config) + + # Merge repo-level config (if exists) - highest precedence + if workspace_root: + repo_policy_path = Path(workspace_root) / '.cycode' / POLICY_FILE_NAME + repo_config = load_yaml_file(repo_policy_path) + if repo_config: + policy = deep_merge(policy, repo_config) + + return policy diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py new file mode 100644 index 00000000..5cf5da38 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -0,0 +1,171 @@ +"""Scan command for AI guardrails IDE hooks. + +Reads a JSON payload from stdin, routes it through the IDE-specific parser and +the shared event handlers, then writes an IDE-specific JSON response to stdout. + +The handlers in ``handlers.py`` are agent-agnostic (they return +``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step. +""" + +from typing import Annotated, Optional, Union +from uuid import uuid4 + +import click +import typer + +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event +from cycode.cli.apps.ai_guardrails.scan.policy import load_policy +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.cli.apps.ai_guardrails.scan.utils import output_json, read_stdin_text, safe_json_parse +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError +from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails') + + +def _get_auth_error_message(error: Exception) -> str: + """User-friendly message for authentication errors.""" + if isinstance(error, click.ClickException): + # Missing credentials + return f'{error.message} Please run `cycode auth` to set up your credentials.' + + if isinstance(error, HttpUnauthorizedError): + # Invalid/expired credentials + return ( + 'Unable to authenticate to Cycode. Your credentials are invalid or have expired. ' + 'Please run `cycode auth` to update your credentials.' + ) + + # Fallback + return 'Authentication failed. Please run `cycode auth` to set up your credentials.' + + +def _deny_for_event( + event_name: Optional[Union[str, AiHookEventType]], + user_message: str, + agent_message: Optional[str] = None, +) -> HookDecision: + """Build a deny decision matched to ``event_name``'s response shape. + + PROMPT events use the prompt-block shape (no agent_message). For anything + else — including unknown event names — fall back to FILE_READ since + FILE_READ and MCP_EXECUTION share the same response shape on both IDEs. + """ + if event_name == AiHookEventType.PROMPT: + return HookDecision.deny(AiHookEventType.PROMPT, user_message) + target = event_name if isinstance(event_name, AiHookEventType) else AiHookEventType.FILE_READ + return HookDecision.deny(target, user_message, agent_message) + + +def _initialize_clients(ctx: typer.Context) -> None: + """Initialize API clients. + + May raise click.ClickException if credentials are missing, + or HttpUnauthorizedError if credentials are invalid. + """ + scan_client = get_scan_cycode_client(ctx) + ctx.obj['client'] = scan_client + + ai_security_client = get_ai_security_manager_client(ctx) + ctx.obj['ai_security_client'] = ai_security_client + + +def scan_command( + ctx: typer.Context, + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', + hidden=True, + ), + ] = DEFAULT_IDE_NAME, +) -> None: + """Scan content from AI IDE hooks for secrets. + + Reads a JSON payload from stdin and outputs a JSON response to stdout + indicating whether to allow or block the action. + """ + ide_integration = get_ide(ide) + + stdin_data = read_stdin_text().strip() + payload = safe_json_parse(stdin_data) + + if not payload: + logger.debug('Empty or invalid JSON payload received') + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return + + # Prevent cross-IDE processing (e.g. Cursor reading Claude Code hooks + # from ~/.claude/settings.json). + if not ide_integration.matches_payload(payload): + logger.debug( + 'Payload event does not match expected IDE, skipping', + extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': ide_integration.name}, + ) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return + + # Fork/subagent completions arrive as synthetic user turns (e.g. Claude Code's + # ); they are agent-generated, not user prompts - skip before + # parse_hook_payload, which reads the transcript and IDE config from disk. + if ide_integration.is_synthetic_prompt(payload): + logger.debug('Synthetic prompt detected, skipping scan') + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return + + unified_payload = ide_integration.parse_hook_payload(payload) + if not unified_payload.generation_id: + # Not every IDE dialect provides a generation id (e.g. Copilot) + unified_payload.generation_id = str(uuid4()) + event_name = unified_payload.event_name + logger.debug( + 'Processing AI guardrails hook', + extra={'event_name': event_name, 'ide': ide_integration.name}, + ) + + # Resolved before any policy/client work: Copilot hooks have no matchers, so + # every tool call arrives here and unmatched tools must exit fast. + handler = get_handler_for_event(event_name) + if handler is None: + logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name}) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return + + # `or` (not a .get default) - Cursor sends workspace_roots=[] when no folder is open. + workspace_roots = payload.get('workspace_roots') or ['.'] + policy = load_policy(workspace_roots[0]) + + try: + _initialize_clients(ctx) + + decision = handler(ctx, unified_payload, policy) + logger.debug('Hook handler completed', extra={'event_name': event_name, 'action': decision.action.value}) + output_json(ide_integration.build_hook_response(decision)) + + except (click.ClickException, HttpUnauthorizedError) as e: + output_json( + ide_integration.build_hook_response( + _deny_for_event(event_name, _get_auth_error_message(e), 'Authentication required') + ) + ) + + except Exception as e: + logger.error('Hook handler failed', exc_info=e) + if policy.get('fail_open', True): + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return + output_json( + ide_integration.build_hook_response( + _deny_for_event( + event_name, + 'Cycode guardrails error - blocking due to fail-closed policy' + if event_name == AiHookEventType.PROMPT + else 'Cycode guardrails error', + 'Blocking due to fail-closed policy', + ) + ) + ) diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py new file mode 100644 index 00000000..5d18e07d --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -0,0 +1,52 @@ +"""Canonical event types and outcome enums for AI guardrails. + +Per-IDE event-name mappings live on the IDE class (in +`cycode/cli/apps/ai_guardrails/ides/`); only the IDE-agnostic enums are kept +here. +""" + +import sys + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + def __str__(self) -> str: + return self.value + + +class AiHookEventType(StrEnum): + """Canonical, IDE-agnostic hook event types.""" + + PROMPT = 'Prompt' + FILE_READ = 'FileRead' + MCP_EXECUTION = 'McpExecution' + + +class AIHookOutcome(StrEnum): + """Outcome of an AI hook event evaluation.""" + + ALLOWED = 'allowed' + BLOCKED = 'blocked' + WARNED = 'warned' + + +class BlockReason(StrEnum): + """Categorical reason for blocking (sent to backend for tracking).""" + + SECRETS_IN_PROMPT = 'secrets_in_prompt' + SECRETS_IN_FILE = 'secrets_in_file' + SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args' + SENSITIVE_PATH = 'sensitive_path' + SCAN_FAILURE = 'scan_failure' + + +# The reason each event type yields when a secret is found in it. Also travels with the scan as +# `detection_source`, so the violation and the hook event are labelled from the same vocabulary. +SECRETS_BLOCK_REASON_BY_EVENT_TYPE: dict[AiHookEventType, BlockReason] = { + AiHookEventType.PROMPT: BlockReason.SECRETS_IN_PROMPT, + AiHookEventType.FILE_READ: BlockReason.SECRETS_IN_FILE, + AiHookEventType.MCP_EXECUTION: BlockReason.SECRETS_IN_MCP_ARGS, +} diff --git a/cycode/cli/apps/ai_guardrails/scan/utils.py b/cycode/cli/apps/ai_guardrails/scan/utils.py new file mode 100644 index 00000000..6223c925 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -0,0 +1,89 @@ +""" +Utility functions for AI guardrails. + +Includes JSON parsing, path matching, and text handling utilities. +""" + +import json +import os +import sys +from pathlib import Path + +from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value + + +def read_stdin_text() -> str: + """Read the hook payload from stdin as UTF-8 text. + + Reads bytes and decodes with utf-8-sig: hook payloads are UTF-8 JSON, but on Windows + Python decodes piped stdin with the ANSI code page (mojibake for non-ASCII prompts), + and Cursor on Windows prefixes the payload with a UTF-8 BOM - the -sig codec strips it. + """ + buffer = getattr(sys.stdin, 'buffer', None) + if buffer is not None: + return buffer.read().decode('utf-8-sig', errors='replace') + # No .buffer (tests mocking sys.stdin with StringIO, exotic streams) - text-mode fallback. + # lstrip the BOM here too: an already-decoded stream leaves it as U+FEFF, which json.loads + # rejects (and .strip() doesn't remove - it is not whitespace). + return sys.stdin.read().lstrip('\ufeff') + + +def safe_json_parse(s: str) -> dict: + """Parse JSON string, returning empty dict on failure.""" + try: + return json.loads(s) if s else {} + except (json.JSONDecodeError, TypeError): + return {} + + +def truncate_utf8(text: str, max_bytes: int) -> str: + """Truncate text to max bytes while preserving valid UTF-8.""" + if not text: + return '' + encoded = text.encode('utf-8') + if len(encoded) <= max_bytes: + return text + return encoded[:max_bytes].decode('utf-8', errors='ignore') + + +def normalize_path(file_path: str) -> str: + """Normalize path to prevent traversal attacks.""" + if not file_path: + return '' + normalized = os.path.normpath(file_path) + # Reject paths that attempt to escape outside bounds + if normalized.startswith('..'): + return '' + return normalized + + +def matches_glob(file_path: str, pattern: str) -> bool: + """Check if file path matches a glob pattern. + + Case-insensitive matching for cross-platform compatibility. + """ + normalized = normalize_path(file_path) + if not normalized or not pattern: + return False + + path = Path(normalized) + # Try case-sensitive first + if path.match(pattern): + return True + + # Then try case-insensitive by lowercasing both path and pattern + path_lower = Path(normalized.lower()) + return path_lower.match(pattern.lower()) + + +def is_denied_path(file_path: str, policy: dict) -> bool: + """Check if file path is in the denylist.""" + if not file_path: + return False + globs = get_policy_value(policy, 'file_read', 'deny_globs', default=[]) + return any(matches_glob(file_path, g) for g in globs) + + +def output_json(obj: dict) -> None: + """Write JSON response to stdout (for IDE to read).""" + print(json.dumps(obj), end='') # noqa: T201 diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py new file mode 100644 index 00000000..bebd421d --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -0,0 +1,160 @@ +"""Handle AI guardrails session start: auth, conversation creation, session context.""" + +import hashlib +import json +import sys +import time +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, collect_all_session_contexts, get_ide +from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse +from cycode.cli.apps.auth.auth_common import get_authorization_info +from cycode.cli.apps.auth.auth_manager import AuthManager +from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception +from cycode.cli.utils.get_api_client import get_ai_security_manager_client +from cycode.cli.utils.host_info import ( + get_hostname, + get_last_login_user, + get_os_version, + get_platform_name, + get_serial_number, +) +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient + +logger = get_logger('AI Guardrails') + +_SESSION_CONTEXT_CACHE_FILE = '.session-context-cache' +_SESSION_CONTEXT_TTL_SECONDS = 7 * 24 * 60 * 60 + + +def _session_context_cache_path() -> Path: + return Path.home() / '.cycode' / _SESSION_CONTEXT_CACHE_FILE + + +def _session_context_digest(report: dict) -> str: + """Deterministic hash of the outgoing payload (not the raw config files, which churn).""" + canonical = json.dumps(report, sort_keys=True, separators=(',', ':'), default=str) + return hashlib.sha256(canonical.encode('utf-8')).hexdigest() + + +def _should_skip_report(digest: str, tenant_id: Optional[str]) -> bool: + """Skip when the same payload was already sent for this tenant and the TTL hasn't expired.""" + try: + cache = json.loads(_session_context_cache_path().read_text(encoding='utf-8')) + return ( + cache.get('hash') == digest + and cache.get('tenant_id') == tenant_id + and time.time() - float(cache.get('sent_at', 0)) < _SESSION_CONTEXT_TTL_SECONDS + ) + except Exception: + # Missing/corrupt cache reads as a miss - over-sending is harmless + return False + + +def _save_report_cache(digest: str, tenant_id: Optional[str]) -> None: + try: + cache_path = _session_context_cache_path() + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text( + json.dumps({'hash': digest, 'tenant_id': tenant_id, 'sent_at': time.time()}), encoding='utf-8' + ) + except Exception as e: + logger.debug('Failed to write session context cache', exc_info=e) + + +def _report_session_context( + ai_client: 'AISecurityManagerClient', + user_email: Optional[str], + tenant_id: Optional[str], +) -> None: + """Report the device + cross-IDE session context to the AI security manager. Never raises. + + The device context is always reported. MCP configs are collected from every registered IDE, + not just the triggering one. Unchanged payloads are skipped via a hash cache until the TTL expires. + """ + try: + config_files_by_ide, enabled_plugins = collect_all_session_contexts() + report = { + 'hostname': get_hostname(), + 'platform_name': get_platform_name(), + 'os_version': get_os_version(), + 'serial_number': get_serial_number(), + 'last_login_user': get_last_login_user(), + # Sorted by path so the digest is stable regardless of IDE registry order. + 'config_files': sorted(config_files_by_ide.values(), key=lambda f: f['path']), + 'enabled_plugins': enabled_plugins, + 'user_email': user_email, + } + + digest = _session_context_digest(report) + if _should_skip_report(digest, tenant_id): + logger.debug('Session context unchanged; skipping report') + return + + if ai_client.report_session_context(**report): + _save_report_cache(digest, tenant_id) + except Exception as e: + logger.debug('Failed to report session context', exc_info=e) + + +def session_start_command( + ctx: typer.Context, + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE that triggered the session start.', + hidden=True, + ), + ] = DEFAULT_IDE_NAME, +) -> None: + """Handle session start: ensure auth, create conversation, report session context.""" + ide_integration = get_ide(ide) + + # Ensure authentication + auth_info = get_authorization_info(ctx) + if auth_info is None: + logger.debug('Not authenticated, starting authentication') + try: + AuthManager().authenticate() + except Exception as err: + handle_auth_exception(ctx, err) + return + auth_info = get_authorization_info(ctx) + else: + logger.debug('Already authenticated') + + # Read stdin payload (backward compat: old hooks pipe no stdin) + if sys.stdin.isatty(): + logger.debug('No stdin payload (TTY), skipping session initialization') + return + + stdin_data = read_stdin_text().strip() + payload = safe_json_parse(stdin_data) + if not payload: + logger.debug('Empty or invalid stdin payload, skipping session initialization') + return + + # Build session payload + initialize API client + session_payload = ide_integration.build_session_payload(payload) + + try: + ai_client = get_ai_security_manager_client(ctx) + except Exception as e: + logger.debug('Failed to initialize AI security client', exc_info=e) + return + + # Create conversation + try: + ai_client.create_conversation(session_payload) + except Exception as e: + logger.debug('Failed to create conversation during session start', exc_info=e) + + # Report session context (device + cross-IDE MCP servers and plugins) + _report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id) diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py new file mode 100644 index 00000000..da201545 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -0,0 +1,95 @@ +"""Status command for AI guardrails hooks.""" + +import os +from pathlib import Path +from typing import Annotated, Optional + +import typer +from rich.table import Table + +from cycode.cli.apps.ai_guardrails.command_utils import console, validate_scope +from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides + + +def status_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Check scope: "user", "repo", or "all" for both.', + ), + ] = 'all', + ide: Annotated[ + str, + typer.Option( + '--ide', + help=f'IDE to check status for ({", ".join(IDES)}, or "all").', + ), + ] = DEFAULT_IDE_NAME, + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped status (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Show AI guardrails hook installation status. + + Examples: + cycode ai-guardrails status # Show both user and repo status + cycode ai-guardrails status --scope user # Show only user-level status + cycode ai-guardrails status --scope repo # Show only repo-level status + cycode ai-guardrails status --ide claude-code + cycode ai-guardrails status --ide all # Check every supported IDE + """ + validate_scope(scope, allowed_scopes=('user', 'repo', 'all')) + if repo_path is None: + repo_path = Path(os.getcwd()) + ides_to_check = resolve_ides(ide) + + scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + + for current_ide in ides_to_check: + console.print() + console.print(f'[bold cyan]═══ {current_ide.display_name} ═══[/]') + + for check_scope in scopes_to_check: + status = get_hooks_status( + current_ide, + check_scope, + repo_path if check_scope == 'repo' else None, + ) + + console.print() + console.print(f'[bold]{check_scope.upper()} SCOPE[/]') + console.print(f'Path: {status["hooks_path"]}') + + if not status['file_exists']: + console.print('[dim]No hooks file found[/]') + continue + + if status['cycode_installed']: + console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') + else: + console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') + + table = Table(show_header=True, header_style='bold') + table.add_column('Hook Event') + table.add_column('Cycode Enabled') + table.add_column('Total Hooks') + + for event, info in status['hooks'].items(): + enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' + table.add_row(event, enabled, str(info['total_entries'])) + + console.print(table) + + console.print() diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py new file mode 100644 index 00000000..f9a995f3 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -0,0 +1,79 @@ +"""Uninstall command for AI guardrails hooks.""" + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope +from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides + + +def uninstall_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Uninstall scope: "user" for user-level hooks, "repo" for repository-level hooks.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help=f'IDE to uninstall hooks from ({", ".join(IDES)}, or "all").', + ), + ] = DEFAULT_IDE_NAME, + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped uninstallation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Remove AI guardrails hooks from supported IDEs. + + Removes Cycode hooks from the IDE's hooks configuration. Other hooks + (if any) are preserved. + + Examples: + cycode ai-guardrails uninstall # Remove user-level hooks + cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks + cycode ai-guardrails uninstall --ide claude-code # Uninstall from a specific IDE + cycode ai-guardrails uninstall --ide all # Uninstall from every supported IDE + """ + validate_scope(scope) + repo_path = resolve_repo_path(scope, repo_path) + ides_to_uninstall = resolve_ides(ide) + + results: list[tuple[str, bool, str]] = [] + for current_ide in ides_to_uninstall: + success, message = uninstall_hooks(current_ide, scope, repo_path) + results.append((current_ide.display_name, success, message)) + + any_success = False + all_success = True + for _name, success, message in results: + if success: + console.print(f'[green]✓[/] {message}') + any_success = True + else: + console.print(f'[red]✗[/] {message}', style='bold red') + all_success = False + + if any_success: + console.print() + successful_ides = [name for name, success, _ in results if success] + ide_list = ', '.join(successful_ides) + console.print(f'[dim]Restart {ide_list} for changes to take effect.[/]') + + if not all_success: + raise typer.Exit(1) diff --git a/cycode/cli/apps/api/__init__.py b/cycode/cli/apps/api/__init__.py new file mode 100644 index 00000000..e65f9c6f --- /dev/null +++ b/cycode/cli/apps/api/__init__.py @@ -0,0 +1,69 @@ +"""Cycode platform API CLI commands. + +Dynamically builds CLI command groups from the Cycode API v4 OpenAPI spec. +The spec is fetched lazily — only when the user invokes `cycode platform ...` — +and cached locally for 24 hours. +""" + +from typing import Any, Optional + +import click + +from cycode.logger import get_logger + +logger = get_logger('Platform') + +_PLATFORM_HELP = ( + '[BETA] Access the Cycode platform.\n\n' + 'Commands are generated dynamically from the Cycode API spec and may change ' + 'between releases. The spec is fetched on first use and cached for 24 hours.' +) + + +class PlatformGroup(click.Group): + """Lazy-loading Click group for `cycode platform` subcommands. + + The OpenAPI spec is only fetched when the user actually invokes + `cycode platform ...` (or asks for its help). Unrelated commands like + `cycode scan` or `cycode status` never trigger a spec fetch. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._loaded: bool = False + + def _ensure_loaded(self, ctx: Optional[click.Context]) -> None: + if self._loaded: + return + self._loaded = True # set first to avoid re-entrancy on errors + + client_id = client_secret = None + if ctx is not None: + root = ctx.find_root() + if root.obj: + client_id = root.obj.get('client_id') + client_secret = root.obj.get('client_secret') + + try: + from cycode.cli.apps.api.api_command import build_api_command_groups + + for sub_group, name in build_api_command_groups(client_id, client_secret): + if name not in self.commands: + self.add_command(sub_group, name) + except Exception as e: + logger.debug('Could not load platform commands: %s', e) + # Surface the error to the user only when they're inside `platform` + click.echo(f'Error loading Cycode platform commands: {e}', err=True) + + def list_commands(self, ctx: click.Context) -> list[str]: + self._ensure_loaded(ctx) + return super().list_commands(ctx) + + def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]: + self._ensure_loaded(ctx) + return super().get_command(ctx, cmd_name) + + +def get_platform_group() -> click.Group: + """Return the top-level `platform` Click group (lazy-loading).""" + return PlatformGroup(name='platform', help=_PLATFORM_HELP, no_args_is_help=True) diff --git a/cycode/cli/apps/api/api_command.py b/cycode/cli/apps/api/api_command.py new file mode 100644 index 00000000..1926c93c --- /dev/null +++ b/cycode/cli/apps/api/api_command.py @@ -0,0 +1,271 @@ +"""OpenAPI-to-Typer translator: dynamically builds CLI commands from the Cycode API v4 spec.""" + +import json +import re +from typing import Any, Optional + +import click + +from cycode.cli.apps.api.openapi_spec import OpenAPISpecError, get_openapi_spec, parse_spec_commands +from cycode.logger import get_logger + +logger = get_logger('API Command') + +# Map OpenAPI parameter types to Click types +_CLICK_TYPE_MAP: dict[str, click.ParamType] = { + 'string': click.STRING, + 'integer': click.INT, + 'number': click.FLOAT, + 'boolean': click.BOOL, +} + + +def _normalize_tag(tag: str) -> str: + """Normalize an OpenAPI tag to a CLI-friendly command name. + + 'Scan Statistics' -> 'scan-statistics' + 'CLI scan statistics' -> 'cli-scan-statistics' + """ + return re.sub(r'[^a-z0-9]+', '-', tag.lower()).strip('-') + + +def _find_common_prefix(paths: list[str]) -> str: + """Find the longest common path prefix shared by all paths.""" + if not paths: + return '' + if len(paths) == 1: + # For single-path tags, use the parent directory as prefix + return '/'.join(paths[0].split('/')[:-1]) + + common = paths[0] + for p in paths[1:]: + while not p.startswith(common + '/') and common != p: + common = '/'.join(common.split('/')[:-1]) + return common + + +def _path_to_command_name(path: str, common_prefix: str, has_path_params: bool) -> str: + """Derive a CLI command name from an API path relative to the tag's common prefix. + + Rules: + 1. Strip the common prefix shared by all endpoints in the tag + 2. Remove path parameter segments ({id}) + 3. If nothing remains: 'list' (no path params) or 'view' (has path params) + 4. Otherwise: use remaining segments joined with hyphens + + Examples: + /v4/projects (prefix=/v4/projects) -> list + /v4/projects/{id} (prefix=/v4/projects) -> view + /v4/projects/assets (prefix=/v4/projects) -> assets + /v4/violations/count (prefix=/v4/violations) -> count + """ + # Strip common prefix + relative = path[len(common_prefix) :] if path.startswith(common_prefix) else path + relative = relative.strip('/') + + # Remove path parameter segments and empty parts + parts = [p for p in relative.split('/') if p and not p.startswith('{')] + + if not parts: + return 'view' if has_path_params else 'list' + + # Join remaining segments with hyphens, normalize to kebab-case + return re.sub(r'[^a-z0-9]+', '-', '-'.join(parts).lower()).strip('-') + + +def _param_to_option_name(name: str) -> str: + """Convert an OpenAPI parameter name to a CLI option name. + + 'page_size' -> '--page-size' + 'pageSize' -> '--page-size' + 'filter.status' -> '--filter-status' + """ + s = re.sub(r'([a-z])([A-Z])', r'\1-\2', name) + # Replace any non-alphanumeric characters with hyphens + s = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-') + return f'--{s}' + + +def _make_api_request( + endpoint_path: str, + method: str, + path_params: dict[str, str], + query_params: dict[str, Any], + client_id: Optional[str] = None, + client_secret: Optional[str] = None, +) -> dict: + """Execute an API request using the CLI's standard auth client.""" + from urllib.parse import quote + + from cycode.cli.apps.api.openapi_spec import resolve_credentials + from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient + + cid, csecret = resolve_credentials(client_id, client_secret) + client = CycodeTokenBasedClient(cid, csecret) + + # Substitute path parameters (URL-encoded to prevent path traversal) + url_path = endpoint_path + for param_name, param_value in path_params.items(): + url_path = url_path.replace(f'{{{param_name}}}', quote(str(param_value), safe='')) + + filtered_query = {k: v for k, v in query_params.items() if v is not None} + + response = client.get(url_path.lstrip('/'), params=filtered_query) + return response.json() + + +def build_api_command_groups( + client_id: Optional[str] = None, + client_secret: Optional[str] = None, +) -> list[tuple[click.Group, str]]: + """Build Click command groups from the OpenAPI spec. + + Returns a list of (click_group, command_name) tuples. + """ + try: + spec = get_openapi_spec(client_id, client_secret) + except OpenAPISpecError as e: + logger.warning('Could not load OpenAPI spec: %s', e) + return [] + + groups = parse_spec_commands(spec) + result = [] + + for tag, endpoints in groups.items(): + tag_name = _normalize_tag(tag) + + group = click.Group(name=tag_name, help=f'[BETA] {tag}') + + # Compute common prefix from all GET (non-deprecated) endpoint paths in this tag + get_endpoints = [ep for ep in endpoints if ep['method'] == 'get' and not ep.get('deprecated')] + if not get_endpoints: + continue + + clean_paths = [re.sub(r'/\{[^}]+\}', '', ep['path']) for ep in get_endpoints] + common_prefix = _find_common_prefix(clean_paths) + + used_names: dict[str, int] = {} + + for endpoint in get_endpoints: + has_path_params = bool(endpoint['path_params']) + cmd_name = _path_to_command_name(endpoint['path'], common_prefix, has_path_params) + + # Fix redundancy: if command name matches the tag name, use list/view + # e.g. "cycode groups groups" -> "cycode groups list" + if cmd_name == tag_name: + cmd_name = 'view' if has_path_params else 'list' + + # Handle duplicate names (e.g. deprecated + new endpoint for same resource) + if cmd_name in used_names: + used_names[cmd_name] += 1 + cmd_name = f'{cmd_name}-v{used_names[cmd_name]}' + else: + used_names[cmd_name] = 1 + + cmd = _build_endpoint_command(cmd_name, endpoint) + group.add_command(cmd, cmd_name) + + result.append((group, tag_name)) + + return result + + +def _build_click_params(endpoint: dict) -> list[click.Parameter]: + """Build Click parameters from OpenAPI endpoint definition.""" + params: list[click.Parameter] = [] + + # Path parameters -> required arguments + for p in endpoint['path_params']: + param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING) + params.append( + click.Argument( + [p['name'].replace('-', '_')], + type=param_type, + required=True, + ) + ) + + # Query parameters -> --option flags + for p in endpoint['query_params']: + param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING) + option_name = _param_to_option_name(p['name']) + required = p.get('required', False) + default = p.get('schema', {}).get('default') + + schema = p.get('schema', {}) + if 'enum' in schema: + param_type = click.Choice(schema['enum']) + + params.append( + click.Option( + [option_name], + type=param_type, + required=required, + default=default, + help=p.get('description', ''), + show_default=default is not None, + ) + ) + + return params + + +def _build_endpoint_command(cmd_name: str, endpoint: dict) -> click.Command: + """Build a Click command for an API endpoint. + + Path parameters become required CLI arguments. + Query parameters become --option flags with proper types. + """ + ep_path = endpoint['path'] + ep_method = endpoint['method'] + ep_path_params = list(endpoint['path_params']) + ep_query_params = list(endpoint['query_params']) + ep_description = endpoint['description'] or endpoint['summary'] + + # Build a mapping from Click's normalized kwarg name to original OpenAPI param name + _path_param_map = {p['name'].replace('-', '_').lower(): p['name'] for p in ep_path_params} + _query_param_map = {re.sub(r'[^a-z0-9]+', '_', p['name'].lower()).strip('_'): p['name'] for p in ep_query_params} + + def _callback(**kwargs: Any) -> None: + ctx = click.get_current_context() + + # Extract path param values using the mapping + path_values = {} + for kwarg_key, original_name in _path_param_map.items(): + if kwarg_key in kwargs and kwargs[kwarg_key] is not None: + path_values[original_name] = kwargs[kwarg_key] + + # Extract query param values (skip None) + query_values = {} + for kwarg_key, original_name in _query_param_map.items(): + value = kwargs.get(kwarg_key) + if value is not None: + query_values[original_name] = value + + # Get auth from root context (set by app_callback) + root_ctx = ctx.find_root() + client_id = root_ctx.obj.get('client_id') if root_ctx.obj else None + client_secret = root_ctx.obj.get('client_secret') if root_ctx.obj else None + + try: + result = _make_api_request( + ep_path, + ep_method, + path_values, + query_values, + client_id=client_id, + client_secret=client_secret, + ) + except Exception as e: + click.echo(f'Error: {e}', err=True) + raise click.Abort from e + + click.echo(json.dumps(result, indent=2)) + + return click.Command( + name=cmd_name, + callback=_callback, + help=ep_description, + short_help=endpoint['summary'], + params=_build_click_params(endpoint), + ) diff --git a/cycode/cli/apps/api/openapi_spec.py b/cycode/cli/apps/api/openapi_spec.py new file mode 100644 index 00000000..74ffdb69 --- /dev/null +++ b/cycode/cli/apps/api/openapi_spec.py @@ -0,0 +1,182 @@ +"""OpenAPI spec manager: fetch, cache, and parse the Cycode API v4 spec.""" + +import json +import os +import time +from pathlib import Path +from typing import Optional + +from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY +from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cyclient import config as cyclient_config +from cycode.logger import get_logger + +logger = get_logger('OpenAPI Spec') + +_CACHE_DIR = Path.home() / CYCODE_CONFIGURATION_DIRECTORY +_CACHE_FILE = _CACHE_DIR / 'openapi-spec.json' +_CACHE_TTL_SECONDS = int(os.getenv('CYCODE_SPEC_CACHE_TTL', str(24 * 60 * 60))) # 24h default + +_OPENAPI_SPEC_PATH = '/v4/api-docs/cycode-api-swagger.json' + + +def get_openapi_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict: + """Get the OpenAPI spec, using cache if fresh, otherwise fetching from API. + + The spec is only fetched when the user actually invokes `cycode platform ...`. + Fetch uses the HTTP client's default timeout; on a slow connection the first + invocation will block accordingly. Once cached, subsequent invocations within + the TTL are near-instant. + + Args: + client_id: Optional client ID override (from CLI flags). + client_secret: Optional client secret override (from CLI flags). + + Returns: + Parsed OpenAPI specification dictionary. + + Raises: + OpenAPISpecError: If spec cannot be loaded from cache or API. + """ + cached = _load_cached_spec() + if cached is not None: + return cached + + return _fetch_and_cache_spec(client_id, client_secret) + + +def _load_cached_spec() -> Optional[dict]: + """Load spec from local cache if it exists and is fresh.""" + if not _CACHE_FILE.exists(): + return None + + try: + mtime = _CACHE_FILE.stat().st_mtime + if time.time() - mtime > _CACHE_TTL_SECONDS: + logger.debug('Cached OpenAPI spec is stale (age > %ds)', _CACHE_TTL_SECONDS) + return None + + spec = json.loads(_CACHE_FILE.read_text(encoding='utf-8')) + logger.debug('Using cached OpenAPI spec from %s', _CACHE_FILE) + return spec + except Exception as e: + logger.warning('Failed to load cached OpenAPI spec: %s', e) + return None + + +def resolve_credentials(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> tuple[str, str]: + """Resolve credentials from args or the CLI's standard credential chain.""" + if not client_id or not client_secret: + credentials_manager = CredentialsManager() + cred_id, cred_secret = credentials_manager.get_credentials() + client_id = client_id or cred_id + client_secret = client_secret or cred_secret + + if not client_id or not client_secret: + raise OpenAPISpecError( + 'Cycode credentials not found. Run `cycode auth` first, ' + 'or set CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET environment variables.' + ) + + return client_id, client_secret + + +def _fetch_and_cache_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict: + """Fetch OpenAPI spec from API and cache to disk. + + Uses CycodeTokenBasedClient for auth and retries. The spec is served from the app URL, + so we create a client with app_url as base instead of the default api_url. + """ + from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient + + cid, csecret = resolve_credentials(client_id, client_secret) + + # The spec is served from app.cycode.com, but token refresh POSTs to api.cycode.com. + # Ensure the token is fresh BEFORE overriding the base URL so that refresh + # targets the correct host. + client = CycodeTokenBasedClient(cid, csecret) + client.get_access_token() + client.api_url = cyclient_config.cycode_app_url + + spec_path = _OPENAPI_SPEC_PATH.lstrip('/') + logger.info('Fetching OpenAPI spec from %s/%s', cyclient_config.cycode_app_url, spec_path) + + try: + response = client.get(spec_path) + spec = response.json() + except Exception as e: + raise OpenAPISpecError( + f'Failed to fetch OpenAPI spec. Check your authentication and network connectivity. Error: {e}' + ) from e + + if not isinstance(spec, dict) or 'paths' not in spec: + raise OpenAPISpecError('Response does not look like a valid OpenAPI spec (missing "paths" key).') + + # Override server URL with API URL (supports on-premise installations) + spec['servers'] = [{'url': cyclient_config.cycode_api_url}] + + # Cache to disk + _cache_spec(spec) + + return spec + + +def _cache_spec(spec: dict) -> None: + """Write spec to local cache file atomically (write to temp file, then rename).""" + try: + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + tmp_file = _CACHE_FILE.with_suffix('.json.tmp') + tmp_file.write_text(json.dumps(spec), encoding='utf-8') + tmp_file.replace(_CACHE_FILE) # atomic on POSIX and Windows + logger.debug('Cached OpenAPI spec to %s', _CACHE_FILE) + except Exception as e: + logger.warning('Failed to cache OpenAPI spec: %s', e) + + +def parse_spec_commands(spec: dict) -> dict[str, list[dict]]: + """Parse OpenAPI spec into resource groups with their endpoints. + + Groups endpoints by their first tag, returning a dict of: + {tag_name: [endpoint_info, ...]} + + Each endpoint_info contains: + - path: API path (e.g., '/v4/projects/{projectId}') + - method: HTTP method (e.g., 'get') + - summary: Human-readable summary + - description: Detailed description + - operation_id: Unique operation ID + - path_params: List of path parameter definitions + - query_params: List of query parameter definitions + """ + groups: dict[str, list[dict]] = {} + + for path, methods in spec.get('paths', {}).items(): + for method, details in methods.items(): + tags = details.get('tags', ['other']) + tag = tags[0] if tags else 'other' + + # Separate path and query parameters + parameters = details.get('parameters', []) + path_params = [p for p in parameters if p.get('in') == 'path'] + query_params = [p for p in parameters if p.get('in') == 'query'] + + endpoint_info = { + 'path': path, + 'method': method, + 'summary': details.get('summary', ''), + 'description': details.get('description', ''), + 'operation_id': details.get('operationId', ''), + 'path_params': path_params, + 'query_params': query_params, + 'deprecated': details.get('deprecated', False), + } + + if tag not in groups: + groups[tag] = [] + groups[tag].append(endpoint_info) + + return groups + + +class OpenAPISpecError(Exception): + """Raised when the OpenAPI spec cannot be loaded.""" diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index 817e0213..005e8c3e 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -1,10 +1,11 @@ import typer +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception from cycode.cli.logger import logger from cycode.cli.models import CliResult -from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cli.utils.get_api_client import get_scan_cycode_client def auth_command(ctx: typer.Context) -> None: @@ -16,7 +17,6 @@ def auth_command(ctx: typer.Context) -> None: * `cycode auth`: Start interactive authentication * `cycode auth --help`: View authentication options """ - add_breadcrumb('auth') printer = ctx.obj.get('console_printer') try: @@ -25,6 +25,12 @@ def auth_command(ctx: typer.Context) -> None: auth_manager = AuthManager() auth_manager.authenticate() + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + scan_client = get_scan_cycode_client(ctx) + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + result = CliResult(success=True, message='Successfully logged into cycode') printer.print_result(result) except Exception as err: diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py index a8759459..3c2f269b 100644 --- a/cycode/cli/apps/configure/configure_command.py +++ b/cycode/cli/apps/configure/configure_command.py @@ -1,7 +1,12 @@ from typing import Optional from cycode.cli.apps.configure.consts import CONFIGURATION_MANAGER, CREDENTIALS_MANAGER -from cycode.cli.apps.configure.messages import get_credentials_update_result_message, get_urls_update_result_message +from cycode.cli.apps.configure.messages import ( + get_credentials_environment_variables_override_warning, + get_credentials_update_result_message, + get_urls_environment_variables_override_warning, + get_urls_update_result_message, +) from cycode.cli.apps.configure.prompts import ( get_api_url_input, get_app_url_input, @@ -10,7 +15,6 @@ get_id_token_input, ) from cycode.cli.console import console -from cycode.cli.utils.sentry import add_breadcrumb def _should_update_value( @@ -39,8 +43,6 @@ def configure_command() -> None: * `cycode configure`: Start interactive configuration * `cycode configure --help`: View configuration options """ - add_breadcrumb('configure') - global_config_manager = CONFIGURATION_MANAGER.global_config_file_manager current_api_url = global_config_manager.get_api_url() @@ -76,3 +78,14 @@ def configure_command() -> None: console.print(get_urls_update_result_message()) if credentials_updated or oidc_credentials_updated: console.print(get_credentials_update_result_message()) + + # Warn about environment variables that override the configured file values, regardless of whether anything was + # updated. The env vars take precedence on every subsequent call, so configuring the file alone has no effect while + # they are set. + urls_override_warning = get_urls_environment_variables_override_warning() + if urls_override_warning: + console.print(f'[yellow]Warning:[/] {urls_override_warning}') + + credentials_override_warning = get_credentials_environment_variables_override_warning() + if credentials_override_warning: + console.print(f'[yellow]Warning:[/] {credentials_override_warning}') diff --git a/cycode/cli/apps/configure/messages.py b/cycode/cli/apps/configure/messages.py index 36ce807b..f008f09d 100644 --- a/cycode/cli/apps/configure/messages.py +++ b/cycode/cli/apps/configure/messages.py @@ -1,3 +1,5 @@ +from typing import Optional + from cycode.cli.apps.configure.consts import ( CONFIGURATION_MANAGER, CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE, @@ -14,11 +16,14 @@ def _are_credentials_exist_in_environment_variables() -> bool: def get_credentials_update_result_message() -> str: - success_message = CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=CREDENTIALS_MANAGER.get_filename()) + return CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=CREDENTIALS_MANAGER.get_filename()) + + +def get_credentials_environment_variables_override_warning() -> Optional[str]: if _are_credentials_exist_in_environment_variables(): - return f'{success_message}. {CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + return CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE - return success_message + return None def _are_urls_exist_in_environment_variables() -> bool: @@ -28,10 +33,13 @@ def _are_urls_exist_in_environment_variables() -> bool: def get_urls_update_result_message() -> str: - success_message = URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( + return URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( filename=CONFIGURATION_MANAGER.global_config_file_manager.get_filename() ) + + +def get_urls_environment_variables_override_warning() -> Optional[str]: if _are_urls_exist_in_environment_variables(): - return f'{success_message}. {URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + return URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE - return success_message + return None diff --git a/cycode/cli/apps/ignore/ignore_command.py b/cycode/cli/apps/ignore/ignore_command.py index 1183114a..c65197c3 100644 --- a/cycode/cli/apps/ignore/ignore_command.py +++ b/cycode/cli/apps/ignore/ignore_command.py @@ -9,7 +9,6 @@ from cycode.cli.config import configuration_manager from cycode.cli.logger import logger from cycode.cli.utils.path_utils import get_absolute_path, is_path_exists -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.string_utils import hash_string_to_sha256 _FILTER_BY_RICH_HELP_PANEL = 'Filter options' @@ -97,8 +96,6 @@ def ignore_command( # noqa: C901 * `cycode ignore --by-rule GUID`: Ignore rule with the specified GUID * `cycode ignore --by-package lodash@4.17.21`: Ignore lodash version 4.17.21 """ - add_breadcrumb('ignore') - all_by_values = [by_value, by_sha, by_path, by_rule, by_package, by_cve] if all(by is None for by in all_by_values): raise click.ClickException('Ignore by type is missing') diff --git a/cycode/cli/apps/mcp/mcp_command.py b/cycode/cli/apps/mcp/mcp_command.py index b9989ce2..517f514f 100644 --- a/cycode/cli/apps/mcp/mcp_command.py +++ b/cycode/cli/apps/mcp/mcp_command.py @@ -6,14 +6,14 @@ import sys import tempfile import uuid -from typing import Annotated, Any +from typing import Annotated, Any, Optional +import anyio import typer from pathvalidate import sanitize_filepath from pydantic import Field from cycode.cli.cli_types import McpTransportOption, ScanTypeOption -from cycode.cli.utils.sentry import add_breadcrumb from cycode.logger import LoggersManager, get_logger try: @@ -29,7 +29,25 @@ _DEFAULT_RUN_COMMAND_TIMEOUT = 10 * 60 -_FILES_TOOL_FIELD = Field(description='Files to scan, mapping file paths to their content') +_FILES_TOOL_FIELD = Field( + default=None, + description=( + 'Files to scan, mapping file paths to their content. ' + 'Provide either this or "paths". ' + 'Note: for large codebases, prefer "paths" to avoid token overhead.' + ), +) +_PATHS_TOOL_FIELD = Field( + default=None, + description=( + 'Paths to scan — file paths or directory paths that exist on disk. ' + 'Directories are scanned recursively. ' + 'Provide either this or "files". ' + 'Preferred over "files" when the files already exist on disk.' + ), +) + +_SEVERITY_ORDER = ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW') def _is_debug_mode() -> bool: @@ -48,6 +66,7 @@ def _get_current_executable() -> str: return 'cycode' +# ruff: disable[ASYNC109] async def _run_cycode_command(*args: str, timeout: int = _DEFAULT_RUN_COMMAND_TIMEOUT) -> dict[str, Any]: """Run a cycode command asynchronously and return the parsed result. @@ -92,6 +111,9 @@ async def _run_cycode_command(*args: str, timeout: int = _DEFAULT_RUN_COMMAND_TI return {'error': f'Failed to run command: {e!s}'} +# ruff: enable[ASYNC109] + + def _sanitize_file_path(file_path: str) -> str: """Sanitize file path to prevent path traversal and other security issues. @@ -164,9 +186,9 @@ def __exit__(self, *_) -> None: shutil.rmtree(self.temp_base_dir, ignore_errors=True) -async def _run_cycode_scan(scan_type: ScanTypeOption, temp_files: list[str]) -> dict[str, Any]: +async def _run_cycode_scan(scan_type: ScanTypeOption, paths: list[str]) -> dict[str, Any]: """Run cycode scan command and return the result.""" - return await _run_cycode_command(*['scan', '-t', str(scan_type), 'path', *temp_files]) + return await _run_cycode_command(*['scan', '-t', str(scan_type), 'path', *paths]) async def _run_cycode_status() -> dict[str, Any]: @@ -174,38 +196,89 @@ async def _run_cycode_status() -> dict[str, Any]: return await _run_cycode_command('status') -async def _cycode_scan_tool(scan_type: ScanTypeOption, files: dict[str, str] = _FILES_TOOL_FIELD) -> str: +def _build_scan_summary(result: dict[str, Any]) -> str: + """Build a human-readable summary line from a scan result dict. + + Args: + result: Parsed JSON scan result from the CLI. + + Returns: + A one-line summary string describing what was found. + """ + detections = result.get('detections', []) + errors = result.get('errors', []) + + if not detections: + if errors: + return f'Scan completed with {len(errors)} error(s) and no violations found.' + return 'No violations found.' + + total = len(detections) + severity_counts: dict[str, int] = {} + for d in detections: + sev = (d.get('severity') or 'UNKNOWN').upper() + severity_counts[sev] = severity_counts.get(sev, 0) + 1 + + parts = [f'{severity_counts[s]} {s}' for s in _SEVERITY_ORDER if s in severity_counts] + other_keys = [k for k in severity_counts if k not in _SEVERITY_ORDER] + parts += [f'{severity_counts[k]} {k}' for k in other_keys] + + label = 'violation' if total == 1 else 'violations' + return f'Cycode found {total} {label}: {", ".join(parts)}.' + + +async def _cycode_scan_tool( + scan_type: ScanTypeOption, + files: Optional[dict[str, str]] = None, + paths: Optional[list[str]] = None, +) -> str: _tool_call_id = _gen_random_id() _logger.info('Scan tool called, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) - if not files: - _logger.error('No files provided for scan') - return json.dumps({'error': 'No files provided'}) + if not files and not paths: + _logger.error('No files or paths provided for scan') + return json.dumps( + {'error': 'No files or paths provided. Pass file contents via "files" or disk paths via "paths".'} + ) try: - with _TempFilesManager(files, _tool_call_id) as temp_files: - original_count = len(files) - processed_count = len(temp_files) - - if processed_count < original_count: - _logger.warning( - 'Some files were rejected during sanitization, %s', - { - 'scan_type': scan_type, - 'original_count': original_count, - 'processed_count': processed_count, - 'call_id': _tool_call_id, - }, - ) + if paths: + missing = [p for p in paths if not await anyio.Path(p).exists()] + if missing: + return json.dumps({'error': f'Paths not found on disk: {missing}'}, indent=2) _logger.info( - 'Running Cycode scan, %s', - {'scan_type': scan_type, 'files_count': processed_count, 'call_id': _tool_call_id}, + 'Running Cycode scan (path-based), %s', + {'scan_type': scan_type, 'paths': paths, 'call_id': _tool_call_id}, ) - result = await _run_cycode_scan(scan_type, temp_files) + result = await _run_cycode_scan(scan_type, paths) + else: + with _TempFilesManager(files, _tool_call_id) as temp_files: + original_count = len(files) + processed_count = len(temp_files) + + if processed_count < original_count: + _logger.warning( + 'Some files were rejected during sanitization, %s', + { + 'scan_type': scan_type, + 'original_count': original_count, + 'processed_count': processed_count, + 'call_id': _tool_call_id, + }, + ) + + _logger.info( + 'Running Cycode scan (files-based), %s', + {'scan_type': scan_type, 'files_count': processed_count, 'call_id': _tool_call_id}, + ) + result = await _run_cycode_scan(scan_type, temp_files) + + if 'error' not in result: + result['summary'] = _build_scan_summary(result) - _logger.info('Scan completed, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) - return json.dumps(result, indent=2) + _logger.info('Scan completed, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) + return json.dumps(result, indent=2) except ValueError as e: _logger.error('Invalid input files, %s', {'scan_type': scan_type, 'call_id': _tool_call_id, 'error': str(e)}) return json.dumps({'error': f'Invalid input files: {e!s}'}, indent=2) @@ -214,8 +287,11 @@ async def _cycode_scan_tool(scan_type: ScanTypeOption, files: dict[str, str] = _ return json.dumps({'error': f'Scan failed: {e!s}'}, indent=2) -async def cycode_secret_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for hardcoded secrets. +async def cycode_secret_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for hardcoded secrets. Use this tool when you need to: - scan code for hardcoded secrets, API keys, passwords, tokens @@ -223,16 +299,20 @@ async def cycode_secret_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - detect potential security vulnerabilities from secret exposure Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results and any secrets found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.SECRET, files) + return await _cycode_scan_tool(ScanTypeOption.SECRET, files=files, paths=paths) -async def cycode_sca_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for Software Composition Analysis (SCA) - vulnerabilities and license issues. +async def cycode_sca_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for Software Composition Analysis (SCA) - vulnerabilities and license issues. Use this tool when you need to: - scan dependencies for known security vulnerabilities @@ -243,19 +323,24 @@ async def cycode_sca_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: Important: You must also include lock files (like package-lock.json, Pipfile.lock, etc.) to get accurate results. - You must provide manifest and lock files together. + When using "paths", pass the directory containing both manifest and lock files. + When using "files", provide both manifest and lock files together. Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results, vulnerabilities, and license issues found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.SCA, files) + return await _cycode_scan_tool(ScanTypeOption.SCA, files=files, paths=paths) -async def cycode_iac_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for Infrastructure as Code (IaC) misconfigurations. +async def cycode_iac_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for Infrastructure as Code (IaC) misconfigurations. Use this tool when you need to: - scan Terraform, CloudFormation, Kubernetes YAML files @@ -265,16 +350,20 @@ async def cycode_iac_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - review Docker files for security issues Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results and any misconfigurations found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.IAC, files) + return await _cycode_scan_tool(ScanTypeOption.IAC, files=files, paths=paths) -async def cycode_sast_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for Static Application Security Testing (SAST) - code quality and security flaws. +async def cycode_sast_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for Static Application Security Testing (SAST) - code quality and security flaws. Use this tool when you need to: - scan source code for security vulnerabilities @@ -284,12 +373,13 @@ async def cycode_sast_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - find SQL injection, XSS, and other application security issues Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results and any security flaws found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.SAST, files) + return await _cycode_scan_tool(ScanTypeOption.SAST, files=files, paths=paths) async def cycode_status() -> str: @@ -381,8 +471,6 @@ def mcp_command( cycode mcp # Start with default transport (stdio) cycode mcp -t sse -p 8080 # Start with Server-Sent Events (SSE) transport on port 8080 """ - add_breadcrumb('mcp') - try: _run_mcp_server(transport, host, port) except Exception as e: diff --git a/cycode/cli/apps/report/report_command.py b/cycode/cli/apps/report/report_command.py index 75debb33..ba19be1c 100644 --- a/cycode/cli/apps/report/report_command.py +++ b/cycode/cli/apps/report/report_command.py @@ -1,7 +1,6 @@ import typer from cycode.cli.utils.progress_bar import SBOM_REPORT_PROGRESS_BAR_SECTIONS, get_progress_bar -from cycode.cli.utils.sentry import add_breadcrumb def report_command(ctx: typer.Context) -> int: @@ -10,6 +9,5 @@ def report_command(ctx: typer.Context) -> int: Example usage: * `cycode report sbom`: Generate SBOM report """ - add_breadcrumb('report') ctx.obj['progress_bar'] = get_progress_bar(hidden=False, sections=SBOM_REPORT_PROGRESS_BAR_SECTIONS) return 1 diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index 61c9ddb7..5f0f625a 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -6,6 +6,13 @@ from cycode.cli import consts from cycode.cli.apps.report.sbom.common import create_sbom_report, send_report_feedback +from cycode.cli.apps.sca_options import ( + GradleAllSubProjectsOption, + MavenSettingsFileOption, + NoRestoreOption, + StopOnErrorOption, + apply_sca_restore_options_to_context, +) from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.files_collector.path_documents import get_relevant_documents from cycode.cli.files_collector.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed @@ -13,7 +20,6 @@ from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config -from cycode.cli.utils.sentry import add_breadcrumb def path_command( @@ -22,8 +28,12 @@ def path_command( Path, typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), ], + no_restore: NoRestoreOption = False, + gradle_all_sub_projects: GradleAllSubProjectsOption = False, + maven_settings_file: MavenSettingsFileOption = None, + stop_on_error: StopOnErrorOption = False, ) -> None: - add_breadcrumb('path') + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file, stop_on_error) client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] @@ -43,6 +53,7 @@ def path_command( consts.SCA_SCAN_TYPE, (str(path),), is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), + stop_on_error=stop_on_error, ) # TODO(MarshalX): combine perform_pre_scan_documents_actions with get_relevant_document. # unhardcode usage of context in perform_pre_scan_documents_actions diff --git a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py index 9e2f4885..2b208ea2 100644 --- a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py +++ b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py @@ -7,15 +7,16 @@ from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection -from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cli.utils.url_utils import sanitize_repository_url +from cycode.logger import get_logger + +logger = get_logger('Repository URL Command') def repository_url_command( ctx: typer.Context, uri: Annotated[str, typer.Argument(help='Repository URL to generate SBOM report for.', show_default=False)], ) -> None: - add_breadcrumb('repository_url') - progress_bar = ctx.obj['progress_bar'] progress_bar.start() progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) @@ -28,8 +29,13 @@ def repository_url_command( start_scan_time = time.time() report_execution_id = -1 + # Sanitize repository URL to remove any embedded credentials/tokens before sending to API + sanitized_uri = sanitize_repository_url(uri) + if sanitized_uri != uri: + logger.debug('Sanitized repository URL to remove credentials') + try: - report_execution = client.request_sbom_report_execution(report_parameters, repository_url=uri) + report_execution = client.request_sbom_report_execution(report_parameters, repository_url=sanitized_uri) report_execution_id = report_execution.id create_sbom_report(progress_bar, client, report_execution_id, output_file, output_format) diff --git a/cycode/cli/apps/report/sbom/sbom_command.py b/cycode/cli/apps/report/sbom/sbom_command.py index 06126dd0..4454a966 100644 --- a/cycode/cli/apps/report/sbom/sbom_command.py +++ b/cycode/cli/apps/report/sbom/sbom_command.py @@ -5,7 +5,6 @@ import typer from cycode.cli.cli_types import SbomFormatOption, SbomOutputFormatOption -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cyclient.report_client import ReportParameters _OUTPUT_RICH_HELP_PANEL = 'Output options' @@ -50,8 +49,6 @@ def sbom_command( ] = False, ) -> int: """Generate SBOM report.""" - add_breadcrumb('sbom') - sbom_format_parts = sbom_format.split('-') if len(sbom_format_parts) != 2: raise click.ClickException('Invalid SBOM format.') diff --git a/cycode/cli/apps/report_import/report_import_command.py b/cycode/cli/apps/report_import/report_import_command.py index 7f4e8844..3e346bbe 100644 --- a/cycode/cli/apps/report_import/report_import_command.py +++ b/cycode/cli/apps/report_import/report_import_command.py @@ -1,7 +1,5 @@ import typer -from cycode.cli.utils.sentry import add_breadcrumb - def report_import_command(ctx: typer.Context) -> int: """:bar_chart: [bold cyan]Import security reports.[/] @@ -9,5 +7,4 @@ def report_import_command(ctx: typer.Context) -> int: Example usage: * `cycode import sbom`: Import SBOM report """ - add_breadcrumb('import') return 1 diff --git a/cycode/cli/apps/report_import/sbom/sbom_command.py b/cycode/cli/apps/report_import/sbom/sbom_command.py index de9e85d4..b6b5dfeb 100644 --- a/cycode/cli/apps/report_import/sbom/sbom_command.py +++ b/cycode/cli/apps/report_import/sbom/sbom_command.py @@ -6,7 +6,6 @@ from cycode.cli.cli_types import BusinessImpactOption from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.utils.get_api_client import get_import_sbom_cycode_client -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cyclient.import_sbom_client import ImportSbomParameters @@ -52,8 +51,6 @@ def sbom_command( ] = BusinessImpactOption.MEDIUM, ) -> None: """Import SBOM.""" - add_breadcrumb('sbom') - client = get_import_sbom_cycode_client(ctx) import_parameters = ImportSbomParameters( diff --git a/cycode/cli/apps/sca_options.py b/cycode/cli/apps/sca_options.py new file mode 100644 index 00000000..01def411 --- /dev/null +++ b/cycode/cli/apps/sca_options.py @@ -0,0 +1,58 @@ +from pathlib import Path +from typing import Annotated, Optional + +import typer + +_SCA_RICH_HELP_PANEL = 'SCA options' + +NoRestoreOption = Annotated[ + bool, + typer.Option( + '--no-restore', + help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + +GradleAllSubProjectsOption = Annotated[ + bool, + typer.Option( + '--gradle-all-sub-projects', + help='When specified, Cycode will run gradle restore command for all sub projects. ' + 'Should run from root project directory [b]only[/]!', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + +MavenSettingsFileOption = Annotated[ + Optional[Path], + typer.Option( + '--maven-settings-file', + show_default=False, + help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', + dir_okay=False, + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + +StopOnErrorOption = Annotated[ + bool, + typer.Option( + '--stop-on-error', + help='When specified, stops the process if any file collection or restore failure occurs.', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + + +def apply_sca_restore_options_to_context( + ctx: typer.Context, + no_restore: bool, + gradle_all_sub_projects: bool, + maven_settings_file: Optional[Path], + stop_on_error: bool = False, +) -> None: + ctx.obj['no_restore'] = no_restore + ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects + ctx.obj['maven_settings_file'] = maven_settings_file + ctx.obj['stop_on_error'] = stop_on_error diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index d3e325f3..667138fa 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -3,6 +3,7 @@ from platform import platform from typing import TYPE_CHECKING, Callable, Optional +import requests import typer from cycode.cli import consts @@ -29,12 +30,15 @@ generate_unique_scan_id, is_cycodeignore_allowed_by_scan_config, set_issue_detected_by_scan_results, + should_use_presigned_upload, ) from cycode.cyclient.models import ZippedFileScanResult from cycode.logger import get_logger if TYPE_CHECKING: from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip + from cycode.cli.printers.console_printer import ConsolePrinter + from cycode.cli.utils.progress_bar import BaseProgressBar from cycode.cyclient.scan_client import ScanClient start_scan_time = time.time() @@ -43,6 +47,36 @@ logger = get_logger('Code Scanner') +class _UploadProgressAggregator: + """Aggregates upload progress across parallel batch uploads for display in the progress bar.""" + + def __init__(self, progress_bar: 'BaseProgressBar') -> None: + self._progress_bar = progress_bar + self._slots: list[list[int]] = [] + + def create_callback(self) -> Callable[[int, int], None]: + """Create a progress callback for one batch upload. Each batch gets its own slot.""" + slot = [0, 0] + self._slots.append(slot) + + def on_upload_progress(bytes_read: int, total_bytes: int) -> None: + slot[0] = bytes_read + slot[1] = total_bytes + + # Sum across all batch slots to show combined progress + total_read = sum(s[0] for s in self._slots) + total_size = sum(s[1] for s in self._slots) + + if total_read >= total_size: + self._progress_bar.update_right_side_label(None) + else: + mb_read = total_read / (1024 * 1024) + mb_total = total_size / (1024 * 1024) + self._progress_bar.update_right_side_label(f'Uploading {mb_read:.1f} / {mb_total:.1f} MB') + + return on_upload_progress + + def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: scan_type = ctx.obj['scan_type'] progress_bar = ctx.obj['progress_bar'] @@ -54,6 +88,7 @@ def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: scan_type, paths, is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), + stop_on_error=ctx.obj.get('stop_on_error', False), ) # Add entrypoint.cycode file at root path to mark the scan root (only for single path that is a directory) @@ -91,7 +126,7 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b if not sync_option and scan_type != consts.IAC_SCAN_TYPE: return False - if command_scan_type not in {'path', 'repository'}: + if command_scan_type not in {'path', 'repository', 'ai_guardrails'}: return False if scan_type == consts.IAC_SCAN_TYPE: @@ -106,13 +141,19 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b def _get_scan_documents_thread_func( - ctx: typer.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict + ctx: typer.Context, + is_git_diff: bool, + is_commit_range: bool, + scan_parameters: dict, ) -> Callable[[list[Document]], tuple[str, CliError, LocalScanResult]]: cycode_client = ctx.obj['client'] scan_type = ctx.obj['scan_type'] severity_threshold = ctx.obj['severity_threshold'] sync_option = ctx.obj['sync'] command_scan_type = ctx.info_name + progress_bar = ctx.obj['progress_bar'] + + aggregator = _UploadProgressAggregator(progress_bar) def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, LocalScanResult]: local_scan_result = error = error_message = None @@ -135,6 +176,7 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local is_commit_range, scan_parameters, should_use_sync_flow, + on_upload_progress=aggregator.create_callback(), ) enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) @@ -162,24 +204,57 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local 'zip_file_size': zip_file_size, }, ) - report_scan_status( - cycode_client, - scan_type, - scan_id, - scan_completed, - relevant_detections_count, - detections_count, - len(batch), - zip_file_size, - command_scan_type, - error_message, - ) + # Sync flows already received the full result inline; only async flows + # need a separate status report to signal polling completion. + if not should_use_sync_flow: + report_scan_status( + cycode_client, + scan_type, + scan_id, + scan_completed, + relevant_detections_count, + detections_count, + len(batch), + zip_file_size, + command_scan_type, + error_message, + ) return scan_id, error, local_scan_result return _scan_batch_thread_func +def _run_presigned_upload_scan( + scan_batch_thread_func: Callable, + scan_type: str, + documents_to_scan: list[Document], + progress_bar: 'BaseProgressBar', + printer: 'ConsolePrinter', +) -> tuple: + try: + # Try to zip all documents as a single batch; ZipTooLargeError raised if it exceeds the scan type's limit + zip_documents(scan_type, documents_to_scan) + # It fits: skip batching and upload everything as one ZIP + return run_parallel_batched_scan( + scan_batch_thread_func, + scan_type, + documents_to_scan, + progress_bar=progress_bar, + skip_batching=True, + ) + except custom_exceptions.ZipTooLargeError: + printer.print_warning( + 'The scan is too large to upload as a single file. This may result in corrupted scan results.' + ) + return run_parallel_batched_scan( + scan_batch_thread_func, + scan_type, + documents_to_scan, + progress_bar=progress_bar, + ) + + def scan_documents( ctx: typer.Context, documents_to_scan: list[Document], @@ -203,9 +278,18 @@ def scan_documents( return scan_batch_thread_func = _get_scan_documents_thread_func(ctx, is_git_diff, is_commit_range, scan_parameters) - errors, local_scan_results = run_parallel_batched_scan( - scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar - ) + + # Presigned single-file upload is async-only; a --sync scan must stay on the batched inline path + # so it never builds one oversized zip to POST synchronously. + should_use_sync_flow = _should_use_sync_flow(ctx.info_name, scan_type, ctx.obj['sync']) + if should_use_presigned_upload(scan_type) and not should_use_sync_flow: + errors, local_scan_results = _run_presigned_upload_scan( + scan_batch_thread_func, scan_type, documents_to_scan, progress_bar, printer + ) + else: + errors, local_scan_results = run_parallel_batched_scan( + scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar + ) try_set_aggregation_report_url_if_needed(ctx, scan_parameters, ctx.obj['client'], scan_type) @@ -217,15 +301,48 @@ def scan_documents( print_local_scan_results(ctx, local_scan_results, errors) +def _perform_scan_v4_async( + cycode_client: 'ScanClient', + zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, + is_git_diff: bool, + is_commit_range: bool, + on_upload_progress: Optional[Callable] = None, +) -> ZippedFileScanResult: + upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got upload link, %s', {'upload_id': upload_link.upload_id}) + + cycode_client.upload_to_presigned_post( + upload_link.url, upload_link.presigned_post_fields, zipped_documents, on_upload_progress + ) + logger.debug('Uploaded zip to presigned URL') + + scan_async_result = cycode_client.scan_repository_from_upload_id( + scan_type, upload_link.upload_id, zipped_documents, scan_parameters, is_git_diff, is_commit_range + ) + logger.debug( + 'Presigned upload scan request triggered, %s', + {'scan_id': scan_async_result.scan_id, 'upload_id': upload_link.upload_id}, + ) + + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters) + + def _perform_scan_async( cycode_client: 'ScanClient', zipped_documents: 'InMemoryZip', scan_type: str, scan_parameters: dict, is_commit_range: bool, + on_upload_progress: Optional[Callable] = None, ) -> ZippedFileScanResult: scan_async_result = cycode_client.zipped_file_scan_async( - zipped_documents, scan_type, scan_parameters, is_commit_range=is_commit_range + zipped_documents, + scan_type, + scan_parameters, + is_commit_range=is_commit_range, + on_upload_progress=on_upload_progress, ) logger.debug('Async scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id}) @@ -257,12 +374,33 @@ def _perform_scan( is_commit_range: bool, scan_parameters: dict, should_use_sync_flow: bool = False, + on_upload_progress: Optional[Callable] = None, ) -> ZippedFileScanResult: if should_use_sync_flow: # it does not support commit range scans; should_use_sync_flow handles it return _perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) - return _perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) + if should_use_presigned_upload(scan_type): + try: + return _perform_scan_v4_async( + cycode_client, + zipped_documents, + scan_type, + scan_parameters, + is_git_diff, + is_commit_range, + on_upload_progress, + ) + except ( + requests.exceptions.RequestException, + custom_exceptions.RequestError, + custom_exceptions.SlowUploadConnectionError, + ): + logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') + + return _perform_scan_async( + cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range, on_upload_progress + ) def poll_scan_results( diff --git a/cycode/cli/apps/scan/commit_history/commit_history_command.py b/cycode/cli/apps/scan/commit_history/commit_history_command.py index 5935cf59..46d911e8 100644 --- a/cycode/cli/apps/scan/commit_history/commit_history_command.py +++ b/cycode/cli/apps/scan/commit_history/commit_history_command.py @@ -6,7 +6,6 @@ from cycode.cli.apps.scan.commit_range_scanner import scan_commit_range from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.logger import logger -from cycode.cli.utils.sentry import add_breadcrumb def commit_history_command( @@ -25,8 +24,6 @@ def commit_history_command( ] = '--all', ) -> None: try: - add_breadcrumb('commit_history') - logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) scan_commit_range(ctx, repo_path=str(path), commit_range=commit_range) except Exception as e: diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 85497d5f..70b7e8e4 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Optional import click +import requests import typer from cycode.cli import consts @@ -18,6 +19,7 @@ print_local_scan_results, ) from cycode.cli.config import configuration_manager +from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.files_collector.commit_range_documents import ( collect_commit_range_diff_documents, @@ -25,7 +27,7 @@ get_diff_file_content, get_diff_file_path, get_pre_commit_modified_documents, - get_safe_head_reference_for_diff, + get_staged_diff_index, parse_commit_range, ) from cycode.cli.files_collector.documents_walk_ignore import filter_documents_with_cycodeignore @@ -44,6 +46,7 @@ generate_unique_scan_id, is_cycodeignore_allowed_by_scan_config, set_issue_detected_by_scan_results, + should_use_presigned_upload, ) from cycode.cyclient.models import ZippedFileScanResult from cycode.logger import get_logger @@ -86,6 +89,38 @@ def _perform_commit_range_scan_async( return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) +def _perform_commit_range_scan_v4_async( + cycode_client: 'ScanClient', + from_commit_zipped_documents: 'InMemoryZip', + to_commit_zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, + timeout: Optional[int] = None, +) -> ZippedFileScanResult: + from_upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got from-commit upload link, %s', {'upload_id': from_upload_link.upload_id}) + + cycode_client.upload_to_presigned_post( + from_upload_link.url, from_upload_link.presigned_post_fields, from_commit_zipped_documents + ) + logger.debug('Uploaded from-commit zip') + + to_upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got to-commit upload link, %s', {'upload_id': to_upload_link.upload_id}) + + cycode_client.upload_to_presigned_post( + to_upload_link.url, to_upload_link.presigned_post_fields, to_commit_zipped_documents + ) + logger.debug('Uploaded to-commit zip') + + scan_async_result = cycode_client.commit_range_scan_from_upload_ids( + scan_type, from_upload_link.upload_id, to_upload_link.upload_id, from_commit_zipped_documents, scan_parameters + ) + logger.debug('V4 commit range scan request triggered, %s', {'scan_id': scan_async_result.scan_id}) + + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) + + def _scan_commit_range_documents( ctx: typer.Context, from_documents_to_scan: list[Document], @@ -118,14 +153,39 @@ def _scan_commit_range_documents( # for SAST it is files with diff between from_commit and to_commit to_commit_zipped_documents = zip_documents(scan_type, to_documents_to_scan) - scan_result = _perform_commit_range_scan_async( - cycode_client, - from_commit_zipped_documents, - to_commit_zipped_documents, - scan_type, - scan_parameters, - timeout, - ) + if should_use_presigned_upload(scan_type): + try: + scan_result = _perform_commit_range_scan_v4_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) + except ( + requests.exceptions.RequestException, + custom_exceptions.RequestError, + custom_exceptions.SlowUploadConnectionError, + ): + logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') + scan_result = _perform_commit_range_scan_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) + else: + scan_result = _perform_commit_range_scan_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) progress_bar.update(ScanProgressBarSection.SCAN) @@ -300,8 +360,7 @@ def _scan_sca_pre_commit(ctx: typer.Context, repo_path: str) -> None: def _scan_secret_pre_commit(ctx: typer.Context, repo_path: str) -> None: progress_bar = ctx.obj['progress_bar'] repo = git_proxy.get_repo(repo_path) - head_reference = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_reference, create_patch=True, R=True) + _, diff_index = get_staged_diff_index(repo) progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_index)) diff --git a/cycode/cli/apps/scan/path/path_command.py b/cycode/cli/apps/scan/path/path_command.py index 3ee87350..6b2beab5 100644 --- a/cycode/cli/apps/scan/path/path_command.py +++ b/cycode/cli/apps/scan/path/path_command.py @@ -5,7 +5,6 @@ from cycode.cli.apps.scan.code_scanner import scan_disk_files from cycode.cli.logger import logger -from cycode.cli.utils.sentry import add_breadcrumb def path_command( @@ -14,8 +13,6 @@ def path_command( list[Path], typer.Argument(exists=True, resolve_path=True, help='Paths to scan', show_default=False) ], ) -> None: - add_breadcrumb('path') - progress_bar = ctx.obj['progress_bar'] progress_bar.start() diff --git a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py index 5693412f..e0cbc7a8 100644 --- a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py @@ -4,15 +4,12 @@ import typer from cycode.cli.apps.scan.commit_range_scanner import scan_pre_commit -from cycode.cli.utils.sentry import add_breadcrumb def pre_commit_command( ctx: typer.Context, _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: - add_breadcrumb('pre_commit') - repo_path = os.getcwd() # change locally for easy testing progress_bar = ctx.obj['progress_bar'] diff --git a/cycode/cli/apps/scan/pre_push/pre_push_command.py b/cycode/cli/apps/scan/pre_push/pre_push_command.py index 868ab62e..729f3571 100644 --- a/cycode/cli/apps/scan/pre_push/pre_push_command.py +++ b/cycode/cli/apps/scan/pre_push/pre_push_command.py @@ -19,7 +19,6 @@ ) from cycode.cli.logger import logger from cycode.cli.utils import scan_utils -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter from cycode.logger import set_logging_level @@ -29,8 +28,6 @@ def pre_push_command( _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: try: - add_breadcrumb('pre_push') - if should_skip_pre_receive_scan(): logger.info( 'A scan has been skipped as per your request. ' @@ -47,6 +44,10 @@ def pre_push_command( timeout = configuration_manager.get_pre_push_command_timeout(command_scan_type) with TimeoutAfter(timeout): push_update_details = parse_pre_push_input() + if not push_update_details: + logger.info('No pre-push input found, nothing to scan') + return + commit_range = calculate_pre_push_commit_range(push_update_details) if not commit_range: logger.info( diff --git a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py index f6265fd2..70abd4aa 100644 --- a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py @@ -19,7 +19,6 @@ ) from cycode.cli.logger import logger from cycode.cli.utils import scan_utils -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter from cycode.logger import set_logging_level @@ -29,8 +28,6 @@ def pre_receive_command( _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: try: - add_breadcrumb('pre_receive') - if should_skip_pre_receive_scan(): logger.info( 'A scan has been skipped as per your request. ' diff --git a/cycode/cli/apps/scan/remote_url_resolver.py b/cycode/cli/apps/scan/remote_url_resolver.py index 967e6ea0..870115e2 100644 --- a/cycode/cli/apps/scan/remote_url_resolver.py +++ b/cycode/cli/apps/scan/remote_url_resolver.py @@ -3,6 +3,7 @@ from cycode.cli import consts from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.shell_executor import shell +from cycode.cli.utils.url_utils import sanitize_repository_url from cycode.logger import get_logger logger = get_logger('Remote URL Resolver') @@ -102,7 +103,11 @@ def _try_get_git_remote_url(path: str) -> Optional[str]: repo = git_proxy.get_repo(path, search_parent_directories=True) remote_url = repo.remotes[0].config_reader.get('url') logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'repo_path': repo.working_dir}) - return remote_url + # Sanitize URL to remove any embedded credentials/tokens before returning + sanitized_url = sanitize_repository_url(remote_url) + if sanitized_url != remote_url: + logger.debug('Sanitized repository URL to remove credentials') + return sanitized_url except Exception as e: logger.debug('Failed to get Git remote URL. Probably not a Git repository', exc_info=e) return None @@ -124,7 +129,9 @@ def get_remote_url_scan_parameter(paths: tuple[str, ...]) -> Optional[str]: # - len(paths)*2 Plastic SCM subprocess calls remote_url = _try_get_any_remote_url(path) if remote_url: - remote_urls.add(remote_url) + # URLs are already sanitized in _try_get_git_remote_url, but sanitize again as safety measure + sanitized_url = sanitize_repository_url(remote_url) + remote_urls.add(sanitized_url) if len(remote_urls) == 1: # we are resolving remote_url only if all paths belong to the same repo (identical remote URLs), diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index f36c07e6..e32fec0d 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -17,7 +17,6 @@ from cycode.cli.utils.path_utils import get_path_by_os from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config -from cycode.cli.utils.sentry import add_breadcrumb def repository_command( @@ -30,8 +29,6 @@ def repository_command( ] = None, ) -> None: try: - add_breadcrumb('repository') - logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) scan_type = ctx.obj['scan_type'] diff --git a/cycode/cli/apps/scan/scan_ci/scan_ci_command.py b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py index 4303cda2..7874a054 100644 --- a/cycode/cli/apps/scan/scan_ci/scan_ci_command.py +++ b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py @@ -5,7 +5,6 @@ from cycode.cli.apps.scan.commit_range_scanner import scan_commit_range from cycode.cli.apps.scan.scan_ci.ci_integrations import get_commit_range -from cycode.cli.utils.sentry import add_breadcrumb # This command is not finished yet. It is not used in the codebase. @@ -16,5 +15,4 @@ ) @click.pass_context def scan_ci_command(ctx: typer.Context) -> None: - add_breadcrumb('ci') scan_commit_range(ctx, repo_path=os.getcwd(), commit_range=get_commit_range()) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 2eb51f12..427f2d78 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,36 +5,65 @@ import click import typer +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation +from cycode.cli.apps.sca_options import ( + GradleAllSubProjectsOption, + MavenSettingsFileOption, + NoRestoreOption, + apply_sca_restore_options_to_context, +) from cycode.cli.apps.scan.remote_url_resolver import _try_get_git_remote_url from cycode.cli.cli_types import ExportTypeOption, ScanTypeOption, ScaScanTypeOption, SeverityOption from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, + SCAN_ERROR_STATUS_CODE, ) from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.utils import scan_utils from cycode.cli.utils.get_api_client import get_scan_cycode_client -from cycode.cli.utils.sentry import add_breadcrumb _EXPORT_RICH_HELP_PANEL = 'Export options' _SCA_RICH_HELP_PANEL = 'SCA options' _SECRET_RICH_HELP_PANEL = 'Secret options' +def _single_value_callback(ctx: typer.Context, param: typer.CallbackParam, value: list) -> list: + if len(value) > 1: + values_str = ', '.join(str(v) for v in value) + param_hint = '/'.join(sorted(param.opts, key=len)) + err = typer.BadParameter( + f'Only one value can be specified per command. Got: {values_str}. Run a separate command for each value.', + ctx=ctx, + param_hint=param_hint, + ) + err.exit_code = 1 + raise err + return value + + def scan_command( ctx: typer.Context, scan_type: Annotated[ - ScanTypeOption, + list[ScanTypeOption], typer.Option( '--scan-type', '-t', help='Specify the type of scan you wish to execute.', case_sensitive=False, + callback=_single_value_callback, ), - ] = ScanTypeOption.SECRET, + ] = (ScanTypeOption.SECRET,), soft_fail: Annotated[ bool, typer.Option('--soft-fail', help='Run the scan without failing; always return a non-error status code.') ] = False, + stop_on_error: Annotated[ + bool, + typer.Option( + '--stop-on-error', + help='When specified, stops the scan if any file collection or restore failure occurs.', + ), + ] = False, severity_threshold: Annotated[ SeverityOption, typer.Option( @@ -73,33 +102,9 @@ def scan_command( rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, - no_restore: Annotated[ - bool, - typer.Option( - '--no-restore', - help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!', - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = False, - gradle_all_sub_projects: Annotated[ - bool, - typer.Option( - '--gradle-all-sub-projects', - help='When specified, Cycode will run gradle restore command for all sub projects. ' - 'Should run from root project directory [b]only[/]!', - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = False, - maven_settings_file: Annotated[ - Optional[Path], - typer.Option( - '--maven-settings-file', - show_default=False, - help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', - dir_okay=False, - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = None, + no_restore: NoRestoreOption = False, + gradle_all_sub_projects: GradleAllSubProjectsOption = False, + maven_settings_file: MavenSettingsFileOption = None, export_type: Annotated[ ExportTypeOption, typer.Option( @@ -136,8 +141,6 @@ def scan_command( * `cycode scan commit-history `: Scan the commit history of a local Git repository. """ - add_breadcrumb('scan') - if export_file and export_type is None: raise typer.BadParameter( 'Export type must be specified when --export-file is provided.', @@ -149,20 +152,27 @@ def scan_command( param_hint='--export-file', ) + # _single_value_callback validated exactly one value was provided; unwrap from list + scan_type = scan_type[0] + ctx.obj['show_secret'] = show_secret ctx.obj['soft_fail'] = soft_fail + ctx.obj['stop_on_error'] = stop_on_error ctx.obj['scan_type'] = scan_type ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor - ctx.obj['maven_settings_file'] = maven_settings_file ctx.obj['report'] = report - ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects - ctx.obj['no_restore'] = no_restore + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file) scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + # Get remote URL from current working directory remote_url = _try_get_git_remote_url(os.getcwd()) @@ -186,7 +196,6 @@ def _sca_scan_to_context(ctx: typer.Context, sca_scan_user_selected: list[str]) @click.pass_context def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: - add_breadcrumb('scan_finalized') ctx.obj['scan_finalized'] = True progress_bar = ctx.obj.get('progress_bar') @@ -197,7 +206,9 @@ def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: raise typer.Exit(0) exit_code = NO_ISSUES_STATUS_CODE - if scan_utils.is_scan_failed(ctx): + if ctx.obj.get('did_fail') and ctx.obj.get('stop_on_error'): + exit_code = SCAN_ERROR_STATUS_CODE + elif scan_utils.is_scan_failed(ctx): exit_code = ISSUE_DETECTED_STATUS_CODE raise typer.Exit(exit_code) diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index 58754e86..f362d419 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -2,6 +2,7 @@ import typer +from cycode import _BOOT_WALL from cycode.cli.apps.scan.remote_url_resolver import get_remote_url_scan_parameter from cycode.cli.utils.scan_utils import generate_unique_scan_id from cycode.logger import get_logger @@ -17,6 +18,7 @@ def _get_default_scan_parameters(ctx: typer.Context) -> dict: 'license_compliance': ctx.obj.get('license-compliance'), 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility 'aggregation_id': str(generate_unique_scan_id()), + 'cli_start_time': _BOOT_WALL, } diff --git a/cycode/cli/apps/scan/scan_result.py b/cycode/cli/apps/scan/scan_result.py index 31a36368..9fb1da1d 100644 --- a/cycode/cli/apps/scan/scan_result.py +++ b/cycode/cli/apps/scan/scan_result.py @@ -88,7 +88,7 @@ def _get_file_name_from_detection(scan_type: str, raw_detection: dict) -> str: if scan_type == consts.SECRET_SCAN_TYPE: return _get_secret_file_name_from_detection(raw_detection) - return raw_detection['detection_details']['file_name'] + return raw_detection['detection_details']['file_path'] def _get_secret_file_name_from_detection(raw_detection: dict) -> str: @@ -189,6 +189,10 @@ def enrich_scan_result_with_data_from_detection_rules( for detection in detections_per_file.detections: detection_rule_ids.add(detection.detection_rule_id) + if not detection_rule_ids: + logger.debug('No detections to enrich, skipping detection_rules fetch') + return + detection_rules = cycode_client.get_detection_rules(detection_rule_ids) detection_rules_by_id = {detection_rule.detection_rule_id: detection_rule for detection_rule in detection_rules} diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index 0cf6e8fd..7018fa29 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from cycode import __version__ +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.status.models import CliStatus, CliSupportedModulesStatus from cycode.cli.consts import PROGRAM_NAME @@ -22,7 +23,11 @@ def get_cli_status(ctx: 'Context') -> CliStatus: supported_modules_status = CliSupportedModulesStatus() if is_authenticated: try: + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') client = get_scan_cycode_client(ctx) + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(client.scan_cycode_client, plugin_app_name, plugin_app_version) supported_modules_preferences = client.get_supported_modules_preferences() supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 63a1cb36..ed277cc6 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -46,6 +46,7 @@ class SbomFormatOption(StrEnum): SPDX_2_2 = 'spdx-2.2' SPDX_2_3 = 'spdx-2.3' CYCLONEDX_1_4 = 'cyclonedx-1.4' + CYCLONEDX_1_6 = 'cyclonedx-1.6' class SbomOutputFormatOption(StrEnum): @@ -86,6 +87,10 @@ def get_member_color(name: str) -> str: def get_member_emoji(name: str) -> str: return _SEVERITY_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_EMOJI) + @staticmethod + def get_member_unicode_emoji(name: str) -> str: + return _SEVERITY_UNICODE_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_UNICODE_EMOJI) + def __rich__(self) -> str: color = self.get_member_color(self.value) return f'[{color}]{self.value.upper()}[/]' @@ -117,3 +122,12 @@ def __rich__(self) -> str: SeverityOption.HIGH.value: ':red_circle:', SeverityOption.CRITICAL.value: ':exclamation_mark:', # double_exclamation_mark is not red } + +_SEVERITY_DEFAULT_UNICODE_EMOJI = '⚪' +_SEVERITY_UNICODE_EMOJIS = { + SeverityOption.INFO.value: '🔵', + SeverityOption.LOW.value: '🟡', + SeverityOption.MEDIUM.value: '🟠', + SeverityOption.HIGH.value: '🔴', + SeverityOption.CRITICAL.value: '❗', +} diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 0acd887e..9007fda9 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -53,6 +53,30 @@ '.iso', ) +# Fallback block-list used for SAST only when the server does not return scannable extensions +# (e.g. when the customer has custom rules, any text file is scannable). These are non-source +# data formats that can slip past binary detection (the EICAR test file and ClamAV signature +# databases are plain ASCII) and may be quarantined by object-storage antivirus after upload. +SAST_SCAN_FILE_EXTENSIONS_TO_IGNORE = ( + '.bin', + '.cvd', + '.cld', + '.cud', + '.hdb', + '.hsb', + '.mdb', + '.msb', + '.ndb', + '.ndu', + '.ldb', + '.ldu', + '.idb', + '.fp', + '.sfp', + '.ign', + '.ign2', +) + SCA_CONFIGURATION_SCAN_SUPPORTED_FILES = ( # keep in lowercase 'cargo.lock', 'cargo.toml', @@ -78,6 +102,7 @@ 'deno.lock', 'deno.json', 'pnpm-lock.yaml', + 'bun.lock', 'npm-shrinkwrap.json', 'packages.config', 'project.assets.json', @@ -91,7 +116,9 @@ 'build.scala', 'build.sbt.lock', 'pyproject.toml', + 'uv.lock', 'poetry.lock', + 'pylock.toml', 'pipfile', 'pipfile.lock', 'requirements.txt', @@ -124,6 +151,7 @@ '.build', '.dart_tool', '.pub', + '.uv', ) PROJECT_FILES_BY_ECOSYSTEM_MAP = { @@ -139,15 +167,18 @@ 'npm-shrinkwrap.json', '.npmrc', 'pnpm-lock.yaml', + 'bun.lock', 'deno.lock', 'deno.json', ], 'nuget': ['packages.config', 'project.assets.json', 'packages.lock.json', 'nuget.config'], 'ruby_gems': ['Gemfile', 'Gemfile.lock'], 'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'], + 'pypi_uv': ['pyproject.toml', 'uv.lock'], 'pypi_poetry': ['pyproject.toml', 'poetry.lock'], + 'pypi_pip': ['pyproject.toml', 'pylock.toml'], 'pypi_pipenv': ['Pipfile', 'Pipfile.lock'], - 'pypi_requirements': ['requirements.txt'], + 'pypi_requirements': ['requirements.txt', 'pylock.toml'], 'pypi_setup': ['setup.py'], 'hex': ['mix.exs', 'mix.lock'], 'swift_pm': ['Package.swift', 'Package.resolved'], @@ -192,15 +223,19 @@ # 5MB in bytes (in decimal) FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000 +PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB (S3 presigned POST limit) +PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE, SECRET_SCAN_TYPE} + DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20 * 1024 * 1024 ZIP_MAX_SIZE_LIMIT_IN_BYTES = { SCA_SCAN_TYPE: 200 * 1024 * 1024, - SAST_SCAN_TYPE: 50 * 1024 * 1024, + SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, + SECRET_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, } # scan in batches DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES = 9 * 1024 * 1024 -SCAN_BATCH_MAX_SIZE_IN_BYTES = {SAST_SCAN_TYPE: 50 * 1024 * 1024} +SCAN_BATCH_MAX_SIZE_IN_BYTES = {SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES} SCAN_BATCH_MAX_SIZE_IN_BYTES_ENV_VAR_NAME = 'SCAN_BATCH_MAX_SIZE_IN_BYTES' DEFAULT_SCAN_BATCH_MAX_FILES_COUNT = 1000 @@ -210,14 +245,6 @@ SCAN_BATCH_MAX_PARALLEL_SCANS = 5 SCAN_BATCH_SCANS_PER_CPU = 1 -# sentry -SENTRY_DSN = 'https://5e26b304b30ced3a34394b6f81f1076d@o1026942.ingest.us.sentry.io/4507543840096256' -SENTRY_DEBUG = False -SENTRY_SAMPLE_RATE = 1.0 -SENTRY_SEND_DEFAULT_PII = False -SENTRY_INCLUDE_LOCAL_VARIABLES = False -SENTRY_MAX_REQUEST_BODY_SIZE = 'never' - # sync scans SYNC_SCAN_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'SYNC_SCAN_TIMEOUT_IN_SECONDS' DEFAULT_SYNC_SCAN_TIMEOUT_IN_SECONDS = 180 @@ -282,6 +309,7 @@ ISSUE_DETECTED_STATUS_CODE = 1 NO_ISSUES_STATUS_CODE = 0 +SCAN_ERROR_STATUS_CODE = 2 LICENSE_COMPLIANCE_POLICY_ID = '8f681450-49e1-4f7e-85b7-0c8fe84b3a35' PACKAGE_VULNERABILITY_POLICY_ID = '9369d10a-9ac0-48d3-9921-5de7fe9a37a7' diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 59c0f693..4a874c1f 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -47,17 +47,19 @@ class ReportAsyncError(CycodeError): pass -class HttpUnauthorizedError(RequestError): +class HttpUnauthorizedError(RequestHttpError): def __init__(self, error_message: str, response: Response) -> None: - self.status_code = 401 - self.error_message = error_message - self.response = response - super().__init__(self.error_message) + super().__init__(401, error_message, response) def __str__(self) -> str: return f'HTTP unauthorized error occurred during the request. Message: {self.error_message}' +class SlowUploadConnectionError(CycodeError): + def __str__(self) -> str: + return 'Upload was interrupted mid-transfer, indicating a slow or unstable network connection.' + + class ZipTooLargeError(CycodeError): def __init__(self, size_limit: int) -> None: self.size_limit = size_limit @@ -67,6 +69,15 @@ def __str__(self) -> str: return f'The size of zip to scan is too large, size limit: {self.size_limit}' +class FileCollectionError(CycodeError): + def __init__(self, error_message: str) -> None: + self.error_message = error_message + super().__init__(self.error_message) + + def __str__(self) -> str: + return self.error_message + + class AuthProcessError(CycodeError): def __init__(self, error_message: str) -> None: self.error_message = error_message @@ -96,6 +107,12 @@ def __str__(self) -> str: code='timeout_error', message='The request timed out. Please try again by executing the `cycode scan` command', ), + SlowUploadConnectionError: CliError( + soft_fail=True, + code='slow_upload_error', + message='The scan upload was interrupted. This is likely due to a slow or unstable network connection. ' + 'Please try again by executing the `cycode scan` command', + ), HttpUnauthorizedError: CliError( soft_fail=True, code='auth_error', diff --git a/cycode/cli/exceptions/handle_errors.py b/cycode/cli/exceptions/handle_errors.py index 8d230902..ded1d88c 100644 --- a/cycode/cli/exceptions/handle_errors.py +++ b/cycode/cli/exceptions/handle_errors.py @@ -4,7 +4,6 @@ import typer from cycode.cli.models import CliError, CliErrors -from cycode.cli.utils.sentry import capture_exception def handle_errors( @@ -28,8 +27,6 @@ def handle_errors( if isinstance(err, click.ClickException): raise err - capture_exception(err) - unknown_error = CliError(code='unknown_error', message=str(err)) if return_exception: return unknown_error diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 229e0f02..56af186c 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -26,6 +26,12 @@ def handle_scan_exception(ctx: typer.Context, err: Exception, *, return_exceptio 'Please try ignoring irrelevant paths using the `cycode ignore --by-path` command ' 'and execute the scan again', ), + custom_exceptions.FileCollectionError: CliError( + soft_fail=False, + code='file_collection_error', + message='File collection failed. ' + 'Use --no-restore to skip dependency restoration, or fix the underlying issue.', + ), custom_exceptions.TfplanKeyError: CliError( soft_fail=True, code='key_error', diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index a4a1a784..2fb63581 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -15,7 +15,7 @@ from cycode.logger import get_logger if TYPE_CHECKING: - from git import Diff, Repo + from git import Diff, DiffIndex, Repo from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection @@ -47,6 +47,23 @@ def get_safe_head_reference_for_diff(repo: 'Repo') -> str: return consts.GIT_EMPTY_TREE_OBJECT +def get_staged_diff_index(repo: 'Repo') -> tuple[str, 'DiffIndex']: + """Diff the index against HEAD, or against the empty tree in repositories with no commits. + + GitPython only inverts the `R` flag for HEAD, so `R` must be off for the empty tree to keep + staged content showing up as added lines in both cases. + + Args: + repo: Git repository object + + Returns: + The reference that was diffed against, and the resulting diff index + """ + head_reference = get_safe_head_reference_for_diff(repo) + reverse = head_reference == consts.GIT_HEAD_COMMIT_REV + return head_reference, repo.index.diff(head_reference, create_patch=True, R=reverse) + + def _does_reach_to_max_commits_to_scan_limit(commit_ids: list[str], max_commits_count: Optional[int]) -> bool: if max_commits_count is None: return False @@ -228,7 +245,7 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] -def parse_pre_push_input() -> str: +def parse_pre_push_input() -> Optional[str]: """Parse input to pre-push hook details. Example input: @@ -237,13 +254,11 @@ def parse_pre_push_input() -> str: refs/heads/main 9cf90954ef26e7c58284f8ebf7dcd0fcf711152a refs/heads/main 973a96d3e925b65941f7c47fa16129f1577d499f refs/heads/feature-branch 3378e52dcfa47fb11ce3a4a520bea5f85d5d0bf3 refs/heads/feature-branch 59564ef68745bca38c42fc57a7822efd519a6bd9 - :return: First, push update details (input's first line) + :return: First push update details (input's first line), or None if no input was provided """ # noqa: E501 pre_push_input = _read_hook_input_from_stdin() if not pre_push_input: - raise ValueError( - 'Pre push input was not found. Make sure that you are using this command only in pre-push hook' - ) + return None # each line represents a branch push request, handle the first one only return pre_push_input.splitlines()[0] @@ -332,6 +347,15 @@ def calculate_pre_push_commit_range(push_update_details: str) -> Optional[str]: """ local_ref, local_object_name, remote_ref, remote_object_name = push_update_details.split() + # Tag pushes don't contain file diffs that need scanning + if local_ref.startswith('refs/tags/') or remote_ref.startswith('refs/tags/'): + logger.info('Skipping scan for tag push: %s -> %s', local_ref, remote_ref) + return None + + # If deleting a ref (local_object_name is all zeros), no need to scan + if local_object_name == consts.EMPTY_COMMIT_SHA: + return None + if remote_object_name == consts.EMPTY_COMMIT_SHA: try: repo = git_proxy.get_repo(os.getcwd()) @@ -356,10 +380,6 @@ def calculate_pre_push_commit_range(push_update_details: str) -> Optional[str]: logger.debug('Failed to get repo for pre-push commit range calculation: %s', exc_info=e) return consts.COMMIT_RANGE_ALL_COMMITS - # If deleting a branch (local_object_name is all zeros), no need to scan - if local_object_name == consts.EMPTY_COMMIT_SHA: - return None - # For updates to existing branches, scan from remote to local return f'{remote_object_name}..{local_object_name}' @@ -408,8 +428,7 @@ def get_pre_commit_modified_documents( diff_documents = [] repo = git_proxy.get_repo(repo_path) - head_reference = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_reference, create_patch=True, R=True) + head_reference, diff_index = get_staged_diff_index(repo) progress_bar.set_section_length(progress_bar_section, len(diff_index)) for diff in diff_index: progress_bar.update(progress_bar_section) diff --git a/cycode/cli/files_collector/file_excluder.py b/cycode/cli/files_collector/file_excluder.py index 11fd3410..066d7669 100644 --- a/cycode/cli/files_collector/file_excluder.py +++ b/cycode/cli/files_collector/file_excluder.py @@ -25,7 +25,7 @@ def _is_subpath_of_cycode_configuration_folder(filename: str) -> bool: ) -def _is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: +def is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: exclusions_by_path = configuration_manager.get_exclusions_by_scan_type(scan_type).get( consts.EXCLUSIONS_BY_PATH_SECTION_NAME, [] ) @@ -63,7 +63,10 @@ def __init__(self) -> None: } self._non_scannable_extensions: dict[str, tuple[str, ...]] = { consts.SECRET_SCAN_TYPE: consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE, + consts.SAST_SCAN_TYPE: consts.SAST_SCAN_FILE_EXTENSIONS_TO_IGNORE, } + # Tracks scan types for which the SAST fallback log has already been emitted (log once, not per file) + self._logged_sast_fallback = False def apply_scan_config(self, scan_type: str, scan_config: 'models.ScanConfiguration') -> None: if scan_config.scannable_extensions: @@ -86,6 +89,11 @@ def _is_file_extension_supported(self, scan_type: str, filename: str) -> bool: non_scannable_extensions = self._non_scannable_extensions.get(scan_type) if non_scannable_extensions: + # For SAST, reaching the block-list means the server returned no scannable extensions + # (e.g. custom rules, or no remote config). Log once so this is diagnosable. + if scan_type == consts.SAST_SCAN_TYPE and not self._logged_sast_fallback: + self._logged_sast_fallback = True + logger.debug('No scannable extensions provided for SAST; falling back to the built-in ignore list') return not filename.endswith(non_scannable_extensions) return True @@ -98,7 +106,7 @@ def _is_relevant_file_to_scan_common(self, scan_type: str, filename: str) -> boo ) return False - if _is_path_configured_in_exclusions(scan_type, filename): + if is_path_configured_in_exclusions(scan_type, filename): logger.debug( 'The document is irrelevant because its path is in the ignore paths list, %s', {'filename': filename} ) diff --git a/cycode/cli/files_collector/models/in_memory_zip.py b/cycode/cli/files_collector/models/in_memory_zip.py index 93ac4ac7..8bb9bf9e 100644 --- a/cycode/cli/files_collector/models/in_memory_zip.py +++ b/cycode/cli/files_collector/models/in_memory_zip.py @@ -26,7 +26,11 @@ def append(self, filename: str, unique_id: Optional[str], content: str) -> None: if unique_id: filename = concat_unique_id(filename, unique_id) - self.zip.writestr(filename, content) + # Encode content to bytes with error handling to handle surrogate characters + # that cannot be encoded to UTF-8. Use 'replace' to replace invalid characters + # with the Unicode replacement character (U+FFFD). + content_bytes = content.encode('utf-8', errors='replace') + self.zip.writestr(filename, content_bytes) def close(self) -> None: self.zip.close() diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 142c63bf..17f7dd41 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -2,6 +2,7 @@ from collections.abc import Generator from typing import TYPE_CHECKING +from cycode.cli.exceptions.custom_exceptions import FileCollectionError from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.files_collector.iac.tf_content_generator import ( generate_tf_content_from_tfplan, @@ -109,6 +110,7 @@ def get_relevant_documents( *, is_git_diff: bool = False, is_cycodeignore_allowed: bool = True, + stop_on_error: bool = False, ) -> list[Document]: relevant_files = _get_relevant_files( progress_bar, progress_bar_section, scan_type, paths, is_cycodeignore_allowed=is_cycodeignore_allowed @@ -119,6 +121,10 @@ def get_relevant_documents( progress_bar.update(progress_bar_section) content = get_file_content(file) + if content is None: + if stop_on_error: + raise FileCollectionError(f'Failed to read file: {file}') + continue if not content: continue diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 80ef4183..d5167e92 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -1,5 +1,5 @@ -import os from abc import ABC, abstractmethod +from pathlib import Path from typing import Optional import typer @@ -7,6 +7,9 @@ from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths from cycode.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('SCA Restore') def build_dep_tree_path(path: str, generated_file_name: str) -> str: @@ -19,11 +22,27 @@ def execute_commands( output_file_path: Optional[str] = None, working_directory: Optional[str] = None, ) -> Optional[str]: + logger.debug( + 'Executing restore commands, %s', + { + 'commands_count': len(commands), + 'timeout_sec': timeout, + 'working_directory': working_directory, + 'output_file_path': output_file_path, + }, + ) + + if not commands: + return None + try: outputs = [] for command in commands: command_output = shell(command=command, timeout=timeout, working_directory=working_directory) + if command_output is None: # shell returns None when the command exited non-zero + logger.debug('Restore command failed, %s', {'command': command}) + return None if command_output: outputs.append(command_output) @@ -32,7 +51,8 @@ def execute_commands( if output_file_path: with open(output_file_path, 'w', encoding='UTF-8') as output_file: output_file.writelines(joined_output) - except Exception: + except Exception as e: + logger.debug('Unexpected error during command execution', exc_info=e) return None return joined_output @@ -75,26 +95,70 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) if output is None: # one of the commands failed return None + file_was_generated = True + else: + file_was_generated = False + logger.debug( + 'Lock file already exists, skipping restore commands, %s', + {'restore_file_path': restore_file_path}, + ) restore_file_content = get_file_content(restore_file_path) + logger.debug( + 'Restore file loaded, %s', + { + 'restore_file_path': restore_file_path, + 'content_size': len(restore_file_content) if restore_file_content else 0, + 'content_empty': not restore_file_content, + }, + ) + + if file_was_generated: + try: + Path(restore_file_path).unlink(missing_ok=True) + logger.debug('Cleaned up generated restore file, %s', {'restore_file_path': restore_file_path}) + except Exception as e: + logger.debug('Failed to clean up generated restore file', exc_info=e) + return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) + def get_manifest_dir(self, document: Document) -> Optional[str]: + """Return the directory containing the manifest file, resolving monitor-mode paths. + + Uses the same path resolution as get_manifest_file_path() to ensure consistency. + Falls back to document.absolute_path when the resolved manifest path is ambiguous. + """ + manifest_file_path = self.get_manifest_file_path(document) + if manifest_file_path: + parent = Path(manifest_file_path).parent + # Skip '.' (no parent) and filesystem root (its own parent) + if parent != Path('.') and parent != parent.parent: + return str(parent) + + base = document.absolute_path or document.path + if base: + parent = Path(base).parent + if parent != Path('.') and parent != parent.parent: + return str(parent) + + return None + def get_working_directory(self, document: Document) -> Optional[str]: - return os.path.dirname(document.absolute_path) + return str(Path(document.absolute_path).parent) def get_restored_lock_file_name(self, restore_file_path: str) -> str: return self.get_lock_file_name() def get_any_restore_file_already_exist(self, document: Document, restore_file_paths: list[str]) -> str: for restore_file_path in restore_file_paths: - if os.path.isfile(restore_file_path): + if Path(restore_file_path).is_file(): return restore_file_path return build_dep_tree_path(document.absolute_path, self.get_lock_file_name()) @staticmethod def verify_restore_file_already_exist(restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) + return Path(restore_file_path).is_file() @abstractmethod def is_project(self, document: Document) -> bool: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 7c24e330..b98fbaf5 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -1,11 +1,13 @@ -import os +from pathlib import Path from typing import Optional import typer from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies -from cycode.cli.logger import logger from cycode.cli.models import Document +from cycode.logger import get_logger + +logger = get_logger('Go Restore Dependencies') GO_PROJECT_FILE_EXTENSIONS = ['.mod', '.sum'] GO_RESTORE_FILE_NAME = 'go.mod.graph' @@ -18,13 +20,13 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) def try_restore_dependencies(self, document: Document) -> Optional[Document]: - manifest_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_FILE_NAME) - lock_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_LOCK_FILE_NAME) + manifest_exists = (Path(self.get_working_directory(document)) / BUILD_GO_FILE_NAME).is_file() + lock_exists = (Path(self.get_working_directory(document)) / BUILD_GO_LOCK_FILE_NAME).is_file() if not manifest_exists or not lock_exists: logger.info('No manifest go.mod file found' if not manifest_exists else 'No manifest go.sum file found') - manifest_files_exists = manifest_exists & lock_exists + manifest_files_exists = manifest_exists and lock_exists if not manifest_files_exists: return None diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index d2687bf6..ea91a8de 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -1,4 +1,5 @@ import os +import platform import re from typing import Optional @@ -12,19 +13,32 @@ BUILD_GRADLE_FILE_NAME = 'build.gradle' BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' -BUILD_GRADLE_ALL_PROJECTS_COMMAND = ['gradle', 'projects'] ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'" +GRADLE_EXECUTABLE = 'gradle' +GRADLEW_FILE_NAME = 'gradlew' +GRADLEW_BAT_FILE_NAME = 'gradlew.bat' + class RestoreGradleDependencies(BaseRestoreDependencies): def __init__( self, ctx: typer.Context, is_git_diff: bool, command_timeout: int, projects: Optional[set[str]] = None ) -> None: super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) + self.gradle_executable = self._resolve_gradle_executable() if projects is None: projects = set() self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects + def _resolve_gradle_executable(self) -> str: + scan_root = get_path_from_context(self.ctx) + if scan_root: + wrapper_name = GRADLEW_BAT_FILE_NAME if platform.system() == 'Windows' else GRADLEW_FILE_NAME + wrapper_path = os.path.join(scan_root, wrapper_name) + if os.path.isfile(wrapper_path): + return wrapper_path + return GRADLE_EXECUTABLE + def is_gradle_sub_projects(self) -> bool: return self.ctx.obj.get('gradle_all_sub_projects', False) @@ -35,7 +49,7 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: return ( self.get_commands_for_sub_projects(manifest_file_path) if self.is_gradle_sub_projects() - else [['gradle', 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] + else [[self.gradle_executable, 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] ) def get_lock_file_name(self) -> str: @@ -49,7 +63,7 @@ def get_working_directory(self, document: Document) -> Optional[str]: def get_all_projects(self) -> set[str]: output = shell( - command=BUILD_GRADLE_ALL_PROJECTS_COMMAND, + command=[self.gradle_executable, 'projects'], timeout=self.command_timeout, working_directory=get_path_from_context(self.ctx), ) @@ -62,7 +76,7 @@ def get_commands_for_sub_projects(self, manifest_file_path: str) -> list[list[st project_name = os.path.basename(os.path.dirname(manifest_file_path)) project_name = f':{project_name}' return ( - [['gradle', f'{project_name}:dependencies', '-q', '--console', 'plain']] + [[self.gradle_executable, f'{project_name}:dependencies', '-q', '--console', 'plain']] if project_name in self.projects else [] ) diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 34499bdf..53ed269f 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -1,4 +1,6 @@ +import json from os import path +from pathlib import Path from typing import Optional import typer @@ -9,13 +11,26 @@ execute_commands, ) from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths +from cycode.cli.utils.path_utils import get_file_content, join_paths +from cycode.logger import get_logger + +logger = get_logger('Maven Restore Dependencies') BUILD_MAVEN_FILE_NAME = 'pom.xml' MAVEN_CYCLONE_DEP_TREE_FILE_NAME = 'bom.json' MAVEN_DEP_TREE_FILE_NAME = 'bcde.mvndeps' +def _has_dependency_graph(bom_content: Optional[str]) -> bool: + try: + if not bom_content: + return False + bom = json.loads(bom_content) + return any(dep.get('dependsOn') for dep in bom.get('dependencies', [])) + except Exception: + return False + + class RestoreMavenDependencies(BaseRestoreDependencies): def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(ctx, is_git_diff, command_timeout) @@ -46,9 +61,10 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: if restore_dependencies_document is None: return None - restore_dependencies_document.content = get_file_content( - join_paths(get_file_dir(manifest_file_path), self.get_lock_file_name()) - ) + if not _has_dependency_graph(restore_dependencies_document.content): + fallback = self.restore_from_secondary_command(document, manifest_file_path) + if fallback is not None and fallback.content is not None: + return fallback return restore_dependencies_document @@ -62,11 +78,17 @@ def restore_from_secondary_command(self, document: Document, manifest_file_path: return None restore_file_path = build_dep_tree_path(document.absolute_path, MAVEN_DEP_TREE_FILE_NAME) + content = get_file_content(restore_file_path) + + try: + Path(restore_file_path).unlink(missing_ok=True) + except Exception as e: + logger.debug('Failed to clean up generated maven dep tree file', exc_info=e) + return Document( path=build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), - content=get_file_content(restore_file_path), + content=content, is_git_diff_format=self.is_git_diff, - absolute_path=restore_file_path, ) def create_secondary_restore_commands(self, manifest_file_path: str) -> list[list[str]]: diff --git a/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py new file mode 100644 index 00000000..2bf0d647 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py @@ -0,0 +1,113 @@ +import json +import re +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('Bun Restore Dependencies') + +BUN_MANIFEST_FILE_NAME = 'package.json' +BUN_LOCK_FILE_NAME = 'bun.lock' + +# Only Bun >=1.2 produces the text-based `bun.lock` lockfile that we parse. +# Older Bun versions emit a binary `bun.lockb`, which is not supported. +MINIMUM_BUN_VERSION = (1, 2) +BUN_VERSION_COMMAND = ['bun', '--version'] + + +def _indicates_bun(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses Bun.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('bun'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'bun' in engines + + +def _parse_bun_version(raw_version: Optional[str]) -> Optional[tuple[int, int]]: + """Parse the (major, minor) version from `bun --version` output (e.g. '1.2.3').""" + if not raw_version: + return None + match = re.match(r'(\d+)\.(\d+)', raw_version.strip()) + if not match: + return None + return int(match.group(1)), int(match.group(2)) + + +class RestoreBunDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != BUN_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / BUN_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_bun(document.content) + + def _is_supported_bun_version(self) -> bool: + """Verify that the installed Bun is >=1.2, which is required to generate a text bun.lock.""" + raw_version = shell(command=BUN_VERSION_COMMAND, timeout=self.command_timeout, silent_exc_info=True) + version = _parse_bun_version(raw_version) + minimum = '.'.join(str(part) for part in MINIMUM_BUN_VERSION) + if version is None: + logger.warning( + 'Could not determine Bun version; Bun %s+ is required to restore Bun dependencies, %s', + minimum, + {'raw_version': raw_version}, + ) + return False + if version < MINIMUM_BUN_VERSION: + logger.warning( + 'Unsupported Bun version; Bun %s+ is required to restore Bun dependencies, %s', + minimum, + {'detected_version': '.'.join(str(part) for part in version)}, + ) + return False + return True + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / BUN_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running bun. + # A text bun.lock only exists when generated by Bun >=1.2, so no version check is needed here. + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, BUN_LOCK_FILE_NAME) + logger.debug('Using existing bun.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — must generate it via `bun install`. This requires Bun >=1.2, + # otherwise an older Bun would emit a binary bun.lockb that we cannot parse. + if not self._is_supported_bun_version(): + return None + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['bun', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return BUN_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [BUN_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py new file mode 100644 index 00000000..d3aeb5e5 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py @@ -0,0 +1,46 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Deno Restore Dependencies') + +DENO_MANIFEST_FILE_NAMES = ('deno.json', 'deno.jsonc') +DENO_LOCK_FILE_NAME = 'deno.lock' + + +class RestoreDenoDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name in DENO_MANIFEST_FILE_NAMES + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + if not manifest_dir: + return None + + lockfile_path = Path(manifest_dir) / DENO_LOCK_FILE_NAME + if not lockfile_path.is_file(): + logger.debug('No deno.lock found alongside deno.json, skipping deno restore, %s', {'path': document.path}) + return None + + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, DENO_LOCK_FILE_NAME) + logger.debug('Using existing deno.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [] + + def get_lock_file_name(self) -> str: + return DENO_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [DENO_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index 9f8c0b66..9416f58c 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -1,21 +1,17 @@ -import os -from typing import Optional +from pathlib import Path import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_content from cycode.logger import get_logger logger = get_logger('NPM Restore Dependencies') -NPM_PROJECT_FILE_EXTENSIONS = ['.json'] -NPM_LOCK_FILE_NAME = 'package-lock.json' -# Alternative lockfiles that should prevent npm install from running -ALTERNATIVE_LOCK_FILES = ['yarn.lock', 'pnpm-lock.yaml', 'deno.lock'] -NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, *ALTERNATIVE_LOCK_FILES] NPM_MANIFEST_FILE_NAME = 'package.json' +NPM_LOCK_FILE_NAME = 'package-lock.json' +# These lockfiles indicate another package manager owns the project — NPM should not run +_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock', 'bun.lock') class RestoreNpmDependencies(BaseRestoreDependencies): @@ -23,128 +19,34 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) super().__init__(ctx, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: - return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) - - def _resolve_manifest_directory(self, document: Document) -> Optional[str]: - """Resolve the directory containing the manifest file. - - Uses the same path resolution logic as get_manifest_file_path() to ensure consistency. - Falls back to absolute_path or document.path if needed. - - Returns: - Directory path if resolved, None otherwise. - """ - manifest_file_path = self.get_manifest_file_path(document) - manifest_dir = os.path.dirname(manifest_file_path) if manifest_file_path else None - - # Fallback: if manifest_dir is empty or root, try using absolute_path or document.path - if not manifest_dir or manifest_dir == os.sep or manifest_dir == '.': - base_path = document.absolute_path if document.absolute_path else document.path - if base_path: - manifest_dir = os.path.dirname(base_path) - - return manifest_dir - - def _find_existing_lockfile(self, manifest_dir: str) -> tuple[Optional[str], list[str]]: - """Find the first existing lockfile in the manifest directory. - - Args: - manifest_dir: Directory to search for lockfiles. - - Returns: - Tuple of (lockfile_path if found, list of checked lockfiles with status). - """ - lock_file_paths = [os.path.join(manifest_dir, lock_file_name) for lock_file_name in NPM_LOCK_FILE_NAMES] - - existing_lock_file = None - checked_lockfiles = [] - for lock_file_path in lock_file_paths: - lock_file_name = os.path.basename(lock_file_path) - exists = os.path.isfile(lock_file_path) - checked_lockfiles.append(f'{lock_file_name}: {"exists" if exists else "not found"}') - if exists: - existing_lock_file = lock_file_path - break - - return existing_lock_file, checked_lockfiles - - def _create_document_from_lockfile(self, document: Document, lockfile_path: str) -> Optional[Document]: - """Create a Document from an existing lockfile. - - Args: - document: Original document (package.json). - lockfile_path: Path to the existing lockfile. - - Returns: - Document with lockfile content if successful, None otherwise. - """ - lock_file_name = os.path.basename(lockfile_path) - logger.info( - 'Skipping npm install: using existing lockfile, %s', - {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, - ) - - relative_restore_file_path = build_dep_tree_path(document.path, lock_file_name) - restore_file_content = get_file_content(lockfile_path) - - if restore_file_content is not None: - logger.debug( - 'Successfully loaded lockfile content, %s', - {'path': document.path, 'lockfile': lock_file_name, 'content_size': len(restore_file_content)}, - ) - return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) - - logger.warning( - 'Lockfile exists but could not read content, %s', - {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, - ) - return None - - def try_restore_dependencies(self, document: Document) -> Optional[Document]: - """Override to prevent npm install when any lockfile exists. - - The base class uses document.absolute_path which might be None or incorrect. - We need to use the same path resolution logic as get_manifest_file_path() - to ensure we check for lockfiles in the correct location. - - If any lockfile exists (package-lock.json, pnpm-lock.yaml, yarn.lock, deno.lock), - we use it directly without running npm install to avoid generating invalid lockfiles. + """Match only package.json files that are not managed by Yarn or pnpm. + + Yarn and pnpm projects are handled by their dedicated handlers, which run before + this one in the handler list. This handler is the npm fallback. + + NOTE: this guard only excludes a project when an alternative lockfile is *physically + present on disk*. It does not inspect the `packageManager`/`engines` signal in + package.json. So a project that declares e.g. `packageManager: "bun@..."` (or pnpm) + but has no lockfile yet is claimed by BOTH the dedicated handler and this npm fallback, + and both restores run. This is pre-existing behavior shared by pnpm/yarn/bun and is + accepted for now (a real Bun/pnpm project ships a lockfile, so npm correctly skips). + If this ever needs tightening, also skip here when package.json declares a non-npm + packageManager/engines signal. """ - # Check if this is a project file first (same as base class caller does) - if not self.is_project(document): - logger.debug('Skipping restore: document is not recognized as npm project, %s', {'path': document.path}) - return None - - # Resolve the manifest directory - manifest_dir = self._resolve_manifest_directory(document) - if not manifest_dir: - logger.debug( - 'Cannot determine manifest directory, proceeding with base class restore flow, %s', - {'path': document.path}, - ) - return super().try_restore_dependencies(document) + if Path(document.path).name != NPM_MANIFEST_FILE_NAME: + return False - # Check for existing lockfiles - logger.debug( - 'Checking for existing lockfiles in directory, %s', {'directory': manifest_dir, 'path': document.path} - ) - existing_lock_file, checked_lockfiles = self._find_existing_lockfile(manifest_dir) + manifest_dir = self.get_manifest_dir(document) + if manifest_dir: + for lock_file in _ALTERNATIVE_LOCK_FILES: + if (Path(manifest_dir) / lock_file).is_file(): + logger.debug( + 'Skipping npm restore: alternative lockfile detected, %s', + {'path': document.path, 'lockfile': lock_file}, + ) + return False - logger.debug( - 'Lockfile check results, %s', - {'path': document.path, 'checked_lockfiles': ', '.join(checked_lockfiles)}, - ) - - # If any lockfile exists, use it directly without running npm install - if existing_lock_file: - return self._create_document_from_lockfile(document, existing_lock_file) - - # No lockfile exists, proceed with the normal restore flow which will run npm install - logger.info( - 'No existing lockfile found, proceeding with npm install to generate package-lock.json, %s', - {'path': document.path, 'directory': manifest_dir, 'checked_lockfiles': ', '.join(checked_lockfiles)}, - ) - return super().try_restore_dependencies(document) + return True def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [ @@ -159,22 +61,16 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: ] ] - def get_restored_lock_file_name(self, restore_file_path: str) -> str: - return os.path.basename(restore_file_path) - def get_lock_file_name(self) -> str: return NPM_LOCK_FILE_NAME def get_lock_file_names(self) -> list[str]: - return NPM_LOCK_FILE_NAMES + return [NPM_LOCK_FILE_NAME] @staticmethod def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: - # Remove package.json from the path if manifest_file_path.endswith(NPM_MANIFEST_FILE_NAME): - # Use os.path.dirname to handle both Unix (/) and Windows (\) separators - # This is cross-platform and handles edge cases correctly - dir_path = os.path.dirname(manifest_file_path) - # If dir_path is empty or just '.', return an empty string (package.json in current dir) + parent = Path(manifest_file_path).parent + dir_path = str(parent) return dir_path if dir_path and dir_path != '.' else '' return manifest_file_path diff --git a/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py new file mode 100644 index 00000000..bce7eff6 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pnpm Restore Dependencies') + +PNPM_MANIFEST_FILE_NAME = 'package.json' +PNPM_LOCK_FILE_NAME = 'pnpm-lock.yaml' + + +def _indicates_pnpm(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses pnpm.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('pnpm'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'pnpm' in engines + + +class RestorePnpmDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != PNPM_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / PNPM_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_pnpm(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PNPM_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running pnpm + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PNPM_LOCK_FILE_NAME) + logger.debug('Using existing pnpm-lock.yaml, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but pnpm is indicated in package.json — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['pnpm', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return PNPM_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PNPM_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py new file mode 100644 index 00000000..79b0c4ec --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Yarn Restore Dependencies') + +YARN_MANIFEST_FILE_NAME = 'package.json' +YARN_LOCK_FILE_NAME = 'yarn.lock' + + +def _indicates_yarn(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses Yarn.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('yarn'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'yarn' in engines + + +class RestoreYarnDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != YARN_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / YARN_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_yarn(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / YARN_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running yarn + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, YARN_LOCK_FILE_NAME) + logger.debug('Using existing yarn.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but yarn is indicated in package.json — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['yarn', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return YARN_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [YARN_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index 95ced0ff..9bd01cd0 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -15,7 +15,17 @@ def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NUGET_PROJECT_FILE_EXTENSIONS) def get_commands(self, manifest_file_path: str) -> list[list[str]]: - return [['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet']] + return [ + [ + 'dotnet', + 'restore', + manifest_file_path, + '--use-lock-file', + '--verbosity', + 'quiet', + '--ignore-failed-sources', + ] + ] def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME diff --git a/cycode/cli/files_collector/sca/php/__init__.py b/cycode/cli/files_collector/sca/php/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py b/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py new file mode 100644 index 00000000..98b3564c --- /dev/null +++ b/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py @@ -0,0 +1,54 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Composer Restore Dependencies') + +COMPOSER_MANIFEST_FILE_NAME = 'composer.json' +COMPOSER_LOCK_FILE_NAME = 'composer.lock' + + +class RestoreComposerDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name == COMPOSER_MANIFEST_FILE_NAME + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / COMPOSER_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running composer + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, COMPOSER_LOCK_FILE_NAME) + logger.debug('Using existing composer.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [ + [ + 'composer', + 'update', + '--no-cache', + '--no-install', + '--no-scripts', + '--ignore-platform-reqs', + ] + ] + + def get_lock_file_name(self) -> str: + return COMPOSER_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [COMPOSER_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/__init__.py b/cycode/cli/files_collector/sca/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py b/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py new file mode 100644 index 00000000..29ebfc6e --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py @@ -0,0 +1,69 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pip Restore Dependencies') + +PIP_PYPROJECT_MANIFEST_FILE_NAME = 'pyproject.toml' +PIP_REQUIREMENTS_MANIFEST_FILE_NAME = 'requirements.txt' +PIP_LOCK_FILE_NAME = 'pylock.toml' + +_POETRY_TOOL_SECTION = '[tool.poetry]' +_UV_TOOL_SECTION = '[tool.uv]' + + +def _indicates_plain_pip(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals a plain-pip project (no Poetry, no uv).""" + if not pyproject_content: + return False + return _POETRY_TOOL_SECTION not in pyproject_content and _UV_TOOL_SECTION not in pyproject_content + + +class RestorePipDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + manifest_name = Path(document.path).name + + if manifest_name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME: + return True + + if manifest_name != PIP_PYPROJECT_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / PIP_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_plain_pip(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PIP_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PIP_LOCK_FILE_NAME) + logger.debug('Using existing pylock.toml, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + if Path(manifest_file_path).name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME: + return [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + return [['pip', 'lock', '.']] + + def get_lock_file_name(self) -> str: + return PIP_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PIP_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py b/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py new file mode 100644 index 00000000..df91707c --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pipenv Restore Dependencies') + +PIPENV_MANIFEST_FILE_NAME = 'Pipfile' +PIPENV_LOCK_FILE_NAME = 'Pipfile.lock' + + +class RestorePipenvDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name == PIPENV_MANIFEST_FILE_NAME + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PIPENV_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running pipenv + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PIPENV_LOCK_FILE_NAME) + logger.debug('Using existing Pipfile.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['pipenv', 'lock']] + + def get_lock_file_name(self) -> str: + return PIPENV_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PIPENV_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py b/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py new file mode 100644 index 00000000..f681bd63 --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py @@ -0,0 +1,62 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Poetry Restore Dependencies') + +POETRY_MANIFEST_FILE_NAME = 'pyproject.toml' +POETRY_LOCK_FILE_NAME = 'poetry.lock' + +# Section header that signals this pyproject.toml is managed by Poetry +_POETRY_TOOL_SECTION = '[tool.poetry]' + + +def _indicates_poetry(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals that this project uses Poetry.""" + if not pyproject_content: + return False + return _POETRY_TOOL_SECTION in pyproject_content + + +class RestorePoetryDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != POETRY_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / POETRY_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_poetry(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / POETRY_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running poetry + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, POETRY_LOCK_FILE_NAME) + logger.debug('Using existing poetry.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but Poetry is indicated in pyproject.toml — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['poetry', 'lock']] + + def get_lock_file_name(self) -> str: + return POETRY_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [POETRY_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py b/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py new file mode 100644 index 00000000..c05d857c --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py @@ -0,0 +1,59 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('UV Restore Dependencies') + +UV_MANIFEST_FILE_NAME = 'pyproject.toml' +UV_LOCK_FILE_NAME = 'uv.lock' + +_UV_TOOL_SECTION = '[tool.uv]' + + +def _indicates_uv(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals that this project uses UV.""" + if not pyproject_content: + return False + return _UV_TOOL_SECTION in pyproject_content + + +class RestoreUvDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != UV_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / UV_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_uv(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / UV_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, UV_LOCK_FILE_NAME) + logger.debug('Using existing uv.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['uv', 'lock']] + + def get_lock_file_name(self) -> str: + return UV_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [UV_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 41f70316..4db5cd04 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -4,13 +4,23 @@ import typer from cycode.cli import consts +from cycode.cli.exceptions.custom_exceptions import FileCollectionError from cycode.cli.files_collector.repository_documents import get_file_content_from_commit_path from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.files_collector.sca.go.restore_go_dependencies import RestoreGoDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies +from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import RestoreBunDependencies +from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import RestoreDenoDependencies from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies +from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import RestorePnpmDependencies +from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import RestoreYarnDependencies from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies +from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies +from cycode.cli.files_collector.sca.python.restore_pip_dependencies import RestorePipDependencies +from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import RestorePipenvDependencies +from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import RestorePoetryDependencies +from cycode.cli.files_collector.sca.python.restore_uv_dependencies import RestoreUvDependencies from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import RestoreRubyDependencies from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies from cycode.cli.models import Document @@ -106,11 +116,21 @@ def _try_restore_dependencies( restore_dependencies_document = restore_dependencies.restore(document) if restore_dependencies_document is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + logger.warning( + 'Error occurred while trying to generate dependencies tree, %s', + {'filename': document.path, 'handler': type(restore_dependencies).__name__}, + ) + if ctx.obj.get('stop_on_error', False): + raise FileCollectionError( + f'Failed to generate dependencies tree for {document.path} using {type(restore_dependencies).__name__}' + ) return None if restore_dependencies_document.content is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + logger.warning( + 'Error occurred while trying to generate dependencies tree, %s', + {'filename': document.path, 'handler': type(restore_dependencies).__name__}, + ) restore_dependencies_document.content = '' else: is_monitor_action = ctx.obj.get('monitor', False) @@ -124,14 +144,30 @@ def _try_restore_dependencies( def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRestoreDependencies]: build_dep_tree_timeout = int(os.getenv('CYCODE_BUILD_DEP_TREE_TIMEOUT_SECONDS', BUILD_DEP_TREE_TIMEOUT)) + logger.debug( + 'SCA restore handler timeout, %s', + { + 'timeout_sec': build_dep_tree_timeout, + 'source': 'env' if os.getenv('CYCODE_BUILD_DEP_TREE_TIMEOUT_SECONDS') else 'default', + }, + ) return [ RestoreGradleDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreMavenDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreSbtDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreGoDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreNugetDependencies(ctx, is_git_diff, build_dep_tree_timeout), - RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreYarnDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePnpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreBunDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn, Pnpm & Bun for fallback RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml + RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePipDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Uv & Poetry (pyproject.toml) + RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreComposerDependencies(ctx, is_git_diff, build_dep_tree_timeout), ] diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 6f5edd81..7927bdc6 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -17,7 +17,11 @@ def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: raise custom_exceptions.ZipTooLargeError(max_size_limit) -def zip_documents(scan_type: str, documents: list[Document], zip_file: Optional[InMemoryZip] = None) -> InMemoryZip: +def zip_documents( + scan_type: str, + documents: list[Document], + zip_file: Optional[InMemoryZip] = None, +) -> InMemoryZip: if zip_file is None: zip_file = InMemoryZip() diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index c0bedcc7..064d21d1 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -86,7 +86,7 @@ def _enrich_table_with_values(table: Table, detection: Detection) -> None: table.add_cell(SEVERITY_COLUMN, 'N/A') table.add_cell(REPOSITORY_COLUMN, detection_details.get('repository_name')) - table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_name')) + table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_path')) table.add_cell(ECOSYSTEM_COLUMN, detection_details.get('ecosystem')) table.add_cell(PACKAGE_COLUMN, detection_details.get('package_name')) diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 6a5dd198..4468ef9f 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -8,7 +8,7 @@ from cycode.cli.printers.tables.table_printer_base import TablePrinterBase from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result -from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text +from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text, sanitize_text_for_encoding if TYPE_CHECKING: from cycode.cli.models import LocalScanResult @@ -96,6 +96,8 @@ def _enrich_table_with_detection_code_segment_values( if not self.show_secret: violation = obfuscate_text(violation) + violation = sanitize_text_for_encoding(violation) + table.add_cell(LINE_NUMBER_COLUMN, str(detection_line)) table.add_cell(COLUMN_NUMBER_COLUMN, str(detection_column)) table.add_cell(VIOLATION_LENGTH_COLUMN, f'{violation_length} chars') diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py index 20f94d4e..57bc084e 100644 --- a/cycode/cli/printers/utils/code_snippet_syntax.py +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -5,7 +5,7 @@ from cycode.cli import consts from cycode.cli.console import _SYNTAX_HIGHLIGHT_THEME from cycode.cli.printers.utils import is_git_diff_based_scan -from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text +from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text, sanitize_text_for_encoding if TYPE_CHECKING: from cycode.cli.models import Document @@ -72,6 +72,7 @@ def _get_code_snippet_syntax_from_file( code_lines_to_render.append(line_content) code_to_render = '\n'.join(code_lines_to_render) + code_to_render = sanitize_text_for_encoding(code_to_render) return _get_syntax_highlighted_code( code=code_to_render, lexer=Syntax.guess_lexer(document.path, code=code_to_render), @@ -94,6 +95,7 @@ def _get_code_snippet_syntax_from_git_diff( violation = line_content[detection_position_in_line : detection_position_in_line + violation_length] line_content = line_content.replace(violation, obfuscate_text(violation)) + line_content = sanitize_text_for_encoding(line_content) return _get_syntax_highlighted_code( code=line_content, lexer='diff', diff --git a/cycode/cli/printers/utils/detection_data.py b/cycode/cli/printers/utils/detection_data.py index 37bee310..679429a3 100644 --- a/cycode/cli/printers/utils/detection_data.py +++ b/cycode/cli/printers/utils/detection_data.py @@ -105,4 +105,4 @@ def get_detection_file_path(scan_type: str, detection: 'Detection') -> Path: return Path(file_path) - return Path(detection.detection_details.get('file_name', '')) + return Path(detection.detection_details.get('file_path', '')) diff --git a/cycode/cli/printers/utils/detection_ordering/sca_ordering.py b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py index a8be3430..9e1f8022 100644 --- a/cycode/cli/printers/utils/detection_ordering/sca_ordering.py +++ b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py @@ -49,7 +49,7 @@ def sort_and_group_detections(detections: list['Detection']) -> tuple[list['Dete grouped_by_repository = __group_by(sorted_detections, 'repository_name') for repository_group in grouped_by_repository.values(): - grouped_by_code_project = __group_by(repository_group, 'file_name') + grouped_by_code_project = __group_by(repository_group, 'file_path') for code_project_group in grouped_by_code_project.values(): grouped_by_package = __group_by(code_project_group, 'package_name') for package_group in grouped_by_package.values(): diff --git a/cycode/cli/printers/utils/rich_helpers.py b/cycode/cli/printers/utils/rich_helpers.py index 52d2a0f2..6049b211 100644 --- a/cycode/cli/printers/utils/rich_helpers.py +++ b/cycode/cli/printers/utils/rich_helpers.py @@ -5,6 +5,7 @@ from rich.panel import Panel from cycode.cli.console import console +from cycode.cli.utils.string_utils import sanitize_text_for_encoding if TYPE_CHECKING: from rich.console import RenderableType @@ -20,8 +21,9 @@ def get_panel(renderable: 'RenderableType', title: str) -> Panel: def get_markdown_panel(markdown_text: str, title: str) -> Panel: + sanitized_text = sanitize_text_for_encoding(markdown_text.strip()) return get_panel( - Markdown(markdown_text.strip()), + Markdown(sanitized_text), title=title, ) diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index 5b029e39..cfab38d2 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -18,6 +18,7 @@ class ConfigFileManager(BaseFileManager): SCAN_SECTION_NAME: str = 'scan' INSTALLATION_ID_FIELD_NAME: str = 'installation_id' + LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: str = 'last_reported_activation_versions' API_URL_FIELD_NAME: str = 'cycode_api_url' APP_URL_FIELD_NAME: str = 'cycode_app_url' VERBOSE_FIELD_NAME: str = 'verbose' @@ -68,6 +69,16 @@ def update_installation_id(self, installation_id: str) -> None: update_data = {self.ENVIRONMENT_SECTION_NAME: {self.INSTALLATION_ID_FIELD_NAME: installation_id}} self.write_content_to_file(update_data) + def get_last_reported_activation_versions(self) -> dict[str, str]: + value = self._get_value_from_environment_section(self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME) + return value if isinstance(value, dict) else {} + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + versions = self.get_last_reported_activation_versions() + versions[client] = version + update_data = {self.ENVIRONMENT_SECTION_NAME: {self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: versions}} + self.write_content_to_file(update_data) + def add_exclusion(self, scan_type: str, exclusion_type: str, new_exclusion: str) -> None: exclusions = self._get_exclusions_by_exclusion_type(scan_type, exclusion_type) if new_exclusion in exclusions: diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 689ec0d5..f80f9a6e 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -94,6 +94,12 @@ def get_or_create_installation_id(self) -> str: return installation_id + def get_last_reported_activation_version(self, client: str) -> Optional[str]: + return self.global_config_file_manager.get_last_reported_activation_versions().get(client) + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + self.global_config_file_manager.update_last_reported_activation_version(client, version) + def get_config_file_manager(self, scope: Optional[str] = None) -> ConfigFileManager: if scope == 'local': return self.local_config_file_manager diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 32564b0e..9522981b 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -9,7 +9,6 @@ ) from cycode.cli.user_settings.base_file_manager import BaseFileManager from cycode.cli.user_settings.jwt_creator import JwtCreator -from cycode.cli.utils.sentry import setup_scope_from_access_token class CredentialsManager(BaseFileManager): @@ -77,8 +76,6 @@ def get_access_token(self) -> tuple[Optional[str], Optional[float], Optional[Jwt if hashed_creator: creator = JwtCreator(hashed_creator) - setup_scope_from_access_token(access_token) - return access_token, expires_in, creator def update_access_token( @@ -91,7 +88,5 @@ def update_access_token( } self.write_content_to_file(file_content_to_update) - setup_scope_from_access_token(access_token) - def get_filename(self) -> str: return os.path.join(self.HOME_PATH, self.CYCODE_HIDDEN_DIRECTORY, self.FILE_NAME) diff --git a/cycode/cli/utils/binary_utils.py b/cycode/cli/utils/binary_utils.py new file mode 100644 index 00000000..e61b7ddc --- /dev/null +++ b/cycode/cli/utils/binary_utils.py @@ -0,0 +1,72 @@ +_CONTROL_CHARS = b'\n\r\t\f\b' +_PRINTABLE_ASCII = _CONTROL_CHARS + bytes(range(32, 127)) +_PRINTABLE_HIGH_ASCII = bytes(range(127, 256)) + +# BOM signatures for encodings that legitimately contain null bytes +_BOM_ENCODINGS = ( + (b'\xff\xfe\x00\x00', 'utf-32-le'), + (b'\x00\x00\xfe\xff', 'utf-32-be'), + (b'\xff\xfe', 'utf-16-le'), + (b'\xfe\xff', 'utf-16-be'), +) + + +def _has_bom_encoding(bytes_to_check: bytes) -> bool: + """Check if bytes start with a BOM and can be decoded as that encoding.""" + for bom, encoding in _BOM_ENCODINGS: + if bytes_to_check.startswith(bom): + try: + bytes_to_check.decode(encoding) + return True + except (UnicodeDecodeError, LookupError): + pass + return False + + +def _is_decodable_as_utf8(bytes_to_check: bytes) -> bool: + """Try to decode bytes as UTF-8.""" + try: + bytes_to_check.decode('utf-8') + return True + except UnicodeDecodeError: + return False + + +def is_binary_string(bytes_to_check: bytes) -> bool: + """Check if a chunk of bytes appears to be binary content. + + Uses a simplified version of the Perl detection algorithm, matching + the structure of binaryornot's is_binary_string. + """ + if not bytes_to_check: + return False + + # Binary if control chars are > 30% of the string + low_chars = bytes_to_check.translate(None, _PRINTABLE_ASCII) + nontext_ratio1 = len(low_chars) / len(bytes_to_check) + + # Binary if high ASCII chars are < 5% of the string + high_chars = bytes_to_check.translate(None, _PRINTABLE_HIGH_ASCII) + nontext_ratio2 = len(high_chars) / len(bytes_to_check) + + is_likely_binary = (nontext_ratio1 > 0.3 and nontext_ratio2 < 0.05) or ( + nontext_ratio1 > 0.8 and nontext_ratio2 > 0.8 + ) + + # BOM-marked UTF-16/32 files legitimately contain null bytes. + # Check this first so they aren't misdetected as binary. + if _has_bom_encoding(bytes_to_check): + return False + + has_null_or_xff = b'\x00' in bytes_to_check or b'\xff' in bytes_to_check + + if is_likely_binary: + # Only let UTF-8 rescue data that doesn't contain null bytes. + # Null bytes are valid UTF-8 but almost never appear in real text files, + # whereas binary formats (e.g. .DS_Store) are full of them. + if has_null_or_xff: + return True + return not _is_decodable_as_utf8(bytes_to_check) + + # Null bytes or 0xff in otherwise normal-looking data indicate binary + return bool(has_null_or_xff) diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index 5c712288..b69666d3 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -3,11 +3,17 @@ import click from cycode.cli.user_settings.credentials_manager import CredentialsManager -from cycode.cyclient.client_creator import create_import_sbom_client, create_report_client, create_scan_client +from cycode.cyclient.client_creator import ( + create_ai_security_manager_client, + create_import_sbom_client, + create_report_client, + create_scan_client, +) if TYPE_CHECKING: import typer + from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient from cycode.cyclient.import_sbom_client import ImportSbomClient from cycode.cyclient.report_client import ReportClient from cycode.cyclient.scan_client import ScanClient @@ -19,7 +25,7 @@ def _get_cycode_client( client_secret: Optional[str], hide_response_log: bool, id_token: Optional[str] = None, -) -> Union['ScanClient', 'ReportClient']: +) -> Union['ScanClient', 'ReportClient', 'ImportSbomClient', 'AISecurityManagerClient']: if client_id and id_token: return create_client_func(client_id, None, hide_response_log, id_token) @@ -62,6 +68,13 @@ def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log, id_token) +def get_ai_security_manager_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'AISecurityManagerClient': + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') + id_token = ctx.obj.get('id_token') + return _get_cycode_client(create_ai_security_manager_client, client_id, client_secret, hide_response_log, id_token) + + def _get_configured_credentials() -> tuple[str, str]: credentials_manager = CredentialsManager() return credentials_manager.get_credentials() diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py new file mode 100644 index 00000000..60a36bed --- /dev/null +++ b/cycode/cli/utils/host_info.py @@ -0,0 +1,179 @@ +import getpass +import os +import platform +import re +import socket +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from cycode.logger import get_logger + +logger = get_logger('HOST INFO') + +_SUBPROCESS_TIMEOUT_SEC = 5 + +_SERIAL_NUMBER_CACHE_FILE_NAME = '.cycode-device-serial' + +_PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'} + + +def _run(command: list, timeout: int = _SUBPROCESS_TIMEOUT_SEC) -> Optional[str]: + """Run a command and return its stripped stdout. Never raises; returns None on any error.""" + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=timeout) # noqa: S603 + return result.stdout.strip() or None + except Exception as e: + logger.debug('Failed to run command %s', command, exc_info=e) + return None + + +def _read_text_file(path: str) -> Optional[str]: + """Read and strip a text file. Never raises; returns None if it can't be read.""" + try: + with open(path) as text_file: + return text_file.read().strip() or None + except OSError: + return None + + +def get_hostname() -> Optional[str]: + try: + return socket.gethostname() or None + except Exception as e: + logger.debug('Failed to resolve hostname', exc_info=e) + return None + + +def get_platform_name() -> Optional[str]: + try: + system = platform.system() + return _PLATFORM_NAMES.get(system, system or None) + except Exception as e: + logger.debug('Failed to resolve platform name', exc_info=e) + return None + + +def get_os_version() -> Optional[str]: + try: + system = platform.system() + if system == 'Darwin': + return platform.mac_ver()[0] or None + if system == 'Windows': + return platform.win32_ver()[1] or platform.version() or None + if system == 'Linux': + return _get_linux_os_version() + return platform.release() or None + except Exception as e: + logger.debug('Failed to resolve OS version', exc_info=e) + return None + + +def _get_linux_os_version() -> Optional[str]: + freedesktop_os_release = getattr(platform, 'freedesktop_os_release', None) # Python 3.10+ + if freedesktop_os_release is not None: + try: + version_id = freedesktop_os_release().get('VERSION_ID') + if version_id: + return version_id + except OSError: + pass + + os_release = _read_text_file('/etc/os-release') # Python 3.9 fallback: parse manually + if os_release: + for line in os_release.splitlines(): + if line.startswith('VERSION_ID='): + return line.split('=', 1)[1].strip().strip('"') or None + + return platform.release() or None + + +def get_last_login_user() -> Optional[str]: + try: + return getpass.getuser() or None + except Exception as e: + logger.debug('Failed to resolve last login user', exc_info=e) + return None + + +def get_serial_number() -> Optional[str]: + # The serial is immutable hardware info, but resolving it shells out (ioreg/WMI) + # and this runs in a fresh process per AI hook event - cache it on disk. + cached = _read_serial_number_cache() + if cached: + return cached + + serial = _resolve_serial_number() + if serial: + _write_serial_number_cache(serial) + return serial + + +def _resolve_serial_number() -> Optional[str]: + try: + system = platform.system() + if system == 'Darwin': + return _get_macos_serial_number() + if system == 'Windows': + return _get_windows_serial_number() + except Exception as e: + logger.debug('Failed to resolve serial number', exc_info=e) + return None + + +def _serial_number_cache_path() -> Path: + # The username suffix avoids collisions on OSes with a shared temp dir + return Path(tempfile.gettempdir()) / f'.cycode-device-serial-{getpass.getuser()}' + + +def _read_serial_number_cache() -> Optional[str]: + try: + return _serial_number_cache_path().read_text(encoding='utf-8').strip() or None + except Exception: + return None + + +def _write_serial_number_cache(serial: str) -> None: + try: + cache_path = _serial_number_cache_path() + + # The serial identifies the machine, and the temp dir is shared, so the cache is created + # readable by its owner alone (what mkstemp does) and moved into place atomically - a hook + # racing another one never reads a half-written cache, and the rename can't be redirected + # by a symlink planted at the destination the way an in-place write could. + file_descriptor, temp_path = tempfile.mkstemp( + dir=cache_path.parent, prefix=f'{_SERIAL_NUMBER_CACHE_FILE_NAME}.' + ) + try: + with os.fdopen(file_descriptor, 'w', encoding='utf-8') as temp_file: + temp_file.write(serial) + os.replace(temp_path, cache_path) + except Exception: + Path(temp_path).unlink(missing_ok=True) + raise + except Exception as e: + logger.debug('Failed to cache serial number', exc_info=e) + + +def _get_macos_serial_number() -> Optional[str]: + output = _run(['ioreg', '-c', 'IOPlatformExpertDevice', '-d', '2']) + if not output: + return None + match = re.search(r'"IOPlatformSerialNumber"\s*=\s*"([^"]+)"', output) + return match.group(1) if match else None + + +def _get_windows_serial_number() -> Optional[str]: + import pythoncom # from pywin32 + import win32com.client # from pywin32 + + pythoncom.CoInitialize() + try: + wmi_service = win32com.client.GetObject('winmgmts:') + for bios in wmi_service.InstancesOf('Win32_BIOS'): + serial = bios.SerialNumber + return serial.strip() if serial else None + finally: + pythoncom.CoUninitialize() + return None diff --git a/cycode/cli/utils/jwt_utils.py b/cycode/cli/utils/jwt_utils.py index c87b7c48..21f767d0 100644 --- a/cycode/cli/utils/jwt_utils.py +++ b/cycode/cli/utils/jwt_utils.py @@ -5,6 +5,14 @@ _JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES = ('userId', 'internalId', 'token-user-id') +def decode_jwt_unverified(token: str) -> Optional[dict]: + """Return JWT claims without signature verification, or None if the token is unreadable.""" + try: + return jwt.decode(token, options={'verify_signature': False}) + except jwt.PyJWTError: + return None + + def get_user_and_tenant_ids_from_access_token(access_token: str) -> tuple[Optional[str], Optional[str]]: payload = jwt.decode(access_token, options={'verify_signature': False}) diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index ce60b0da..c2d59805 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, AnyStr, Optional, Union import typer -from binaryornot.helpers import is_binary_string from cycode.cli.logger import logger +from cycode.cli.utils.binary_utils import is_binary_string if TYPE_CHECKING: from os import PathLike diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 8bfd7ed0..97e58bc7 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -111,9 +111,13 @@ def run_parallel_batched_scan( scan_type: str, documents: list[Document], progress_bar: 'BaseProgressBar', + skip_batching: bool = False, ) -> tuple[dict[str, 'CliError'], list['LocalScanResult']]: # batching is disabled for SCA; requested by Mor - batches = [documents] if scan_type == consts.SCA_SCAN_TYPE else split_documents_into_batches(scan_type, documents) + if scan_type == consts.SCA_SCAN_TYPE or skip_batching: + batches = [documents] + else: + batches = split_documents_into_batches(scan_type, documents) progress_bar.set_section_length(ScanProgressBarSection.SCAN, len(batches)) # * 3 # TODO(MarshalX): we should multiply the count of batches in SCAN section because each batch has 3 steps: diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 1332a7cf..819a4116 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,9 +1,13 @@ import os +from collections import defaultdict from typing import TYPE_CHECKING, Optional from uuid import UUID, uuid4 import typer +from cycode.cli import consts +from cycode.cli.cli_types import SeverityOption + if TYPE_CHECKING: from cycode.cli.models import LocalScanResult from cycode.cyclient.models import ScanConfiguration @@ -28,8 +32,33 @@ def is_cycodeignore_allowed_by_scan_config(ctx: typer.Context) -> bool: return scan_config.is_cycode_ignore_allowed if scan_config else True +def should_use_presigned_upload(scan_type: str) -> bool: + return scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES + + def generate_unique_scan_id() -> UUID: if 'PYTEST_TEST_UNIQUE_ID' in os.environ: return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) return uuid4() + + +def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: + """Build violation summary string with severity breakdown and emojis.""" + detections_count = 0 + severity_counts = defaultdict(int) + + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + for detection in document_detections.detections: + if detection.severity: + detections_count += 1 + severity_counts[SeverityOption(detection.severity)] += 1 + + severity_parts = [] + for severity in reversed(SeverityOption): + emoji = SeverityOption.get_member_unicode_emoji(severity) + count = severity_counts[severity] + severity_parts.append(f'{emoji} {severity.upper()} - {count}') + + return f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}' diff --git a/cycode/cli/utils/sentry.py b/cycode/cli/utils/sentry.py deleted file mode 100644 index 16b2a982..00000000 --- a/cycode/cli/utils/sentry.py +++ /dev/null @@ -1,112 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import Optional - -import sentry_sdk -from sentry_sdk.integrations.atexit import AtexitIntegration -from sentry_sdk.integrations.dedupe import DedupeIntegration -from sentry_sdk.integrations.excepthook import ExcepthookIntegration -from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber - -from cycode import __version__ -from cycode.cli import consts -from cycode.cli.logger import logger -from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token -from cycode.cyclient.config import on_premise_installation - -# when Sentry is blocked on the machine, we want to keep clean output without retries warnings -logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR) -logging.getLogger('sentry_sdk').setLevel(logging.ERROR) - - -@dataclass -class _SentrySession: - user_id: Optional[str] = None - tenant_id: Optional[str] = None - correlation_id: Optional[str] = None - - -_SENTRY_SESSION = _SentrySession() -_DENY_LIST = [*DEFAULT_DENYLIST, 'access_token'] - - -def _get_sentry_release() -> str: - return f'{consts.APP_NAME}@{__version__}' - - -def _get_sentry_local_release() -> str: - return f'{consts.APP_NAME}@0.0.0' - - -_SENTRY_LOCAL_RELEASE = _get_sentry_local_release() -_SENTRY_DISABLED = on_premise_installation - - -def _before_sentry_event_send(event: dict, _: dict) -> Optional[dict]: - if _SENTRY_DISABLED: - # drop all events when Sentry is disabled - return None - - if event.get('release') == _SENTRY_LOCAL_RELEASE: - logger.debug('Dropping Sentry event due to local development setup') - return None - - return event - - -def init_sentry() -> None: - sentry_sdk.init( - dsn=consts.SENTRY_DSN, - debug=consts.SENTRY_DEBUG, - release=_get_sentry_release(), - server_name='', - before_send=_before_sentry_event_send, - sample_rate=consts.SENTRY_SAMPLE_RATE, - send_default_pii=consts.SENTRY_SEND_DEFAULT_PII, - include_local_variables=consts.SENTRY_INCLUDE_LOCAL_VARIABLES, - max_request_body_size=consts.SENTRY_MAX_REQUEST_BODY_SIZE, - event_scrubber=EventScrubber(denylist=_DENY_LIST, recursive=True), - default_integrations=False, - integrations=[ - AtexitIntegration(lambda _, __: None), # disable output to stderr about pending events - ExcepthookIntegration(), - DedupeIntegration(), - LoggingIntegration(), - ], - ) - - -def setup_scope_from_access_token(access_token: Optional[str]) -> None: - if not access_token: - return - - user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token) - - _SENTRY_SESSION.user_id = user_id - _SENTRY_SESSION.tenant_id = tenant_id - - _setup_scope(user_id, tenant_id, _SENTRY_SESSION.correlation_id) - - -def add_correlation_id_to_scope(correlation_id: str) -> None: - _setup_scope(_SENTRY_SESSION.user_id, _SENTRY_SESSION.tenant_id, correlation_id) - - -def _setup_scope(user_id: str, tenant_id: str, correlation_id: Optional[str] = None) -> None: - scope = sentry_sdk.Scope.get_current_scope() - sentry_sdk.set_tag('tenant_id', tenant_id) - - user = {'id': user_id, 'tenant_id': tenant_id} - if correlation_id: - user['correlation_id'] = correlation_id - - scope.set_user(user) - - -def capture_exception(exception: BaseException) -> None: - sentry_sdk.capture_exception(exception) - - -def add_breadcrumb(message: str, category: str = 'cli') -> None: - sentry_sdk.add_breadcrumb(category=category, message=message, level='info') diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index 2529890b..b39d2a0b 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -1,4 +1,5 @@ import subprocess +import time from typing import Optional, Union import click @@ -21,15 +22,27 @@ def shell( logger.debug('Executing shell command: %s', command) try: + start = time.monotonic() result = subprocess.run( # noqa: S603 command, cwd=working_directory, timeout=timeout, check=True, capture_output=True ) - logger.debug('Shell command executed successfully') + duration_sec = round(time.monotonic() - start, 2) + stdout = result.stdout.decode('UTF-8').strip() + stderr = result.stderr.decode('UTF-8').strip() - return result.stdout.decode('UTF-8').strip() + logger.debug( + 'Shell command executed successfully, %s', + {'duration_sec': duration_sec, 'stdout': stdout if stdout else '', 'stderr': stderr if stderr else ''}, + ) + + return stdout except subprocess.CalledProcessError as e: if not silent_exc_info: logger.debug('Error occurred while running shell command', exc_info=e) + if e.stdout: + logger.debug('Shell command stdout: %s', e.stdout.decode('UTF-8').strip()) + if e.stderr: + logger.debug('Shell command stderr: %s', e.stderr.decode('UTF-8').strip()) except subprocess.TimeoutExpired as e: logger.debug('Command timed out', exc_info=e) raise typer.Abort(f'Command "{command}" timed out') from e diff --git a/cycode/cli/utils/string_utils.py b/cycode/cli/utils/string_utils.py index c3c0c6c6..43931239 100644 --- a/cycode/cli/utils/string_utils.py +++ b/cycode/cli/utils/string_utils.py @@ -5,9 +5,8 @@ import string from sys import getsizeof -from binaryornot.check import is_binary_string - from cycode.cli.consts import SCA_SHORTCUT_DEPENDENCY_PATHS +from cycode.cli.utils.binary_utils import is_binary_string def obfuscate_text(text: str) -> str: @@ -65,3 +64,12 @@ def shortcut_dependency_paths(dependency_paths_list: str) -> str: result += '\n' return result.rstrip().rstrip(',') + + +def sanitize_text_for_encoding(text: str) -> str: + """Sanitize text by replacing surrogate characters and invalid UTF-8 sequences. + + This prevents encoding errors when Rich tries to display the content, especially on Windows. + Surrogate characters (U+D800 to U+DFFF) cannot be encoded to UTF-8 and will cause errors. + """ + return text.encode('utf-8', errors='replace').decode('utf-8') diff --git a/cycode/cli/utils/url_utils.py b/cycode/cli/utils/url_utils.py new file mode 100644 index 00000000..91e50f77 --- /dev/null +++ b/cycode/cli/utils/url_utils.py @@ -0,0 +1,64 @@ +from typing import Optional +from urllib.parse import urlparse, urlunparse + +from cycode.logger import get_logger + +logger = get_logger('URL Utils') + + +def sanitize_repository_url(url: Optional[str]) -> Optional[str]: + """Remove credentials (username, password, tokens) from repository URL. + + This function sanitizes repository URLs to prevent sending PAT tokens or other + credentials to the API. It handles both HTTP/HTTPS URLs with embedded credentials + and SSH URLs (which are returned as-is since they don't contain credentials in the URL). + + Args: + url: Repository URL that may contain credentials (e.g., https://token@github.com/user/repo.git) + + Returns: + Sanitized URL without credentials (e.g., https://github.com/user/repo.git), or None if input is None + + Examples: + >>> sanitize_repository_url('https://token@github.com/user/repo.git') + 'https://github.com/user/repo.git' + >>> sanitize_repository_url('https://user:token@github.com/user/repo.git') + 'https://github.com/user/repo.git' + >>> sanitize_repository_url('git@github.com:user/repo.git') + 'git@github.com:user/repo.git' + >>> sanitize_repository_url(None) + None + """ + if not url: + return url + + # Handle SSH URLs - no credentials to remove + # ssh:// URLs have the format ssh://git@host/path + if url.startswith('ssh://'): + return url + # git@host:path format (scp-style) + if '@' in url and '://' not in url and url.startswith('git@'): + return url + + try: + parsed = urlparse(url) + # Remove username and password from netloc + # Reconstruct URL without credentials + sanitized_netloc = parsed.hostname + if parsed.port: + sanitized_netloc = f'{sanitized_netloc}:{parsed.port}' + + return urlunparse( + ( + parsed.scheme, + sanitized_netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + except Exception as e: + logger.debug('Failed to sanitize repository URL, returning original, %s', {'url': url, 'error': str(e)}) + # If parsing fails, return original URL to avoid breaking functionality + return url diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py new file mode 100644 index 00000000..5dee7f2c --- /dev/null +++ b/cycode/cyclient/ai_security_manager_client.py @@ -0,0 +1,123 @@ +"""Client for AI Security Manager service.""" + +from typing import TYPE_CHECKING, Optional + +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError +from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.logger import logger + +if TYPE_CHECKING: + from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload + from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason + from cycode.cyclient.ai_security_manager_service_config import AISecurityManagerServiceConfigBase + + +class AISecurityManagerClient: + """Client for interacting with AI Security Manager service.""" + + _CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations' + _EVENTS_PATH = 'v4/ai-security/interactions/events' + _SESSION_CONTEXT_PATH = 'v4/ai-security/interactions/session-context' + + def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None: + self.client = client + self.service_config = service_config + + def _build_endpoint_path(self, path: str) -> str: + """Build the full endpoint path including service name/port.""" + service_name = self.service_config.get_service_name() + if service_name: + return f'{service_name}/{path}' + return path + + def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]: + """Creates an AI conversation from hook payload.""" + conversation_id = payload.conversation_id + if not conversation_id: + return None + + body = { + 'id': conversation_id, + 'ide_user_email': payload.ide_user_email, + 'model': payload.model, + 'ide_provider': payload.ide_provider, + 'ide_version': payload.ide_version, + 'source': payload.source, + } + + try: + self.client.post(self._build_endpoint_path(self._CONVERSATIONS_PATH), body=body) + except HttpUnauthorizedError: + # Authentication error - re-raise so prompt_command can catch it + raise + except Exception as e: + logger.debug('Failed to create conversation', exc_info=e) + # Don't fail the hook if tracking fails (non-auth errors) + + return conversation_id + + def create_event( + self, + payload: 'AIHookPayload', + event_type: 'AiHookEventType', + outcome: 'AIHookOutcome', + scan_id: Optional[str] = None, + block_reason: Optional['BlockReason'] = None, + error_message: Optional[str] = None, + file_path: Optional[str] = None, + ) -> None: + """Create an AI hook event from hook payload.""" + conversation_id = payload.conversation_id + if not conversation_id: + logger.debug('No conversation ID available, skipping event creation') + return + + body = { + 'conversation_id': conversation_id, + 'event_type': event_type, + 'outcome': outcome, + 'generation_id': payload.generation_id, + 'block_reason': block_reason, + 'cli_scan_id': scan_id, + 'mcp_server_name': payload.mcp_server_name, + 'mcp_tool_name': payload.mcp_tool_name, + 'error_message': error_message, + 'file_path': file_path, + } + + try: + self.client.post(self._build_endpoint_path(self._EVENTS_PATH), body=body) + except Exception as e: + logger.debug('Failed to create AI hook event', exc_info=e) + # Don't fail the hook if tracking fails + + def report_session_context( + self, + hostname: Optional[str] = None, + platform_name: Optional[str] = None, + os_version: Optional[str] = None, + serial_number: Optional[str] = None, + last_login_user: Optional[str] = None, + config_files: Optional[list[dict]] = None, + enabled_plugins: Optional[dict] = None, + user_email: Optional[str] = None, + ) -> bool: + """Report session context to the backend. Returns whether the report was accepted.""" + body: dict = { + 'hostname': hostname, + 'platform_name': platform_name, + 'os_version': os_version, + 'serial_number': serial_number, + 'last_login_user': last_login_user, + 'user_email': user_email, + 'config_files': config_files, + 'enabled_plugins': enabled_plugins, + } + + try: + self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body) + return True + except Exception as e: + logger.debug('Failed to report session context', exc_info=e) + # Don't fail the session if reporting fails + return False diff --git a/cycode/cyclient/ai_security_manager_service_config.py b/cycode/cyclient/ai_security_manager_service_config.py new file mode 100644 index 00000000..60d7f2dd --- /dev/null +++ b/cycode/cyclient/ai_security_manager_service_config.py @@ -0,0 +1,27 @@ +"""Service configuration for AI Security Manager.""" + + +class AISecurityManagerServiceConfigBase: + """Base class for AI Security Manager service configuration.""" + + def get_service_name(self) -> str: + """Get the service name or port for URL construction. + + In dev mode, returns the port number. + In production, returns the service name. + """ + raise NotImplementedError + + +class DevAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase): + """Dev configuration for AI Security Manager.""" + + def get_service_name(self) -> str: + return '5163/api' + + +class DefaultAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase): + """Production configuration for AI Security Manager.""" + + def get_service_name(self) -> str: + return '' diff --git a/cycode/cyclient/base_token_auth_client.py b/cycode/cyclient/base_token_auth_client.py index 3f164836..ec315e7d 100644 --- a/cycode/cyclient/base_token_auth_client.py +++ b/cycode/cyclient/base_token_auth_client.py @@ -24,19 +24,10 @@ def __init__(self, client_id: str) -> None: self.client_id = client_id self._credentials_manager = CredentialsManager() - # load cached access token - access_token, expires_in, creator = self._credentials_manager.get_access_token() - - self._access_token = self._expires_in = None - expected_creator = self._create_jwt_creator() - if creator == expected_creator: - # we must be sure that cached access token is created using the same client id and client secret. - # because client id and client secret could be passed via command, via env vars or via config file. - # we must not use cached access token if client id or client secret was changed. - self._access_token = access_token - self._expires_in = arrow.get(expires_in) if expires_in else None - + self._access_token = None + self._expires_in = None self._lock = Lock() + self._load_token_from_disk() def get_access_token(self) -> str: with self._lock: @@ -51,8 +42,30 @@ def invalidate_access_token(self, in_storage: bool = False) -> None: self._credentials_manager.update_access_token(None, None, None) def refresh_access_token_if_needed(self) -> None: - if self._access_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: - self.refresh_access_token() + if self._has_valid_token(): + return + # Re-check disk before doing the network refresh: another client instance + # in this process may have already refreshed and persisted a fresh token. + self._load_token_from_disk() + if self._has_valid_token(): + return + self.refresh_access_token() + + def _has_valid_token(self) -> bool: + return self._access_token is not None and self._expires_in is not None and arrow.utcnow() < self._expires_in + + def _load_token_from_disk(self) -> None: + access_token, expires_in, creator = self._credentials_manager.get_access_token() + expected_creator = self._create_jwt_creator() + # We must be sure that cached access token is created using the same client id and client secret. + # Because client id and client secret could be passed via command, via env vars or via config file. + # We must not use cached access token if client id or client secret was changed. + if creator == expected_creator and access_token: + self._access_token = access_token + self._expires_in = arrow.get(expires_in) if expires_in else None + else: + self._access_token = None + self._expires_in = None def refresh_access_token(self) -> None: auth_response = self._request_new_access_token() diff --git a/cycode/cyclient/cli_activation_client.py b/cycode/cyclient/cli_activation_client.py new file mode 100644 index 00000000..2932c353 --- /dev/null +++ b/cycode/cyclient/cli_activation_client.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +_CLI_ACTIVATION_PATH = 'scans/api/v4/cli-activation' + + +class CliActivationClient: + def __init__(self, cycode_client: 'CycodeClientBase') -> None: + self._cycode_client = cycode_client + + def report_activation(self) -> None: + self._cycode_client.put(url_path=_CLI_ACTIVATION_PATH) diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py index 01ab6b59..c26795c7 100644 --- a/cycode/cyclient/client_creator.py +++ b/cycode/cyclient/client_creator.py @@ -1,5 +1,10 @@ from typing import Optional +from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient +from cycode.cyclient.ai_security_manager_service_config import ( + DefaultAISecurityManagerServiceConfig, + DevAISecurityManagerServiceConfig, +) from cycode.cyclient.config import dev_mode from cycode.cyclient.config_dev import DEV_CYCODE_API_URL from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient @@ -49,3 +54,18 @@ def create_import_sbom_client( else: client = CycodeTokenBasedClient(client_id, client_secret) return ImportSbomClient(client) + + +def create_ai_security_manager_client( + client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None +) -> AISecurityManagerClient: + if dev_mode: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) + service_config = DevAISecurityManagerServiceConfig() + else: + if id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) + service_config = DefaultAISecurityManagerServiceConfig() + return AISecurityManagerClient(client, service_config) diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 4b2e2698..bde0e880 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,6 +1,8 @@ +import functools import os import platform import ssl +from io import BytesIO from typing import TYPE_CHECKING, Callable, ClassVar, Optional import requests @@ -15,6 +17,7 @@ RequestHttpError, RequestSslError, RequestTimeoutError, + SlowUploadConnectionError, ) from cycode.cyclient import config from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id @@ -37,16 +40,29 @@ def cert_verify(self, *args, **kwargs) -> None: conn.ca_certs = None +@functools.cache +def _get_session() -> requests.Session: + """Process-wide Session so TCP+TLS connections are reused across all API calls.""" + session = requests.Session() + # On Windows without an explicit CA bundle env var, fall back to the system + # trust store via a custom SSL context. + if platform.system() == 'Windows' and not ( + os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE') + ): + session.mount('https://', SystemStorageSslContext()) + return session + + def _get_request_function() -> Callable: - if os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE'): - return requests.request + return _get_session().request - if platform.system() != 'Windows': - return requests.request - session = requests.Session() - session.mount('https://', SystemStorageSslContext()) - return session.request +def _log_response(response: Response, url: str, hide_response_content_log: bool) -> None: + content = 'HIDDEN' if hide_response_content_log else response.text + logger.debug( + 'Receiving response, %s', + {'status_code': response.status_code, 'url': url, 'content': content}, + ) _REQUEST_ERRORS_TO_RETRY = ( @@ -90,6 +106,23 @@ def _should_retry_exception(exception: BaseException) -> bool: return is_request_error or is_server_error +class UploadProgressTracker: + """File-like wrapper that tracks bytes read during upload and fires a progress callback.""" + + def __init__(self, data: bytes, callback: Optional[Callable[[int, int], None]]) -> None: + self._io = BytesIO(data) + self._callback = callback + self.bytes_read = 0 + self.len = len(data) + + def read(self, size: int = -1) -> bytes: + chunk = self._io.read(size) + self.bytes_read += len(chunk) + if self._callback and chunk: + self._callback(self.bytes_read, self.len) + return chunk + + class CycodeClientBase: MANDATORY_HEADERS: ClassVar[dict[str, str]] = { 'User-Agent': get_cli_user_agent(), @@ -117,6 +150,67 @@ def put(self, url_path: str, body: Optional[dict] = None, headers: Optional[dict def get(self, url_path: str, headers: Optional[dict] = None, **kwargs) -> Response: return self._execute(method='get', endpoint=url_path, headers=headers, **kwargs) + def post_multipart( + self, + url_path: str, + form_fields: dict, + files: dict, + on_upload_progress: Optional[Callable[[int, int], None]] = None, + hide_response_content_log: bool = False, + ) -> Response: + """POST a multipart form body with optional upload progress tracking and retry.""" + url = self.build_full_url(self.api_url, url_path) + logger.debug('Executing request, %s', {'method': 'POST', 'url': url}) + + # Encode the multipart body once up front so we can reuse the same bytes across retries. + # A dummy URL is used because requests.Request requires one, but only the encoded body matters here. + prepared = requests.Request('POST', 'https://dummy', data=form_fields, files=files).prepare() + + return self._send_multipart( + url=url, + body=prepared.body, + content_type=prepared.headers['Content-Type'], + on_upload_progress=on_upload_progress, + hide_response_content_log=hide_response_content_log, + ) + + @retry( + retry=retry_if_exception(_should_retry_exception), + stop=_RETRY_STOP_STRATEGY, + wait=_RETRY_WAIT_STRATEGY, + reraise=True, + before_sleep=_retry_before_sleep, + ) + def _send_multipart( + self, + url: str, + body: bytes, + content_type: str, + on_upload_progress: Optional[Callable[[int, int], None]], + hide_response_content_log: bool, + ) -> Response: + # Wrap the body in a fresh tracker each attempt so bytes_read starts from zero. + tracker = UploadProgressTracker(body, on_upload_progress) + headers = self.get_request_headers({'Content-Type': content_type}) + try: + response = _get_request_function()( + method='post', url=url, data=tracker, headers=headers, timeout=self.timeout + ) + _log_response(response, url, hide_response_content_log) + + response.raise_for_status() + return response + except (exceptions.ChunkedEncodingError, exceptions.ConnectionError) as e: + # A connection drop before the full body was sent indicates a slow/unstable network. + if tracker.bytes_read < tracker.len: + raise SlowUploadConnectionError from e + # Full body was sent — map to our types so _should_retry_exception handles retry logic. + if isinstance(e, exceptions.ConnectionError): + raise RequestConnectionError from e + raise + except Exception as e: + self._handle_exception(e) + @retry( retry=retry_if_exception(_should_retry_exception), stop=_RETRY_STOP_STRATEGY, @@ -146,14 +240,8 @@ def _execute( try: headers = self.get_request_headers(headers, without_auth=without_auth) - request = _get_request_function() - response = request(method=method, url=url, timeout=timeout, headers=headers, **kwargs) - - content = 'HIDDEN' if hide_response_content_log else response.text - logger.debug( - 'Receiving response, %s', - {'status_code': response.status_code, 'url': url, 'content': content}, - ) + response = _get_request_function()(method=method, url=url, timeout=timeout, headers=headers, **kwargs) + _log_response(response, url, hide_response_content_log) response.raise_for_status() return response diff --git a/cycode/cyclient/headers.py b/cycode/cyclient/headers.py index 5d10f69b..937f4333 100644 --- a/cycode/cyclient/headers.py +++ b/cycode/cyclient/headers.py @@ -5,7 +5,6 @@ from cycode import __version__ from cycode.cli import consts from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.utils.sentry import add_correlation_id_to_scope from cycode.cyclient.logger import logger @@ -42,8 +41,6 @@ def get_correlation_id(self) -> str: self._id = str(uuid4()) logger.debug('Correlation ID: %s', self._id) - add_correlation_id_to_scope(self._id) - return self._id diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index c3144a53..904fe0ef 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -114,6 +114,26 @@ def build_dto(self, data: dict[str, Any], **_) -> 'ScanResult': return ScanResult(**data) +@dataclass +class UploadLinkResponse: + upload_id: str + url: str + presigned_post_fields: dict[str, str] + + +class UploadLinkResponseSchema(Schema): + class Meta: + unknown = EXCLUDE + + upload_id = fields.String() + url = fields.String() + presigned_post_fields = fields.Dict(keys=fields.String(), values=fields.String()) + + @post_load + def build_dto(self, data: dict[str, Any], **_) -> 'UploadLinkResponse': + return UploadLinkResponse(**data) + + class ScanInitializationResponse(Schema): def __init__(self, scan_id: Optional[str] = None, err: Optional[str] = None) -> None: super().__init__() diff --git a/cycode/cyclient/report_client.py b/cycode/cyclient/report_client.py index e8107827..a55b5c40 100644 --- a/cycode/cyclient/report_client.py +++ b/cycode/cyclient/report_client.py @@ -6,8 +6,12 @@ from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cli.utils.url_utils import sanitize_repository_url from cycode.cyclient import models from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.logger import get_logger + +logger = get_logger('Report Client') @dataclasses.dataclass @@ -49,7 +53,11 @@ def request_sbom_report_execution( # entity type required only for zipped-file request_data = {'report_parameters': params.to_json(without_entity_type=zip_file is None)} if repository_url: - request_data['repository_url'] = repository_url + # Sanitize repository URL to remove any embedded credentials/tokens before sending to API + sanitized_url = sanitize_repository_url(repository_url) + if sanitized_url != repository_url: + logger.debug('Sanitized repository URL to remove credentials') + request_data['repository_url'] = sanitized_url request_args = { 'url_path': url_path, diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 4f2debca..18f400ac 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,16 +1,21 @@ import json from copy import deepcopy -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Callable, Optional, Union from uuid import UUID +import requests from requests import Response from cycode.cli import consts from cycode.cli.config import configuration_manager -from cycode.cli.exceptions.custom_exceptions import CycodeError, RequestHttpError +from cycode.cli.exceptions.custom_exceptions import ( + CycodeError, + RequestHttpError, + SlowUploadConnectionError, +) from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cyclient import models -from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.cycode_client_base import CycodeClientBase, UploadProgressTracker from cycode.cyclient.logger import logger if TYPE_CHECKING: @@ -25,6 +30,7 @@ def __init__( self.scan_config = scan_config self._SCAN_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/cli-scan' + self._SCAN_SERVICE_V4_CLI_CONTROLLER_PATH = 'api/v4/scans/cli' self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/detections/cli' self._POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' @@ -56,6 +62,10 @@ def get_scan_aggregation_report_url(self, aggregation_id: str, scan_type: str) - ) return models.ScanReportUrlResponseSchema().build_dto(response.json()) + def get_scan_service_v4_url_path(self, scan_type: str) -> str: + service_path = self.scan_config.get_service_name(scan_type) + return f'{service_path}/{self._SCAN_SERVICE_V4_CLI_CONTROLLER_PATH}' + def get_zipped_file_scan_async_url_path(self, scan_type: str, should_use_sync_flow: bool = False) -> str: async_scan_type = self.scan_config.get_async_scan_type(scan_type) async_entity_type = self.scan_config.get_async_entity_type(scan_type) @@ -108,18 +118,74 @@ def zipped_file_scan_async( scan_parameters: dict, is_git_diff: bool = False, is_commit_range: bool = False, + on_upload_progress: Optional[Callable[[int, int], None]] = None, ) -> models.ScanInitializationResponse: - files = {'file': ('multiple_files_scan.zip', zip_file.read())} - - response = self.scan_cycode_client.post( + response = self.scan_cycode_client.post_multipart( url_path=self.get_zipped_file_scan_async_url_path(scan_type), - data={ + form_fields={ 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters), 'is_commit_range': is_commit_range, 'compression_manifest': self._create_compression_manifest_string(zip_file), }, - files=files, + files={'file': ('multiple_files_scan.zip', zip_file.read(), 'application/octet-stream')}, + on_upload_progress=on_upload_progress, + ) + return models.ScanInitializationResponseSchema().load(response.json()) + + def get_upload_link(self, scan_type: str) -> models.UploadLinkResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/upload-link' + response = self.scan_cycode_client.get(url_path=url_path, hide_response_content_log=self._hide_response_log) + return models.UploadLinkResponseSchema().load(response.json()) + + def upload_to_presigned_post( + self, + url: str, + fields: dict[str, str], + zip_file: 'InMemoryZip', + on_upload_progress: Optional[Callable[[int, int], None]] = None, + ) -> None: + all_files = {key: (None, value) for key, value in fields.items()} + all_files['file'] = ('multiple_files_scan.zip', zip_file.read(), 'application/octet-stream') + + prepared = requests.Request('POST', 'https://dummy', files=all_files).prepare() + tracker = UploadProgressTracker(prepared.body, on_upload_progress) + + try: + # We are not using Cycode client, as we are calling aws S3. + response = requests.post( + url, + data=tracker, + headers={'Content-Type': prepared.headers['Content-Type']}, + timeout=self.scan_cycode_client.timeout, + ) + response.raise_for_status() + except (requests.exceptions.ChunkedEncodingError, requests.exceptions.ConnectionError) as e: + if tracker.bytes_read < tracker.len: + raise SlowUploadConnectionError from e + raise + + def scan_repository_from_upload_id( + self, + scan_type: str, + upload_id: str, + zip_file: InMemoryZip, + scan_parameters: dict, + is_git_diff: bool = False, + is_commit_range: bool = False, + ) -> models.ScanInitializationResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/repository' + response = self.scan_cycode_client.post( + url_path=url_path, + body={ + 'upload_id': upload_id, + 'is_git_diff': is_git_diff, + 'is_commit_range': is_commit_range, + 'scan_parameters': json.dumps(scan_parameters), + 'compression_manifest': self._create_compression_manifest_string(zip_file), + }, ) return models.ScanInitializationResponseSchema().load(response.json()) @@ -161,6 +227,29 @@ def commit_range_scan_async( ) return models.ScanInitializationResponseSchema().load(response.json()) + def commit_range_scan_from_upload_ids( + self, + scan_type: str, + from_commit_upload_id: str, + to_commit_upload_id: str, + from_commit_zip_file: InMemoryZip, + scan_parameters: dict, + is_git_diff: bool = False, + ) -> models.ScanInitializationResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/commit-range' + response = self.scan_cycode_client.post( + url_path=url_path, + body={ + 'from_commit_upload_id': from_commit_upload_id, + 'to_commit_upload_id': to_commit_upload_id, + 'is_git_diff': is_git_diff, + 'scan_parameters': json.dumps(scan_parameters), + 'compression_manifest': self._create_compression_manifest_string(from_commit_zip_file), + }, + ) + return models.ScanInitializationResponseSchema().load(response.json()) + def get_scan_details_path(self, scan_type: str, scan_id: str) -> str: return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}' diff --git a/cycode/logger.py b/cycode/logger.py index 2fd44e4f..c5cdebcf 100644 --- a/cycode/logger.py +++ b/cycode/logger.py @@ -31,8 +31,6 @@ def _set_io_encodings() -> None: logging.getLogger('werkzeug').setLevel(logging.WARNING) logging.getLogger('schedule').setLevel(logging.WARNING) logging.getLogger('kubernetes').setLevel(logging.WARNING) -logging.getLogger('binaryornot').setLevel(logging.WARNING) -logging.getLogger('chardet').setLevel(logging.WARNING) logging.getLogger('git.cmd').setLevel(logging.WARNING) logging.getLogger('git.util').setLevel(logging.WARNING) diff --git a/poetry.lock b/poetry.lock index 3f5f9388..44fa8fb6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,16 +1,16 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "altgraph" -version = "0.17.4" +version = "0.17.5" description = "Python graph (network) package" optional = false python-versions = "*" groups = ["executable"] -markers = "python_version < \"3.13\"" +markers = "python_version < \"3.15\"" files = [ - {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, - {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, + {file = "altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597"}, + {file = "altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7"}, ] [[package]] @@ -20,226 +20,363 @@ description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" groups = ["main"] +markers = "python_version < \"3.14\"" files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "annotated-types" +version = "0.8.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, +] + [[package]] name = "anyio" -version = "4.11.0" +version = "4.12.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_version >= \"3.10\"" files = [ - {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, - {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, ] [package.dependencies] exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" -sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -trio = ["trio (>=0.31.0)"] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] [[package]] name = "arrow" -version = "1.3.0" +version = "1.4.0" description = "Better dates & times for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, - {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, + {file = "arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205"}, + {file = "arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7"}, ] [package.dependencies] python-dateutil = ">=2.7.0" -types-python-dateutil = ">=2.8.10" +tzdata = {version = "*", markers = "python_version >= \"3.9\""} [package.extras] doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] -test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] +test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2025.2)", "simplejson (==3.*)"] [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, - {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] [[package]] -name = "binaryornot" -version = "0.4.4" -description = "Ultra-lightweight pure Python package to check if a file is binary or text." +name = "backports-datetime-fromisoformat" +version = "2.0.3" +description = "Backport of Python 3.11's datetime.fromisoformat" optional = false -python-versions = "*" +python-versions = ">3" groups = ["main"] +markers = "python_version < \"3.11\"" files = [ - {file = "binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4"}, - {file = "binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"}, + {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"}, ] -[package.dependencies] -chardet = ">=3.0.2" - [[package]] name = "certifi" -version = "2025.10.5" +version = "2026.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, - {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] [[package]] -name = "chardet" -version = "5.2.0" -description = "Universal encoding detector for Python 3" +name = "cffi" +version = "2.1.0" +description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" groups = ["main"] -files = [ - {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, - {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0"}, + {file = "cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd"}, + {file = "cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f"}, + {file = "cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e"}, + {file = "cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479"}, + {file = "cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458"}, + {file = "cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b"}, + {file = "cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a"}, + {file = "cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384"}, + {file = "cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a"}, + {file = "cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2"}, + {file = "cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512"}, + {file = "cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326"}, + {file = "cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd"}, + {file = "cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"}, + {file = "cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94"}, + {file = "cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76"}, + {file = "cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5"}, + {file = "cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629"}, + {file = "cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6"}, + {file = "cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853"}, + {file = "cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9"}, + {file = "cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b"}, + {file = "cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5"}, + {file = "cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210"}, + {file = "cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9"}, ] +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, + {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, + {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, ] [[package]] @@ -268,90 +405,199 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {test = "sys_platform == \"win32\""} [[package]] name = "coverage" -version = "7.2.7" +version = "7.10.7" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["test"] files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, - {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, - {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, - {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, - {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, - {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, - {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, - {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, + {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, + {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, + {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, + {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, + {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, + {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, + {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, + {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, + {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, + {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, ] [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "cryptography" +version = "49.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + [[package]] name = "dunamai" -version = "1.21.2" +version = "1.26.1" description = "Dynamic version generation" optional = false python-versions = ">=3.5" groups = ["executable"] files = [ - {file = "dunamai-1.21.2-py3-none-any.whl", hash = "sha256:87db76405bf9366f9b4925ff5bb1db191a9a1bd9f9693f81c4d3abb8298be6f0"}, - {file = "dunamai-1.21.2.tar.gz", hash = "sha256:05827fb5f032f5596bfc944b23f613c147e676de118681f3bb1559533d8a65c4"}, + {file = "dunamai-1.26.1-py3-none-any.whl", hash = "sha256:2727d939c5b4257cb01ea404372803b477f5176e5a347c43beaf89cd5072e853"}, + {file = "dunamai-1.26.1.tar.gz", hash = "sha256:3b46007bd65b00b4824ead0a1aee365fd22d0ec2b9c219497d4fd48f52860c8b"}, ] [package.dependencies] @@ -359,16 +605,16 @@ packaging = ">=20.9" [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "test"] +markers = "python_version < \"3.11\"" files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] -markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} @@ -393,14 +639,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.45" +version = "3.1.57" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"}, - {file = "gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c"}, + {file = "gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf"}, + {file = "gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488"}, ] [package.dependencies] @@ -408,8 +654,8 @@ gitdb = ">=4.0.1,<5" typing-extensions = {version = ">=3.10.0.2", markers = "python_version < \"3.10\""} [package.extras] -doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] -test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] +doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] +test = ["basedpyright (==1.39.9) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] [[package]] name = "h11" @@ -488,30 +734,30 @@ files = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["executable"] markers = "python_version == \"3.9\"" files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, ] [package.dependencies] @@ -521,10 +767,10 @@ zipp = ">=3.20" check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] +enabler = ["pytest-enabler (>=3.4)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] [[package]] name = "iniconfig" @@ -533,29 +779,43 @@ description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" groups = ["test"] +markers = "python_version < \"3.14\"" files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["test"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" description = "An implementation of JSON Schema validation for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, - {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, ] [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" -rpds-py = ">=0.7.1" +rpds-py = ">=0.25.0" [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] @@ -579,15 +839,15 @@ referencing = ">=0.31.0" [[package]] name = "macholib" -version = "1.16.3" +version = "1.16.4" description = "Mach-O header analysis and editing" optional = false python-versions = "*" groups = ["executable"] -markers = "python_version < \"3.13\" and sys_platform == \"darwin\"" +markers = "python_version < \"3.15\" and sys_platform == \"darwin\"" files = [ - {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, - {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, + {file = "macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea"}, + {file = "macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362"}, ] [package.dependencies] @@ -600,6 +860,7 @@ description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" groups = ["main"] +markers = "python_version < \"3.14\"" files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -618,50 +879,88 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, + {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] + [[package]] name = "marshmallow" -version = "3.22.0" +version = "4.0.1" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "marshmallow-3.22.0-py3-none-any.whl", hash = "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"}, - {file = "marshmallow-3.22.0.tar.gz", hash = "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e"}, + {file = "marshmallow-4.0.1-py3-none-any.whl", hash = "sha256:72f14ef346f81269dbddee891bac547dda1501e9e08b6a809756ea3dbb7936a1"}, + {file = "marshmallow-4.0.1.tar.gz", hash = "sha256:e1d860bd262737cb2d34e1541b84cb52c32c72c9474e3fe6f30f137ef8b0d97f"}, ] [package.dependencies] -packaging = ">=17.0" +backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""} +typing-extensions = {version = "*", markers = "python_version < \"3.11\""} [package.extras] -dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] -docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] -tests = ["pytest", "pytz", "simplejson"] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] +docs = ["autodocsumm (==0.2.14)", "furo (==2025.7.19)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.1)", "sphinxext-opengraph (==0.12.0)"] +tests = ["pytest", "simplejson"] [[package]] name = "mcp" -version = "1.18.0" +version = "1.28.1" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a"}, - {file = "mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6"}, + {file = "mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df"}, + {file = "mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683"}, ] [package.dependencies] anyio = ">=4.5" -httpx = ">=0.27.1" +httpx = ">=0.27.1,<1.0.0" httpx-sse = ">=0.4" jsonschema = ">=4.20.0" -pydantic = ">=2.11.0,<3.0.0" +pydantic = [ + {version = ">=2.11.0,<3.0.0", markers = "python_version < \"3.14\""}, + {version = ">=2.12.0,<3.0.0", markers = "python_version >= \"3.14\""}, +] pydantic-settings = ">=2.5.2" +pyjwt = {version = ">=2.10.1", extras = ["crypto"]} python-multipart = ">=0.0.9" -pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} +pywin32 = [ + {version = ">=310", markers = "sys_platform == \"win32\" and python_version < \"3.14\""}, + {version = ">=311", markers = "sys_platform == \"win32\" and python_version >= \"3.14\""}, +] sse-starlette = ">=1.6.1" -starlette = ">=0.27" +starlette = [ + {version = ">=0.27", markers = "python_version < \"3.14\""}, + {version = ">=0.48.0", markers = "python_version >= \"3.14\""}, +] +typing-extensions = ">=4.9.0" +typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} [package.extras] @@ -683,42 +982,43 @@ files = [ [[package]] name = "mock" -version = "4.0.3" +version = "5.2.0" description = "Rolling backport of unittest.mock for all Pythons" optional = false python-versions = ">=3.6" groups = ["test"] files = [ - {file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"}, - {file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"}, + {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, + {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, ] [package.extras] build = ["blurb", "twine", "wheel"] docs = ["sphinx"] -test = ["pytest (<5.4)", "pytest-cov"] +test = ["pytest", "pytest-cov"] [[package]] name = "packaging" -version = "25.0" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "executable", "test"] +groups = ["executable", "test"] files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] name = "patch-ng" -version = "1.18.1" +version = "1.19.1" description = "Library to parse and apply unified diffs." optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "patch-ng-1.18.1.tar.gz", hash = "sha256:52fd46ee46f6c8667692682c1fd7134edc65a2d2d084ebec1d295a6087fc0291"}, + {file = "patch_ng-1.19.1-py3-none-any.whl", hash = "sha256:d45fd47b3f74b48c3e336690341876bb26244a077a06f5f7e6e47c19c15c1ca4"}, + {file = "patch_ng-1.19.1.tar.gz", hash = "sha256:036a3cc00134ec53f37e92333958ee75e117f2e62a5ec2b85c7122e5e815c29e"}, ] [[package]] @@ -745,7 +1045,7 @@ description = "Python PE parsing module" optional = false python-versions = ">=3.6.0" groups = ["executable"] -markers = "python_version < \"3.13\" and sys_platform == \"win32\"" +markers = "python_version < \"3.15\" and sys_platform == \"win32\"" files = [ {file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"}, {file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"}, @@ -767,21 +1067,34 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pydantic" -version = "2.12.3" +version = "2.13.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf"}, - {file = "pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74"}, + {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, + {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.41.4" +pydantic-core = "2.46.4" typing-extensions = ">=4.14.1" typing-inspection = ">=0.4.2" @@ -791,129 +1104,132 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.41.4" +version = "2.46.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e"}, - {file = "pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9"}, - {file = "pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57"}, - {file = "pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc"}, - {file = "pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80"}, - {file = "pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db"}, - {file = "pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887"}, - {file = "pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8"}, - {file = "pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746"}, - {file = "pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89"}, - {file = "pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1"}, - {file = "pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0"}, - {file = "pydantic_core-2.41.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062"}, - {file = "pydantic_core-2.41.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8"}, - {file = "pydantic_core-2.41.4-cp39-cp39-win32.whl", hash = "sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb"}, - {file = "pydantic_core-2.41.4-cp39-cp39-win_amd64.whl", hash = "sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f"}, - {file = "pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, + {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, ] [package.dependencies] @@ -921,15 +1237,15 @@ typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" -version = "2.11.0" +version = "2.14.2" description = "Settings management using Pydantic" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, - {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, + {file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"}, + {file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"}, ] [package.dependencies] @@ -938,7 +1254,7 @@ python-dotenv = ">=0.21.0" typing-inspection = ">=0.4.0" [package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +aws-secrets-manager = ["boto3 (>=1.35.0)", "types-boto3[secretsmanager]"] azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] @@ -946,26 +1262,26 @@ yaml = ["pyyaml (>=6.0.1)"] [[package]] name = "pyfakefs" -version = "5.7.4" -description = "pyfakefs implements a fake file system that mocks the Python file system modules." +version = "5.10.2" +description = "Implements a fake file system that mocks the Python file system modules." optional = false python-versions = ">=3.7" groups = ["test"] files = [ - {file = "pyfakefs-5.7.4-py3-none-any.whl", hash = "sha256:3e763d700b91c54ade6388be2cfa4e521abc00e34f7defb84ee511c73031f45f"}, - {file = "pyfakefs-5.7.4.tar.gz", hash = "sha256:4971e65cc80a93a1e6f1e3a4654909c0c493186539084dc9301da3d68c8878fe"}, + {file = "pyfakefs-5.10.2-py3-none-any.whl", hash = "sha256:6ff0e84653a71efc6a73f9ee839c3141e3a7cdf4e1fb97666f82ac5b24308d64"}, + {file = "pyfakefs-5.10.2.tar.gz", hash = "sha256:8ae0e5421e08de4e433853a4609a06a1835f4bc2a3ce13b54f36713a897474ba"}, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" -groups = ["main"] +python-versions = ">=3.9" +groups = ["main", "test"] files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] @@ -973,97 +1289,100 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "5.13.2" +version = "6.21.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false -python-versions = "<3.13,>=3.7" +python-versions = "<3.16,>=3.8" groups = ["executable"] -markers = "python_version < \"3.13\"" -files = [ - {file = "pyinstaller-5.13.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:16cbd66b59a37f4ee59373a003608d15df180a0d9eb1a29ff3bfbfae64b23d0f"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8f6dd0e797ae7efdd79226f78f35eb6a4981db16c13325e962a83395c0ec7420"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_i686.whl", hash = "sha256:65133ed89467edb2862036b35d7c5ebd381670412e1e4361215e289c786dd4e6"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d51734423685ab2a4324ab2981d9781b203dcae42839161a9ee98bfeaabdade"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:2c2fe9c52cb4577a3ac39626b84cf16cf30c2792f785502661286184f162ae0d"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c63ef6133eefe36c4b2f4daf4cfea3d6412ece2ca218f77aaf967e52a95ac9b8"}, - {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:aadafb6f213549a5906829bb252e586e2cf72a7fbdb5731810695e6516f0ab30"}, - {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b2e1c7f5cceb5e9800927ddd51acf9cc78fbaa9e79e822c48b0ee52d9ce3c892"}, - {file = "pyinstaller-5.13.2-py3-none-win32.whl", hash = "sha256:421cd24f26144f19b66d3868b49ed673176765f92fa9f7914cd2158d25b6d17e"}, - {file = "pyinstaller-5.13.2-py3-none-win_amd64.whl", hash = "sha256:ddcc2b36052a70052479a9e5da1af067b4496f43686ca3cdda99f8367d0627e4"}, - {file = "pyinstaller-5.13.2-py3-none-win_arm64.whl", hash = "sha256:27cd64e7cc6b74c5b1066cbf47d75f940b71356166031deb9778a2579bb874c6"}, - {file = "pyinstaller-5.13.2.tar.gz", hash = "sha256:c8e5d3489c3a7cc5f8401c2d1f48a70e588f9967e391c3b06ddac1f685f8d5d2"}, +markers = "python_version < \"3.15\"" +files = [ + {file = "pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f"}, + {file = "pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1"}, + {file = "pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74"}, + {file = "pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9"}, + {file = "pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7"}, + {file = "pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025"}, + {file = "pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd"}, ] [package.dependencies] altgraph = "*" +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +packaging = ">=22.0" pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} -pyinstaller-hooks-contrib = ">=2021.4" +pyinstaller-hooks-contrib = ">=2026.6" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" [package.extras] -encryption = ["tinyaes (>=1.0.0)"] +completion = ["argcomplete"] hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2025.9" +version = "2026.6" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["executable"] -markers = "python_version < \"3.13\"" +markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller_hooks_contrib-2025.9-py3-none-any.whl", hash = "sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038"}, - {file = "pyinstaller_hooks_contrib-2025.9.tar.gz", hash = "sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6"}, + {file = "pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3"}, + {file = "pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725"}, ] [package.dependencies] importlib_metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} packaging = ">=22.0" -setuptools = ">=42.0.0" [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] [[package]] name = "pytest" -version = "7.3.2" +version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["test"] files = [ - {file = "pytest-7.3.2-py3-none-any.whl", hash = "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295"}, - {file = "pytest-7.3.2.tar.gz", hash = "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"}, + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-mock" @@ -1100,15 +1419,15 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, - {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, ] [package.extras] @@ -1116,46 +1435,47 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.32" description = "A streaming multipart parser for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, - {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, + {file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"}, + {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, ] [[package]] name = "pywin32" -version = "311" -description = "Python for Window Extensions" +version = "312" +description = "Python for Windows Extensions" optional = false -python-versions = "*" +python-versions = ">=3.9" groups = ["main"] -markers = "python_version >= \"3.10\" and sys_platform == \"win32\"" -files = [ - {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, - {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, - {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, - {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, - {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, - {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, - {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, - {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, - {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, - {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, - {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, - {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, - {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, - {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, - {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, - {file = "pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c"}, - {file = "pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd"}, - {file = "pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b"}, - {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, - {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, +markers = "sys_platform == \"win32\" and python_version >= \"3.10\"" +files = [ + {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, + {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, + {file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"}, + {file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"}, + {file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"}, + {file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"}, + {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, + {file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"}, + {file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"}, + {file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"}, + {file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"}, + {file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"}, + {file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"}, + {file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"}, + {file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"}, + {file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"}, + {file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"}, + {file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"}, + {file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"}, + {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, + {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, ] [[package]] @@ -1165,7 +1485,7 @@ description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false python-versions = ">=3.6" groups = ["executable"] -markers = "python_version < \"3.13\" and sys_platform == \"win32\"" +markers = "python_version < \"3.15\" and sys_platform == \"win32\"" files = [ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, @@ -1279,6 +1599,7 @@ description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" groups = ["main", "test"] +markers = "python_version < \"3.14\"" files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -1294,322 +1615,392 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main", "test"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + [[package]] name = "responses" -version = "0.23.3" +version = "0.26.2" description = "A utility library for mocking out the `requests` Python library." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["test"] files = [ - {file = "responses-0.23.3-py3-none-any.whl", hash = "sha256:e6fbcf5d82172fecc0aa1860fd91e58cbfd96cee5e96da5b63fa6eb3caa10dd3"}, - {file = "responses-0.23.3.tar.gz", hash = "sha256:205029e1cb334c21cb4ec64fc7599be48b859a0fd381a42443cdd600bfe8b16a"}, + {file = "responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364"}, + {file = "responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8"}, ] [package.dependencies] pyyaml = "*" requests = ">=2.30.0,<3.0" -types-PyYAML = "*" urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-requests"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "rich" -version = "13.9.4" +version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.9.0" groups = ["main"] files = [ - {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, - {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "rpds-py" -version = "0.27.1" +version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef"}, - {file = "rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10"}, - {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808"}, - {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8"}, - {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9"}, - {file = "rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4"}, - {file = "rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1"}, - {file = "rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881"}, - {file = "rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde"}, - {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21"}, - {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9"}, - {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948"}, - {file = "rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39"}, - {file = "rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15"}, - {file = "rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746"}, - {file = "rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90"}, - {file = "rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444"}, - {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a"}, - {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1"}, - {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998"}, - {file = "rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39"}, - {file = "rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594"}, - {file = "rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502"}, - {file = "rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b"}, - {file = "rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274"}, - {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd"}, - {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2"}, - {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002"}, - {file = "rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3"}, - {file = "rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83"}, - {file = "rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d"}, - {file = "rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228"}, - {file = "rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef"}, - {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081"}, - {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd"}, - {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7"}, - {file = "rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688"}, - {file = "rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797"}, - {file = "rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334"}, - {file = "rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60"}, - {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e"}, - {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212"}, - {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675"}, - {file = "rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3"}, - {file = "rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456"}, - {file = "rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3"}, - {file = "rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2"}, - {file = "rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb"}, - {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734"}, - {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb"}, - {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0"}, - {file = "rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a"}, - {file = "rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772"}, - {file = "rpds_py-0.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c918c65ec2e42c2a78d19f18c553d77319119bf43aa9e2edf7fb78d624355527"}, - {file = "rpds_py-0.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1fea2b1a922c47c51fd07d656324531adc787e415c8b116530a1d29c0516c62d"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf94c58e8e0cd6b6f38d8de67acae41b3a515c26169366ab58bdca4a6883bb8"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2a8fed130ce946d5c585eddc7c8eeef0051f58ac80a8ee43bd17835c144c2cc"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:037a2361db72ee98d829bc2c5b7cc55598ae0a5e0ec1823a56ea99374cfd73c1"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5281ed1cc1d49882f9997981c88df1a22e140ab41df19071222f7e5fc4e72125"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd50659a069c15eef8aa3d64bbef0d69fd27bb4a50c9ab4f17f83a16cbf8905"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:c4b676c4ae3921649a15d28ed10025548e9b561ded473aa413af749503c6737e"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:079bc583a26db831a985c5257797b2b5d3affb0386e7ff886256762f82113b5e"}, - {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e44099bd522cba71a2c6b97f68e19f40e7d85399de899d66cdb67b32d7cb786"}, - {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e202e6d4188e53c6661af813b46c37ca2c45e497fc558bacc1a7630ec2695aec"}, - {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f41f814b8eaa48768d1bb551591f6ba45f87ac76899453e8ccd41dba1289b04b"}, - {file = "rpds_py-0.27.1-cp39-cp39-win32.whl", hash = "sha256:9e71f5a087ead99563c11fdaceee83ee982fd39cf67601f4fd66cb386336ee52"}, - {file = "rpds_py-0.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:71108900c9c3c8590697244b9519017a400d9ba26a36c48381b3f64743a44aab"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aa8933159edc50be265ed22b401125c9eebff3171f570258854dbce3ecd55475"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50431bf02583e21bf273c71b89d710e7a710ad5e39c725b14e685610555926f"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78af06ddc7fe5cc0e967085a9115accee665fb912c22a3f54bad70cc65b05fe6"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70d0738ef8fee13c003b100c2fbd667ec4f133468109b3472d249231108283a3"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2f6fd8a1cea5bbe599b6e78a6e5ee08db434fc8ffea51ff201c8765679698b3"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8177002868d1426305bb5de1e138161c2ec9eb2d939be38291d7c431c4712df8"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:008b839781d6c9bf3b6a8984d1d8e56f0ec46dc56df61fd669c49b58ae800400"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:a55b9132bb1ade6c734ddd2759c8dc132aa63687d259e725221f106b83a0e485"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a46fdec0083a26415f11d5f236b79fa1291c32aaa4a17684d82f7017a1f818b1"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8a63b640a7845f2bdd232eb0d0a4a2dd939bcdd6c57e6bb134526487f3160ec5"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:7e32721e5d4922deaaf963469d795d5bde6093207c52fec719bd22e5d1bedbc4"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:2c426b99a068601b5f4623573df7a7c3d72e87533a2dd2253353a03e7502566c"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4fc9b7fe29478824361ead6e14e4f5aed570d477e06088826537e202d25fe859"}, - {file = "rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8"}, +markers = "python_version < \"3.14\" and python_version >= \"3.10\"" +files = [ + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.11" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826"}, + {file = "rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4"}, ] [[package]] name = "ruff" -version = "0.11.7" +version = "0.15.20" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c"}, - {file = "ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee"}, - {file = "ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63"}, - {file = "ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502"}, - {file = "ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92"}, - {file = "ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94"}, - {file = "ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6"}, - {file = "ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6"}, - {file = "ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26"}, - {file = "ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a"}, - {file = "ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177"}, - {file = "ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4"}, -] - -[[package]] -name = "sentry-sdk" -version = "2.42.1" -description = "Python client for Sentry (https://sentry.io)" + {file = "ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078"}, + {file = "ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b"}, + {file = "ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460"}, + {file = "ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21"}, + {file = "ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415"}, + {file = "ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca"}, + {file = "ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566"}, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +description = "Most extensible Python build backend with support for C/C++ extension modules" optional = false -python-versions = ">=3.6" -groups = ["main"] +python-versions = ">=3.9" +groups = ["executable"] +markers = "python_version < \"3.14\"" files = [ - {file = "sentry_sdk-2.42.1-py2.py3-none-any.whl", hash = "sha256:f8716b50c927d3beb41bc88439dc6bcd872237b596df5b14613e2ade104aee02"}, - {file = "sentry_sdk-2.42.1.tar.gz", hash = "sha256:8598cc6edcfe74cb8074ba6a7c15338cdee93d63d3eb9b9943b4b568354ad5b6"}, + {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, + {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, ] -[package.dependencies] -certifi = "*" -urllib3 = ">=1.26.11" - [package.extras] -aiohttp = ["aiohttp (>=3.5)"] -anthropic = ["anthropic (>=0.16)"] -arq = ["arq (>=0.23)"] -asyncpg = ["asyncpg (>=0.23)"] -beam = ["apache-beam (>=2.12)"] -bottle = ["bottle (>=0.12.13)"] -celery = ["celery (>=3)"] -celery-redbeat = ["celery-redbeat (>=2)"] -chalice = ["chalice (>=1.16.0)"] -clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] -django = ["django (>=1.8)"] -falcon = ["falcon (>=1.4)"] -fastapi = ["fastapi (>=0.79.0)"] -flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] -google-genai = ["google-genai (>=1.29.0)"] -grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] -http2 = ["httpcore[http2] (==1.*)"] -httpx = ["httpx (>=0.16.0)"] -huey = ["huey (>=2)"] -huggingface-hub = ["huggingface_hub (>=0.22)"] -langchain = ["langchain (>=0.0.210)"] -langgraph = ["langgraph (>=0.6.6)"] -launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] -litellm = ["litellm (>=1.77.5)"] -litestar = ["litestar (>=2.0.0)"] -loguru = ["loguru (>=0.5)"] -openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] -openfeature = ["openfeature-sdk (>=0.7.1)"] -opentelemetry = ["opentelemetry-distro (>=0.35b0)"] -opentelemetry-experimental = ["opentelemetry-distro"] -pure-eval = ["asttokens", "executing", "pure_eval"] -pymongo = ["pymongo (>=3.1)"] -pyspark = ["pyspark (>=2.4.4)"] -quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] -rq = ["rq (>=0.6)"] -sanic = ["sanic (>=0.8)"] -sqlalchemy = ["sqlalchemy (>=1.2)"] -starlette = ["starlette (>=0.19.1)"] -starlite = ["starlite (>=1.48)"] -statsig = ["statsig (>=0.55.3)"] -tornado = ["tornado (>=6)"] -unleash = ["UnleashClient (>=6.0.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] [[package]] name = "setuptools" -version = "80.9.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +version = "83.0.0" +description = "Most extensible Python build backend with support for C/C++ extension modules" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["executable"] -markers = "python_version < \"3.13\"" +markers = "python_version == \"3.14\"" files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, + {file = "setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3"}, + {file = "setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] +enabler = ["pytest-enabler (>=3.4)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] [[package]] name = "shellingham" @@ -1637,62 +2028,51 @@ files = [ [[package]] name = "smmap" -version = "5.0.2" +version = "5.0.3" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, - {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "python_version >= \"3.10\"" -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, + {file = "smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f"}, + {file = "smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c"}, ] [[package]] name = "sse-starlette" -version = "3.0.2" +version = "3.4.6" description = "SSE plugin for Starlette" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a"}, - {file = "sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a"}, + {file = "sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6"}, + {file = "sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627"}, ] [package.dependencies] anyio = ">=4.7.0" +starlette = ">=0.49.1" [package.extras] daphne = ["daphne (>=4.2.0)"] -examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"] +examples = ["fastapi (>=0.115.12)", "pydantic (>=2)", "uvicorn (>=0.34.0)"] +examples-db = ["aiosqlite (>=0.21.0)", "sqlalchemy[asyncio] (>=2.0.41)"] granian = ["granian (>=2.3.1)"] uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "0.48.0" +version = "1.3.1" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"}, - {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"}, + {file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"}, + {file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"}, ] [package.dependencies] @@ -1700,18 +2080,36 @@ anyio = ">=3.6.2,<5" typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "tenacity" -version = "9.0.0" +version = "9.1.2" description = "Retry code until it succeeds" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] +markers = "python_version < \"3.14\"" files = [ - {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"}, - {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"}, + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + +[[package]] +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, ] [package.extras] @@ -1720,55 +2118,72 @@ test = ["pytest", "tornado (>=4.5)", "typeguard"] [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["test"] +groups = ["main", "test"] markers = "python_version < \"3.11\"" files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +description = "A lil' TOML writer" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, + {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, ] [[package]] @@ -1789,40 +2204,16 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" -[[package]] -name = "types-python-dateutil" -version = "2.9.0.20251008" -description = "Typing stubs for python-dateutil" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "types_python_dateutil-2.9.0.20251008-py3-none-any.whl", hash = "sha256:b9a5232c8921cf7661b29c163ccc56055c418ab2c6eabe8f917cbcc73a4c4157"}, - {file = "types_python_dateutil-2.9.0.20251008.tar.gz", hash = "sha256:c3826289c170c93ebd8360c3485311187df740166dbab9dd3b792e69f2bc1f9c"}, -] - -[[package]] -name = "types-pyyaml" -version = "6.0.12.20250915" -description = "Typing stubs for PyYAML" -optional = false -python-versions = ">=3.9" -groups = ["test"] -files = [ - {file = "types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6"}, - {file = "types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3"}, -] - [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] markers = {test = "python_version < \"3.11\""} @@ -1841,34 +2232,67 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2026.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, + {file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"}, +] + [[package]] name = "urllib3" -version = "1.26.19" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +python-versions = ">=3.9" groups = ["main", "test"] +markers = "python_version < \"3.14\"" files = [ - {file = "urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3"}, - {file = "urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "urllib3" +version = "2.7.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["main", "test"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.51.0" description = "The lightning-fast ASGI server." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\" and sys_platform != \"emscripten\"" files = [ - {file = "uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02"}, - {file = "uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d"}, + {file = "uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b"}, + {file = "uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0"}, ] [package.dependencies] @@ -1877,19 +2301,19 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["httptools (>=0.8.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=13.0)"] [[package]] name = "zipp" -version = "3.23.0" +version = "3.23.1" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["executable"] markers = "python_version == \"3.9\"" files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, + {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"}, + {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"}, ] [package.extras] @@ -1903,4 +2327,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "f0854d96f0878d9765ad704e15f5c7b53f2387a81df64a2d04e9221959720662" +content-hash = "ba9807509e16982bc1c02848ce47c5a149542dc6613dd8518192e79ea4acb351" diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 00000000..9f0eee61 --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[solver] +min-release-age = 7 # Requires Poetry >= 2.4.0; silently ignored by older versions diff --git a/process_executable_file.py b/process_executable_file.py index 367bb18d..19cfbb44 100755 --- a/process_executable_file.py +++ b/process_executable_file.py @@ -22,7 +22,7 @@ _OS_TO_CLI_DIST_TEMPLATE = { 'darwin': Template('cycode-mac$suffix$ext'), 'linux': Template('cycode-linux$suffix$ext'), - 'windows': Template('cycode-win$suffix.exe$ext'), + 'windows': Template('cycode-win$suffix$ext'), } _WINDOWS = 'windows' _WINDOWS_EXECUTABLE_SUFFIX = '.exe' @@ -87,8 +87,7 @@ def get_cli_file_name(suffix: str = '', ext: str = '') -> str: if os_name not in _OS_TO_CLI_DIST_TEMPLATE: raise Exception(f'Unsupported OS: {os_name}') - template = _OS_TO_CLI_DIST_TEMPLATE[os_name] - return template.substitute(suffix=suffix, ext=ext) + return _OS_TO_CLI_DIST_TEMPLATE[os_name].substitute(suffix=suffix, ext=ext) def get_cli_file_suffix(is_onedir: bool) -> str: @@ -117,7 +116,9 @@ def write_hashes_db_to_file(hashes: DirHashes, output_path: str) -> None: def get_cli_filename(is_onedir: bool) -> str: - return get_cli_file_name(get_cli_file_suffix(is_onedir)) + # onedir is distributed as an archive of a directory, so only onefile carries .exe + ext = _WINDOWS_EXECUTABLE_SUFFIX if get_os_name() == _WINDOWS and not is_onedir else '' + return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir), ext=ext) def get_cli_path(output_path: Path, is_onedir: bool) -> str: @@ -125,7 +126,7 @@ def get_cli_path(output_path: Path, is_onedir: bool) -> str: def get_cli_hash_filename(is_onedir: bool) -> str: - return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir), ext=_HASH_FILE_EXT) + return get_cli_filename(is_onedir) + _HASH_FILE_EXT def get_cli_hash_path(output_path: Path, is_onedir: bool) -> str: @@ -133,13 +134,17 @@ def get_cli_hash_path(output_path: Path, is_onedir: bool) -> str: def get_cli_archive_filename(is_onedir: bool) -> str: - return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir)) + return get_cli_filename(is_onedir) def get_cli_archive_path(output_path: Path, is_onedir: bool) -> str: return os.path.join(output_path, get_cli_archive_filename(is_onedir)) +def archive_directory(input_path: Path, output_path: str) -> None: + shutil.make_archive(output_path.removesuffix(f'.{_ARCHIVE_FORMAT}'), _ARCHIVE_FORMAT, input_path) + + def process_executable_file(input_path: Path, is_onedir: bool) -> str: output_path = input_path.parent hash_file_path = get_cli_hash_path(output_path, is_onedir) @@ -150,7 +155,7 @@ def process_executable_file(input_path: Path, is_onedir: bool) -> str: write_hashes_db_to_file(normalized_hashes, hash_file_path) archived_file_path = get_cli_archive_path(output_path, is_onedir) - shutil.make_archive(archived_file_path, _ARCHIVE_FORMAT, input_path) + archive_directory(input_path, f'{archived_file_path}.{_ARCHIVE_FORMAT}') shutil.rmtree(input_path) else: file_hash = get_hash_of_file(input_path) diff --git a/pyinstaller.spec b/pyinstaller.spec index 39b8588f..e5be2bc2 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -2,6 +2,10 @@ # Run `poetry run pyinstaller pyinstaller.spec` to generate the binary. # Set the env var `CYCODE_ONEDIR_MODE` to generate a single directory instead of a single file. +import os +import platform +import subprocess + _INIT_FILE_PATH = os.path.join('cycode', '__init__.py') _CODESIGN_IDENTITY = os.environ.get('APPLE_CERT_NAME') _ONEDIR_MODE = os.environ.get('CYCODE_ONEDIR_MODE') is not None @@ -21,12 +25,47 @@ CLI_VERSION = _dunamai.get_version('cycode', first_choice=_dunamai.Version.from_ with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: file.write(prev_content.replace(VERSION_PLACEHOLDER, CLI_VERSION)) +# Top-level subapp modules are loaded lazily via importlib.import_module() in +# cycode/cli/app.py to keep startup fast on hot paths (e.g. ai-guardrails scan). +# PyInstaller's static analyzer can't see those imports, so list them explicitly. +_hiddenimports = [ + 'cycode.cli.apps.ai_guardrails', + 'cycode.cli.apps.ai_remediation', + 'cycode.cli.apps.auth', + 'cycode.cli.apps.configure', + 'cycode.cli.apps.ignore', + 'cycode.cli.apps.report', + 'cycode.cli.apps.report_import', + 'cycode.cli.apps.scan', + 'cycode.cli.apps.status', + 'cycode.cli.apps.mcp', +] + a = Analysis( scripts=['cycode/cli/main.py'], - excludes=['tests'], + excludes=['tests', 'setuptools', 'pkg_resources'], + hiddenimports=_hiddenimports, ) -exe_args = [PYZ(a.pure, a.zipped_data), a.scripts, a.binaries, a.zipfiles, a.datas] +if platform.system() == 'Darwin': + # cryptography ships no macOS x86_64 wheel since 46.0.4, so on Intel it is built from source and + # dynamically links Homebrew's OpenSSL 3 (it needs symbols like `SSL_get0_group_name`, added in + # OpenSSL 3.2). PyInstaller also collects the older OpenSSL 3.0.x that ships with the + # setup-python toolcache Python; both land at the same destination name and the toolcache copy + # wins the dedup, which breaks `import cryptography` at runtime. Drop every collected + # libssl/libcrypto and inject Homebrew's, which satisfies both consumers. + try: + openssl_lib = os.path.join( + subprocess.check_output(['brew', '--prefix', 'openssl@3'], text=True).strip(), 'lib' + ) + a.binaries = [b for b in a.binaries if 'libssl' not in b[0] and 'libcrypto' not in b[0]] + for name in ('libssl.3.dylib', 'libcrypto.3.dylib'): + a.binaries.append((name, os.path.join(openssl_lib, name), 'BINARY')) + print(f'Replaced collected OpenSSL dylibs with Homebrew ones from {openssl_lib}') + except Exception as e: + print(f'Warning: Could not override OpenSSL binaries: {e}') + +exe_args = [PYZ(a.pure), a.scripts, a.binaries, a.datas] if _ONEDIR_MODE: exe_args = [PYZ(a.pure), a.scripts] diff --git a/pyproject.toml b/pyproject.toml index 65fa2d65..2d2beccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,36 +36,39 @@ version = "0.0.0" # DON'T TOUCH. Placeholder. Will be filled automatically on po click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" -marshmallow = ">=3.15.0,<3.23.0" # 3.23 dropped support for Python 3.8 -gitpython = ">=3.1.30,<3.2.0" -arrow = ">=1.0.0,<1.4.0" -binaryornot = ">=0.4.4,<0.5.0" +# capped while we support Python 3.9 +marshmallow = ">=4.0.1,<4.1.0" +# floor required by get_staged_diff_index() +gitpython = ">=3.1.51,<3.2.0" +arrow = ">=1.0.0,<1.5.0" requests = ">=2.32.4,<3.0" -urllib3 = "1.26.19" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS -sentry-sdk = ">=2.8.0,<3.0" +urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" -rich = ">=13.9.4, <14" -patch-ng = "1.18.1" +rich = ">=15.0.0,<16.0.0" +patch-ng = "1.19.1" typer = "^0.15.3" -tenacity = ">=9.0.0,<9.1.0" -mcp = { version = ">=1.9.3,<2.0.0", markers = "python_version >= '3.10'" } +tenacity = ">=9.1.2,<9.2.0" +mcp = { version = ">=1.28.1,<2.0.0", markers = "python_version >= '3.10'" } pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" +tomli-w = ">=1.0.0,<2.0.0" +tomli = {version = ">=2.0.0,<3.0.0", python = "<3.11"} +anyio = ">=4.0.0, <4.13.0" [tool.poetry.group.test.dependencies] -mock = ">=4.0.3,<4.1.0" -pytest = ">=7.3.1,<7.4.0" +mock = ">=5.2.0,<5.3.0" +pytest = ">=7.3.1,<8.5.0" pytest-mock = ">=3.10.0,<3.11.0" -coverage = ">=7.2.3,<7.3.0" -responses = ">=0.23.1,<0.24.0" -pyfakefs = ">=5.7.2,<5.8.0" +coverage = ">=7.2.3,<7.11.0" +responses = ">=0.23.1,<0.27.0" +pyfakefs = ">=5.7.2,<5.11.0" [tool.poetry.group.executable.dependencies] -pyinstaller = {version=">=5.13.2,<5.14.0", python=">=3.8,<3.13"} -dunamai = ">=1.18.0,<1.22.0" +pyinstaller = {version=">=6.20.0,<7.0.0", python=">=3.9,<3.15"} +dunamai = ">=1.26.1,<1.27.0" [tool.poetry.group.dev.dependencies] -ruff = "0.11.7" +ruff = "0.15.20" [tool.pytest.ini_options] log_cli = true diff --git a/tests/cli/apps/__init__.py b/tests/cli/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/apps/api/__init__.py b/tests/cli/apps/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/apps/api/test_api_command.py b/tests/cli/apps/api/test_api_command.py new file mode 100644 index 00000000..0e981f33 --- /dev/null +++ b/tests/cli/apps/api/test_api_command.py @@ -0,0 +1,110 @@ +"""Tests for the OpenAPI-to-Click translator.""" + +from cycode.cli.apps.api.api_command import ( + _find_common_prefix, + _normalize_tag, + _param_to_option_name, + _path_to_command_name, +) + +# --- _normalize_tag --- + + +def test_normalize_tag_simple() -> None: + assert _normalize_tag('Projects') == 'projects' + + +def test_normalize_tag_multi_word() -> None: + assert _normalize_tag('Scan Statistics') == 'scan-statistics' + + +def test_normalize_tag_with_special_chars() -> None: + assert _normalize_tag('CLI scan statistics') == 'cli-scan-statistics' + + +def test_normalize_tag_strips_leading_trailing_separators() -> None: + assert _normalize_tag(' Projects ') == 'projects' + + +# --- _param_to_option_name --- + + +def test_param_to_option_name_snake_case() -> None: + assert _param_to_option_name('page_size') == '--page-size' + + +def test_param_to_option_name_camel_case() -> None: + assert _param_to_option_name('pageSize') == '--page-size' + + +def test_param_to_option_name_with_dot() -> None: + assert _param_to_option_name('filter.status') == '--filter-status' + + +def test_param_to_option_name_already_kebab() -> None: + assert _param_to_option_name('page-size') == '--page-size' + + +# --- _find_common_prefix --- + + +def test_find_common_prefix_empty() -> None: + assert _find_common_prefix([]) == '' + + +def test_find_common_prefix_single_path() -> None: + # Single path: use parent directory as prefix + assert _find_common_prefix(['/v4/projects']) == '/v4' + + +def test_find_common_prefix_two_paths_with_common_parent() -> None: + assert _find_common_prefix(['/v4/projects', '/v4/projects/assets']) == '/v4/projects' + + +def test_find_common_prefix_two_paths_with_grandparent() -> None: + assert _find_common_prefix(['/v4/projects', '/v4/members']) == '/v4' + + +def test_find_common_prefix_identical_paths() -> None: + assert _find_common_prefix(['/v4/projects', '/v4/projects']) == '/v4/projects' + + +# --- _path_to_command_name --- + + +def test_path_to_command_name_collection() -> None: + # /v4/projects with prefix /v4/projects -> nothing left -> 'list' + assert _path_to_command_name('/v4/projects', '/v4/projects', has_path_params=False) == 'list' + + +def test_path_to_command_name_single_resource() -> None: + # /v4/projects/{id} with prefix /v4/projects -> only path param left -> 'view' + assert _path_to_command_name('/v4/projects/{projectId}', '/v4/projects', has_path_params=True) == 'view' + + +def test_path_to_command_name_sub_resource() -> None: + # /v4/projects/assets with prefix /v4/projects -> 'assets' + assert _path_to_command_name('/v4/projects/assets', '/v4/projects', has_path_params=False) == 'assets' + + +def test_path_to_command_name_sub_resource_count() -> None: + # /v4/violations/count with prefix /v4/violations -> 'count' + assert _path_to_command_name('/v4/violations/count', '/v4/violations', has_path_params=False) == 'count' + + +def test_path_to_command_name_multi_segment() -> None: + # /v4/projects/collisions/count with prefix /v4/projects -> 'collisions-count' + assert ( + _path_to_command_name('/v4/projects/collisions/count', '/v4/projects', has_path_params=False) + == 'collisions-count' + ) + + +def test_path_to_command_name_with_path_param_in_middle() -> None: + # /v4/workflows/{id}/jobs with prefix /v4/workflows -> 'jobs' (path param stripped) + assert _path_to_command_name('/v4/workflows/{workflowId}/jobs', '/v4/workflows', has_path_params=True) == 'jobs' + + +def test_path_to_command_name_kebab_case_normalization() -> None: + # Path with underscores or special chars -> kebab-case + assert _path_to_command_name('/v4/brokers/broker_metrics', '/v4/brokers', has_path_params=False) == 'broker-metrics' diff --git a/tests/cli/apps/api/test_openapi_spec.py b/tests/cli/apps/api/test_openapi_spec.py new file mode 100644 index 00000000..1b863d89 --- /dev/null +++ b/tests/cli/apps/api/test_openapi_spec.py @@ -0,0 +1,72 @@ +"""Tests for the OpenAPI spec parser.""" + +from cycode.cli.apps.api.openapi_spec import parse_spec_commands + + +def test_parse_spec_commands_groups_by_tag() -> None: + spec = { + 'paths': { + '/v4/projects': { + 'get': {'tags': ['Projects'], 'summary': 'Get projects'}, + }, + '/v4/violations': { + 'get': {'tags': ['Violations'], 'summary': 'Get violations'}, + }, + } + } + groups = parse_spec_commands(spec) + assert set(groups.keys()) == {'Projects', 'Violations'} + + +def test_parse_spec_commands_extracts_path_params() -> None: + spec = { + 'paths': { + '/v4/projects/{projectId}': { + 'get': { + 'tags': ['Projects'], + 'parameters': [ + {'name': 'projectId', 'in': 'path', 'required': True}, + {'name': 'page_size', 'in': 'query', 'required': False}, + ], + }, + }, + } + } + groups = parse_spec_commands(spec) + ep = groups['Projects'][0] + assert len(ep['path_params']) == 1 + assert ep['path_params'][0]['name'] == 'projectId' + assert len(ep['query_params']) == 1 + assert ep['query_params'][0]['name'] == 'page_size' + + +def test_parse_spec_commands_captures_deprecated_flag() -> None: + spec = { + 'paths': { + '/v4/old': { + 'get': {'tags': ['T'], 'summary': 'old', 'deprecated': True}, + }, + '/v4/new': { + 'get': {'tags': ['T'], 'summary': 'new'}, + }, + } + } + groups = parse_spec_commands(spec) + by_path = {ep['path']: ep for ep in groups['T']} + assert by_path['/v4/old']['deprecated'] is True + assert by_path['/v4/new']['deprecated'] is False + + +def test_parse_spec_commands_no_tags_uses_other() -> None: + spec = { + 'paths': { + '/v4/foo': {'get': {}}, + } + } + groups = parse_spec_commands(spec) + assert 'other' in groups + + +def test_parse_spec_commands_empty_spec() -> None: + assert parse_spec_commands({}) == {} + assert parse_spec_commands({'paths': {}}) == {} diff --git a/tests/cli/apps/mcp/__init__.py b/tests/cli/apps/mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/apps/mcp/test_mcp_command.py b/tests/cli/apps/mcp/test_mcp_command.py new file mode 100644 index 00000000..cbb65b1c --- /dev/null +++ b/tests/cli/apps/mcp/test_mcp_command.py @@ -0,0 +1,399 @@ +import json +import os +import sys +from unittest.mock import AsyncMock, patch + +import pytest + +if sys.version_info < (3, 10): + pytest.skip('MCP requires Python 3.10+', allow_module_level=True) + +from cycode.cli.apps.mcp.mcp_command import ( + _build_scan_summary, + _sanitize_file_path, + _TempFilesManager, +) + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return 'asyncio' + + +# --- _sanitize_file_path input validation --- + + +def test_sanitize_file_path_rejects_empty_string() -> None: + with pytest.raises(ValueError, match='non-empty string'): + _sanitize_file_path('') + + +def test_sanitize_file_path_rejects_none() -> None: + with pytest.raises(ValueError, match='non-empty string'): + _sanitize_file_path(None) + + +def test_sanitize_file_path_rejects_non_string() -> None: + with pytest.raises(ValueError, match='non-empty string'): + _sanitize_file_path(123) + + +def test_sanitize_file_path_strips_null_bytes() -> None: + result = _sanitize_file_path('foo/bar\x00baz.py') + assert '\x00' not in result + + +def test_sanitize_file_path_passes_valid_path_through() -> None: + result = _sanitize_file_path('src/main.py') + assert os.path.normpath(result) == os.path.normpath('src/main.py') + + +# --- _TempFilesManager: path traversal prevention --- +# +# _sanitize_file_path delegates to pathvalidate which does NOT block +# path traversal (../ passes through). The real security boundary is +# the normpath containment check in _TempFilesManager.__enter__ (lines 136-139). +# These tests verify that the two layers together prevent escaping the temp dir. + + +def test_traversal_simple_dotdot_rejected() -> None: + """../../../etc/passwd must not escape the temp directory.""" + files = { + '../../../etc/passwd': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-traversal') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + for tf in temp_files: + assert '/etc/passwd' not in tf + + +def test_traversal_backslash_dotdot_rejected() -> None: + """..\\..\\windows\\system32 must not escape the temp directory.""" + files = { + '..\\..\\windows\\system32\\config': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-backslash') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + + +def test_traversal_embedded_dotdot_rejected() -> None: + """foo/../../../etc/passwd resolves outside temp dir and must be rejected.""" + files = { + 'foo/../../../etc/passwd': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-embedded') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + + +def test_traversal_absolute_path_rejected() -> None: + """Absolute paths must not be written outside the temp directory.""" + files = { + '/etc/passwd': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-absolute') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + + +def test_traversal_dotdot_only_rejected() -> None: + """A bare '..' path must be rejected.""" + files = { + '..': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-bare-dotdot') as temp_files: + assert len(temp_files) == 1 + + +def test_traversal_all_malicious_raises() -> None: + """If every file path is a traversal attempt, no files are created and ValueError is raised.""" + files = { + '../../../etc/passwd': 'malicious', + '../../shadow': 'also malicious', + } + with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-all-malicious'): + pass + + +def test_all_created_files_are_inside_temp_dir() -> None: + """Every created file must be under the temp base directory.""" + files = { + 'a.py': 'aaa', + 'sub/b.py': 'bbb', + 'sub/deep/c.py': 'ccc', + } + manager = _TempFilesManager(files, 'test-containment') + with manager as temp_files: + base = os.path.normcase(os.path.normpath(manager.temp_base_dir)) + for tf in temp_files: + normalized = os.path.normcase(os.path.normpath(tf)) + assert normalized.startswith(base + os.sep), f'{tf} escaped temp dir {base}' + + +def test_mixed_valid_and_traversal_only_creates_valid() -> None: + """Valid files are created, traversal attempts are silently skipped.""" + files = { + '../escape.py': 'bad', + 'legit.py': 'good', + 'foo/../../escape2.py': 'bad', + 'src/app.py': 'good', + } + manager = _TempFilesManager(files, 'test-mixed') + with manager as temp_files: + base = os.path.normcase(os.path.normpath(manager.temp_base_dir)) + assert len(temp_files) == 2 + for tf in temp_files: + assert os.path.normcase(os.path.normpath(tf)).startswith(base + os.sep) + basenames = [os.path.basename(tf) for tf in temp_files] + assert 'legit.py' in basenames + assert 'app.py' in basenames + + +# --- _TempFilesManager: general functionality --- + + +def test_temp_files_manager_creates_files() -> None: + files = { + 'test1.py': 'print("hello")', + 'subdir/test2.js': 'console.log("world")', + } + with _TempFilesManager(files, 'test-call-id') as temp_files: + assert len(temp_files) == 2 + for tf in temp_files: + assert os.path.exists(tf) + + +def test_temp_files_manager_writes_correct_content() -> None: + files = {'hello.py': 'print("hello world")'} + with _TempFilesManager(files, 'test-content') as temp_files, open(temp_files[0]) as f: + assert f.read() == 'print("hello world")' + + +def test_temp_files_manager_cleans_up_on_exit() -> None: + files = {'cleanup.py': 'code'} + manager = _TempFilesManager(files, 'test-cleanup') + with manager as temp_files: + temp_dir = manager.temp_base_dir + assert os.path.exists(temp_dir) + assert len(temp_files) == 1 + assert not os.path.exists(temp_dir) + + +def test_temp_files_manager_empty_path_raises() -> None: + files = {'': 'empty path'} + with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-empty-path'): + pass + + +def test_temp_files_manager_preserves_subdirectory_structure() -> None: + files = { + 'src/main.py': 'main', + 'src/utils/helper.py': 'helper', + } + with _TempFilesManager(files, 'test-dirs') as temp_files: + assert len(temp_files) == 2 + paths = [os.path.basename(tf) for tf in temp_files] + assert 'main.py' in paths + assert 'helper.py' in paths + + +# --- _run_cycode_command (async) --- + + +@pytest.mark.anyio +async def test_run_cycode_command_returns_dict() -> None: + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + mock_process = AsyncMock() + mock_process.communicate.return_value = (b'', b'error output') + mock_process.returncode = 1 + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('--invalid-flag-for-test') + assert isinstance(result, dict) + assert 'error' in result + + +@pytest.mark.anyio +async def test_run_cycode_command_parses_json_output() -> None: + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + mock_process = AsyncMock() + mock_process.communicate.return_value = (b'{"status": "ok"}', b'') + mock_process.returncode = 0 + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('version') + assert result == {'status': 'ok'} + + +@pytest.mark.anyio +async def test_run_cycode_command_handles_invalid_json() -> None: + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + mock_process = AsyncMock() + mock_process.communicate.return_value = (b'not json{', b'') + mock_process.returncode = 0 + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('version') + assert result['error'] == 'Failed to parse JSON output' + + +@pytest.mark.anyio +async def test_run_cycode_command_timeout() -> None: + import asyncio + + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + async def slow_communicate() -> tuple[bytes, bytes]: + await asyncio.sleep(10) + return b'', b'' + + mock_process = AsyncMock() + mock_process.communicate = slow_communicate + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('status', timeout=0.001) + assert isinstance(result, dict) + assert 'error' in result + assert 'timeout' in result['error'].lower() + + +# --- _cycode_scan_tool --- + + +@pytest.mark.anyio +async def test_cycode_scan_tool_no_files_no_paths() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET) + parsed = json.loads(result) + assert 'error' in parsed + assert 'No files or paths provided' in parsed['error'] + + +@pytest.mark.anyio +async def test_cycode_scan_tool_no_files() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET, files={}) + parsed = json.loads(result) + assert 'error' in parsed + assert 'No files or paths provided' in parsed['error'] + + +@pytest.mark.anyio +async def test_cycode_scan_tool_invalid_files() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET, files={'': 'content'}) + parsed = json.loads(result) + assert 'error' in parsed + + +@pytest.mark.anyio +async def test_cycode_scan_tool_paths_not_found() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET, paths=['/nonexistent/path/that/does/not/exist']) + parsed = json.loads(result) + assert 'error' in parsed + assert 'not found on disk' in parsed['error'] + + +# --- _build_scan_summary --- + + +def test_build_scan_summary_no_detections() -> None: + result = _build_scan_summary({'scan_ids': [], 'detections': [], 'report_urls': [], 'errors': []}) + assert result == 'No violations found.' + + +def test_build_scan_summary_no_detections_with_errors() -> None: + result = _build_scan_summary({'detections': [], 'errors': [{'code': 'E001', 'message': 'oops'}]}) + assert '1 error' in result + assert 'no violations' in result.lower() + + +def test_build_scan_summary_single_violation() -> None: + result = _build_scan_summary({'detections': [{'severity': 'HIGH'}], 'errors': []}) + assert '1 violation' in result + assert 'HIGH' in result + + +def test_build_scan_summary_multiple_severities() -> None: + detections = [ + {'severity': 'CRITICAL'}, + {'severity': 'HIGH'}, + {'severity': 'HIGH'}, + {'severity': 'MEDIUM'}, + ] + result = _build_scan_summary({'detections': detections, 'errors': []}) + assert '4 violations' in result + assert '1 CRITICAL' in result + assert '2 HIGH' in result + assert '1 MEDIUM' in result + + +def test_build_scan_summary_severity_order() -> None: + """CRITICAL should appear before HIGH before MEDIUM before LOW.""" + detections = [ + {'severity': 'LOW'}, + {'severity': 'CRITICAL'}, + {'severity': 'MEDIUM'}, + {'severity': 'HIGH'}, + ] + result = _build_scan_summary({'detections': detections, 'errors': []}) + critical_pos = result.index('CRITICAL') + high_pos = result.index('HIGH') + medium_pos = result.index('MEDIUM') + low_pos = result.index('LOW') + assert critical_pos < high_pos < medium_pos < low_pos + + +def test_build_scan_summary_unknown_severity() -> None: + result = _build_scan_summary({'detections': [{'severity': None}], 'errors': []}) + assert '1 violation' in result + assert 'UNKNOWN' in result + + +def test_build_scan_summary_missing_detections_key() -> None: + result = _build_scan_summary({}) + assert result == 'No violations found.' + + +# --- _create_mcp_server --- + + +def test_create_mcp_server() -> None: + from cycode.cli.apps.mcp.mcp_command import _create_mcp_server + + server = _create_mcp_server('127.0.0.1', 8000) + assert server is not None + assert server.name == 'cycode' + + +def test_create_mcp_server_registers_tools() -> None: + from cycode.cli.apps.mcp.mcp_command import _create_mcp_server + + server = _create_mcp_server('127.0.0.1', 8000) + tool_names = [t.name for t in server._tool_manager._tools.values()] + assert 'cycode_status' in tool_names + assert 'cycode_secret_scan' in tool_names + assert 'cycode_sca_scan' in tool_names + assert 'cycode_iac_scan' in tool_names + assert 'cycode_sast_scan' in tool_names diff --git a/tests/cli/commands/ai_guardrails/__init__.py b/tests/cli/commands/ai_guardrails/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/ides/__init__.py b/tests/cli/commands/ai_guardrails/ides/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py new file mode 100644 index 00000000..743bb372 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -0,0 +1,388 @@ +"""Claude Code IDE integration tests.""" + +import json +from pathlib import Path +from unittest.mock import patch + +from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.claude_code import ( + ClaudeCode, + _email_from_config, + _read_claude_plugin, + load_claude_config, + resolve_plugins, +) +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_matches_payload_only_claude_events() -> None: + claude = ClaudeCode() + transcript = {'transcript_path': '/home/user/.claude/projects/transcript.jsonl'} + assert claude.matches_payload({'hook_event_name': 'UserPromptSubmit', **transcript}) is True + assert claude.matches_payload({'hook_event_name': 'PreToolUse', **transcript}) is True + assert claude.matches_payload({'hook_event_name': 'beforeSubmitPrompt', **transcript}) is False + assert claude.matches_payload({'hook_event_name': 'beforeReadFile', **transcript}) is False + + +def test_matches_payload_rejects_vscode_copilot_payloads() -> None: + """VS Code Copilot sends the same event names in the same snake_case dialect, so + the documented transcript_path is what keeps its events from being claimed here.""" + claude = ClaudeCode() + assert ( + claude.matches_payload( + { + 'timestamp': '2026-07-14T13:33:24.387Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'read_file', + 'tool_input': {'filePath': '/Users/user/.gitconfig'}, + 'tool_use_id': 'call_KuiUJvNJ06uHlIdwKy16G9W6__vscode-1784034535752', + } + ) + is False + ) + assert ( + claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'}) + is False + ) + + +def test_is_synthetic_prompt_task_notification() -> None: + claude = ClaudeCode() + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Task dummy-task-1 completed', + } + assert claude.is_synthetic_prompt(payload) is True + + payload['prompt'] = ' \nTask dummy-task-2 completed' + assert claude.is_synthetic_prompt(payload) is True + + +def test_is_synthetic_prompt_regular_prompt() -> None: + claude = ClaudeCode() + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': 'Test prompt'}) is False + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': ''}) is False + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit'}) is False + + +def test_is_synthetic_prompt_ignores_tool_events() -> None: + claude = ClaudeCode() + payload = { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/file'}, + 'prompt': 'not a prompt event', + } + assert claude.is_synthetic_prompt(payload) is False + + +def test_parse_prompt_payload() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Test prompt', + } + ) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.ide_provider == 'claude-code' + assert unified.prompt == 'Test prompt' + + +def test_parse_file_read_payload() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-456', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/secret.env'}, + } + ) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.mcp_tool_name is None + + +def test_parse_mcp_execution_payload() -> None: + args = {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': args, + } + ) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_empty_payload_defaults() -> None: + unified = ClaudeCode().parse_hook_payload({'hook_event_name': 'UserPromptSubmit'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' + assert unified.ide_provider == 'claude-code' + + +def test_build_prompt_responses() -> None: + claude = ClaudeCode() + assert claude.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {} + assert claude.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'decision': 'block', + 'reason': 'no!', + } + + +def test_build_permission_responses() -> None: + claude = ClaudeCode() + allow = claude.build_hook_response(HookDecision.allow(AiHookEventType.FILE_READ)) + assert allow == {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow'}} + + deny = claude.build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'user!', 'agent!')) + assert deny == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'user!', + } + } + + ask = claude.build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'u')) + assert ask == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'u', + } + } + + +# Transcript extraction + + +def test_extract_from_transcript(mocker: MockerFixture) -> None: + """version, model, generation_id from a Claude Code transcript JSONL.""" + transcript_content = ( + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-1","message":{"role":"user","content":"hello"}}\n' + b'{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","role":"assistant",' + b'"content":[{"type":"text","text":"Hi!"}]},"uuid":"assistant-uuid-1"}\n' + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-2","message":{"role":"user","content":"test prompt"}}\n' + ) + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.ides.claude_code.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test prompt', + 'transcript_path': '/mock/transcript.jsonl', + } + ) + + assert unified.ide_version == '2.1.20' + assert unified.model == 'claude-opus-4-5-20251101' + assert unified.generation_id == 'user-uuid-2' + + +def test_missing_transcript_does_not_break_parsing(mocker: MockerFixture) -> None: + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.ides.claude_code.Path') + mock_path.return_value.exists.return_value = False + + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/nonexistent/path/transcript.jsonl', + } + ) + + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + assert unified.conversation_id == 'session-123' + assert unified.prompt == 'test' + + +def test_absent_transcript_path() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + ) + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + + +# Email extraction from ~/.claude.json + + +def test_email_from_config(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email == 'user@example.com' + + +def test_email_none_when_config_missing(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value=None, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email is None + + +def test_email_none_when_no_oauth(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value={'someOtherKey': 'value'}, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email is None + + +# _read_claude_plugin + + +def test_read_claude_plugin_includes_mcp_config_file(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + mcp_content = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + fs.create_file(plugin_dir / '.mcp.json', contents=json.dumps(mcp_content)) + + entry, servers = _read_claude_plugin(plugin_dir) + + assert 'mcp_config_file' in entry + assert json.loads(entry['mcp_config_file']) == mcp_content + assert entry['mcp_config_file_path'] == str(plugin_dir / '.mcp.json') + assert servers == mcp_content['mcpServers'] + + +def test_read_claude_plugin_no_mcp_config_file_when_no_servers(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + fs.create_file(plugin_dir / '.mcp.json', contents=json.dumps({'mcpServers': {}})) + + entry, servers = _read_claude_plugin(plugin_dir) + + assert 'mcp_config_file' not in entry + assert servers == {} + + +def test_read_claude_plugin_no_mcp_config_file_when_missing(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + fs.create_dir(plugin_dir) + + entry, servers = _read_claude_plugin(plugin_dir) + + assert 'mcp_config_file' not in entry + assert servers == {} + + +# resolve_plugins + + +def test_resolve_plugins_git_marketplace_resolves_from_cache(fs: FakeFilesystem) -> None: + """Non-directory marketplaces (git/github) resolve through ~/.claude/plugins/cache.""" + plugin_dir = Path.home() / '.claude' / 'plugins' / 'cache' / 'dummy-marketplace' / 'dummy-plugin' / '1.0.1' + fs.create_file( + plugin_dir / '.claude-plugin' / 'plugin.json', + contents=json.dumps({'name': 'dummy-plugin', 'version': '1.0.1'}), + ) + fs.create_file( + plugin_dir / '.mcp.json', + contents=json.dumps({'mcpServers': {'dummy-server': {'command': 'dummy-command'}}}), + ) + + settings = { + 'enabledPlugins': {'dummy-plugin@dummy-marketplace': True}, + 'extraKnownMarketplaces': { + 'dummy-marketplace': {'source': {'source': 'git', 'url': 'git@example.com:dummy/dummy-marketplace.git'}} + }, + } + plugins = resolve_plugins(settings) + + entry = plugins['dummy-plugin@dummy-marketplace'] + assert entry['version'] == '1.0.1' + assert entry['mcp_server_names'] == ['dummy-server'] + assert entry['mcp_config_file_path'] == str(plugin_dir / '.mcp.json') + + +# Session context + + +def test_session_context_no_config() -> None: + with ( + patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None), + patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None), + ): + global_config_file, plugins = ClaudeCode().get_session_context() + assert global_config_file is None + assert plugins == {} + + +# Claude config parsing (load_claude_config + _email_from_config) + + +def test_load_claude_config_valid(fs: FakeFilesystem) -> None: + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents=json.dumps(config)) + + assert load_claude_config(config_path) == config + + +def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: + fs.create_dir(Path.home()) + assert load_claude_config(Path.home() / '.claude.json') is None + + +def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents='not valid json {{{') + + assert load_claude_config(config_path) is None + + +def test_email_from_config_present() -> None: + assert _email_from_config({'oauthAccount': {'emailAddress': 'user@example.com'}}) == 'user@example.com' + + +def test_email_from_config_missing_oauth_account() -> None: + assert _email_from_config({'someOtherKey': 'value'}) is None + + +def test_email_from_config_missing_email_address() -> None: + assert _email_from_config({'oauthAccount': {'someOtherField': 'value'}}) is None diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py new file mode 100644 index 00000000..33137311 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -0,0 +1,397 @@ +"""Codex CLI IDE integration tests.""" + +import base64 +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest +from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.codex import ( + Codex, + _codex_home, + _email_from_auth, + _enable_codex_hooks_feature, + _load_codex_config, + _read_codex_plugin, +) +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - py<3.11 fallback + import tomli as tomllib + + +# --- payload parsing --------------------------------------------------------- + + +def test_matches_payload_only_codex_events() -> None: + codex = Codex() + assert codex.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is True + assert codex.matches_payload({'hook_event_name': 'PreToolUse'}) is True + assert codex.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is False + assert codex.matches_payload({'hook_event_name': 'SessionStart'}) is False + + +def test_parse_prompt_payload() -> None: + unified = Codex().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'turn_id': 'turn-456', + 'model': 'gpt-5-codex', + 'prompt': 'Test prompt', + } + ) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.generation_id == 'turn-456' + assert unified.model == 'gpt-5-codex' + assert unified.ide_provider == 'codex' + assert unified.prompt == 'Test prompt' + + +def test_parse_mcp_execution_payload() -> None: + args = {'resource_type': 'merge_request', 'resource_id': '4'} + unified = Codex().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': args, + } + ) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_unknown_event_falls_through() -> None: + unified = Codex().parse_hook_payload({'hook_event_name': 'Stop'}) + assert unified.event_name == 'Stop' + + +def test_parse_empty_payload_defaults() -> None: + unified = Codex().parse_hook_payload({'hook_event_name': 'UserPromptSubmit'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.prompt == '' + assert unified.ide_provider == 'codex' + + +# --- response building ------------------------------------------------------- + + +def test_build_prompt_responses() -> None: + codex = Codex() + assert codex.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {} + assert codex.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'decision': 'block', + 'reason': 'no!', + } + + +def test_build_mcp_execution_allow_and_deny() -> None: + codex = Codex() + allow = codex.build_hook_response(HookDecision.allow(AiHookEventType.MCP_EXECUTION)) + assert allow == {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow'}} + + deny = codex.build_hook_response(HookDecision.deny(AiHookEventType.MCP_EXECUTION, 'secret in args!')) + assert deny == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'secret in args!', + } + } + + +def test_build_mcp_execution_ask() -> None: + ask = Codex().build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'maybe?')) + assert ask == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'maybe?', + } + } + + +# --- settings paths ---------------------------------------------------------- + + +def test_settings_path_user_scope() -> None: + path = Codex().settings_path('user') + assert path.name == 'hooks.json' + assert path.parent.name == '.codex' + + +def test_settings_path_repo_scope(fs: FakeFilesystem) -> None: + repo = Path('/my-repo') + fs.create_dir(repo) + path = Codex().settings_path('repo', repo) + assert path == repo / '.codex' / 'hooks.json' + + +def test_settings_path_honors_codex_home_env(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + override = '/custom/codex/home' + fs.create_dir(override) + monkeypatch.setenv('CODEX_HOME', override) + assert _codex_home() == Path(override) + assert Codex().settings_path('user') == Path(override) / 'hooks.json' + + +# --- hooks config rendering -------------------------------------------------- + + +def test_render_hooks_session_start_matches_all_sources() -> None: + """SessionStart must fire on every source (a forked session reports 'resume', + so no matcher is set -> match-all).""" + rendered = Codex().render_hooks_config() + assert 'matcher' not in rendered['hooks']['SessionStart'][0] + assert '--ide codex' in rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] + + +def test_render_hooks_never_emits_async_toml_flags() -> None: + """Codex's TOML `async: true` / `timeout` flags are unimplemented; we must not emit them.""" + for mode in (False, True): + rendered = Codex().render_hooks_config(async_mode=mode) + for entry in rendered['hooks']['PreToolUse']: + for hook in entry['hooks']: + assert 'async' not in hook + assert 'timeout' not in hook + + +def test_render_hooks_async_backgrounds_scan_hooks(mocker: MockerFixture) -> None: + """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background (unix).""" + mocker.patch('platform.system', return_value='Linux') + rendered = Codex().render_hooks_config(async_mode=True) + prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] + pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] + assert prompt_cmd.endswith(' &') + assert pretool_cmd.endswith(' &') + + +def test_render_hooks_async_windows_stays_sync(mocker: MockerFixture) -> None: + """No '&' on Windows - nothing there detaches safely, so hooks run sync.""" + mocker.patch('platform.system', return_value='Windows') + rendered = Codex().render_hooks_config(async_mode=True) + prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] + pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] + assert '&' not in prompt_cmd + assert '&' not in pretool_cmd + + +def test_render_hooks_session_start_always_synchronous() -> None: + """SessionStart registers the conversation context — never backgrounded.""" + for mode in (False, True): + rendered = Codex().render_hooks_config(async_mode=mode) + session_cmd = rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] + assert '&' not in session_cmd + + +def test_render_hooks_pretooluse_matchers_are_mcp_only() -> None: + matchers = [e['matcher'] for e in Codex().render_hooks_config()['hooks']['PreToolUse']] + assert matchers == ['mcp__.*'] + + +# --- post_install: TOML feature flag ---------------------------------------- + + +def test_post_install_creates_config_toml(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + + success, message = Codex().post_install('user') + assert success is True + config_path = Path(home) / 'config.toml' + assert config_path.exists() + assert 'config.toml' in message + + with config_path.open('rb') as f: + config = tomllib.load(f) + assert config['features']['hooks'] is True + + +def test_post_install_preserves_existing_keys(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + config_path = Path(home) / 'config.toml' + # Pre-existing settings the user cares about + config_path.write_text('model = "gpt-5-codex"\n\n[features]\nother = true\n') + + success, _ = Codex().post_install('user') + assert success is True + + with config_path.open('rb') as f: + config = tomllib.load(f) + assert config['model'] == 'gpt-5-codex' + assert config['features']['other'] is True + assert config['features']['hooks'] is True + + +def test_post_install_repo_scope_writes_to_repo_dir(fs: FakeFilesystem) -> None: + repo = Path('/my-repo') + fs.create_dir(repo) + success, _ = Codex().post_install('repo', repo) + assert success is True + assert (repo / '.codex' / 'config.toml').exists() + + +def test_enable_codex_hooks_feature_fails_on_corrupt_toml(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'config.toml').write_text('this is = not [ valid] toml = ') + + success, message = _enable_codex_hooks_feature('user') + assert success is False + assert 'Failed to parse' in message + + +# --- TOML config loading ----------------------------------------------------- + + +def test_load_codex_config_valid(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'config.toml').write_text('model = "gpt-5-codex"\n[mcp_servers.linear]\ncommand = "linear-mcp"\n') + + config = _load_codex_config() + assert config is not None + assert config['model'] == 'gpt-5-codex' + assert config['mcp_servers']['linear']['command'] == 'linear-mcp' + + +def test_load_codex_config_missing_file(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + assert _load_codex_config() is None + + +def test_load_codex_config_invalid_toml(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'config.toml').write_text('this is = not [ valid] toml = ') + assert _load_codex_config() is None + + +# --- JWT email extraction ---------------------------------------------------- + + +def _make_jwt(claims: dict) -> str: + """Build a JWT-shaped token with the given claims (signature ignored).""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b'=').decode() + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b'=').decode() + return f'{header}.{payload}.signature-not-verified' + + +def test_email_from_auth_returns_email(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + token = _make_jwt({'email': 'codex-user@example.com'}) + (Path(home) / 'auth.json').write_text(json.dumps({'tokens': {'id_token': token}})) + + assert _email_from_auth() == 'codex-user@example.com' + + +def test_email_from_auth_missing_file(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + assert _email_from_auth() is None + + +def test_email_from_auth_no_id_token(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'auth.json').write_text(json.dumps({'tokens': {}})) + assert _email_from_auth() is None + + +def test_email_from_auth_malformed_token(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'auth.json').write_text(json.dumps({'tokens': {'id_token': 'not.a.jwt-with-bad-payload!!'}})) + assert _email_from_auth() is None + + +# --- session context -------------------------------------------------------- + + +def test_session_context_reads_mcp_servers() -> None: + mcp = {'linear': {'command': 'linear-mcp'}, 'github': {'command': 'gh-mcp'}} + with patch( + 'cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', + return_value={'mcp_servers': mcp}, + ): + global_config_file, plugins = Codex().get_session_context() + assert global_config_file is not None + assert global_config_file['path'].endswith('config.toml') + assert global_config_file['content'] == json.dumps({'mcpServers': mcp}) + assert plugins == {} + + +def test_session_context_no_config() -> None: + with patch('cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value=None): + global_config_file, plugins = Codex().get_session_context() + assert global_config_file is None + assert plugins == {} + + +def _write_codex_plugin(plugin_dir: Path, mcp_doc: dict) -> None: + """Lay out a Codex plugin: manifest referencing .mcp.json + the MCP file itself.""" + (plugin_dir / '.codex-plugin').mkdir(parents=True, exist_ok=True) + (plugin_dir / '.codex-plugin' / 'plugin.json').write_text(json.dumps({'name': 'demo', 'mcpServers': '.mcp.json'})) + (plugin_dir / '.mcp.json').write_text(json.dumps(mcp_doc)) + + +def test_read_codex_plugin_includes_mcp_config_file(tmp_path: Path) -> None: + mcp_content = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + _write_codex_plugin(tmp_path, mcp_content) + + entry, servers = _read_codex_plugin(tmp_path) + + assert json.loads(entry['mcp_config_file']) == mcp_content + assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json') + assert servers == mcp_content['mcpServers'] + + +def test_read_codex_plugin_mcp_config_file_bare_map(tmp_path: Path) -> None: + # Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper; the serialized + # session-context content is normalized to the canonical wrapped shape. + mcp_content = {'dummy-server': {'command': 'dummy-command'}} + _write_codex_plugin(tmp_path, mcp_content) + + entry, servers = _read_codex_plugin(tmp_path) + + assert json.loads(entry['mcp_config_file']) == {'mcpServers': mcp_content} + assert servers == mcp_content + + +def test_read_codex_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None: + _write_codex_plugin(tmp_path, {'mcpServers': {}}) + + entry, servers = _read_codex_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + +def test_read_codex_plugin_no_mcp_config_file_when_no_manifest(tmp_path: Path) -> None: + entry, servers = _read_codex_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py new file mode 100644 index 00000000..0984c97a --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -0,0 +1,155 @@ +"""IDE contract tests, parameterized over the entire IDES registry. + +Every concrete IDE registered in `ides/__init__.py` must satisfy these +assertions. Adding a new IDE without updating these tests means the new +IDE inherits the same baseline guarantees (and fails fast if it doesn't). +""" + +from pathlib import Path + +import pytest +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.ides import IDES +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_ides_registry_is_non_empty() -> None: + """Sanity check: the refactor isn't useful with zero registered IDEs.""" + assert len(IDES) >= 1 + + +@pytest.fixture(params=sorted(IDES), ids=sorted(IDES)) +def ide(request: pytest.FixtureRequest) -> IDE: + return IDES[request.param] + + +def test_identity_attributes_set(ide: IDE) -> None: + """Every IDE must declare name, display_name, hook_events.""" + assert isinstance(ide.name, str) + assert ide.name + assert isinstance(ide.display_name, str) + assert ide.display_name + assert isinstance(ide.hook_events, list) + assert ide.hook_events + + +def test_registry_key_matches_name(ide: IDE) -> None: + """The registry key must equal the IDE's own `name` attribute.""" + assert IDES[ide.name] is ide + + +def test_settings_path_user_scope(ide: IDE) -> None: + """User scope must return a Path (without requiring a repo_path).""" + path = ide.settings_path('user') + assert isinstance(path, Path) + + +def test_settings_path_repo_scope(ide: IDE, tmp_path: Path) -> None: + """Repo scope path must live under the supplied repo directory.""" + path = ide.settings_path('repo', tmp_path) + assert isinstance(path, Path) + assert str(path).startswith(str(tmp_path)) + + +def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: + """All IDEs share the outer `{"hooks": ...}` wrapper so hooks_manager can merge.""" + rendered = ide.render_hooks_config() + assert isinstance(rendered, dict) + assert 'hooks' in rendered + assert isinstance(rendered['hooks'], dict) + + +def test_render_hooks_config_async_changes_output(ide: IDE, mocker: MockerFixture) -> None: + """async_mode must influence the rendered output. + + Pinned to a unix platform: IDEs that background via a shell `&` render + identical sync/async configs on Windows, where no safe suffix exists. + """ + mocker.patch('platform.system', return_value='Linux') + assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) + + +def test_matches_payload_rejects_empty(ide: IDE) -> None: + """Empty payloads can't legitimately come from any IDE.""" + assert ide.matches_payload({}) is False + assert ide.matches_payload({'hook_event_name': ''}) is False + + +def test_matches_payload_rejects_unrelated_event_names(ide: IDE) -> None: + """Unknown event names from other IDEs must be ignored to avoid double-processing.""" + assert ide.matches_payload({'hook_event_name': 'completely-fabricated-event'}) is False + + +def test_is_synthetic_prompt_rejects_empty(ide: IDE) -> None: + """The safe default: no payload is ever treated as synthetic unless an IDE opts in.""" + assert ide.is_synthetic_prompt({}) is False + + +@pytest.mark.parametrize('event_type', list(AiHookEventType)) +def test_build_hook_response_allow_returns_dict(ide: IDE, event_type: AiHookEventType) -> None: + """ALLOW for every canonical event type yields a serializable dict.""" + response = ide.build_hook_response(HookDecision.allow(event_type)) + assert isinstance(response, dict) + + +@pytest.mark.parametrize('event_type', list(AiHookEventType)) +def test_build_hook_response_deny_carries_message(ide: IDE, event_type: AiHookEventType) -> None: + """DENY must surface the user message somewhere in the response (any key).""" + response = ide.build_hook_response(HookDecision.deny(event_type, 'A unique deny reason', 'agent msg')) + # Search recursively — IDEs use different key names for the message. + assert _contains_value(response, 'A unique deny reason'), response + + +@pytest.mark.parametrize('event_type', [AiHookEventType.FILE_READ, AiHookEventType.MCP_EXECUTION]) +def test_build_hook_response_ask_carries_message(ide: IDE, event_type: AiHookEventType) -> None: + """ASK is meaningful for permission events. Message must propagate.""" + response = ide.build_hook_response(HookDecision.ask(event_type, 'A unique ask reason')) + assert _contains_value(response, 'A unique ask reason'), response + + +def test_build_session_payload_tags_ide(ide: IDE) -> None: + """Session payload must identify the originating IDE.""" + session = ide.build_session_payload({}) + assert session.ide_provider == ide.name + + +def test_get_session_context_returns_pair(ide: IDE) -> None: + """Session context must be a ``(global_config_file, plugins)`` pair. + + ``global_config_file`` is ``None`` or a ``{"path", "content"}`` dict; ``plugins`` is a dict. + """ + global_config_file, plugins = ide.get_session_context() + assert global_config_file is None or isinstance(global_config_file, dict) + assert isinstance(plugins, dict) + + +# HookDecision helpers + + +def test_hook_decision_helpers() -> None: + allow = HookDecision.allow(AiHookEventType.PROMPT) + assert allow.action == DecisionAction.ALLOW + assert allow.event_type == AiHookEventType.PROMPT + assert allow.user_message is None + + deny = HookDecision.deny(AiHookEventType.FILE_READ, 'why', 'agent') + assert deny.action == DecisionAction.DENY + assert deny.user_message == 'why' + assert deny.agent_message == 'agent' + + ask = HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'maybe?') + assert ask.action == DecisionAction.ASK + assert ask.user_message == 'maybe?' + + +def _contains_value(obj: object, needle: str) -> bool: + """Recursively search a nested dict/list for a string value.""" + if isinstance(obj, str): + return needle in obj + if isinstance(obj, dict): + return any(_contains_value(v, needle) for v in obj.values()) + if isinstance(obj, list): + return any(_contains_value(v, needle) for v in obj) + return False diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py new file mode 100644 index 00000000..5d5f6d4f --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -0,0 +1,500 @@ +"""GitHub Copilot (VS Code) IDE integration tests. + +Payload fixtures mirror real events captured from VS Code (built-in Copilot +Chat 0.56.0) and Copilot CLI, with identifying values swapped for dummies. +""" + +import json +import os +from pathlib import Path +from typing import Optional + +from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.copilot import ( + Copilot, + _vscode_mcp_config_path, + split_mcp_tool_name, +) +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + +_VSCODE_PROMPT_PAYLOAD = { + 'timestamp': '2026-07-14T13:32:46.517Z', + 'hook_event_name': 'UserPromptSubmit', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'prompt': 'test prompt', +} + +# VS Code attaches a per-session transcript_path whenever a folder is open: the +# transcript dir is derived from the extension's workspace storageUri, which is +# undefined only in an empty window. +_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT = { + **_VSCODE_PROMPT_PAYLOAD, + 'cwd': '/Users/user/project', + 'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/dummy/t.jsonl', +} + +_VSCODE_READ_FILE_PAYLOAD = { + 'timestamp': '2026-07-14T13:35:08.758Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'read_file', + 'tool_input': {'filePath': '/Users/user/.gitconfig', 'startLine': 1, 'endLine': 200}, + 'tool_use_id': 'call_dummyDummyDummyDummy__vscode-1784034535752', +} + +_VSCODE_MCP_PAYLOAD = { + 'timestamp': '2026-07-14T14:03:57.337Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'mcp_gitlab_get_user', + 'tool_input': {'user_id': 'dummy-user'}, + 'tool_use_id': 'call_dummyDummyDummyDummy__vscode-1784034535755', +} + +_VSCODE_SESSION_START_PAYLOAD = { + 'timestamp': '2026-07-14T13:32:46.474Z', + 'hook_event_name': 'SessionStart', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'source': 'new', + 'model': 'auto', +} + +# Agent-runtime payloads (Copilot CLI, and VS Code agent sessions) under PascalCase +# event keys: same Claude-style dialect as VS Code, but a `cwd` and its own tool +# vocabulary (`Read`/`path`, `-`) rather than VS Code's. +_AGENT_PROMPT_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test prompt', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:49:22.000Z', +} + +_AGENT_READ_FILE_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:51:17.000Z', + 'tool_name': 'Read', + 'tool_input': {'path': '/Users/user/.gitconfig'}, +} + +_AGENT_MCP_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:51:17.000Z', + 'tool_name': 'gitlab-get_user', + 'tool_input': {'user_id': 'dummy-user'}, +} + +# Stale pre-PascalCase installs still emit this: camelCase, epoch-ms timestamp, +# no event name, stringified args. Rejected — they are corrected on reinstall. +_COPILOT_CLI_TOOL_PAYLOAD = { + 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': 1784038775604, + 'cwd': '/Users/user', + 'toolName': 'view', + 'toolArgs': '{"path": "/Users/user/.zshrc"}', +} + +_CLAUDE_CODE_PAYLOAD = { + 'session_id': 'session-123', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/Users/user/.gitconfig'}, + 'tool_use_id': 'toolu_dummyDummyDummyDummy', +} + + +# --- matches_payload ------------------------------------------------------------ + + +def test_matches_payload_accepts_vscode_events() -> None: + copilot = Copilot() + assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_READ_FILE_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_MCP_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT) is True + + +def test_matches_payload_rejects_claude_code_payloads() -> None: + # Same event names and dialect, and both carry transcript_path - only the + # top-level timestamp separates them, and Claude Code never sends one. + assert Copilot().matches_payload(_CLAUDE_CODE_PAYLOAD) is False + + +def test_matches_payload_rejects_copilot_cli_payloads() -> None: + # Only reachable from a stale camelCase install; corrected by reinstalling hooks. + assert Copilot().matches_payload(_COPILOT_CLI_TOOL_PAYLOAD) is False + + +def test_matches_payload_rejects_cursor_payloads() -> None: + assert Copilot().matches_payload({'hook_event_name': 'beforeSubmitPrompt', 'prompt': 'test'}) is False + + +def test_matches_payload_requires_timestamp() -> None: + payload = {k: v for k, v in _VSCODE_PROMPT_PAYLOAD.items() if k != 'timestamp'} + assert Copilot().matches_payload(payload) is False + + +# --- parse_hook_payload --------------------------------------------------------- + + +def test_parse_prompt_payload() -> None: + unified = Copilot().parse_hook_payload(_VSCODE_PROMPT_PAYLOAD) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == '43cbad91-ea8b-4d4a-9acc-56561421c5d2' + assert unified.ide_provider == 'copilot' + assert unified.prompt == 'test prompt' + + +def test_parse_read_file_payload(fs: FakeFilesystem) -> None: + fs.create_file('/Users/user/.gitconfig') + # Agent-runtime naming (`Read` + `path`) must map identically to VS Code's + # (`read_file` + `filePath`): one hooks file serves both runtimes. + for payload in (_VSCODE_READ_FILE_PAYLOAD, _AGENT_READ_FILE_PAYLOAD): + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/Users/user/.gitconfig' + assert unified.mcp_tool_name is None + + +def test_parse_read_of_directory_is_not_a_file_read(fs: FakeFilesystem) -> None: + # The agent runtime reuses `Read` for directory listings with an identical + # payload shape, so only a stat separates them. + fs.create_dir('/Users/user/project') + payload = {**_AGENT_READ_FILE_PAYLOAD, 'tool_input': {'path': '/Users/user/project'}} + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == 'Read' + assert unified.file_path is None + + +def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) -> None: + # No known servers on disk - honest fallback: no fabricated server, the full + # unsplit remainder as the tool. + unified = Copilot().parse_hook_payload(_VSCODE_MCP_PAYLOAD) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name is None + assert unified.mcp_tool_name == 'gitlab_get_user' + assert unified.mcp_arguments == {'user_id': 'dummy-user'} + + +def test_parse_agent_mcp_payload_uses_hyphenated_naming(fs: FakeFilesystem) -> None: + # The agent runtime names MCP tools `-` with no prefix, and + # declares its servers in its own config rather than VS Code's mcp.json. + fs.create_file( + Path.home() / '.copilot' / 'mcp-config.json', + contents=json.dumps({'mcpServers': {'gitlab': {'command': 'dummy-mcp'}}}), + ) + unified = Copilot().parse_hook_payload(_AGENT_MCP_PAYLOAD) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'get_user' + assert unified.mcp_arguments == {'user_id': 'dummy-user'} + + +def test_parse_agent_mcp_payload_prefers_longest_hyphenated_server(fs: FakeFilesystem) -> None: + # Server names may themselves contain the separator, so the split must not be + # greedy on the first hyphen. + fs.create_file( + Path.home() / '.copilot' / 'mcp-config.json', + contents=json.dumps({'mcpServers': {'gitlab': {}, 'gitlab-selfhosted': {}}}), + ) + payload = {**_AGENT_MCP_PAYLOAD, 'tool_name': 'gitlab-selfhosted-get_user'} + unified = Copilot().parse_hook_payload(payload) + assert unified.mcp_server_name == 'gitlab-selfhosted' + assert unified.mcp_tool_name == 'get_user' + + +def test_parse_mcp_payload_with_known_server_containing_underscores(fs: FakeFilesystem) -> None: + fs.create_file( + _vscode_mcp_config_path(), + contents=json.dumps({'servers': {'gitlab_selfhosted': {'command': 'dummy-mcp'}}}), + ) + payload = {**_VSCODE_MCP_PAYLOAD, 'tool_name': 'mcp_gitlab_selfhosted_get_user'} + unified = Copilot().parse_hook_payload(payload) + assert unified.mcp_server_name == 'gitlab_selfhosted' + assert unified.mcp_tool_name == 'get_user' + + +def test_parse_unmatched_tool_passes_raw_tool_name_through() -> None: + # No matchers in Copilot hooks: unscanned tools must map to an event that + # matches no handler so scan_command answers with a neutral allow. + payload = {**_VSCODE_READ_FILE_PAYLOAD, 'tool_name': 'list_dir', 'tool_input': {'path': '/Users/user'}} + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == 'list_dir' + assert unified.file_path is None + assert unified.mcp_server_name is None + + +# --- split_mcp_tool_name -------------------------------------------------------- + + +def test_split_mcp_tool_name_prefers_longest_known_server() -> None: + servers = ['gitlab', 'gitlab_selfhosted'] + assert split_mcp_tool_name('mcp_gitlab_selfhosted_get_user', servers) == ('gitlab_selfhosted', 'get_user') + + +def test_split_mcp_tool_name_matches_normalized_config_name() -> None: + # The wire prefix is the sanitized SELF-REPORTED server name (`DummyTracker` -> + # `dummytracker`), which resembles the config name modulo separators. + assert split_mcp_tool_name('mcp_dummytracker_fetch_api', ['dummy-tracker']) == ('dummy-tracker', 'fetch_api') + + +def test_split_mcp_tool_name_unknown_server_reports_raw() -> None: + # Self-reported names can diverge entirely from config names (e.g. a server + # configured as `dummy-plugin` self-reporting `Vendor.DummyApp.Hybrid` yields + # a sanitized+truncated prefix like `vendor_du`) - never guess a split. + assert split_mcp_tool_name('mcp_vendor_du_search_instructions', ['dummy-plugin']) == ( + None, + 'vendor_du_search_instructions', + ) + + +def test_split_mcp_tool_name_server_only() -> None: + assert split_mcp_tool_name('mcp_gitlab', ['gitlab']) == ('gitlab', None) + + +# --- build_hook_response -------------------------------------------------------- + + +def test_allow_is_neutral_for_every_event_type() -> None: + """Allow must be {} - an explicit permissionDecision "allow" would pre-approve + tools past VS Code's own permission prompts (and with no matchers, that would + cover every tool, not just scanned ones).""" + copilot = Copilot() + for event_type in AiHookEventType: + assert copilot.build_hook_response(HookDecision.allow(event_type)) == {} + + +def test_deny_prompt_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'Secrets detected')) + assert response['decision'] == 'block' + assert response['reason'] == 'Secrets detected' + assert response['continue'] is False + assert response['stopReason'] == 'Secrets detected' + + +def test_deny_tool_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'Sensitive file')) + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'Sensitive file', + } + } + + +def test_ask_mcp_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'Allow execution?')) + assert response['hookSpecificOutput']['permissionDecision'] == 'ask' + assert response['hookSpecificOutput']['permissionDecisionReason'] == 'Allow execution?' + + +# --- render_hooks_config / settings_path ---------------------------------------- + + +def test_render_hooks_config_sync_uses_cross_platform_command() -> None: + rendered = Copilot().render_hooks_config() + assert rendered['version'] == 1 + + prompt_entry = rendered['hooks']['UserPromptSubmit'][0] + assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot' + assert 'bash' not in prompt_entry + + tool_entry = rendered['hooks']['PreToolUse'][0] + assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot' + + session_entry = rendered['hooks']['SessionStart'][0] + assert session_entry['command'] == 'cycode ai-guardrails session-start --ide copilot' + + +def test_render_hooks_config_async_backgrounds_on_unix() -> None: + rendered = Copilot().render_hooks_config(async_mode=True) + tool_entry = rendered['hooks']['PreToolUse'][0] + # <&0 keeps the payload (a bare `cmd &` gets stdin from /dev/null and scans nothing); + # the stdout redirect releases the pipe the runner waits on, or it still blocks. + assert tool_entry['bash'].endswith('<&0 >/dev/null 2>&1 &') + assert not tool_entry['powershell'].endswith('&') + assert 'command' not in tool_entry + + +def test_settings_path_user_scope() -> None: + path = Copilot().settings_path('user') + assert path == Path.home() / '.copilot' / 'hooks' / 'cycode.json' + + +def test_settings_path_honors_copilot_home(mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {'COPILOT_HOME': '/custom/copilot-home'}) + path = Copilot().settings_path('user') + assert path == Path('/custom/copilot-home') / 'hooks' / 'cycode.json' + + +def test_settings_path_repo_scope(tmp_path: Path) -> None: + path = Copilot().settings_path('repo', tmp_path) + assert path == tmp_path / '.github' / 'hooks' / 'cycode.json' + + +# --- session payload / context -------------------------------------------------- + + +def test_build_session_payload() -> None: + session = Copilot().build_session_payload(_VSCODE_SESSION_START_PAYLOAD) + assert session.ide_provider == 'copilot' + assert session.conversation_id == '43cbad91-ea8b-4d4a-9acc-56561421c5d2' + assert session.model == 'auto' + assert session.source == 'new' + + +def test_get_session_context_normalizes_servers_key(fs: FakeFilesystem) -> None: + config_path = _vscode_mcp_config_path() + fs.create_file( + config_path, + contents=json.dumps({'servers': {'gitlab': {'type': 'stdio', 'command': 'dummy-mcp'}}}), + ) + + global_config_file, plugins = Copilot().get_session_context() + + assert global_config_file is not None + assert global_config_file['path'] == str(config_path) + # VS Code's `servers` key is normalized to the canonical mcpServers shape. + assert json.loads(global_config_file['content']) == { + 'mcpServers': {'gitlab': {'type': 'stdio', 'command': 'dummy-mcp'}} + } + assert plugins == {} + + +def test_get_session_context_without_config(fs: FakeFilesystem) -> None: + assert Copilot().get_session_context() == (None, {}) + + +# --- plugins inventory ----------------------------------------------------------- + + +def _create_plugin_on_disk( + fs: FakeFilesystem, + plugin_dir: Path, + manifest_location: str = '.github/plugin/plugin.json', + manifest_extra: Optional[dict] = None, + mcp_file: str = '.mcp.json', + server_name: str = 'dummy-server', +) -> None: + manifest = {'name': plugin_dir.name, 'version': '1.0.0', 'description': 'Dummy plugin', **(manifest_extra or {})} + fs.create_file(plugin_dir / manifest_location, contents=json.dumps(manifest)) + fs.create_file( + plugin_dir / mcp_file, + contents=json.dumps({'mcpServers': {server_name: {'command': 'dummy-mcp'}}}), + ) + + +def test_cli_registry_plugins(fs: FakeFilesystem) -> None: + """CLI-installed plugins: comment-headed config.json registry, manifest with an + mcpServers path-ref.""" + plugin_dir = Path.home() / '.copilot' / 'installed-plugins' / 'dummy-marketplace' / 'dummy-plugin' + _create_plugin_on_disk(fs, plugin_dir, manifest_extra={'mcpServers': './.mcp.json'}) + fs.create_file( + Path.home() / '.copilot' / 'config.json', + contents='// This file is managed automatically.\n' + + json.dumps( + { + 'installedPlugins': [ + { + 'name': 'dummy-plugin', + 'marketplace': 'dummy-marketplace', + 'version': '1.0.0', + 'cache_path': str(plugin_dir), + 'enabled': True, + }, + { + 'name': 'disabled-plugin', + 'marketplace': 'dummy-marketplace', + 'cache_path': str(plugin_dir), + 'enabled': False, + }, + ] + } + ), + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'dummy-plugin@dummy-marketplace'} + entry = plugins['dummy-plugin@dummy-marketplace'] + assert entry['enabled'] is True + assert entry['version'] == '1.0.0' + assert entry['mcp_server_names'] == ['dummy-server'] + assert json.loads(entry['mcp_config_file']) == {'mcpServers': {'dummy-server': {'command': 'dummy-mcp'}}} + + +def test_vscode_registry_plugins(fs: FakeFilesystem) -> None: + """VS Code UI-installed plugins: installed.json registry with file:// pluginUri, + root .mcp.json convention without a manifest mcpServers field.""" + plugin_dir = ( + Path.home() / '.vscode' / 'agent-plugins' / 'github.com' / 'dummy-org' / 'repo' / 'plugins' / 'dummy-plugin' + ) + _create_plugin_on_disk(fs, plugin_dir, manifest_location='.claude-plugin/plugin.json') + fs.create_file( + Path.home() / '.vscode' / 'agent-plugins' / 'installed.json', + contents=json.dumps( + { + 'version': 1, + 'installed': [ + {'pluginUri': plugin_dir.as_uri(), 'marketplace': 'dummy-marketplace', 'name': 'dummy-plugin'} + ], + } + ), + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'dummy-plugin@dummy-marketplace'} + assert plugins['dummy-plugin@dummy-marketplace']['mcp_server_names'] == ['dummy-server'] + + +def test_local_dir_plugins_from_plugin_locations_setting(fs: FakeFilesystem) -> None: + """Local-directory plugins declared via chat.pluginLocations (JSONC settings).""" + enabled_dir = Path('/plugins/local-plugin') + disabled_dir = Path('/plugins/disabled-plugin') + _create_plugin_on_disk(fs, enabled_dir, manifest_location='plugin.json') + _create_plugin_on_disk(fs, disabled_dir, manifest_location='plugin.json') + # json.dumps escapes Windows path separators; the comment line exercises JSONC handling. + settings = json.dumps({'chat.pluginLocations': {str(enabled_dir): True, str(disabled_dir): False}}) + fs.create_file( + _vscode_mcp_config_path().parent / 'settings.json', + contents=f'// user settings\n{settings}', + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'local-plugin@local'} + assert plugins['local-plugin@local']['mcp_server_names'] == ['dummy-server'] + + +def test_parse_mcp_payload_matches_plugin_server_via_normalized_name(fs: FakeFilesystem) -> None: + """End-to-end split: a plugin-declared server named dummy-tracker attributes the + wire tool mcp_dummytracker_fetch_api (prefix = sanitized self-reported name).""" + plugin_dir = Path.home() / '.copilot' / 'installed-plugins' / 'dummy-marketplace' / 'dummy-tracker' + _create_plugin_on_disk(fs, plugin_dir, server_name='dummy-tracker') + fs.create_file( + Path.home() / '.copilot' / 'config.json', + contents=json.dumps( + { + 'installedPlugins': [ + {'name': 'dummy-tracker', 'marketplace': 'dummy-marketplace', 'cache_path': str(plugin_dir)} + ] + } + ), + ) + + payload = {**_VSCODE_MCP_PAYLOAD, 'tool_name': 'mcp_dummytracker_fetch_api', 'tool_input': {'key': 'payments'}} + unified = Copilot().parse_hook_payload(payload) + + assert unified.mcp_server_name == 'dummy-tracker' + assert unified.mcp_tool_name == 'fetch_api' diff --git a/tests/cli/commands/ai_guardrails/ides/test_cursor.py b/tests/cli/commands/ai_guardrails/ides/test_cursor.py new file mode 100644 index 00000000..4d082d3a --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_cursor.py @@ -0,0 +1,155 @@ +"""Cursor IDE integration tests (payload parsing, response building, MCP context).""" + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_matches_payload_only_cursor_events() -> None: + cursor = Cursor() + assert cursor.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is True + assert cursor.matches_payload({'hook_event_name': 'beforeReadFile'}) is True + assert cursor.matches_payload({'hook_event_name': 'beforeMCPExecution'}) is True + assert cursor.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is False + assert cursor.matches_payload({'hook_event_name': 'PreToolUse'}) is False + + +def test_parse_prompt_payload() -> None: + payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'conversation_id': 'conv-123', + 'generation_id': 'gen-456', + 'user_email': 'user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + 'prompt': 'Test prompt', + } + unified = Cursor().parse_hook_payload(payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'conv-123' + assert unified.generation_id == 'gen-456' + assert unified.ide_user_email == 'user@example.com' + assert unified.model == 'gpt-4' + assert unified.ide_provider == 'cursor' + assert unified.ide_version == '0.42.0' + assert unified.prompt == 'Test prompt' + + +def test_parse_file_read_payload() -> None: + unified = Cursor().parse_hook_payload({'hook_event_name': 'beforeReadFile', 'file_path': '/path/to/secret.env'}) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + + +def test_parse_mcp_execution_payload() -> None: + args: dict[str, Any] = {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + unified = Cursor().parse_hook_payload( + { + 'hook_event_name': 'beforeMCPExecution', + 'command': 'GitLab', + 'tool_name': 'discussion_list', + 'arguments': args, + } + ) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'GitLab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_alternative_field_names() -> None: + """Cursor's payload has alternative names for some fields.""" + fr = Cursor().parse_hook_payload({'hook_event_name': 'beforeReadFile', 'path': '/alt/path.txt'}) + assert fr.file_path == '/alt/path.txt' + + mcp = Cursor().parse_hook_payload( + { + 'hook_event_name': 'beforeMCPExecution', + 'tool': 'my_tool', + 'tool_input': {'key': 'value'}, + } + ) + assert mcp.mcp_tool_name == 'my_tool' + assert mcp.mcp_arguments == {'key': 'value'} + + +def test_parse_unknown_event_name_falls_through() -> None: + """Unknown event names pass through as the raw string.""" + unified = Cursor().parse_hook_payload({'hook_event_name': 'unknownEvent'}) + assert unified.event_name == 'unknownEvent' + + +def test_parse_empty_payload_defaults() -> None: + unified = Cursor().parse_hook_payload({'hook_event_name': 'beforeSubmitPrompt'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' + assert unified.ide_provider == 'cursor' + + +def test_build_prompt_responses() -> None: + cursor = Cursor() + assert cursor.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {'continue': True} + assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'continue': False, + 'user_message': 'no!', + } + + +def test_build_permission_responses() -> None: + cursor = Cursor() + assert cursor.build_hook_response(HookDecision.allow(AiHookEventType.FILE_READ)) == {'permission': 'allow'} + assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'user!', 'agent!')) == { + 'permission': 'deny', + 'user_message': 'user!', + 'agent_message': 'agent!', + } + assert cursor.build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'u', 'a')) == { + 'permission': 'ask', + 'user_message': 'u', + 'agent_message': 'a', + } + + +def test_session_payload_carries_cursor_fields() -> None: + payload = { + 'conversation_id': 'conv-456', + 'user_email': 'cursor-user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + } + session = Cursor().build_session_payload(payload) + assert session.conversation_id == 'conv-456' + assert session.model == 'gpt-4' + assert session.ide_user_email == 'cursor-user@example.com' + assert session.ide_version == '0.42.0' + assert session.ide_provider == 'cursor' + + +def test_session_context_loads_mcp_servers() -> None: + """Cursor wraps ~/.cursor/mcp.json into a global_config_file.""" + mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + + with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config') as load: + load.return_value = {'mcpServers': mcp_servers} + global_config_file, plugins = Cursor().get_session_context() + + assert global_config_file == { + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': mcp_servers}), + } + assert plugins == {} + + +def test_session_context_no_config_returns_empty() -> None: + with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config', return_value=None): + global_config_file, plugins = Cursor().get_session_context() + assert global_config_file is None + assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/scan/__init__.py b/tests/cli/commands/ai_guardrails/scan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py new file mode 100644 index 00000000..401482ac --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -0,0 +1,587 @@ +"""Tests for AI guardrails handlers.""" + +import os +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode +from cycode.cli.apps.ai_guardrails.ides.base import DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.handlers import ( + _perform_scan, + _scan_path_for_secrets, + _scan_text_for_secrets, + build_ai_guardrails_scan_parameters, + get_effective_mode, + handle_before_mcp_execution, + handle_before_read_file, + handle_before_submit_prompt, +) +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason +from cycode.cli.models import Document, LocalScanResult + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock Typer context.""" + ctx = MagicMock(spec=typer.Context) + ctx.obj = { + 'ai_security_client': MagicMock(), + 'scan_type': 'secret', + } + return ctx + + +@pytest.fixture +def mock_payload() -> AIHookPayload: + """Create a mock AIHookPayload.""" + return AIHookPayload( + event_name='Prompt', + conversation_id='test-conv-id', + generation_id='test-gen-id', + ide_user_email='test@example.com', + model='gpt-4', + ide_provider='cursor', + ide_version='1.0.0', + prompt='Test prompt', + ) + + +@pytest.fixture +def default_policy() -> dict[str, Any]: + """Create a default policy dict.""" + return { + 'mode': 'block', + 'fail_open': True, + 'secrets': {'max_bytes': 200000}, + 'prompt': {'enabled': True, 'action': 'block'}, + 'file_read': {'enabled': True, 'action': 'block', 'scan_content': True, 'deny_globs': []}, + 'mcp': {'enabled': True, 'action': 'block', 'scan_arguments': True}, + } + + +# Tests for handle_before_submit_prompt + + +def test_handle_before_submit_prompt_disabled( + mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that disabled prompt scanning allows the prompt.""" + default_policy['prompt']['enabled'] = False + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == HookDecision.allow(AiHookEventType.PROMPT) + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_no_secrets( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that prompt with no secrets is allowed.""" + mock_scan.return_value = (None, 'scan-id-123') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == HookDecision.allow(AiHookEventType.PROMPT) + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + assert call_args.kwargs['scan_id'] == 'scan-id-123' + assert call_args.kwargs['block_reason'] is None + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_blocked( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that prompt with secrets is blocked.""" + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.PROMPT + assert 'Found 1 secret: API key' in result.user_message + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_PROMPT + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_warned( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that prompt with secrets in warn mode is allowed.""" + default_policy['prompt']['action'] = 'warn' + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-789') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == HookDecision.allow(AiHookEventType.PROMPT) + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_scan_failure_fail_open( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that scan failure with fail_open=True allows the prompt.""" + mock_scan.side_effect = RuntimeError('Scan failed') + default_policy['fail_open'] = True + + with pytest.raises(RuntimeError): + handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_scan_failure_fail_closed( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that scan failure with fail_open=False blocks the prompt.""" + mock_scan.side_effect = RuntimeError('Scan failed') + default_policy['fail_open'] = False + + with pytest.raises(RuntimeError): + handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE + + +# Tests for handle_before_read_file + + +def test_handle_before_read_file_disabled(mock_ctx: MagicMock, default_policy: dict[str, Any]) -> None: + """Test that disabled file read scanning allows the file.""" + default_policy['file_read']['enabled'] = False + payload = AIHookPayload( + event_name='FileRead', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == HookDecision.allow(AiHookEventType.FILE_READ) + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +def test_handle_before_read_file_sensitive_path( + mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path is blocked.""" + mock_is_denied.return_value = True + payload = AIHookPayload( + event_name='FileRead', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + assert call_args.kwargs['file_path'] == '/path/to/.env' + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_no_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that file with no secrets is allowed.""" + mock_is_denied.return_value = False + mock_scan.return_value = (None, 'scan-id-123') + payload = AIHookPayload( + event_name='FileRead', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == HookDecision.allow(AiHookEventType.FILE_READ) + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + assert call_args.kwargs['file_path'] == '/path/to/file.txt' + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_with_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that file with secrets is blocked.""" + mock_is_denied.return_value = False + mock_scan.return_value = ('Found 1 secret: password', 'scan-id-456') + payload = AIHookPayload( + event_name='FileRead', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.FILE_READ + assert 'Found 1 secret: password' in result.user_message + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE + assert call_args.kwargs['file_path'] == '/path/to/file.txt' + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_scan_disabled( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that file is allowed when content scanning is disabled.""" + mock_is_denied.return_value = False + default_policy['file_read']['scan_content'] = False + payload = AIHookPayload( + event_name='FileRead', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == HookDecision.allow(AiHookEventType.FILE_READ) + mock_scan.assert_not_called() + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_sensitive_path_warn_mode_scans_content( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path in warn mode still scans file content and emits two events.""" + mock_is_denied.return_value = True + mock_scan.return_value = (None, 'scan-id-123') + default_policy['mode'] = 'warn' + payload = AIHookPayload( + event_name='FileRead', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + mock_scan.assert_called_once() + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message + + assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 + first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] + assert first_event.args[2] == AIHookOutcome.WARNED + assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1] + assert second_event.args[2] == AIHookOutcome.ALLOWED + assert second_event.kwargs['block_reason'] is None + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path in warn mode reports secrets and emits two events.""" + mock_is_denied.return_value = True + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') + default_policy['mode'] = 'warn' + payload = AIHookPayload( + event_name='FileRead', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + mock_scan.assert_called_once() + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert 'Found 1 secret: API key' in result.user_message + + assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 + first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] + assert first_event.args[2] == AIHookOutcome.WARNED + assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1] + assert second_event.args[2] == AIHookOutcome.WARNED + assert second_event.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_sensitive_path_scan_disabled_warns( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path in warn mode with scan disabled emits a single event.""" + mock_is_denied.return_value = True + default_policy['mode'] = 'warn' + default_policy['file_read']['scan_content'] = False + payload = AIHookPayload( + event_name='FileRead', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + mock_scan.assert_not_called() + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message + + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + + +def test_scan_path_for_secrets_directory( + mock_ctx: MagicMock, default_policy: dict[str, Any], mock_payload: AIHookPayload, fs: Any +) -> None: + """Test that _scan_path_for_secrets returns (None, None) for directories.""" + fs.create_dir('/path/to/some_directory') + + result = _scan_path_for_secrets( + mock_ctx, '/path/to/some_directory', default_policy, payload=mock_payload, effective_mode=GuardrailsMode.BLOCK + ) + + assert result == (None, None) + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._perform_scan') +def test_scan_path_for_secrets_skips_path_configured_in_exclusions( + mock_perform_scan: MagicMock, + mock_ctx: MagicMock, + default_policy: dict[str, Any], + mock_payload: AIHookPayload, + fs: Any, +) -> None: + """Test that a path ignored via `cycode ignore --by-path` is not scanned.""" + # `cycode ignore --by-path` stores absolute paths; on Windows that includes the drive prefix + excluded_dir = os.path.abspath(os.path.join(os.sep, 'project', 'secrets')) + file_path = os.path.join(excluded_dir, 'creds.env') + fs.create_file(file_path, contents='password=hunter2') + mock_perform_scan.return_value = ('Cycode found 1 violations', 'scan-id-123') + + with patch( + 'cycode.cli.files_collector.file_excluder.configuration_manager.get_exclusions_by_scan_type', + return_value={'paths': [excluded_dir]}, + ): + result = _scan_path_for_secrets( + mock_ctx, file_path, default_policy, payload=mock_payload, effective_mode=GuardrailsMode.BLOCK + ) + + assert result == (None, None) + mock_perform_scan.assert_not_called() + + +def test_perform_scan_no_violation_when_all_detections_excluded(mock_ctx: MagicMock) -> None: + """Test that detections filtered out by ignore rules do not produce a violation.""" + local_scan_result = LocalScanResult( + scan_id='scan-id-123', + report_url=None, + document_detections=[], + issue_detected=False, + detections_count=1, + relevant_detections_count=0, + ) + document = Document(path='prompt-content.txt', content='some content', is_git_diff_format=False) + + with patch( + 'cycode.cli.apps.ai_guardrails.scan.handlers._get_scan_documents_thread_func', + return_value=lambda batch: ('scan-id-123', None, local_scan_result), + ): + violation_summary, scan_id = _perform_scan(mock_ctx, [document], {}, timeout_seconds=5.0) + + assert violation_summary is None + assert scan_id == 'scan-id-123' + + +# Tests for handle_before_mcp_execution + + +def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_policy: dict[str, Any]) -> None: + """Test that disabled MCP scanning allows the execution.""" + default_policy['mcp']['enabled'] = False + payload = AIHookPayload( + event_name='McpExecution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_no_secrets( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution with no secrets is allowed.""" + mock_scan.return_value = (None, 'scan-id-123') + payload = AIHookPayload( + event_name='McpExecution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_blocked( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution with secrets is blocked.""" + mock_scan.return_value = ('Found 1 secret: token', 'scan-id-456') + payload = AIHookPayload( + event_name='McpExecution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'secret_token_12345'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.MCP_EXECUTION + assert 'Found 1 secret: token' in result.user_message + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_MCP_ARGS + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_warned( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution with secrets in warn mode asks permission.""" + mock_scan.return_value = ('Found 1 secret: token', 'scan-id-789') + default_policy['mcp']['action'] = 'warn' + payload = AIHookPayload( + event_name='McpExecution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'secret_token_12345'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.MCP_EXECUTION + assert 'Found 1 secret: token' in result.user_message + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_scan_disabled( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution is allowed when argument scanning is disabled.""" + default_policy['mcp']['scan_arguments'] = False + payload = AIHookPayload( + event_name='McpExecution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) + mock_scan.assert_not_called() + + +def test_get_effective_mode_block_only_when_both_mode_and_action_block() -> None: + """The event blocks only when both the global mode and the per-guardrail action are block.""" + assert get_effective_mode({'mode': 'block'}, {'action': 'block'}) == GuardrailsMode.BLOCK + assert get_effective_mode({'mode': 'block'}, {'action': 'warn'}) == GuardrailsMode.REPORT + assert get_effective_mode({'mode': 'warn'}, {'action': 'block'}) == GuardrailsMode.REPORT + assert get_effective_mode({'mode': 'warn'}, {'action': 'warn'}) == GuardrailsMode.REPORT + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_serial_number', return_value='SER-123') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_hostname', return_value='test-host') +def test_build_ai_guardrails_scan_parameters( + mock_hostname: MagicMock, mock_serial: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload +) -> None: + """The built scan parameters embed the full hook context alongside the standard scan parameters.""" + mock_ctx.info_name = 'ai_guardrails' + + params = build_ai_guardrails_scan_parameters( + mock_ctx, None, mock_payload, AiHookEventType.PROMPT, effective_mode=GuardrailsMode.REPORT + ) + + assert params['command_type'] == 'ai_guardrails' + assert params['metadata']['ai_guardrails'] == { + 'mode': 'report', + 'ide_provider': 'cursor', + 'detection_source': 'secrets_in_prompt', + 'device_id': 'SER-123', + 'device_hostname': 'test-host', + 'conversation_id': 'test-conv-id', + 'generation_id': 'test-gen-id', + 'ide_user_email': 'test@example.com', + 'mcp_server_name': None, + 'mcp_tool_name': None, + } + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._perform_scan') +def test_scan_text_for_secrets_injects_ai_guardrails_scan_parameter( + mock_perform_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload +) -> None: + """The scan parameters sent to the server include the ai_guardrails context.""" + mock_ctx.obj['progress_bar'] = MagicMock() + mock_perform_scan.return_value = (None, 'scan-id-123') + + _scan_text_for_secrets( + mock_ctx, + 'some text', + 1000, + payload=mock_payload, + event_type=AiHookEventType.PROMPT, + effective_mode=GuardrailsMode.REPORT, + ) + + ai_guardrails = mock_perform_scan.call_args.args[2]['metadata']['ai_guardrails'] + assert ai_guardrails['mode'] == 'report' + assert ai_guardrails['detection_source'] == 'secrets_in_prompt' + assert ai_guardrails['conversation_id'] == 'test-conv-id' + assert ai_guardrails['generation_id'] == 'test-gen-id' diff --git a/tests/cli/commands/ai_guardrails/scan/test_policy.py b/tests/cli/commands/ai_guardrails/scan/test_policy.py new file mode 100644 index 00000000..a378ad1c --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_policy.py @@ -0,0 +1,252 @@ +"""Tests for AI guardrails policy loading and management.""" + +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.scan.policy import ( + deep_merge, + get_machine_policy_path, + get_policy_value, + load_defaults, + load_policy, + load_yaml_file, +) + + +def test_deep_merge_simple() -> None: + """Test deep merging two simple dictionaries.""" + base = {'a': 1, 'b': 2} + override = {'b': 3, 'c': 4} + result = deep_merge(base, override) + + assert result == {'a': 1, 'b': 3, 'c': 4} + + +def test_deep_merge_nested() -> None: + """Test deep merging nested dictionaries.""" + base = {'level1': {'level2': {'key1': 'value1', 'key2': 'value2'}}} + override = {'level1': {'level2': {'key2': 'override2', 'key3': 'value3'}}} + result = deep_merge(base, override) + + assert result == {'level1': {'level2': {'key1': 'value1', 'key2': 'override2', 'key3': 'value3'}}} + + +def test_deep_merge_override_with_non_dict() -> None: + """Test that non-dict overrides replace the base value entirely.""" + base = {'key': {'nested': 'value'}} + override = {'key': 'simple_value'} + result = deep_merge(base, override) + + assert result == {'key': 'simple_value'} + + +def test_load_yaml_file_nonexistent(fs: FakeFilesystem) -> None: + """Test loading a non-existent file returns None.""" + result = load_yaml_file(Path('/fake/nonexistent.yaml')) + assert result is None + + +def test_load_yaml_file_valid_yaml(fs: FakeFilesystem) -> None: + """Test loading a valid YAML file.""" + fs.create_file('/fake/config.yaml', contents='mode: block\nfail_open: true\n') + + result = load_yaml_file(Path('/fake/config.yaml')) + assert result == {'mode': 'block', 'fail_open': True} + + +def test_load_yaml_file_valid_json(fs: FakeFilesystem) -> None: + """Test loading a valid JSON file.""" + fs.create_file('/fake/config.json', contents='{"mode": "block", "fail_open": true}') + + result = load_yaml_file(Path('/fake/config.json')) + assert result == {'mode': 'block', 'fail_open': True} + + +def test_load_yaml_file_invalid_yaml(fs: FakeFilesystem) -> None: + """Test loading an invalid YAML file returns None.""" + fs.create_file('/fake/invalid.yaml', contents='{ invalid yaml content [') + + result = load_yaml_file(Path('/fake/invalid.yaml')) + assert result is None + + +def test_load_defaults() -> None: + """Test that load_defaults returns a dict with expected keys.""" + defaults = load_defaults() + + assert isinstance(defaults, dict) + assert 'mode' in defaults + assert 'fail_open' in defaults + assert 'prompt' in defaults + assert 'file_read' in defaults + assert 'mcp' in defaults + + +def test_get_policy_value_single_key() -> None: + """Test getting a single-level value.""" + policy = {'mode': 'block', 'fail_open': True} + + assert get_policy_value(policy, 'mode') == 'block' + assert get_policy_value(policy, 'fail_open') is True + + +def test_get_policy_value_nested_keys() -> None: + """Test getting a nested value.""" + policy = {'prompt': {'enabled': True, 'action': 'block'}} + + assert get_policy_value(policy, 'prompt', 'enabled') is True + assert get_policy_value(policy, 'prompt', 'action') == 'block' + + +def test_get_policy_value_missing_key() -> None: + """Test that missing keys return the default value.""" + policy = {'mode': 'block'} + + assert get_policy_value(policy, 'nonexistent', default='default_value') == 'default_value' + + +def test_get_policy_value_deeply_nested() -> None: + """Test getting deeply nested values.""" + policy = {'level1': {'level2': {'level3': 'value'}}} + + assert get_policy_value(policy, 'level1', 'level2', 'level3') == 'value' + assert get_policy_value(policy, 'level1', 'level2', 'missing', default='def') == 'def' + + +def test_get_policy_value_non_dict_in_path() -> None: + """Test that non-dict values in path return default.""" + policy = {'key': 'string_value'} + + # Trying to access nested key on non-dict should return default + assert get_policy_value(policy, 'key', 'nested', default='default') == 'default' + + +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_defaults_only(mock_load: MagicMock) -> None: + """Test loading policy with only defaults (no user or repo config).""" + mock_load.return_value = None # No user or repo config + + policy = load_policy() + + assert 'mode' in policy + assert 'fail_open' in policy + + +@patch('pathlib.Path.home') +def test_load_policy_with_user_config(mock_home: MagicMock, fs: FakeFilesystem) -> None: + """Test loading policy with user config override.""" + mock_home.return_value = Path('/home/testuser') + + # Create user config in fake filesystem + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='mode: warn\nfail_open: false\n') + + policy = load_policy() + + # User config should override defaults + assert policy['mode'] == 'warn' + assert policy['fail_open'] is False + + +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_with_repo_config(mock_load: MagicMock) -> None: + """Test loading policy with repo config (highest precedence).""" + repo_path = Path('/fake/repo') + repo_config = repo_path / '.cycode' / 'ai-guardrails.yaml' + + def side_effect(path: Path) -> Optional[dict]: + if path == repo_config: + return {'mode': 'block', 'prompt': {'enabled': False}} + return None + + mock_load.side_effect = side_effect + + policy = load_policy(str(repo_path)) + + # Repo config should have highest precedence + assert policy['mode'] == 'block' + assert policy['prompt']['enabled'] is False + + +@patch('pathlib.Path.home') +def test_load_policy_precedence(mock_home: MagicMock, fs: FakeFilesystem) -> None: + """Test that policy precedence is: defaults < user < repo.""" + mock_home.return_value = Path('/home/testuser') + + # Create user config + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='mode: warn\nfail_open: false\n') + + # Create repo config + fs.create_file('/fake/repo/.cycode/ai-guardrails.yaml', contents='mode: block\n') + + policy = load_policy('/fake/repo') + + # mode should come from repo (highest precedence) + assert policy['mode'] == 'block' + # fail_open should come from user config (repo doesn't override it) + assert policy['fail_open'] is False + + +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_none_workspace_root(mock_load: MagicMock) -> None: + """Test that None workspace_root is handled correctly.""" + mock_load.return_value = None + + policy = load_policy(None) + + # Should only load defaults (no repo config) + assert 'mode' in policy + + +def test_get_machine_policy_path_per_os(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the per-OS machine policy locations.""" + with patch('sys.platform', 'darwin'): + assert get_machine_policy_path() == Path('/Library/Application Support/Cycode') / 'ai-guardrails.yaml' + + with patch('sys.platform', 'linux'): + assert get_machine_policy_path() == Path('/etc/cycode') / 'ai-guardrails.yaml' + + with patch('sys.platform', 'win32'): + monkeypatch.setenv('PROGRAMDATA', 'C:\\ProgramData') + assert get_machine_policy_path() == Path('C:\\ProgramData') / 'Cycode' / 'ai-guardrails.yaml' + + +@patch('pathlib.Path.home') +@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path') +def test_load_policy_with_machine_config( + mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem +) -> None: + """Test that the machine-wide config overrides defaults.""" + mock_home.return_value = Path('/home/testuser') + machine_path = Path('/machine/ai-guardrails.yaml') + mock_machine_path.return_value = machine_path + fs.create_file(str(machine_path), contents='mode: warn\n') + + policy = load_policy() + + # Machine config overrides the built-in default (block); other keys inherit from defaults. + assert policy['mode'] == 'warn' + assert policy['fail_open'] is True + + +@patch('pathlib.Path.home') +@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path') +def test_load_policy_precedence_defaults_machine_user_repo( + mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem +) -> None: + """Test full precedence: defaults < machine < user < repo.""" + mock_home.return_value = Path('/home/testuser') + machine_path = Path('/machine/ai-guardrails.yaml') + mock_machine_path.return_value = machine_path + fs.create_file(str(machine_path), contents='mode: warn\nfail_open: false\n') + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='fail_open: true\n') + fs.create_file('/fake/repo/.cycode/ai-guardrails.yaml', contents='mode: block\n') + + policy = load_policy('/fake/repo') + + # repo overrides machine's mode; user overrides machine's fail_open. + assert policy['mode'] == 'block' + assert policy['fail_open'] is True diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py new file mode 100644 index 00000000..8b7b611e --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -0,0 +1,284 @@ +"""Tests for AI guardrails scan command.""" + +import json +from io import StringIO +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture +from typer.testing import CliRunner + +from cycode.cli.apps.ai_guardrails import app as ai_guardrails_app +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock typer context.""" + ctx = MagicMock() + ctx.obj = {} + return ctx + + +@pytest.fixture +def mock_scan_command_deps(mocker: MockerFixture) -> dict[str, MagicMock]: + """Mock scan_command dependencies that should not be called on early exit.""" + return { + 'initialize_clients': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients'), + 'load_policy': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy'), + 'get_handler': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event'), + } + + +def _assert_no_api_calls(mocks: dict[str, MagicMock]) -> None: + """Assert that no API-related functions were called.""" + mocks['initialize_clients'].assert_not_called() + mocks['load_policy'].assert_not_called() + mocks['get_handler'].assert_not_called() + + +class TestIdeMismatchSkipsProcessing: + """Tests that verify IDE mismatch causes early exit without API calls.""" + + def test_claude_code_payload_with_cursor_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Claude Code payload is skipped when --ide cursor is specified. + + When Cursor reads Claude Code hooks from ~/.claude/settings.json, it will invoke + the hook with Claude Code event names. The scan command should skip processing. + """ + payload = {'hook_event_name': 'UserPromptSubmit', 'session_id': 'session-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='cursor') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + def test_cursor_payload_with_claude_code_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Cursor payload is skipped when --ide claude-code is specified.""" + payload = {'hook_event_name': 'beforeSubmitPrompt', 'conversation_id': 'conv-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='claude-code') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response == {} # Claude Code allow_prompt returns empty dict + + +class TestSyntheticPromptSkipsProcessing: + """Tests that verify synthetic (harness-generated) prompts cause early exit without API calls.""" + + def test_task_notification_prompt_skipped( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Fork/subagent completions arrive as synthetic user turns + that fire UserPromptSubmit in the parent session; they must not be scanned.""" + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + 'prompt': 'Task dummy-task-1 completed', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='claude-code') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response == {} # Claude Code allow_prompt returns empty dict + + +class TestInvalidPayloadSkipsProcessing: + """Tests that verify invalid payloads cause early exit without API calls.""" + + def test_empty_payload( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test empty payload skips processing.""" + mocker.patch('sys.stdin', StringIO('')) + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['initialize_clients'].assert_not_called() + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + def test_invalid_json_payload( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test invalid JSON skips processing.""" + mocker.patch('sys.stdin', StringIO('not valid json {')) + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['initialize_clients'].assert_not_called() + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + +class TestMatchingIdeProcessesPayload: + """Tests that verify matching IDE processes the payload normally.""" + + def test_claude_code_payload_with_claude_code_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Claude Code payload is processed when --ide claude-code is specified.""" + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} + mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mock_scan_command_deps['get_handler'].return_value = mock_handler + + scan_command(mock_ctx, ide='claude-code') + + mock_scan_command_deps['initialize_clients'].assert_called_once() + mock_scan_command_deps['load_policy'].assert_called_once() + mock_scan_command_deps['get_handler'].assert_called_once() + mock_handler.assert_called_once() + + def test_empty_workspace_roots_falls_back_to_cwd( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Cursor sends workspace_roots=[] when no folder is open - must not crash.""" + payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'conversation_id': 'conv-123', + 'prompt': 'test', + 'workspace_roots': [], + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} + mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mock_scan_command_deps['get_handler'].return_value = mock_handler + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['load_policy'].assert_called_once_with('.') + mock_handler.assert_called_once() + + +class TestCopilotPayloadRouting: + """Copilot-specific routing through scan_command.""" + + def test_unmatched_tool_allows_without_policy_or_clients( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Copilot hooks have no matchers - tools we don't scan must skip fast. + + The handler lookup runs before load_policy/_initialize_clients, so an + unmatched tool costs neither file I/O nor network setup. + """ + payload = { + 'timestamp': '2026-07-14T13:33:24.387Z', + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-123', + 'tool_name': 'list_dir', + 'tool_input': {'path': '/Users/user'}, + 'tool_use_id': 'call_abc__vscode-1', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + mock_scan_command_deps['get_handler'].return_value = None + + scan_command(mock_ctx, ide='copilot') + + mock_scan_command_deps['get_handler'].assert_called_once_with('list_dir') + mock_scan_command_deps['load_policy'].assert_not_called() + mock_scan_command_deps['initialize_clients'].assert_not_called() + assert json.loads(capsys.readouterr().out) == {} + + def test_copilot_cli_payload_skipped( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Copilot CLI shares the hooks file but speaks camelCase without an event + name - until its dialect is supported, its events skip fail-open.""" + payload = { + 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': 1784038775604, + 'cwd': '/Users/user', + 'toolName': 'view', + 'toolArgs': '{"path": "/Users/user/file"}', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='copilot') + + _assert_no_api_calls(mock_scan_command_deps) + assert json.loads(capsys.readouterr().out) == {} + + +class TestDefaultIdeParameterViaCli: + """Tests that verify default IDE parameter works correctly via CLI invocation.""" + + def test_scan_command_default_ide_via_cli(self, mocker: MockerFixture) -> None: + """Test scan_command works with default --ide when invoked via CLI. + + Catches regressions where the default value would no longer match a + registered IDE name (e.g. after renaming `DEFAULT_IDE_NAME`). + """ + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy', + return_value={'fail_open': True}, + ) + mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', + return_value=mock_handler, + ) + + runner = CliRunner() + payload = json.dumps({'hook_event_name': 'beforeSubmitPrompt', 'prompt': 'test'}) + + # Invoke via CLI without --ide flag to use default + result = runner.invoke(ai_guardrails_app, ['scan'], input=payload) + + assert result.exit_code == 0, f'Command failed: {result.output}' diff --git a/tests/cli/commands/ai_guardrails/scan/test_utils.py b/tests/cli/commands/ai_guardrails/scan/test_utils.py new file mode 100644 index 00000000..46ae195d --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_utils.py @@ -0,0 +1,144 @@ +"""Tests for AI guardrails utility functions.""" + +import io +from unittest.mock import patch + +from cycode.cli.apps.ai_guardrails.scan.utils import ( + is_denied_path, + matches_glob, + normalize_path, + read_stdin_text, + safe_json_parse, +) + + +def test_read_stdin_text_decodes_bom_and_utf8() -> None: + """utf-8-sig byte decode strips the BOM Cursor sends on Windows and avoids ANSI mojibake.""" + raw = '\ufeff{"prompt": "café"}'.encode() # utf-8 with BOM, multi-byte non-ASCII content + fake_stdin = io.TextIOWrapper(io.BytesIO(raw), encoding='utf-8') + + with patch('sys.stdin', fake_stdin): + text = read_stdin_text() + + assert safe_json_parse(text)['prompt'] == 'café' + + +def test_read_stdin_text_falls_back_without_buffer() -> None: + """Streams without .buffer (e.g. StringIO in tests) fall back to a text-mode read, BOM-stripped.""" + with patch('sys.stdin', io.StringIO('{"a": 1}')): + assert read_stdin_text() == '{"a": 1}' + + with patch('sys.stdin', io.StringIO('\ufeff{"a": 1}')): + assert read_stdin_text() == '{"a": 1}' + + +def test_safe_json_parse_invalid_and_empty() -> None: + """Invalid JSON and empty inputs return an empty dict.""" + assert safe_json_parse('not valid json {') == {} + assert safe_json_parse('') == {} + + +def test_normalize_path_rejects_escape() -> None: + """Test that paths attempting to escape are rejected.""" + path = '../../../etc/passwd' + result = normalize_path(path) + + assert result == '' + + +def test_normalize_path_empty() -> None: + """Test normalizing empty path.""" + result = normalize_path('') + + assert result == '' + + +def test_matches_glob_simple() -> None: + """Test simple glob pattern matching.""" + assert matches_glob('secret.env', '*.env') is True + assert matches_glob('secret.txt', '*.env') is False + + +def test_matches_glob_recursive() -> None: + """Test recursive glob pattern with **.""" + assert matches_glob('path/to/secret.env', '**/*.env') is True + # Note: '**/*.env' requires at least one path separator, so 'secret.env' won't match + assert matches_glob('secret.env', '*.env') is True # Use non-recursive pattern instead + assert matches_glob('path/to/file.txt', '**/*.env') is False + + +def test_matches_glob_directory() -> None: + """Test matching files in specific directories.""" + assert matches_glob('.env', '.env') is True + assert matches_glob('config/.env', '**/.env') is True + assert matches_glob('other/file', '**/.env') is False + + +def test_matches_glob_case_insensitive() -> None: + """Test that glob matching handles case variations.""" + # Case-insensitive matching for cross-platform compatibility + assert matches_glob('secret.env', '*.env') is True + assert matches_glob('SECRET.ENV', '*.env') is True # Uppercase path matches lowercase pattern + assert matches_glob('Secret.Env', '*.env') is True # Mixed case matches + assert matches_glob('secret.env', '*.ENV') is True # Lowercase path matches uppercase pattern + assert matches_glob('SECRET.ENV', '*.ENV') is True # Both uppercase match + + +def test_matches_glob_empty_inputs() -> None: + """Test glob matching with empty inputs.""" + assert matches_glob('', '*.env') is False + assert matches_glob('file.env', '') is False + assert matches_glob('', '') is False + + +def test_matches_glob_with_traversal_attempt() -> None: + """Test that path traversal is normalized before matching.""" + # Path traversal attempts should be normalized + assert matches_glob('../secret.env', '*.env') is False + + +def test_is_denied_path_with_deny_globs() -> None: + """Test path denial with deny_globs policy.""" + policy = {'file_read': {'deny_globs': ['*.env', '.git/*', '**/secrets/*']}} + + assert is_denied_path('.env', policy) is True + # Note: Path.match('*.env') matches paths ending with .env, including nested paths + assert is_denied_path('config/.env', policy) is True # Matches *.env + assert is_denied_path('.git/config', policy) is True # Matches .git/* + assert is_denied_path('app/secrets/api_keys.txt', policy) is True # Matches **/secrets/* + assert is_denied_path('app/config.yaml', policy) is False + + +def test_is_denied_path_nested_patterns() -> None: + """Test denial with various nesting patterns.""" + policy = {'file_read': {'deny_globs': ['*.key', '**/*.key', 'config/*.env']}} + + # *.key matches .key files at root level, **/*.key for nested + assert is_denied_path('private.key', policy) is True + assert is_denied_path('app/private.key', policy) is True + # config/*.env only matches .env files directly in config/ + assert is_denied_path('config/app.env', policy) is True + assert is_denied_path('config/sub/app.env', policy) is False # Not direct child + assert is_denied_path('app/config.yaml', policy) is False + + +def test_is_denied_path_empty_globs() -> None: + """Test that empty deny_globs list denies nothing.""" + policy = {'file_read': {'deny_globs': []}} + + assert is_denied_path('.env', policy) is False + assert is_denied_path('any/path', policy) is False + + +def test_is_denied_path_no_policy() -> None: + """Test denial with missing policy configuration.""" + policy = {} + + assert is_denied_path('.env', policy) is False + + +def test_is_denied_path_empty_path() -> None: + """Test denial check with empty path.""" + policy = {'file_read': {'deny_globs': ['*.env']}} + + assert is_denied_path('', policy) is False diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py new file mode 100644 index 00000000..1a7b7c2f --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -0,0 +1,316 @@ +"""Tests for AI guardrails hooks manager and per-IDE hooks rendering.""" + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml +from pyfakefs.fake_filesystem import FakeFilesystem + +if TYPE_CHECKING: + import pytest + from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.consts import ( + CYCODE_SCAN_PROMPT_COMMAND, + CYCODE_SESSION_START_COMMAND, + PolicyMode, +) +from cycode.cli.apps.ai_guardrails.hooks_manager import ( + create_policy_file, + install_hooks, + is_cycode_hook_entry, + uninstall_hooks, +) +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.codex import Codex +from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor + + +def test_is_cycode_hook_entry_cursor_format() -> None: + """Detect Cycode hook in Cursor's flat command format.""" + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan'}) is True + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan --some-flag'}) is True + + +def test_is_cycode_hook_entry_claude_code_format() -> None: + """Detect Cycode hook in Claude Code's nested format.""" + entry = {'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}]} + assert is_cycode_hook_entry(entry) is True + + entry = { + 'matcher': 'Read', + 'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}], + } + assert is_cycode_hook_entry(entry) is True + + +def test_is_cycode_hook_entry_copilot_shell_fields() -> None: + # Copilot async entries carry per-OS bash/powershell fields instead of `command`. + assert is_cycode_hook_entry({'type': 'command', 'bash': 'cycode ai-guardrails scan --ide copilot &'}) is True + assert is_cycode_hook_entry({'type': 'command', 'powershell': 'cycode ai-guardrails scan --ide copilot'}) is True + assert is_cycode_hook_entry({'type': 'command', 'bash': '/usr/local/bin/user-hook.sh'}) is False + + +def test_is_cycode_hook_entry_non_cycode() -> None: + """Non-Cycode hooks must not be detected.""" + assert is_cycode_hook_entry({'command': 'some-other-command'}) is False + assert is_cycode_hook_entry({'hooks': [{'type': 'command', 'command': 'some-other-command'}]}) is False + assert is_cycode_hook_entry({}) is False + + +def test_is_cycode_hook_entry_partial_match() -> None: + """Detection is substring-based: full paths and trailing flags still count.""" + assert is_cycode_hook_entry({'command': '/usr/local/bin/cycode ai-guardrails scan'}) is True + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan --verbose'}) is True + + +# Per-IDE hook config tests (now exposed via IDE.render_hooks_config) + + +def test_cursor_render_hooks_sync() -> None: + """Cursor sync hooks: no '&' in scan commands.""" + config = Cursor().render_hooks_config() + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} + for entries in scan_hooks.values(): + for entry in entries: + assert entry['command'] == CYCODE_SCAN_PROMPT_COMMAND + assert '&' not in entry['command'] + + +def test_cursor_render_hooks_async(mocker: 'MockerFixture') -> None: + """Cursor async hooks: '&' suffix on scan commands (unix).""" + mocker.patch('platform.system', return_value='Linux') + config = Cursor().render_hooks_config(async_mode=True) + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} + for entries in scan_hooks.values(): + for entry in entries: + assert entry['command'].endswith('&') + assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] + + +def test_cursor_render_hooks_async_windows_stays_sync(mocker: 'MockerFixture') -> None: + """No '&' on Windows: cmd treats it as a no-op separator and Windows + PowerShell rejects it outright - either way nothing detaches.""" + mocker.patch('platform.system', return_value='Windows') + config = Cursor().render_hooks_config(async_mode=True) + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} + for entries in scan_hooks.values(): + for entry in entries: + assert '&' not in entry['command'] + + +def test_cursor_render_hooks_session_start() -> None: + """Cursor session_start carries the --ide flag explicitly.""" + config = Cursor().render_hooks_config() + assert 'sessionStart' in config['hooks'] + entries = config['hooks']['sessionStart'] + assert len(entries) == 1 + assert CYCODE_SESSION_START_COMMAND in entries[0]['command'] + assert '--ide cursor' in entries[0]['command'] + + +def test_claude_code_render_hooks_sync() -> None: + """Claude Code sync hooks: no async/timeout fields.""" + config = ClaudeCode().render_hooks_config() + scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} + for event_entries in scan_events.values(): + for event_entry in event_entries: + for hook in event_entry['hooks']: + assert 'async' not in hook + assert 'timeout' not in hook + + +def test_claude_code_render_hooks_async() -> None: + """Claude Code async hooks: 'async' flag + timeout.""" + config = ClaudeCode().render_hooks_config(async_mode=True) + scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} + for event_entries in scan_events.values(): + for event_entry in event_entries: + for hook in event_entry['hooks']: + assert hook['async'] is True + + +def test_claude_code_render_hooks_session_start() -> None: + """Claude Code SessionStart fires on every source (a forked session reports + 'resume', so the matcher is empty -> match-all).""" + config = ClaudeCode().render_hooks_config() + entries = config['hooks']['SessionStart'] + assert len(entries) == 1 + assert 'matcher' not in entries[0] + assert CYCODE_SESSION_START_COMMAND in entries[0]['hooks'][0]['command'] + assert '--ide claude-code' in entries[0]['hooks'][0]['command'] + + +# Policy file tests + + +def test_create_policy_file_warn(fs: FakeFilesystem) -> None: + """Create a warn-mode policy file.""" + fs.create_dir(Path.home()) + success, message = create_policy_file('user', PolicyMode.WARN) + + assert success is True + assert 'warn mode' in message + + policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' + assert policy_path.exists() + assert yaml.safe_load(policy_path.read_text())['mode'] == 'warn' + + +def test_create_policy_file_block(fs: FakeFilesystem) -> None: + """Create a block-mode policy file.""" + fs.create_dir(Path.home()) + success, message = create_policy_file('user', PolicyMode.BLOCK) + + assert success is True + assert 'block mode' in message + + policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'block' + + +def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: + """Re-running updates only the mode field and preserves customizations.""" + policy_dir = Path.home() / '.cycode' + fs.create_dir(policy_dir) + policy_path = policy_dir / 'ai-guardrails.yaml' + policy_path.write_text(yaml.dump({'version': 1, 'mode': 'warn', 'custom_field': 'keep_me'})) + + success, _ = create_policy_file('user', PolicyMode.BLOCK) + + assert success is True + policy = yaml.safe_load(policy_path.read_text()) + assert policy['mode'] == 'block' + assert policy['custom_field'] == 'keep_me' + + +def test_install_preserves_user_hook_colocated_with_cycode( + fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' +) -> None: + """install must not clobber a user-authored hook that shares + an entry with a Cycode hook. The filter is hook-level, not entry-level. + """ + repo = Path('/repo') + fs.create_dir(repo) + hooks_path = repo / '.codex' / 'hooks.json' + fs.create_file( + hooks_path, + contents=json.dumps( + { + 'version': 1, + 'hooks': { + 'SessionStart': [ + { + 'matcher': 'startup|clear', + 'hooks': [ + {'type': 'command', 'command': '/usr/local/bin/user-debug.sh SessionStart'}, + {'type': 'command', 'command': 'cycode ai-guardrails session-start --ide codex'}, + ], + } + ], + # Unrelated event with no Cycode hooks at all — must be untouched. + 'PostToolUse': [{'hooks': [{'type': 'command', 'command': '/usr/local/bin/user-postlog.sh'}]}], + }, + } + ), + ) + + # Codex's post_install touches ~/.codex/config.toml (user scope) — keep that off + # the filesystem under test by pinning CODEX_HOME inside the fake FS. + monkeypatch.setenv('CODEX_HOME', '/codex-home') + fs.create_dir('/codex-home') + + success, _ = install_hooks(Codex(), scope='repo', repo_path=repo) + assert success is True + + saved = json.loads(hooks_path.read_text()) + session_start = saved['hooks']['SessionStart'] + # The pre-existing entry should still exist with the user hook preserved, + # and a separate fresh Cycode entry should have been appended. + user_hook_cmd = '/usr/local/bin/user-debug.sh SessionStart' + remaining_user_hooks = [ + h for entry in session_start for h in entry.get('hooks', []) if h.get('command') == user_hook_cmd + ] + assert remaining_user_hooks, 'user hook was clobbered by install' + + # Unrelated event untouched. + assert saved['hooks']['PostToolUse'][0]['hooks'][0]['command'] == '/usr/local/bin/user-postlog.sh' + + +def test_uninstall_preserves_user_hook_colocated_with_cycode( + fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' +) -> None: + """uninstall must strip only the Cycode hook from a mixed entry.""" + repo = Path('/repo') + fs.create_dir(repo) + hooks_path = repo / '.codex' / 'hooks.json' + fs.create_file( + hooks_path, + contents=json.dumps( + { + 'version': 1, + 'hooks': { + 'UserPromptSubmit': [ + { + 'hooks': [ + {'type': 'command', 'command': '/usr/local/bin/user-debug.sh UserPromptSubmit'}, + {'type': 'command', 'command': 'cycode ai-guardrails scan --ide codex'}, + ] + } + ] + }, + } + ), + ) + monkeypatch.setenv('CODEX_HOME', '/codex-home') + fs.create_dir('/codex-home') + + success, _ = uninstall_hooks(Codex(), scope='repo', repo_path=repo) + assert success is True + + saved = json.loads(hooks_path.read_text()) + hooks = saved['hooks']['UserPromptSubmit'][0]['hooks'] + commands = [h['command'] for h in hooks] + assert '/usr/local/bin/user-debug.sh UserPromptSubmit' in commands + assert not any('cycode ai-guardrails' in c for c in commands) + + +def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) -> None: + """Copilot uses a dedicated Cycode-owned file: install creates it from + scratch, reinstall is idempotent, uninstall removes the file entirely.""" + copilot = Copilot() + hooks_path = copilot.settings_path('user') + + success, _ = install_hooks(copilot) + assert success is True + saved = json.loads(hooks_path.read_text()) + assert saved['version'] == 1 + assert set(saved['hooks']) == {'SessionStart', 'UserPromptSubmit', 'PreToolUse'} + assert all(len(entries) == 1 for entries in saved['hooks'].values()) + + # Reinstall (also flipping mode) must replace, not duplicate. + success, _ = install_hooks(copilot, report_mode=True) + assert success is True + saved = json.loads(hooks_path.read_text()) + assert all(len(entries) == 1 for entries in saved['hooks'].values()) + assert saved['hooks']['PreToolUse'][0]['bash'].endswith('&') + + # Uninstall deletes the emptied dedicated file rather than leaving a husk. + success, _ = uninstall_hooks(copilot) + assert success is True + assert not hooks_path.exists() + + +def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: + """Create a policy file in repo scope.""" + repo_path = Path('/my-repo') + fs.create_dir(repo_path) + + success, _ = create_policy_file('repo', PolicyMode.WARN, repo_path=repo_path) + + assert success is True + policy_path = repo_path / '.cycode' / 'ai-guardrails.yaml' + assert policy_path.exists() + assert yaml.safe_load(policy_path.read_text())['mode'] == 'warn' diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py new file mode 100644 index 00000000..eaec8531 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -0,0 +1,592 @@ +"""Tests for session-start command.""" + +import json +from io import StringIO +from pathlib import Path +from unittest.mock import ANY, MagicMock, patch + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.ides import IDES, collect_all_session_contexts +from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod +from cycode.cli.apps.ai_guardrails.ides import codex as _codex_mod +from cycode.cli.apps.ai_guardrails.ides import copilot as _copilot_mod +from cycode.cli.apps.ai_guardrails.ides import cursor as _cursor_mod +from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock Typer context.""" + ctx = MagicMock(spec=typer.Context) + ctx.obj = {} + return ctx + + +@pytest.fixture(autouse=True) +def _isolated_session_context_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the dedup cache away from the real ~/.cycode in every test.""" + monkeypatch.setattr(_session_start_mod, '_session_context_cache_path', lambda: tmp_path / '.session-context-cache') + + +# Auth tests + + +@patch.object(_session_start_mod, 'get_authorization_info') +def test_already_authenticated_skips_auth(mock_get_auth: MagicMock, mock_ctx: MagicMock) -> None: + """When already authenticated, AuthManager should not be called.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_not_authenticated_triggers_auth( + mock_get_auth: MagicMock, mock_auth_manager_cls: MagicMock, mock_ctx: MagicMock +) -> None: + """When not authenticated, AuthManager.authenticate should be called.""" + mock_get_auth.return_value = None + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_auth_manager_cls.return_value.authenticate.assert_called_once() + + +@patch.object(_session_start_mod, 'handle_auth_exception') +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_auth_failure_handled_gracefully( + mock_get_auth: MagicMock, + mock_auth_manager_cls: MagicMock, + mock_handle_err: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Auth failure should be handled gracefully, not crash.""" + mock_get_auth.return_value = None + mock_auth_manager_cls.return_value.authenticate.side_effect = RuntimeError('auth failed') + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_handle_err.assert_called_once() + + +# Stdin / payload tests + + +@patch.object(_session_start_mod, 'get_authorization_info') +def test_tty_stdin_auth_only(mock_get_auth: MagicMock, mock_ctx: MagicMock) -> None: + """When stdin is a TTY (old hooks), only auth is performed.""" + mock_get_auth.return_value = MagicMock() + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + + with patch('sys.stdin', new=mock_stdin): + session_start_command(mock_ctx) + + mock_stdin.read.assert_not_called() + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_empty_stdin_skips_session_init( + mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_ctx: MagicMock +) -> None: + """Empty stdin should skip session initialization.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_get_client.assert_not_called() + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_invalid_json_stdin_skips_session_init( + mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_ctx: MagicMock +) -> None: + """Invalid JSON stdin should skip session initialization.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('not valid json')): + session_start_command(mock_ctx) + + mock_get_client.assert_not_called() + + +# Conversation creation tests + + +@patch.object(_claude_mod, 'extract_from_claude_transcript') +@patch.object(_claude_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_creates_conversation( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_extract: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Claude Code payload should create a conversation with session_id, model, email, version.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'user@example.com'}} + mock_extract.return_value = ('2.1.20', 'claude-opus', 'gen-abc') + + transcript_path = '/fake/transcript.jsonl' + payload = {'session_id': 'session-123', 'model': 'claude-opus', 'transcript_path': transcript_path} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_extract.assert_called_once_with(transcript_path) + mock_ai_client.create_conversation.assert_called_once() + call_payload = mock_ai_client.create_conversation.call_args[0][0] + assert call_payload.conversation_id == 'session-123' + assert call_payload.model == 'claude-opus' + assert call_payload.ide_user_email == 'user@example.com' + assert call_payload.ide_provider == 'claude-code' + assert call_payload.ide_version == '2.1.20' + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_creates_conversation( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor payload should create conversation with conversation_id and model.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + + payload = { + 'conversation_id': 'conv-456', + 'user_email': 'cursor-user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + } + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.create_conversation.assert_called_once() + call_payload = mock_ai_client.create_conversation.call_args[0][0] + assert call_payload.conversation_id == 'conv-456' + assert call_payload.model == 'gpt-4' + assert call_payload.ide_user_email == 'cursor-user@example.com' + assert call_payload.ide_provider == 'cursor' + + +@patch.object(_claude_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_conversation_creation_failure_non_blocking( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Conversation creation failure should not crash the command.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_ai_client.create_conversation.side_effect = RuntimeError('API down') + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = None + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + # Should not raise + + +# Session context reporting tests + + +@patch.object(_claude_mod, 'load_claude_config', return_value={}) +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_reports_cross_ide_session_context( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """All registered IDEs' configs go into config_files.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + cursor_file = {'path': '/home/u/.cursor/mcp.json', 'content': '{"mcpServers": {}}'} + claude_file = {'path': '/home/u/.claude.json', 'content': '{"mcpServers": {}}'} + plugins = {'dummy-plugin@dummy-marketplace': {'enabled': True}} + mock_collect.return_value = ({'cursor': cursor_file, 'claude-code': claude_file}, plugins) + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + # config_files is sorted by path for a stable digest. + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[claude_file, cursor_file], + enabled_plugins=plugins, + user_email=None, + ) + + +@patch.object(_claude_mod, 'load_claude_config', return_value={}) +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_no_mcp_anywhere_still_reports_device( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A machine with no MCP configs or plugins must still report its device context.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({}, {}) + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[], + enabled_plugins={}, + user_email=None, + ) + + +@patch.object(_copilot_mod, '_collect_installed_plugins') +@patch.object(_copilot_mod, '_load_vscode_mcp_config') +@patch.object(_codex_mod, '_load_codex_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_reports_config_files_and_plugin_metadata( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + mock_load_cursor: MagicMock, + mock_load_codex: MagicMock, + mock_load_vscode: MagicMock, + mock_collect_copilot_plugins: MagicMock, + mock_ctx: MagicMock, + tmp_path: Path, +) -> None: + """The global config file carries only the global MCP servers; the plugin's own + .mcp.json content + path + metadata enrich enabled_plugins (no merge into the global).""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_cursor.return_value = None + mock_load_codex.return_value = None + mock_load_vscode.return_value = None + mock_collect_copilot_plugins.return_value = {} + + # Set up a fake plugin directory on disk. + plugin_dir = tmp_path / 'dummy-plugin' + plugin_dir.mkdir() + (plugin_dir / '.mcp.json').write_text( + json.dumps({'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}}) + ) + claude_plugin_dir = plugin_dir / '.claude-plugin' + claude_plugin_dir.mkdir() + (claude_plugin_dir / 'plugin.json').write_text( + json.dumps({'name': 'dummy-plugin', 'version': '1.0.28', 'description': 'Dummy plugin'}) + ) + + user_mcp_servers = {'dummy-global': {'command': 'dummy-command'}} + mock_load_config.return_value = {'mcpServers': user_mcp_servers} + mock_load_settings.return_value = { + 'enabledPlugins': {'dummy-plugin@dummy-marketplace': True}, + 'extraKnownMarketplaces': {'dummy-marketplace': {'source': {'source': 'directory', 'path': str(plugin_dir)}}}, + } + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + plugin_mcp = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + claude_file = { + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': user_mcp_servers}), + } + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[claude_file], + enabled_plugins={ + 'dummy-plugin@dummy-marketplace': { + 'enabled': True, + 'name': 'dummy-plugin', + 'version': '1.0.28', + 'description': 'Dummy plugin', + 'mcp_server_names': ['dummy-server'], + 'mcp_config_file_path': str(plugin_dir / '.mcp.json'), + 'mcp_config_file': json.dumps(plugin_mcp), + } + }, + user_email=None, + ) + + +@patch.object(_copilot_mod, '_collect_installed_plugins') +@patch.object(_copilot_mod, '_load_vscode_mcp_config') +@patch.object(_codex_mod, '_load_codex_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_trigger_sweeps_other_ides( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_cursor: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + mock_load_codex: MagicMock, + mock_load_vscode: MagicMock, + mock_collect_copilot_plugins: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A Cursor-triggered session start also reports Claude's config via config_files.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + cursor_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + claude_servers = {'gitlab': {'command': 'npx'}} + mock_load_cursor.return_value = {'mcpServers': cursor_servers} + mock_load_config.return_value = {'mcpServers': claude_servers} + mock_load_settings.return_value = None + mock_load_codex.return_value = None + mock_load_vscode.return_value = None + mock_collect_copilot_plugins.return_value = {} + + payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + cursor_file = { + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': cursor_servers}), + } + claude_file = { + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': claude_servers}), + } + # config_files is sorted by path for a stable digest (~/.claude.json < ~/.cursor/mcp.json). + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[claude_file, cursor_file], + enabled_plugins={}, + user_email=None, + ) + + +# Dedup cache tests + + +def _run_session_start(mock_ctx: MagicMock, payload: dict) -> None: + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_unchanged_context_skips_second_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """An identical payload within the TTL is sent once; the second session start skips it.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + mock_ai_client.report_session_context.assert_called_once() + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_changed_context_resends( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A change in the collected inventory busts the cache immediately.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c1'}}, {}) + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c2'}}, {}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_tenant_change_resends( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Re-authenticating against a different tenant must re-send the same inventory.""" + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + + mock_get_auth.return_value = MagicMock(tenant_id='tenant-2') + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_failed_report_is_not_cached( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A failed send must not populate the cache - the next session retries.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_ai_client.report_session_context.return_value = False + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_expired_ttl_resends( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """After the TTL, an unchanged payload is re-sent (self-healing / liveness heartbeat).""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + + # Age the cache entry past the TTL. + cache_path = _session_start_mod._session_context_cache_path() + cache = json.loads(cache_path.read_text(encoding='utf-8')) + cache['sent_at'] = cache['sent_at'] - _session_start_mod._SESSION_CONTEXT_TTL_SECONDS - 1 + cache_path.write_text(json.dumps(cache), encoding='utf-8') + + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +# Cross-IDE sweep tests + + +def test_collect_all_session_contexts_merges_plugins_first_wins() -> None: + """A plugin key present in two IDEs keeps the first registered IDE's entry.""" + claude_plugin = {'enabled': True, 'version': '1.0.0'} + codex_plugin = {'enabled': True, 'version': '2.0.0'} + + with ( + patch.object(IDES['cursor'], 'get_session_context', return_value=(None, {})), + patch.object(IDES['claude-code'], 'get_session_context', return_value=(None, {'plug@m': claude_plugin})), + patch.object(IDES['codex'], 'get_session_context', return_value=(None, {'plug@m': codex_plugin})), + patch.object(IDES['copilot'], 'get_session_context', return_value=(None, {})), + ): + _, plugins = collect_all_session_contexts() + + assert plugins == {'plug@m': claude_plugin} + + +@patch.object(_session_start_mod, 'handle_auth_exception') +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_unauthenticated_skips_session_init( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_auth_manager_cls: MagicMock, + mock_handle_err: MagicMock, + mock_ctx: MagicMock, +) -> None: + """When auth fails, session initialization should be skipped entirely.""" + mock_get_auth.return_value = None + mock_auth_manager_cls.return_value.authenticate.side_effect = RuntimeError('auth failed') + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_get_client.assert_not_called() diff --git a/tests/cli/commands/configure/test_configure_command.py b/tests/cli/commands/configure/test_configure_command.py index 0d763edd..3548d4ed 100644 --- a/tests/cli/commands/configure/test_configure_command.py +++ b/tests/cli/commands/configure/test_configure_command.py @@ -1,314 +1,170 @@ +import os from typing import TYPE_CHECKING -from typer.testing import CliRunner +import pytest +import yaml +from click.testing import CliRunner +from typer.main import get_command from cycode.cli.app import app +from cycode.cli.apps.configure.consts import CONFIGURATION_MANAGER, CREDENTIALS_MANAGER +from cycode.cli.user_settings.config_file_manager import ConfigFileManager +from cycode.cli.user_settings.credentials_manager import CredentialsManager if TYPE_CHECKING: from pytest_mock import MockerFixture +# Built eagerly on the real filesystem; building it under pyfakefs breaks typer's +# pathlib.Path parameter introspection. +_click_app = get_command(app) -def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> None: - # Arrange - app_url_user_input = 'new app url' - api_url_user_input = 'new api url' - client_id_user_input = 'new client id' - client_secret_user_input = 'new client secret' - id_token_user_input = 'new id token' - - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(None, None), - ) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', - return_value=(None, None), - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value=None, - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', - return_value=None, - ) - - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch( - 'typer.prompt', - side_effect=[ - api_url_user_input, - app_url_user_input, - client_id_user_input, - client_secret_user_input, - id_token_user_input, - ], - ) - - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) - mocked_update_oidc_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' - ) - mocked_update_api_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' - ) - mocked_update_app_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) - mocked_update_oidc_credentials.assert_called_once_with(client_id_user_input, id_token_user_input) - mocked_update_api_base_url.assert_called_once_with(api_url_user_input) - mocked_update_app_base_url.assert_called_once_with(app_url_user_input) +# `cycode configure` reads/writes the real ~/.cycode files; run every test on pyfakefs +# so file access never reaches the developer's machine. +pytestmark = pytest.mark.usefixtures('fs') +_CURRENT_CREDENTIALS = { + CredentialsManager.CLIENT_ID_FIELD_NAME: 'current client id', + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'current client secret', + CredentialsManager.ID_TOKEN_FIELD_NAME: 'current id token', +} +_CURRENT_CONFIG = { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'current api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'current app url', + } +} -def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixture') -> None: - # Arrange - app_url_user_input = 'new app url' - api_url_user_input = 'new api url' - client_id_user_input = 'new client id' - client_secret_user_input = 'new client secret' - id_token_user_input = 'new id token' - - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', - return_value=('client id file', 'id token file'), - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value='api url file', - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', - return_value='app url file', - ) - - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch( - 'typer.prompt', - side_effect=[ - api_url_user_input, - app_url_user_input, - client_id_user_input, - client_secret_user_input, - id_token_user_input, - ], - ) - - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) - mocked_update_api_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' - ) - mocked_update_app_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' - ) - mocker_update_oidc_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) - mocker_update_oidc_credentials.assert_called_once_with(client_id_user_input, id_token_user_input) - mocked_update_api_base_url.assert_called_once_with(api_url_user_input) - mocked_update_app_base_url.assert_called_once_with(app_url_user_input) +def _credentials_filename() -> str: + return CREDENTIALS_MANAGER.get_filename() -def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: - # Arrange - client_id_user_input = 'new client id' - current_client_id = 'client secret file' - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, '', '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) +def _config_filename() -> str: + return CONFIGURATION_MANAGER.global_config_file_manager.get_filename() - # Act - CliRunner().invoke(app, ['configure']) - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, current_client_id) +def _seed_yaml(filename: str, content: dict) -> None: + os.makedirs(os.path.dirname(filename), exist_ok=True) + with open(filename, 'w', encoding='UTF-8') as file: + yaml.safe_dump(content, file) -def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> None: - # Arrange - client_secret_user_input = 'new client secret' - current_client_id = 'client secret file' +def _read_yaml(filename: str) -> dict: + with open(filename, encoding='UTF-8') as file: + return yaml.safe_load(file) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(current_client_id, 'client secret file'), - ) - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', '', client_secret_user_input, '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) +def _run_configure(mocker: 'MockerFixture', prompt_answers: list[str]) -> None: + # Prompt order: api url, app url, client id, client secret, id token + mocker.patch('typer.prompt', side_effect=prompt_answers) + result = CliRunner().invoke(_click_app, ['configure']) + assert result.exit_code == 0 - # Act - CliRunner().invoke(app, ['configure']) - # Assert - mocked_update_credentials.assert_called_once_with(current_client_id, client_secret_user_input) +def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> None: + _run_configure(mocker, ['new api url', 'new app url', 'new client id', 'new client secret', 'new id token']) + assert _read_yaml(_credentials_filename()) == { + CredentialsManager.CLIENT_ID_FIELD_NAME: 'new client id', + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'new client secret', + CredentialsManager.ID_TOKEN_FIELD_NAME: 'new id token', + } + assert _read_yaml(_config_filename()) == { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'new api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'new app url', + } + } -def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: - # Arrange - api_url_user_input = 'new api url' - current_api_url = 'api url' - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value=current_api_url, - ) +def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + _seed_yaml(_config_filename(), _CURRENT_CONFIG) - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=[api_url_user_input, '', '', '', '']) - mocked_update_api_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' - ) + _run_configure(mocker, ['new api url', 'new app url', 'new client id', 'new client secret', 'new id token']) - # Act - CliRunner().invoke(app, ['configure']) + assert _read_yaml(_credentials_filename()) == { + CredentialsManager.CLIENT_ID_FIELD_NAME: 'new client id', + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'new client secret', + CredentialsManager.ID_TOKEN_FIELD_NAME: 'new id token', + } + assert _read_yaml(_config_filename()) == { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'new api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'new app url', + } + } - # Assert - mocked_update_api_base_url.assert_called_once_with(api_url_user_input) +def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) -def test_configure_command_update_only_id_token(mocker: 'MockerFixture') -> None: - # Arrange - current_client_id = 'client id file' - current_id_token = 'old id token' - new_id_token = 'new id token' + _run_configure(mocker, ['', '', 'new client id', '', '']) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(current_client_id, 'client secret file'), - ) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', - return_value=(current_client_id, current_id_token), - ) + # Client id is replaced in both the token and OIDC credential pairs; everything else is kept + assert _read_yaml(_credentials_filename()) == { + **_CURRENT_CREDENTIALS, + CredentialsManager.CLIENT_ID_FIELD_NAME: 'new client id', + } + assert not os.path.exists(_config_filename()) - mocker.patch('typer.prompt', side_effect=['', '', '', '', new_id_token]) - mocked_update_oidc_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' - ) +def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) - # Act - CliRunner().invoke(app, ['configure']) + _run_configure(mocker, ['', '', '', 'new client secret', '']) - # Assert - mocked_update_oidc_credentials.assert_called_once_with(current_client_id, new_id_token) + assert _read_yaml(_credentials_filename()) == { + **_CURRENT_CREDENTIALS, + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'new client secret', + } -def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture') -> None: - # Arrange - client_id_user_input = '' - client_secret_user_input = '' +def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: + _seed_yaml(_config_filename(), _CURRENT_CONFIG) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) + _run_configure(mocker, ['new api url', '', '', '', '']) - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input, '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) + assert _read_yaml(_config_filename()) == { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'new api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'current app url', + } + } + assert not os.path.exists(_credentials_filename()) - # Act - CliRunner().invoke(app, ['configure']) - # Assert - assert not mocked_update_credentials.called +def test_configure_command_update_only_id_token(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + + _run_configure(mocker, ['', '', '', '', 'new id token']) + + assert _read_yaml(_credentials_filename()) == { + **_CURRENT_CREDENTIALS, + CredentialsManager.ID_TOKEN_FIELD_NAME: 'new id token', + } + + +def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + + _run_configure(mocker, ['', '', '', '', '']) + + assert _read_yaml(_credentials_filename()) == _CURRENT_CREDENTIALS def test_configure_command_should_not_update_config_file(mocker: 'MockerFixture') -> None: - # Arrange - app_url_user_input = '' - api_url_user_input = '' - - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value='api url file', - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', - return_value='app url file', - ) - - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=[api_url_user_input, app_url_user_input, '', '', '']) - mocked_update_api_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' - ) - mocked_update_app_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # Assert - assert not mocked_update_api_base_url.called - assert not mocked_update_app_base_url.called + _seed_yaml(_config_filename(), _CURRENT_CONFIG) + + _run_configure(mocker, ['', '', '', '', '']) + + assert _read_yaml(_config_filename()) == _CURRENT_CONFIG def test_configure_command_should_not_update_oidc_credentials(mocker: 'MockerFixture') -> None: - # Arrange - current_client_id = 'client id file' - current_client_secret = 'client secret file' - current_id_token = 'old id token' - - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(current_client_id, current_client_secret), - ) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', - return_value=(current_client_id, current_id_token), - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', - return_value='api url file', - ) - mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', - return_value='app url file', - ) - - mocker.patch('typer.prompt', side_effect=['', '', '', '', '']) - - mocked_update_oidc_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # Assert - mocked_update_oidc_credentials.assert_not_called() + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + + # Re-entering the same client id must not rewrite anything + _run_configure(mocker, ['', '', 'current client id', '', '']) + + assert _read_yaml(_credentials_filename()) == _CURRENT_CREDENTIALS diff --git a/tests/cli/commands/configure/test_messages.py b/tests/cli/commands/configure/test_messages.py new file mode 100644 index 00000000..9eb0190a --- /dev/null +++ b/tests/cli/commands/configure/test_messages.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING + +from cycode.cli.apps.configure import messages +from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME + +if TYPE_CHECKING: + import pytest + + +def test_credentials_override_warning_absent_when_no_env_vars(monkeypatch: 'pytest.MonkeyPatch') -> None: + monkeypatch.delenv(CYCODE_CLIENT_ID_ENV_VAR_NAME, raising=False) + monkeypatch.delenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME, raising=False) + + assert messages.get_credentials_environment_variables_override_warning() is None + + +def test_credentials_override_warning_present_when_only_client_id_set(monkeypatch: 'pytest.MonkeyPatch') -> None: + monkeypatch.setenv(CYCODE_CLIENT_ID_ENV_VAR_NAME, 'env-client-id') + monkeypatch.delenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME, raising=False) + + assert messages.get_credentials_environment_variables_override_warning() is not None + + +def test_credentials_success_message_does_not_embed_override_warning(monkeypatch: 'pytest.MonkeyPatch') -> None: + monkeypatch.setenv(CYCODE_CLIENT_ID_ENV_VAR_NAME, 'env-client-id') + monkeypatch.setenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME, 'env-client-secret') + + assert 'environment variables' not in messages.get_credentials_update_result_message() diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 8a9f60b7..8b4a30b3 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -2,8 +2,11 @@ from os.path import normpath from unittest.mock import MagicMock, Mock, patch +import pytest + from cycode.cli import consts -from cycode.cli.apps.scan.code_scanner import scan_disk_files +from cycode.cli.apps.scan.code_scanner import _perform_scan, scan_disk_files, scan_documents +from cycode.cli.exceptions import custom_exceptions from cycode.cli.files_collector.file_excluder import _is_file_relevant_for_sca_scan from cycode.cli.files_collector.path_documents import _generate_document from cycode.cli.models import Document @@ -162,3 +165,75 @@ def test_entrypoint_cycode_not_added_for_single_file( assert len(entrypoint_docs) == 0 # Verify only the original documents are present assert len(documents_passed) == len(mock_documents) + + +@pytest.mark.parametrize( + ('scan_type', 'command_scan_type', 'sync_option', 'expect_presigned'), + [ + # SAST keeps uploading directly to S3 via a presigned URL (regression guard for the new sync gate). + (consts.SAST_SCAN_TYPE, 'path', False, True), + # Async secret scans now upload as a single file directly to S3 via a presigned URL. + (consts.SECRET_SCAN_TYPE, 'path', False, True), + # A --sync secret scan must stay on the batched inline path and never build one giant zip. + (consts.SECRET_SCAN_TYPE, 'path', True, False), + ], +) +@patch('cycode.cli.apps.scan.code_scanner.print_local_scan_results') +@patch('cycode.cli.apps.scan.code_scanner.set_issue_detected_by_scan_results') +@patch('cycode.cli.apps.scan.code_scanner.try_set_aggregation_report_url_if_needed') +@patch('cycode.cli.apps.scan.code_scanner.run_parallel_batched_scan') +@patch('cycode.cli.apps.scan.code_scanner._run_presigned_upload_scan') +def test_scan_documents_routes_upload_by_scan_type_and_sync( + mock_presigned_upload: Mock, + mock_batched_scan: Mock, + mock_aggregation: Mock, + mock_set_issue: Mock, + mock_print: Mock, + scan_type: str, + command_scan_type: str, + sync_option: bool, + expect_presigned: bool, +) -> None: + mock_presigned_upload.return_value = ([], []) + mock_batched_scan.return_value = ([], []) + + mock_ctx = MagicMock() + mock_ctx.info_name = command_scan_type + mock_ctx.obj = { + 'scan_type': scan_type, + 'progress_bar': MagicMock(), + 'console_printer': MagicMock(), + 'client': MagicMock(), + 'severity_threshold': None, + 'sync': sync_option, + } + documents = [Document('/repo/file.py', 'content', is_git_diff_format=False)] + + scan_documents(mock_ctx, documents, {}) + + assert mock_presigned_upload.called is expect_presigned + assert mock_batched_scan.called is (not expect_presigned) + + +@patch('cycode.cli.apps.scan.code_scanner._perform_scan_async') +@patch('cycode.cli.apps.scan.code_scanner._perform_scan_v4_async') +def test_perform_scan_falls_back_to_api_when_presigned_upload_raises_wrapped_error( + mock_v4_async: Mock, mock_async: Mock +) -> None: + # RequestConnectionError is a CycodeError, not a requests.RequestException — the fallback must still catch it. + mock_v4_async.side_effect = custom_exceptions.RequestConnectionError + fallback_result = object() + mock_async.return_value = fallback_result + + result = _perform_scan( + cycode_client=MagicMock(), + zipped_documents=MagicMock(), + scan_type=consts.SAST_SCAN_TYPE, + is_git_diff=False, + is_commit_range=False, + scan_parameters={}, + ) + + assert result is fallback_result + mock_v4_async.assert_called_once() + mock_async.assert_called_once() diff --git a/tests/cli/commands/scan/test_commit_range_scanner.py b/tests/cli/commands/scan/test_commit_range_scanner.py new file mode 100644 index 00000000..a4a6c58b --- /dev/null +++ b/tests/cli/commands/scan/test_commit_range_scanner.py @@ -0,0 +1,49 @@ +from unittest.mock import MagicMock, Mock, patch + +from cycode.cli import consts +from cycode.cli.apps.scan.commit_range_scanner import _scan_commit_range_documents +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.models import Document + + +@patch('cycode.cli.apps.scan.commit_range_scanner.report_scan_status') +@patch('cycode.cli.apps.scan.commit_range_scanner.handle_scan_exception') +@patch('cycode.cli.apps.scan.commit_range_scanner.print_local_scan_results') +@patch('cycode.cli.apps.scan.commit_range_scanner.set_issue_detected_by_scan_results') +@patch('cycode.cli.apps.scan.commit_range_scanner.create_local_scan_result') +@patch('cycode.cli.apps.scan.commit_range_scanner.enrich_scan_result_with_data_from_detection_rules') +@patch('cycode.cli.apps.scan.commit_range_scanner.zip_documents') +@patch('cycode.cli.apps.scan.commit_range_scanner._perform_commit_range_scan_async') +@patch('cycode.cli.apps.scan.commit_range_scanner._perform_commit_range_scan_v4_async') +def test_commit_range_scan_falls_back_to_api_when_presigned_upload_raises_wrapped_error( + mock_v4_async: Mock, + mock_async: Mock, + mock_zip: Mock, + mock_enrich: Mock, + mock_create_result: Mock, + mock_set_issue: Mock, + mock_print: Mock, + mock_handle_exception: Mock, + mock_report_status: Mock, +) -> None: + # SlowUploadConnectionError is a CycodeError, not a requests.RequestException — the presigned + # commit-range fallback must still catch it and retry via the Cycode API. + mock_v4_async.side_effect = custom_exceptions.SlowUploadConnectionError + fallback_result = MagicMock() + mock_async.return_value = fallback_result + + mock_ctx = MagicMock() + mock_ctx.info_name = 'commit_history' + mock_ctx.obj = { + 'client': MagicMock(), + 'scan_type': consts.SECRET_SCAN_TYPE, + 'severity_threshold': None, + 'progress_bar': MagicMock(), + } + documents = [Document('/repo/file.py', 'content', is_git_diff_format=False)] + + _scan_commit_range_documents(mock_ctx, documents, []) + + mock_v4_async.assert_called_once() + mock_async.assert_called_once() + mock_handle_exception.assert_not_called() diff --git a/tests/cli/commands/scan/test_scan_command.py b/tests/cli/commands/scan/test_scan_command.py new file mode 100644 index 00000000..bb5f363d --- /dev/null +++ b/tests/cli/commands/scan/test_scan_command.py @@ -0,0 +1,75 @@ +import re + +import click +import pytest +import typer +from typer.testing import CliRunner + +from cycode.cli.app import app +from cycode.cli.apps.scan.scan_command import scan_command_result_callback +from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, SCAN_ERROR_STATUS_CODE + + +def _strip_ansi(text: str) -> str: + return re.sub(r'\x1b\[[0-9;]*[mGKHF]', '', text) + + +def _make_ctx(**obj_overrides: object) -> click.Context: + obj = { + 'soft_fail': False, + 'did_fail': False, + 'issue_detected': False, + 'stop_on_error': False, + } + obj.update(obj_overrides) + ctx = click.Context(click.Command('scan')) + ctx.obj = obj + return ctx + + +def _invoke_result_callback(ctx: click.Context) -> int: + with pytest.raises(typer.Exit) as exc_info, ctx: + scan_command_result_callback() + return exc_info.value.exit_code + + +class TestScanCommand: + def test_multiple_scan_types_rejected(self) -> None: + result = CliRunner().invoke(app, ['scan', '-t', 'iac', '-t', 'sast', 'path', '.']) + assert result.exit_code == 1 + output = _strip_ansi(result.output) + assert '-t/--scan-type' in output + assert 'iac' in output + assert 'sast' in output + + def test_single_scan_type_accepted(self) -> None: + result = CliRunner().invoke(app, ['scan', '-t', 'iac', '--help']) + assert result.exit_code == 0 + assert 'Error' not in result.output + + +class TestScanCommandResultCallback: + def test_no_issues_no_errors_exits_zero(self) -> None: + assert _invoke_result_callback(_make_ctx()) == NO_ISSUES_STATUS_CODE + + def test_issue_detected_exits_one(self) -> None: + assert _invoke_result_callback(_make_ctx(issue_detected=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_did_fail_without_stop_on_error_exits_one(self) -> None: + assert _invoke_result_callback(_make_ctx(did_fail=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_did_fail_with_stop_on_error_exits_two(self) -> None: + assert _invoke_result_callback(_make_ctx(did_fail=True, stop_on_error=True)) == SCAN_ERROR_STATUS_CODE + + def test_issue_detected_with_stop_on_error_exits_one(self) -> None: + # stop_on_error only affects the error code path, not violations + assert _invoke_result_callback(_make_ctx(issue_detected=True, stop_on_error=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_soft_fail_overrides_violations(self) -> None: + assert _invoke_result_callback(_make_ctx(soft_fail=True, issue_detected=True)) == NO_ISSUES_STATUS_CODE + + def test_soft_fail_overrides_stop_on_error(self) -> None: + assert ( + _invoke_result_callback(_make_ctx(soft_fail=True, did_fail=True, stop_on_error=True)) + == NO_ISSUES_STATUS_CODE + ) diff --git a/tests/cli/commands/scan/test_scan_result.py b/tests/cli/commands/scan/test_scan_result.py new file mode 100644 index 00000000..e85ca116 --- /dev/null +++ b/tests/cli/commands/scan/test_scan_result.py @@ -0,0 +1,47 @@ +import os + +from cycode.cli.apps.scan.scan_result import _get_file_name_from_detection +from cycode.cli.consts import IAC_SCAN_TYPE, SAST_SCAN_TYPE, SCA_SCAN_TYPE, SECRET_SCAN_TYPE + + +def test_get_file_name_from_detection_sca_uses_file_path() -> None: + raw_detection = { + 'detection_details': { + 'file_name': 'package.json', + 'file_path': '/repo/path/package.json', + }, + } + result = _get_file_name_from_detection(SCA_SCAN_TYPE, raw_detection) + assert result == '/repo/path/package.json' + + +def test_get_file_name_from_detection_iac_uses_file_path() -> None: + raw_detection = { + 'detection_details': { + 'file_name': 'main.tf', + 'file_path': '/repo/infra/main.tf', + }, + } + result = _get_file_name_from_detection(IAC_SCAN_TYPE, raw_detection) + assert result == '/repo/infra/main.tf' + + +def test_get_file_name_from_detection_sast_uses_file_path() -> None: + raw_detection = { + 'detection_details': { + 'file_path': '/repo/src/app.py', + }, + } + result = _get_file_name_from_detection(SAST_SCAN_TYPE, raw_detection) + assert result == '/repo/src/app.py' + + +def test_get_file_name_from_detection_secret_uses_file_path_and_file_name() -> None: + raw_detection = { + 'detection_details': { + 'file_path': '/repo/src', + 'file_name': '.env', + }, + } + result = _get_file_name_from_detection(SECRET_SCAN_TYPE, raw_detection) + assert result == os.path.join('/repo/src', '.env') diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index ce72e9de..fb14bc8a 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -32,6 +32,7 @@ def ctx() -> typer.Context: (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), (custom_exceptions.ZipTooLargeError(1000), True), (custom_exceptions.TfplanKeyError('msg'), True), + (custom_exceptions.FileCollectionError('Failed to generate dependencies tree for pom.xml'), None), (git_proxy.get_invalid_git_repository_error()(), None), ], ) diff --git a/tests/cli/files_collector/sca/__init__.py b/tests/cli/files_collector/sca/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/go/__init__.py b/tests/cli/files_collector/sca/go/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py new file mode 100644 index 00000000..633d24e8 --- /dev/null +++ b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py @@ -0,0 +1,90 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.go.restore_go_dependencies import ( + GO_RESTORE_FILE_NAME, + RestoreGoDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_go(mock_ctx: typer.Context) -> RestoreGoDependencies: + return RestoreGoDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_go_mod_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('go.mod', 'module example.com/mymod\ngo 1.21\n') + assert restore_go.is_project(doc) is True + + def test_go_sum_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('go.sum', 'github.com/pkg/errors v0.9.1 h1:...\n') + assert restore_go.is_project(doc) is True + + def test_go_in_subdir_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('myapp/go.mod', 'module example.com/mymod\n') + assert restore_go.is_project(doc) is True + + def test_pom_xml_does_not_match(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_go.is_project(doc) is False + + +class TestCleanup: + def test_generated_output_file_is_deleted_after_restore( + self, restore_go: RestoreGoDependencies, tmp_path: Path + ) -> None: + # Go handler requires both go.mod and go.sum to be present + (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n') + (tmp_path / 'go.sum').write_text('github.com/pkg/errors v0.9.1 h1:abc\n') + doc = Document( + str(tmp_path / 'go.mod'), + 'module example.com/test\ngo 1.21\n', + absolute_path=str(tmp_path / 'go.mod'), + ) + output_path = tmp_path / GO_RESTORE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + # Go uses create_output_file_manually=True; output_file_path is provided + target = output_file_path or str(output_path) + Path(target).write_text('example.com/test github.com/pkg/errors@v0.9.1\n') + return 'graph output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_go.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{GO_RESTORE_FILE_NAME} must be deleted after restore' + + def test_missing_go_sum_returns_none(self, restore_go: RestoreGoDependencies, tmp_path: Path) -> None: + (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n') + # go.sum intentionally absent + doc = Document( + str(tmp_path / 'go.mod'), + 'module example.com/test\ngo 1.21\n', + absolute_path=str(tmp_path / 'go.mod'), + ) + + result = restore_go.try_restore_dependencies(doc) + + assert result is None diff --git a/tests/cli/files_collector/sca/maven/__init__.py b/tests/cli/files_collector/sca/maven/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py new file mode 100644 index 00000000..43d34e30 --- /dev/null +++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py @@ -0,0 +1,178 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import ( + BUILD_GRADLE_DEP_TREE_FILE_NAME, + BUILD_GRADLE_FILE_NAME, + BUILD_GRADLE_KTS_FILE_NAME, + GRADLE_EXECUTABLE, + GRADLEW_BAT_FILE_NAME, + GRADLEW_FILE_NAME, + RestoreGradleDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' +_GRADLE_MODULE = 'cycode.cli.files_collector.sca.maven.restore_gradle_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'gradle_all_sub_projects': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_gradle(mock_ctx: typer.Context) -> RestoreGradleDependencies: + return RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_build_gradle_matches(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('build.gradle', 'apply plugin: "java"\n') + assert restore_gradle.is_project(doc) is True + + def test_build_gradle_kts_matches(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('build.gradle.kts', 'plugins { java }\n') + assert restore_gradle.is_project(doc) is True + + def test_pom_xml_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_gradle.is_project(doc) is False + + def test_settings_gradle_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('settings.gradle', 'rootProject.name = "test"') + assert restore_gradle.is_project(doc) is False + + +class TestResolveGradleExecutable: + def test_falls_back_to_gradle_when_no_wrapper(self, restore_gradle: RestoreGradleDependencies) -> None: + assert restore_gradle.gradle_executable == GRADLE_EXECUTABLE + + def test_prefers_gradlew_wrapper_on_posix(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_FILE_NAME + wrapper.write_text('#!/bin/sh\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + assert restore.gradle_executable == str(wrapper) + + def test_prefers_gradlew_bat_on_windows(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_BAT_FILE_NAME + wrapper.write_text('@echo off\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Windows'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + assert restore.gradle_executable == str(wrapper) + + def test_posix_ignores_bat_wrapper(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + (tmp_path / GRADLEW_BAT_FILE_NAME).write_text('@echo off\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + assert restore.gradle_executable == GRADLE_EXECUTABLE + + def test_wrapper_is_threaded_into_get_commands(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_FILE_NAME + wrapper.write_text('#!/bin/sh\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + commands = restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) + assert commands == [ + [str(wrapper), 'dependencies', '-b', str(tmp_path / BUILD_GRADLE_FILE_NAME), '-q', '--console', 'plain'] + ] + + def test_wrapper_is_threaded_into_sub_project_commands(self, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_FILE_NAME + wrapper.write_text('#!/bin/sh\n') + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'gradle_all_sub_projects': True} + ctx.params = {'path': str(tmp_path)} + module_project = tmp_path / 'module-a' + module_project.mkdir() + manifest = module_project / BUILD_GRADLE_FILE_NAME + + with ( + patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'), + patch.object(RestoreGradleDependencies, 'get_all_projects', return_value={':module-a'}), + ): + restore = RestoreGradleDependencies(ctx, is_git_diff=False, command_timeout=30) + + commands = restore.get_commands_for_sub_projects(str(manifest)) + assert commands == [[str(wrapper), ':module-a:dependencies', '-q', '--console', 'plain']] + + +class TestCleanup: + def test_generated_dep_tree_file_is_deleted_after_restore( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n') + doc = Document( + str(tmp_path / BUILD_GRADLE_FILE_NAME), + 'apply plugin: "java"\n', + absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME), + ) + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + # Gradle uses create_output_file_manually=True; output_file_path is provided + target = output_file_path or str(output_path) + Path(target).write_text('compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n') + return 'dep tree output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_preexisting_dep_tree_file_is_not_deleted( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + dep_tree_content = 'compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n' + (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n') + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + output_path.write_text(dep_tree_content) + doc = Document( + str(tmp_path / BUILD_GRADLE_FILE_NAME), + 'apply plugin: "java"\n', + absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME), + ) + + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert output_path.exists(), f'Pre-existing {BUILD_GRADLE_DEP_TREE_FILE_NAME} must not be deleted' + + def test_kts_build_file_also_cleaned_up(self, restore_gradle: RestoreGradleDependencies, tmp_path: Path) -> None: + (tmp_path / BUILD_GRADLE_KTS_FILE_NAME).write_text('plugins { java }\n') + doc = Document( + str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME), + 'plugins { java }\n', + absolute_path=str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME), + ) + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + target = output_file_path or str(output_path) + Path(target).write_text('compileClasspath\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore' diff --git a/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py new file mode 100644 index 00000000..f365bd92 --- /dev/null +++ b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py @@ -0,0 +1,124 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import ( + BUILD_MAVEN_FILE_NAME, + MAVEN_CYCLONE_DEP_TREE_FILE_NAME, + MAVEN_DEP_TREE_FILE_NAME, + RestoreMavenDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' +_MAVEN_MODULE = 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'maven_settings_file': None} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_maven(mock_ctx: typer.Context) -> RestoreMavenDependencies: + return RestoreMavenDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pom_xml_matches(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_maven.is_project(doc) is True + + def test_pom_xml_in_subdir_matches(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('mymodule/pom.xml', '') + assert restore_maven.is_project(doc) is True + + def test_build_gradle_does_not_match(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('build.gradle', '') + assert restore_maven.is_project(doc) is False + + +class TestCleanup: + def test_generated_bom_is_deleted_after_primary_restore( + self, restore_maven: RestoreMavenDependencies, tmp_path: Path + ) -> None: + """Primary path: super().try_restore_dependencies() generates target/bom.json and cleans it up.""" + pom_content = '4.0.0' + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content) + target_dir = tmp_path / 'target' + target_dir.mkdir() + bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + pom_content, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + bom_path.write_text('{"bomFormat": "CycloneDX", "components": []}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert result.content is not None, 'Document content must be populated even after file deletion' + assert not bom_path.exists(), f'target/{MAVEN_CYCLONE_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_generated_dep_tree_is_deleted_after_secondary_restore( + self, restore_maven: RestoreMavenDependencies, tmp_path: Path + ) -> None: + """Secondary path (content=None): mvn dependency:tree generates bcde.mvndeps and it must be cleaned up.""" + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text('') + dep_tree_path = tmp_path / MAVEN_DEP_TREE_FILE_NAME + # content=None triggers the secondary command path + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + None, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + dep_tree_path.write_text('[INFO] com.example:my-app:jar:1.0.0\n') + return '[INFO] BUILD SUCCESS' + + with patch(f'{_MAVEN_MODULE}.execute_commands', side_effect=side_effect): + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert result.content is not None + assert not dep_tree_path.exists(), f'{MAVEN_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_preexisting_bom_is_not_deleted(self, restore_maven: RestoreMavenDependencies, tmp_path: Path) -> None: + pom_content = '4.0.0' + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content) + target_dir = tmp_path / 'target' + target_dir.mkdir() + bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME + bom_path.write_text('{"bomFormat": "CycloneDX", "components": [{"name": "requests"}]}') + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + pom_content, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert bom_path.exists(), f'Pre-existing target/{MAVEN_CYCLONE_DEP_TREE_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py new file mode 100644 index 00000000..17f189df --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py @@ -0,0 +1,205 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import ( + BUN_LOCK_FILE_NAME, + RestoreBunDependencies, + _parse_bun_version, +) +from cycode.cli.models import Document + +_BUN_MODULE = 'cycode.cli.files_collector.sca.npm.restore_bun_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_bun(mock_ctx: typer.Context) -> RestoreBunDependencies: + return RestoreBunDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_bun_lock_matches(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_package_manager_bun_matches(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "packageManager": "bun@1.1.0"}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_engines_bun_matches(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "engines": {"bun": ">=1"}}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_no_bun_signal_does_not_match( + self, restore_bun: RestoreBunDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_bun.is_project(doc) is False + + def test_package_json_with_yarn_lock_does_not_match( + self, restore_bun: RestoreBunDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_bun.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_bun.is_project(doc) is False + + def test_package_manager_yarn_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.0"}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_bun.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_bun_lock_returned_directly(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + bun_lock_content = '{"lockfileVersion": 1, "packages": {"package": ["package@1.0.0", "", {}, ""]}}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text(bun_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert BUN_LOCK_FILE_NAME in result.path + assert result.content == bun_lock_content + + def test_get_lock_file_name(self, restore_bun: RestoreBunDependencies) -> None: + assert restore_bun.get_lock_file_name() == BUN_LOCK_FILE_NAME + + def test_get_commands_returns_bun_install(self, restore_bun: RestoreBunDependencies) -> None: + commands = restore_bun.get_commands('/path/to/package.json') + assert commands == [['bun', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestParseBunVersion: + def test_parses_full_semver(self) -> None: + assert _parse_bun_version('1.2.3') == (1, 2) + + def test_parses_with_surrounding_whitespace(self) -> None: + assert _parse_bun_version(' 1.2.0\n') == (1, 2) + + def test_none_input_returns_none(self) -> None: + assert _parse_bun_version(None) is None + + def test_non_version_string_returns_none(self) -> None: + assert _parse_bun_version('not-a-version') is None + + +class TestBunVersionGate: + def test_supported_version_proceeds_to_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'), + patch.object( + restore_bun.__class__.__bases__[0], 'try_restore_dependencies', return_value=None + ) as mock_super, + ): + restore_bun.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) + + def test_old_version_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.1.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.1.38'), + patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super, + ): + result = restore_bun.try_restore_dependencies(doc) + assert result is None + mock_super.assert_not_called() + + def test_missing_bun_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value=None), + patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super, + ): + result = restore_bun.try_restore_dependencies(doc) + assert result is None + mock_super.assert_not_called() + + def test_existing_lockfile_skips_version_check(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + with patch(f'{_BUN_MODULE}.shell') as mock_shell: + result = restore_bun.try_restore_dependencies(doc) + assert result is not None + mock_shell.assert_not_called() + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_bun: RestoreBunDependencies, tmp_path: Path + ) -> None: + # bun: no pre-existing bun.lock but package.json indicates bun (supported version installed) + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / BUN_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"lockfileVersion": 1}\n') + return 'output' + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'), + patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect), + ): + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{BUN_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + lock_content = '{"lockfileVersion": 1, "packages": {"pkg": ["pkg@1.0.0", "", {}, ""]}}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / BUN_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {BUN_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py new file mode 100644 index 00000000..2d6e9a4b --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py @@ -0,0 +1,65 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import ( + DENO_LOCK_FILE_NAME, + DENO_MANIFEST_FILE_NAMES, + RestoreDenoDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_deno(mock_ctx: typer.Context) -> RestoreDenoDependencies: + return RestoreDenoDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + @pytest.mark.parametrize('filename', DENO_MANIFEST_FILE_NAMES) + def test_deno_manifest_files_match(self, restore_deno: RestoreDenoDependencies, filename: str) -> None: + doc = Document(filename, '{}') + assert restore_deno.is_project(doc) is True + + @pytest.mark.parametrize('filename', ['package.json', 'tsconfig.json', 'deno.ts', 'main.ts', 'deno.lock']) + def test_non_deno_manifest_files_do_not_match(self, restore_deno: RestoreDenoDependencies, filename: str) -> None: + doc = Document(filename, '') + assert restore_deno.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_deno_lock_returned(self, restore_deno: RestoreDenoDependencies, tmp_path: Path) -> None: + deno_lock_content = '{"version": "3", "packages": {}}' + (tmp_path / 'deno.json').write_text('{"imports": {}}') + (tmp_path / 'deno.lock').write_text(deno_lock_content) + + doc = Document(str(tmp_path / 'deno.json'), '{"imports": {}}', absolute_path=str(tmp_path / 'deno.json')) + result = restore_deno.try_restore_dependencies(doc) + + assert result is not None + assert DENO_LOCK_FILE_NAME in result.path + assert result.content == deno_lock_content + + def test_no_deno_lock_returns_none(self, restore_deno: RestoreDenoDependencies, tmp_path: Path) -> None: + (tmp_path / 'deno.json').write_text('{"imports": {}}') + + doc = Document(str(tmp_path / 'deno.json'), '{"imports": {}}', absolute_path=str(tmp_path / 'deno.json')) + result = restore_deno.try_restore_dependencies(doc) + + assert result is None + + def test_get_lock_file_name(self, restore_deno: RestoreDenoDependencies) -> None: + assert restore_deno.get_lock_file_name() == DENO_LOCK_FILE_NAME + + def test_get_commands_returns_empty(self, restore_deno: RestoreDenoDependencies) -> None: + assert restore_deno.get_commands('/path/to/deno.json') == [] diff --git a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py index af990085..95f94da0 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -1,11 +1,11 @@ from pathlib import Path +from typing import Optional from unittest.mock import MagicMock, patch import pytest import typer from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import ( - ALTERNATIVE_LOCK_FILES, NPM_LOCK_FILE_NAME, RestoreNpmDependencies, ) @@ -14,7 +14,6 @@ @pytest.fixture def mock_ctx(tmp_path: Path) -> typer.Context: - """Create a mock typer context.""" ctx = MagicMock(spec=typer.Context) ctx.obj = {'monitor': False} ctx.params = {'path': str(tmp_path)} @@ -22,326 +21,141 @@ def mock_ctx(tmp_path: Path) -> typer.Context: @pytest.fixture -def restore_npm_dependencies(mock_ctx: typer.Context) -> RestoreNpmDependencies: - """Create a RestoreNpmDependencies instance.""" +def restore_npm(mock_ctx: typer.Context) -> RestoreNpmDependencies: return RestoreNpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) -class TestRestoreNpmDependenciesAlternativeLockfiles: - """Test that lockfiles prevent npm install from running.""" - - @pytest.mark.parametrize( - ('lockfile_name', 'lockfile_content', 'expected_content'), - [ - ('pnpm-lock.yaml', 'lockfileVersion: 5.4\n', 'lockfileVersion: 5.4\n'), - ('yarn.lock', '# yarn lockfile v1\n', '# yarn lockfile v1\n'), - ('deno.lock', '{"version": 2}\n', '{"version": 2}\n'), - ('package-lock.json', '{"lockfileVersion": 2}\n', '{"lockfileVersion": 2}\n'), - ], - ) - def test_lockfile_exists_should_skip_npm_install( - self, - restore_npm_dependencies: RestoreNpmDependencies, - tmp_path: Path, - lockfile_name: str, - lockfile_content: str, - expected_content: str, - ) -> None: - """Test that when any lockfile exists, npm install is skipped.""" - # Setup: Create package.json and lockfile - package_json_path = tmp_path / 'package.json' - lockfile_path = tmp_path / lockfile_name - - package_json_path.write_text('{"name": "test", "version": "1.0.0"}') - lockfile_path.write_text(lockfile_content) - - document = Document( - path=str(package_json_path), - content=package_json_path.read_text(), - absolute_path=str(package_json_path), - ) - - # Execute - result = restore_npm_dependencies.try_restore_dependencies(document) +class TestIsProject: + def test_package_json_with_no_lockfile_matches(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is True - # Verify: Should return lockfile content without running npm install - assert result is not None - assert lockfile_name in result.path - assert result.content == expected_content - - def test_no_lockfile_exists_should_proceed_with_normal_flow( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + def test_package_json_with_yarn_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test that when no lockfile exists, normal flow proceeds (will run npm install).""" - # Setup: Create only package.json (no lockfile) - package_json_path = tmp_path / 'package.json' - package_json_path.write_text('{"name": "test", "version": "1.0.0"}') - - document = Document( - path=str(package_json_path), - content=package_json_path.read_text(), - absolute_path=str(package_json_path), - ) - - # Mock the base class's try_restore_dependencies to verify it's called - with patch.object( - restore_npm_dependencies.__class__.__bases__[0], - 'try_restore_dependencies', - return_value=None, - ) as mock_super: - # Execute - restore_npm_dependencies.try_restore_dependencies(document) - - # Verify: Should call parent's try_restore_dependencies (which will run npm install) - mock_super.assert_called_once_with(document) - - -class TestRestoreNpmDependenciesPathResolution: - """Test path resolution scenarios.""" - - @pytest.mark.parametrize( - 'has_absolute_path', - [True, False], - ) - def test_path_resolution_with_different_path_types( - self, - restore_npm_dependencies: RestoreNpmDependencies, - tmp_path: Path, - has_absolute_path: bool, + """Yarn projects are handled by RestoreYarnDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False + + def test_package_json_with_pnpm_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test path resolution with absolute or relative paths.""" - package_json_path = tmp_path / 'package.json' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path) if has_absolute_path else None, - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - def test_path_resolution_in_monitor_mode(self, tmp_path: Path) -> None: - """Test path resolution in monitor mode.""" - # Setup monitor mode context - ctx = MagicMock(spec=typer.Context) - ctx.obj = {'monitor': True} - ctx.params = {'path': str(tmp_path)} - - restore_npm = RestoreNpmDependencies(ctx, is_git_diff=False, command_timeout=30) - - # Create files in a subdirectory - subdir = tmp_path / 'project' - subdir.mkdir() - package_json_path = subdir / 'package.json' - pnpm_lock_path = subdir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - # Document with a relative path - document = Document( - path='project/package.json', - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - def test_path_resolution_with_nested_directory( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + """pnpm projects are handled by RestorePnpmDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False + + def test_package_json_with_bun_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test path resolution with a nested directory structure.""" - subdir = tmp_path / 'src' / 'app' - subdir.mkdir(parents=True) + """Bun projects are handled by RestoreBunDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False - package_json_path = subdir / 'package.json' - pnpm_lock_path = subdir / 'pnpm-lock.yaml' + def test_tsconfig_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + doc = Document('tsconfig.json', '{}') + assert restore_npm.is_project(doc) is False - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + def test_arbitrary_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + for filename in ('jest.config.json', '.eslintrc.json', 'settings.json', 'bom.json'): + doc = Document(filename, '{}') + assert restore_npm.is_project(doc) is False, f'Expected False for {filename}' - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) + def test_non_json_file_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + for filename in ('readme.txt', 'script.js', 'Makefile'): + doc = Document(filename, '') + assert restore_npm.is_project(doc) is False, f'Expected False for {filename}' - result = restore_npm_dependencies.try_restore_dependencies(document) - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' +class TestTryRestoreDependencies: + def test_no_lockfile_calls_base_class(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + """When no lockfile exists, the base class (npm install) should be invoked.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + with patch.object( + restore_npm.__class__.__bases__[0], 'try_restore_dependencies', return_value=None + ) as mock_super: + restore_npm.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) -class TestRestoreNpmDependenciesEdgeCases: - """Test edge cases and error scenarios.""" - - def test_empty_lockfile_should_still_be_used( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that the empty lockfile is still used (prevents npm install).""" - package_json_path = tmp_path / 'package.json' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('') # Empty file - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should still return the empty lockfile (prevents npm install) - assert result is not None - assert result.content == '' - - def test_multiple_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when multiple lockfiles exist, the first one found is used (package-lock.json has priority).""" - package_json_path = tmp_path / 'package.json' - package_lock_path = tmp_path / 'package-lock.json' - yarn_lock_path = tmp_path / 'yarn.lock' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - package_lock_path.write_text('{"lockfileVersion": 2}\n') - yarn_lock_path.write_text('# yarn lockfile\n') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should use package-lock.json (first in the check order) - assert result is not None - assert 'package-lock.json' in result.path - assert result.content == '{"lockfileVersion": 2}\n' - - def test_multiple_alternative_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when multiple alternative lockfiles exist (but no package-lock.json), - the first one found is used.""" - package_json_path = tmp_path / 'package.json' - yarn_lock_path = tmp_path / 'yarn.lock' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - yarn_lock_path.write_text('# yarn lockfile\n') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should use yarn.lock (first in ALTERNATIVE_LOCK_FILES list) - assert result is not None - assert 'yarn.lock' in result.path - assert result.content == '# yarn lockfile\n' - - def test_lockfile_in_different_directory_should_not_be_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + def test_lockfile_in_different_directory_still_calls_base_class( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test that lockfile in a different directory is not found.""" - package_json_path = tmp_path / 'package.json' + (tmp_path / 'package.json').write_text('{"name": "test"}') other_dir = tmp_path / 'other' other_dir.mkdir() - pnpm_lock_path = other_dir / 'pnpm-lock.yaml' + (other_dir / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - # Mock the base class to verify it's called (since lockfile not found) with patch.object( - restore_npm_dependencies.__class__.__bases__[0], - 'try_restore_dependencies', - return_value=None, + restore_npm.__class__.__bases__[0], 'try_restore_dependencies', return_value=None ) as mock_super: - restore_npm_dependencies.try_restore_dependencies(document) + restore_npm.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) - # Should proceed with normal flow since lockfile not in same directory - mock_super.assert_called_once_with(document) - def test_non_json_file_should_not_trigger_restore( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that non-JSON files don't trigger restore.""" - text_file = tmp_path / 'readme.txt' - text_file.write_text('Some text') +class TestGetLockFileName: + def test_get_lock_file_name(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.get_lock_file_name() == NPM_LOCK_FILE_NAME - document = Document( - path=str(text_file), - content='Some text', - absolute_path=str(text_file), - ) + def test_get_lock_file_names_contains_only_npm_lock(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.get_lock_file_names() == [NPM_LOCK_FILE_NAME] - # Should return None because is_project() returns False - result = restore_npm_dependencies.try_restore_dependencies(document) - assert result is None +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' -class TestRestoreNpmDependenciesHelperMethods: - """Test helper methods.""" +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / NPM_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"lockfileVersion": 3}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_npm.try_restore_dependencies(doc) - def test_is_project_with_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test is_project identifies JSON files correctly.""" - document = Document('package.json', '{}') - assert restore_npm_dependencies.is_project(document) is True + assert result is not None + assert not lock_path.exists(), f'{NPM_LOCK_FILE_NAME} must be deleted after restore' - document = Document('tsconfig.json', '{}') - assert restore_npm_dependencies.is_project(document) is True + def test_preexisting_lockfile_is_not_deleted(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / NPM_LOCK_FILE_NAME + lock_path.write_text('{"lockfileVersion": 3, "packages": {}}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) - def test_is_project_with_non_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test is_project returns False for non-JSON files.""" - document = Document('readme.txt', 'text') - assert restore_npm_dependencies.is_project(document) is False + result = restore_npm.try_restore_dependencies(doc) - document = Document('script.js', 'code') - assert restore_npm_dependencies.is_project(document) is False + assert result is not None + assert lock_path.exists(), f'Pre-existing {NPM_LOCK_FILE_NAME} must not be deleted' - def test_get_lock_file_name(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test get_lock_file_name returns the correct name.""" - assert restore_npm_dependencies.get_lock_file_name() == NPM_LOCK_FILE_NAME - def test_get_lock_file_names(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test get_lock_file_names returns all lockfile names.""" - lock_file_names = restore_npm_dependencies.get_lock_file_names() - assert NPM_LOCK_FILE_NAME in lock_file_names - for alt_lock in ALTERNATIVE_LOCK_FILES: - assert alt_lock in lock_file_names +class TestPrepareManifestFilePath: + def test_strips_package_json_filename(self, restore_npm: RestoreNpmDependencies) -> None: + path = str(Path('/path/to/package.json')) + expected = str(Path('/path/to')) + assert restore_npm.prepare_manifest_file_path_for_command(path) == expected - def test_prepare_manifest_file_path_for_command(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test prepare_manifest_file_path_for_command removes package.json from the path.""" - result = restore_npm_dependencies.prepare_manifest_file_path_for_command('/path/to/package.json') - assert result == '/path/to' + def test_package_json_in_cwd_returns_empty_string(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.prepare_manifest_file_path_for_command('package.json') == '' - result = restore_npm_dependencies.prepare_manifest_file_path_for_command('package.json') - assert result == '' + def test_non_package_json_path_returned_unchanged(self, restore_npm: RestoreNpmDependencies) -> None: + path = str(Path('/path/to/')) + assert restore_npm.prepare_manifest_file_path_for_command(path) == path diff --git a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py new file mode 100644 index 00000000..88502578 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py @@ -0,0 +1,133 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import ( + PNPM_LOCK_FILE_NAME, + RestorePnpmDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pnpm(mock_ctx: typer.Context) -> RestorePnpmDependencies: + return RestorePnpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_pnpm_lock_matches(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_package_manager_pnpm_matches(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "packageManager": "pnpm@8.6.2"}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_engines_pnpm_matches(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "engines": {"pnpm": ">=8"}}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_no_pnpm_signal_does_not_match( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is False + + def test_package_json_with_yarn_lock_does_not_match( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_pnpm.is_project(doc) is False + + def test_package_manager_yarn_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.0"}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_pnpm.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_pnpm_lock_returned_directly(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + pnpm_lock_content = 'lockfileVersion: 5.4\n\npackages:\n /package@1.0.0:\n resolution: {}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text(pnpm_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert PNPM_LOCK_FILE_NAME in result.path + assert result.content == pnpm_lock_content + + def test_get_lock_file_name(self, restore_pnpm: RestorePnpmDependencies) -> None: + assert restore_pnpm.get_lock_file_name() == PNPM_LOCK_FILE_NAME + + def test_get_commands_returns_pnpm_install(self, restore_pnpm: RestorePnpmDependencies) -> None: + commands = restore_pnpm.get_commands('/path/to/package.json') + assert commands == [['pnpm', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + # pnpm: no pre-existing pnpm-lock.yaml but package.json indicates pnpm + content = '{"name": "test", "packageManager": "pnpm@8.6.2"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / PNPM_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('lockfileVersion: 5.4\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PNPM_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + lock_content = 'lockfileVersion: 5.4\n\npackages:\n /pkg@1.0.0:\n resolution: {}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / PNPM_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PNPM_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py new file mode 100644 index 00000000..88175031 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py @@ -0,0 +1,133 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import ( + YARN_LOCK_FILE_NAME, + RestoreYarnDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_yarn(mock_ctx: typer.Context) -> RestoreYarnDependencies: + return RestoreYarnDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_yarn_lock_matches(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_package_manager_yarn_matches(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.2"}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_engines_yarn_matches(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "engines": {"yarn": ">=1.22"}}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_no_yarn_signal_does_not_match( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is False + + def test_package_json_with_pnpm_lock_does_not_match( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_yarn.is_project(doc) is False + + def test_package_manager_npm_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "packageManager": "npm@9.0.0"}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_yarn.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_yarn_lock_returned_directly(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + yarn_lock_content = '# yarn lockfile v1\n\npackage@1.0.0:\n resolved "https://example.com"\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text(yarn_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert YARN_LOCK_FILE_NAME in result.path + assert result.content == yarn_lock_content + + def test_get_lock_file_name(self, restore_yarn: RestoreYarnDependencies) -> None: + assert restore_yarn.get_lock_file_name() == YARN_LOCK_FILE_NAME + + def test_get_commands_returns_yarn_install(self, restore_yarn: RestoreYarnDependencies) -> None: + commands = restore_yarn.get_commands('/path/to/package.json') + assert commands == [['yarn', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + # Yarn: no pre-existing yarn.lock but package.json indicates yarn + content = '{"name": "test", "packageManager": "yarn@4.0.2"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / YARN_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('# yarn lockfile v1\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{YARN_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + lock_content = '# yarn lockfile v1\n\npackage@1.0.0:\n resolved "https://example.com"\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / YARN_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {YARN_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/nuget/__init__.py b/tests/cli/files_collector/sca/nuget/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py new file mode 100644 index 00000000..0ec13441 --- /dev/null +++ b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import ( + NUGET_LOCK_FILE_NAME, + RestoreNugetDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_nuget(mock_ctx: typer.Context) -> RestoreNugetDependencies: + return RestoreNugetDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_csproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MyProject.csproj', '') + assert restore_nuget.is_project(doc) is True + + def test_vbproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MyProject.vbproj', '') + assert restore_nuget.is_project(doc) is True + + def test_sln_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MySolution.sln', '') + assert restore_nuget.is_project(doc) is False + + def test_packages_json_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('packages.json', '{}') + assert restore_nuget.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_nuget: RestoreNugetDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'MyProject.csproj').write_text('') + doc = Document( + str(tmp_path / 'MyProject.csproj'), + '', + absolute_path=str(tmp_path / 'MyProject.csproj'), + ) + lock_path = tmp_path / NUGET_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"version": 1, "dependencies": {}}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_nuget.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{NUGET_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_nuget: RestoreNugetDependencies, tmp_path: Path) -> None: + lock_content = '{"version": 1, "dependencies": {"net8.0": {}}}' + (tmp_path / 'MyProject.csproj').write_text('') + lock_path = tmp_path / NUGET_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'MyProject.csproj'), + '', + absolute_path=str(tmp_path / 'MyProject.csproj'), + ) + + result = restore_nuget.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {NUGET_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/php/__init__.py b/tests/cli/files_collector/sca/php/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py new file mode 100644 index 00000000..6e3ea53b --- /dev/null +++ b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py @@ -0,0 +1,131 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.php.restore_composer_dependencies import ( + COMPOSER_LOCK_FILE_NAME, + RestoreComposerDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_composer(mock_ctx: typer.Context) -> RestoreComposerDependencies: + return RestoreComposerDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_composer_json_matches(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('composer.json', '{"name": "vendor/project"}\n') + assert restore_composer.is_project(doc) is True + + def test_composer_json_in_subdir_matches(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('myapp/composer.json', '{"name": "vendor/project"}\n') + assert restore_composer.is_project(doc) is True + + def test_composer_lock_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('composer.lock', '{"_readme": []}\n') + assert restore_composer.is_project(doc) is False + + def test_package_json_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('package.json', '{"name": "test"}\n') + assert restore_composer.is_project(doc) is False + + def test_other_json_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('config.json', '{"setting": "value"}\n') + assert restore_composer.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_composer_lock_returned_directly( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + lock_content = '{\n "_readme": ["This file is @generated by Composer"],\n "packages": []\n}\n' + (tmp_path / 'composer.json').write_text('{"name": "vendor/project"}\n') + (tmp_path / 'composer.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'composer.json'), + '{"name": "vendor/project"}\n', + absolute_path=str(tmp_path / 'composer.json'), + ) + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert COMPOSER_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_composer: RestoreComposerDependencies) -> None: + assert restore_composer.get_lock_file_name() == COMPOSER_LOCK_FILE_NAME + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + manifest_content = '{"name": "vendor/project"}\n' + (tmp_path / 'composer.json').write_text(manifest_content) + doc = Document(str(tmp_path / 'composer.json'), manifest_content, absolute_path=str(tmp_path / 'composer.json')) + lock_path = tmp_path / COMPOSER_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"_readme": [], "packages": []}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{COMPOSER_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + lock_content = '{\n "_readme": ["This file is @generated by Composer"],\n "packages": []\n}\n' + (tmp_path / 'composer.json').write_text('{"name": "vendor/project"}\n') + lock_path = tmp_path / COMPOSER_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'composer.json'), + '{"name": "vendor/project"}\n', + absolute_path=str(tmp_path / 'composer.json'), + ) + + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {COMPOSER_LOCK_FILE_NAME} must not be deleted' + + +class TestGetCommands: + def test_get_commands_returns_composer_update(self, restore_composer: RestoreComposerDependencies) -> None: + commands = restore_composer.get_commands('/path/to/composer.json') + assert commands == [ + [ + 'composer', + 'update', + '--no-cache', + '--no-install', + '--no-scripts', + '--ignore-platform-reqs', + ] + ] diff --git a/tests/cli/files_collector/sca/python/__init__.py b/tests/cli/files_collector/sca/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py new file mode 100644 index 00000000..c0f17476 --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py @@ -0,0 +1,217 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_pip_dependencies import ( + PIP_LOCK_FILE_NAME, + RestorePipDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pip(mock_ctx: typer.Context) -> RestorePipDependencies: + return RestorePipDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_plain_pyproject_toml_matches(self, restore_pip: RestorePipDependencies) -> None: + content = '[project]\nname = "my-project"\ndependencies = ["requests"]\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is True + + def test_pyproject_toml_with_poetry_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + content = '[tool.poetry]\nname = "my-project"\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is False + + def test_pyproject_toml_with_uv_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + content = '[tool.uv]\nindex-url = "https://example.com"\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is False + + def test_pyproject_toml_with_existing_pylock_matches( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_pip.is_project(doc) is True + + def test_requirements_txt_matches(self, restore_pip: RestorePipDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_pip.is_project(doc) is True + + def test_setup_py_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + doc = Document('setup.py', 'from setuptools import setup\nsetup()\n') + assert restore_pip.is_project(doc) is False + + def test_empty_pyproject_toml_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + # Same conservative behavior as Poetry/Uv's own is_project: empty content can't be + # confirmed as plain-pip, so don't claim it. + doc = Document('pyproject.toml', '') + assert restore_pip.is_project(doc) is False + + +class TestGetCommands: + def test_get_commands_for_pyproject_toml(self, restore_pip: RestorePipDependencies) -> None: + commands = restore_pip.get_commands('/path/to/pyproject.toml') + assert commands == [['pip', 'lock', '.']] + + def test_get_commands_for_requirements_txt(self, restore_pip: RestorePipDependencies) -> None: + commands = restore_pip.get_commands('/path/to/requirements.txt') + assert commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + def test_get_lock_file_name(self, restore_pip: RestorePipDependencies) -> None: + assert restore_pip.get_lock_file_name() == PIP_LOCK_FILE_NAME + + +class TestTryRestoreDependencies: + def test_existing_pylock_returned_directly_for_pyproject_toml( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert PIP_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_existing_pylock_returned_directly_for_requirements_txt( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n' + (tmp_path / 'requirements.txt').write_text('requests==2.31.0\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content) + + doc = Document( + str(tmp_path / 'requirements.txt'), + 'requests==2.31.0\n', + absolute_path=str(tmp_path / 'requirements.txt'), + ) + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert result.content == lock_content + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestRestoreWithoutExistingLock: + def test_pyproject_toml_runs_pip_lock_dot(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + + seen_commands = [] + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + seen_commands.extend(commands) + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert seen_commands == [['pip', 'lock', '.']] + + def test_requirements_txt_runs_pip_lock_dash_r(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + (tmp_path / 'requirements.txt').write_text('requests==2.31.0\n') + doc = Document( + str(tmp_path / 'requirements.txt'), + 'requests==2.31.0\n', + absolute_path=str(tmp_path / 'requirements.txt'), + ) + + seen_commands = [] + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + seen_commands.extend(commands) + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert seen_commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / PIP_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PIP_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + lock_content = 'lock-version = "1.0"\n' + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + lock_path = tmp_path / PIP_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PIP_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py new file mode 100644 index 00000000..a6d97320 --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py @@ -0,0 +1,118 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import ( + PIPENV_LOCK_FILE_NAME, + RestorePipenvDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pipenv(mock_ctx: typer.Context) -> RestorePipenvDependencies: + return RestorePipenvDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pipfile_matches(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('Pipfile', '[[source]]\nname = "pypi"\n') + assert restore_pipenv.is_project(doc) is True + + def test_pipfile_in_subdir_matches(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('myapp/Pipfile', '[[source]]\nname = "pypi"\n') + assert restore_pipenv.is_project(doc) is True + + def test_pipfile_lock_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('Pipfile.lock', '{"default": {}}\n') + assert restore_pipenv.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_pipenv.is_project(doc) is False + + def test_pyproject_toml_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('pyproject.toml', '[build-system]\nrequires = ["setuptools"]\n') + assert restore_pipenv.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_pipfile_lock_returned_directly( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + lock_content = '{"_meta": {"hash": {"sha256": "abc"}}, "default": {}, "develop": {}}\n' + (tmp_path / 'Pipfile').write_text('[[source]]\nname = "pypi"\n') + (tmp_path / 'Pipfile.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'Pipfile'), + '[[source]]\nname = "pypi"\n', + absolute_path=str(tmp_path / 'Pipfile'), + ) + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert PIPENV_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_pipenv: RestorePipenvDependencies) -> None: + assert restore_pipenv.get_lock_file_name() == PIPENV_LOCK_FILE_NAME + + def test_get_commands_returns_pipenv_lock(self, restore_pipenv: RestorePipenvDependencies) -> None: + commands = restore_pipenv.get_commands('/path/to/Pipfile') + assert commands == [['pipenv', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + manifest_content = '[[source]]\nname = "pypi"\n' + (tmp_path / 'Pipfile').write_text(manifest_content) + doc = Document(str(tmp_path / 'Pipfile'), manifest_content, absolute_path=str(tmp_path / 'Pipfile')) + lock_path = tmp_path / PIPENV_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"_meta": {}, "default": {}, "develop": {}}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PIPENV_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + lock_content = '{"_meta": {"hash": {"sha256": "abc"}}, "default": {}, "develop": {}}\n' + (tmp_path / 'Pipfile').write_text('[[source]]\nname = "pypi"\n') + lock_path = tmp_path / PIPENV_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'Pipfile'), '[[source]]\nname = "pypi"\n', absolute_path=str(tmp_path / 'Pipfile') + ) + + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PIPENV_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py new file mode 100644 index 00000000..cf4c312b --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py @@ -0,0 +1,149 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import ( + POETRY_LOCK_FILE_NAME, + RestorePoetryDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_poetry(mock_ctx: typer.Context) -> RestorePoetryDependencies: + return RestorePoetryDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pyproject_toml_with_poetry_lock_matches( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + (tmp_path / 'poetry.lock').write_text('# This file is generated by Poetry\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is True + + def test_pyproject_toml_with_tool_poetry_section_matches(self, restore_poetry: RestorePoetryDependencies) -> None: + content = '[tool.poetry]\nname = "my-project"\nversion = "1.0.0"\n' + doc = Document('pyproject.toml', content) + assert restore_poetry.is_project(doc) is True + + def test_pyproject_toml_without_poetry_section_does_not_match( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + content = '[build-system]\nrequires = ["setuptools"]\n' + (tmp_path / 'pyproject.toml').write_text(content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + content, + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_poetry: RestorePoetryDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_poetry.is_project(doc) is False + + def test_setup_py_does_not_match(self, restore_poetry: RestorePoetryDependencies) -> None: + doc = Document('setup.py', 'from setuptools import setup\nsetup()\n') + assert restore_poetry.is_project(doc) is False + + def test_empty_content_does_not_match(self, restore_poetry: RestorePoetryDependencies, tmp_path: Path) -> None: + (tmp_path / 'pyproject.toml').write_text('') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_poetry_lock_returned_directly( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + lock_content = '# This file is generated by Poetry\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + (tmp_path / 'poetry.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert POETRY_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_poetry: RestorePoetryDependencies) -> None: + assert restore_poetry.get_lock_file_name() == POETRY_LOCK_FILE_NAME + + def test_get_commands_returns_poetry_lock(self, restore_poetry: RestorePoetryDependencies) -> None: + commands = restore_poetry.get_commands('/path/to/pyproject.toml') + assert commands == [['poetry', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + # Poetry: no pre-existing poetry.lock but pyproject.toml indicates poetry + manifest_content = '[tool.poetry]\nname = "test"\nversion = "1.0.0"\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / POETRY_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('# This file is generated by Poetry\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{POETRY_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + lock_content = '# This file is generated by Poetry\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + lock_path = tmp_path / POETRY_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {POETRY_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py new file mode 100644 index 00000000..70e4e7ae --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py @@ -0,0 +1,138 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_uv_dependencies import ( + UV_LOCK_FILE_NAME, + RestoreUvDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_uv(mock_ctx: typer.Context) -> RestoreUvDependencies: + return RestoreUvDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pyproject_toml_with_uv_lock_matches(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + (tmp_path / 'pyproject.toml').write_text('[build-system]\nrequires = ["hatchling"]\n') + (tmp_path / 'uv.lock').write_text('version = 1\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[build-system]\nrequires = ["hatchling"]\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_uv.is_project(doc) is True + + def test_pyproject_toml_with_tool_uv_section_matches(self, restore_uv: RestoreUvDependencies) -> None: + content = '[tool.uv]\ndev-dependencies = ["pytest"]\n' + doc = Document('pyproject.toml', content) + assert restore_uv.is_project(doc) is True + + def test_pyproject_toml_without_uv_signals_does_not_match( + self, restore_uv: RestoreUvDependencies, tmp_path: Path + ) -> None: + content = '[tool.poetry]\nname = "my-project"\n' + (tmp_path / 'pyproject.toml').write_text(content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + content, + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_uv.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_uv: RestoreUvDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_uv.is_project(doc) is False + + def test_empty_content_does_not_match(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + (tmp_path / 'pyproject.toml').write_text('') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_uv.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_uv_lock_returned_directly(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.uv]\n') + (tmp_path / 'uv.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.uv]\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_uv.try_restore_dependencies(doc) + + assert result is not None + assert UV_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_uv: RestoreUvDependencies) -> None: + assert restore_uv.get_lock_file_name() == UV_LOCK_FILE_NAME + + def test_get_commands_returns_uv_lock(self, restore_uv: RestoreUvDependencies) -> None: + commands = restore_uv.get_commands('/path/to/pyproject.toml') + assert commands == [['uv', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_uv: RestoreUvDependencies, tmp_path: Path + ) -> None: + manifest_content = '[tool.uv]\ndev-dependencies = ["pytest"]\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / UV_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('version = 1\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_uv.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{UV_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.uv]\n') + lock_path = tmp_path / UV_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.uv]\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_uv.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {UV_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/ruby/__init__.py b/tests/cli/files_collector/sca/ruby/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py new file mode 100644 index 00000000..ac3e9d73 --- /dev/null +++ b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import ( + RUBY_LOCK_FILE_NAME, + RestoreRubyDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_ruby(mock_ctx: typer.Context) -> RestoreRubyDependencies: + return RestoreRubyDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_gemfile_matches(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Gemfile', "source 'https://rubygems.org'\n") + assert restore_ruby.is_project(doc) is True + + def test_gemfile_in_subdir_matches(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('myapp/Gemfile', "source 'https://rubygems.org'\n") + assert restore_ruby.is_project(doc) is True + + def test_gemfile_lock_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Gemfile.lock', 'GEM\n remote: https://rubygems.org/\n') + assert restore_ruby.is_project(doc) is False + + def test_other_file_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Rakefile', '') + assert restore_ruby.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_ruby: RestoreRubyDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\n") + doc = Document( + str(tmp_path / 'Gemfile'), + "source 'https://rubygems.org'\n", + absolute_path=str(tmp_path / 'Gemfile'), + ) + lock_path = tmp_path / RUBY_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('GEM\n remote: https://rubygems.org/\n specs:\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_ruby.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{RUBY_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_ruby: RestoreRubyDependencies, tmp_path: Path) -> None: + lock_content = 'GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.6)\n' + (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\ngem 'rake'\n") + lock_path = tmp_path / RUBY_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'Gemfile'), + "source 'https://rubygems.org'\ngem 'rake'\n", + absolute_path=str(tmp_path / 'Gemfile'), + ) + + result = restore_ruby.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {RUBY_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/sbt/__init__.py b/tests/cli/files_collector/sca/sbt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py new file mode 100644 index 00000000..415e5f94 --- /dev/null +++ b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import ( + SBT_LOCK_FILE_NAME, + RestoreSbtDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_sbt(mock_ctx: typer.Context) -> RestoreSbtDependencies: + return RestoreSbtDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_sbt_file_matches(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('build.sbt', 'name := "my-project"\n') + assert restore_sbt.is_project(doc) is True + + def test_sbt_in_subdir_matches(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('myapp/build.sbt', 'name := "my-project"\n') + assert restore_sbt.is_project(doc) is True + + def test_build_gradle_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('build.gradle', '') + assert restore_sbt.is_project(doc) is False + + def test_pom_xml_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_sbt.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_sbt: RestoreSbtDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'build.sbt').write_text('name := "test"\n') + doc = Document( + str(tmp_path / 'build.sbt'), + 'name := "test"\n', + absolute_path=str(tmp_path / 'build.sbt'), + ) + lock_path = tmp_path / SBT_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_sbt.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{SBT_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_sbt: RestoreSbtDependencies, tmp_path: Path) -> None: + lock_content = '[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]' + (tmp_path / 'build.sbt').write_text('name := "test"\n') + lock_path = tmp_path / SBT_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'build.sbt'), + 'name := "test"\n', + absolute_path=str(tmp_path / 'build.sbt'), + ) + + result = restore_sbt.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {SBT_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py new file mode 100644 index 00000000..7d8d8743 --- /dev/null +++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py @@ -0,0 +1,199 @@ +"""Tests for BaseRestoreDependencies cleanup behavior. + +Verifies that lock files generated by restore commands are deleted after +scanning, while pre-existing lock files are left untouched. +""" + +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, execute_commands +from cycode.cli.models import Document + +_LOCK_FILE_NAME = 'generated.lock' +_MANIFEST_FILE_NAME = 'manifest.txt' +_LOCK_CONTENT = 'generated lock content' + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class _MinimalRestoreHandler(BaseRestoreDependencies): + """Minimal concrete subclass for directly testing BaseRestoreDependencies.""" + + def is_project(self, document: Document) -> bool: + return document.path.endswith(_MANIFEST_FILE_NAME) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['echo', 'fake']] + + def get_lock_file_name(self) -> str: + return _LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [_LOCK_FILE_NAME] + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def handler(mock_ctx: typer.Context) -> _MinimalRestoreHandler: + return _MinimalRestoreHandler(mock_ctx, is_git_diff=False, command_timeout=30) + + +def _make_doc(tmp_path: Path) -> Document: + manifest = tmp_path / _MANIFEST_FILE_NAME + manifest.write_text('content') + return Document(str(manifest), 'content', absolute_path=str(manifest)) + + +def _make_execute_side_effect(lock_path: Path, content: str = _LOCK_CONTENT) -> object: + """Returns an execute_commands side_effect that writes the lock file.""" + + def side_effect( + commands: list, timeout: int, output_file_path: Optional[str] = None, working_directory: Optional[str] = None + ) -> str: + lock_path.write_text(content) + return 'output' + + return side_effect + + +class TestExecuteCommands: + """Directly test the shell-failure sentinel handling in execute_commands.""" + + def test_returns_none_when_a_command_fails(self) -> None: + """shell() returns None on non-zero exit; execute_commands must propagate None, not ''.""" + with patch(f'{_BASE_MODULE}.shell', return_value=None): + result = execute_commands([['poetry', 'lock']], timeout=30) + + assert result is None + + def test_stops_at_first_failing_command(self) -> None: + """A failure in an earlier command short-circuits; later commands do not run.""" + mock_shell = MagicMock(side_effect=[None, 'should-not-run']) + with patch(f'{_BASE_MODULE}.shell', mock_shell): + result = execute_commands([['a'], ['b']], timeout=30) + + assert result is None + assert mock_shell.call_count == 1 + + def test_empty_output_success_is_not_treated_as_failure(self) -> None: + """A successful command with empty stdout ('') must NOT be treated as a failure.""" + with patch(f'{_BASE_MODULE}.shell', return_value=''): + result = execute_commands([['poetry', 'lock']], timeout=30) + + assert result == '' + + def test_joins_successful_outputs(self) -> None: + with patch(f'{_BASE_MODULE}.shell', side_effect=['out1', 'out2']): + result = execute_commands([['a'], ['b']], timeout=30) + + assert result == 'out1\nout2' + + +class TestCleanupGeneratedFile: + def test_generated_lockfile_is_deleted_after_restore(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path)): + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == _LOCK_CONTENT + assert not lock_path.exists(), 'Generated lock file must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + lock_path.write_text('pre-existing content') + + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == 'pre-existing content' + assert lock_path.exists(), 'Pre-existing lock file must not be deleted' + + def test_returned_document_content_matches_generated_file( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + expected = '{"dependencies": {"requests": "^2.31"}}' + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)): + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == expected + + def test_cleanup_does_not_raise_when_generated_file_missing( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """unlink(missing_ok=True) must not raise even if the command didn't create the file.""" + doc = _make_doc(tmp_path) + + def side_effect(**_kwargs: object) -> str: + return 'output' # returned non-None but didn't create the file + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = handler.try_restore_dependencies(doc) + + # File was never created; content is None but no exception raised + assert result is not None + assert result.content is None + + def test_failed_command_returns_none_and_no_file_created( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', return_value=None): + result = handler.try_restore_dependencies(doc) + + assert result is None + assert not lock_path.exists() + + def test_shell_failure_propagates_to_none_and_no_lockfile( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """End-to-end failure path: shell() returns None (non-zero exit) -> restore returns None. + + Regression for the stop-on-error bug: execute_commands must NOT swallow a failed + command into an empty-string success. This exercises the real execute_commands with + only shell() mocked (the layer where a non-zero exit is signalled as None). + """ + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.shell', return_value=None): + result = handler.try_restore_dependencies(doc) + + assert result is None, 'A failed restore command must return None so stop-on-error can fire' + assert not lock_path.exists() + + def test_generated_file_content_available_in_document_after_deletion( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """The Document must carry the file content even after the file is removed.""" + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + expected = 'important scan data' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)): + result = handler.try_restore_dependencies(doc) + + assert not lock_path.exists() + assert result is not None + assert result.content == expected diff --git a/tests/cli/files_collector/sca/test_restore_maven_dependencies.py b/tests/cli/files_collector/sca/test_restore_maven_dependencies.py new file mode 100644 index 00000000..fc49cb91 --- /dev/null +++ b/tests/cli/files_collector/sca/test_restore_maven_dependencies.py @@ -0,0 +1,105 @@ +import json +from unittest.mock import MagicMock, patch + +from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import ( + RestoreMavenDependencies, + _has_dependency_graph, +) +from cycode.cli.models import Document + + +class TestHasDependencyGraph: + def test_returns_false_when_content_is_none(self) -> None: + assert _has_dependency_graph(None) is False + + def test_returns_false_when_content_is_empty_string(self) -> None: + assert _has_dependency_graph('') is False + + def test_returns_false_when_dependencies_section_is_missing(self) -> None: + content = json.dumps({'components': [{'name': 'foo'}]}) + assert _has_dependency_graph(content) is False + + def test_returns_false_when_all_dependencies_have_empty_depends_on(self) -> None: + content = json.dumps({'dependencies': [{'ref': 'pkg:maven/foo/bar@1.0', 'dependsOn': []}]}) + assert _has_dependency_graph(content) is False + + def test_returns_false_when_dependencies_list_is_empty(self) -> None: + content = json.dumps({'dependencies': []}) + assert _has_dependency_graph(content) is False + + def test_returns_true_when_at_least_one_dependency_has_depends_on(self) -> None: + content = json.dumps( + { + 'dependencies': [ + {'ref': 'pkg:maven/com.example/root@1.0', 'dependsOn': ['pkg:maven/io.netty/netty-all@4.1.0']}, + {'ref': 'pkg:maven/io.netty/netty-all@4.1.0', 'dependsOn': []}, + ] + } + ) + assert _has_dependency_graph(content) is True + + def test_returns_false_when_content_is_invalid_json(self) -> None: + assert _has_dependency_graph('not valid json {{{') is False + + +class TestRestoreMavenDependenciesFallback: + def _make_instance(self) -> RestoreMavenDependencies: + ctx = MagicMock() + ctx.obj = {} + return RestoreMavenDependencies(ctx=ctx, is_git_diff=False, command_timeout=60) + + def test_falls_back_to_secondary_command_when_bom_has_no_dependency_graph(self) -> None: + instance = self._make_instance() + document = MagicMock(spec=Document) + document.content = 'some content' + + bom_doc = MagicMock(spec=Document) + bom_doc.content = json.dumps({'dependencies': []}) + fallback_doc = MagicMock(spec=Document) + fallback_doc.content = '[INFO] com.example:root:jar:1.0\n+- io.netty:netty-all:jar:4.1.0' + + with ( + patch.object(instance, 'get_manifest_file_path', return_value='/project/pom.xml'), + patch( + 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies.BaseRestoreDependencies.try_restore_dependencies', + return_value=bom_doc, + ), + patch.object(instance, 'restore_from_secondary_command', return_value=fallback_doc) as mock_fallback, + ): + result = instance.try_restore_dependencies(document) + + mock_fallback.assert_called_once_with(document, '/project/pom.xml') + assert result is fallback_doc + + def test_returns_bom_document_when_dependency_graph_is_present(self) -> None: + instance = self._make_instance() + document = MagicMock(spec=Document) + document.content = 'some content' + + bom_doc = MagicMock(spec=Document) + bom_doc.content = json.dumps( + { + 'dependencies': [ + {'ref': 'pkg:maven/com.example/root@1.0', 'dependsOn': ['pkg:maven/io.netty/netty@4.1.0']} + ] + } + ) + + with ( + patch.object(instance, 'get_manifest_file_path', return_value='/project/pom.xml'), + patch( + 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies.BaseRestoreDependencies.try_restore_dependencies', + return_value=bom_doc, + ), + patch.object(instance, 'restore_from_secondary_command') as mock_fallback, + ): + result = instance.try_restore_dependencies(document) + + mock_fallback.assert_not_called() + assert result is bom_doc + + def test_uses_plugin_version_2_9_1(self) -> None: + instance = self._make_instance() + commands = instance.get_commands('/path/to/pom.xml') + assert len(commands) == 1 + assert 'org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom' in commands[0] diff --git a/tests/cli/files_collector/sca/test_sca_file_collector.py b/tests/cli/files_collector/sca/test_sca_file_collector.py new file mode 100644 index 00000000..f645283d --- /dev/null +++ b/tests/cli/files_collector/sca/test_sca_file_collector.py @@ -0,0 +1,79 @@ +from unittest.mock import MagicMock + +import click +import pytest +import typer + +from cycode.cli.exceptions.custom_exceptions import FileCollectionError +from cycode.cli.files_collector.sca.sca_file_collector import _try_restore_dependencies +from cycode.cli.models import Document + + +def _make_ctx(*, stop_on_error: bool = False) -> typer.Context: + ctx = typer.Context(click.Command('path'), obj={'stop_on_error': stop_on_error, 'monitor': False}) + ctx.obj['path'] = '/some/path' + return ctx + + +def _make_handler(*, is_project: bool = True, restore_result: object = None) -> MagicMock: + handler = MagicMock() + handler.is_project.return_value = is_project + handler.restore.return_value = restore_result + return handler + + +class TestTryRestoreDependencies: + def test_returns_none_when_handler_does_not_match(self) -> None: + ctx = _make_ctx() + doc = Document('pom.xml', '', is_git_diff_format=False) + handler = _make_handler(is_project=False) + + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is None + handler.restore.assert_not_called() + + def test_returns_none_on_restore_failure_without_stop_on_error(self) -> None: + ctx = _make_ctx(stop_on_error=False) + doc = Document('pom.xml', '', is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=None) + + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is None + + def test_raises_file_collection_error_on_restore_failure_with_stop_on_error(self) -> None: + ctx = _make_ctx(stop_on_error=True) + doc = Document('pom.xml', '', is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=None) + handler.__class__.__name__ = 'RestoreMavenDependencies' + type(handler).__name__ = 'RestoreMavenDependencies' + + with pytest.raises(FileCollectionError) as exc_info, ctx: + _try_restore_dependencies(ctx, handler, doc) + + assert 'pom.xml' in str(exc_info.value) + + def test_returns_document_on_success(self) -> None: + ctx = _make_ctx() + doc = Document('pom.xml', '', is_git_diff_format=False) + restored_doc = Document('pom.xml.lock', 'dep-tree-content', is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=restored_doc) + + with ctx: + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is restored_doc + assert result.content == 'dep-tree-content' + + def test_sets_empty_content_when_restore_returns_document_with_none_content(self) -> None: + ctx = _make_ctx() + doc = Document('pom.xml', '', is_git_diff_format=False) + restored_doc = Document('pom.xml.lock', None, is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=restored_doc) + + with ctx: + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is not None + assert result.content == '' diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 501c1811..d972144c 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -16,6 +16,7 @@ collect_commit_range_diff_documents, get_diff_file_path, get_safe_head_reference_for_diff, + get_staged_diff_index, parse_commit_range, parse_pre_push_input, parse_pre_receive_input, @@ -67,7 +68,7 @@ def test_returns_head_when_repository_has_commits(self) -> None: def test_returns_empty_tree_hash_when_repository_has_no_commits(self) -> None: """Test that an empty tree hash is returned when the repository has no commits.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, repo): result = get_safe_head_reference_for_diff(repo) expected_empty_tree_hash = consts.GIT_EMPTY_TREE_OBJECT assert result == expected_empty_tree_hash @@ -85,12 +86,14 @@ def test_index_diff_works_on_bare_repository(self) -> None: repo.index.add(['staged_file.py']) - head_ref = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_ref, create_patch=True, R=True) + head_ref, diff_index = get_staged_diff_index(repo) + assert head_ref == consts.GIT_EMPTY_TREE_OBJECT assert len(diff_index) == 1 diff = diff_index[0] assert diff.b_path == 'staged_file.py' + # staged content must be an addition, not a removal + assert b"+print('staged content')" in diff.diff def test_index_diff_works_on_repository_with_commits(self) -> None: """Test that index.diff continues to work on repositories with existing commits.""" @@ -111,14 +114,17 @@ def test_index_diff_works_on_repository_with_commits(self) -> None: repo.index.add(['new_file.py', 'initial.py']) - head_ref = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_ref, create_patch=True, R=True) + head_ref, diff_index = get_staged_diff_index(repo) assert len(diff_index) == 2 file_paths = {diff.b_path or diff.a_path for diff in diff_index} assert 'new_file.py' in file_paths assert 'initial.py' in file_paths assert head_ref == consts.GIT_HEAD_COMMIT_REV + # staged content must be additions, not removals + patches = b''.join(diff.diff for diff in diff_index) + assert b"+print('new file')" in patches + assert b"+print('modified initial')" in patches def test_sequential_operations_on_same_repository(self) -> None: """Test behavior when transitioning from bare to committed repository.""" @@ -129,8 +135,7 @@ def test_sequential_operations_on_same_repository(self) -> None: repo.index.add(['test.py']) - head_ref_before = get_safe_head_reference_for_diff(repo) - diff_before = repo.index.diff(head_ref_before, create_patch=True, R=True) + head_ref_before, diff_before = get_staged_diff_index(repo) expected_empty_tree = consts.GIT_EMPTY_TREE_OBJECT assert head_ref_before == expected_empty_tree @@ -144,8 +149,7 @@ def test_sequential_operations_on_same_repository(self) -> None: repo.index.add(['new.py']) - head_ref_after = get_safe_head_reference_for_diff(repo) - diff_after = repo.index.diff(head_ref_after, create_patch=True, R=True) + head_ref_after, diff_after = get_staged_diff_index(repo) assert head_ref_after == consts.GIT_HEAD_COMMIT_REV assert len(diff_after) == 1 @@ -167,8 +171,7 @@ def test_git_mv_pre_commit_scan() -> None: repo.index.remove(['NEWFILE.txt']) repo.index.add(['RENAMED.txt']) - head_ref = get_safe_head_reference_for_diff(repo) - diff_index = repo.index.diff(head_ref, create_patch=True, R=True) + _, diff_index = get_staged_diff_index(repo) for diff in diff_index: file_path = get_path_by_os(get_diff_file_path(diff, repo=repo)) @@ -343,7 +346,7 @@ def test_diff_with_bare_repository(self) -> None: def test_diff_with_no_paths(self) -> None: """Test behavior when the diff has neither a_path nor b_path.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, repo): class MockDiff: def __init__(self) -> None: @@ -392,15 +395,15 @@ def test_parse_branch_deletion_input(self) -> None: result = parse_pre_push_input() assert result == pre_push_input - def test_parse_empty_input_raises_error(self) -> None: - """Test that empty input raises ValueError.""" - with patch('sys.stdin', StringIO('')), pytest.raises(ValueError, match='Pre push input was not found'): - parse_pre_push_input() + def test_parse_empty_input_returns_none(self) -> None: + """Test that empty input returns None instead of raising.""" + with patch('sys.stdin', StringIO('')): + assert parse_pre_push_input() is None - def test_parse_whitespace_only_input_raises_error(self) -> None: - """Test that whitespace-only input raises ValueError.""" - with patch('sys.stdin', StringIO(' \n\t ')), pytest.raises(ValueError, match='Pre push input was not found'): - parse_pre_push_input() + def test_parse_whitespace_only_input_returns_none(self) -> None: + """Test that whitespace-only input returns None instead of raising.""" + with patch('sys.stdin', StringIO(' \n\t ')): + assert parse_pre_push_input() is None class TestGetDefaultBranchesForMergeBase: @@ -409,7 +412,7 @@ class TestGetDefaultBranchesForMergeBase: def test_environment_variable_override(self) -> None: """Test that the environment variable takes precedence.""" with ( - temporary_git_repository() as (temp_dir, repo), + temporary_git_repository() as (_temp_dir, repo), patch.dict(os.environ, {consts.CYCODE_DEFAULT_BRANCH_ENV_VAR_NAME: 'custom-main'}), ): branches = _get_default_branches_for_merge_base(repo) @@ -418,7 +421,7 @@ def test_environment_variable_override(self) -> None: def test_git_symbolic_ref_success(self) -> None: """Test getting default branch via git symbolic-ref.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo with a git interface that returns origin/main mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/main' @@ -429,7 +432,7 @@ def test_git_symbolic_ref_success(self) -> None: def test_git_symbolic_ref_with_master(self) -> None: """Test getting default branch via git symbolic-ref when it's master.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo with a git interface that returns origin/master mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/master' @@ -440,7 +443,7 @@ def test_git_symbolic_ref_with_master(self) -> None: def test_git_remote_show_fallback(self) -> None: """Test fallback to git remote show when symbolic-ref fails.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo where symbolic-ref fails but the remote show succeeds mock_repo = Mock() mock_repo.git.symbolic_ref.side_effect = Exception('symbolic-ref failed') @@ -459,7 +462,7 @@ def test_git_remote_show_fallback(self) -> None: def test_both_git_methods_fail_fallback_to_hardcoded(self) -> None: """Test fallback to hardcoded branches when both Git methods fail.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo where both Git methods fail mock_repo = Mock() mock_repo.git.symbolic_ref.side_effect = Exception('symbolic-ref failed') @@ -474,7 +477,7 @@ def test_both_git_methods_fail_fallback_to_hardcoded(self) -> None: def test_no_duplicates_in_branch_list(self) -> None: """Test that duplicate branches are not added to the list.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo that returns main (which is also in fallback list) mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/main' @@ -486,7 +489,7 @@ def test_no_duplicates_in_branch_list(self) -> None: def test_env_var_plus_git_detection(self) -> None: """Test combination of environment variable and git detection.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/develop' @@ -500,7 +503,7 @@ def test_env_var_plus_git_detection(self) -> None: def test_malformed_symbolic_ref_response(self) -> None: """Test handling of malformed symbolic-ref response.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (_temp_dir, _repo): # Create a mock repo that returns a malformed response mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'malformed-response' @@ -758,26 +761,23 @@ def test_calculate_range_parsing_push_details(self) -> None: result = calculate_pre_push_commit_range(push_details) assert result == '789xyz456abc..abc123def456' - def test_calculate_range_with_tags(self) -> None: - """Test calculating commit range when pushing tags.""" + def test_calculate_range_with_new_tag_push_returns_none(self) -> None: + """Test that pushing a new tag returns None (no scanning needed).""" push_details = f'refs/tags/v1.0.0 1234567890abcdef refs/tags/v1.0.0 {consts.EMPTY_COMMIT_SHA}' + result = calculate_pre_push_commit_range(push_details) + assert result is None - with temporary_git_repository() as (temp_dir, repo): - # Create a commit - test_file = os.path.join(temp_dir, 'test.py') - with open(test_file, 'w') as f: - f.write("print('test')") - - repo.index.add(['test.py']) - commit = repo.index.commit('Test commit') - - # Create tag - repo.create_tag('v1.0.0', commit) + def test_calculate_range_with_tag_deletion_returns_none(self) -> None: + """Test that deleting a tag returns None (no scanning needed).""" + push_details = f'refs/tags/v1.0.0 {consts.EMPTY_COMMIT_SHA} refs/tags/v1.0.0 1234567890abcdef' + result = calculate_pre_push_commit_range(push_details) + assert result is None - with patch('os.getcwd', return_value=temp_dir): - result = calculate_pre_push_commit_range(push_details) - # For new tags, should try to find a merge base or fall back to --all - assert result in [f'{commit.hexsha}..{commit.hexsha}', '--all'] + def test_calculate_range_with_tag_update_returns_none(self) -> None: + """Test that updating a tag returns None (no scanning needed).""" + push_details = 'refs/tags/v1.0.0 1234567890abcdef refs/tags/v1.0.0 0987654321fedcba' + result = calculate_pre_push_commit_range(push_details) + assert result is None class TestPrePushHookIntegration: @@ -805,12 +805,15 @@ def test_simulate_pre_push_hook_input_format(self) -> None: # Test that we can calculate the commit range for each case commit_range = calculate_pre_push_commit_range(parsed) - if consts.EMPTY_COMMIT_SHA in push_input: - if push_input.startswith('refs/heads/') and push_input.split()[1] == consts.EMPTY_COMMIT_SHA: + if push_input.startswith('refs/tags/'): + # Tag pushes - should return None (no scanning needed) + assert commit_range is None + elif consts.EMPTY_COMMIT_SHA in push_input: + if push_input.split()[1] == consts.EMPTY_COMMIT_SHA: # Branch deletion - should return None assert commit_range is None else: - # New branch/tag - should return a range or --all + # New branch - should return a range or --all assert commit_range is not None else: # Regular update - should return proper range @@ -845,7 +848,7 @@ def _make_linear_history(self, repo: Repo, base_dir: str) -> tuple[str, str, str def test_two_dot_linear_history(self) -> None: """For 'A..C', expect (A,C) in linear history.""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'{a}..{c}', temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') @@ -853,7 +856,7 @@ def test_two_dot_linear_history(self) -> None: def test_three_dot_linear_history(self) -> None: """For 'A...C' in linear history, expect (A,C).""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'{a}...{c}', temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '...') @@ -861,7 +864,7 @@ def test_three_dot_linear_history(self) -> None: def test_open_right_linear_history(self) -> None: """For 'A..', expect (A,HEAD=C).""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'{a}..', temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') @@ -869,7 +872,7 @@ def test_open_right_linear_history(self) -> None: def test_open_left_linear_history(self) -> None: """For '..C' where HEAD==C, expect (HEAD=C,C).""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + _a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'..{c}', temp_dir) assert (parsed_from, parsed_to, separator) == (c, c, '..') @@ -877,7 +880,7 @@ def test_open_left_linear_history(self) -> None: def test_single_commit_spec(self) -> None: """For 'A', expect (A,HEAD=C).""" with temporary_git_repository() as (temp_dir, repo): - a, b, c = self._make_linear_history(repo, temp_dir) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(a, temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') @@ -935,7 +938,7 @@ def test_parse_all_for_empty_remote_scenario_with_two_commits(self) -> None: def test_parse_all_with_empty_repository_returns_none(self) -> None: """Test that '--all' returns None when repository has no commits.""" - with temporary_git_repository() as (temp_dir, repo): + with temporary_git_repository() as (temp_dir, _repo): # Empty repository with no commits parsed_from, parsed_to, separator = parse_commit_range('--all', temp_dir) # Should return None, None, None when HEAD doesn't exist diff --git a/tests/cli/files_collector/test_in_memory_zip.py b/tests/cli/files_collector/test_in_memory_zip.py new file mode 100644 index 00000000..d1790c7c --- /dev/null +++ b/tests/cli/files_collector/test_in_memory_zip.py @@ -0,0 +1,29 @@ +"""Tests for InMemoryZip class, specifically for handling surrogate characters and encoding issues.""" + +import zipfile +from io import BytesIO + +from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip + + +def test_append_with_surrogate_characters() -> None: + """Test that surrogate characters are handled gracefully without raising encoding errors.""" + # Surrogate characters (U+D800 to U+DFFF) cannot be encoded to UTF-8 directly + zip_file = InMemoryZip() + content = 'Normal text \udc96 more text' + + # Should not raise UnicodeEncodeError + zip_file.append('test.txt', None, content) + zip_file.close() + + # Verify the ZIP was created successfully + zip_data = zip_file.read() + assert len(zip_data) > 0 + + # Verify we can read it back and the surrogate was replaced + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + extracted = zf.read('test.txt').decode('utf-8') + assert 'Normal text' in extracted + assert 'more text' in extracted + # The surrogate should have been replaced with the replacement character + assert '\udc96' not in extracted diff --git a/tests/cli/printers/__init__.py b/tests/cli/printers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/printers/utils/__init__.py b/tests/cli/printers/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/printers/utils/test_detection_data.py b/tests/cli/printers/utils/test_detection_data.py new file mode 100644 index 00000000..603c25db --- /dev/null +++ b/tests/cli/printers/utils/test_detection_data.py @@ -0,0 +1,41 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from cycode.cli.consts import IAC_SCAN_TYPE, SAST_SCAN_TYPE, SCA_SCAN_TYPE, SECRET_SCAN_TYPE +from cycode.cli.printers.utils.detection_data import get_detection_file_path + + +def _make_detection(**details: str) -> MagicMock: + detection = MagicMock() + detection.detection_details = dict(details) + return detection + + +def test_get_detection_file_path_sca_uses_file_path() -> None: + detection = _make_detection(file_name='package.json', file_path='/repo/path/package.json') + result = get_detection_file_path(SCA_SCAN_TYPE, detection) + assert result == Path('/repo/path/package.json') + + +def test_get_detection_file_path_iac_uses_file_path() -> None: + detection = _make_detection(file_name='main.tf', file_path='/repo/infra/main.tf') + result = get_detection_file_path(IAC_SCAN_TYPE, detection) + assert result == Path('/repo/infra/main.tf') + + +def test_get_detection_file_path_sca_fallback_empty() -> None: + detection = _make_detection() + result = get_detection_file_path(SCA_SCAN_TYPE, detection) + assert result == Path('') + + +def test_get_detection_file_path_secret() -> None: + detection = _make_detection(file_path='/repo/src', file_name='.env') + result = get_detection_file_path(SECRET_SCAN_TYPE, detection) + assert result == Path('/repo/src/.env') + + +def test_get_detection_file_path_sast() -> None: + detection = _make_detection(file_path='repo/src/app.py') + result = get_detection_file_path(SAST_SCAN_TYPE, detection) + assert result == Path('/repo/src/app.py') diff --git a/tests/cli/printers/utils/test_rich_encoding_fix.py b/tests/cli/printers/utils/test_rich_encoding_fix.py new file mode 100644 index 00000000..721f1c6a --- /dev/null +++ b/tests/cli/printers/utils/test_rich_encoding_fix.py @@ -0,0 +1,86 @@ +"""Tests for Rich encoding fix to handle surrogate characters.""" + +from io import StringIO +from typing import Any +from unittest.mock import MagicMock + +from rich.console import Console + +from cycode.cli import consts +from cycode.cli.models import Document +from cycode.cli.printers.rich_printer import RichPrinter +from cycode.cyclient.models import Detection + + +def create_strict_encoding_console() -> tuple[Console, StringIO]: + """Create a Console that enforces strict UTF-8 encoding, simulating Windows console behavior. + + When Rich writes to the console, the file object needs to encode strings to bytes. + With errors='strict' (default for TextIOWrapper), this raises UnicodeEncodeError on surrogates. + This function simulates that behavior to test the encoding fix. + """ + buffer = StringIO() + + class StrictEncodingWrapper: + def __init__(self, file_obj: StringIO) -> None: + self._file = file_obj + + def write(self, text: str) -> int: + """Validate encoding before writing to simulate strict encoding behavior.""" + text.encode('utf-8') + return self._file.write(text) + + def flush(self) -> None: + self._file.flush() + + def isatty(self) -> bool: + return False + + def __getattr__(self, name: str) -> Any: + # Delegate all other attributes to the underlying file + return getattr(self._file, name) + + strict_file = StrictEncodingWrapper(buffer) + console = Console(file=strict_file, width=80, force_terminal=False) + return console, buffer + + +def test_rich_printer_handles_surrogate_characters_in_violation_card() -> None: + """Test that RichPrinter._print_violation_card() handles surrogate characters without errors. + + The error occurs in Rich's console._write_buffer() -> write() when console.print() is called. + On Windows with strict encoding, this raises UnicodeEncodeError on surrogates. + """ + surrogate_char = chr(0xDC96) + document_content = 'A' * 1236 + surrogate_char + 'B' * 100 + document = Document( + path='test.py', + content=document_content, + is_git_diff_format=False, + ) + + detection = Detection( + detection_type_id='test-id', + type='test-type', + message='Test message', + detection_details={ + 'description': 'Summary with ' + surrogate_char + ' surrogate character', + 'policy_display_name': 'Test Policy', + 'start_position': 1236, + 'length': 1, + 'line': 0, + }, + detection_rule_id='test-rule-id', + severity='Medium', + ) + + mock_ctx = MagicMock() + mock_ctx.obj = { + 'scan_type': consts.SAST_SCAN_TYPE, + 'show_secret': False, + } + mock_ctx.info_name = consts.SAST_SCAN_TYPE + + console, _ = create_strict_encoding_console() + printer = RichPrinter(mock_ctx, console, console) + printer._print_violation_card(document, detection, 1, 1) diff --git a/tests/cli/test_app_argv_peek.py b/tests/cli/test_app_argv_peek.py new file mode 100644 index 00000000..bd4c61a8 --- /dev/null +++ b/tests/cli/test_app_argv_peek.py @@ -0,0 +1,82 @@ +"""Tests for the argv-peek lazy subapp registration in cycode/cli/app.py. + +The argv-peek picks the invoked subapp from sys.argv before Typer dispatches, +so it has to walk argv itself — skipping flags and (importantly) the values +those flags consume. The `_ROOT_OPTS_WITH_VALUE` set lists every root-level +flag that consumes a following positional token. If a maintainer adds a new +value-taking option to `app_callback` and forgets to register it here, the +argv-peek will silently fall back to the cold path (loading every subapp). +The test below catches that drift by comparing the hand-maintained set +against what Click's introspection sees on the built command. +""" + +from typing import Optional +from unittest.mock import patch + +import click +import pytest +import typer.main + +from cycode.cli.app import _ROOT_OPTS_WITH_VALUE, _detect_invocation, app + + +def test_root_opts_with_value_matches_click_introspection() -> None: + """Every root option that takes a value must be registered in _ROOT_OPTS_WITH_VALUE.""" + cmd = typer.main.get_command(app) + expected = { + opt + for param in cmd.params + if isinstance(param, click.Option) and not param.is_flag + for opt in param.opts + if opt.startswith('-') + } + assert frozenset(expected) == _ROOT_OPTS_WITH_VALUE, ( + f'_ROOT_OPTS_WITH_VALUE is out of sync with app_callback.\n' + f' Missing: {sorted(expected - _ROOT_OPTS_WITH_VALUE)}\n' + f' Extra: {sorted(_ROOT_OPTS_WITH_VALUE - expected)}\n' + f'Update _ROOT_OPTS_WITH_VALUE in cycode/cli/app.py.' + ) + + +@pytest.mark.parametrize( + 'argv', + [ + ['cycode', 'ai-guardrails', 'scan'], + ['cycode', '-v', 'ai-guardrails', 'scan'], + ['cycode', '--verbose', 'ai-guardrails', 'scan'], + ['cycode', '--output', 'json', 'ai-guardrails', 'scan'], + ['cycode', '-o', 'json', 'ai-guardrails', 'scan'], + ['cycode', '--user-agent', '{"app_name":"x"}', 'ai-guardrails', 'scan'], + ['cycode', '--client-secret', 'secret-val', 'ai-guardrails', 'scan'], + ['cycode', '--client-id', 'client-val', 'ai-guardrails', 'scan'], + ['cycode', '--id-token', 'token-val', 'ai-guardrails', 'scan'], + ['cycode', '--show-completion', 'bash', 'ai-guardrails', 'scan'], + # --key=value form is one token; argv-peek should treat it as a flag + ['cycode', '--output=json', 'ai-guardrails', 'scan'], + # multiple value-taking options stacked + ['cycode', '-v', '--output', 'json', '--client-id', 'foo', 'ai-guardrails', 'scan'], + ], +) +def test_detect_invocation_finds_subcommand_past_flags(argv: list[str]) -> None: + with patch('sys.argv', argv): + assert _detect_invocation() == ('ai-guardrails', 'scan') + + +@pytest.mark.parametrize( + ('argv', 'expected'), + [ + # No positional args → no match + (['cycode'], (None, None)), + (['cycode', '-v'], (None, None)), + # Unknown subapp → no match (graceful: app.py falls back to cold path) + (['cycode', 'not-a-real-subapp'], (None, None)), + # Known subapp, no subcommand + (['cycode', 'scan'], ('scan', None)), + # Alias resolution + (['cycode', 'ai_remediation'], ('ai-remediation', None)), + (['cycode', 'version'], ('status', None)), + ], +) +def test_detect_invocation_edge_cases(argv: list[str], expected: tuple[Optional[str], Optional[str]]) -> None: + with patch('sys.argv', argv): + assert _detect_invocation() == expected diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index c37c1d8a..b2be7ab5 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -5,6 +5,7 @@ import responses +from cycode.cli.utils.scan_utils import should_use_presigned_upload from cycode.cyclient.scan_client import ScanClient from tests.conftest import MOCKED_RESPONSES_PATH @@ -128,6 +129,38 @@ def get_scan_configuration_response(url: str) -> responses.Response: return responses.Response(method=responses.GET, url=url, json=json_response, status=200) +_PRESIGNED_UPLOAD_URL = 'https://cycode-tests.s3.amazonaws.com/presigned-upload' + + +def get_upload_link_url(scan_type: str, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + async_scan_type = scan_client.scan_config.get_async_scan_type(scan_type) + service_url = f'{scan_client.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/upload-link' + return f'{api_url}/{service_url}' + + +def get_upload_link_response(url: str) -> responses.Response: + json_response = {'upload_id': str(uuid4()), 'url': _PRESIGNED_UPLOAD_URL, 'presigned_post_fields': {}} + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + +def get_presigned_upload_response() -> responses.Response: + return responses.Response(method=responses.POST, url=_PRESIGNED_UPLOAD_URL, status=204) + + +def get_scan_from_upload_id_url(scan_type: str, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + async_scan_type = scan_client.scan_config.get_async_scan_type(scan_type) + service_url = f'{scan_client.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/repository' + return f'{api_url}/{service_url}' + + +def get_scan_from_upload_id_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: + if not scan_id: + scan_id = uuid4() + return responses.Response(method=responses.POST, url=url, json={'scan_id': str(scan_id)}, status=200) + + def mock_remote_config_responses(responses_module: responses, scan_type: str, scan_client: ScanClient) -> None: responses_module.add(get_scan_configuration_response(get_scan_configuration_url(scan_type, scan_client))) @@ -136,9 +169,18 @@ def mock_scan_async_responses( responses_module: responses, scan_type: str, scan_client: ScanClient, scan_id: UUID, zip_content_path: Path ) -> None: mock_remote_config_responses(responses_module, scan_type, scan_client) - responses_module.add( - get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id) - ) + + if should_use_presigned_upload(scan_type): + responses_module.add(get_upload_link_response(get_upload_link_url(scan_type, scan_client))) + responses_module.add(get_presigned_upload_response()) + responses_module.add( + get_scan_from_upload_id_response(get_scan_from_upload_id_url(scan_type, scan_client), scan_id) + ) + else: + responses_module.add( + get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id) + ) + responses_module.add(get_scan_details_response(get_scan_details_url(scan_type, scan_id, scan_client), scan_id)) responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client))) responses_module.add(get_scan_detections_response(get_scan_detections_url(scan_client), scan_id, zip_content_path)) diff --git a/tests/cyclient/test_client_base_exceptions.py b/tests/cyclient/test_client_base_exceptions.py new file mode 100644 index 00000000..f99453d3 --- /dev/null +++ b/tests/cyclient/test_client_base_exceptions.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock + +import pytest +import responses +from requests.exceptions import ( + ConnectionError as RequestsConnectionError, +) +from requests.exceptions import ( + HTTPError, + SSLError, + Timeout, +) + +from cycode.cli.exceptions.custom_exceptions import ( + HttpUnauthorizedError, + RequestConnectionError, + RequestHttpError, + RequestSslError, + RequestTimeoutError, +) +from cycode.cyclient import config +from cycode.cyclient.cycode_client_base import CycodeClientBase + + +def _make_client() -> CycodeClientBase: + return CycodeClientBase(config.cycode_api_url) + + +# --- _handle_exception mapping --- + + +def test_handle_exception_timeout() -> None: + client = _make_client() + with pytest.raises(RequestTimeoutError): + client._handle_exception(Timeout('timed out')) + + +def test_handle_exception_ssl_error() -> None: + client = _make_client() + with pytest.raises(RequestSslError): + client._handle_exception(SSLError('cert verify failed')) + + +def test_handle_exception_connection_error() -> None: + client = _make_client() + with pytest.raises(RequestConnectionError): + client._handle_exception(RequestsConnectionError('refused')) + + +def test_handle_exception_http_error_401() -> None: + response = MagicMock() + response.status_code = 401 + response.text = 'Unauthorized' + error = HTTPError(response=response) + + client = _make_client() + with pytest.raises(HttpUnauthorizedError): + client._handle_exception(error) + + +def test_handle_exception_http_error_500() -> None: + response = MagicMock() + response.status_code = 500 + response.text = 'Internal Server Error' + error = HTTPError(response=response) + + client = _make_client() + with pytest.raises(RequestHttpError) as exc_info: + client._handle_exception(error) + assert exc_info.value.status_code == 500 + + +def test_handle_exception_unknown_error_reraises() -> None: + client = _make_client() + with pytest.raises(RuntimeError, match='something unexpected'): + client._handle_exception(RuntimeError('something unexpected')) + + +# --- HTTP integration via responses mock --- + + +@responses.activate +def test_get_returns_response_on_success() -> None: + client = _make_client() + url = f'{client.api_url}/test-endpoint' + responses.add(responses.GET, url, json={'ok': True}, status=200) + + response = client.get('test-endpoint') + assert response.status_code == 200 + assert response.json() == {'ok': True} + + +@responses.activate +def test_post_returns_response_on_success() -> None: + client = _make_client() + url = f'{client.api_url}/test-endpoint' + responses.add(responses.POST, url, json={'created': True}, status=201) + + response = client.post('test-endpoint', body={'data': 'value'}) + assert response.status_code == 201 + + +@responses.activate +def test_get_raises_timeout_error() -> None: + client = _make_client() + url = f'{client.api_url}/slow-endpoint' + responses.add(responses.GET, url, body=Timeout('Connection timed out')) + + with pytest.raises(RequestTimeoutError): + client.get('slow-endpoint') + + +@responses.activate +def test_get_raises_ssl_error() -> None: + client = _make_client() + url = f'{client.api_url}/ssl-endpoint' + responses.add(responses.GET, url, body=SSLError('certificate verify failed')) + + with pytest.raises(RequestSslError): + client.get('ssl-endpoint') + + +@responses.activate +def test_get_raises_connection_error() -> None: + client = _make_client() + url = f'{client.api_url}/down-endpoint' + responses.add(responses.GET, url, body=RequestsConnectionError('Connection refused')) + + with pytest.raises(RequestConnectionError): + client.get('down-endpoint') + + +@responses.activate +def test_get_raises_http_unauthorized_error() -> None: + client = _make_client() + url = f'{client.api_url}/auth-endpoint' + responses.add(responses.GET, url, json={'error': 'unauthorized'}, status=401) + + with pytest.raises(HttpUnauthorizedError): + client.get('auth-endpoint') + + +@responses.activate +def test_get_raises_http_error_on_500() -> None: + client = _make_client() + url = f'{client.api_url}/error-endpoint' + responses.add(responses.GET, url, json={'error': 'server error'}, status=500) + + with pytest.raises(RequestHttpError) as exc_info: + client.get('error-endpoint') + assert exc_info.value.status_code == 500 + + +@responses.activate +def test_get_raises_http_error_on_403() -> None: + client = _make_client() + url = f'{client.api_url}/forbidden-endpoint' + responses.add(responses.GET, url, json={'error': 'forbidden'}, status=403) + + with pytest.raises(RequestHttpError) as exc_info: + client.get('forbidden-endpoint') + assert exc_info.value.status_code == 403 diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index d6928118..505d8d50 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -4,6 +4,7 @@ import pytest import requests import responses +from pytest_mock import MockerFixture from requests.exceptions import ConnectionError as RequestsConnectionError from cycode.cli.cli_types import ScanTypeOption @@ -12,6 +13,7 @@ HttpUnauthorizedError, RequestConnectionError, RequestTimeoutError, + SlowUploadConnectionError, ) from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.models import Document @@ -168,3 +170,28 @@ def test_get_scan_details( scan_details_response = scan_client.get_scan_details(scan_type, str(scan_id)) assert scan_details_response.id == str(scan_id) assert scan_details_response.scan_status == 'Completed' + + +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) +def test_zipped_file_scan_async_slow_upload_error( + scan_type: ScanTypeOption, scan_client: ScanClient, mocker: MockerFixture +) -> None: + """Test that a connection failure mid-transfer raises SlowUploadConnectionError.""" + zip_file = get_test_zip_file(scan_type) + + def _partial_upload_then_fail(**kwargs) -> None: + # Read only a small portion of the body to simulate a partial upload + data = kwargs.get('data') + if data is not None: + data.read(10) + raise requests.exceptions.ChunkedEncodingError('Connection broken mid-transfer') + + mocker.patch('cycode.cyclient.cycode_client_base._get_request_function', return_value=_partial_upload_then_fail) + mocker.patch.object( + scan_client.scan_cycode_client, + 'get_request_headers', + return_value={'Authorization': 'Bearer test'}, + ) + + with pytest.raises(SlowUploadConnectionError): + scan_client.zipped_file_scan_async(zip_file=zip_file, scan_type=scan_type, scan_parameters={}) diff --git a/tests/test_models_deserialization.py b/tests/test_models_deserialization.py new file mode 100644 index 00000000..4c7dcd72 --- /dev/null +++ b/tests/test_models_deserialization.py @@ -0,0 +1,451 @@ +from cycode.cyclient.models import ( + ApiToken, + ApiTokenGenerationPollingResponse, + ApiTokenGenerationPollingResponseSchema, + ApiTokenSchema, + AuthenticationSession, + AuthenticationSessionSchema, + ClassificationData, + ClassificationDataSchema, + Detection, + DetectionRule, + DetectionRuleSchema, + DetectionSchema, + Member, + MemberDetails, + MemberSchema, + ReportExecution, + ReportExecutionSchema, + RequestedMemberDetailsResultSchema, + RequestedSbomReportResultSchema, + SbomReport, + SbomReportStorageDetails, + SbomReportStorageDetailsSchema, + ScanConfiguration, + ScanConfigurationSchema, + ScanInitializationResponse, + ScanInitializationResponseSchema, + ScanResult, + ScanResultSchema, + ScanResultsSyncFlow, + ScanResultsSyncFlowSchema, + SupportedModulesPreferences, + SupportedModulesPreferencesSchema, + UserAgentOption, + UserAgentOptionScheme, +) + +# --- DetectionSchema --- + + +def test_detection_schema_load() -> None: + raw = { + 'id': 'det-123', + 'message': 'API key exposed', + 'type': 'secret', + 'severity': 'critical', + 'detection_type_id': 'secret-1', + 'detection_details': {'alert': True, 'value': 'sk_live_xxx'}, + 'detection_rule_id': 'rule-456', + } + result = DetectionSchema().load(raw) + assert isinstance(result, Detection) + assert result.id == 'det-123' + assert result.message == 'API key exposed' + assert result.type == 'secret' + assert result.severity == 'critical' + assert result.detection_type_id == 'secret-1' + assert result.detection_details == {'alert': True, 'value': 'sk_live_xxx'} + assert result.detection_rule_id == 'rule-456' + + +def test_detection_schema_load_defaults() -> None: + raw = { + 'message': 'Vulnerability found', + 'type': 'sca', + 'detection_type_id': 'vuln-1', + 'detection_details': {}, + 'detection_rule_id': 'rule-789', + } + result = DetectionSchema().load(raw) + assert result.id is None + assert result.severity is None + + +def test_detection_schema_excludes_unknown_fields() -> None: + raw = { + 'message': 'Test', + 'type': 'test', + 'detection_type_id': 'test-1', + 'detection_details': {}, + 'detection_rule_id': 'test-rule', + 'unknown_field': 'should_be_ignored', + 'another_unknown': 123, + } + result = DetectionSchema().load(raw) + assert isinstance(result, Detection) + assert not hasattr(result, 'unknown_field') + + +def test_detection_has_alert_true() -> None: + detection = Detection( + detection_type_id='secret-1', + type='secret', + message='Key found', + detection_details={'alert': {'severity': 'high'}}, + detection_rule_id='rule-1', + ) + assert detection.has_alert is True + + +def test_detection_has_alert_false() -> None: + detection = Detection( + detection_type_id='license-1', + type='sca', + message='License issue', + detection_details={'license': 'GPL'}, + detection_rule_id='rule-2', + ) + assert detection.has_alert is False + + +def test_detection_repr() -> None: + detection = Detection( + detection_type_id='secret-1', + type='secret', + message='API key exposed', + detection_details={'value': 'sk_live_xxx'}, + detection_rule_id='rule-1', + severity='critical', + ) + repr_str = repr(detection) + assert 'secret' in repr_str + assert 'critical' in repr_str + assert 'API key exposed' in repr_str + assert 'rule-1' in repr_str + + +# --- ScanResultSchema --- + + +def test_scan_result_schema_load_with_detections() -> None: + raw = { + 'did_detect': True, + 'scan_id': 'scan-abc', + 'detections': [ + { + 'id': 'det-1', + 'message': 'Secret found', + 'type': 'secret', + 'detection_type_id': 'secret-1', + 'detection_details': {'alert': {}}, + 'detection_rule_id': 'rule-1', + } + ], + 'err': '', + } + result = ScanResultSchema().load(raw) + assert isinstance(result, ScanResult) + assert result.did_detect is True + assert result.scan_id == 'scan-abc' + assert len(result.detections) == 1 + assert isinstance(result.detections[0], Detection) + assert result.detections[0].id == 'det-1' + + +def test_scan_result_schema_load_no_detections() -> None: + raw = { + 'did_detect': False, + 'scan_id': 'scan-def', + 'detections': None, + 'err': 'No files to scan', + } + result = ScanResultSchema().load(raw) + assert result.did_detect is False + assert result.detections is None + assert result.err == 'No files to scan' + + +def test_scan_result_schema_excludes_unknown_fields() -> None: + raw = { + 'did_detect': False, + 'scan_id': 'scan-1', + 'detections': None, + 'err': '', + 'extra_field': 'ignored', + } + result = ScanResultSchema().load(raw) + assert isinstance(result, ScanResult) + + +# --- ScanInitializationResponseSchema --- + + +def test_scan_initialization_response_schema_load() -> None: + raw = {'scan_id': 'scan-init-123', 'err': ''} + result = ScanInitializationResponseSchema().load(raw) + assert isinstance(result, ScanInitializationResponse) + assert result.scan_id == 'scan-init-123' + + +# --- AuthenticationSessionSchema --- + + +def test_authentication_session_schema_load() -> None: + raw = {'session_id': 'sess-123'} + result = AuthenticationSessionSchema().load(raw) + assert isinstance(result, AuthenticationSession) + assert result.session_id == 'sess-123' + + +# --- ApiTokenSchema (tests data_key mapping) --- + + +def test_api_token_schema_load_data_key() -> None: + raw = { + 'clientId': 'client-123', + 'secret': 'secret-456', + 'description': 'My API Token', + } + result = ApiTokenSchema().load(raw) + assert isinstance(result, ApiToken) + assert result.client_id == 'client-123' + assert result.secret == 'secret-456' + assert result.description == 'My API Token' + + +# --- ApiTokenGenerationPollingResponseSchema (nested) --- + + +def test_api_token_generation_polling_schema_load() -> None: + raw = { + 'status': 'completed', + 'api_token': { + 'clientId': 'client-abc', + 'secret': 'secret-xyz', + 'description': 'Generated token', + }, + } + result = ApiTokenGenerationPollingResponseSchema().load(raw) + assert isinstance(result, ApiTokenGenerationPollingResponse) + assert result.status == 'completed' + assert isinstance(result.api_token, ApiToken) + assert result.api_token.client_id == 'client-abc' + + +def test_api_token_generation_polling_schema_load_null_token() -> None: + raw = { + 'status': 'pending', + 'api_token': None, + } + result = ApiTokenGenerationPollingResponseSchema().load(raw) + assert result.status == 'pending' + assert result.api_token is None + + +# --- SbomReportStorageDetailsSchema / ReportExecutionSchema / RequestedSbomReportResultSchema --- + + +def test_sbom_report_storage_details_schema_load() -> None: + raw = {'path': '/reports/sbom.json', 'folder': '/reports', 'size': 4096} + result = SbomReportStorageDetailsSchema().load(raw) + assert isinstance(result, SbomReportStorageDetails) + assert result.path == '/reports/sbom.json' + assert result.size == 4096 + + +def test_report_execution_schema_load() -> None: + raw = { + 'id': 1, + 'status': 'completed', + 'error_message': None, + 'status_message': 'Success', + 'storage_details': {'path': '/reports/sbom.json', 'folder': '/reports', 'size': 4096}, + } + result = ReportExecutionSchema().load(raw) + assert isinstance(result, ReportExecution) + assert result.id == 1 + assert result.status == 'completed' + assert isinstance(result.storage_details, SbomReportStorageDetails) + + +def test_requested_sbom_report_result_schema_load() -> None: + raw = { + 'report_executions': [ + { + 'id': 1, + 'status': 'completed', + 'error_message': None, + 'status_message': 'Done', + 'storage_details': {'path': '/r/sbom.json', 'folder': '/r', 'size': 1024}, + }, + { + 'id': 2, + 'status': 'failed', + 'error_message': 'Timeout', + 'status_message': None, + 'storage_details': None, + }, + ] + } + result = RequestedSbomReportResultSchema().load(raw) + assert isinstance(result, SbomReport) + assert len(result.report_executions) == 2 + assert result.report_executions[0].storage_details.path == '/r/sbom.json' + assert result.report_executions[1].error_message == 'Timeout' + assert result.report_executions[1].storage_details is None + + +# --- UserAgentOptionScheme --- + + +def test_user_agent_option_schema_load() -> None: + raw = { + 'app_name': 'vscode_extension', + 'app_version': '0.2.3', + 'env_name': 'Visual Studio Code', + 'env_version': '1.78.2', + } + result = UserAgentOptionScheme().load(raw) + assert isinstance(result, UserAgentOption) + assert result.app_name == 'vscode_extension' + assert 'vscode_extension' in result.user_agent_suffix + assert 'AppVersion: 0.2.3' in result.user_agent_suffix + + +# --- MemberSchema / RequestedMemberDetailsResultSchema --- + + +def test_member_schema_load() -> None: + raw = {'external_id': 'user-ext-123'} + result = MemberSchema().load(raw) + assert isinstance(result, Member) + assert result.external_id == 'user-ext-123' + + +def test_requested_member_details_schema_load() -> None: + raw = { + 'items': [{'external_id': 'u1'}, {'external_id': 'u2'}], + 'page_size': 50, + 'next_page_token': 'token-abc', + } + result = RequestedMemberDetailsResultSchema().load(raw) + assert isinstance(result, MemberDetails) + assert len(result.items) == 2 + assert result.page_size == 50 + assert result.next_page_token == 'token-abc' + + +def test_requested_member_details_schema_load_null_token() -> None: + raw = { + 'items': [], + 'page_size': 50, + 'next_page_token': None, + } + result = RequestedMemberDetailsResultSchema().load(raw) + assert result.next_page_token is None + + +# --- ClassificationDataSchema / DetectionRuleSchema --- + + +def test_classification_data_schema_load() -> None: + raw = {'severity': 'high'} + result = ClassificationDataSchema().load(raw) + assert isinstance(result, ClassificationData) + assert result.severity == 'high' + + +def test_detection_rule_schema_load() -> None: + raw = { + 'classification_data': [{'severity': 'high'}, {'severity': 'medium'}], + 'detection_rule_id': 'rule-123', + 'custom_remediation_guidelines': 'Rotate the key', + 'remediation_guidelines': 'See docs', + 'description': 'Exposed API key', + 'policy_name': 'secrets-policy', + 'display_name': 'API Key Exposure', + } + result = DetectionRuleSchema().load(raw) + assert isinstance(result, DetectionRule) + assert len(result.classification_data) == 2 + assert result.classification_data[0].severity == 'high' + assert result.detection_rule_id == 'rule-123' + assert result.custom_remediation_guidelines == 'Rotate the key' + + +def test_detection_rule_schema_load_optional_nulls() -> None: + raw = { + 'classification_data': [{'severity': 'low'}], + 'detection_rule_id': 'rule-456', + 'custom_remediation_guidelines': None, + 'remediation_guidelines': None, + 'description': None, + 'policy_name': None, + 'display_name': None, + } + result = DetectionRuleSchema().load(raw) + assert result.custom_remediation_guidelines is None + assert result.display_name is None + + +# --- ScanResultsSyncFlowSchema --- + + +def test_scan_results_sync_flow_schema_load() -> None: + raw = { + 'id': 'sync-123', + 'detection_messages': [{'msg': 'found secret'}, {'msg': 'found vuln'}], + } + result = ScanResultsSyncFlowSchema().load(raw) + assert isinstance(result, ScanResultsSyncFlow) + assert result.id == 'sync-123' + assert len(result.detection_messages) == 2 + + +# --- SupportedModulesPreferencesSchema --- + + +def test_supported_modules_preferences_schema_load() -> None: + raw = { + 'secret_scanning': True, + 'leak_scanning': True, + 'iac_scanning': False, + 'sca_scanning': True, + 'ci_cd_scanning': False, + 'sast_scanning': True, + 'container_scanning': False, + 'access_review': True, + 'asoc': False, + 'cimon': True, + 'ai_machine_learning': True, + 'ai_large_language_model': False, + } + result = SupportedModulesPreferencesSchema().load(raw) + assert isinstance(result, SupportedModulesPreferences) + assert result.secret_scanning is True + assert result.iac_scanning is False + assert result.ai_large_language_model is False + + +# --- ScanConfigurationSchema --- + + +def test_scan_configuration_schema_load() -> None: + raw = { + 'scannable_extensions': ['.py', '.js', '.ts'], + 'is_cycode_ignore_allowed': True, + } + result = ScanConfigurationSchema().load(raw) + assert isinstance(result, ScanConfiguration) + assert result.scannable_extensions == ['.py', '.js', '.ts'] + assert result.is_cycode_ignore_allowed is True + + +def test_scan_configuration_schema_load_defaults() -> None: + raw = { + 'scannable_extensions': None, + } + result = ScanConfigurationSchema().load(raw) + assert result.scannable_extensions is None + assert result.is_cycode_ignore_allowed is True # load_default=True diff --git a/tests/user_settings/test_activation_tracking.py b/tests/user_settings/test_activation_tracking.py new file mode 100644 index 00000000..5c8faf02 --- /dev/null +++ b/tests/user_settings/test_activation_tracking.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import pytest + +from cycode.cli.user_settings.config_file_manager import ConfigFileManager + + +@pytest.fixture +def config_manager(tmp_path: Path) -> ConfigFileManager: + return ConfigFileManager(tmp_path) + + +def test_get_last_reported_activation_versions_returns_empty_when_not_set( + config_manager: ConfigFileManager, +) -> None: + assert config_manager.get_last_reported_activation_versions() == {} + + +def test_update_and_get_last_reported_activation_version_cli(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.7'} + + +def test_update_and_get_last_reported_activation_version_plugin(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + + assert config_manager.get_last_reported_activation_versions() == {'vscode_extension': '2.0.0'} + + +def test_update_last_reported_activation_version_multiple_clients(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('jetbrains_extension', '1.5.0') + + assert config_manager.get_last_reported_activation_versions() == { + 'cli': '1.10.7', + 'vscode_extension': '2.0.0', + 'jetbrains_extension': '1.5.0', + } + + +def test_update_last_reported_activation_version_overwrites_existing(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.8'} + + +def test_update_last_reported_activation_version_does_not_affect_other_clients( + config_manager: ConfigFileManager, +) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions()['vscode_extension'] == '2.0.0' diff --git a/tests/utils/test_binary_utils.py b/tests/utils/test_binary_utils.py new file mode 100644 index 00000000..c8fa7e53 --- /dev/null +++ b/tests/utils/test_binary_utils.py @@ -0,0 +1,42 @@ +import pytest + +from cycode.cli.utils.binary_utils import is_binary_string + + +@pytest.mark.parametrize( + ('data', 'expected'), + [ + # Empty / None-ish + (b'', False), + (None, False), + # Plain ASCII text + (b'Hello, world!', False), + (b'print("hello")\nfor i in range(10):\n pass\n', False), + # Whitespace-heavy text (tabs, newlines) is not binary + (b'\t\t\n\n\r\n some text\n', False), + # UTF-8 multibyte text (accented, CJK, emoji) + ('café résumé naïve'.encode(), False), + ('日本語テキスト'.encode(), False), + ('🎉🚀💻'.encode(), False), + # BOM-marked UTF-16/32 text is not binary + ('\ufeffHello UTF-16'.encode('utf-16-le'), False), + ('\ufeffHello UTF-16'.encode('utf-16-be'), False), + ('\ufeffHello UTF-32'.encode('utf-32-le'), False), + ('\ufeffHello UTF-32'.encode('utf-32-be'), False), + # Null bytes → binary + (b'\x00', True), + (b'hello\x00world', True), + (b'\x00\x01\x02\x03', True), + # 0xff in otherwise normal data → binary + (b'hello\xffworld', True), + # Mostly control chars + invalid UTF-8 → binary + (b'\x01\x02\x03\x04\x05\x06\x07\x0e\x0f\x10' * 10 + b'\x80', True), + # Real binary format headers + (b'\x89PNG\r\n\x1a\n' + b'\x00' * 100, True), + (b'\x7fELF' + b'\x00' * 100, True), + # DS_Store-like: null-byte-heavy valid UTF-8 → still binary + (b'\x00\x00\x00\x01Bud1' + b'\x00' * 100, True), + ], +) +def test_is_binary_string(data: bytes, expected: bool) -> None: + assert is_binary_string(data) is expected diff --git a/tests/utils/test_url_utils.py b/tests/utils/test_url_utils.py new file mode 100644 index 00000000..f7f6b6b0 --- /dev/null +++ b/tests/utils/test_url_utils.py @@ -0,0 +1,80 @@ +from cycode.cli.utils.url_utils import sanitize_repository_url + + +def test_sanitize_repository_url_with_token() -> None: + """Test that PAT tokens are removed from HTTPS URLs.""" + url = 'https://token@github.com/user/repo.git' + expected = 'https://github.com/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_with_username_and_token() -> None: + """Test that username and token are removed from HTTPS URLs.""" + url = 'https://user:token@github.com/user/repo.git' + expected = 'https://github.com/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_with_port() -> None: + """Test that URLs with ports are handled correctly.""" + url = 'https://token@github.com:443/user/repo.git' + expected = 'https://github.com:443/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_ssh_format() -> None: + """Test that SSH URLs are returned as-is (no credentials in URL format).""" + url = 'git@github.com:user/repo.git' + assert sanitize_repository_url(url) == url + + +def test_sanitize_repository_url_ssh_protocol() -> None: + """Test that ssh:// URLs are returned as-is.""" + url = 'ssh://git@github.com/user/repo.git' + assert sanitize_repository_url(url) == url + + +def test_sanitize_repository_url_no_credentials() -> None: + """Test that URLs without credentials are returned unchanged.""" + url = 'https://github.com/user/repo.git' + assert sanitize_repository_url(url) == url + + +def test_sanitize_repository_url_none() -> None: + """Test that None input returns None.""" + assert sanitize_repository_url(None) is None + + +def test_sanitize_repository_url_empty_string() -> None: + """Test that empty string is returned as-is.""" + assert sanitize_repository_url('') == '' + + +def test_sanitize_repository_url_gitlab() -> None: + """Test that GitLab URLs are sanitized correctly.""" + url = 'https://oauth2:token@gitlab.com/user/repo.git' + expected = 'https://gitlab.com/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_bitbucket() -> None: + """Test that Bitbucket URLs are sanitized correctly.""" + url = 'https://x-token-auth:token@bitbucket.org/user/repo.git' + expected = 'https://bitbucket.org/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_with_path_and_query() -> None: + """Test that URLs with paths, query params, and fragments are preserved.""" + url = 'https://token@github.com/user/repo.git?ref=main#section' + expected = 'https://github.com/user/repo.git?ref=main#section' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_invalid_url() -> None: + """Test that invalid URLs are returned as-is (graceful degradation).""" + # This should not raise an exception, but return the original + url = 'not-a-valid-url' + result = sanitize_repository_url(url) + # Should return original since parsing fails + assert result == url