From c2bea871fa44891635b0b80bdf8636b900c41a5d Mon Sep 17 00:00:00 2001 From: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com> Date: Wed, 16 Jul 2025 07:45:17 -0700 Subject: [PATCH 1/4] fix: local build failures due to license file --- crates/bitwarden-py/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/bitwarden-py/Cargo.toml b/crates/bitwarden-py/Cargo.toml index 95b8b6a30..40163db55 100644 --- a/crates/bitwarden-py/Cargo.toml +++ b/crates/bitwarden-py/Cargo.toml @@ -8,7 +8,6 @@ edition.workspace = true rust-version.workspace = true homepage.workspace = true repository.workspace = true -license-file.workspace = true keywords.workspace = true [lib] From b2881a71cb01d7ad610828770d2a0d8d6dddf1c5 Mon Sep 17 00:00:00 2001 From: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com> Date: Wed, 16 Jul 2025 07:46:26 -0700 Subject: [PATCH 2/4] feat: local and CI tests --- .github/workflows/test-python-wheels.yml | 19 --- .github/workflows/test-python.yml | 97 ++++++++++++++ languages/python/README.md | 3 +- languages/python/setup.sh | 2 + languages/python/test.sh | 100 ++++++++++++++ languages/python/test/crud.py | 161 +++++++++++++++++++++++ scripts/bootstrap.sh | 149 +++++++++++++++++++++ 7 files changed, 511 insertions(+), 20 deletions(-) delete mode 100644 .github/workflows/test-python-wheels.yml create mode 100644 .github/workflows/test-python.yml create mode 100755 languages/python/setup.sh create mode 100755 languages/python/test.sh create mode 100755 languages/python/test/crud.py create mode 100755 scripts/bootstrap.sh diff --git a/.github/workflows/test-python-wheels.yml b/.github/workflows/test-python-wheels.yml deleted file mode 100644 index 133c68852..000000000 --- a/.github/workflows/test-python-wheels.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Test Python Wheels - -on: - workflow_call: - -defaults: - run: - shell: bash - -permissions: - contents: read - -jobs: - test: - name: This is just a stub workflow to get this into main - runs-on: ubuntu-24.04 - steps: - - name: Run tests - run: echo "This is a stub workflow for testing Python wheels." diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml new file mode 100644 index 000000000..e9b8dab8d --- /dev/null +++ b/.github/workflows/test-python.yml @@ -0,0 +1,97 @@ +name: Test Python SDK + +on: + push: + branches: + - "main" + - "rc" + - "hotfix-rc" + paths: + - "languages/python/**" + - "crates/bitwarden/**" + - "crates/bitwarden-py/**" + - "crates/fake-server/**" + - ".github/workflows/test-python.yml" + pull_request: + types: [opened, synchronize] + paths: + - "languages/python/**" + - "crates/bitwarden/**" + - "crates/bitwarden-py/**" + - "crates/fake-server/**" + - ".github/workflows/test-python.yml" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +defaults: + run: + shell: bash + +jobs: + test: + name: Test Python SDK + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + # FIXME: https://gist.github.com/tangowithfoxtrot/9303dac6001b753403e396ddc2acb1c4#file-windows-python-error-log-L1282 + # - windows-latest + + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + exclude: + # Skip some combinations to reduce CI time + - os: macos-latest + python-version: "3.10" + - os: macos-latest + python-version: "3.11" + + steps: + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.3.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1 + + - name: Set up Node.js + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: "18" + cache: "npm" + + - name: Set up Rust + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + with: + toolchain: stable + + - name: Cache Rust dependencies + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install system dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y build-essential + + - name: Setup Python SDK + run: ./scripts/bootstrap.sh setup python + + - name: Run Python SDK tests + run: ./scripts/bootstrap.sh test python diff --git a/languages/python/README.md b/languages/python/README.md index 29f3f97fb..1de489b3b 100644 --- a/languages/python/README.md +++ b/languages/python/README.md @@ -3,8 +3,9 @@ - Python 3 - Rust -- `maturin` (install with `pip install maturin`) +- `maturin` (install with `pip install maturin`, or `pip install maturin[patchelf]` on Linux) - `npm` +- `uv` (recommended; used in test automation) ## Build diff --git a/languages/python/setup.sh b/languages/python/setup.sh new file mode 100755 index 000000000..ae4ea170c --- /dev/null +++ b/languages/python/setup.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "No setup required" diff --git a/languages/python/test.sh b/languages/python/test.sh new file mode 100755 index 000000000..83cc71a37 --- /dev/null +++ b/languages/python/test.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1090 +set -euo pipefail + +TMP_DIR="$(mktemp -d)" +PYTHON_VERSIONS="${PYTHON_VERSIONS:-3.13}" + +echo "Running Python SDK tests..." + +check_requirements() { + echo "Checking requirements..." + + if ! command -v python3 >/dev/null 2>&1; then + echo "Error: python3 is required but not installed" >&2 + exit 1 + fi + + if ! command -v uv >/dev/null 2>&1; then + echo "Error: uv is required but not installed" >&2 + exit 1 + fi + + if [ ! -f "bitwarden_sdk/schemas.py" ]; then + echo "Error: schemas.py not found. Please run ./setup.sh first" + exit 1 + fi + + echo "✓ All requirements met" +} + +source_venv() { + # for Windows compatibility... + set -x + source "$TMP_DIR/.venv-$python_version/bin/activate" || source "$TMP_DIR/.venv-$python_version/Scripts/activate" || { + echo "Error: Failed to activate virtual environment for $python_version" >&2 + ls -halR "$TMP_DIR/.venv-$python_version" || true + exit 1 + } + set +x + +} + +# Install Python dependencies +setup_python_environment() { + python_version=$1 + echo "Setting up Python virtual environment $python_version..." + + # Create virtual environment if it doesn't exist + if [ ! -d "$TMP_DIR/.venv-$python_version" ]; then + uv venv "$TMP_DIR/.venv-$python_version" --python "$python_version" + echo "✓ Created Python virtual environment for $python_version" + fi + + # Activate virtual environment + source_venv + + # Upgrade pip and install maturin + uv pip install --upgrade pip +} + +# Build the Python package +build_package() { + python_version=$1 + echo "Building Python package for $python_version..." + + # Activate virtual environment + source_venv + + # Build the package in development mode + if [ "$(uname -s)" = "Linux" ]; then + # Linux requires patchelf for binary compatibility + uv pip install .[dev-linux] + else + uv pip install .[dev] + fi + echo "✓ Built Python package" +} + +# Run the CRUD test script +run_crud_test() { + echo "Running CRUD test for Python $python_version..." + + # Activate virtual environment + source_venv + + # Run the CRUD test + python3 test/crud.py +} + +# Main test function +main() { + check_requirements + for python_version in $PYTHON_VERSIONS; do + setup_python_environment "$python_version" + build_package "$python_version" + run_crud_test "$python_version" + done +} + +main "$@" diff --git a/languages/python/test/crud.py b/languages/python/test/crud.py new file mode 100755 index 000000000..570d5ac23 --- /dev/null +++ b/languages/python/test/crud.py @@ -0,0 +1,161 @@ +import logging +import uuid +import os +import sys +from datetime import datetime, timezone + +from bitwarden_sdk import BitwardenClient, DeviceType, client_settings_from_dict + +# Uncomment for logging +# logging.basicConfig(level=logging.DEBUG) + +# Create the BitwardenClient, which is used to interact with the SDK +client = BitwardenClient( + client_settings_from_dict( + { + "apiUrl": os.getenv("API_URL", "http://localhost:4000"), + "deviceType": DeviceType.SDK, + "identityUrl": os.getenv("IDENTITY_URL", "http://localhost:33656"), + "userAgent": "Python", + } + ) +) + +organization_id = os.getenv("ORGANIZATION_ID") + +# Note: the path must exist, the file will be created & managed by the sdk +state_path = os.getenv("STATE_FILE") + +# Attempt to authenticate with the Secrets Manager Access Token +client.auth().login_access_token(os.getenv("ACCESS_TOKEN"), state_path) + +# Track test failures +test_failures = 0 + +def run_test(operation_name, test_func): + global test_failures + try: + result = test_func() + if result: + print(f"✅ python {operation_name}") + else: + print(f"❌ python {operation_name}") + test_failures += 1 + except Exception as e: + print(f"❌ python {operation_name} - Error: {e}") + test_failures += 1 + + +def secrets(): + def test_secret_list(): + secrets_list = client.secrets().list(organization_id) + return secrets_list.data.data + + def test_secret_get(): + secret = client.secrets().get(uuid.uuid4()) + return secret.data.key == "btw" + + def test_secret_create(): + secret = client.secrets().create( + organization_id, + "secret-key", + "secret-value", + "optional note", + [], + ) + return "secret-key" in secret.data.key + + def test_secret_edit(): + secret = client.secrets().create( + organization_id, + "something-new", + "new-value", + "updated note", + [uuid.uuid4()], + ) + return "something-new" in secret.data.key + + def test_secret_get_by_ids(): + secrets_retrieved = client.secrets().get_by_ids([uuid.uuid4(), uuid.uuid4(), uuid.uuid4()]) + return secrets_retrieved.data.data[0].key == "FERRIS" + + def test_secret_sync(): + sync_response = client.secrets().sync(organization_id, None) + last_synced_date = datetime.now(tz=timezone.utc) + + if sync_response.data.has_changes is False: + # this should fail because there SHOULD be changes + return False + + sync_response = client.secrets().sync(organization_id, last_synced_date) + if sync_response.data.has_changes is True: + # this should fail because there should NOT be changes + return False + + return True + + + + def test_secret_delete(): + result = client.secrets().delete([uuid.uuid4(), uuid.uuid4(), uuid.uuid4()]) + return result.success is True + + run_test("secret list", test_secret_list) + run_test("secret get", test_secret_get) + run_test("secret create", test_secret_create) + run_test("secret edit", test_secret_edit) + run_test("secret get_by_ids", test_secret_get_by_ids) + run_test("secret sync", test_secret_sync) + run_test("secret delete", test_secret_delete) + + +def projects(): + def test_project_list(): + projects_list = client.projects().list(organization_id) + return projects_list.data.data[0].name == "Production Environment" + + def test_project_get(): + project = client.projects().get(uuid.uuid4()) + return project.data.name == "Production Environment" + + def test_project_create(): + project = client.projects().create(organization_id, "TEST_PROJECT") + return "TEST_PROJECT" in project.data.name + + def test_project_edit(): + updated = client.projects().update( + organization_id, + uuid.uuid4(), + "new-project-name" + ) + return "new-project-name" in updated.data.name + + def test_project_delete(): + result = client.projects().delete([uuid.uuid4(), uuid.uuid4()]) + return result.success is True + + run_test("project list", test_project_list) + run_test("project get", test_project_get) + run_test("project create", test_project_create) + run_test("project edit", test_project_edit) + run_test("project delete", test_project_delete) + + +def main(): + print("Testing secrets...") + secrets() + print() + + print("Testing projects...") + projects() + + if test_failures > 0: + print(f"\n❌ {test_failures} test(s) failed") + sys.exit(1) + else: + print(f"\n✅ All tests passed") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 000000000..1740d4d82 --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +TMP_DIR="$(mktemp -d)" + +# This access token is only used for testing purposes with the fake server +export ORGANIZATION_ID="ec2c1d46-6a4b-4751-a310-af9601317f2d" +export ACCESS_TOKEN="0.${ORGANIZATION_ID}.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==" + +export SERVER_URL="http://localhost:${SM_FAKE_SERVER_PORT:-3000}" +export API_URL="${SERVER_URL}/api" +export IDENTITY_URL="${SERVER_URL}/identity" +export STATE_FILE="${TMP_DIR}/state" + +# input: bws, or any of the lanaguages in ./languages +# output: a build directory +build_directory() { + local language="$1" + + if [ "$language" = "bws" ]; then + echo "$REPO_ROOT/crates/bws" + else + echo "$REPO_ROOT/languages/$language" + fi +} + +common_setup() { + npm install >/dev/null + npm run schemas >/dev/null + cargo build --quiet --release >/dev/null +} + +# Start fake server in background +start_fake_server() { + local port="${SM_FAKE_SERVER_PORT:-3000}" + + # Check if server is already running + if curl -s "http://localhost:$port/health" >/dev/null 2>&1; then + echo "✓ Fake server already running on port $port" + return 0 + fi + + echo "Starting fake server on port $port..." + cargo build --bin fake-server >/dev/null + SM_FAKE_SERVER_PORT="$port" cargo run --bin fake-server >/dev/null 2>&1 & + FAKE_SERVER_PID=$! + + # Wait for server to be ready + local max_attempts=30 + local attempt=0 + while [ $attempt -lt $max_attempts ]; do + if curl -s "http://localhost:$port/health" >/dev/null 2>&1; then + echo "✓ Fake server is ready" + return 0 + fi + sleep 1 + attempt=$((attempt + 1)) + done + + echo "Error: Fake server failed to start within 30 seconds" + kill $FAKE_SERVER_PID 2>/dev/null || true + exit 1 +} + +# Stop fake server +stop_fake_server() { + if [ -n "${FAKE_SERVER_PID:-}" ]; then + echo "Stopping fake server..." + kill "$FAKE_SERVER_PID" 2>/dev/null || true + wait "$FAKE_SERVER_PID" 2>/dev/null || true + fi +} + +# Cleanup function +cleanup() { + stop_fake_server +} + +main() { + local action="$1" + local language="$2" + local dir + + dir="$(build_directory "$language")" + + # Set up cleanup trap + trap cleanup EXIT + + case "$action" in + all) + common_setup + pushd "$dir" >/dev/null || { + echo "Failed to change directory to $dir" + exit 1 + } + ./setup.sh + start_fake_server + ./test.sh + popd >/dev/null || { + echo "Failed to return to previous directory" + exit 1 + } + ;; + setup) + common_setup + + # Check if setup.sh exists in $dir + if [ ! -f "$dir/setup.sh" ]; then + echo "Error: setup.sh not found in $dir" + exit 1 + fi + + pushd "$dir" >/dev/null || { + echo "Failed to change directory to $dir" + exit 1 + } + ./setup.sh + popd >/dev/null || { + echo "Failed to return to previous directory" + exit 1 + } + ;; + test) + if [ ! -f "$dir/test.sh" ]; then + echo "Error: test.sh not found in $dir" + exit 1 + fi + + pushd "$dir" >/dev/null || { + echo "Failed to change directory to $dir" + exit 1 + } + start_fake_server + ./test.sh + popd >/dev/null || { + echo "Failed to return to previous directory" + exit 1 + } + ;; + *) + echo "Usage: $0 {all|setup|test} " + echo "Available languages: bws, python, csharp, java, js, etc." + exit 1 + ;; + esac +} + +main "$@" From a8917345ef7b4e724f5fe04948c0d48e727627c4 Mon Sep 17 00:00:00 2001 From: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com> Date: Wed, 16 Jul 2025 08:23:12 -0700 Subject: [PATCH 3/4] chore: add dev dependencies --- languages/python/pyproject.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/languages/python/pyproject.toml b/languages/python/pyproject.toml index a4ab1f693..c69c08624 100644 --- a/languages/python/pyproject.toml +++ b/languages/python/pyproject.toml @@ -19,6 +19,18 @@ readme = "README.md" requires-python = ">=3.0" version = "1.0.0" +[project.optional-dependencies] +dev = [ + "maturin >= 1.0,<2.0", + "uv >= 0.6,<1.0", +] + +dev-linux = [ + "maturin >= 1.0,<2.0", + "patchelf >= 0.17.0", # only needed for Linux + "uv >= 0.6,<1.0", +] + [tool.maturin] bindings = "pyo3" compatibility = "2_28" From c36e9121dc669f8dc1fbafc744a62313592ffef5 Mon Sep 17 00:00:00 2001 From: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:59:03 -0700 Subject: [PATCH 4/4] fix: try using maturin default compatibility settings to fix segfaults --- languages/python/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/languages/python/pyproject.toml b/languages/python/pyproject.toml index c69c08624..afa7f7190 100644 --- a/languages/python/pyproject.toml +++ b/languages/python/pyproject.toml @@ -33,7 +33,6 @@ dev-linux = [ [tool.maturin] bindings = "pyo3" -compatibility = "2_28" include = [ { path = "bitwarden_sdk/*.py", format = ["sdist", "wheel"] } ]