on:
  push:
    branches: [main, release]
  pull_request:
    types: [unlabeled, opened, synchronize, reopened]
  merge_group:
  workflow_dispatch:

name: CI

# Cancel previous workflows if they are the same workflow on same ref (branch/tags)
# with the same event (push/pull_request) even they are in progress.
# This setting will help reduce the number of duplicated workflows.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
  cancel-in-progress: true

env:
  CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls,host_env
  CARGO_ARGS_NO_SSL: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,host_env
  # Crates excluded from workspace builds:
  # - rustpython_wasm: requires wasm target
  # - rustpython-compiler-source: deprecated
  # - rustpython-venvlauncher: Windows-only
  WORKSPACE_EXCLUDES: --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher
  # Python version targeted by the CI.
  PYTHON_VERSION: "3.14.3"
  X86_64_PC_WINDOWS_MSVC_OPENSSL_LIB_DIR: C:\Program Files\OpenSSL\lib\VC\x64\MD
  X86_64_PC_WINDOWS_MSVC_OPENSSL_INCLUDE_DIR: C:\Program Files\OpenSSL\include

jobs:
  rust_tests:
    if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }}
    env:
      RUST_BACKTRACE: full
    name: Run rust tests
    runs-on: ${{ matrix.os }}
    timeout-minutes: 45
    strategy:
      matrix:
        os: [macos-latest, ubuntu-latest, windows-2025]
      fail-fast: false
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy

      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
        with:
          save-if: ${{ github.ref == 'refs/heads/main' }}

      - name: Install macOS dependencies
        uses: ./.github/actions/install-macos-deps

      - name: run clippy
        run: cargo clippy ${{ env.CARGO_ARGS }} --workspace --all-targets ${{ env.WORKSPACE_EXCLUDES }} -- -Dwarnings

      - name: run rust tests
        run: cargo test --workspace ${{ env.WORKSPACE_EXCLUDES }} --verbose --features threading ${{ env.CARGO_ARGS }}

      - name: check compilation without threading
        run: cargo check ${{ env.CARGO_ARGS }}

      - name: check compilation without host_env (sandbox mode)
        run: |
          cargo check -p rustpython-vm --no-default-features --features compiler
          cargo check -p rustpython-stdlib --no-default-features --features compiler
          cargo build --no-default-features --features stdlib,importlib,stdio,encodings,freeze-stdlib
        if: runner.os == 'Linux'

      - name: sandbox smoke test
        run: |
          target/debug/rustpython extra_tests/snippets/sandbox_smoke.py
          target/debug/rustpython extra_tests/snippets/stdlib_re.py
        if: runner.os == 'Linux'

      - name: Test openssl build
        run: cargo build --no-default-features --features ssl-openssl
        if: runner.os == 'Linux'

      # - name: Install tk-dev for tkinter build
      #   run: sudo apt-get update && sudo apt-get install -y tk-dev
      #   if: runner.os == 'Linux'

      # - name: Test tkinter build
      #   run: cargo build --features tkinter
      #   if: runner.os == 'Linux'

      - name: Test example projects
        run: |
          cargo run --manifest-path example_projects/barebone/Cargo.toml
          cargo run --manifest-path example_projects/frozen_stdlib/Cargo.toml
        if: runner.os == 'Linux'

      - name: run update_lib tests
        run: cargo run -- -m unittest discover -s scripts/update_lib/tests -v
        env:
          PYTHONPATH: scripts
        if: runner.os == 'Linux'

  cargo_check:
    if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }}
    name: Ensure compilation on various targets
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include: 
        - os: ubuntu-latest
          targets:
            - aarch64-linux-android
            - i686-unknown-linux-gnu
            - i686-unknown-linux-musl
            - wasm32-wasip2
            - x86_64-unknown-freebsd
          dependencies:
            gcc-multilib: true
            musl-tools: true
        - os: ubuntu-latest
          targets:
            - aarch64-unknown-linux-gnu
          dependencies:
            gcc-aarch64-linux-gnu: true # conflict with `gcc-multilib`
        - os: macos-latest
          targets:
            - aarch64-apple-ios
            - x86_64-apple-darwin
      fail-fast: false
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
        with:
          prefix-key: v0-rust-${{ join(matrix.targets, '-') }}
          save-if: ${{ github.ref == 'refs/heads/main' }}

      - name: Install dependencies
        uses: ./.github/actions/install-linux-deps
        # zizmor has an issue with dynamic `with`
        # with: ${{ matrix.dependencies || fromJSON('{}') }}
        with:
          gcc-multilib: ${{ matrix.dependencies.gcc-multilib || false }}
          musl-tools: ${{ matrix.dependencies.musl-tools || false }}
          gcc-aarch64-linux-gnu: ${{ matrix.dependencies.gcc-aarch64-linux-gnu || false }}

      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ join(matrix.targets, ',') }}

      - name: Setup Android NDK
        if: ${{ contains(matrix.targets, 'aarch64-linux-android') }}
        id: setup-ndk
        uses: nttld/setup-ndk@v1
        with:
          ndk-version: r27
          add-to-path: true

      # - name: Prepare repository for redox compilation
      #   run: bash scripts/redox/uncomment-cargo.sh
      # - name: Check compilation for Redox
      #   uses: coolreader18/redoxer-action@v1
      #   with:
      #     command: check
      #     args: --ignore-rust-version

      - name: Check compilation
        run: |
          for target in ${{ join(matrix.targets, ' ') }}
          do
            echo "::group::${target}"
            cargo check --target $target ${{ env.CARGO_ARGS_NO_SSL }}
            echo "::endgroup::"
          done
        env:
          CC_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang
          AR_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar
          CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang

  snippets_cpython:
    if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }}
    env:
      RUST_BACKTRACE: full
      # Tests that can be flaky when running with multiple processes `-j 2`. We will use `-j 1` for these.
      FLAKY_MP_TESTS: >-
        test_class
        test_eintr
        test_multiprocessing_fork
        test_multiprocessing_forkserver
        test_multiprocessing_spawn
    name: Run snippets and cpython tests
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
        - os: macos-latest
          extra_test_args:
            - '-u all'
          env_polluting_tests: []
          skips: []
          timeout: 50
        - os: ubuntu-latest
          extra_test_args:
            - '-u all'
          env_polluting_tests: []
          skips: []
          timeout: 60
        - os: windows-2025
          extra_test_args: [] # TODO: Enable '-u all'
          env_polluting_tests: []
          skips:
            - test_rlcompleter
            - test_pathlib # panic by surrogate chars
            - test_posixpath # OSError: (22, 'The filename, directory name, or volume label syntax is incorrect. (os error 123)')
            - test_venv # couple of failing tests
          timeout: 50
      fail-fast: false
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - uses: dtolnay/rust-toolchain@stable

      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
        with:
          save-if: ${{ github.ref == 'refs/heads/main' }}

      - uses: actions/setup-python@v6.2.0
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install macOS dependencies
        uses: ./.github/actions/install-macos-deps
        with:
          openssl: true

      - name: build rustpython
        run: cargo build --release --verbose --features=threading,jit ${{ env.CARGO_ARGS }}

      - name: run snippets
        run: python -m pip install -r requirements.txt && pytest -v
        working-directory: ./extra_tests

      - name: Detect available cores
        id: cores
        shell: bash
        run: |
          cores=$(python -c 'print(__import__("os").process_cpu_count())')
          echo "cores=${cores}" >> $GITHUB_OUTPUT

      - name: Run CPython tests
        run: |
          target/release/rustpython -m test -j ${{ steps.cores.outputs.cores }} ${{ join(matrix.extra_test_args, ' ') }} --slowest --fail-env-changed --timeout 600 -v -x ${{ env.FLAKY_MP_TESTS }} ${{ join(matrix.skips, ' ') }}
        timeout-minutes: ${{ matrix.timeout }}
        env:
          RUSTPYTHON_SKIP_ENV_POLLUTERS: true

      - name: Run flaky MP CPython tests
        run: |
          target/release/rustpython -m test -j 1 ${{ join(matrix.extra_test_args, ' ') }} --slowest --fail-env-changed --timeout 600 -v ${{ env.FLAKY_MP_TESTS }}
        timeout-minutes: ${{ matrix.timeout }}
        env:
          RUSTPYTHON_SKIP_ENV_POLLUTERS: true

      - name: run cpython tests to check if env polluters have stopped polluting
        shell: bash
        run: |
          for thing in ${{ join(matrix.env_polluting_tests, ' ') }}; do
            for i in $(seq 1 10); do
              set +e
              target/release/rustpython -m test -j 1 --slowest --fail-env-changed --timeout 600 -v ${thing}
              exit_code=$?
              set -e
              if [ ${exit_code} -eq 3 ]; then
                echo "Test ${thing} polluted the environment on attempt ${i}."
                break
              fi
            done
            if [ ${exit_code} -ne 3 ]; then
              echo "Test ${thing} is no longer polluting the environment after ${i} attempts!"
              echo "Please remove ${thing} from matrix.env_polluting_tests in '.github/workflows/ci.yaml'."
              echo "Please also remove the skip decorators that include the word 'POLLUTERS' in ${thing}."
              if [ ${exit_code} -ne 0 ]; then
                echo "Test ${thing} failed with exit code ${exit_code}."
                echo "Please investigate which test item in ${thing} is failing and either mark it as an expected failure or a skip."
              fi
              exit 1
            fi
          done
        timeout-minutes: 15

      - if: runner.os != 'Windows'
        name: check that --install-pip succeeds
        run: |
          mkdir site-packages
          target/release/rustpython --install-pip ensurepip --user
          target/release/rustpython -m pip install six

      - name: Check that ensurepip succeeds.
        run: |
          target/release/rustpython -m ensurepip
          target/release/rustpython -c "import pip"

      - if: runner.os != 'Windows'
        name: Check if pip inside venv is functional
        run: |
          target/release/rustpython -m venv testvenv
          testvenv/bin/rustpython -m pip install wheel

      - name: Check whats_left is not broken
        shell: bash
        run: python -I scripts/whats_left.py ${{ env.CARGO_ARGS }} --features jit

  lint:
    name: Lint Rust & Python code
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - uses: actions/setup-python@v6.2.0
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Check for redundant test patches
        run: python scripts/check_redundant_patches.py

      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy

      - name: run clippy on wasm
        run: cargo clippy --manifest-path=crates/wasm/Cargo.toml -- -Dwarnings

      - name: Ensure docs generate no warnings
        run: cargo doc --locked

      - name: Ensure Lib/_opcode_metadata is updated
        run: |
          python scripts/generate_opcode_metadata.py
          if [ -n "$(git status --porcelain)" ]; then
            exit 1
          fi

      - name: Install ruff
        uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
        with:
          version: "0.15.5"
          args: "--version"

      - run: ruff check --diff

      - run: ruff format --check

      - name: install prettier
        run: |
          yarn global add prettier
          yarn global bin >> "$GITHUB_PATH"

      - name: check wasm code with prettier
        # prettier doesn't handle ignore files very well: https://github.com/prettier/prettier/issues/8506
        run: cd wasm && git ls-files -z | xargs -0 prettier --check -u
      # Keep cspell check as the last step. This is optional test.
      - name: install extra dictionaries
        run: npm install @cspell/dict-en_us @cspell/dict-cpp @cspell/dict-python @cspell/dict-rust @cspell/dict-win32 @cspell/dict-shell
      - name: spell checker
        uses: streetsidesoftware/cspell-action@v8
        with:
          files: "**/*.rs"
          incremental_files_only: true

  miri:
    if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }}
    name: Run tests under miri
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      NIGHTLY_CHANNEL: nightly
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - uses: dtolnay/rust-toolchain@master
        with:
          toolchain: ${{ env.NIGHTLY_CHANNEL }}
          components: miri

      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
        with:
          save-if: ${{ github.ref == 'refs/heads/main' }}

      - name: Run tests under miri
        run: cargo +${{ env.NIGHTLY_CHANNEL }} miri test -p rustpython-vm -- miri_test
        env:
          # miri-ignore-leaks because the type-object circular reference means that there will always be
          # a memory leak, at least until we have proper cyclic gc
          MIRIFLAGS: "-Zmiri-ignore-leaks"

  wasm:
    if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }}
    name: Check the WASM package and demo
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - uses: dtolnay/rust-toolchain@stable

      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
        with:
          save-if: ${{ github.ref == 'refs/heads/main' }}

      - name: install wasm-pack
        run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
      - name: install geckodriver
        run: |
          wget https://github.com/mozilla/geckodriver/releases/download/v0.36.0/geckodriver-v0.36.0-linux64.tar.gz
          mkdir geckodriver
          tar -xzf geckodriver-v0.36.0-linux64.tar.gz -C geckodriver
      - uses: actions/setup-python@v6.2.0
        with:
          python-version: ${{ env.PYTHON_VERSION }}
      - run: python -m pip install -r requirements.txt
        working-directory: ./wasm/tests
      - uses: actions/setup-node@v6
        with:
          cache: "npm"
          cache-dependency-path: "wasm/demo/package-lock.json"
      - name: run test
        run: |
          driver_path="$(pwd)/../../geckodriver"
          export PATH="$PATH:${driver_path}"
          npm install
          npm run test
        env:
          NODE_OPTIONS: "--openssl-legacy-provider"
        working-directory: ./wasm/demo
      - uses: mwilliamson/setup-wabt-action@v3
        with: { wabt-version: "1.0.36" }
      - name: check wasm32-unknown without js
        run: |
          cd example_projects/wasm32_without_js/rustpython-without-js
          cargo build
          cd ..
          if wasm-objdump -xj Import rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm; then
            echo "ERROR: wasm32-unknown module expects imports from the host environment" >&2
          fi
          cargo run --release --manifest-path wasm-runtime/Cargo.toml rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm
      - name: build notebook demo
        if: github.ref == 'refs/heads/release'
        run: |
          npm install
          npm run dist
          mv dist ../demo/dist/notebook
        env:
          NODE_OPTIONS: "--openssl-legacy-provider"
        working-directory: ./wasm/notebook
      - name: Deploy demo to Github Pages
        if: success() && github.ref == 'refs/heads/release'
        uses: peaceiris/actions-gh-pages@v4
        env:
          ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEMO_DEPLOY_KEY }}
          PUBLISH_DIR: ./wasm/demo/dist
          EXTERNAL_REPOSITORY: RustPython/demo
          PUBLISH_BRANCH: master

  wasm-wasi:
    if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }}
    name: Run snippets and cpython tests on wasm-wasi
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - uses: dtolnay/rust-toolchain@stable
        with:
          target: wasm32-wasip1

      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
        with:
          save-if: ${{ github.ref == 'refs/heads/main' }}

      - name: Setup Wasmer
        uses: wasmerio/setup-wasmer@v3

      - name: Install clang
        uses: ./.github/actions/install-linux-deps
        with:
          clang: true

      - name: build rustpython
        run: cargo build --release --target wasm32-wasip1 --features freeze-stdlib,stdlib --verbose
      - name: run snippets
        run: wasmer run --dir $(pwd) target/wasm32-wasip1/release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_random.py"
      - name: run cpython unittest
        run: wasmer run --dir $(pwd) target/wasm32-wasip1/release/rustpython.wasm -- "$(pwd)/Lib/test/test_int.py"

  cargo-shear:
    name: cargo shear
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - uses: cargo-bins/cargo-binstall@1800853f2578f8c34492ec76154caef8e163fbca # v1.17.7

      - run: cargo binstall --no-confirm cargo-shear

      - run: cargo shear

  security-lint:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Run zizmor
        uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
