diff --git a/.env.example b/.env.example index 6cddda2..749c598 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,22 @@ # Claude CLI Configuration CLAUDE_CLI_PATH=claude +# Authentication Method (optional - explicit selection) +# Set this to override auto-detection. Values: cli, api_key, bedrock, vertex +# If not set, auto-detects based on available env vars (ANTHROPIC_API_KEY, etc.) +# CLAUDE_AUTH_METHOD=cli + # API Configuration # If API_KEY is not set, server will prompt for interactive API key protection on startup # Leave commented out to enable interactive prompt, or uncomment to use a fixed API key # API_KEY=your-optional-api-key-here + +# Server Configuration PORT=8000 +# Host binding address - use 127.0.0.1 for local-only access, 0.0.0.0 for all interfaces +# CLAUDE_WRAPPER_HOST=0.0.0.0 +# Maximum request body size in bytes (default: 10MB) +# MAX_REQUEST_SIZE=10485760 # Timeout Configuration (milliseconds) MAX_TIMEOUT=600000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7df474a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + + - name: Install project + run: poetry install --no-interaction + + - name: Run linting + run: poetry run black --check src tests + + - name: Type checking + run: poetry run mypy src --ignore-missing-imports + continue-on-error: true + + - name: Security scan + run: poetry run bandit -r src/ -ll -x tests + + - name: Dependency vulnerability scan + run: poetry run safety check || true + continue-on-error: true + + - name: Run tests + run: poetry run pytest tests/ -v --cov=src --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false diff --git a/README.md b/README.md index 8fbd42b..72ee5c4 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,9 @@ Env vars override defaults and can be set at runtime with `-e` flags or in `dock - **Core Server Settings**: - `PORT=9000`: Changes the internal listening port (default: 8000; update port mapping accordingly). + - `CLAUDE_WRAPPER_HOST=127.0.0.1`: Sets the host binding address (default: 0.0.0.0 for all interfaces; use 127.0.0.1 for local-only access). - `MAX_TIMEOUT=600`: Sets the request timeout in seconds (default: 300; increase for complex Claude queries). + - `MAX_REQUEST_SIZE=10485760`: Maximum request body size in bytes (default: 10MB; increase for large payloads). - `CLAUDE_CWD=/path/to/workspace`: Sets Claude Code's working directory (default: isolated temp directory for security). - **Authentication and Providers**: diff --git a/poetry.lock b/poetry.lock index b039ce4..03d8e92 100644 --- a/poetry.lock +++ b/poetry.lock @@ -47,6 +47,104 @@ files = [ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] +[[package]] +name = "authlib" +version = "1.6.6" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"}, + {file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"}, +] + +[package.dependencies] +cryptography = "*" + +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +description = "Backport of Python 3.11's datetime.fromisoformat" +optional = false +python-versions = ">3" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {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]] +name = "bandit" +version = "1.9.2" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "bandit-1.9.2-py3-none-any.whl", hash = "sha256:bda8d68610fc33a6e10b7a8f1d61d92c8f6c004051d5e946406be1fb1b16a868"}, + {file = "bandit-1.9.2.tar.gz", hash = "sha256:32410415cd93bf9c8b91972159d5cf1e7f063a9146d70345641cd3877de348ce"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" + +[package.extras] +baseline = ["GitPython (>=3.1.30)"] +sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] +toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] +yaml = ["PyYAML"] + [[package]] name = "black" version = "24.10.0" @@ -112,7 +210,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, @@ -357,13 +455,121 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.13.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"}, + {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"}, + {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"}, + {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"}, + {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"}, + {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"}, + {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"}, + {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"}, + {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"}, + {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"}, + {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"}, + {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"}, + {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"}, + {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"}, + {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"}, + {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"}, + {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"}, + {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"}, + {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"}, + {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"}, + {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"}, + {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"}, + {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"}, + {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + [[package]] name = "cryptography" version = "46.0.3" 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.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, @@ -465,6 +671,28 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "dparse" +version = "0.6.4" +description = "A parser for Python dependency files" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57"}, + {file = "dparse-0.6.4.tar.gz", hash = "sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a"}, +] + +[package.dependencies] +packaging = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +all = ["pipenv", "poetry", "pyyaml"] +conda = ["pyyaml"] +pipenv = ["pipenv"] +poetry = ["poetry"] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -505,6 +733,18 @@ typing-extensions = ">=4.8.0" all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "filelock" +version = "3.20.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"}, + {file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"}, +] + [[package]] name = "h11" version = "0.16.0" @@ -633,6 +873,40 @@ files = [ {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] +[[package]] +name = "hypothesis" +version = "6.148.8" +description = "The property-based testing library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "hypothesis-6.148.8-py3-none-any.whl", hash = "sha256:c1842f47f974d74661b3779a26032f8b91bc1eb30d84741714d3712d7f43e85e"}, + {file = "hypothesis-6.148.8.tar.gz", hash = "sha256:fa6b2ae029bc02f9d2d6c2257b0cbf2dc3782362457d2027a038ad7f4209c385"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.101)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.27)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"] +cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +crosshair = ["crosshair-tool (>=0.0.101)", "hypothesis-crosshair (>=0.0.27)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=4.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=20.8b0)"] +lark = ["lark (>=0.10.1)"] +numpy = ["numpy (>=1.21.6)"] +pandas = ["pandas (>=1.1)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +watchdog = ["watchdog (>=4.0.0)"] +zoneinfo = ["tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] + [[package]] name = "idna" version = "3.10" @@ -660,6 +934,24 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "jiter" version = "0.10.0" @@ -747,6 +1039,18 @@ files = [ {file = "jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500"}, ] +[[package]] +name = "joblib" +version = "1.5.3" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"}, + {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"}, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -784,6 +1088,93 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "librt" +version = "0.7.5" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.7.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81056e01bba1394f1d92904ec61a4078f66df785316275edbaf51d90da8c6e26"}, + {file = "librt-0.7.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d7c72c8756eeb3aefb1b9e3dac7c37a4a25db63640cac0ab6fc18e91a0edf05a"}, + {file = "librt-0.7.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ddc4a16207f88f9597b397fc1f60781266d13b13de922ff61c206547a29e4bbd"}, + {file = "librt-0.7.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63055d3dda433ebb314c9f1819942f16a19203c454508fdb2d167613f7017169"}, + {file = "librt-0.7.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f85f9b5db87b0f52e53c68ad2a0c5a53e00afa439bd54a1723742a2b1021276"}, + {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c566a4672564c5d54d8ab65cdaae5a87ee14c1564c1a2ddc7a9f5811c750f023"}, + {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fee15c2a190ef389f14928135c6fb2d25cd3fdb7887bfd9a7b444bbdc8c06b96"}, + {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:584cb3e605ec45ba350962cec853e17be0a25a772f21f09f1e422f7044ae2a7d"}, + {file = "librt-0.7.5-cp310-cp310-win32.whl", hash = "sha256:9c08527055fbb03c641c15bbc5b79dd2942fb6a3bd8dabf141dd7e97eeea4904"}, + {file = "librt-0.7.5-cp310-cp310-win_amd64.whl", hash = "sha256:dd810f2d39c526c42ea205e0addad5dc08ef853c625387806a29d07f9d150d9b"}, + {file = "librt-0.7.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f952e1a78c480edee8fb43aa2bf2e84dcd46c917d44f8065b883079d3893e8fc"}, + {file = "librt-0.7.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75965c1f4efb7234ff52a58b729d245a21e87e4b6a26a0ec08052f02b16274e4"}, + {file = "librt-0.7.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:732e0aa0385b59a1b2545159e781c792cc58ce9c134249233a7c7250a44684c4"}, + {file = "librt-0.7.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdde31759bd8888f3ef0eebda80394a48961328a17c264dce8cc35f4b9cde35d"}, + {file = "librt-0.7.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3146d52465b3b6397d25d513f428cb421c18df65b7378667bb5f1e3cc45805"}, + {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29c8d2fae11d4379ea207ba7fc69d43237e42cf8a9f90ec6e05993687e6d648b"}, + {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb41f04046b4f22b1e7ba5ef513402cd2e3477ec610e5f92d38fe2bba383d419"}, + {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8bb7883c1e94ceb87c2bf81385266f032da09cd040e804cc002f2c9d6b842e2f"}, + {file = "librt-0.7.5-cp311-cp311-win32.whl", hash = "sha256:84d4a6b9efd6124f728558a18e79e7cc5c5d4efc09b2b846c910de7e564f5bad"}, + {file = "librt-0.7.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab4b0d3bee6f6ff7017e18e576ac7e41a06697d8dea4b8f3ab9e0c8e1300c409"}, + {file = "librt-0.7.5-cp311-cp311-win_arm64.whl", hash = "sha256:730be847daad773a3c898943cf67fb9845a3961d06fb79672ceb0a8cd8624cfa"}, + {file = "librt-0.7.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba1077c562a046208a2dc6366227b3eeae8f2c2ab4b41eaf4fd2fa28cece4203"}, + {file = "librt-0.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:654fdc971c76348a73af5240d8e2529265b9a7ba6321e38dd5bae7b0d4ab3abe"}, + {file = "librt-0.7.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6b7b58913d475911f6f33e8082f19dd9b120c4f4a5c911d07e395d67b81c6982"}, + {file = "librt-0.7.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e0fd344bad57026a8f4ccfaf406486c2fc991838050c2fef156170edc3b775"}, + {file = "librt-0.7.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46aa91813c267c3f60db75d56419b42c0c0b9748ec2c568a0e3588e543fb4233"}, + {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ddc0ab9dbc5f9ceaf2bf7a367bf01f2697660e908f6534800e88f43590b271db"}, + {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a488908a470451338607650f1c064175094aedebf4a4fa37890682e30ce0b57"}, + {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47fc52602ffc374e69bf1b76536dc99f7f6dd876bd786c8213eaa3598be030a"}, + {file = "librt-0.7.5-cp312-cp312-win32.whl", hash = "sha256:cda8b025875946ffff5a9a7590bf9acde3eb02cb6200f06a2d3e691ef3d9955b"}, + {file = "librt-0.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:b591c094afd0ffda820e931148c9e48dc31a556dc5b2b9b3cc552fa710d858e4"}, + {file = "librt-0.7.5-cp312-cp312-win_arm64.whl", hash = "sha256:532ddc6a8a6ca341b1cd7f4d999043e4c71a212b26fe9fd2e7f1e8bb4e873544"}, + {file = "librt-0.7.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1795c4b2789b458fa290059062c2f5a297ddb28c31e704d27e161386469691a"}, + {file = "librt-0.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2fcbf2e135c11f721193aa5f42ba112bb1046afafbffd407cbc81d8d735c74d0"}, + {file = "librt-0.7.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c039bbf79a9a2498404d1ae7e29a6c175e63678d7a54013a97397c40aee026c5"}, + {file = "librt-0.7.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3919c9407faeeee35430ae135e3a78acd4ecaaaa73767529e2c15ca1d73ba325"}, + {file = "librt-0.7.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26b46620e1e0e45af510d9848ea0915e7040605dd2ae94ebefb6c962cbb6f7ec"}, + {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9bbb8facc5375476d392990dd6a71f97e4cb42e2ac66f32e860f6e47299d5e89"}, + {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e9e9c988b5ffde7be02180f864cbd17c0b0c1231c235748912ab2afa05789c25"}, + {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:edf6b465306215b19dbe6c3fb63cf374a8f3e1ad77f3b4c16544b83033bbb67b"}, + {file = "librt-0.7.5-cp313-cp313-win32.whl", hash = "sha256:060bde69c3604f694bd8ae21a780fe8be46bb3dbb863642e8dfc75c931ca8eee"}, + {file = "librt-0.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:a82d5a0ee43aeae2116d7292c77cc8038f4841830ade8aa922e098933b468b9e"}, + {file = "librt-0.7.5-cp313-cp313-win_arm64.whl", hash = "sha256:3c98a8d0ac9e2a7cb8ff8c53e5d6e8d82bfb2839abf144fdeaaa832f2a12aa45"}, + {file = "librt-0.7.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9937574e6d842f359b8585903d04f5b4ab62277a091a93e02058158074dc52f2"}, + {file = "librt-0.7.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5cd3afd71e9bc146203b6c8141921e738364158d4aa7cdb9a874e2505163770f"}, + {file = "librt-0.7.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cffa3ef0af29687455161cb446eff059bf27607f95163d6a37e27bcb37180f6"}, + {file = "librt-0.7.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f3f088482e2229387eadf8215c03f7726d56f69cce8c0c40f0795aebc9b361"}, + {file = "librt-0.7.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7aa33153a5bb0bac783d2c57885889b1162823384e8313d47800a0e10d0070e"}, + {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:265729b551a2dd329cc47b323a182fb7961af42abf21e913c9dd7d3331b2f3c2"}, + {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:168e04663e126416ba712114050f413ac306759a1791d87b7c11d4428ba75760"}, + {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:553dc58987d1d853adda8aeadf4db8e29749f0b11877afcc429a9ad892818ae2"}, + {file = "librt-0.7.5-cp314-cp314-win32.whl", hash = "sha256:263f4fae9eba277513357c871275b18d14de93fd49bf5e43dc60a97b81ad5eb8"}, + {file = "librt-0.7.5-cp314-cp314-win_amd64.whl", hash = "sha256:85f485b7471571e99fab4f44eeb327dc0e1f814ada575f3fa85e698417d8a54e"}, + {file = "librt-0.7.5-cp314-cp314-win_arm64.whl", hash = "sha256:49c596cd18e90e58b7caa4d7ca7606049c1802125fcff96b8af73fa5c3870e4d"}, + {file = "librt-0.7.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:54d2aef0b0f5056f130981ad45081b278602ff3657fe16c88529f5058038e802"}, + {file = "librt-0.7.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b4791202296ad51ac09a3ff58eb49d9da8e3a4009167a6d76ac418a974e5fd4"}, + {file = "librt-0.7.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e860909fea75baef941ee6436e0453612505883b9d0d87924d4fda27865b9a2"}, + {file = "librt-0.7.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f02c4337bf271c4f06637f5ff254fad2238c0b8e32a3a480ebb2fc5e26f754a5"}, + {file = "librt-0.7.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f51ffe59f4556243d3cc82d827bde74765f594fa3ceb80ec4de0c13ccd3416"}, + {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b7f080ba30601dfa3e3deed3160352273e1b9bc92e652f51103c3e9298f7899"}, + {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb565b4219abc8ea2402e61c7ba648a62903831059ed3564fa1245cc245d58d7"}, + {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a3cfb15961e7333ea6ef033dc574af75153b5c230d5ad25fbcd55198f21e0cf"}, + {file = "librt-0.7.5-cp314-cp314t-win32.whl", hash = "sha256:118716de5ad6726332db1801bc90fa6d94194cd2e07c1a7822cebf12c496714d"}, + {file = "librt-0.7.5-cp314-cp314t-win_amd64.whl", hash = "sha256:3dd58f7ce20360c6ce0c04f7bd9081c7f9c19fc6129a3c705d0c5a35439f201d"}, + {file = "librt-0.7.5-cp314-cp314t-win_arm64.whl", hash = "sha256:08153ea537609d11f774d2bfe84af39d50d5c9ca3a4d061d946e0c9d8bce04a1"}, + {file = "librt-0.7.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:df2e210400b28e50994477ebf82f055698c79797b6ee47a1669d383ca33263e1"}, + {file = "librt-0.7.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d2cc7d187e8c6e9b7bdbefa9697ce897a704ea7a7ce844f2b4e0e2aa07ae51d3"}, + {file = "librt-0.7.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39183abee670bc37b85f11e86c44a9cad1ed6efa48b580083e89ecee13dd9717"}, + {file = "librt-0.7.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191cbd42660446d67cf7a95ac7bfa60f49b8b3b0417c64f216284a1d86fc9335"}, + {file = "librt-0.7.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea1b60b86595a5dc1f57b44a801a1c4d8209c0a69518391d349973a4491408e6"}, + {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:af69d9e159575e877c7546d1ee817b4ae089aa221dd1117e20c24ad8dc8659c7"}, + {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0e2bf8f91093fac43e3eaebacf777f12fd539dce9ec5af3efc6d8424e96ccd49"}, + {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8dcae24de1bc9da93aa689cb6313c70e776d7cea2fcf26b9b6160fedfe6bd9af"}, + {file = "librt-0.7.5-cp39-cp39-win32.whl", hash = "sha256:cdb001a1a0e4f41e613bca2c0fc147fc8a7396f53fc94201cbfd8ec7cd69ca4b"}, + {file = "librt-0.7.5-cp39-cp39-win_amd64.whl", hash = "sha256:a9eacbf983319b26b5f340a2e0cd47ac1ee4725a7f3a72fd0f15063c934b69d6"}, + {file = "librt-0.7.5.tar.gz", hash = "sha256:de4221a1181fa9c8c4b5f35506ed6f298948f44003d84d2a8b9885d7e01e6cfa"}, +] + [[package]] name = "limits" version = "5.4.0" @@ -813,6 +1204,150 @@ redis = ["redis (>3,!=4.5.2,!=4.5.3,<6.0.0)"] rediscluster = ["redis (>=4.2.0,!=4.5.2,!=4.5.3)"] valkey = ["valkey (>=6)"] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[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", "requests"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "marshmallow" +version = "4.1.2" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "marshmallow-4.1.2-py3-none-any.whl", hash = "sha256:a8cfa18bd8d0e5f7339e734edf84815fe8db1bdb57358c7ccc05472b746eeadc"}, + {file = "marshmallow-4.1.2.tar.gz", hash = "sha256:083f250643d2e75fd363f256aeb6b1af369a7513ad37647ce4a601f6966e3ba5"}, +] + +[package.dependencies] +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,<5.0)", "tox"] +docs = ["autodocsumm (==0.2.14)", "furo (==2025.9.25)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.1)", "sphinxext-opengraph (==0.13.0)"] +tests = ["pytest", "simplejson"] + [[package]] name = "mcp" version = "1.20.0" @@ -844,6 +1379,80 @@ cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] rich = ["rich (>=13.9.4)"] ws = ["websockets (>=15.0.1)"] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy" +version = "1.19.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, + {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, + {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, + {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, + {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, + {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, + {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, + {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, + {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, + {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, + {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, + {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, + {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, + {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, + {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, + {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, +] + +[package.dependencies] +librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -856,6 +1465,32 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "nltk" +version = "3.9.2" +description = "Natural Language Toolkit" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"}, + {file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"}, +] + +[package.dependencies] +click = "*" +joblib = "*" +regex = ">=2021.8.3" +tqdm = "*" + +[package.extras] +all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] +corenlp = ["requests"] +machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] +plot = ["matplotlib"] +tgrep = ["pyparsing"] +twitter = ["twython"] + [[package]] name = "openai" version = "1.93.0" @@ -947,7 +1582,7 @@ version = "2.23" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, @@ -1191,6 +1826,26 @@ pytest = ">=7.0.0,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "pytest-cov" +version = "7.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -1255,7 +1910,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -1329,6 +1984,131 @@ attrs = ">=22.2.0" rpds-py = ">=0.7.0" typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} +[[package]] +name = "regex" +version = "2025.11.3" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af"}, + {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313"}, + {file = "regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5"}, + {file = "regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec"}, + {file = "regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd"}, + {file = "regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e"}, + {file = "regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf"}, + {file = "regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a"}, + {file = "regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0"}, + {file = "regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204"}, + {file = "regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9"}, + {file = "regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7"}, + {file = "regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c"}, + {file = "regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5"}, + {file = "regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2"}, + {file = "regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a"}, + {file = "regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c"}, + {file = "regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed"}, + {file = "regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4"}, + {file = "regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad"}, + {file = "regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379"}, + {file = "regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38"}, + {file = "regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de"}, + {file = "regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:81519e25707fc076978c6143b81ea3dc853f176895af05bf7ec51effe818aeec"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3bf28b1873a8af8bbb58c26cc56ea6e534d80053b41fb511a35795b6de507e6a"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:856a25c73b697f2ce2a24e7968285579e62577a048526161a2c0f53090bea9f9"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a3d571bd95fade53c86c0517f859477ff3a93c3fde10c9e669086f038e0f207"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:732aea6de26051af97b94bc98ed86448821f839d058e5d259c72bf6d73ad0fc0"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51c1c1847128238f54930edb8805b660305dca164645a9fd29243f5610beea34"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22dd622a402aad4558277305350699b2be14bc59f64d64ae1d928ce7d072dced"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f3b5a391c7597ffa96b41bd5cbd2ed0305f515fcbb367dfa72735679d5502364"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:cc4076a5b4f36d849fd709284b4a3b112326652f3b0466f04002a6c15a0c96c1"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a295ca2bba5c1c885826ce3125fa0b9f702a1be547d821c01d65f199e10c01e2"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b4774ff32f18e0504bfc4e59a3e71e18d83bc1e171a3c8ed75013958a03b2f14"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e7d1cdfa88ef33a2ae6aa0d707f9255eb286ffbd90045f1088246833223aee"}, + {file = "regex-2025.11.3-cp39-cp39-win32.whl", hash = "sha256:74d04244852ff73b32eeede4f76f51c5bcf44bc3c207bc3e6cf1c5c45b890708"}, + {file = "regex-2025.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:7a50cd39f73faa34ec18d6720ee25ef10c4c1839514186fcda658a06c06057a2"}, + {file = "regex-2025.11.3-cp39-cp39-win_arm64.whl", hash = "sha256:43b4fb020e779ca81c1b5255015fe2b82816c76ec982354534ad9ec09ad7c9e3"}, + {file = "regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01"}, +] + [[package]] name = "requests" version = "2.32.4" @@ -1351,6 +2131,25 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rich" +version = "14.2.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["dev"] +files = [ + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "rpds-py" version = "0.28.0" @@ -1476,6 +2275,165 @@ files = [ {file = "rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea"}, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.17" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d"}, + {file = "ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.15", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.15\""} + +[package.extras] +docs = ["mercurial (>5.7)", "ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation == \"CPython\" and python_version < \"3.15\"" +files = [ + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win32.whl", hash = "sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win_amd64.whl", hash = "sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:923816815974425fbb1f1bf57e85eca6e14d8adc313c66db21c094927ad01815"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dcc7f3162d3711fd5d52e2267e44636e3e566d1e5675a5f0b30e98f2c4af7974"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d3c9210219cbc0f22706f19b154c9a798ff65a6beeafbf77fc9c057ec806f7d"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bb7b728fd9f405aa00b4a0b17ba3f3b810d0ccc5f77f7373162e9b5f0ff75d5"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cb75a3c14f1d6c3c2a94631e362802f70e83e20d1f2b2ef3026c05b415c4900"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:badd1d7283f3e5894779a6ea8944cc765138b96804496c91812b2829f70e18a7"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0ba6604bbc3dfcef844631932d06a1a4dcac3fee904efccf582261948431628a"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8220fd4c6f98485e97aea65e1df76d4fed1678ede1fe1d0eed2957230d287c4"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win32.whl", hash = "sha256:04d21dc9c57d9608225da28285900762befbb0165ae48482c15d8d4989d4af14"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win_amd64.whl", hash = "sha256:27dc656e84396e6d687f97c6e65fb284d100483628f02d95464fd731743a4afe"}, + {file = "ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600"}, +] + +[[package]] +name = "safety" +version = "3.7.0" +description = "Scan dependencies for known vulnerabilities and licenses." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "safety-3.7.0-py3-none-any.whl", hash = "sha256:65e71db45eb832e8840e3456333d44c23927423753d5610596a09e909a66d2bf"}, + {file = "safety-3.7.0.tar.gz", hash = "sha256:daec15a393cafc32b846b7ef93f9c952a1708863e242341ab5bde2e4beabb54e"}, +] + +[package.dependencies] +authlib = ">=1.2.0" +click = ">=8.0.2" +dparse = ">=0.6.4" +filelock = ">=3.16.1,<4.0" +httpx = "*" +jinja2 = ">=3.1.0" +marshmallow = ">=3.15.0" +nltk = ">=3.9" +packaging = ">=21.0" +pydantic = ">=2.6.0" +requests = "*" +ruamel-yaml = ">=0.17.21" +safety-schemas = "0.0.16" +tenacity = ">=8.1.0" +tomli = {version = "*", markers = "python_version < \"3.11\""} +tomlkit = "*" +typer = ">=0.16.0" +typing-extensions = ">=4.7.1" + +[package.extras] +github = ["pygithub (>=1.43.3)"] +gitlab = ["python-gitlab (>=1.3.0)"] +spdx = ["spdx-tools (>=0.8.2)"] + +[[package]] +name = "safety-schemas" +version = "0.0.16" +description = "Schemas for Safety tools" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"}, + {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"}, +] + +[package.dependencies] +dparse = ">=0.6.4" +packaging = ">=21.0" +pydantic = ">=2.6.0" +ruamel-yaml = ">=0.17.21" +typing-extensions = ">=4.7.1" + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "slowapi" version = "0.1.9" @@ -1506,6 +2464,18 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + [[package]] name = "sse-starlette" version = "2.3.6" @@ -1545,6 +2515,34 @@ anyio = ">=3.6.2,<5" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +[[package]] +name = "stevedore" +version = "5.6.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820"}, + {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"}, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {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 = "tomli" version = "2.2.1" @@ -1552,7 +2550,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version == \"3.10\"" +markers = "python_full_version <= \"3.11.0a6\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1588,6 +2586,18 @@ files = [ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, + {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1610,6 +2620,24 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] +[[package]] +name = "typer" +version = "0.21.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6"}, + {file = "typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + [[package]] name = "typing-extensions" version = "4.14.0" @@ -2025,4 +3053,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "22f3aba55fc6dc8ea77aced440d59a91550236ad85d81aeeb9ba6f1b23a1233c" +content-hash = "995cbb6b6bfbf14612eff7e0690ca47fc7b0c01fd2ef3351dea01d6940be0ed6" diff --git a/pyproject.toml b/pyproject.toml index c6cff36..ff00fda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,11 @@ pytest = "^8.0.0" pytest-asyncio = "^0.23.0" requests = "^2.32.0" openai = "^1.0.0" +pytest-cov = "^7.0.0" +mypy = "^1.14.0" +bandit = "^1.8.0" +safety = "^3.2.0" +hypothesis = "^6.122.0" [build-system] requires = ["poetry-core"] diff --git a/src/__init__.py b/src/__init__.py index 1d29cf2..e2b4f2d 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "1.0.0" +__version__ = "2.1.0" diff --git a/src/auth.py b/src/auth.py index 2a9f43f..7b23e69 100644 --- a/src/auth.py +++ b/src/auth.py @@ -32,7 +32,34 @@ def get_api_key(self): return self.env_api_key def _detect_auth_method(self) -> str: - """Detect which Claude Code authentication method is configured.""" + """Detect which Claude Code authentication method is configured. + + Priority: + 1. Explicit CLAUDE_AUTH_METHOD env var (cli, api_key, bedrock, vertex) + 2. Legacy env vars (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX) + 3. Auto-detect based on ANTHROPIC_API_KEY presence + 4. Default to claude_cli + """ + # Check for explicit auth method first + explicit_method = os.getenv("CLAUDE_AUTH_METHOD", "").lower() + if explicit_method: + method_map = { + "cli": "claude_cli", + "claude_cli": "claude_cli", + "api_key": "anthropic", + "anthropic": "anthropic", + "bedrock": "bedrock", + "vertex": "vertex", + } + if explicit_method in method_map: + logger.info(f"Using explicit auth method: {method_map[explicit_method]}") + return method_map[explicit_method] + else: + logger.warning( + f"Unknown CLAUDE_AUTH_METHOD '{explicit_method}', falling back to auto-detect" + ) + + # Fall back to legacy env vars and auto-detection if os.getenv("CLAUDE_CODE_USE_BEDROCK") == "1": return "bedrock" elif os.getenv("CLAUDE_CODE_USE_VERTEX") == "1": diff --git a/src/claude_cli.py b/src/claude_cli.py index da5c648..d87057e 100644 --- a/src/claude_cli.py +++ b/src/claude_cli.py @@ -103,6 +103,7 @@ async def run_completion( disallowed_tools: Optional[List[str]] = None, session_id: Optional[str] = None, continue_session: bool = False, + permission_mode: Optional[str] = None, ) -> AsyncGenerator[Dict[str, Any], None]: """Run Claude Agent using the Python SDK and yield response chunks.""" @@ -136,6 +137,10 @@ async def run_completion( if disallowed_tools: options.disallowed_tools = disallowed_tools + # Set permission mode (needed for tool execution in API context) + if permission_mode: + options.permission_mode = permission_mode + # Handle session continuity if continue_session: options.continue_session = True @@ -188,7 +193,18 @@ async def run_completion( } def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]: - """Extract the assistant message from Claude Agent SDK messages.""" + """Extract the assistant message from Claude Agent SDK messages. + + Prioritizes ResultMessage.result for multi-turn conversations, + falls back to last AssistantMessage content. + """ + # First, check for ResultMessage with 'result' field (multi-turn completion) + for message in messages: + if message.get("subtype") == "success" and "result" in message: + return message["result"] + + # Collect all text from AssistantMessages (take the last one with text) + last_text = None for message in messages: # Look for AssistantMessage type (new SDK format) if "content" in message and isinstance(message["content"], list): @@ -203,7 +219,7 @@ def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]: text_parts.append(block) if text_parts: - return "\n".join(text_parts) + last_text = "\n".join(text_parts) # Fallback: look for old format elif message.get("type") == "assistant" and "message" in message: @@ -216,11 +232,12 @@ def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]: for block in content: if isinstance(block, dict) and block.get("type") == "text": text_parts.append(block.get("text", "")) - return "\n".join(text_parts) if text_parts else None + if text_parts: + last_text = "\n".join(text_parts) elif isinstance(content, str): - return content + last_text = content - return None + return last_text def extract_metadata(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: """Extract metadata like costs, tokens, and session info from SDK messages.""" diff --git a/src/constants.py b/src/constants.py index ed4ebfa..5fb452b 100644 --- a/src/constants.py +++ b/src/constants.py @@ -2,12 +2,33 @@ Constants and configuration for Claude Code OpenAI Wrapper. Single source of truth for tool names, models, and other configuration values. + +Usage Examples: + # Check if a model is supported + from src.constants import CLAUDE_MODELS + if model_name in CLAUDE_MODELS: + # proceed with request + + # Get default allowed tools + from src.constants import DEFAULT_ALLOWED_TOOLS + options = {"allowed_tools": DEFAULT_ALLOWED_TOOLS} + + # Use rate limits in FastAPI + from src.constants import RATE_LIMIT_CHAT + @limiter.limit(f"{RATE_LIMIT_CHAT}/minute") + async def chat_endpoint(): ... + +Note: + - Tool configurations are managed by ToolManager (see tool_manager.py) + - Model validation uses graceful degradation (warns but allows unknown models) + - Rate limits can be overridden via environment variables """ import os # Claude Agent SDK Tool Names # These are the built-in tools available in the Claude Agent SDK +# See: https://docs.anthropic.com/en/docs/claude-code/sdk CLAUDE_TOOLS = [ "Task", # Launch agents for complex tasks "Bash", # Execute bash commands @@ -49,7 +70,7 @@ # NOTE: Claude Agent SDK only supports Claude 4+ models, not Claude 3.x CLAUDE_MODELS = [ # Claude 4.5 Family (Latest - Fall 2025) - RECOMMENDED - "claude-opus-4-5-20250929", # Latest Opus 4.5 - Most capable + "claude-opus-4-5-20250929", # Latest Opus 4.5 - Most capable "claude-sonnet-4-5-20250929", # Recommended - best coding model "claude-haiku-4-5-20251001", # Fast & cheap # Claude 4.1 diff --git a/src/main.py b/src/main.py index dba2405..4a74aa4 100644 --- a/src/main.py +++ b/src/main.py @@ -4,13 +4,14 @@ import logging import secrets import string +import uuid from typing import Optional, AsyncGenerator, Dict, Any from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request, Depends from fastapi.security import HTTPAuthorizationCredentials from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, JSONResponse +from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse from fastapi.exceptions import RequestValidationError from pydantic import ValidationError from dotenv import load_dotenv @@ -32,6 +33,11 @@ MCPServerInfoResponse, MCPServersListResponse, MCPConnectionRequest, + # Anthropic API compatible models + AnthropicMessagesRequest, + AnthropicMessagesResponse, + AnthropicTextBlock, + AnthropicUsage, ) from src.claude_cli import ClaudeCodeCLI from src.message_adapter import MessageAdapter @@ -45,7 +51,7 @@ rate_limit_exceeded_handler, rate_limit_endpoint, ) -from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS +from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS # Load environment variables load_dotenv() @@ -215,23 +221,65 @@ async def lifespan(app: FastAPI): app.state.limiter = limiter app.add_exception_handler(429, rate_limit_exceeded_handler) -# Add debug logging middleware +# Security configuration +MAX_REQUEST_SIZE = int(os.getenv("MAX_REQUEST_SIZE", str(10 * 1024 * 1024))) # 10MB default + +# Add middleware from starlette.middleware.base import BaseHTTPMiddleware +class RequestIDMiddleware(BaseHTTPMiddleware): + """Add unique request ID to each request for audit trails.""" + + async def dispatch(self, request: Request, call_next): + request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) + request.state.request_id = request_id + + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +class RequestSizeLimitMiddleware(BaseHTTPMiddleware): + """Limit request body size to prevent DoS attacks.""" + + async def dispatch(self, request: Request, call_next): + content_length = request.headers.get("content-length") + if content_length and int(content_length) > MAX_REQUEST_SIZE: + return JSONResponse( + status_code=413, + content={ + "error": { + "message": f"Request body too large. Maximum size is {MAX_REQUEST_SIZE} bytes.", + "type": "request_too_large", + "code": 413, + } + }, + ) + return await call_next(request) + + +# Add security middleware (order matters - first added = last executed) +app.add_middleware(RequestIDMiddleware) +app.add_middleware(RequestSizeLimitMiddleware) + + class DebugLoggingMiddleware(BaseHTTPMiddleware): """ASGI-compliant middleware for logging request/response details when debug mode is enabled.""" async def dispatch(self, request: Request, call_next): + # Get request ID for correlation + request_id = getattr(request.state, "request_id", "unknown") + if not (DEBUG_MODE or VERBOSE): return await call_next(request) # Log request details start_time = asyncio.get_event_loop().time() - # Log basic request info - logger.debug(f"๐Ÿ” Incoming request: {request.method} {request.url}") - logger.debug(f"๐Ÿ” Headers: {dict(request.headers)}") + # Log basic request info with request ID for correlation + logger.debug(f"๐Ÿ” [{request_id}] Incoming request: {request.method} {request.url}") + logger.debug(f"๐Ÿ” [{request_id}] Headers: {dict(request.headers)}") # For POST requests, try to log body (but don't break if we can't) body_logged = False @@ -385,7 +433,11 @@ async def generate_streaming_response( claude_options["max_turns"] = 1 # Single turn for Q&A logger.info("Tools disabled (default behavior for OpenAI compatibility)") else: - logger.info("Tools enabled by user request") + # Enable tools - use default safe subset (Read, Glob, Grep, Bash, Write, Edit) + claude_options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS + # Set permission mode to bypass prompts (required for API/headless usage) + claude_options["permission_mode"] = "bypassPermissions" + logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}") # Run Claude Code chunks_buffer = [] @@ -399,6 +451,7 @@ async def generate_streaming_response( max_turns=claude_options.get("max_turns", 10), allowed_tools=claude_options.get("allowed_tools"), disallowed_tools=claude_options.get("disallowed_tools"), + permission_mode=claude_options.get("permission_mode"), stream=True, ): chunks_buffer.append(chunk) @@ -642,7 +695,11 @@ async def chat_completions( claude_options["max_turns"] = 1 # Single turn for Q&A logger.info("Tools disabled (default behavior for OpenAI compatibility)") else: - logger.info("Tools enabled by user request") + # Enable tools - use default safe subset (Read, Glob, Grep, Bash, Write, Edit) + claude_options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS + # Set permission mode to bypass prompts (required for API/headless usage) + claude_options["permission_mode"] = "bypassPermissions" + logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}") # Collect all chunks chunks = [] @@ -653,6 +710,7 @@ async def chat_completions( max_turns=claude_options.get("max_turns", 10), allowed_tools=claude_options.get("allowed_tools"), disallowed_tools=claude_options.get("disallowed_tools"), + permission_mode=claude_options.get("permission_mode"), stream=False, ): chunks.append(chunk) @@ -702,6 +760,102 @@ async def chat_completions( raise HTTPException(status_code=500, detail=str(e)) +@app.post("/v1/messages") +@rate_limit_endpoint("chat") +async def anthropic_messages( + request_body: AnthropicMessagesRequest, + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +): + """Anthropic Messages API compatible endpoint. + + This endpoint provides compatibility with the native Anthropic SDK, + allowing tools like VC to use this wrapper via the VC_API_BASE setting. + """ + # Check FastAPI API key if configured + await verify_api_key(request, credentials) + + # Validate Claude Code authentication + auth_valid, auth_info = validate_claude_code_auth() + + if not auth_valid: + error_detail = { + "message": "Claude Code authentication failed", + "errors": auth_info.get("errors", []), + "method": auth_info.get("method", "none"), + "help": "Check /v1/auth/status for detailed authentication information", + } + raise HTTPException(status_code=503, detail=error_detail) + + try: + logger.info(f"Anthropic Messages API request: model={request_body.model}") + + # Convert Anthropic messages to internal format + messages = request_body.to_openai_messages() + + # Build prompt from messages + prompt_parts = [] + for msg in messages: + if msg.role == "user": + prompt_parts.append(msg.content) + elif msg.role == "assistant": + prompt_parts.append(f"Assistant: {msg.content}") + + prompt = "\n\n".join(prompt_parts) + system_prompt = request_body.system + + # Filter content + prompt = MessageAdapter.filter_content(prompt) + if system_prompt: + system_prompt = MessageAdapter.filter_content(system_prompt) + + # Run Claude Code - tools enabled by default for Anthropic SDK clients + # (they're typically using this for agentic workflows) + chunks = [] + async for chunk in claude_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + model=request_body.model, + max_turns=10, + allowed_tools=DEFAULT_ALLOWED_TOOLS, + permission_mode="bypassPermissions", + stream=False, + ): + chunks.append(chunk) + + # Extract assistant message + raw_assistant_content = claude_cli.parse_claude_message(chunks) + + if not raw_assistant_content: + raise HTTPException(status_code=500, detail="No response from Claude Code") + + # Filter out tool usage and thinking blocks + assistant_content = MessageAdapter.filter_content(raw_assistant_content) + + # Estimate tokens + prompt_tokens = MessageAdapter.estimate_tokens(prompt) + completion_tokens = MessageAdapter.estimate_tokens(assistant_content) + + # Create Anthropic-format response + response = AnthropicMessagesResponse( + model=request_body.model, + content=[AnthropicTextBlock(text=assistant_content)], + stop_reason="end_turn", + usage=AnthropicUsage( + input_tokens=prompt_tokens, + output_tokens=completion_tokens, + ), + ) + + return response + + except HTTPException: + raise + except Exception as e: + logger.error(f"Anthropic Messages API error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/v1/models") async def list_models( request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) @@ -757,6 +911,626 @@ async def health_check(request: Request): return {"status": "healthy", "service": "claude-code-openai-wrapper"} +@app.get("/version") +@rate_limit_endpoint("health") +async def version_info(request: Request): + """Version information endpoint.""" + from src import __version__ + + return { + "version": __version__, + "service": "claude-code-openai-wrapper", + "api_version": "v1", + } + + +@app.get("/", response_class=HTMLResponse) +async def root(): + """Landing page with API documentation.""" + from src import __version__ + + auth_info = get_claude_code_auth_info() + auth_method = auth_info.get("method", "unknown") + auth_valid = auth_info.get("status", {}).get("valid", False) + status_color = "#22c55e" if auth_valid else "#ef4444" + status_text = "Connected" if auth_valid else "Not Connected" + + html_content = f""" + + + + + + + Claude Code OpenAI Wrapper + + + + + + +
+ +
+
+
+
+ + + +
+
+
+

Claude Code OpenAI Wrapper

+

OpenAI-compatible API for Claude

+
+
+
+ v{__version__} + + + + + + +
+
+ + +
+
+
+ + {status_text} +
+ Auth: {auth_method} +
+
+ + +
+
+ + Quick Start +
+
+ +
+
+
+ + +
+
+ + API Endpoints +
+ + +
+ POST + /v1/chat/completions + OpenAI-compatible chat +
+
+ POST + /v1/messages + Anthropic-compatible +
+ + +
+ + GET + /v1/models + List models + +
+ +
+
+
+ +
+ + GET + /v1/auth/status + Auth status + +
+ +
+
+
+ +
+ + GET + /v1/sessions + Active sessions + +
+ +
+
+
+ +
+ + GET + /health + Health check + +
+ +
+
+
+ +
+ + GET + /version + API version + +
+ +
+
+
+
+ + +
+
+ + Configuration +
+

Set CLAUDE_AUTH_METHOD to choose authentication:

+
+
+ cli +

Claude CLI auth

+
+
+ api_key +

ANTHROPIC_API_KEY

+
+
+ bedrock +

AWS Bedrock

+
+
+ vertex +

Google Vertex AI

+
+
+
+ + + +
+ + + """ + return HTMLResponse(content=html_content) + + @app.post("/v1/debug/request") @rate_limit_endpoint("debug") async def debug_request_validation(request: Request): @@ -1165,7 +1939,7 @@ def find_available_port(start_port: int = 8000, max_attempts: int = 10) -> int: ) -def run_server(port: int = None): +def run_server(port: int = None, host: str = None): """Run the server - used as Poetry script entry point.""" import uvicorn @@ -1176,11 +1950,15 @@ def run_server(port: int = None): # Priority: CLI arg > ENV var > default if port is None: port = int(os.getenv("PORT", "8000")) + if host is None: + # Default to 0.0.0.0 for container/development use (configurable via CLAUDE_WRAPPER_HOST env) + host = os.getenv("CLAUDE_WRAPPER_HOST", "0.0.0.0") # nosec B104 preferred_port = port try: # Try the preferred port first - uvicorn.run(app, host="0.0.0.0", port=preferred_port) + # Binding to 0.0.0.0 is intentional for container/development use + uvicorn.run(app, host=host, port=preferred_port) # nosec B104 except OSError as e: if "Address already in use" in str(e) or e.errno == 48: logger.warning(f"Port {preferred_port} is already in use. Finding alternative port...") @@ -1189,7 +1967,8 @@ def run_server(port: int = None): logger.info(f"Starting server on alternative port {available_port}") print(f"\n๐Ÿš€ Server starting on http://localhost:{available_port}") print(f"๐Ÿ“ Update your client base_url to: http://localhost:{available_port}/v1") - uvicorn.run(app, host="0.0.0.0", port=available_port) + # Binding to 0.0.0.0 is intentional for container/development use + uvicorn.run(app, host=host, port=available_port) # nosec B104 except RuntimeError as port_error: logger.error(f"Could not find available port: {port_error}") print(f"\nโŒ Error: {port_error}") diff --git a/src/models.py b/src/models.py index 3738481..82e85f4 100644 --- a/src/models.py +++ b/src/models.py @@ -6,10 +6,12 @@ logger = logging.getLogger(__name__) + # Import DEFAULT_MODEL to avoid circular imports def get_default_model(): """Get default model from constants to avoid circular imports.""" from src.constants import DEFAULT_MODEL + return DEFAULT_MODEL @@ -407,3 +409,71 @@ def validate_tool_name(cls, v: str) -> str: if len(v) > 200: raise ValueError("Tool name too long (max 200 characters)") return v.strip() + + +# ============================================================================ +# Anthropic API Compatible Models (for /v1/messages endpoint) +# ============================================================================ + + +class AnthropicTextBlock(BaseModel): + """Anthropic text content block.""" + + type: Literal["text"] = "text" + text: str + + +class AnthropicMessage(BaseModel): + """Anthropic message format.""" + + role: Literal["user", "assistant"] + content: Union[str, List[AnthropicTextBlock]] + + +class AnthropicMessagesRequest(BaseModel): + """Anthropic Messages API request format.""" + + model: str + messages: List[AnthropicMessage] + max_tokens: int = Field(default=4096, description="Maximum tokens to generate") + system: Optional[str] = Field(default=None, description="System prompt") + temperature: Optional[float] = Field(default=1.0, ge=0, le=1) + top_p: Optional[float] = Field(default=None, ge=0, le=1) + top_k: Optional[int] = Field(default=None, ge=0) + stop_sequences: Optional[List[str]] = None + stream: Optional[bool] = False + metadata: Optional[Dict[str, Any]] = None + + def to_openai_messages(self) -> List[Message]: + """Convert Anthropic messages to OpenAI format.""" + result = [] + for msg in self.messages: + content = msg.content + if isinstance(content, list): + # Extract text from content blocks + text_parts = [ + block.text for block in content if isinstance(block, AnthropicTextBlock) + ] + content = "\n".join(text_parts) + result.append(Message(role=msg.role, content=content)) + return result + + +class AnthropicUsage(BaseModel): + """Anthropic usage information.""" + + input_tokens: int + output_tokens: int + + +class AnthropicMessagesResponse(BaseModel): + """Anthropic Messages API response format.""" + + id: str = Field(default_factory=lambda: f"msg_{uuid.uuid4().hex[:24]}") + type: Literal["message"] = "message" + role: Literal["assistant"] = "assistant" + content: List[AnthropicTextBlock] + model: str + stop_reason: Optional[Literal["end_turn", "max_tokens", "stop_sequence"]] = "end_turn" + stop_sequence: Optional[str] = None + usage: AnthropicUsage diff --git a/src/parameter_validator.py b/src/parameter_validator.py index 06bbb43..e45452f 100644 --- a/src/parameter_validator.py +++ b/src/parameter_validator.py @@ -17,7 +17,7 @@ class ParameterValidator: SUPPORTED_MODELS = set(CLAUDE_MODELS) # Valid permission modes for Claude Code SDK - VALID_PERMISSION_MODES = {"default", "acceptEdits", "bypassPermissions"} + VALID_PERMISSION_MODES = {"default", "acceptEdits", "bypassPermissions", "plan"} @classmethod def validate_model(cls, model: str) -> bool: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d5ab386 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +""" +Pytest configuration and fixtures for claude-code-openai-wrapper tests. +""" + +import pytest +import requests + + +# Check if server is running for integration tests +def is_server_running(base_url: str = "http://localhost:8000") -> bool: + """Check if the test server is running.""" + try: + response = requests.get(f"{base_url}/health", timeout=2) + return response.status_code == 200 + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + return False + + +# Marker for tests that require a running server +requires_server = pytest.mark.skipif( + not is_server_running(), + reason="Server not running at localhost:8000. Start with: poetry run python main.py", +) diff --git a/tests/test_anthropic_messages.py b/tests/test_anthropic_messages.py new file mode 100644 index 0000000..1f8d303 --- /dev/null +++ b/tests/test_anthropic_messages.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +Tests for the Anthropic Messages API compatible endpoint (/v1/messages). + +This endpoint provides compatibility with the native Anthropic SDK, +enabling tools like VC to use this wrapper via custom base URL configuration. +""" + +import pytest +import requests + +from tests.conftest import requires_server + +BASE_URL = "http://localhost:8000" + + +class TestAnthropicMessagesModels: + """Test Anthropic API model classes.""" + + def test_anthropic_text_block(self): + """Test AnthropicTextBlock model.""" + from src.models import AnthropicTextBlock + + block = AnthropicTextBlock(text="Hello world") + assert block.type == "text" + assert block.text == "Hello world" + + def test_anthropic_message(self): + """Test AnthropicMessage model.""" + from src.models import AnthropicMessage + + # String content + msg = AnthropicMessage(role="user", content="Hello") + assert msg.role == "user" + assert msg.content == "Hello" + + # List content + from src.models import AnthropicTextBlock + + msg2 = AnthropicMessage(role="assistant", content=[AnthropicTextBlock(text="Hi there")]) + assert msg2.role == "assistant" + assert len(msg2.content) == 1 + + def test_anthropic_messages_request(self): + """Test AnthropicMessagesRequest model.""" + from src.models import AnthropicMessagesRequest, AnthropicMessage + + request = AnthropicMessagesRequest( + model="claude-sonnet-4-5-20250929", + messages=[AnthropicMessage(role="user", content="Hello")], + max_tokens=100, + system="You are helpful", + ) + + assert request.model == "claude-sonnet-4-5-20250929" + assert len(request.messages) == 1 + assert request.max_tokens == 100 + assert request.system == "You are helpful" + + def test_anthropic_messages_request_to_openai(self): + """Test conversion from Anthropic to OpenAI message format.""" + from src.models import AnthropicMessagesRequest, AnthropicMessage + + request = AnthropicMessagesRequest( + model="claude-sonnet-4-5-20250929", + messages=[ + AnthropicMessage(role="user", content="Hello"), + AnthropicMessage(role="assistant", content="Hi there"), + AnthropicMessage(role="user", content="How are you?"), + ], + ) + + openai_messages = request.to_openai_messages() + assert len(openai_messages) == 3 + assert openai_messages[0].role == "user" + assert openai_messages[0].content == "Hello" + assert openai_messages[1].role == "assistant" + assert openai_messages[2].content == "How are you?" + + def test_anthropic_messages_response(self): + """Test AnthropicMessagesResponse model.""" + from src.models import ( + AnthropicMessagesResponse, + AnthropicTextBlock, + AnthropicUsage, + ) + + response = AnthropicMessagesResponse( + model="claude-sonnet-4-5-20250929", + content=[AnthropicTextBlock(text="Hello!")], + usage=AnthropicUsage(input_tokens=10, output_tokens=5), + ) + + assert response.type == "message" + assert response.role == "assistant" + assert response.model == "claude-sonnet-4-5-20250929" + assert len(response.content) == 1 + assert response.content[0].text == "Hello!" + assert response.stop_reason == "end_turn" + assert response.usage.input_tokens == 10 + assert response.usage.output_tokens == 5 + + +class TestAnthropicMessagesEndpoint: + """Integration tests for /v1/messages endpoint.""" + + @requires_server + def test_basic_message(self): + """Test basic message request.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 50, + "messages": [{"role": "user", "content": "Say 'test' and nothing else"}], + }, + ) + + assert response.status_code == 200 + result = response.json() + + # Verify Anthropic response format + assert result["type"] == "message" + assert result["role"] == "assistant" + assert "content" in result + assert len(result["content"]) > 0 + assert result["content"][0]["type"] == "text" + assert "usage" in result + assert "input_tokens" in result["usage"] + assert "output_tokens" in result["usage"] + + @requires_server + def test_message_with_system_prompt(self): + """Test message with system prompt.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 50, + "system": "You always respond with exactly one word.", + "messages": [{"role": "user", "content": "Say hello"}], + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["type"] == "message" + assert len(result["content"]) > 0 + + @requires_server + def test_multi_turn_conversation(self): + """Test multi-turn conversation.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 100, + "messages": [ + {"role": "user", "content": "My name is Alice."}, + {"role": "assistant", "content": "Hello Alice!"}, + {"role": "user", "content": "What's my name?"}, + ], + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["type"] == "message" + # The response should reference Alice + response_text = result["content"][0]["text"].lower() + assert "alice" in response_text + + @requires_server + def test_invalid_request_missing_messages(self): + """Test error handling for missing messages.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 50, + # Missing 'messages' field + }, + ) + + assert response.status_code == 422 # Validation error + + @requires_server + def test_response_format_matches_anthropic_sdk(self): + """Test that response format matches what Anthropic SDK expects.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 50, + "messages": [{"role": "user", "content": "Hi"}], + }, + ) + + assert response.status_code == 200 + result = response.json() + + # Required fields for Anthropic SDK compatibility + assert "id" in result + assert result["id"].startswith("msg_") + assert result["type"] == "message" + assert result["role"] == "assistant" + assert isinstance(result["content"], list) + assert result["stop_reason"] in ["end_turn", "max_tokens", "stop_sequence"] + assert "usage" in result + assert "input_tokens" in result["usage"] + assert "output_tokens" in result["usage"] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_auth_unit.py b/tests/test_auth_unit.py new file mode 100644 index 0000000..ba9ec92 --- /dev/null +++ b/tests/test_auth_unit.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/auth.py + +Tests the ClaudeCodeAuthManager and authentication functions. +These are pure unit tests that don't require a running server. +""" + +import pytest +import os +from unittest.mock import MagicMock, patch, AsyncMock +from fastapi import HTTPException + +# We need to patch environment before importing auth module +import importlib + + +class TestClaudeCodeAuthManagerDetectMethod: + """Test _detect_auth_method()""" + + def test_explicit_cli_method(self): + """CLAUDE_AUTH_METHOD=cli uses claude_cli.""" + with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "cli"}, clear=False): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "claude_cli" + + def test_explicit_claude_cli_method(self): + """CLAUDE_AUTH_METHOD=claude_cli uses claude_cli.""" + with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "claude_cli"}, clear=False): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "claude_cli" + + def test_explicit_api_key_method(self): + """CLAUDE_AUTH_METHOD=api_key uses anthropic.""" + with patch.dict( + os.environ, + {"CLAUDE_AUTH_METHOD": "api_key", "ANTHROPIC_API_KEY": "test-key-12345"}, + clear=False, + ): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "anthropic" + + def test_explicit_anthropic_method(self): + """CLAUDE_AUTH_METHOD=anthropic uses anthropic.""" + with patch.dict( + os.environ, + {"CLAUDE_AUTH_METHOD": "anthropic", "ANTHROPIC_API_KEY": "test-key-12345"}, + clear=False, + ): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "anthropic" + + def test_explicit_bedrock_method(self): + """CLAUDE_AUTH_METHOD=bedrock uses bedrock.""" + with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "bedrock"}, clear=False): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "bedrock" + + def test_explicit_vertex_method(self): + """CLAUDE_AUTH_METHOD=vertex uses vertex.""" + with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "vertex"}, clear=False): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "vertex" + + def test_unknown_method_falls_back(self): + """Unknown CLAUDE_AUTH_METHOD falls back to auto-detect.""" + with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "unknown_method"}, clear=False): + import src.auth + + importlib.reload(src.auth) + # Should fall back to claude_cli (default) + assert src.auth.auth_manager.auth_method in [ + "claude_cli", + "anthropic", + "bedrock", + "vertex", + ] + + def test_legacy_bedrock_env_var(self): + """CLAUDE_CODE_USE_BEDROCK=1 uses bedrock.""" + env = {"CLAUDE_CODE_USE_BEDROCK": "1"} + # Remove CLAUDE_AUTH_METHOD if present + env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"} + env_copy.update(env) + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "bedrock" + + def test_legacy_vertex_env_var(self): + """CLAUDE_CODE_USE_VERTEX=1 uses vertex.""" + env = {"CLAUDE_CODE_USE_VERTEX": "1"} + env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"} + env_copy.update(env) + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "vertex" + + def test_auto_detect_anthropic_key(self): + """ANTHROPIC_API_KEY auto-detects to anthropic.""" + env = {"ANTHROPIC_API_KEY": "test-key-12345678901234567890"} + env_copy = { + k: v + for k, v in os.environ.items() + if k not in ["CLAUDE_AUTH_METHOD", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"] + } + env_copy.update(env) + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "anthropic" + + def test_default_to_claude_cli(self): + """No env vars defaults to claude_cli.""" + env_copy = { + k: v + for k, v in os.environ.items() + if k + not in [ + "CLAUDE_AUTH_METHOD", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "ANTHROPIC_API_KEY", + ] + } + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.auth_method == "claude_cli" + + +class TestClaudeCodeAuthManagerValidation: + """Test authentication validation methods.""" + + def test_validate_anthropic_valid(self): + """Valid ANTHROPIC_API_KEY passes validation.""" + with patch.dict( + os.environ, + { + "CLAUDE_AUTH_METHOD": "anthropic", + "ANTHROPIC_API_KEY": "sk-ant-api03-validkey1234567890", + }, + ): + import src.auth + + importlib.reload(src.auth) + status = src.auth.auth_manager.auth_status + assert status["valid"] is True + assert status["method"] == "anthropic" + + def test_validate_anthropic_missing_key(self): + """Missing ANTHROPIC_API_KEY fails validation.""" + env_copy = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"} + env_copy["CLAUDE_AUTH_METHOD"] = "anthropic" + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + status = src.auth.auth_manager.auth_status + assert status["valid"] is False + assert any("ANTHROPIC_API_KEY" in err for err in status["errors"]) + + def test_validate_anthropic_short_key(self): + """Too short ANTHROPIC_API_KEY fails validation.""" + with patch.dict( + os.environ, + {"CLAUDE_AUTH_METHOD": "anthropic", "ANTHROPIC_API_KEY": "short"}, + ): + import src.auth + + importlib.reload(src.auth) + status = src.auth.auth_manager.auth_status + assert status["valid"] is False + assert any("too short" in err for err in status["errors"]) + + def test_validate_bedrock_valid(self): + """Valid Bedrock config passes validation.""" + with patch.dict( + os.environ, + { + "CLAUDE_AUTH_METHOD": "bedrock", + "CLAUDE_CODE_USE_BEDROCK": "1", + "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "AWS_REGION": "us-east-1", + }, + ): + import src.auth + + importlib.reload(src.auth) + status = src.auth.auth_manager.auth_status + assert status["valid"] is True + assert status["method"] == "bedrock" + + def test_validate_bedrock_missing_credentials(self): + """Missing AWS credentials fails Bedrock validation.""" + env_copy = { + k: v + for k, v in os.environ.items() + if k not in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"] + } + env_copy["CLAUDE_AUTH_METHOD"] = "bedrock" + env_copy["CLAUDE_CODE_USE_BEDROCK"] = "1" + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + status = src.auth.auth_manager.auth_status + assert status["valid"] is False + assert len(status["errors"]) > 0 + + def test_validate_vertex_valid(self): + """Valid Vertex config passes validation.""" + with patch.dict( + os.environ, + { + "CLAUDE_AUTH_METHOD": "vertex", + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "my-project-123", + "CLOUD_ML_REGION": "us-central1", + }, + ): + import src.auth + + importlib.reload(src.auth) + status = src.auth.auth_manager.auth_status + assert status["valid"] is True + assert status["method"] == "vertex" + + def test_validate_vertex_missing_config(self): + """Missing Vertex config fails validation.""" + env_copy = { + k: v + for k, v in os.environ.items() + if k not in ["ANTHROPIC_VERTEX_PROJECT_ID", "CLOUD_ML_REGION"] + } + env_copy["CLAUDE_AUTH_METHOD"] = "vertex" + env_copy["CLAUDE_CODE_USE_VERTEX"] = "1" + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + status = src.auth.auth_manager.auth_status + assert status["valid"] is False + + def test_validate_claude_cli_always_valid(self): + """Claude CLI auth is always considered valid initially.""" + env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"} + env_copy["CLAUDE_AUTH_METHOD"] = "cli" + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + status = src.auth.auth_manager.auth_status + assert status["valid"] is True + assert status["method"] == "claude_cli" + + +class TestClaudeCodeAuthManagerEnvVars: + """Test get_claude_code_env_vars()""" + + def test_anthropic_env_vars(self): + """Anthropic method returns ANTHROPIC_API_KEY.""" + with patch.dict( + os.environ, + { + "CLAUDE_AUTH_METHOD": "anthropic", + "ANTHROPIC_API_KEY": "test-key-12345", + }, + ): + import src.auth + + importlib.reload(src.auth) + env_vars = src.auth.auth_manager.get_claude_code_env_vars() + assert "ANTHROPIC_API_KEY" in env_vars + assert env_vars["ANTHROPIC_API_KEY"] == "test-key-12345" + + def test_bedrock_env_vars(self): + """Bedrock method returns AWS credentials.""" + with patch.dict( + os.environ, + { + "CLAUDE_AUTH_METHOD": "bedrock", + "CLAUDE_CODE_USE_BEDROCK": "1", + "AWS_ACCESS_KEY_ID": "AKIATEST", + "AWS_SECRET_ACCESS_KEY": "secretkey", + "AWS_REGION": "us-east-1", + }, + ): + import src.auth + + importlib.reload(src.auth) + env_vars = src.auth.auth_manager.get_claude_code_env_vars() + assert env_vars.get("CLAUDE_CODE_USE_BEDROCK") == "1" + assert "AWS_ACCESS_KEY_ID" in env_vars + assert "AWS_SECRET_ACCESS_KEY" in env_vars + assert "AWS_REGION" in env_vars + + def test_vertex_env_vars(self): + """Vertex method returns Google Cloud credentials.""" + with patch.dict( + os.environ, + { + "CLAUDE_AUTH_METHOD": "vertex", + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "my-project", + "CLOUD_ML_REGION": "us-central1", + }, + ): + import src.auth + + importlib.reload(src.auth) + env_vars = src.auth.auth_manager.get_claude_code_env_vars() + assert env_vars.get("CLAUDE_CODE_USE_VERTEX") == "1" + assert "ANTHROPIC_VERTEX_PROJECT_ID" in env_vars + assert "CLOUD_ML_REGION" in env_vars + + def test_cli_env_vars_empty(self): + """CLI method returns no environment variables.""" + env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"} + env_copy["CLAUDE_AUTH_METHOD"] = "cli" + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + env_vars = src.auth.auth_manager.get_claude_code_env_vars() + assert env_vars == {} + + +class TestVerifyApiKey: + """Test verify_api_key() function.""" + + @pytest.mark.asyncio + async def test_no_api_key_configured_allows_all(self): + """When no API_KEY is set, all requests are allowed.""" + env_copy = {k: v for k, v in os.environ.items() if k != "API_KEY"} + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + + # Mock auth_manager to have no API key + with patch.object(src.auth.auth_manager, "get_api_key", return_value=None): + mock_request = MagicMock() + result = await src.auth.verify_api_key(mock_request) + assert result is True + + @pytest.mark.asyncio + async def test_valid_api_key_passes(self): + """Valid API key in Authorization header passes.""" + with patch.dict(os.environ, {"API_KEY": "test-secret-key"}): + import src.auth + + importlib.reload(src.auth) + + from fastapi.security import HTTPAuthorizationCredentials + + mock_request = MagicMock() + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", credentials="test-secret-key" + ) + + with patch.object(src.auth.auth_manager, "get_api_key", return_value="test-secret-key"): + result = await src.auth.verify_api_key(mock_request, credentials) + assert result is True + + @pytest.mark.asyncio + async def test_invalid_api_key_raises_401(self): + """Invalid API key raises 401 HTTPException.""" + with patch.dict(os.environ, {"API_KEY": "correct-key"}): + import src.auth + + importlib.reload(src.auth) + + from fastapi.security import HTTPAuthorizationCredentials + + mock_request = MagicMock() + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="wrong-key") + + with patch.object(src.auth.auth_manager, "get_api_key", return_value="correct-key"): + with pytest.raises(HTTPException) as exc_info: + await src.auth.verify_api_key(mock_request, credentials) + assert exc_info.value.status_code == 401 + assert "Invalid API key" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_missing_credentials_raises_401(self): + """Missing credentials raise 401 HTTPException.""" + with patch.dict(os.environ, {"API_KEY": "test-key"}): + import src.auth + + importlib.reload(src.auth) + + mock_request = MagicMock() + # Mock security to return None (no credentials) + with patch.object(src.auth, "security", AsyncMock(return_value=None)): + with patch.object(src.auth.auth_manager, "get_api_key", return_value="test-key"): + with pytest.raises(HTTPException) as exc_info: + await src.auth.verify_api_key(mock_request, None) + assert exc_info.value.status_code == 401 + assert "Missing API key" in exc_info.value.detail + + +class TestValidateClaudeCodeAuth: + """Test validate_claude_code_auth() function.""" + + def test_valid_auth_returns_true(self): + """Valid auth returns (True, status).""" + with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "cli"}): + import src.auth + + importlib.reload(src.auth) + + is_valid, status = src.auth.validate_claude_code_auth() + assert is_valid is True + assert status["valid"] is True + + def test_invalid_auth_returns_false(self): + """Invalid auth returns (False, status).""" + env_copy = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"} + env_copy["CLAUDE_AUTH_METHOD"] = "anthropic" + with patch.dict(os.environ, env_copy, clear=True): + import src.auth + + importlib.reload(src.auth) + + is_valid, status = src.auth.validate_claude_code_auth() + assert is_valid is False + assert status["valid"] is False + + +class TestGetClaudeCodeAuthInfo: + """Test get_claude_code_auth_info() function.""" + + def test_returns_auth_info(self): + """Returns comprehensive auth information.""" + with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "cli"}): + import src.auth + + importlib.reload(src.auth) + + info = src.auth.get_claude_code_auth_info() + assert "method" in info + assert "status" in info + assert "environment_variables" in info + + +class TestGetApiKey: + """Test ClaudeCodeAuthManager.get_api_key()""" + + def test_returns_env_api_key(self): + """Returns API_KEY from environment.""" + with patch.dict(os.environ, {"API_KEY": "env-api-key"}): + import src.auth + + importlib.reload(src.auth) + assert src.auth.auth_manager.get_api_key() == "env-api-key" + + def test_returns_runtime_key_when_available(self): + """Returns runtime key when set in main module.""" + with patch.dict(os.environ, {"API_KEY": "env-key"}): + import src.auth + + importlib.reload(src.auth) + + # Mock the runtime API key + mock_main = MagicMock() + mock_main.runtime_api_key = "runtime-key" + + with patch.dict("sys.modules", {"src.main": mock_main}): + # Need to reload to pick up the mock + result = src.auth.auth_manager.get_api_key() + # May return env key if import fails, but shouldn't error + assert result in ["env-key", "runtime-key"] + + +# Reset module state after tests +@pytest.fixture(autouse=True) +def reset_auth_module(): + """Reset auth module after each test.""" + yield + # Restore default state + import src.auth + + importlib.reload(src.auth) diff --git a/tests/test_basic.py b/tests/test_basic.py index 9808483..084ccc7 100755 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -6,22 +6,26 @@ import sys import os +import pytest import requests + +from tests.conftest import requires_server from openai import OpenAI + def get_api_key(): """Get the appropriate API key for testing.""" # Check if user provided API key via environment if os.getenv("TEST_API_KEY"): return os.getenv("TEST_API_KEY") - + # Check server auth status try: response = requests.get("http://localhost:8000/v1/auth/status") if response.status_code == 200: auth_data = response.json() server_info = auth_data.get("server_info", {}) - + if not server_info.get("api_key_required", False): # No auth required, use a dummy key return "no-auth-required" @@ -34,9 +38,11 @@ def get_api_key(): except Exception as e: print(f"โš ๏ธ Could not check server auth status: {e}") print(" Assuming no authentication required") - + return "fallback-dummy-key" + +@requires_server def test_health_check(): """Test the health endpoint.""" print("Testing health check...") @@ -52,6 +58,8 @@ def test_health_check(): print(f"โœ— Cannot connect to server: {e}") return False + +@requires_server def test_models_endpoint(): """Test the models endpoint.""" print("\nTesting models endpoint...") @@ -68,28 +76,25 @@ def test_models_endpoint(): print(f"โœ— Models endpoint error: {e}") return False + +@requires_server def test_openai_sdk(): """Test with OpenAI SDK.""" print("\nTesting OpenAI SDK integration...") - + api_key = get_api_key() if api_key is None: print("โœ— Cannot run test - API key required but not provided") return False - + try: - client = OpenAI( - base_url="http://localhost:8000/v1", - api_key=api_key - ) - + client = OpenAI(base_url="http://localhost:8000/v1", api_key=api_key) + # Simple test - use a model supported by Claude Agent SDK response = client.chat.completions.create( model="claude-sonnet-4-5-20250929", # Use newer model supported by SDK - messages=[ - {"role": "user", "content": "Say 'Hello, World!' and nothing else."} - ], - max_tokens=50 + messages=[{"role": "user", "content": "Say 'Hello, World!' and nothing else."}], + max_tokens=50, ) content = response.choices[0].message.content @@ -103,41 +108,38 @@ def test_openai_sdk(): print(f"โœ“ OpenAI SDK test passed") print(f" Response: {content}") return True - + except Exception as e: print(f"โœ— OpenAI SDK test failed: {e}") return False + +@requires_server def test_streaming(): """Test streaming functionality.""" print("\nTesting streaming...") - + api_key = get_api_key() if api_key is None: print("โœ— Cannot run test - API key required but not provided") return False - + try: - client = OpenAI( - base_url="http://localhost:8000/v1", - api_key=api_key - ) - + client = OpenAI(base_url="http://localhost:8000/v1", api_key=api_key) + stream = client.chat.completions.create( model="claude-sonnet-4-5-20250929", # Use newer model supported by SDK - messages=[ - {"role": "user", "content": "Count from 1 to 3."} - ], - stream=True + messages=[{"role": "user", "content": "Count from 1 to 3."}], + stream=True, ) - + chunks_received = 0 content = "" for chunk in stream: chunks_received += 1 if chunk.choices[0].delta.content: content += chunk.choices[0].delta.content - + if chunks_received > 0: print(f"โœ“ Streaming test passed ({chunks_received} chunks)") print(f" Response: {content[:50]}...") @@ -145,18 +147,62 @@ def test_streaming(): else: print("โœ— No streaming chunks received") return False - + except Exception as e: print(f"โœ— Streaming test failed: {e}") return False + +@requires_server +def test_version_endpoint(): + """Test the version endpoint.""" + print("\nTesting version endpoint...") + try: + response = requests.get("http://localhost:8000/version") + if response.status_code == 200: + data = response.json() + assert "version" in data, "Response missing 'version' field" + assert "service" in data, "Response missing 'service' field" + assert data["service"] == "claude-code-openai-wrapper" + print(f"โœ“ Version endpoint works. Version: {data['version']}") + return True + else: + print(f"โœ— Version endpoint failed: {response.status_code}") + return False + except Exception as e: + print(f"โœ— Version endpoint error: {e}") + return False + + +@requires_server +def test_landing_page(): + """Test the landing page returns HTML.""" + print("\nTesting landing page...") + try: + response = requests.get("http://localhost:8000/") + if response.status_code == 200: + content_type = response.headers.get("content-type", "") + assert "text/html" in content_type, f"Expected HTML, got {content_type}" + assert "Claude Code OpenAI Wrapper" in response.text, "Missing page title" + assert "Quick Start" in response.text, "Missing Quick Start section" + assert "API Endpoints" in response.text, "Missing API Endpoints section" + print("โœ“ Landing page works") + return True + else: + print(f"โœ— Landing page failed: {response.status_code}") + return False + except Exception as e: + print(f"โœ— Landing page error: {e}") + return False + + def main(): """Run all tests.""" print("Claude Code OpenAI Wrapper - Basic Tests") - print("="*50) + print("=" * 50) print("Make sure the server is running: python main.py") - print("="*50) - + print("=" * 50) + # Show API key status api_key = get_api_key() if api_key: @@ -166,23 +212,18 @@ def main(): print("๐Ÿ”‘ Server authentication: Required (using provided key)") else: print("โŒ Server authentication: Required but no key available") - print("="*50) - - tests = [ - test_health_check, - test_models_endpoint, - test_openai_sdk, - test_streaming - ] - + print("=" * 50) + + tests = [test_health_check, test_models_endpoint, test_openai_sdk, test_streaming] + passed = 0 for test in tests: if test(): passed += 1 - - print("\n" + "="*50) + + print("\n" + "=" * 50) print(f"Tests completed: {passed}/{len(tests)} passed") - + if passed == len(tests): print("โœ“ All tests passed! The wrapper is working correctly.") return 0 @@ -190,5 +231,6 @@ def main(): print("โœ— Some tests failed. Check the server logs for details.") return 1 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/tests/test_claude_cli_unit.py b/tests/test_claude_cli_unit.py new file mode 100644 index 0000000..c67c7fe --- /dev/null +++ b/tests/test_claude_cli_unit.py @@ -0,0 +1,725 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/claude_cli.py + +Tests the ClaudeCodeCLI class methods. +These are pure unit tests that don't require a running server or Claude SDK. +""" + +import pytest +import os +import tempfile +import sys +from unittest.mock import MagicMock, patch, AsyncMock +from pathlib import Path + + +class TestClaudeCodeCLIParseMessage: + """Test ClaudeCodeCLI.parse_claude_message()""" + + @pytest.fixture + def cli_class(self): + """Get the ClaudeCodeCLI class without instantiating.""" + from src.claude_cli import ClaudeCodeCLI + + return ClaudeCodeCLI + + def test_parse_result_message(self, cli_class): + """Parses result message with 'result' field.""" + # Use classmethod-like approach - create minimal mock instance + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + messages = [{"subtype": "success", "result": "The final answer is 42."}] + result = cli.parse_claude_message(messages) + assert result == "The final answer is 42." + + def test_parse_assistant_message_with_content_list(self, cli_class): + """Parses assistant message with content list.""" + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + messages = [ + { + "content": [ + {"type": "text", "text": "Hello "}, + {"type": "text", "text": "World!"}, + ] + } + ] + result = cli.parse_claude_message(messages) + assert result == "Hello \nWorld!" + + def test_parse_assistant_message_with_textblock_objects(self, cli_class): + """Parses assistant message with TextBlock objects.""" + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + # Mock TextBlock object + text_block = MagicMock() + text_block.text = "Response text" + + messages = [{"content": [text_block]}] + result = cli.parse_claude_message(messages) + assert result == "Response text" + + def test_parse_assistant_message_with_string_content(self, cli_class): + """Parses assistant message with string content blocks.""" + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + messages = [{"content": ["Part 1", "Part 2"]}] + result = cli.parse_claude_message(messages) + assert result == "Part 1\nPart 2" + + def test_parse_old_format_assistant_message(self, cli_class): + """Parses old format assistant message.""" + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + messages = [ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Old format response"}]}, + } + ] + result = cli.parse_claude_message(messages) + assert result == "Old format response" + + def test_parse_old_format_string_content(self, cli_class): + """Parses old format with string content.""" + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + messages = [ + { + "type": "assistant", + "message": {"content": "Simple string content"}, + } + ] + result = cli.parse_claude_message(messages) + assert result == "Simple string content" + + def test_parse_empty_messages_returns_none(self, cli_class): + """Empty messages list returns None.""" + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + result = cli.parse_claude_message([]) + assert result is None + + def test_parse_no_matching_messages_returns_none(self, cli_class): + """No matching messages returns None.""" + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + messages = [{"type": "system", "content": "System message"}] + result = cli.parse_claude_message(messages) + assert result is None + + def test_parse_uses_last_text(self, cli_class): + """When multiple messages, uses the last one with text.""" + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + messages = [ + {"content": [{"type": "text", "text": "First response"}]}, + {"content": [{"type": "text", "text": "Second response"}]}, + ] + result = cli.parse_claude_message(messages) + assert result == "Second response" + + def test_result_takes_priority(self, cli_class): + """ResultMessage.result takes priority over AssistantMessage.""" + cli = MagicMock() + cli.parse_claude_message = cli_class.parse_claude_message.__get__(cli, cli_class) + + messages = [ + {"content": [{"type": "text", "text": "Some response"}]}, + {"subtype": "success", "result": "Final result"}, + ] + result = cli.parse_claude_message(messages) + assert result == "Final result" + + +class TestClaudeCodeCLIExtractMetadata: + """Test ClaudeCodeCLI.extract_metadata()""" + + @pytest.fixture + def cli_class(self): + """Get the ClaudeCodeCLI class.""" + from src.claude_cli import ClaudeCodeCLI + + return ClaudeCodeCLI + + def test_extract_from_result_message(self, cli_class): + """Extracts metadata from new SDK ResultMessage.""" + cli = MagicMock() + cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class) + + messages = [ + { + "subtype": "success", + "total_cost_usd": 0.05, + "duration_ms": 1500, + "num_turns": 3, + "session_id": "sess-123", + } + ] + metadata = cli.extract_metadata(messages) + + assert metadata["total_cost_usd"] == 0.05 + assert metadata["duration_ms"] == 1500 + assert metadata["num_turns"] == 3 + assert metadata["session_id"] == "sess-123" + + def test_extract_from_system_init_message(self, cli_class): + """Extracts metadata from SystemMessage init.""" + cli = MagicMock() + cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class) + + messages = [ + { + "subtype": "init", + "data": {"session_id": "init-sess-456", "model": "claude-3-opus"}, + } + ] + metadata = cli.extract_metadata(messages) + + assert metadata["session_id"] == "init-sess-456" + assert metadata["model"] == "claude-3-opus" + + def test_extract_from_old_result_message(self, cli_class): + """Extracts metadata from old format result message.""" + cli = MagicMock() + cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class) + + messages = [ + { + "type": "result", + "total_cost_usd": 0.03, + "duration_ms": 1000, + "num_turns": 2, + "session_id": "old-sess", + } + ] + metadata = cli.extract_metadata(messages) + + assert metadata["total_cost_usd"] == 0.03 + assert metadata["duration_ms"] == 1000 + assert metadata["session_id"] == "old-sess" + + def test_extract_from_old_system_init(self, cli_class): + """Extracts metadata from old format system init.""" + cli = MagicMock() + cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class) + + messages = [ + { + "type": "system", + "subtype": "init", + "session_id": "old-init-sess", + "model": "claude-3-haiku", + } + ] + metadata = cli.extract_metadata(messages) + + assert metadata["session_id"] == "old-init-sess" + assert metadata["model"] == "claude-3-haiku" + + def test_extract_empty_messages_returns_defaults(self, cli_class): + """Empty messages returns default metadata.""" + cli = MagicMock() + cli.extract_metadata = cli_class.extract_metadata.__get__(cli, cli_class) + + metadata = cli.extract_metadata([]) + + assert metadata["session_id"] is None + assert metadata["total_cost_usd"] == 0.0 + assert metadata["duration_ms"] == 0 + assert metadata["num_turns"] == 0 + assert metadata["model"] is None + + +class TestClaudeCodeCLIEstimateTokenUsage: + """Test ClaudeCodeCLI.estimate_token_usage()""" + + @pytest.fixture + def cli_class(self): + """Get the ClaudeCodeCLI class.""" + from src.claude_cli import ClaudeCodeCLI + + return ClaudeCodeCLI + + def test_estimate_basic(self, cli_class): + """Basic token estimation.""" + cli = MagicMock() + cli.estimate_token_usage = cli_class.estimate_token_usage.__get__(cli, cli_class) + + # 12 chars / 4 = 3 tokens, 16 chars / 4 = 4 tokens + result = cli.estimate_token_usage("Hello World!", "Response here!") + assert result["prompt_tokens"] == 3 + assert result["completion_tokens"] == 3 + assert result["total_tokens"] == 6 + + def test_estimate_minimum_one_token(self, cli_class): + """Minimum is 1 token.""" + cli = MagicMock() + cli.estimate_token_usage = cli_class.estimate_token_usage.__get__(cli, cli_class) + + result = cli.estimate_token_usage("Hi", "X") + assert result["prompt_tokens"] >= 1 + assert result["completion_tokens"] >= 1 + + def test_estimate_long_text(self, cli_class): + """Longer text estimation.""" + cli = MagicMock() + cli.estimate_token_usage = cli_class.estimate_token_usage.__get__(cli, cli_class) + + prompt = "a" * 400 # 100 tokens + completion = "b" * 200 # 50 tokens + result = cli.estimate_token_usage(prompt, completion) + + assert result["prompt_tokens"] == 100 + assert result["completion_tokens"] == 50 + assert result["total_tokens"] == 150 + + def test_estimate_empty_strings(self, cli_class): + """Empty strings return minimum 1 token each.""" + cli = MagicMock() + cli.estimate_token_usage = cli_class.estimate_token_usage.__get__(cli, cli_class) + + result = cli.estimate_token_usage("", "") + assert result["prompt_tokens"] == 1 + assert result["completion_tokens"] == 1 + + +class TestClaudeCodeCLICleanupTempDir: + """Test ClaudeCodeCLI._cleanup_temp_dir()""" + + def test_cleanup_removes_existing_dir(self): + """Cleanup removes existing temp directory.""" + from src.claude_cli import ClaudeCodeCLI + + # Create a mock instance + cli = MagicMock(spec=ClaudeCodeCLI) + + # Create an actual temp directory + temp_dir = tempfile.mkdtemp(prefix="test_cleanup_") + cli.temp_dir = temp_dir + + # Bind the method + cli._cleanup_temp_dir = ClaudeCodeCLI._cleanup_temp_dir.__get__(cli, ClaudeCodeCLI) + + assert os.path.exists(temp_dir) + + cli._cleanup_temp_dir() + + assert not os.path.exists(temp_dir) + + def test_cleanup_handles_missing_dir(self): + """Cleanup handles already-deleted directory gracefully.""" + from src.claude_cli import ClaudeCodeCLI + + cli = MagicMock(spec=ClaudeCodeCLI) + cli.temp_dir = "/nonexistent/test/dir/12345" + + cli._cleanup_temp_dir = ClaudeCodeCLI._cleanup_temp_dir.__get__(cli, ClaudeCodeCLI) + + # Should not raise + cli._cleanup_temp_dir() + + def test_cleanup_no_temp_dir_set(self): + """Cleanup does nothing when temp_dir is None.""" + from src.claude_cli import ClaudeCodeCLI + + cli = MagicMock(spec=ClaudeCodeCLI) + cli.temp_dir = None + + cli._cleanup_temp_dir = ClaudeCodeCLI._cleanup_temp_dir.__get__(cli, ClaudeCodeCLI) + + # Should not raise + cli._cleanup_temp_dir() + + +class TestClaudeCodeCLIInit: + """Test ClaudeCodeCLI.__init__() initialization logic.""" + + def test_timeout_conversion(self): + """Timeout is converted from milliseconds to seconds.""" + # Test the conversion logic directly + timeout_ms = 120000 + timeout_seconds = timeout_ms / 1000 + assert timeout_seconds == 120.0 + + def test_path_handling_with_valid_dir(self): + """Valid directory path is handled correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) + assert path.exists() + + def test_path_handling_with_invalid_dir(self): + """Invalid directory path is detected.""" + path = Path("/nonexistent/path/12345") + assert not path.exists() + + def test_init_with_cwd(self): + """ClaudeCodeCLI initializes with provided cwd.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("src.auth.validate_claude_code_auth") as mock_validate: + with patch("src.auth.auth_manager") as mock_auth: + mock_validate.return_value = (True, {"method": "anthropic"}) + mock_auth.get_claude_code_env_vars.return_value = {} + + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd=temp_dir) + + assert cli.cwd == Path(temp_dir) + assert cli.temp_dir is None + assert cli.timeout == 600.0 # 600000ms / 1000 + + def test_init_with_invalid_cwd_raises(self): + """ClaudeCodeCLI raises ValueError for non-existent cwd.""" + with patch("src.auth.validate_claude_code_auth") as mock_validate: + with patch("src.auth.auth_manager") as mock_auth: + mock_validate.return_value = (True, {"method": "anthropic"}) + mock_auth.get_claude_code_env_vars.return_value = {} + + from src.claude_cli import ClaudeCodeCLI + + with pytest.raises(ValueError, match="Working directory does not exist"): + ClaudeCodeCLI(cwd="/nonexistent/path/12345") + + def test_init_without_cwd_creates_temp(self): + """ClaudeCodeCLI creates temp directory when no cwd provided.""" + with patch("src.auth.validate_claude_code_auth") as mock_validate: + with patch("src.auth.auth_manager") as mock_auth: + with patch("atexit.register"): # Don't actually register cleanup + mock_validate.return_value = (True, {"method": "anthropic"}) + mock_auth.get_claude_code_env_vars.return_value = {} + + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI() + + assert cli.temp_dir is not None + assert cli.cwd == Path(cli.temp_dir) + assert "claude_code_workspace_" in cli.temp_dir + + # Cleanup + if cli.temp_dir and os.path.exists(cli.temp_dir): + import shutil + + shutil.rmtree(cli.temp_dir) + + def test_init_with_custom_timeout(self): + """ClaudeCodeCLI uses custom timeout.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("src.auth.validate_claude_code_auth") as mock_validate: + with patch("src.auth.auth_manager") as mock_auth: + mock_validate.return_value = (True, {"method": "anthropic"}) + mock_auth.get_claude_code_env_vars.return_value = {} + + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(timeout=120000, cwd=temp_dir) + + assert cli.timeout == 120.0 + + def test_init_auth_validation_failure(self): + """ClaudeCodeCLI handles auth validation failure gracefully.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("src.auth.validate_claude_code_auth") as mock_validate: + with patch("src.auth.auth_manager") as mock_auth: + # Auth fails + mock_validate.return_value = (False, {"errors": ["Missing API key"]}) + mock_auth.get_claude_code_env_vars.return_value = {} + + from src.claude_cli import ClaudeCodeCLI + + # Should not raise, just log warning + cli = ClaudeCodeCLI(cwd=temp_dir) + assert cli.cwd == Path(temp_dir) + + +class TestClaudeCodeCLIVerifyCLI: + """Test ClaudeCodeCLI.verify_cli()""" + + @pytest.fixture + def cli_instance(self): + """Create a CLI instance with mocked auth.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("src.auth.validate_claude_code_auth") as mock_validate: + with patch("src.auth.auth_manager") as mock_auth: + mock_validate.return_value = (True, {"method": "anthropic"}) + mock_auth.get_claude_code_env_vars.return_value = {} + + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd=temp_dir) + yield cli + + @pytest.mark.asyncio + async def test_verify_cli_success(self, cli_instance): + """verify_cli returns True on successful SDK response.""" + mock_message = {"type": "assistant", "content": [{"type": "text", "text": "Hello"}]} + + async def mock_query(*args, **kwargs): + yield mock_message + + with patch("src.claude_cli.query", mock_query): + result = await cli_instance.verify_cli() + assert result is True + + @pytest.mark.asyncio + async def test_verify_cli_no_messages(self, cli_instance): + """verify_cli returns False when no messages returned.""" + + async def mock_query(*args, **kwargs): + return + yield # Make it a generator but yield nothing + + with patch("src.claude_cli.query", mock_query): + result = await cli_instance.verify_cli() + assert result is False + + @pytest.mark.asyncio + async def test_verify_cli_exception(self, cli_instance): + """verify_cli returns False on exception.""" + + async def mock_query(*args, **kwargs): + raise RuntimeError("SDK error") + yield # Make it a generator + + with patch("src.claude_cli.query", mock_query): + result = await cli_instance.verify_cli() + assert result is False + + +class TestClaudeCodeCLIRunCompletion: + """Test ClaudeCodeCLI.run_completion()""" + + @pytest.fixture + def cli_instance(self): + """Create a CLI instance with mocked auth.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("src.auth.validate_claude_code_auth") as mock_validate: + with patch("src.auth.auth_manager") as mock_auth: + mock_validate.return_value = (True, {"method": "anthropic"}) + mock_auth.get_claude_code_env_vars.return_value = { + "ANTHROPIC_API_KEY": "test-key" + } + + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd=temp_dir) + yield cli + + @pytest.mark.asyncio + async def test_run_completion_basic(self, cli_instance): + """run_completion yields messages from SDK.""" + mock_message = {"type": "assistant", "content": [{"type": "text", "text": "Hello"}]} + + async def mock_query(*args, **kwargs): + yield mock_message + + with patch("src.claude_cli.query", mock_query): + messages = [] + async for msg in cli_instance.run_completion("Hello"): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0] == mock_message + + @pytest.mark.asyncio + async def test_run_completion_with_system_prompt(self, cli_instance): + """run_completion sets system_prompt option.""" + mock_message = {"type": "assistant", "content": "Response"} + captured_options = [] + + async def mock_query(prompt, options): + captured_options.append(options) + yield mock_message + + with patch("src.claude_cli.query", mock_query): + async for _ in cli_instance.run_completion("Hello", system_prompt="You are helpful"): + pass + + assert len(captured_options) == 1 + opts = captured_options[0] + assert opts.system_prompt == {"type": "text", "text": "You are helpful"} + + @pytest.mark.asyncio + async def test_run_completion_with_model(self, cli_instance): + """run_completion sets model option.""" + mock_message = {"type": "assistant"} + captured_options = [] + + async def mock_query(prompt, options): + captured_options.append(options) + yield mock_message + + with patch("src.claude_cli.query", mock_query): + async for _ in cli_instance.run_completion("Hello", model="claude-3-opus"): + pass + + assert captured_options[0].model == "claude-3-opus" + + @pytest.mark.asyncio + async def test_run_completion_with_tool_restrictions(self, cli_instance): + """run_completion sets allowed/disallowed tools.""" + mock_message = {"type": "assistant"} + captured_options = [] + + async def mock_query(prompt, options): + captured_options.append(options) + yield mock_message + + with patch("src.claude_cli.query", mock_query): + async for _ in cli_instance.run_completion( + "Hello", + allowed_tools=["Bash", "Read"], + disallowed_tools=["Task"], + ): + pass + + assert captured_options[0].allowed_tools == ["Bash", "Read"] + assert captured_options[0].disallowed_tools == ["Task"] + + @pytest.mark.asyncio + async def test_run_completion_with_permission_mode(self, cli_instance): + """run_completion sets permission_mode.""" + mock_message = {"type": "assistant"} + captured_options = [] + + async def mock_query(prompt, options): + captured_options.append(options) + yield mock_message + + with patch("src.claude_cli.query", mock_query): + async for _ in cli_instance.run_completion("Hello", permission_mode="acceptEdits"): + pass + + assert captured_options[0].permission_mode == "acceptEdits" + + @pytest.mark.asyncio + async def test_run_completion_continue_session(self, cli_instance): + """run_completion sets continue_session option.""" + mock_message = {"type": "assistant"} + captured_options = [] + + async def mock_query(prompt, options): + captured_options.append(options) + yield mock_message + + with patch("src.claude_cli.query", mock_query): + async for _ in cli_instance.run_completion("Hello", continue_session=True): + pass + + assert captured_options[0].continue_session is True + + @pytest.mark.asyncio + async def test_run_completion_resume_session(self, cli_instance): + """run_completion sets resume option for session_id.""" + mock_message = {"type": "assistant"} + captured_options = [] + + async def mock_query(prompt, options): + captured_options.append(options) + yield mock_message + + with patch("src.claude_cli.query", mock_query): + async for _ in cli_instance.run_completion("Hello", session_id="sess-123"): + pass + + assert captured_options[0].resume == "sess-123" + + @pytest.mark.asyncio + async def test_run_completion_converts_objects_to_dicts(self, cli_instance): + """run_completion converts message objects to dicts.""" + # Create a mock object with attributes + mock_obj = MagicMock() + mock_obj.type = "assistant" + mock_obj.content = "Hello" + + async def mock_query(*args, **kwargs): + yield mock_obj + + with patch("src.claude_cli.query", mock_query): + messages = [] + async for msg in cli_instance.run_completion("Hello"): + messages.append(msg) + + assert len(messages) == 1 + # Should be converted to dict + assert isinstance(messages[0], dict) + assert "type" in messages[0] + + @pytest.mark.asyncio + async def test_run_completion_exception_yields_error(self, cli_instance): + """run_completion yields error message on exception.""" + + async def mock_query(*args, **kwargs): + raise RuntimeError("SDK failed") + yield # Make it a generator + + with patch("src.claude_cli.query", mock_query): + messages = [] + async for msg in cli_instance.run_completion("Hello"): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "result" + assert messages[0]["subtype"] == "error_during_execution" + assert messages[0]["is_error"] is True + assert "SDK failed" in messages[0]["error_message"] + + @pytest.mark.asyncio + async def test_run_completion_restores_env_vars(self, cli_instance): + """run_completion restores environment variables after execution.""" + # Set an env var that will be modified + original_key = os.environ.get("ANTHROPIC_API_KEY") + + mock_message = {"type": "assistant"} + + async def mock_query(*args, **kwargs): + yield mock_message + + with patch("src.claude_cli.query", mock_query): + async for _ in cli_instance.run_completion("Hello"): + pass + + # Env should be restored + if original_key is None: + assert ( + "ANTHROPIC_API_KEY" not in os.environ + or os.environ.get("ANTHROPIC_API_KEY") == original_key + ) + else: + assert os.environ.get("ANTHROPIC_API_KEY") == original_key + + +class TestClaudeCodeCLICleanupException: + """Test ClaudeCodeCLI._cleanup_temp_dir() exception handling.""" + + def test_cleanup_exception_is_caught(self): + """Cleanup catches exceptions during rmtree.""" + from src.claude_cli import ClaudeCodeCLI + + cli = MagicMock(spec=ClaudeCodeCLI) + temp_dir = tempfile.mkdtemp(prefix="test_cleanup_exc_") + cli.temp_dir = temp_dir + + # Bind the real method + cli._cleanup_temp_dir = ClaudeCodeCLI._cleanup_temp_dir.__get__(cli, ClaudeCodeCLI) + + with patch("shutil.rmtree", side_effect=PermissionError("Cannot delete")): + # Should not raise + cli._cleanup_temp_dir() + + # Clean up manually + import shutil + + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 6799285..3592818 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -4,11 +4,16 @@ Run this while the server is running on localhost:8000 """ +import pytest import requests + +from tests.conftest import requires_server import json BASE_URL = "http://localhost:8000" + +@requires_server def test_health(): print("Testing /health endpoint...") try: @@ -20,6 +25,8 @@ def test_health(): print(f" Error: {e}") return False + +@requires_server def test_auth_status(): print("\nTesting /v1/auth/status endpoint...") try: @@ -31,6 +38,8 @@ def test_auth_status(): print(f" Error: {e}") return False + +@requires_server def test_models(): print("\nTesting /v1/models endpoint...") try: @@ -38,74 +47,81 @@ def test_models(): print(f" Status: {response.status_code}") models = response.json() print(f" Found {len(models.get('data', []))} models") - for model in models.get('data', [])[:3]: # Show first 3 + for model in models.get("data", [])[:3]: # Show first 3 print(f" - {model.get('id')}") return response.status_code == 200 except Exception as e: print(f" Error: {e}") return False + +@requires_server def test_chat_completion(): print("\nTesting /v1/chat/completions endpoint...") try: payload = { "model": "claude-3-5-haiku-20241022", # Use fastest model "messages": [ - {"role": "user", "content": "Say 'Hello, SDK integration working!' and nothing else."} + { + "role": "user", + "content": "Say 'Hello, SDK integration working!' and nothing else.", + } ], - "max_tokens": 50 + "max_tokens": 50, } - + response = requests.post( f"{BASE_URL}/v1/chat/completions", json=payload, - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, ) - + print(f" Status: {response.status_code}") - + if response.status_code == 200: result = response.json() - content = result.get('choices', [{}])[0].get('message', {}).get('content', '') + content = result.get("choices", [{}])[0].get("message", {}).get("content", "") print(f" Response: {content}") print(f" Usage: {result.get('usage', {})}") return True else: print(f" Error: {response.text}") return False - + except Exception as e: print(f" Error: {e}") return False + def main(): print("Claude Code OpenAI Wrapper - Endpoint Tests") print("=" * 50) - + tests = [ ("Health Check", test_health), - ("Auth Status", test_auth_status), + ("Auth Status", test_auth_status), ("Models List", test_models), - ("Chat Completion", test_chat_completion) + ("Chat Completion", test_chat_completion), ] - + passed = 0 total = len(tests) - + for name, test_func in tests: if test_func(): print(f"โœ“ {name} passed") passed += 1 else: print(f"โœ— {name} failed") - + print("=" * 50) print(f"Results: {passed}/{total} tests passed") - + if passed == total: print("๐ŸŽ‰ All tests passed! SDK integration is working correctly.") else: print("โŒ Some tests failed. Check server logs for details.") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_mcp_client_unit.py b/tests/test_mcp_client_unit.py new file mode 100644 index 0000000..b983fdc --- /dev/null +++ b/tests/test_mcp_client_unit.py @@ -0,0 +1,887 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/mcp_client.py + +Tests the MCPClient, MCPServerConfig, and MCPServerConnection classes. +These are pure unit tests that don't require actual MCP servers. +""" + +import pytest +from datetime import datetime +from unittest.mock import MagicMock, patch, AsyncMock +import threading + +from src.mcp_client import ( + MCPServerConfig, + MCPServerConnection, + MCPClient, + mcp_client, + MCP_AVAILABLE, +) + + +class TestMCPServerConfig: + """Test the MCPServerConfig dataclass.""" + + def test_creation_with_required_fields(self): + """MCPServerConfig can be created with just required fields.""" + config = MCPServerConfig(name="test-server", command="test-cmd") + assert config.name == "test-server" + assert config.command == "test-cmd" + assert config.args == [] + assert config.env is None + assert config.description == "" + assert config.enabled is True + + def test_creation_with_all_fields(self): + """MCPServerConfig can be created with all fields.""" + config = MCPServerConfig( + name="full-server", + command="/usr/bin/server", + args=["--port", "8080"], + env={"DEBUG": "1"}, + description="A full test server", + enabled=False, + ) + assert config.name == "full-server" + assert config.command == "/usr/bin/server" + assert config.args == ["--port", "8080"] + assert config.env == {"DEBUG": "1"} + assert config.description == "A full test server" + assert config.enabled is False + + def test_args_default_is_empty_list(self): + """Default args is an empty list (not shared between instances).""" + config1 = MCPServerConfig(name="s1", command="cmd") + config2 = MCPServerConfig(name="s2", command="cmd") + + config1.args.append("--flag") + assert "--flag" not in config2.args + + +class TestMCPServerConnection: + """Test the MCPServerConnection dataclass.""" + + @pytest.fixture + def mock_config(self): + """Create a mock server config.""" + return MCPServerConfig(name="test", command="test-cmd") + + def test_creation_with_required_fields(self, mock_config): + """MCPServerConnection can be created with required fields.""" + mock_session = MagicMock() + mock_read = MagicMock() + mock_write = MagicMock() + + connection = MCPServerConnection( + config=mock_config, + session=mock_session, + read_stream=mock_read, + write_stream=mock_write, + ) + assert connection.config is mock_config + assert connection.session is mock_session + assert isinstance(connection.connected_at, datetime) + assert connection.available_tools == [] + assert connection.available_resources == [] + assert connection.available_prompts == [] + + def test_creation_with_capabilities(self, mock_config): + """MCPServerConnection can be created with capabilities.""" + connection = MCPServerConnection( + config=mock_config, + session=MagicMock(), + read_stream=MagicMock(), + write_stream=MagicMock(), + available_tools=[{"name": "tool1"}], + available_resources=[{"uri": "file://test"}], + available_prompts=[{"name": "prompt1"}], + ) + assert len(connection.available_tools) == 1 + assert len(connection.available_resources) == 1 + assert len(connection.available_prompts) == 1 + + +class TestMCPClient: + """Test the MCPClient class.""" + + @pytest.fixture + def client(self): + """Create a fresh MCPClient for each test.""" + return MCPClient() + + def test_initialization(self, client): + """MCPClient initializes with empty servers and connections.""" + assert client.servers == {} + assert client.connections == {} + + def test_is_available(self, client): + """is_available returns MCP_AVAILABLE constant.""" + assert client.is_available() == MCP_AVAILABLE + + def test_register_server(self, client): + """register_server adds server configuration.""" + config = MCPServerConfig(name="test-server", command="test-cmd") + client.register_server(config) + + assert "test-server" in client.servers + assert client.servers["test-server"] is config + + def test_register_server_overwrites_existing(self, client): + """register_server overwrites existing configuration.""" + config1 = MCPServerConfig(name="test-server", command="cmd1") + config2 = MCPServerConfig(name="test-server", command="cmd2") + + client.register_server(config1) + client.register_server(config2) + + assert client.servers["test-server"].command == "cmd2" + + def test_unregister_server_existing(self, client): + """unregister_server removes existing server.""" + config = MCPServerConfig(name="test-server", command="cmd") + client.register_server(config) + assert "test-server" in client.servers + + result = client.unregister_server("test-server") + assert result is True + assert "test-server" not in client.servers + + def test_unregister_server_nonexistent(self, client): + """unregister_server returns False for nonexistent server.""" + result = client.unregister_server("nonexistent") + assert result is False + + def test_list_servers(self, client): + """list_servers returns all registered servers.""" + config1 = MCPServerConfig(name="server1", command="cmd1") + config2 = MCPServerConfig(name="server2", command="cmd2") + + client.register_server(config1) + client.register_server(config2) + + servers = client.list_servers() + assert len(servers) == 2 + names = [s.name for s in servers] + assert "server1" in names + assert "server2" in names + + def test_list_servers_empty(self, client): + """list_servers returns empty list when no servers registered.""" + assert client.list_servers() == [] + + def test_get_server_existing(self, client): + """get_server returns existing server config.""" + config = MCPServerConfig(name="test-server", command="cmd") + client.register_server(config) + + result = client.get_server("test-server") + assert result is config + + def test_get_server_nonexistent(self, client): + """get_server returns None for nonexistent server.""" + result = client.get_server("nonexistent") + assert result is None + + def test_list_connected_servers_empty(self, client): + """list_connected_servers returns empty list when no connections.""" + assert client.list_connected_servers() == [] + + def test_list_connected_servers_with_connections(self, client): + """list_connected_servers returns names of connected servers.""" + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=MagicMock(), + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + result = client.list_connected_servers() + assert result == ["test"] + + def test_get_connection_existing(self, client): + """get_connection returns existing connection.""" + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=MagicMock(), + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + result = client.get_connection("test") + assert result is connection + + def test_get_connection_nonexistent(self, client): + """get_connection returns None for nonexistent connection.""" + result = client.get_connection("nonexistent") + assert result is None + + def test_get_all_tools_empty(self, client): + """get_all_tools returns empty dict when no connections.""" + assert client.get_all_tools() == {} + + def test_get_all_tools_with_connections(self, client): + """get_all_tools returns tools from all connections.""" + config1 = MCPServerConfig(name="server1", command="cmd") + config2 = MCPServerConfig(name="server2", command="cmd") + + conn1 = MCPServerConnection( + config=config1, + session=MagicMock(), + read_stream=MagicMock(), + write_stream=MagicMock(), + available_tools=[{"name": "tool1"}], + ) + conn2 = MCPServerConnection( + config=config2, + session=MagicMock(), + read_stream=MagicMock(), + write_stream=MagicMock(), + available_tools=[{"name": "tool2"}, {"name": "tool3"}], + ) + + client.connections["server1"] = conn1 + client.connections["server2"] = conn2 + + result = client.get_all_tools() + assert "server1" in result + assert "server2" in result + assert len(result["server1"]) == 1 + assert len(result["server2"]) == 2 + + def test_get_stats_empty(self, client): + """get_stats returns correct stats when empty.""" + stats = client.get_stats() + + assert stats["mcp_available"] == MCP_AVAILABLE + assert stats["registered_servers"] == 0 + assert stats["connected_servers"] == 0 + assert stats["total_tools"] == 0 + assert stats["total_resources"] == 0 + assert stats["total_prompts"] == 0 + assert stats["servers"] == [] + + def test_get_stats_with_servers(self, client): + """get_stats includes registered server info.""" + config = MCPServerConfig( + name="test-server", + command="cmd", + description="Test server", + enabled=True, + ) + client.register_server(config) + + stats = client.get_stats() + assert stats["registered_servers"] == 1 + assert len(stats["servers"]) == 1 + assert stats["servers"][0]["name"] == "test-server" + assert stats["servers"][0]["enabled"] is True + assert stats["servers"][0]["connected"] is False + + def test_get_stats_with_connections(self, client): + """get_stats counts tools, resources, prompts correctly.""" + config = MCPServerConfig(name="test", command="cmd") + client.register_server(config) + + connection = MCPServerConnection( + config=config, + session=MagicMock(), + read_stream=MagicMock(), + write_stream=MagicMock(), + available_tools=[{"name": "t1"}, {"name": "t2"}], + available_resources=[{"uri": "r1"}], + available_prompts=[{"name": "p1"}, {"name": "p2"}, {"name": "p3"}], + ) + client.connections["test"] = connection + + stats = client.get_stats() + assert stats["connected_servers"] == 1 + assert stats["total_tools"] == 2 + assert stats["total_resources"] == 1 + assert stats["total_prompts"] == 3 + assert stats["servers"][0]["connected"] is True + + +class TestMCPClientAsync: + """Test async methods of MCPClient.""" + + @pytest.fixture + def client(self): + """Create a fresh MCPClient for each test.""" + return MCPClient() + + @pytest.mark.asyncio + async def test_connect_server_not_registered(self, client): + """connect_server returns False for unregistered server.""" + result = await client.connect_server("nonexistent") + assert result is False + + @pytest.mark.asyncio + async def test_connect_server_disabled(self, client): + """connect_server returns False for disabled server.""" + config = MCPServerConfig(name="disabled", command="cmd", enabled=False) + client.register_server(config) + + result = await client.connect_server("disabled") + assert result is False + + @pytest.mark.asyncio + async def test_connect_server_already_connected(self, client): + """connect_server returns True when already connected.""" + config = MCPServerConfig(name="test", command="cmd") + client.register_server(config) + + # Add fake connection + connection = MCPServerConnection( + config=config, + session=MagicMock(), + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + result = await client.connect_server("test") + assert result is True + + @pytest.mark.asyncio + async def test_disconnect_server_not_connected(self, client): + """disconnect_server returns False when not connected.""" + result = await client.disconnect_server("nonexistent") + assert result is False + + @pytest.mark.asyncio + async def test_disconnect_server_success(self, client): + """disconnect_server removes connection and returns True.""" + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=MagicMock(), + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + result = await client.disconnect_server("test") + assert result is True + assert "test" not in client.connections + + @pytest.mark.asyncio + async def test_call_tool_not_connected(self, client): + """call_tool raises ValueError when not connected.""" + with pytest.raises(ValueError, match="Not connected to MCP server"): + await client.call_tool("nonexistent", "tool", {}) + + @pytest.mark.asyncio + async def test_call_tool_success(self, client): + """call_tool delegates to session.call_tool.""" + mock_session = AsyncMock() + mock_session.call_tool = AsyncMock(return_value={"result": "success"}) + + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=mock_session, + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + result = await client.call_tool("test", "my-tool", {"arg": "value"}) + assert result == {"result": "success"} + mock_session.call_tool.assert_called_once_with("my-tool", {"arg": "value"}) + + @pytest.mark.asyncio + async def test_call_tool_error(self, client): + """call_tool propagates errors from session.""" + mock_session = AsyncMock() + mock_session.call_tool = AsyncMock(side_effect=RuntimeError("Tool failed")) + + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=mock_session, + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + with pytest.raises(RuntimeError, match="Tool failed"): + await client.call_tool("test", "my-tool", {}) + + @pytest.mark.asyncio + async def test_read_resource_not_connected(self, client): + """read_resource raises ValueError when not connected.""" + with pytest.raises(ValueError, match="Not connected to MCP server"): + await client.read_resource("nonexistent", "file://test") + + @pytest.mark.asyncio + async def test_read_resource_success(self, client): + """read_resource delegates to session.read_resource.""" + mock_session = AsyncMock() + mock_session.read_resource = AsyncMock(return_value="resource content") + + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=mock_session, + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + result = await client.read_resource("test", "file://path") + assert result == "resource content" + mock_session.read_resource.assert_called_once_with("file://path") + + @pytest.mark.asyncio + async def test_read_resource_error(self, client): + """read_resource propagates errors from session.""" + mock_session = AsyncMock() + mock_session.read_resource = AsyncMock(side_effect=FileNotFoundError("Not found")) + + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=mock_session, + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + with pytest.raises(FileNotFoundError): + await client.read_resource("test", "file://missing") + + @pytest.mark.asyncio + async def test_get_prompt_not_connected(self, client): + """get_prompt raises ValueError when not connected.""" + with pytest.raises(ValueError, match="Not connected to MCP server"): + await client.get_prompt("nonexistent", "prompt-name") + + @pytest.mark.asyncio + async def test_get_prompt_success(self, client): + """get_prompt delegates to session.get_prompt.""" + mock_session = AsyncMock() + mock_session.get_prompt = AsyncMock(return_value={"messages": []}) + + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=mock_session, + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + result = await client.get_prompt("test", "my-prompt", {"arg": "val"}) + assert result == {"messages": []} + mock_session.get_prompt.assert_called_once_with("my-prompt", {"arg": "val"}) + + @pytest.mark.asyncio + async def test_get_prompt_no_arguments(self, client): + """get_prompt uses empty dict when no arguments provided.""" + mock_session = AsyncMock() + mock_session.get_prompt = AsyncMock(return_value={"messages": []}) + + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=mock_session, + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + await client.get_prompt("test", "my-prompt") + mock_session.get_prompt.assert_called_once_with("my-prompt", {}) + + @pytest.mark.asyncio + async def test_get_prompt_error(self, client): + """get_prompt propagates errors from session.""" + mock_session = AsyncMock() + mock_session.get_prompt = AsyncMock(side_effect=KeyError("Prompt not found")) + + config = MCPServerConfig(name="test", command="cmd") + connection = MCPServerConnection( + config=config, + session=mock_session, + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + with pytest.raises(KeyError): + await client.get_prompt("test", "missing-prompt") + + +class TestMCPClientConnectServerMCPAvailable: + """Test connect_server when MCP SDK is available (mocked).""" + + @pytest.fixture + def client(self): + """Create a fresh MCPClient for each test.""" + return MCPClient() + + @pytest.mark.asyncio + @patch("src.mcp_client.MCP_AVAILABLE", False) + async def test_connect_server_mcp_not_available(self): + """connect_server returns False when MCP SDK not available.""" + # Create client with mocked MCP_AVAILABLE + with patch("src.mcp_client.MCP_AVAILABLE", False): + client = MCPClient() + config = MCPServerConfig(name="test", command="cmd") + client.register_server(config) + + result = await client.connect_server("test") + assert result is False + + +class TestMCPClientConnectServerWithMocking: + """Test connect_server with full MCP SDK mocking.""" + + @pytest.fixture + def client(self): + """Create a fresh MCPClient for each test.""" + return MCPClient() + + @pytest.mark.asyncio + async def test_connect_server_success(self, client): + """connect_server successfully connects and lists capabilities.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="test-cmd") + client.register_server(config) + + # Create mock tools, resources, prompts + mock_tool = MagicMock() + mock_tool.name = "mock-tool" + mock_tool.description = "A mock tool" + mock_tool.inputSchema = {"type": "object"} + + mock_resource = MagicMock() + mock_resource.uri = "file://test" + mock_resource.name = "test-resource" + mock_resource.description = "A test resource" + mock_resource.mimeType = "text/plain" + + mock_prompt = MagicMock() + mock_prompt.name = "mock-prompt" + mock_prompt.description = "A mock prompt" + mock_prompt.arguments = [] + + mock_tools_response = MagicMock() + mock_tools_response.tools = [mock_tool] + + mock_resources_response = MagicMock() + mock_resources_response.resources = [mock_resource] + + mock_prompts_response = MagicMock() + mock_prompts_response.prompts = [mock_prompt] + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_session.list_tools = AsyncMock(return_value=mock_tools_response) + mock_session.list_resources = AsyncMock(return_value=mock_resources_response) + mock_session.list_prompts = AsyncMock(return_value=mock_prompts_response) + + mock_read = MagicMock() + mock_write = MagicMock() + + with patch("src.mcp_client.StdioServerParameters") as mock_params: + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + with patch("src.mcp_client.ClientSession") as mock_client_session: + mock_stdio.return_value = (mock_read, mock_write) + mock_client_session.return_value = mock_session + + result = await client.connect_server("test") + + assert result is True + assert "test" in client.connections + conn = client.connections["test"] + assert len(conn.available_tools) == 1 + assert len(conn.available_resources) == 1 + assert len(conn.available_prompts) == 1 + + @pytest.mark.asyncio + async def test_connect_server_list_tools_fails(self, client): + """connect_server handles tool listing failure gracefully.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="test-cmd") + client.register_server(config) + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_session.list_tools = AsyncMock(side_effect=RuntimeError("Tools error")) + mock_session.list_resources = AsyncMock(return_value=MagicMock(resources=[])) + mock_session.list_prompts = AsyncMock(return_value=MagicMock(prompts=[])) + + with patch("src.mcp_client.StdioServerParameters"): + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + with patch("src.mcp_client.ClientSession") as mock_client_session: + mock_stdio.return_value = (MagicMock(), MagicMock()) + mock_client_session.return_value = mock_session + + result = await client.connect_server("test") + + assert result is True + conn = client.connections["test"] + assert conn.available_tools == [] + + @pytest.mark.asyncio + async def test_connect_server_list_resources_fails(self, client): + """connect_server handles resource listing failure gracefully.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="test-cmd") + client.register_server(config) + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[])) + mock_session.list_resources = AsyncMock(side_effect=RuntimeError("Resources error")) + mock_session.list_prompts = AsyncMock(return_value=MagicMock(prompts=[])) + + with patch("src.mcp_client.StdioServerParameters"): + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + with patch("src.mcp_client.ClientSession") as mock_client_session: + mock_stdio.return_value = (MagicMock(), MagicMock()) + mock_client_session.return_value = mock_session + + result = await client.connect_server("test") + + assert result is True + conn = client.connections["test"] + assert conn.available_resources == [] + + @pytest.mark.asyncio + async def test_connect_server_list_prompts_fails(self, client): + """connect_server handles prompt listing failure gracefully.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="test-cmd") + client.register_server(config) + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[])) + mock_session.list_resources = AsyncMock(return_value=MagicMock(resources=[])) + mock_session.list_prompts = AsyncMock(side_effect=RuntimeError("Prompts error")) + + with patch("src.mcp_client.StdioServerParameters"): + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + with patch("src.mcp_client.ClientSession") as mock_client_session: + mock_stdio.return_value = (MagicMock(), MagicMock()) + mock_client_session.return_value = mock_session + + result = await client.connect_server("test") + + assert result is True + conn = client.connections["test"] + assert conn.available_prompts == [] + + @pytest.mark.asyncio + async def test_connect_server_connection_error(self, client): + """connect_server returns False on ConnectionError.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="test-cmd") + client.register_server(config) + + with patch("src.mcp_client.StdioServerParameters"): + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + mock_stdio.side_effect = ConnectionError("Connection refused") + + result = await client.connect_server("test") + assert result is False + + @pytest.mark.asyncio + async def test_connect_server_value_error(self, client): + """connect_server returns False on ValueError.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="test-cmd") + client.register_server(config) + + with patch("src.mcp_client.StdioServerParameters"): + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + mock_stdio.side_effect = ValueError("Invalid config") + + result = await client.connect_server("test") + assert result is False + + @pytest.mark.asyncio + async def test_connect_server_timeout_error(self, client): + """connect_server returns False on TimeoutError.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="test-cmd") + client.register_server(config) + + with patch("src.mcp_client.StdioServerParameters"): + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + mock_stdio.side_effect = TimeoutError("Connection timeout") + + result = await client.connect_server("test") + assert result is False + + @pytest.mark.asyncio + async def test_connect_server_file_not_found_error(self, client): + """connect_server returns False on FileNotFoundError.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="nonexistent-cmd") + client.register_server(config) + + with patch("src.mcp_client.StdioServerParameters"): + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + mock_stdio.side_effect = FileNotFoundError("Command not found") + + result = await client.connect_server("test") + assert result is False + + @pytest.mark.asyncio + async def test_connect_server_permission_error(self, client): + """connect_server returns False on PermissionError.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="test-cmd") + client.register_server(config) + + with patch("src.mcp_client.StdioServerParameters"): + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + mock_stdio.side_effect = PermissionError("Permission denied") + + result = await client.connect_server("test") + assert result is False + + @pytest.mark.asyncio + async def test_connect_server_unexpected_error(self, client): + """connect_server returns False on unexpected Exception.""" + if not MCP_AVAILABLE: + pytest.skip("MCP SDK not available") + + config = MCPServerConfig(name="test", command="test-cmd") + client.register_server(config) + + with patch("src.mcp_client.StdioServerParameters"): + with patch("src.mcp_client.stdio_client", new_callable=AsyncMock) as mock_stdio: + mock_stdio.side_effect = RuntimeError("Unexpected error") + + result = await client.connect_server("test") + assert result is False + + @pytest.mark.asyncio + async def test_disconnect_server_exception(self, client): + """disconnect_server handles exception during cleanup.""" + config = MCPServerConfig(name="test", command="cmd") + + # Create a connection with a mock session that raises on cleanup + mock_session = MagicMock() + + connection = MCPServerConnection( + config=config, + session=mock_session, + read_stream=MagicMock(), + write_stream=MagicMock(), + ) + client.connections["test"] = connection + + # The disconnect should still return True (cleanup exception is logged) + result = await client.disconnect_server("test") + assert result is True + assert "test" not in client.connections + + +class TestMCPClientThreadSafety: + """Test thread safety of MCPClient operations.""" + + @pytest.fixture + def client(self): + """Create a fresh MCPClient for each test.""" + return MCPClient() + + def test_concurrent_server_registration(self, client): + """Multiple threads can register servers concurrently.""" + results = [] + errors = [] + + def register_server(name): + try: + config = MCPServerConfig(name=name, command="cmd") + client.register_server(config) + results.append(name) + except Exception as e: + errors.append(str(e)) + + threads = [] + for i in range(20): + t = threading.Thread(target=register_server, args=(f"server-{i}",)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + assert len(errors) == 0 + assert len(results) == 20 + assert len(client.servers) == 20 + + def test_concurrent_get_stats(self, client): + """Multiple threads can call get_stats concurrently.""" + # Register some servers first + for i in range(10): + config = MCPServerConfig(name=f"server-{i}", command="cmd") + client.register_server(config) + + results = [] + errors = [] + + def get_stats(): + try: + stats = client.get_stats() + results.append(stats) + except Exception as e: + errors.append(str(e)) + + threads = [] + for _ in range(20): + t = threading.Thread(target=get_stats) + threads.append(t) + t.start() + + for t in threads: + t.join() + + assert len(errors) == 0 + assert len(results) == 20 + # All stats should show 10 registered servers + for stats in results: + assert stats["registered_servers"] == 10 + + +class TestGlobalMCPClientInstance: + """Test the global mcp_client instance.""" + + def test_global_instance_exists(self): + """Global mcp_client instance is available.""" + assert mcp_client is not None + assert isinstance(mcp_client, MCPClient) + + def test_global_instance_is_available_method(self): + """Global instance has is_available method.""" + # Should not raise + result = mcp_client.is_available() + assert isinstance(result, bool) diff --git a/tests/test_message_adapter_unit.py b/tests/test_message_adapter_unit.py new file mode 100644 index 0000000..90f3c52 --- /dev/null +++ b/tests/test_message_adapter_unit.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/message_adapter.py + +Tests the MessageAdapter class for message format conversion. +These are pure unit tests that don't require a running server. +""" + +import pytest +from src.message_adapter import MessageAdapter +from src.models import Message + + +class TestMessagesToPrompt: + """Test MessageAdapter.messages_to_prompt()""" + + def test_single_user_message(self): + """Single user message converts correctly.""" + messages = [Message(role="user", content="Hello")] + prompt, system = MessageAdapter.messages_to_prompt(messages) + + assert "Human: Hello" in prompt + assert system is None + + def test_user_and_assistant_conversation(self): + """User and assistant messages form conversation.""" + messages = [ + Message(role="user", content="Hello"), + Message(role="assistant", content="Hi there!"), + Message(role="user", content="How are you?"), + ] + prompt, system = MessageAdapter.messages_to_prompt(messages) + + assert "Human: Hello" in prompt + assert "Assistant: Hi there!" in prompt + assert "Human: How are you?" in prompt + + def test_system_message_extracted(self): + """System message is extracted as system_prompt.""" + messages = [ + Message(role="system", content="You are a helpful assistant."), + Message(role="user", content="Hello"), + ] + prompt, system = MessageAdapter.messages_to_prompt(messages) + + assert system == "You are a helpful assistant." + assert "Human: Hello" in prompt + + def test_multiple_system_messages_uses_last(self): + """Multiple system messages use the last one.""" + messages = [ + Message(role="system", content="First system message"), + Message(role="user", content="Hello"), + Message(role="system", content="Second system message"), + ] + prompt, system = MessageAdapter.messages_to_prompt(messages) + + assert system == "Second system message" + + def test_last_message_not_user_adds_continue(self): + """If last message isn't from user, adds 'Please continue'.""" + messages = [ + Message(role="user", content="Hello"), + Message(role="assistant", content="Hi there!"), + ] + prompt, system = MessageAdapter.messages_to_prompt(messages) + + assert "Please continue" in prompt + + def test_last_message_is_user_no_continue(self): + """If last message is from user, no 'Please continue' added.""" + messages = [ + Message(role="user", content="Hello"), + Message(role="assistant", content="Hi!"), + Message(role="user", content="What's up?"), + ] + prompt, system = MessageAdapter.messages_to_prompt(messages) + + assert "Please continue" not in prompt + + def test_empty_messages_list(self): + """Empty messages list returns empty prompt.""" + messages = [] + prompt, system = MessageAdapter.messages_to_prompt(messages) + + assert prompt == "" + assert system is None + + +class TestFilterContent: + """Test MessageAdapter.filter_content()""" + + def test_empty_content_returns_empty(self): + """Empty content returns empty.""" + assert MessageAdapter.filter_content("") == "" + assert MessageAdapter.filter_content(None) is None + + def test_plain_text_unchanged(self): + """Plain text content is unchanged.""" + content = "Hello, how can I help you today?" + result = MessageAdapter.filter_content(content) + assert result == content + + def test_removes_thinking_blocks(self): + """Thinking blocks are removed.""" + content = "Let me think about this...Here is my answer." + result = MessageAdapter.filter_content(content) + + assert "" not in result + assert "Let me think" not in result + assert "Here is my answer" in result + + def test_removes_multiline_thinking_blocks(self): + """Multiline thinking blocks are removed.""" + content = """ + Line 1 of thinking + Line 2 of thinking + + The actual response.""" + result = MessageAdapter.filter_content(content) + + assert "" not in result + assert "The actual response" in result + + def test_extracts_attempt_completion_content(self): + """Content from attempt_completion blocks is extracted.""" + content = """Some preamble + + This is the actual response to return. + + Some other stuff""" + result = MessageAdapter.filter_content(content) + + assert "This is the actual response to return" in result + + def test_extracts_result_from_attempt_completion(self): + """Content from result tags inside attempt_completion is extracted.""" + content = """ + The extracted result. + """ + result = MessageAdapter.filter_content(content) + + assert result == "The extracted result." + + def test_removes_read_file_blocks(self): + """read_file blocks are removed.""" + content = "Response path/to/file.txt more text" + result = MessageAdapter.filter_content(content) + + assert "" not in result + assert "path/to/file" not in result + + def test_removes_write_file_blocks(self): + """write_file blocks are removed.""" + content = "Response content more text" + result = MessageAdapter.filter_content(content) + + assert "" not in result + + def test_removes_bash_blocks(self): + """bash blocks are removed.""" + content = "Here's the output: ls -la done" + result = MessageAdapter.filter_content(content) + + assert "" not in result + assert "ls -la" not in result + + def test_removes_search_files_blocks(self): + """search_files blocks are removed.""" + content = "patternResult" + result = MessageAdapter.filter_content(content) + + assert "" not in result + + def test_removes_str_replace_editor_blocks(self): + """str_replace_editor blocks are removed.""" + content = "editDone" + result = MessageAdapter.filter_content(content) + + assert "" not in result + + def test_removes_args_blocks(self): + """args blocks are removed.""" + content = "Command --flag value executed" + result = MessageAdapter.filter_content(content) + + assert "" not in result + + def test_removes_ask_followup_question_blocks(self): + """ask_followup_question blocks are removed.""" + content = "What do you mean?Ok" + result = MessageAdapter.filter_content(content) + + assert "" not in result + + def test_removes_question_blocks(self): + """question blocks are removed.""" + content = "Do you want to proceed?Answer" + result = MessageAdapter.filter_content(content) + + assert "" not in result + + def test_removes_follow_up_blocks(self): + """follow_up blocks are removed.""" + content = "Please clarifyResponse" + result = MessageAdapter.filter_content(content) + + assert "" not in result + + def test_removes_suggest_blocks(self): + """suggest blocks are removed.""" + content = "try thisSuggestion" + result = MessageAdapter.filter_content(content) + + assert "" not in result + + def test_replaces_image_references(self): + """Image references are replaced with placeholder.""" + content = "Here's the image: [Image: screenshot.png] as you can see" + result = MessageAdapter.filter_content(content) + + assert "[Image: Content not supported by Claude Code]" in result + assert "screenshot.png" not in result + + def test_replaces_base64_image_data(self): + """Base64 image data is replaced.""" + content = "Image: data:image/png;base64,iVBORw0KGgoAAAANSUhE end" + result = MessageAdapter.filter_content(content) + + assert "base64" not in result + assert "iVBORw0" not in result + + def test_collapses_multiple_newlines(self): + """Multiple consecutive newlines are collapsed.""" + content = "Line 1\n\n\n\n\nLine 2" + result = MessageAdapter.filter_content(content) + + # Should have at most double newlines + assert "\n\n\n" not in result + + def test_empty_after_filtering_returns_fallback(self): + """If content is empty after filtering, returns fallback message.""" + content = "Only thinking content" + result = MessageAdapter.filter_content(content) + + assert "How can I help you today?" in result + + def test_whitespace_only_after_filtering_returns_fallback(self): + """If content is only whitespace after filtering, returns fallback.""" + content = "content \n \n " + result = MessageAdapter.filter_content(content) + + assert "How can I help you today?" in result + + +class TestFormatClaudeResponse: + """Test MessageAdapter.format_claude_response()""" + + def test_basic_formatting(self): + """Basic response formatting.""" + result = MessageAdapter.format_claude_response(content="Hello!", model="claude-3-opus") + + assert result["role"] == "assistant" + assert result["content"] == "Hello!" + assert result["model"] == "claude-3-opus" + assert result["finish_reason"] == "stop" + + def test_custom_finish_reason(self): + """Can specify custom finish_reason.""" + result = MessageAdapter.format_claude_response( + content="Hello!", model="claude-3", finish_reason="length" + ) + + assert result["finish_reason"] == "length" + + def test_preserves_content_exactly(self): + """Content is preserved exactly as provided.""" + content = 'Multi\nline\ncontent with special chars: <>&"' + result = MessageAdapter.format_claude_response(content=content, model="claude") + + assert result["content"] == content + + +class TestEstimateTokens: + """Test MessageAdapter.estimate_tokens()""" + + def test_short_text(self): + """Short text token estimation.""" + # 12 chars / 4 = 3 tokens + result = MessageAdapter.estimate_tokens("Hello World!") + assert result == 3 + + def test_empty_text(self): + """Empty text returns 0 tokens.""" + result = MessageAdapter.estimate_tokens("") + assert result == 0 + + def test_long_text(self): + """Longer text estimation.""" + # 100 chars / 4 = 25 tokens + text = "a" * 100 + result = MessageAdapter.estimate_tokens(text) + assert result == 25 + + def test_realistic_text(self): + """Realistic text estimation.""" + text = "This is a realistic sentence that might appear in a conversation." + result = MessageAdapter.estimate_tokens(text) + # 67 chars / 4 = 16 tokens + assert result == 16 diff --git a/tests/test_models_unit.py b/tests/test_models_unit.py new file mode 100644 index 0000000..5e6387d --- /dev/null +++ b/tests/test_models_unit.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/models.py + +Tests all Pydantic models including validators and methods. +These are pure unit tests that don't require a running server. +""" + +import pytest +from datetime import datetime +from unittest.mock import patch + +from src.models import ( + ContentPart, + Message, + StreamOptions, + ChatCompletionRequest, + Choice, + Usage, + ChatCompletionResponse, + StreamChoice, + ChatCompletionStreamResponse, + ErrorDetail, + ErrorResponse, + SessionInfo, + SessionListResponse, + ToolMetadataResponse, + ToolListResponse, + ToolConfigurationResponse, + ToolConfigurationRequest, + ToolValidationResponse, + MCPServerConfigRequest, + MCPServerInfoResponse, + MCPServersListResponse, + MCPConnectionRequest, + MCPToolCallRequest, + AnthropicTextBlock, + AnthropicMessage, + AnthropicMessagesRequest, + AnthropicUsage, + AnthropicMessagesResponse, +) + + +class TestContentPart: + """Test ContentPart model.""" + + def test_create_text_content_part(self): + """Can create a text content part.""" + part = ContentPart(type="text", text="Hello world") + assert part.type == "text" + assert part.text == "Hello world" + + +class TestMessage: + """Test Message model.""" + + def test_create_user_message(self): + """Can create a user message.""" + msg = Message(role="user", content="Hello") + assert msg.role == "user" + assert msg.content == "Hello" + + def test_create_assistant_message(self): + """Can create an assistant message.""" + msg = Message(role="assistant", content="Hi there") + assert msg.role == "assistant" + assert msg.content == "Hi there" + + def test_create_system_message(self): + """Can create a system message.""" + msg = Message(role="system", content="You are helpful") + assert msg.role == "system" + assert msg.content == "You are helpful" + + def test_message_with_name(self): + """Can create a message with a name.""" + msg = Message(role="user", content="Hello", name="alice") + assert msg.name == "alice" + + def test_message_normalizes_array_content(self): + """Array content is normalized to string.""" + content_parts = [ + ContentPart(type="text", text="Part 1"), + ContentPart(type="text", text="Part 2"), + ] + msg = Message(role="user", content=content_parts) + assert msg.content == "Part 1\nPart 2" + + def test_message_normalizes_dict_content(self): + """Dict content parts are normalized to string.""" + content = [ + {"type": "text", "text": "Hello"}, + {"type": "text", "text": "World"}, + ] + msg = Message(role="user", content=content) + assert msg.content == "Hello\nWorld" + + def test_empty_array_content_becomes_empty_string(self): + """Empty array content becomes empty string.""" + msg = Message(role="user", content=[]) + assert msg.content == "" + + +class TestStreamOptions: + """Test StreamOptions model.""" + + def test_default_include_usage_is_false(self): + """Default include_usage is False.""" + options = StreamOptions() + assert options.include_usage is False + + def test_can_set_include_usage(self): + """Can set include_usage to True.""" + options = StreamOptions(include_usage=True) + assert options.include_usage is True + + +class TestChatCompletionRequest: + """Test ChatCompletionRequest model.""" + + def test_minimal_request(self): + """Can create request with just messages.""" + request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")]) + assert len(request.messages) == 1 + + def test_default_model(self): + """Default model is set from constants.""" + request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")]) + assert request.model is not None + + def test_temperature_range_validation(self): + """Temperature must be between 0 and 2.""" + # Valid range + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], temperature=1.5 + ) + assert request.temperature == 1.5 + + # Invalid - too high + with pytest.raises(ValueError): + ChatCompletionRequest(messages=[Message(role="user", content="Hi")], temperature=3.0) + + # Invalid - too low + with pytest.raises(ValueError): + ChatCompletionRequest(messages=[Message(role="user", content="Hi")], temperature=-1.0) + + def test_top_p_range_validation(self): + """top_p must be between 0 and 1.""" + request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")], top_p=0.5) + assert request.top_p == 0.5 + + with pytest.raises(ValueError): + ChatCompletionRequest(messages=[Message(role="user", content="Hi")], top_p=1.5) + + def test_n_must_be_1(self): + """n > 1 raises validation error.""" + with pytest.raises(ValueError) as exc_info: + ChatCompletionRequest(messages=[Message(role="user", content="Hi")], n=3) + assert "multiple choices" in str(exc_info.value).lower() + + def test_presence_penalty_range(self): + """presence_penalty must be between -2 and 2.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], presence_penalty=1.0 + ) + assert request.presence_penalty == 1.0 + + with pytest.raises(ValueError): + ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], presence_penalty=3.0 + ) + + def test_frequency_penalty_range(self): + """frequency_penalty must be between -2 and 2.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], frequency_penalty=-1.0 + ) + assert request.frequency_penalty == -1.0 + + with pytest.raises(ValueError): + ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], frequency_penalty=5.0 + ) + + def test_stream_options(self): + """Can set stream_options.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], + stream_options=StreamOptions(include_usage=True), + ) + assert request.stream_options.include_usage is True + + def test_log_parameter_info(self): + """log_parameter_info logs warnings for unsupported params.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], + temperature=0.5, + presence_penalty=0.5, + stop=["END"], + ) + with patch("src.models.logger") as mock_logger: + request.log_parameter_info() + # Should have info for temperature, warnings for penalty and stop + assert mock_logger.info.called + assert mock_logger.warning.called + + def test_get_sampling_instructions_low_temperature(self): + """Low temperature produces focused instructions.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], temperature=0.2 + ) + instructions = request.get_sampling_instructions() + assert instructions is not None + assert "deterministic" in instructions.lower() or "focused" in instructions.lower() + + def test_get_sampling_instructions_high_temperature(self): + """High temperature produces creative instructions.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], temperature=1.8 + ) + instructions = request.get_sampling_instructions() + assert instructions is not None + assert "creative" in instructions.lower() + + def test_get_sampling_instructions_low_top_p(self): + """Low top_p produces focused instructions.""" + request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")], top_p=0.3) + instructions = request.get_sampling_instructions() + assert instructions is not None + assert "probable" in instructions.lower() or "mainstream" in instructions.lower() + + def test_get_sampling_instructions_default_returns_none(self): + """Default values return no instructions.""" + request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")]) + instructions = request.get_sampling_instructions() + assert instructions is None + + def test_to_claude_options_basic(self): + """to_claude_options() returns model.""" + request = ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hi")], + ) + options = request.to_claude_options() + assert options["model"] == "claude-sonnet-4-5-20250929" + + def test_to_claude_options_with_max_tokens(self): + """to_claude_options() maps max_tokens to max_thinking_tokens.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], max_tokens=500 + ) + options = request.to_claude_options() + assert options.get("max_thinking_tokens") == 500 + + def test_to_claude_options_prefers_max_completion_tokens(self): + """max_completion_tokens takes precedence over max_tokens.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hi")], + max_tokens=500, + max_completion_tokens=1000, + ) + options = request.to_claude_options() + assert options.get("max_thinking_tokens") == 1000 + + +class TestChatCompletionResponse: + """Test ChatCompletionResponse model.""" + + def test_response_has_auto_generated_id(self): + """Response has auto-generated ID starting with chatcmpl-.""" + response = ChatCompletionResponse( + model="claude-3", + choices=[ + Choice( + index=0, + message=Message(role="assistant", content="Hello"), + finish_reason="stop", + ) + ], + ) + assert response.id.startswith("chatcmpl-") + + def test_response_object_type(self): + """Response object is chat.completion.""" + response = ChatCompletionResponse( + model="claude-3", + choices=[ + Choice( + index=0, + message=Message(role="assistant", content="Hello"), + finish_reason="stop", + ) + ], + ) + assert response.object == "chat.completion" + + def test_response_created_timestamp(self): + """Response has created timestamp.""" + before = int(datetime.now().timestamp()) + response = ChatCompletionResponse( + model="claude-3", + choices=[ + Choice( + index=0, + message=Message(role="assistant", content="Hello"), + finish_reason="stop", + ) + ], + ) + after = int(datetime.now().timestamp()) + assert before <= response.created <= after + + +class TestChatCompletionStreamResponse: + """Test ChatCompletionStreamResponse model.""" + + def test_stream_response_object_type(self): + """Stream response object is chat.completion.chunk.""" + response = ChatCompletionStreamResponse( + model="claude-3", + choices=[StreamChoice(index=0, delta={"content": "Hello"})], + ) + assert response.object == "chat.completion.chunk" + + def test_stream_response_with_usage(self): + """Stream response can include usage.""" + response = ChatCompletionStreamResponse( + model="claude-3", + choices=[StreamChoice(index=0, delta={}, finish_reason="stop")], + usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ) + assert response.usage.total_tokens == 15 + + +class TestErrorModels: + """Test error response models.""" + + def test_error_detail(self): + """Can create ErrorDetail.""" + error = ErrorDetail(message="Something went wrong", type="invalid_request") + assert error.message == "Something went wrong" + assert error.type == "invalid_request" + + def test_error_response(self): + """Can create ErrorResponse.""" + response = ErrorResponse( + error=ErrorDetail(message="Bad request", type="invalid_request", code="400") + ) + assert response.error.code == "400" + + +class TestSessionModels: + """Test session-related models.""" + + def test_session_info(self): + """Can create SessionInfo.""" + now = datetime.utcnow() + info = SessionInfo( + session_id="test-123", + created_at=now, + last_accessed=now, + message_count=5, + expires_at=now, + ) + assert info.session_id == "test-123" + assert info.message_count == 5 + + def test_session_list_response(self): + """Can create SessionListResponse.""" + now = datetime.utcnow() + response = SessionListResponse( + sessions=[ + SessionInfo( + session_id="s1", + created_at=now, + last_accessed=now, + message_count=1, + expires_at=now, + ) + ], + total=1, + ) + assert response.total == 1 + + +class TestToolModels: + """Test tool-related models.""" + + def test_tool_metadata_response(self): + """Can create ToolMetadataResponse.""" + tool = ToolMetadataResponse( + name="Read", + description="Read a file", + category="filesystem", + parameters={"path": "string"}, + examples=["Read file.txt"], + is_safe=True, + requires_network=False, + ) + assert tool.name == "Read" + + def test_tool_list_response(self): + """Can create ToolListResponse.""" + response = ToolListResponse(tools=[], total=0) + assert response.total == 0 + + def test_tool_configuration_response(self): + """Can create ToolConfigurationResponse.""" + now = datetime.utcnow() + response = ToolConfigurationResponse( + allowed_tools=["Read"], + effective_tools=["Read", "Write"], + created_at=now, + updated_at=now, + ) + assert "Read" in response.allowed_tools + + def test_tool_configuration_request(self): + """Can create ToolConfigurationRequest.""" + request = ToolConfigurationRequest(allowed_tools=["Read", "Write"], session_id="test") + assert len(request.allowed_tools) == 2 + + def test_tool_validation_response(self): + """Can create ToolValidationResponse.""" + response = ToolValidationResponse( + valid={"Read": True, "Invalid": False}, invalid_tools=["Invalid"] + ) + assert "Invalid" in response.invalid_tools + + +class TestMCPModels: + """Test MCP-related models.""" + + def test_mcp_server_config_request(self): + """Can create MCPServerConfigRequest.""" + config = MCPServerConfigRequest( + name="test-server", + command="node", + args=["server.js"], + description="Test MCP server", + ) + assert config.name == "test-server" + + def test_mcp_server_name_validation(self): + """Server name is validated.""" + # Valid name + config = MCPServerConfigRequest(name="my-server.v1", command="node") + assert config.name == "my-server.v1" + + # Empty name + with pytest.raises(ValueError): + MCPServerConfigRequest(name="", command="node") + + # Invalid characters + with pytest.raises(ValueError): + MCPServerConfigRequest(name="server name with spaces", command="node") + + def test_mcp_server_command_validation(self): + """Server command is validated.""" + with pytest.raises(ValueError): + MCPServerConfigRequest(name="server", command="") + + def test_mcp_server_info_response(self): + """Can create MCPServerInfoResponse.""" + info = MCPServerInfoResponse( + name="test", + command="node", + args=[], + description="Test", + enabled=True, + connected=False, + tools_count=5, + ) + assert info.tools_count == 5 + + def test_mcp_servers_list_response(self): + """Can create MCPServersListResponse.""" + response = MCPServersListResponse(servers=[], total=0) + assert response.total == 0 + + def test_mcp_connection_request(self): + """Can create MCPConnectionRequest.""" + request = MCPConnectionRequest(server_name="my-server") + assert request.server_name == "my-server" + + def test_mcp_connection_request_validation(self): + """Server name in connection request is validated.""" + with pytest.raises(ValueError): + MCPConnectionRequest(server_name="") + + def test_mcp_tool_call_request(self): + """Can create MCPToolCallRequest.""" + request = MCPToolCallRequest( + server_name="server", tool_name="read_file", arguments={"path": "/tmp/test"} + ) + assert request.tool_name == "read_file" + + def test_mcp_tool_call_request_validation(self): + """Tool call request validates names.""" + with pytest.raises(ValueError): + MCPToolCallRequest(server_name="", tool_name="tool") + + with pytest.raises(ValueError): + MCPToolCallRequest(server_name="server", tool_name="") + + +class TestAnthropicModels: + """Test Anthropic API compatible models.""" + + def test_anthropic_text_block(self): + """Can create AnthropicTextBlock.""" + block = AnthropicTextBlock(text="Hello world") + assert block.type == "text" + assert block.text == "Hello world" + + def test_anthropic_message(self): + """Can create AnthropicMessage.""" + msg = AnthropicMessage(role="user", content="Hello") + assert msg.role == "user" + + def test_anthropic_message_with_blocks(self): + """Can create AnthropicMessage with content blocks.""" + msg = AnthropicMessage(role="assistant", content=[AnthropicTextBlock(text="Hi there")]) + assert len(msg.content) == 1 + + def test_anthropic_messages_request(self): + """Can create AnthropicMessagesRequest.""" + request = AnthropicMessagesRequest( + model="claude-3-opus", + messages=[AnthropicMessage(role="user", content="Hello")], + max_tokens=1000, + ) + assert request.model == "claude-3-opus" + assert request.max_tokens == 1000 + + def test_anthropic_messages_request_to_openai(self): + """to_openai_messages() converts correctly.""" + request = AnthropicMessagesRequest( + model="claude-3", + messages=[ + AnthropicMessage(role="user", content="Hello"), + AnthropicMessage( + role="assistant", + content=[ + AnthropicTextBlock(text="Part 1"), + AnthropicTextBlock(text="Part 2"), + ], + ), + ], + ) + openai_msgs = request.to_openai_messages() + assert len(openai_msgs) == 2 + assert openai_msgs[0].content == "Hello" + assert openai_msgs[1].content == "Part 1\nPart 2" + + def test_anthropic_usage(self): + """Can create AnthropicUsage.""" + usage = AnthropicUsage(input_tokens=100, output_tokens=50) + assert usage.input_tokens == 100 + + def test_anthropic_messages_response(self): + """Can create AnthropicMessagesResponse.""" + response = AnthropicMessagesResponse( + model="claude-3", + content=[AnthropicTextBlock(text="Response text")], + usage=AnthropicUsage(input_tokens=10, output_tokens=5), + ) + assert response.type == "message" + assert response.role == "assistant" + assert response.stop_reason == "end_turn" + assert response.id.startswith("msg_") diff --git a/tests/test_non_streaming.py b/tests/test_non_streaming.py index 61170c0..ec94673 100644 --- a/tests/test_non_streaming.py +++ b/tests/test_non_streaming.py @@ -5,60 +5,58 @@ import os import json +import pytest import requests +from tests.conftest import requires_server + # Set debug mode -os.environ['DEBUG_MODE'] = 'true' +os.environ["DEBUG_MODE"] = "true" + +@requires_server def test_non_streaming(): """Test that non-streaming responses work correctly.""" print("๐Ÿงช Testing non-streaming response...") - + # Simple request with streaming disabled request_data = { "model": "claude-3-7-sonnet-20250219", - "messages": [ - { - "role": "user", - "content": "What is 2+2?" - } - ], + "messages": [{"role": "user", "content": "What is 2+2?"}], "stream": False, - "temperature": 0.0 + "temperature": 0.0, } - + try: # Send non-streaming request response = requests.post( - "http://localhost:8000/v1/chat/completions", - json=request_data, - timeout=30 + "http://localhost:8000/v1/chat/completions", json=request_data, timeout=30 ) - + print(f"โœ… Response status: {response.status_code}") - + if response.status_code != 200: print(f"โŒ Request failed: {response.text}") return False - + # Parse response data = response.json() - + # Check response structure - if 'choices' in data and len(data['choices']) > 0: - message = data['choices'][0]['message'] - content = message['content'] - + if "choices" in data and len(data["choices"]) > 0: + message = data["choices"][0]["message"] + content = message["content"] + print(f"๐Ÿ“Š Response content: {content}") - + # Check if we got actual content instead of fallback message fallback_messages = [ "I'm unable to provide a response at the moment", - "I understand you're testing the system" + "I understand you're testing the system", ] - + is_fallback = any(msg in content for msg in fallback_messages) - + if not is_fallback and len(content) > 0: print("\n๐ŸŽ‰ Non-streaming response is working!") print("โœ… Real content extracted successfully") @@ -70,18 +68,19 @@ def test_non_streaming(): else: print("โŒ Unexpected response structure") return False - + except Exception as e: print(f"โŒ Test failed with exception: {e}") return False + def main(): """Test non-streaming responses.""" print("๐Ÿ” Testing Non-Streaming Responses") print("=" * 50) - + success = test_non_streaming() - + print("\n" + "=" * 50) if success: print("๐ŸŽ‰ Non-streaming test PASSED!") @@ -89,9 +88,10 @@ def main(): else: print("โŒ Non-streaming test FAILED") print("โš ๏ธ Issue may still persist") - + return success + if __name__ == "__main__": success = main() - exit(0 if success else 1) \ No newline at end of file + exit(0 if success else 1) diff --git a/tests/test_parameter_mapping.py b/tests/test_parameter_mapping.py index e27a7f6..d6bcaa2 100644 --- a/tests/test_parameter_mapping.py +++ b/tests/test_parameter_mapping.py @@ -1,33 +1,41 @@ #!/usr/bin/env python3 """ Test script demonstrating OpenAI to Claude Code SDK parameter mapping. + +These are integration tests that require a running server. +Run with: poetry run pytest tests/test_parameter_mapping.py -v """ import asyncio import json +import pytest import requests from typing import Dict, Any +from tests.conftest import requires_server + # Test server URL BASE_URL = "http://localhost:8000" + +@requires_server def test_basic_completion(): """Test basic chat completion with OpenAI parameters.""" print("=== Testing Basic Completion ===") - + payload = { "model": "claude-3-5-sonnet-20241022", "messages": [ {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Say hello in a creative way."} + {"role": "user", "content": "Say hello in a creative way."}, ], "temperature": 0.7, # Will be ignored with warning - "max_tokens": 100, # Will be ignored with warning - "stream": False + "max_tokens": 100, # Will be ignored with warning + "stream": False, } - + response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload) - + if response.status_code == 200: print("โœ… Request successful") result = response.json() @@ -36,31 +44,27 @@ def test_basic_completion(): print(f"โŒ Request failed: {response.status_code}") print(response.text) + +@requires_server def test_with_claude_headers(): """Test completion with Claude-specific headers.""" print("\n=== Testing with Claude-Specific Headers ===") - + payload = { - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "List the files in the current directory"} - ], - "stream": False + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "List the files in the current directory"}], + "stream": False, } - + headers = { "Content-Type": "application/json", "X-Claude-Max-Turns": "5", "X-Claude-Allowed-Tools": "ls,pwd,cat", - "X-Claude-Permission-Mode": "acceptEdits" + "X-Claude-Permission-Mode": "acceptEdits", } - - response = requests.post( - f"{BASE_URL}/v1/chat/completions", - json=payload, - headers=headers - ) - + + response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, headers=headers) + if response.status_code == 200: print("โœ… Request with Claude headers successful") result = response.json() @@ -69,10 +73,12 @@ def test_with_claude_headers(): print(f"โŒ Request failed: {response.status_code}") print(response.text) + +@requires_server def test_compatibility_check(): """Test the compatibility endpoint.""" print("\n=== Testing Compatibility Check ===") - + payload = { "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Hello"}], @@ -84,11 +90,11 @@ def test_compatibility_check(): "logit_bias": {"hello": 2.0}, "stop": ["END"], "n": 1, - "user": "test_user" + "user": "test_user", } - + response = requests.post(f"{BASE_URL}/v1/compatibility", json=payload) - + if response.status_code == 200: print("โœ… Compatibility check successful") result = response.json() @@ -97,54 +103,51 @@ def test_compatibility_check(): print(f"โŒ Compatibility check failed: {response.status_code}") print(response.text) + +@requires_server def test_parameter_validation(): """Test parameter validation (should fail).""" print("\n=== Testing Parameter Validation ===") - + # Test with n > 1 (should fail) payload = { "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Hello"}], - "n": 3 # Should fail validation + "n": 3, # Should fail validation } - + response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload) - + if response.status_code == 422: print("โœ… Validation correctly rejected n > 1") print(response.json()) else: print(f"โŒ Expected validation error, got: {response.status_code}") + def test_streaming_with_parameters(): """Test streaming response with unsupported parameters.""" print("\n=== Testing Streaming with Unsupported Parameters ===") - + payload = { "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Write a short poem about programming"} - ], + "messages": [{"role": "user", "content": "Write a short poem about programming"}], "temperature": 0.9, # Will be warned about - "max_tokens": 200, # Will be warned about - "stream": True + "max_tokens": 200, # Will be warned about + "stream": True, } - + try: - response = requests.post( - f"{BASE_URL}/v1/chat/completions", - json=payload, - stream=True - ) - + response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, stream=True) + if response.status_code == 200: print("โœ… Streaming request successful") print("First few chunks:") count = 0 for line in response.iter_lines(): if line and count < 5: - line_str = line.decode('utf-8') - if line_str.startswith('data: ') and not line_str.endswith('[DONE]'): + line_str = line.decode("utf-8") + if line_str.startswith("data: ") and not line_str.endswith("[DONE]"): print(f" {line_str}") count += 1 else: @@ -152,11 +155,12 @@ def test_streaming_with_parameters(): except Exception as e: print(f"โŒ Streaming test error: {e}") + def main(): """Run all tests.""" print("OpenAI to Claude Code SDK Parameter Mapping Tests") print("=" * 50) - + try: # Check if server is running response = requests.get(f"{BASE_URL}/health") @@ -164,23 +168,26 @@ def main(): print("โŒ Server is not running. Start it with: poetry run python main.py") return print("โœ… Server is running") - + # Run tests test_basic_completion() test_with_claude_headers() test_compatibility_check() test_parameter_validation() test_streaming_with_parameters() - + print("\n" + "=" * 50) print("๐ŸŽ‰ All tests completed!") print("\nTo see parameter warnings in detail, run the server with:") - print("PYTHONPATH=. poetry run python -c \"import logging; logging.basicConfig(level=logging.DEBUG); exec(open('main.py').read())\"") - + print( + "PYTHONPATH=. poetry run python -c \"import logging; logging.basicConfig(level=logging.DEBUG); exec(open('main.py').read())\"" + ) + except requests.exceptions.ConnectionError: print("โŒ Cannot connect to server. Make sure it's running on port 8000") except Exception as e: print(f"โŒ Test error: {e}") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_parameter_validator_unit.py b/tests/test_parameter_validator_unit.py new file mode 100644 index 0000000..3c31945 --- /dev/null +++ b/tests/test_parameter_validator_unit.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/parameter_validator.py + +Tests the ParameterValidator and CompatibilityReporter classes. +These are pure unit tests that don't require a running server. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from src.parameter_validator import ParameterValidator, CompatibilityReporter +from src.models import Message, ChatCompletionRequest + + +class TestParameterValidatorValidateModel: + """Test ParameterValidator.validate_model()""" + + def test_valid_model_returns_true(self): + """Known supported model returns True.""" + result = ParameterValidator.validate_model("claude-sonnet-4-5-20250929") + assert result is True + + def test_unknown_model_returns_true_with_warning(self): + """Unknown model returns True (graceful degradation) with warning logged.""" + with patch("src.parameter_validator.logger") as mock_logger: + result = ParameterValidator.validate_model("unknown-model-xyz") + assert result is True + mock_logger.warning.assert_called_once() + assert "unknown-model-xyz" in str(mock_logger.warning.call_args) + + def test_all_known_models_valid(self): + """All models in SUPPORTED_MODELS are valid.""" + for model in ParameterValidator.SUPPORTED_MODELS: + assert ParameterValidator.validate_model(model) is True + + +class TestParameterValidatorValidatePermissionMode: + """Test ParameterValidator.validate_permission_mode()""" + + def test_valid_permission_mode_default(self): + """'default' permission mode is valid.""" + assert ParameterValidator.validate_permission_mode("default") is True + + def test_valid_permission_mode_accept_edits(self): + """'acceptEdits' permission mode is valid.""" + assert ParameterValidator.validate_permission_mode("acceptEdits") is True + + def test_valid_permission_mode_bypass(self): + """'bypassPermissions' permission mode is valid.""" + assert ParameterValidator.validate_permission_mode("bypassPermissions") is True + + def test_invalid_permission_mode_returns_false(self): + """Invalid permission mode returns False with error logged.""" + with patch("src.parameter_validator.logger") as mock_logger: + result = ParameterValidator.validate_permission_mode("invalidMode") + assert result is False + mock_logger.error.assert_called_once() + assert "invalidMode" in str(mock_logger.error.call_args) + + +class TestParameterValidatorValidateTools: + """Test ParameterValidator.validate_tools()""" + + def test_valid_tools_list(self): + """List of valid tool names returns True.""" + tools = ["Read", "Write", "Bash"] + assert ParameterValidator.validate_tools(tools) is True + + def test_empty_string_tool_returns_false(self): + """Tool list with empty string returns False.""" + with patch("src.parameter_validator.logger") as mock_logger: + result = ParameterValidator.validate_tools(["Read", "", "Bash"]) + assert result is False + mock_logger.error.assert_called_once() + + def test_whitespace_only_tool_returns_false(self): + """Tool list with whitespace-only string returns False.""" + with patch("src.parameter_validator.logger") as mock_logger: + result = ParameterValidator.validate_tools(["Read", " ", "Bash"]) + assert result is False + mock_logger.error.assert_called_once() + + def test_non_string_tool_returns_false(self): + """Tool list with non-string element returns False.""" + with patch("src.parameter_validator.logger") as mock_logger: + result = ParameterValidator.validate_tools(["Read", 123, "Bash"]) + assert result is False + mock_logger.error.assert_called_once() + + def test_empty_list_returns_true(self): + """Empty tool list returns True (no invalid tools).""" + assert ParameterValidator.validate_tools([]) is True + + +class TestParameterValidatorCreateEnhancedOptions: + """Test ParameterValidator.create_enhanced_options()""" + + @pytest.fixture + def basic_request(self): + """Create a basic chat completion request for testing.""" + return ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + ) + + def test_basic_options_from_request(self, basic_request): + """Basic request options are extracted correctly.""" + options = ParameterValidator.create_enhanced_options(basic_request) + assert "model" in options + assert options["model"] == "claude-sonnet-4-5-20250929" + + def test_max_turns_added_when_provided(self, basic_request): + """max_turns is added to options when provided.""" + options = ParameterValidator.create_enhanced_options(basic_request, max_turns=5) + assert options.get("max_turns") == 5 + + def test_max_turns_warning_for_out_of_range(self, basic_request): + """Warning logged when max_turns is out of recommended range.""" + with patch("src.parameter_validator.logger") as mock_logger: + # Test value below range + ParameterValidator.create_enhanced_options(basic_request, max_turns=0) + mock_logger.warning.assert_called() + + mock_logger.reset_mock() + + # Test value above range + ParameterValidator.create_enhanced_options(basic_request, max_turns=150) + mock_logger.warning.assert_called() + + def test_allowed_tools_added_when_valid(self, basic_request): + """allowed_tools is added when valid tools provided.""" + tools = ["Read", "Write"] + options = ParameterValidator.create_enhanced_options(basic_request, allowed_tools=tools) + assert options.get("allowed_tools") == tools + + def test_disallowed_tools_added_when_valid(self, basic_request): + """disallowed_tools is added when valid tools provided.""" + tools = ["Bash", "Edit"] + options = ParameterValidator.create_enhanced_options(basic_request, disallowed_tools=tools) + assert options.get("disallowed_tools") == tools + + def test_permission_mode_added_when_valid(self, basic_request): + """permission_mode is added when valid mode provided.""" + options = ParameterValidator.create_enhanced_options( + basic_request, permission_mode="acceptEdits" + ) + assert options.get("permission_mode") == "acceptEdits" + + def test_permission_mode_not_added_when_invalid(self, basic_request): + """permission_mode is not added when invalid mode provided.""" + options = ParameterValidator.create_enhanced_options( + basic_request, permission_mode="invalidMode" + ) + assert "permission_mode" not in options + + def test_max_thinking_tokens_added(self, basic_request): + """max_thinking_tokens is added when provided.""" + options = ParameterValidator.create_enhanced_options( + basic_request, max_thinking_tokens=5000 + ) + assert options.get("max_thinking_tokens") == 5000 + + def test_max_thinking_tokens_warning_for_out_of_range(self, basic_request): + """Warning logged when max_thinking_tokens is out of range.""" + with patch("src.parameter_validator.logger") as mock_logger: + # Test negative value + ParameterValidator.create_enhanced_options(basic_request, max_thinking_tokens=-100) + mock_logger.warning.assert_called() + + mock_logger.reset_mock() + + # Test value above range + ParameterValidator.create_enhanced_options(basic_request, max_thinking_tokens=60000) + mock_logger.warning.assert_called() + + +class TestParameterValidatorExtractClaudeHeaders: + """Test ParameterValidator.extract_claude_headers()""" + + def test_empty_headers_returns_empty_dict(self): + """Empty headers dict returns empty options dict.""" + result = ParameterValidator.extract_claude_headers({}) + assert result == {} + + def test_extracts_max_turns(self): + """X-Claude-Max-Turns header is extracted correctly.""" + headers = {"x-claude-max-turns": "10"} + result = ParameterValidator.extract_claude_headers(headers) + assert result.get("max_turns") == 10 + + def test_invalid_max_turns_logs_warning(self): + """Invalid X-Claude-Max-Turns logs warning and is ignored.""" + headers = {"x-claude-max-turns": "not-a-number"} + with patch("src.parameter_validator.logger") as mock_logger: + result = ParameterValidator.extract_claude_headers(headers) + assert "max_turns" not in result + mock_logger.warning.assert_called_once() + + def test_extracts_allowed_tools(self): + """X-Claude-Allowed-Tools header is extracted correctly.""" + headers = {"x-claude-allowed-tools": "Read,Write,Bash"} + result = ParameterValidator.extract_claude_headers(headers) + assert result.get("allowed_tools") == ["Read", "Write", "Bash"] + + def test_allowed_tools_strips_whitespace(self): + """Tool names have whitespace stripped.""" + headers = {"x-claude-allowed-tools": " Read , Write , Bash "} + result = ParameterValidator.extract_claude_headers(headers) + assert result.get("allowed_tools") == ["Read", "Write", "Bash"] + + def test_extracts_disallowed_tools(self): + """X-Claude-Disallowed-Tools header is extracted correctly.""" + headers = {"x-claude-disallowed-tools": "Edit,Delete"} + result = ParameterValidator.extract_claude_headers(headers) + assert result.get("disallowed_tools") == ["Edit", "Delete"] + + def test_extracts_permission_mode(self): + """X-Claude-Permission-Mode header is extracted correctly.""" + headers = {"x-claude-permission-mode": "bypassPermissions"} + result = ParameterValidator.extract_claude_headers(headers) + assert result.get("permission_mode") == "bypassPermissions" + + def test_extracts_max_thinking_tokens(self): + """X-Claude-Max-Thinking-Tokens header is extracted correctly.""" + headers = {"x-claude-max-thinking-tokens": "5000"} + result = ParameterValidator.extract_claude_headers(headers) + assert result.get("max_thinking_tokens") == 5000 + + def test_invalid_max_thinking_tokens_logs_warning(self): + """Invalid X-Claude-Max-Thinking-Tokens logs warning.""" + headers = {"x-claude-max-thinking-tokens": "invalid"} + with patch("src.parameter_validator.logger") as mock_logger: + result = ParameterValidator.extract_claude_headers(headers) + assert "max_thinking_tokens" not in result + mock_logger.warning.assert_called_once() + + def test_extracts_multiple_headers(self): + """Multiple Claude headers are all extracted.""" + headers = { + "x-claude-max-turns": "5", + "x-claude-allowed-tools": "Read,Write", + "x-claude-permission-mode": "default", + "x-claude-max-thinking-tokens": "3000", + } + result = ParameterValidator.extract_claude_headers(headers) + assert result.get("max_turns") == 5 + assert result.get("allowed_tools") == ["Read", "Write"] + assert result.get("permission_mode") == "default" + assert result.get("max_thinking_tokens") == 3000 + + +class TestCompatibilityReporter: + """Test CompatibilityReporter.generate_compatibility_report()""" + + @pytest.fixture + def minimal_request(self): + """Request with minimal parameters.""" + return ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + ) + + def test_supported_parameters_identified(self, minimal_request): + """Model and messages are identified as supported.""" + report = CompatibilityReporter.generate_compatibility_report(minimal_request) + assert "model" in report["supported_parameters"] + assert "messages" in report["supported_parameters"] + + def test_stream_identified_as_supported(self): + """Stream parameter is identified as supported.""" + request = ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + stream=True, + ) + report = CompatibilityReporter.generate_compatibility_report(request) + assert "stream" in report["supported_parameters"] + + def test_user_identified_as_supported(self): + """User parameter is identified as supported (for logging).""" + request = ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + user="test_user", + ) + report = CompatibilityReporter.generate_compatibility_report(request) + assert "user (for logging)" in report["supported_parameters"] + + def test_temperature_unsupported_when_not_default(self): + """Non-default temperature is flagged as unsupported.""" + request = ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + temperature=0.8, + ) + report = CompatibilityReporter.generate_compatibility_report(request) + assert "temperature" in report["unsupported_parameters"] + assert len(report["suggestions"]) > 0 + + def test_top_p_unsupported_when_not_default(self): + """Non-default top_p is flagged as unsupported.""" + request = ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + top_p=0.9, + ) + report = CompatibilityReporter.generate_compatibility_report(request) + assert "top_p" in report["unsupported_parameters"] + + def test_max_tokens_unsupported(self): + """max_tokens is flagged as unsupported.""" + request = ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + max_tokens=500, + ) + report = CompatibilityReporter.generate_compatibility_report(request) + assert "max_tokens" in report["unsupported_parameters"] + + def test_stop_sequences_unsupported(self): + """stop sequences are flagged as unsupported.""" + request = ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + stop=["END"], + ) + report = CompatibilityReporter.generate_compatibility_report(request) + assert "stop" in report["unsupported_parameters"] + + def test_penalties_unsupported(self): + """presence_penalty and frequency_penalty are flagged as unsupported.""" + request = ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + presence_penalty=0.5, + frequency_penalty=0.5, + ) + report = CompatibilityReporter.generate_compatibility_report(request) + assert "presence_penalty" in report["unsupported_parameters"] + assert "frequency_penalty" in report["unsupported_parameters"] + + def test_logit_bias_unsupported(self): + """logit_bias is flagged as unsupported.""" + request = ChatCompletionRequest( + model="claude-sonnet-4-5-20250929", + messages=[Message(role="user", content="Hello")], + logit_bias={"hello": 2.0}, + ) + report = CompatibilityReporter.generate_compatibility_report(request) + assert "logit_bias" in report["unsupported_parameters"] + + def test_report_has_all_sections(self, minimal_request): + """Report contains all expected sections.""" + report = CompatibilityReporter.generate_compatibility_report(minimal_request) + assert "supported_parameters" in report + assert "unsupported_parameters" in report + assert "warnings" in report + assert "suggestions" in report + + def test_minimal_request_has_no_unsupported(self, minimal_request): + """Minimal request with defaults has no unsupported parameters.""" + report = CompatibilityReporter.generate_compatibility_report(minimal_request) + assert len(report["unsupported_parameters"]) == 0 diff --git a/tests/test_property_based.py b/tests/test_property_based.py new file mode 100644 index 0000000..f0b3edf --- /dev/null +++ b/tests/test_property_based.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +Property-based tests using Hypothesis for edge case discovery. + +These tests generate random inputs to find edge cases that manual testing might miss. +""" + +import pytest +from hypothesis import given, strategies as st, settings, assume + +from src.message_adapter import MessageAdapter +from src.parameter_validator import ParameterValidator +from src.constants import CLAUDE_MODELS + + +class TestMessageAdapterProperties: + """Property-based tests for MessageAdapter.""" + + @given(content=st.text(min_size=1, max_size=1000)) + @settings(max_examples=50) + def test_filter_content_handles_any_text(self, content: str): + """filter_content should handle any text without crashing.""" + assume("\x00" not in content) # Null bytes + + # Should not crash + result = MessageAdapter.filter_content(content) + assert isinstance(result, str) + + @given(text=st.text(min_size=0, max_size=10000)) + @settings(max_examples=30) + def test_estimate_tokens_returns_positive(self, text: str): + """Token estimation should return a non-negative integer.""" + result = MessageAdapter.estimate_tokens(text) + assert isinstance(result, int) + assert result >= 0 + + +class TestParameterValidatorProperties: + """Property-based tests for ParameterValidator.""" + + @given(model=st.sampled_from(CLAUDE_MODELS)) + @settings(max_examples=20) + def test_valid_model_names_accepted(self, model: str): + """Valid Claude model names should be accepted.""" + result = ParameterValidator.validate_model(model) + # validate_model always returns True (allows graceful degradation) + assert result is True + + @given(model=st.text(min_size=1, max_size=50)) + @settings(max_examples=30) + def test_any_model_name_accepted_gracefully(self, model: str): + """Any model name is accepted (graceful degradation).""" + assume(model.strip()) # Non-empty + + # validate_model always returns True to allow trying unknown models + result = ParameterValidator.validate_model(model) + assert result is True + + @given(permission_mode=st.sampled_from(["default", "acceptEdits", "bypassPermissions", "plan"])) + @settings(max_examples=10) + def test_valid_permission_modes_accepted(self, permission_mode: str): + """Valid permission modes should be accepted.""" + result = ParameterValidator.validate_permission_mode(permission_mode) + assert result is True + + @given(permission_mode=st.text(min_size=1, max_size=30)) + @settings(max_examples=20) + def test_invalid_permission_modes_rejected(self, permission_mode: str): + """Invalid permission modes should be rejected.""" + valid_modes = {"default", "acceptEdits", "bypassPermissions", "plan"} + assume(permission_mode not in valid_modes) + + result = ParameterValidator.validate_permission_mode(permission_mode) + assert result is False + + +class TestTokenEstimation: + """Property-based tests for token estimation consistency.""" + + @given(text=st.text(min_size=0, max_size=5000)) + @settings(max_examples=30) + def test_token_estimation_non_negative(self, text: str): + """Token estimation should always return non-negative value.""" + tokens = MessageAdapter.estimate_tokens(text) + assert tokens >= 0 + + @given( + prefix=st.text(min_size=10, max_size=100), + suffix=st.text(min_size=10, max_size=100), + ) + @settings(max_examples=20) + def test_concatenation_increases_tokens(self, prefix: str, suffix: str): + """Concatenating text should not decrease token count.""" + tokens_prefix = MessageAdapter.estimate_tokens(prefix) + tokens_suffix = MessageAdapter.estimate_tokens(suffix) + tokens_combined = MessageAdapter.estimate_tokens(prefix + suffix) + + # Combined should be at least as many as the larger part + # (may be less than sum due to subword tokenization) + assert tokens_combined >= min(tokens_prefix, tokens_suffix) diff --git a/tests/test_rate_limiter_unit.py b/tests/test_rate_limiter_unit.py new file mode 100644 index 0000000..2b14157 --- /dev/null +++ b/tests/test_rate_limiter_unit.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/rate_limiter.py + +Tests the rate limiting functions and configuration. +These are pure unit tests that don't require a running server. +""" + +import pytest +from unittest.mock import MagicMock, patch +from fastapi import Request +from fastapi.responses import JSONResponse + +# Need to patch environment before importing the module +import os + + +class TestGetRateLimitKey: + """Test get_rate_limit_key()""" + + def test_returns_remote_address(self): + """Should return the remote address from the request.""" + with patch("src.rate_limiter.get_remote_address") as mock_get_addr: + mock_get_addr.return_value = "192.168.1.100" + mock_request = MagicMock(spec=Request) + + from src.rate_limiter import get_rate_limit_key + + result = get_rate_limit_key(mock_request) + assert result == "192.168.1.100" + mock_get_addr.assert_called_once_with(mock_request) + + +class TestCreateRateLimiter: + """Test create_rate_limiter()""" + + def test_rate_limiter_disabled_returns_none(self): + """When RATE_LIMIT_ENABLED=false, returns None.""" + with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "false"}): + # Need to reimport to pick up new env var + import importlib + import src.rate_limiter + + importlib.reload(src.rate_limiter) + result = src.rate_limiter.create_rate_limiter() + assert result is None + + def test_rate_limiter_enabled_returns_limiter(self): + """When RATE_LIMIT_ENABLED=true, returns Limiter instance.""" + with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "true"}): + import importlib + import src.rate_limiter + + importlib.reload(src.rate_limiter) + result = src.rate_limiter.create_rate_limiter() + assert result is not None + + def test_rate_limiter_disabled_with_0(self): + """When RATE_LIMIT_ENABLED=0, returns None.""" + with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "0"}): + import importlib + import src.rate_limiter + + importlib.reload(src.rate_limiter) + result = src.rate_limiter.create_rate_limiter() + assert result is None + + def test_rate_limiter_disabled_with_no(self): + """When RATE_LIMIT_ENABLED=no, returns None.""" + with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "no"}): + import importlib + import src.rate_limiter + + importlib.reload(src.rate_limiter) + result = src.rate_limiter.create_rate_limiter() + assert result is None + + def test_rate_limiter_enabled_by_default(self): + """When RATE_LIMIT_ENABLED not set, rate limiting is enabled.""" + # Remove the env var if set + env_copy = os.environ.copy() + if "RATE_LIMIT_ENABLED" in env_copy: + del env_copy["RATE_LIMIT_ENABLED"] + + with patch.dict(os.environ, env_copy, clear=True): + import importlib + import src.rate_limiter + + importlib.reload(src.rate_limiter) + result = src.rate_limiter.create_rate_limiter() + assert result is not None + + +class TestRateLimitExceededHandler: + """Test rate_limit_exceeded_handler()""" + + @pytest.fixture + def mock_rate_limit_exceeded(self): + """Create a mock RateLimitExceeded exception.""" + from slowapi.errors import RateLimitExceeded + + # Create a mock Limit object that RateLimitExceeded expects + mock_limit = MagicMock() + mock_limit.error_message = None + mock_exc = MagicMock(spec=RateLimitExceeded) + mock_exc.limit = mock_limit + return mock_exc + + def test_returns_json_response(self, mock_rate_limit_exceeded): + """Returns a JSONResponse.""" + from src.rate_limiter import rate_limit_exceeded_handler + + mock_request = MagicMock(spec=Request) + + response = rate_limit_exceeded_handler(mock_request, mock_rate_limit_exceeded) + assert isinstance(response, JSONResponse) + + def test_returns_429_status(self, mock_rate_limit_exceeded): + """Returns 429 Too Many Requests status.""" + from src.rate_limiter import rate_limit_exceeded_handler + + mock_request = MagicMock(spec=Request) + + response = rate_limit_exceeded_handler(mock_request, mock_rate_limit_exceeded) + assert response.status_code == 429 + + def test_includes_retry_after_header(self, mock_rate_limit_exceeded): + """Response includes Retry-After header.""" + from src.rate_limiter import rate_limit_exceeded_handler + + mock_request = MagicMock(spec=Request) + + response = rate_limit_exceeded_handler(mock_request, mock_rate_limit_exceeded) + assert "Retry-After" in response.headers + assert response.headers["Retry-After"] == "60" + + +class TestGetRateLimitForEndpoint: + """Test get_rate_limit_for_endpoint()""" + + def test_chat_endpoint_default(self): + """Chat endpoint has default rate limit.""" + with patch.dict(os.environ, {}, clear=False): + # Ensure no override + if "RATE_LIMIT_CHAT_PER_MINUTE" in os.environ: + del os.environ["RATE_LIMIT_CHAT_PER_MINUTE"] + + import importlib + import src.rate_limiter + + importlib.reload(src.rate_limiter) + result = src.rate_limiter.get_rate_limit_for_endpoint("chat") + assert result == "10/minute" + + def test_debug_endpoint_default(self): + """Debug endpoint has default rate limit.""" + import importlib + import src.rate_limiter + + # Clear any override + env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_DEBUG_PER_MINUTE"} + with patch.dict(os.environ, env_copy, clear=True): + importlib.reload(src.rate_limiter) + result = src.rate_limiter.get_rate_limit_for_endpoint("debug") + assert result == "2/minute" + + def test_health_endpoint_default(self): + """Health endpoint has default rate limit.""" + import importlib + import src.rate_limiter + + env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_HEALTH_PER_MINUTE"} + with patch.dict(os.environ, env_copy, clear=True): + importlib.reload(src.rate_limiter) + result = src.rate_limiter.get_rate_limit_for_endpoint("health") + assert result == "30/minute" + + def test_session_endpoint_default(self): + """Session endpoint has default rate limit.""" + import importlib + import src.rate_limiter + + env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_SESSION_PER_MINUTE"} + with patch.dict(os.environ, env_copy, clear=True): + importlib.reload(src.rate_limiter) + result = src.rate_limiter.get_rate_limit_for_endpoint("session") + assert result == "15/minute" + + def test_auth_endpoint_default(self): + """Auth endpoint has default rate limit.""" + import importlib + import src.rate_limiter + + env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_AUTH_PER_MINUTE"} + with patch.dict(os.environ, env_copy, clear=True): + importlib.reload(src.rate_limiter) + result = src.rate_limiter.get_rate_limit_for_endpoint("auth") + assert result == "10/minute" + + def test_general_endpoint_default(self): + """General/unknown endpoint has default rate limit.""" + import importlib + import src.rate_limiter + + env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_PER_MINUTE"} + with patch.dict(os.environ, env_copy, clear=True): + importlib.reload(src.rate_limiter) + result = src.rate_limiter.get_rate_limit_for_endpoint("general") + assert result == "30/minute" + + def test_custom_rate_limit_from_env(self): + """Rate limit can be customized via environment variable.""" + import importlib + import src.rate_limiter + + with patch.dict(os.environ, {"RATE_LIMIT_CHAT_PER_MINUTE": "50"}): + importlib.reload(src.rate_limiter) + result = src.rate_limiter.get_rate_limit_for_endpoint("chat") + assert result == "50/minute" + + def test_unknown_endpoint_uses_general_default(self): + """Unknown endpoint uses general rate limit.""" + import importlib + import src.rate_limiter + + env_copy = {k: v for k, v in os.environ.items() if k != "RATE_LIMIT_PER_MINUTE"} + with patch.dict(os.environ, env_copy, clear=True): + importlib.reload(src.rate_limiter) + result = src.rate_limiter.get_rate_limit_for_endpoint("unknown_endpoint") + assert result == "30/minute" + + +class TestRateLimitEndpointDecorator: + """Test rate_limit_endpoint decorator factory.""" + + def test_decorator_returns_function(self): + """Decorator returns a function.""" + from src.rate_limiter import rate_limit_endpoint + + decorator = rate_limit_endpoint("chat") + assert callable(decorator) + + def test_decorator_wraps_function_with_request(self): + """Decorated function with request parameter can still be called.""" + from src.rate_limiter import rate_limit_endpoint + + # slowapi requires a 'request' parameter on decorated functions + @rate_limit_endpoint("chat") + def my_endpoint(request): + return "hello" + + # The function should still be callable (though it may be wrapped) + assert callable(my_endpoint) + + def test_decorator_without_limiter(self): + """When limiter is None, returns original function unchanged.""" + import importlib + import src.rate_limiter + + # Disable rate limiting + with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "false"}): + importlib.reload(src.rate_limiter) + + @src.rate_limiter.rate_limit_endpoint("chat") + def my_endpoint(): + return "hello" + + # Function should work normally + assert my_endpoint() == "hello" + + +# Reset module state after tests +@pytest.fixture(autouse=True) +def reset_rate_limiter_module(): + """Reset rate limiter module after each test to avoid test pollution.""" + yield + # Clean up after test + import importlib + import src.rate_limiter + + # Reset to default state + with patch.dict(os.environ, {"RATE_LIMIT_ENABLED": "true"}, clear=False): + importlib.reload(src.rate_limiter) diff --git a/tests/test_sdk_migration.py b/tests/test_sdk_migration.py index afb1398..6ad2d95 100644 --- a/tests/test_sdk_migration.py +++ b/tests/test_sdk_migration.py @@ -16,11 +16,7 @@ class TestSystemPromptFormats: def test_text_system_prompt_format(self): """Test text-based system prompt format.""" options = ClaudeAgentOptions( - max_turns=1, - system_prompt={ - "type": "text", - "text": "You are a helpful assistant." - } + max_turns=1, system_prompt={"type": "text", "text": "You are a helpful assistant."} ) assert options.system_prompt is not None assert isinstance(options.system_prompt, dict) @@ -29,11 +25,7 @@ def test_text_system_prompt_format(self): def test_preset_system_prompt_format(self): """Test preset-based system prompt format.""" options = ClaudeAgentOptions( - max_turns=1, - system_prompt={ - "type": "preset", - "preset": "claude_code" - } + max_turns=1, system_prompt={"type": "preset", "preset": "claude_code"} ) assert options.system_prompt is not None assert isinstance(options.system_prompt, dict) @@ -51,18 +43,13 @@ def test_basic_options_creation(self): def test_options_with_model(self): """Test options with model specification.""" - options = ClaudeAgentOptions( - max_turns=1, - model="claude-sonnet-4-5-20250929" - ) + options = ClaudeAgentOptions(max_turns=1, model="claude-sonnet-4-5-20250929") assert options.model == "claude-sonnet-4-5-20250929" def test_options_with_tools(self): """Test options with tool restrictions.""" options = ClaudeAgentOptions( - max_turns=1, - allowed_tools=["Read", "Write"], - disallowed_tools=["Bash"] + max_turns=1, allowed_tools=["Read", "Write"], disallowed_tools=["Bash"] ) assert options.allowed_tools == ["Read", "Write"] assert options.disallowed_tools == ["Bash"] @@ -153,10 +140,7 @@ def test_chat_completion_request_creation(self): from src.models import ChatCompletionRequest request = ChatCompletionRequest( - model="claude-sonnet-4-5-20250929", - messages=[ - {"role": "user", "content": "Hello"} - ] + model="claude-sonnet-4-5-20250929", messages=[{"role": "user", "content": "Hello"}] ) assert request.model == "claude-sonnet-4-5-20250929" diff --git a/tests/test_sdk_quick.py b/tests/test_sdk_quick.py index 5f43734..fd6b1cb 100644 --- a/tests/test_sdk_quick.py +++ b/tests/test_sdk_quick.py @@ -1,15 +1,28 @@ #!/usr/bin/env python3 -"""Quick test of Claude Agent SDK to verify migration.""" +"""Quick test of Claude Agent SDK to verify migration. + +This is an integration test that calls the Claude API. +""" import asyncio import sys import os +import pytest + # Ensure we're in the right directory sys.path.insert(0, os.path.dirname(__file__)) from claude_agent_sdk import query, ClaudeAgentOptions +# Skip if no API key available (integration test) +pytestmark = pytest.mark.skipif( + not os.getenv("ANTHROPIC_API_KEY"), + reason="ANTHROPIC_API_KEY not set - skipping SDK integration test", +) + + +@pytest.mark.asyncio async def test_simple_query(): """Test a simple query with the new SDK.""" print("Testing Claude Agent SDK with simple query...") @@ -20,21 +33,24 @@ async def test_simple_query(): async for message in query( prompt="Say 'Hello!' and nothing else.", options=ClaudeAgentOptions( - max_turns=1, - model="claude-3-5-haiku-20241022" # Fastest model for testing - ) + max_turns=1, model="claude-3-5-haiku-20241022" # Fastest model for testing + ), ): messages.append(message) print(f"Got message type: {type(message)}") # Try to extract content - if hasattr(message, 'content'): + if hasattr(message, "content"): print(f"Content: {message.content}") elif isinstance(message, dict): print(f"Message dict: {message}") # Break early if we get an assistant message - msg_type = getattr(message, 'type', None) if hasattr(message, 'type') else message.get("type") if isinstance(message, dict) else None + msg_type = ( + getattr(message, "type", None) + if hasattr(message, "type") + else message.get("type") if isinstance(message, dict) else None + ) if msg_type == "assistant": print("โœ“ Got assistant response!") break @@ -50,9 +66,11 @@ async def test_simple_query(): except Exception as e: print(f"โœ— Test failed with error: {e}") import traceback + traceback.print_exc() return False + if __name__ == "__main__": result = asyncio.run(test_simple_query()) sys.exit(0 if result else 1) diff --git a/tests/test_session_complete.py b/tests/test_session_complete.py index d929673..425aeb4 100644 --- a/tests/test_session_complete.py +++ b/tests/test_session_complete.py @@ -3,18 +3,23 @@ Comprehensive test for session continuity functionality. """ +import pytest import requests + +from tests.conftest import requires_server import json import time BASE_URL = "http://localhost:8000" + +@requires_server def test_session_continuity_comprehensive(): """Test session continuity with multiple conversation turns.""" print("๐Ÿงช Testing comprehensive session continuity...") - + session_id = "comprehensive-test" - + # Conversation sequence to test memory conversation = [ {"user": "Hello! My name is Charlie and I'm 25 years old.", "expect_memory": None}, @@ -23,137 +28,163 @@ def test_session_continuity_comprehensive(): {"user": "How old am I?", "expect_memory": "25"}, {"user": "What do I do for work?", "expect_memory": "software engineer"}, ] - + for i, turn in enumerate(conversation, 1): print(f"\n{i}๏ธโƒฃ Turn {i}: {turn['user']}") - - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": turn["user"]}], - "session_id": session_id - }) - + + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": turn["user"]}], + "session_id": session_id, + }, + ) + if response.status_code != 200: print(f"โŒ Turn {i} failed: {response.status_code}") return False - + result = response.json() - response_text = result['choices'][0]['message']['content'] + response_text = result["choices"][0]["message"]["content"] print(f" Response: {response_text[:100]}...") - + # Check if expected information is remembered if turn["expect_memory"]: if turn["expect_memory"].lower() in response_text.lower(): print(f" โœ… Memory check passed: '{turn['expect_memory']}' found") else: - print(f" โš ๏ธ Memory check unclear: '{turn['expect_memory']}' not found, but may still be working") - + print( + f" โš ๏ธ Memory check unclear: '{turn['expect_memory']}' not found, but may still be working" + ) + # Check session info session_info = requests.get(f"{BASE_URL}/v1/sessions/{session_id}") if session_info.status_code == 200: info = session_info.json() print(f"\n๐Ÿ“Š Session info: {info['message_count']} messages stored") expected_messages = len(conversation) * 2 # user + assistant for each turn - if info['message_count'] == expected_messages: + if info["message_count"] == expected_messages: print(f" โœ… Correct message count: {expected_messages}") else: - print(f" โš ๏ธ Message count mismatch: expected {expected_messages}, got {info['message_count']}") - + print( + f" โš ๏ธ Message count mismatch: expected {expected_messages}, got {info['message_count']}" + ) + # Cleanup requests.delete(f"{BASE_URL}/v1/sessions/{session_id}") print(f" ๐Ÿงน Session {session_id} cleaned up") - + return True + +@requires_server def test_stateless_vs_session(): """Test that stateless and session modes work differently.""" print("\n๐Ÿงช Testing stateless vs session behavior...") - + # Test stateless (no session_id) print("1๏ธโƒฃ Stateless mode:") - requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "Remember: my favorite color is blue."}] - }) - + requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Remember: my favorite color is blue."}], + }, + ) + # Follow up question without session_id - response1 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "What's my favorite color?"}] - }) - + response1 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my favorite color?"}], + }, + ) + if response1.status_code == 200: result1 = response1.json() - stateless_response = result1['choices'][0]['message']['content'] + stateless_response = result1["choices"][0]["message"]["content"] print(f" Stateless response: {stateless_response[:100]}...") - - # Test session mode + + # Test session mode print("2๏ธโƒฃ Session mode:") session_id = "color-test-session" - - requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "Remember: my favorite color is red."}], - "session_id": session_id - }) - - response2 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "What's my favorite color?"}], - "session_id": session_id - }) - + + requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Remember: my favorite color is red."}], + "session_id": session_id, + }, + ) + + response2 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my favorite color?"}], + "session_id": session_id, + }, + ) + if response2.status_code == 200: result2 = response2.json() - session_response = result2['choices'][0]['message']['content'] + session_response = result2["choices"][0]["message"]["content"] print(f" Session response: {session_response[:100]}...") - + if "red" in session_response.lower(): print(" โœ… Session mode correctly remembered the color") else: print(" โš ๏ธ Session mode didn't clearly show memory, but may still be working") - + # Cleanup requests.delete(f"{BASE_URL}/v1/sessions/{session_id}") return True + +@requires_server def test_session_endpoints(): """Test all session management endpoints.""" print("\n๐Ÿงช Testing session management endpoints...") - + # Create some sessions session_ids = ["endpoint-test-1", "endpoint-test-2", "endpoint-test-3"] - + for session_id in session_ids: - requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": f"Test session {session_id}"}], - "session_id": session_id - }) - + requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": f"Test session {session_id}"}], + "session_id": session_id, + }, + ) + # Test list sessions list_response = requests.get(f"{BASE_URL}/v1/sessions") if list_response.status_code == 200: sessions = list_response.json() print(f" โœ… Listed {sessions['total']} sessions") - - if sessions['total'] >= len(session_ids): + + if sessions["total"] >= len(session_ids): print(f" โœ… Found all test sessions") else: print(f" โš ๏ธ Expected at least {len(session_ids)} sessions, found {sessions['total']}") - + # Test get specific session get_response = requests.get(f"{BASE_URL}/v1/sessions/{session_ids[0]}") if get_response.status_code == 200: session_info = get_response.json() print(f" โœ… Retrieved session info: {session_info['message_count']} messages") - + # Test session stats stats_response = requests.get(f"{BASE_URL}/v1/sessions/stats") if stats_response.status_code == 200: stats = stats_response.json() print(f" โœ… Session stats: {stats['session_stats']['active_sessions']} active") - + # Test delete sessions for session_id in session_ids: delete_response = requests.delete(f"{BASE_URL}/v1/sessions/{session_id}") @@ -161,13 +192,14 @@ def test_session_endpoints(): print(f" โœ… Deleted session {session_id}") else: print(f" โŒ Failed to delete session {session_id}") - + return True + def main(): """Run comprehensive session tests.""" print("๐Ÿš€ Starting comprehensive session continuity tests...") - + # Test server health try: health = requests.get(f"{BASE_URL}/health", timeout=5) @@ -178,14 +210,14 @@ def main(): except Exception as e: print(f"โŒ Server connection error: {e}") return - + # Run all tests tests = [ ("Session Continuity", test_session_continuity_comprehensive), ("Stateless vs Session", test_stateless_vs_session), ("Session Endpoints", test_session_endpoints), ] - + passed = 0 for test_name, test_func in tests: try: @@ -197,15 +229,16 @@ def main(): print(f"โŒ {test_name} test failed") except Exception as e: print(f"โŒ {test_name} test error: {e}") - + print(f"\n{'='*50}") print(f"๐Ÿ“Š Final Results: {passed}/{len(tests)} tests passed") - + if passed == len(tests): print("๐ŸŽ‰ All comprehensive session tests passed!") print("โœจ Session continuity is working correctly!") else: print("โš ๏ธ Some tests failed - check the output above") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_session_continuity.py b/tests/test_session_continuity.py index 887f44a..26bb143 100644 --- a/tests/test_session_continuity.py +++ b/tests/test_session_continuity.py @@ -5,7 +5,10 @@ import asyncio import json +import pytest import requests + +from tests.conftest import requires_server import time from typing import Dict, Any @@ -14,17 +17,19 @@ TEST_SESSION_ID = "test-session-123" +@requires_server def test_stateless_mode(): """Test traditional stateless OpenAI-style requests.""" print("๐Ÿงช Testing stateless mode...") - - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Hello! My name is Alice."} - ] - }) - + + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Hello! My name is Alice."}], + }, + ) + if response.status_code == 200: result = response.json() print(f"โœ… Stateless request successful") @@ -35,48 +40,51 @@ def test_stateless_mode(): return False +@requires_server def test_session_mode(): """Test session-based requests with conversation continuity.""" print(f"\n๐Ÿงช Testing session mode with session_id: {TEST_SESSION_ID}") - + # First message in session print("1๏ธโƒฃ First message in session...") - response1 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Hello! My name is Bob. Remember this name."} - ], - "session_id": TEST_SESSION_ID - }) - + response1 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Hello! My name is Bob. Remember this name."}], + "session_id": TEST_SESSION_ID, + }, + ) + if response1.status_code != 200: print(f"โŒ First session request failed: {response1.status_code} - {response1.text}") return False - + result1 = response1.json() print(f"โœ… First session message successful") print(f" Response: {result1['choices'][0]['message']['content'][:100]}...") - + # Second message in same session - should remember the name print("2๏ธโƒฃ Second message in same session...") - response2 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "What's my name?"} - ], - "session_id": TEST_SESSION_ID - }) - + response2 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my name?"}], + "session_id": TEST_SESSION_ID, + }, + ) + if response2.status_code != 200: print(f"โŒ Second session request failed: {response2.status_code} - {response2.text}") return False - + result2 = response2.json() print(f"โœ… Second session message successful") print(f" Response: {result2['choices'][0]['message']['content'][:100]}...") - + # Check if the response mentions the name "Bob" - response_text = result2['choices'][0]['message']['content'].lower() + response_text = result2["choices"][0]["message"]["content"].lower() if "bob" in response_text: print("โœ… Session continuity working - Claude remembered the name!") return True @@ -85,22 +93,23 @@ def test_session_mode(): return True # Still successful, maybe Claude responded differently +@requires_server def test_session_management_endpoints(): """Test session management endpoints.""" print(f"\n๐Ÿงช Testing session management endpoints...") - + # List sessions print("1๏ธโƒฃ Listing sessions...") response = requests.get(f"{BASE_URL}/v1/sessions") if response.status_code == 200: sessions = response.json() print(f"โœ… Sessions listed: {sessions['total']} active sessions") - if sessions['total'] > 0: + if sessions["total"] > 0: print(f" First session: {sessions['sessions'][0]['session_id']}") else: print(f"โŒ Failed to list sessions: {response.status_code}") return False - + # Get specific session info print("2๏ธโƒฃ Getting session info...") response = requests.get(f"{BASE_URL}/v1/sessions/{TEST_SESSION_ID}") @@ -112,7 +121,7 @@ def test_session_management_endpoints(): else: print(f"โŒ Failed to get session info: {response.status_code}") return False - + # Get session stats print("3๏ธโƒฃ Getting session stats...") response = requests.get(f"{BASE_URL}/v1/sessions/stats") @@ -124,49 +133,58 @@ def test_session_management_endpoints(): else: print(f"โŒ Failed to get session stats: {response.status_code}") return False - + return True +@requires_server def test_session_streaming(): """Test session continuity with streaming.""" print(f"\n๐Ÿงช Testing session streaming...") - + # Create a new session for streaming test stream_session_id = "test-stream-456" - - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Hello! I'm testing streaming. My favorite color is purple."} - ], - "session_id": stream_session_id, - "stream": True - }, stream=True) - + + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [ + { + "role": "user", + "content": "Hello! I'm testing streaming. My favorite color is purple.", + } + ], + "session_id": stream_session_id, + "stream": True, + }, + stream=True, + ) + if response.status_code != 200: print(f"โŒ Streaming request failed: {response.status_code}") return False - + print("โœ… Streaming response received") - + # Follow up with another message in the same session time.sleep(1) # Give time for the session to be updated - - response2 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "What's my favorite color?"} - ], - "session_id": stream_session_id - }) - + + response2 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my favorite color?"}], + "session_id": stream_session_id, + }, + ) + if response2.status_code == 200: result = response2.json() - response_text = result['choices'][0]['message']['content'].lower() + response_text = result["choices"][0]["message"]["content"].lower() print(f"โœ… Follow-up message successful") print(f" Response: {result['choices'][0]['message']['content'][:100]}...") - + if "purple" in response_text: print("โœ… Session continuity working with streaming!") else: @@ -180,7 +198,7 @@ def test_session_streaming(): def cleanup_test_sessions(): """Clean up test sessions.""" print(f"\n๐Ÿงน Cleaning up test sessions...") - + for session_id in [TEST_SESSION_ID, "test-stream-456"]: response = requests.delete(f"{BASE_URL}/v1/sessions/{session_id}") if response.status_code == 200: @@ -195,7 +213,7 @@ def main(): """Run all session continuity tests.""" print("๐Ÿš€ Starting session continuity tests...") print(f" Server: {BASE_URL}") - + # Test server health first try: response = requests.get(f"{BASE_URL}/health", timeout=5) @@ -207,10 +225,10 @@ def main(): print(f"โŒ Cannot connect to server: {e}") print(" Make sure the server is running with: poetry run python main.py") return - + success_count = 0 total_tests = 4 - + # Run tests tests = [ ("Stateless Mode", test_stateless_mode), @@ -218,7 +236,7 @@ def main(): ("Session Management", test_session_management_endpoints), ("Session Streaming", test_session_streaming), ] - + for test_name, test_func in tests: try: if test_func(): @@ -227,13 +245,13 @@ def main(): print(f"โŒ {test_name} test failed") except Exception as e: print(f"โŒ {test_name} test error: {e}") - + # Cleanup cleanup_test_sessions() - + # Results print(f"\n๐Ÿ“Š Test Results: {success_count}/{total_tests} tests passed") - + if success_count == total_tests: print("๐ŸŽ‰ All session continuity tests passed!") else: @@ -241,4 +259,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_session_manager_unit.py b/tests/test_session_manager_unit.py new file mode 100644 index 0000000..961a385 --- /dev/null +++ b/tests/test_session_manager_unit.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/session_manager.py + +Tests the Session and SessionManager classes. +These are pure unit tests that don't require a running server. +""" + +import pytest +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch +import asyncio + +from src.session_manager import Session, SessionManager +from src.models import Message + + +class TestSession: + """Test the Session dataclass.""" + + def test_session_creation_with_id(self): + """Session can be created with just an ID.""" + session = Session(session_id="test-123") + assert session.session_id == "test-123" + assert session.messages == [] + assert isinstance(session.created_at, datetime) + assert isinstance(session.last_accessed, datetime) + assert isinstance(session.expires_at, datetime) + + def test_session_expiry_in_future(self): + """Newly created session expires in the future.""" + session = Session(session_id="test-123") + assert session.expires_at > datetime.utcnow() + + def test_touch_updates_last_accessed(self): + """touch() updates last_accessed time.""" + session = Session(session_id="test-123") + original_accessed = session.last_accessed + + # Small delay to ensure time difference + import time + + time.sleep(0.01) + session.touch() + + assert session.last_accessed >= original_accessed + + def test_touch_extends_expiration(self): + """touch() extends the expiration time.""" + session = Session(session_id="test-123") + original_expires = session.expires_at + + import time + + time.sleep(0.01) + session.touch() + + assert session.expires_at >= original_expires + + def test_add_messages_appends_to_list(self): + """add_messages() appends new messages to the session.""" + session = Session(session_id="test-123") + msg1 = Message(role="user", content="Hello") + msg2 = Message(role="assistant", content="Hi there") + + session.add_messages([msg1]) + assert len(session.messages) == 1 + + session.add_messages([msg2]) + assert len(session.messages) == 2 + + def test_add_messages_touches_session(self): + """add_messages() also touches the session.""" + session = Session(session_id="test-123") + original_accessed = session.last_accessed + + import time + + time.sleep(0.01) + session.add_messages([Message(role="user", content="Test")]) + + assert session.last_accessed >= original_accessed + + def test_get_all_messages_returns_copy(self): + """get_all_messages() returns all messages.""" + session = Session(session_id="test-123") + msg1 = Message(role="user", content="Hello") + msg2 = Message(role="assistant", content="Hi") + + session.add_messages([msg1, msg2]) + messages = session.get_all_messages() + + assert len(messages) == 2 + assert messages[0].content == "Hello" + assert messages[1].content == "Hi" + + def test_is_expired_false_for_new_session(self): + """Newly created session is not expired.""" + session = Session(session_id="test-123") + assert session.is_expired() is False + + def test_is_expired_true_for_past_expiry(self): + """Session with past expiry is expired.""" + session = Session(session_id="test-123", expires_at=datetime.utcnow() - timedelta(hours=1)) + assert session.is_expired() is True + + def test_to_session_info_returns_correct_model(self): + """to_session_info() returns properly populated SessionInfo.""" + session = Session(session_id="test-123") + session.add_messages([Message(role="user", content="Test")]) + + info = session.to_session_info() + + assert info.session_id == "test-123" + assert info.message_count == 1 + assert isinstance(info.created_at, datetime) + assert isinstance(info.last_accessed, datetime) + assert isinstance(info.expires_at, datetime) + + +class TestSessionManager: + """Test the SessionManager class.""" + + @pytest.fixture + def manager(self): + """Create a fresh SessionManager for each test.""" + return SessionManager(default_ttl_hours=1, cleanup_interval_minutes=5) + + def test_manager_initialization(self, manager): + """SessionManager initializes with empty sessions.""" + assert len(manager.sessions) == 0 + assert manager.default_ttl_hours == 1 + assert manager.cleanup_interval_minutes == 5 + + def test_get_or_create_session_creates_new(self, manager): + """get_or_create_session() creates new session if not exists.""" + session = manager.get_or_create_session("new-session") + + assert session is not None + assert session.session_id == "new-session" + assert "new-session" in manager.sessions + + def test_get_or_create_session_returns_existing(self, manager): + """get_or_create_session() returns existing session.""" + session1 = manager.get_or_create_session("existing") + session1.add_messages([Message(role="user", content="Test")]) + + session2 = manager.get_or_create_session("existing") + + assert session2 is session1 + assert len(session2.messages) == 1 + + def test_get_or_create_replaces_expired_session(self, manager): + """get_or_create_session() replaces expired session with new one.""" + # Create session and add messages first + session1 = manager.get_or_create_session("expiring") + session1.add_messages([Message(role="user", content="Old")]) + # Expire AFTER adding messages (add_messages calls touch() which extends expiry) + session1.expires_at = datetime.utcnow() - timedelta(hours=1) + + # Should get a new session since the old one is expired + session2 = manager.get_or_create_session("expiring") + + assert len(session2.messages) == 0 # New session has no messages + + def test_get_session_returns_none_for_nonexistent(self, manager): + """get_session() returns None for non-existent session.""" + result = manager.get_session("nonexistent") + assert result is None + + def test_get_session_returns_existing(self, manager): + """get_session() returns existing active session.""" + manager.get_or_create_session("existing") + result = manager.get_session("existing") + + assert result is not None + assert result.session_id == "existing" + + def test_get_session_returns_none_for_expired(self, manager): + """get_session() returns None and cleans up expired session.""" + session = manager.get_or_create_session("expiring") + session.expires_at = datetime.utcnow() - timedelta(hours=1) + + result = manager.get_session("expiring") + + assert result is None + assert "expiring" not in manager.sessions + + def test_delete_session_removes_session(self, manager): + """delete_session() removes existing session.""" + manager.get_or_create_session("to-delete") + assert "to-delete" in manager.sessions + + result = manager.delete_session("to-delete") + + assert result is True + assert "to-delete" not in manager.sessions + + def test_delete_session_returns_false_for_nonexistent(self, manager): + """delete_session() returns False for non-existent session.""" + result = manager.delete_session("nonexistent") + assert result is False + + def test_list_sessions_returns_active_sessions(self, manager): + """list_sessions() returns list of active sessions.""" + manager.get_or_create_session("session-1") + manager.get_or_create_session("session-2") + + sessions = manager.list_sessions() + + assert len(sessions) == 2 + session_ids = [s.session_id for s in sessions] + assert "session-1" in session_ids + assert "session-2" in session_ids + + def test_list_sessions_excludes_expired(self, manager): + """list_sessions() excludes and cleans up expired sessions.""" + manager.get_or_create_session("active") + expired = manager.get_or_create_session("expired") + expired.expires_at = datetime.utcnow() - timedelta(hours=1) + + sessions = manager.list_sessions() + + assert len(sessions) == 1 + assert sessions[0].session_id == "active" + + def test_process_messages_stateless_mode(self, manager): + """process_messages() in stateless mode returns messages as-is.""" + messages = [Message(role="user", content="Hello")] + + result_msgs, session_id = manager.process_messages(messages, session_id=None) + + assert result_msgs == messages + assert session_id is None + + def test_process_messages_session_mode(self, manager): + """process_messages() in session mode accumulates messages.""" + msg1 = Message(role="user", content="First") + msg2 = Message(role="user", content="Second") + + # First call + result1, sid1 = manager.process_messages([msg1], session_id="my-session") + assert len(result1) == 1 + assert sid1 == "my-session" + + # Second call - should have both messages + result2, sid2 = manager.process_messages([msg2], session_id="my-session") + assert len(result2) == 2 + assert sid2 == "my-session" + + def test_add_assistant_response_in_session_mode(self, manager): + """add_assistant_response() adds response to session.""" + manager.get_or_create_session("my-session") + assistant_msg = Message(role="assistant", content="Hello!") + + manager.add_assistant_response("my-session", assistant_msg) + + session = manager.get_session("my-session") + assert len(session.messages) == 1 + assert session.messages[0].role == "assistant" + + def test_add_assistant_response_stateless_mode_noop(self, manager): + """add_assistant_response() does nothing in stateless mode.""" + assistant_msg = Message(role="assistant", content="Hello!") + + # Should not raise, just do nothing + manager.add_assistant_response(None, assistant_msg) + + def test_get_stats_returns_correct_counts(self, manager): + """get_stats() returns correct statistics.""" + manager.get_or_create_session("session-1") + session2 = manager.get_or_create_session("session-2") + session2.add_messages([Message(role="user", content="Test")]) + + # Create expired session + expired = manager.get_or_create_session("expired") + expired.expires_at = datetime.utcnow() - timedelta(hours=1) + + stats = manager.get_stats() + + assert stats["active_sessions"] == 2 + assert stats["expired_sessions"] == 1 + assert stats["total_messages"] == 1 + + def test_shutdown_clears_sessions(self, manager): + """shutdown() clears all sessions.""" + manager.get_or_create_session("session-1") + manager.get_or_create_session("session-2") + assert len(manager.sessions) == 2 + + manager.shutdown() + + assert len(manager.sessions) == 0 + + def test_cleanup_expired_sessions(self, manager): + """_cleanup_expired_sessions() removes only expired sessions.""" + manager.get_or_create_session("active") + expired = manager.get_or_create_session("expired") + expired.expires_at = datetime.utcnow() - timedelta(hours=1) + + manager._cleanup_expired_sessions() + + assert "active" in manager.sessions + assert "expired" not in manager.sessions + + +class TestSessionManagerAsync: + """Test async functionality of SessionManager.""" + + @pytest.fixture + def manager(self): + """Create a fresh SessionManager for each test.""" + return SessionManager(default_ttl_hours=1, cleanup_interval_minutes=5) + + @pytest.mark.asyncio + async def test_start_cleanup_task_creates_task(self, manager): + """start_cleanup_task() creates an async task when loop is running.""" + # Start the cleanup task + manager.start_cleanup_task() + + # Task should be created + assert manager._cleanup_task is not None + + # Clean up + manager.shutdown() + + @pytest.mark.asyncio + async def test_start_cleanup_task_idempotent(self, manager): + """start_cleanup_task() only creates one task.""" + manager.start_cleanup_task() + first_task = manager._cleanup_task + + manager.start_cleanup_task() + second_task = manager._cleanup_task + + assert first_task is second_task + + # Clean up + manager.shutdown() + + +class TestSessionManagerThreadSafety: + """Test thread safety of SessionManager operations.""" + + @pytest.fixture + def manager(self): + """Create a fresh SessionManager for each test.""" + return SessionManager() + + def test_concurrent_session_creation(self, manager): + """Multiple threads can create sessions concurrently.""" + import threading + + results = [] + errors = [] + + def create_session(session_id): + try: + session = manager.get_or_create_session(session_id) + results.append(session.session_id) + except Exception as e: + errors.append(str(e)) + + threads = [] + for i in range(10): + t = threading.Thread(target=create_session, args=(f"session-{i}",)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + assert len(errors) == 0 + assert len(results) == 10 + assert len(manager.sessions) == 10 diff --git a/tests/test_session_simple.py b/tests/test_session_simple.py index 3c3c2f4..0ddb224 100644 --- a/tests/test_session_simple.py +++ b/tests/test_session_simple.py @@ -1,42 +1,50 @@ #!/usr/bin/env python3 """ Simple test for session continuity functionality. + +These are integration tests that require a running server. """ +import pytest import requests import json import time +from tests.conftest import requires_server + BASE_URL = "http://localhost:8000" TEST_SESSION_ID = "test-simple-session" + +@requires_server def test_session_creation(): """Test creating a session and checking it appears in the list.""" print("๐Ÿงช Testing session creation...") - + # Make a request with a session_id - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Hello, remember my name is Alice."} - ], - "session_id": TEST_SESSION_ID - }) - + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Hello, remember my name is Alice."}], + "session_id": TEST_SESSION_ID, + }, + ) + if response.status_code != 200: print(f"โŒ Session creation failed: {response.status_code}") return False - + print("โœ… Session creation request successful") - + # Check if session appears in the list sessions_response = requests.get(f"{BASE_URL}/v1/sessions") if sessions_response.status_code == 200: sessions_data = sessions_response.json() print(f"โœ… Found {sessions_data['total']} sessions") - + # Check if our session is in the list - session_ids = [s['session_id'] for s in sessions_data['sessions']] + session_ids = [s["session_id"] for s in sessions_data["sessions"]] if TEST_SESSION_ID in session_ids: print(f"โœ… Session {TEST_SESSION_ID} found in session list") return True @@ -47,27 +55,30 @@ def test_session_creation(): print(f"โŒ Failed to list sessions: {sessions_response.status_code}") return False + +@requires_server def test_session_continuity(): """Test that conversation context is maintained across requests.""" print("\n๐Ÿงช Testing session continuity...") - + # Follow up message asking about the name - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "What's my name?"} - ], - "session_id": TEST_SESSION_ID - }) - + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my name?"}], + "session_id": TEST_SESSION_ID, + }, + ) + if response.status_code != 200: print(f"โŒ Continuity test failed: {response.status_code}") return False - + result = response.json() - response_text = result['choices'][0]['message']['content'].lower() + response_text = result["choices"][0]["message"]["content"].lower() print(f"Response: {result['choices'][0]['message']['content'][:100]}...") - + # Check if response mentions Alice if "alice" in response_text: print("โœ… Session continuity working - name remembered!") @@ -76,20 +87,22 @@ def test_session_continuity(): print("โš ๏ธ Response doesn't mention Alice, but session continuity may still be working") return True # Don't fail the test just because of this + +@requires_server def test_session_cleanup(): """Test session deletion.""" print("\n๐Ÿงช Testing session cleanup...") - + # Delete the session delete_response = requests.delete(f"{BASE_URL}/v1/sessions/{TEST_SESSION_ID}") if delete_response.status_code == 200: print("โœ… Session deleted successfully") - + # Verify it's gone from the list sessions_response = requests.get(f"{BASE_URL}/v1/sessions") if sessions_response.status_code == 200: sessions_data = sessions_response.json() - session_ids = [s['session_id'] for s in sessions_data['sessions']] + session_ids = [s["session_id"] for s in sessions_data["sessions"]] if TEST_SESSION_ID not in session_ids: print("โœ… Session successfully removed from list") return True @@ -103,10 +116,11 @@ def test_session_cleanup(): print(f"โŒ Failed to delete session: {delete_response.status_code}") return False + def main(): """Run simple session tests.""" print("๐Ÿš€ Starting simple session tests...") - + # Test server health try: health_response = requests.get(f"{BASE_URL}/health", timeout=5) @@ -117,14 +131,14 @@ def main(): except Exception as e: print(f"โŒ Cannot connect to server: {e}") return - + # Run tests tests = [ ("Session Creation", test_session_creation), ("Session Continuity", test_session_continuity), ("Session Cleanup", test_session_cleanup), ] - + passed = 0 for test_name, test_func in tests: try: @@ -134,13 +148,14 @@ def main(): print(f"โŒ {test_name} test failed") except Exception as e: print(f"โŒ {test_name} test error: {e}") - + print(f"\n๐Ÿ“Š Results: {passed}/{len(tests)} tests passed") - + if passed == len(tests): print("๐ŸŽ‰ All session tests passed!") else: print("โš ๏ธ Some tests failed") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_textblock_fix.py b/tests/test_textblock_fix.py index c5b3409..69fc7db 100644 --- a/tests/test_textblock_fix.py +++ b/tests/test_textblock_fix.py @@ -8,92 +8,85 @@ import requests # Set debug mode -os.environ['DEBUG_MODE'] = 'true' +os.environ["DEBUG_MODE"] = "true" + def test_textblock_fix(): """Test that TextBlock content extraction is working.""" print("๐Ÿงช Testing TextBlock content extraction fix...") - + # Simple request that should trigger Claude to respond with normal text request_data = { "model": "claude-3-7-sonnet-20250219", - "messages": [ - { - "role": "user", - "content": "Hello! Can you briefly introduce yourself?" - } - ], + "messages": [{"role": "user", "content": "Hello! Can you briefly introduce yourself?"}], "stream": True, - "temperature": 0.0 + "temperature": 0.0, } - + try: # Send streaming request response = requests.post( - "http://localhost:8000/v1/chat/completions", - json=request_data, - stream=True, - timeout=30 + "http://localhost:8000/v1/chat/completions", json=request_data, stream=True, timeout=30 ) - + print(f"โœ… Response status: {response.status_code}") - + if response.status_code != 200: print(f"โŒ Request failed: {response.text}") return False - + # Parse streaming chunks and collect content all_content = "" has_role_chunk = False has_content = False - + for line in response.iter_lines(): if line: - line_str = line.decode('utf-8') - if line_str.startswith('data: '): + line_str = line.decode("utf-8") + if line_str.startswith("data: "): data_str = line_str[6:] # Remove "data: " prefix - + if data_str == "[DONE]": break - + try: chunk_data = json.loads(data_str) - + # Check chunk structure - if 'choices' in chunk_data and len(chunk_data['choices']) > 0: - choice = chunk_data['choices'][0] - delta = choice.get('delta', {}) - + if "choices" in chunk_data and len(chunk_data["choices"]) > 0: + choice = chunk_data["choices"][0] + delta = choice.get("delta", {}) + # Check for role chunk - if 'role' in delta: + if "role" in delta: has_role_chunk = True print(f"โœ… Found role chunk") - - # Check for content chunk - if 'content' in delta: - content = delta['content'] + + # Check for content chunk + if "content" in delta: + content = delta["content"] all_content += content has_content = True print(f"โœ… Found content: {content[:50]}...") - + except json.JSONDecodeError as e: print(f"โŒ Invalid JSON in chunk: {data_str}") return False - + print(f"\n๐Ÿ“Š Test Results:") print(f" Has role chunk: {has_role_chunk}") print(f" Has content: {has_content}") print(f" Total content length: {len(all_content)}") print(f" Content preview: {all_content[:200]}...") - + # Check if we got actual content instead of fallback message fallback_messages = [ "I'm unable to provide a response at the moment", - "I understand you're testing the system" + "I understand you're testing the system", ] - + is_fallback = any(msg in all_content for msg in fallback_messages) - + if has_content and not is_fallback and len(all_content) > 20: print("\n๐ŸŽ‰ TextBlock fix is working!") print("โœ… Real content extracted successfully") @@ -103,18 +96,19 @@ def test_textblock_fix(): print("\nโŒ TextBlock fix is not working") print("โš ๏ธ Still receiving fallback content or no content") return False - + except Exception as e: print(f"โŒ Test failed with exception: {e}") return False + def main(): """Test the TextBlock fix.""" print("๐Ÿ” Testing TextBlock Content Extraction Fix") print("=" * 50) - + success = test_textblock_fix() - + print("\n" + "=" * 50) if success: print("๐ŸŽ‰ TextBlock fix test PASSED!") @@ -122,9 +116,10 @@ def main(): else: print("โŒ TextBlock fix test FAILED") print("โš ๏ธ Issue may still persist") - + return success + if __name__ == "__main__": success = main() - exit(0 if success else 1) \ No newline at end of file + exit(0 if success else 1) diff --git a/tests/test_tool_execution.py b/tests/test_tool_execution.py new file mode 100644 index 0000000..3c8fe34 --- /dev/null +++ b/tests/test_tool_execution.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Tests for tool execution functionality. + +Tests the fixes for enable_tools=true parameter: +- permission_mode passthrough to ClaudeAgentOptions +- parse_claude_message correctly handling multi-turn ResultMessage +- DEFAULT_ALLOWED_TOOLS configuration +""" + +import pytest +from claude_agent_sdk import ClaudeAgentOptions + + +class TestPermissionMode: + """Test permission_mode configuration for tool execution.""" + + def test_permission_mode_option_exists(self): + """Test that ClaudeAgentOptions supports permission_mode.""" + options = ClaudeAgentOptions(max_turns=1, permission_mode="bypassPermissions") + assert options.permission_mode == "bypassPermissions" + + def test_permission_mode_default(self): + """Test that permission_mode defaults to None/default.""" + options = ClaudeAgentOptions(max_turns=1) + # permission_mode should be None or "default" when not set + assert options.permission_mode in [None, "default", ""] + + def test_permission_mode_accept_edits(self): + """Test acceptEdits permission mode.""" + options = ClaudeAgentOptions(max_turns=1, permission_mode="acceptEdits") + assert options.permission_mode == "acceptEdits" + + +class TestDefaultAllowedTools: + """Test DEFAULT_ALLOWED_TOOLS constant.""" + + def test_default_allowed_tools_defined(self): + """Test that DEFAULT_ALLOWED_TOOLS is defined.""" + from src.constants import DEFAULT_ALLOWED_TOOLS + + assert isinstance(DEFAULT_ALLOWED_TOOLS, list) + assert len(DEFAULT_ALLOWED_TOOLS) > 0 + + def test_default_allowed_tools_contains_safe_tools(self): + """Test that DEFAULT_ALLOWED_TOOLS contains expected safe tools.""" + from src.constants import DEFAULT_ALLOWED_TOOLS + + # These tools should be in the default allowed set + expected_tools = ["Read", "Glob", "Grep", "Bash", "Write", "Edit"] + for tool in expected_tools: + assert tool in DEFAULT_ALLOWED_TOOLS, f"Expected {tool} in DEFAULT_ALLOWED_TOOLS" + + def test_default_allowed_tools_excludes_dangerous(self): + """Test that potentially dangerous tools are excluded by default.""" + from src.constants import DEFAULT_ALLOWED_TOOLS + + # These tools should NOT be in the default allowed set + # (they're in DEFAULT_DISALLOWED_TOOLS) + dangerous_tools = ["Task", "WebFetch", "WebSearch"] + for tool in dangerous_tools: + assert ( + tool not in DEFAULT_ALLOWED_TOOLS + ), f"{tool} should not be in DEFAULT_ALLOWED_TOOLS" + + +class TestParseClaudeMessage: + """Test parse_claude_message correctly handles multi-turn conversations.""" + + def test_result_message_priority(self): + """Test that ResultMessage.result is prioritized over AssistantMessage.""" + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd="/tmp") + + # Simulate multi-turn conversation messages + messages = [ + # First assistant message (initial response) + { + "content": [type("TextBlock", (), {"text": "I'll list the files."})()], + }, + # Tool use message (not text) + { + "content": [type("ToolUseBlock", (), {"name": "Bash", "input": {}})()], + }, + # Final result message with full answer + { + "subtype": "success", + "result": "The files are:\n1. file1.txt\n2. file2.txt\n3. file3.txt", + }, + ] + + result = cli.parse_claude_message(messages) + + # Should return the ResultMessage.result, not the first AssistantMessage + assert result == "The files are:\n1. file1.txt\n2. file2.txt\n3. file3.txt" + + def test_fallback_to_last_assistant_message(self): + """Test fallback to last AssistantMessage when no ResultMessage.""" + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd="/tmp") + + # Simulate messages without ResultMessage + messages = [ + { + "content": [type("TextBlock", (), {"text": "First response"})()], + }, + { + "content": [type("TextBlock", (), {"text": "Second response"})()], + }, + ] + + result = cli.parse_claude_message(messages) + + # Should return the LAST text, not the first + assert result == "Second response" + + def test_handles_empty_messages(self): + """Test handling of empty message list.""" + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd="/tmp") + + result = cli.parse_claude_message([]) + assert result is None + + def test_handles_dict_content_blocks(self): + """Test handling of dict-based content blocks (old format).""" + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd="/tmp") + + messages = [{"content": [{"type": "text", "text": "Hello world"}]}] + + result = cli.parse_claude_message(messages) + assert result == "Hello world" + + +class TestClaudeCliPermissionMode: + """Test that ClaudeCodeCLI passes permission_mode correctly.""" + + def test_run_completion_accepts_permission_mode(self): + """Test that run_completion method accepts permission_mode parameter.""" + from src.claude_cli import ClaudeCodeCLI + import inspect + + # Check that permission_mode is in the method signature + sig = inspect.signature(ClaudeCodeCLI.run_completion) + param_names = list(sig.parameters.keys()) + + assert ( + "permission_mode" in param_names + ), "run_completion should accept permission_mode parameter" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_tool_manager_unit.py b/tests/test_tool_manager_unit.py new file mode 100644 index 0000000..78fea02 --- /dev/null +++ b/tests/test_tool_manager_unit.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/tool_manager.py + +Tests the ToolMetadata, ToolConfiguration, and ToolManager classes. +These are pure unit tests that don't require a running server. +""" + +import pytest +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock +import threading +import time + +from src.tool_manager import ( + ToolMetadata, + ToolConfiguration, + ToolManager, + TOOL_METADATA, + tool_manager, +) +from src.constants import CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS, DEFAULT_DISALLOWED_TOOLS + + +class TestToolMetadata: + """Test the ToolMetadata dataclass.""" + + def test_creation_with_required_fields(self): + """ToolMetadata can be created with just required fields.""" + metadata = ToolMetadata( + name="TestTool", + description="A test tool", + category="test", + ) + assert metadata.name == "TestTool" + assert metadata.description == "A test tool" + assert metadata.category == "test" + assert metadata.parameters == {} + assert metadata.examples == [] + assert metadata.is_safe is True + assert metadata.requires_network is False + + def test_creation_with_all_fields(self): + """ToolMetadata can be created with all fields.""" + metadata = ToolMetadata( + name="FullTool", + description="Full description", + category="system", + parameters={"param1": "value1", "param2": "value2"}, + examples=["Example 1", "Example 2"], + is_safe=False, + requires_network=True, + ) + assert metadata.name == "FullTool" + assert metadata.parameters == {"param1": "value1", "param2": "value2"} + assert len(metadata.examples) == 2 + assert metadata.is_safe is False + assert metadata.requires_network is True + + def test_tool_metadata_has_bash_tool(self): + """TOOL_METADATA contains Bash tool.""" + assert "Bash" in TOOL_METADATA + bash = TOOL_METADATA["Bash"] + assert bash.name == "Bash" + assert bash.category == "system" + assert "command" in bash.parameters + + def test_tool_metadata_has_read_tool(self): + """TOOL_METADATA contains Read tool.""" + assert "Read" in TOOL_METADATA + read = TOOL_METADATA["Read"] + assert read.name == "Read" + assert read.category == "file" + assert read.is_safe is True + + def test_tool_metadata_has_webfetch_tool(self): + """TOOL_METADATA contains WebFetch tool with network requirement.""" + assert "WebFetch" in TOOL_METADATA + webfetch = TOOL_METADATA["WebFetch"] + assert webfetch.requires_network is True + + def test_tool_metadata_task_is_unsafe(self): + """Task tool is marked as unsafe (can spawn sub-agents).""" + assert "Task" in TOOL_METADATA + task = TOOL_METADATA["Task"] + assert task.is_safe is False + assert task.category == "agent" + + +class TestToolConfiguration: + """Test the ToolConfiguration dataclass.""" + + def test_creation_with_defaults(self): + """ToolConfiguration creates with default values.""" + config = ToolConfiguration() + assert config.allowed_tools is None + assert config.disallowed_tools is None + assert isinstance(config.created_at, datetime) + assert isinstance(config.updated_at, datetime) + + def test_creation_with_allowed_tools(self): + """ToolConfiguration can be created with allowed tools.""" + config = ToolConfiguration(allowed_tools=["Bash", "Read", "Write"]) + assert config.allowed_tools == ["Bash", "Read", "Write"] + assert config.disallowed_tools is None + + def test_creation_with_disallowed_tools(self): + """ToolConfiguration can be created with disallowed tools.""" + config = ToolConfiguration(disallowed_tools=["Task", "WebSearch"]) + assert config.allowed_tools is None + assert config.disallowed_tools == ["Task", "WebSearch"] + + def test_creation_with_both_lists(self): + """ToolConfiguration can have both allowed and disallowed.""" + config = ToolConfiguration( + allowed_tools=["Bash", "Read", "Write", "Task"], + disallowed_tools=["Task"], + ) + assert "Task" in config.allowed_tools + assert "Task" in config.disallowed_tools + + def test_get_effective_tools_with_allowed_only(self): + """get_effective_tools uses allowed_tools when set.""" + config = ToolConfiguration(allowed_tools=["Bash", "Read"]) + effective = config.get_effective_tools() + assert effective == {"Bash", "Read"} + + def test_get_effective_tools_with_disallowed_only(self): + """get_effective_tools removes disallowed from all tools.""" + config = ToolConfiguration(disallowed_tools=["Task"]) + effective = config.get_effective_tools() + assert "Task" not in effective + # Should have most other tools + assert "Bash" in effective + assert "Read" in effective + + def test_get_effective_tools_with_both(self): + """get_effective_tools applies both allowed and disallowed.""" + config = ToolConfiguration( + allowed_tools=["Bash", "Read", "Write", "Task"], + disallowed_tools=["Task", "Write"], + ) + effective = config.get_effective_tools() + assert effective == {"Bash", "Read"} + + def test_get_effective_tools_defaults_to_all_claude_tools(self): + """When nothing set, uses all CLAUDE_TOOLS.""" + config = ToolConfiguration() + effective = config.get_effective_tools() + assert effective == set(CLAUDE_TOOLS) + + def test_update_sets_allowed_tools(self): + """update() can set allowed_tools.""" + config = ToolConfiguration() + original_updated = config.updated_at + + time.sleep(0.001) # Ensure time difference + config.update(allowed_tools=["Bash"]) + + assert config.allowed_tools == ["Bash"] + assert config.updated_at > original_updated + + def test_update_sets_disallowed_tools(self): + """update() can set disallowed_tools.""" + config = ToolConfiguration() + config.update(disallowed_tools=["Task"]) + + assert config.disallowed_tools == ["Task"] + + def test_update_with_none_preserves_existing(self): + """update() with None doesn't clear existing values.""" + config = ToolConfiguration(allowed_tools=["Bash"]) + config.update(disallowed_tools=["Task"]) + + assert config.allowed_tools == ["Bash"] + assert config.disallowed_tools == ["Task"] + + +class TestToolManager: + """Test the ToolManager class.""" + + @pytest.fixture + def manager(self): + """Create a fresh ToolManager for each test.""" + return ToolManager() + + def test_initialization(self, manager): + """ToolManager initializes with global config and empty session configs.""" + assert manager.global_config is not None + assert manager.session_configs == {} + assert manager.global_config.allowed_tools == list(DEFAULT_ALLOWED_TOOLS) + assert manager.global_config.disallowed_tools == list(DEFAULT_DISALLOWED_TOOLS) + + def test_get_tool_metadata_existing(self, manager): + """get_tool_metadata returns metadata for existing tool.""" + metadata = manager.get_tool_metadata("Bash") + assert metadata is not None + assert metadata.name == "Bash" + assert metadata.category == "system" + + def test_get_tool_metadata_nonexistent(self, manager): + """get_tool_metadata returns None for nonexistent tool.""" + metadata = manager.get_tool_metadata("NonExistentTool") + assert metadata is None + + def test_list_all_tools(self, manager): + """list_all_tools returns all tool metadata.""" + tools = manager.list_all_tools() + assert len(tools) == len(TOOL_METADATA) + assert all(isinstance(t, ToolMetadata) for t in tools) + + def test_get_global_config(self, manager): + """get_global_config returns the global configuration.""" + config = manager.get_global_config() + assert config is manager.global_config + + def test_update_global_config(self, manager): + """update_global_config updates the global configuration.""" + result = manager.update_global_config( + allowed_tools=["Bash", "Read"], + disallowed_tools=["Task"], + ) + assert result.allowed_tools == ["Bash", "Read"] + assert result.disallowed_tools == ["Task"] + + def test_get_session_config_nonexistent(self, manager): + """get_session_config returns None for nonexistent session.""" + config = manager.get_session_config("nonexistent-session") + assert config is None + + def test_set_session_config_creates_new(self, manager): + """set_session_config creates new config for session.""" + result = manager.set_session_config( + session_id="session-123", + allowed_tools=["Bash"], + ) + assert result.allowed_tools == ["Bash"] + assert "session-123" in manager.session_configs + + def test_set_session_config_updates_existing(self, manager): + """set_session_config updates existing session config.""" + manager.set_session_config("session-123", allowed_tools=["Bash"]) + manager.set_session_config("session-123", disallowed_tools=["Task"]) + + config = manager.get_session_config("session-123") + assert config.allowed_tools == ["Bash"] + assert config.disallowed_tools == ["Task"] + + def test_delete_session_config_existing(self, manager): + """delete_session_config removes existing session config.""" + manager.set_session_config("session-123", allowed_tools=["Bash"]) + assert "session-123" in manager.session_configs + + result = manager.delete_session_config("session-123") + assert result is True + assert "session-123" not in manager.session_configs + + def test_delete_session_config_nonexistent(self, manager): + """delete_session_config returns False for nonexistent session.""" + result = manager.delete_session_config("nonexistent") + assert result is False + + def test_get_effective_config_no_session(self, manager): + """get_effective_config returns global config when no session.""" + config = manager.get_effective_config() + assert config is manager.global_config + + def test_get_effective_config_with_session(self, manager): + """get_effective_config returns session config when exists.""" + manager.set_session_config("session-123", allowed_tools=["Bash"]) + config = manager.get_effective_config("session-123") + + assert config is not manager.global_config + assert config.allowed_tools == ["Bash"] + + def test_get_effective_config_missing_session_uses_global(self, manager): + """get_effective_config uses global when session doesn't exist.""" + config = manager.get_effective_config("nonexistent") + assert config is manager.global_config + + def test_get_effective_tools_global(self, manager): + """get_effective_tools returns sorted list from global config.""" + manager.update_global_config(allowed_tools=["Write", "Bash", "Read"]) + tools = manager.get_effective_tools() + + assert tools == ["Bash", "Read", "Write"] # Sorted + + def test_get_effective_tools_session(self, manager): + """get_effective_tools returns tools from session config.""" + manager.set_session_config("session-123", allowed_tools=["Grep", "Glob"]) + tools = manager.get_effective_tools("session-123") + + assert tools == ["Glob", "Grep"] # Sorted + + def test_validate_tools_all_valid(self, manager): + """validate_tools returns True for valid tools.""" + result = manager.validate_tools(["Bash", "Read", "Write"]) + assert result == {"Bash": True, "Read": True, "Write": True} + + def test_validate_tools_some_invalid(self, manager): + """validate_tools returns False for invalid tools.""" + result = manager.validate_tools(["Bash", "FakeTool", "Read"]) + assert result == {"Bash": True, "FakeTool": False, "Read": True} + + def test_validate_tools_empty_list(self, manager): + """validate_tools handles empty list.""" + result = manager.validate_tools([]) + assert result == {} + + def test_get_stats(self, manager): + """get_stats returns statistics about tools.""" + # Add some session configs + manager.set_session_config("session-1", allowed_tools=["Bash"]) + manager.set_session_config("session-2", allowed_tools=["Read"]) + + stats = manager.get_stats() + + assert stats["total_tools"] == len(CLAUDE_TOOLS) + assert stats["session_configs"] == 2 + assert "tool_categories" in stats + assert "file" in stats["tool_categories"] + assert "system" in stats["tool_categories"] + + def test_global_allowed_count_in_stats(self, manager): + """get_stats shows correct global allowed count.""" + manager.update_global_config(allowed_tools=["Bash", "Read", "Write"]) + stats = manager.get_stats() + assert stats["global_allowed"] == 3 + + def test_global_disallowed_count_in_stats(self, manager): + """get_stats shows correct global disallowed count.""" + manager.update_global_config(disallowed_tools=["Task", "WebSearch"]) + stats = manager.get_stats() + assert stats["global_disallowed"] == 2 + + +class TestToolManagerThreadSafety: + """Test thread safety of ToolManager operations.""" + + @pytest.fixture + def manager(self): + """Create a fresh ToolManager for each test.""" + return ToolManager() + + def test_concurrent_session_creation(self, manager): + """Multiple threads can create session configs concurrently.""" + results = [] + errors = [] + + def create_session(session_id): + try: + manager.set_session_config(session_id, allowed_tools=["Bash"]) + results.append(session_id) + except Exception as e: + errors.append(str(e)) + + threads = [] + for i in range(20): + t = threading.Thread(target=create_session, args=(f"session-{i}",)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + assert len(errors) == 0 + assert len(results) == 20 + assert len(manager.session_configs) == 20 + + def test_concurrent_config_updates(self, manager): + """Multiple threads can update global config concurrently.""" + errors = [] + + def update_config(tool_name): + try: + manager.update_global_config(allowed_tools=[tool_name]) + except Exception as e: + errors.append(str(e)) + + threads = [] + tools = ["Bash", "Read", "Write", "Edit", "Glob"] + for tool in tools: + t = threading.Thread(target=update_config, args=(tool,)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + assert len(errors) == 0 + # One of the tools should be set (last one to update wins) + assert manager.global_config.allowed_tools is not None + + +class TestToolMetadataCategories: + """Test tool categories in TOOL_METADATA.""" + + def test_file_tools_category(self): + """File tools are correctly categorized.""" + file_tools = ["Glob", "Grep", "Read", "Edit", "Write", "NotebookEdit"] + for tool_name in file_tools: + assert TOOL_METADATA[tool_name].category == "file" + + def test_system_tools_category(self): + """System tools are correctly categorized.""" + system_tools = ["Bash", "BashOutput", "KillShell"] + for tool_name in system_tools: + assert TOOL_METADATA[tool_name].category == "system" + + def test_web_tools_category(self): + """Web tools are correctly categorized.""" + web_tools = ["WebFetch", "WebSearch"] + for tool_name in web_tools: + assert TOOL_METADATA[tool_name].category == "web" + assert TOOL_METADATA[tool_name].requires_network is True + + def test_productivity_tools_category(self): + """Productivity tools are correctly categorized.""" + productivity_tools = ["TodoWrite", "Skill", "SlashCommand"] + for tool_name in productivity_tools: + assert TOOL_METADATA[tool_name].category == "productivity" + + def test_agent_tools_category(self): + """Agent tools are correctly categorized.""" + assert TOOL_METADATA["Task"].category == "agent" + + +class TestGlobalToolManagerInstance: + """Test the global tool_manager instance.""" + + def test_global_instance_exists(self): + """Global tool_manager instance is available.""" + assert tool_manager is not None + assert isinstance(tool_manager, ToolManager) + + def test_global_instance_has_default_config(self): + """Global instance has default configuration.""" + assert tool_manager.global_config is not None diff --git a/tests/test_working_directory.py b/tests/test_working_directory.py index a275036..d552cba 100644 --- a/tests/test_working_directory.py +++ b/tests/test_working_directory.py @@ -15,33 +15,36 @@ from src.claude_cli import ClaudeCodeCLI + def test_default_temp_directory(): """Test that default working directory is a temp directory.""" print("Testing default temp directory creation...") - + # Ensure CLAUDE_CWD is not set original_cwd = os.environ.pop("CLAUDE_CWD", None) - + try: # Create CLI instance without cwd parameter cli = ClaudeCodeCLI() - + # Check that a temp directory was created assert cli.temp_dir is not None, "Temp directory should be created" - assert cli.temp_dir.startswith(tempfile.gettempdir()), f"Temp dir should be in system temp: {cli.temp_dir}" + assert cli.temp_dir.startswith( + tempfile.gettempdir() + ), f"Temp dir should be in system temp: {cli.temp_dir}" assert "claude_code_workspace_" in cli.temp_dir, "Temp dir should have correct prefix" assert os.path.exists(cli.cwd), f"Working directory should exist: {cli.cwd}" assert str(cli.cwd) == cli.temp_dir, "Working directory should be the temp directory" - + print(f" โœ“ Created temp directory: {cli.temp_dir}") - + # Clean up manually for testing if cli.temp_dir and os.path.exists(cli.temp_dir): shutil.rmtree(cli.temp_dir) print(f" โœ“ Cleaned up temp directory") - + return True - + except AssertionError as e: print(f" โœ— {e}") return False @@ -57,26 +60,26 @@ def test_default_temp_directory(): def test_env_var_directory(): """Test that CLAUDE_CWD environment variable is respected.""" print("\nTesting CLAUDE_CWD environment variable...") - + # Create a test directory test_dir = tempfile.mkdtemp(prefix="test_claude_cwd_") original_cwd = os.environ.get("CLAUDE_CWD") - + try: # Set CLAUDE_CWD environment variable os.environ["CLAUDE_CWD"] = test_dir - + # Create CLI instance - it reads from env var directly cli = ClaudeCodeCLI(cwd=os.environ.get("CLAUDE_CWD")) - + # Check that the specified directory is used assert cli.temp_dir is None, "No temp directory should be created when CLAUDE_CWD exists" assert str(cli.cwd) == test_dir, f"Working directory should be {test_dir}, got {cli.cwd}" - + print(f" โœ“ Using CLAUDE_CWD: {test_dir}") - + return True - + except AssertionError as e: print(f" โœ— {e}") return False @@ -96,22 +99,22 @@ def test_env_var_directory(): def test_explicit_cwd_parameter(): """Test that explicit cwd parameter takes precedence.""" print("\nTesting explicit cwd parameter...") - + # Create a test directory test_dir = tempfile.mkdtemp(prefix="test_explicit_cwd_") - + try: # Create CLI instance with explicit cwd cli = ClaudeCodeCLI(cwd=test_dir) - + # Check that the specified directory is used assert cli.temp_dir is None, "No temp directory should be created when cwd is provided" assert str(cli.cwd) == test_dir, f"Working directory should be {test_dir}, got {cli.cwd}" - + print(f" โœ“ Using explicit cwd: {test_dir}") - + return True - + except AssertionError as e: print(f" โœ— {e}") return False @@ -127,9 +130,9 @@ def test_explicit_cwd_parameter(): def test_nonexistent_directory_error(): """Test that specifying a non-existent directory raises an error.""" print("\nTesting non-existent directory handling...") - + non_existent_dir = "/this/directory/does/not/exist/12345" - + try: # Try to create CLI instance with non-existent directory cli = ClaudeCodeCLI(cwd=non_existent_dir) @@ -150,31 +153,31 @@ def test_nonexistent_directory_error(): def test_cross_platform_compatibility(): """Test that temp directory creation works across platforms.""" print("\nTesting cross-platform compatibility...") - + try: # Get platform-specific temp directory system_temp = tempfile.gettempdir() print(f" System temp directory: {system_temp}") - + # Create CLI instance cli = ClaudeCodeCLI() - + # Verify temp directory is in the correct location assert cli.temp_dir.startswith(system_temp), f"Temp dir should be in {system_temp}" - + # Verify path handling works correctly assert isinstance(cli.cwd, Path), "Working directory should be a Path object" assert cli.cwd.exists(), "Working directory should exist" - + print(f" โœ“ Platform: {os.name}") print(f" โœ“ Temp directory created correctly") - + # Clean up if cli.temp_dir and os.path.exists(cli.temp_dir): shutil.rmtree(cli.temp_dir) - + return True - + except Exception as e: print(f" โœ— Error: {e}") return False @@ -185,15 +188,15 @@ def main(): print("=" * 60) print("Testing Working Directory Configuration") print("=" * 60) - + tests = [ test_default_temp_directory, test_env_var_directory, test_explicit_cwd_parameter, test_nonexistent_directory_error, - test_cross_platform_compatibility + test_cross_platform_compatibility, ] - + results = [] for test in tests: try: @@ -201,11 +204,11 @@ def main(): except Exception as e: print(f"\nโœ— Test {test.__name__} failed with exception: {e}") results.append(False) - + print("\n" + "=" * 60) passed = sum(results) total = len(results) - + if passed == total: print(f"โœ… All {total} tests passed!") return 0 @@ -215,4 +218,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main())