From 7e6629f1d54876798c596bc9c7ccd0508cc8ed04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 15:12:08 +0300 Subject: [PATCH 01/22] refactor: restructure monorepo to organize packages by language - Move TypeScript packages to packages/ts/ (sdk, mcp, ai-sdk) - Update pnpm workspace to use packages/ts/* - Update GitHub Actions workflows for new paths - Update package.json repository directory fields - Add Python-specific entries to .gitignore This prepares the monorepo structure for adding Python SDK at packages/py/ --- .github/workflows/ecr-deploy.yml | 4 ++-- .github/workflows/publish-mcp.yml | 2 +- .github/workflows/release.yml | 4 ++-- .gitignore | 17 +++++++++++++++++ package.json | 11 +++++++++-- packages/{ => ts}/mcp/.prettierignore | 0 packages/{ => ts}/mcp/CHANGELOG.md | 0 packages/{ => ts}/mcp/Dockerfile | 0 packages/{ => ts}/mcp/LICENSE | 0 packages/{ => ts}/mcp/README.md | 0 packages/{ => ts}/mcp/eslint.config.js | 0 packages/{ => ts}/mcp/mcpb/.mcpbignore | 0 packages/{ => ts}/mcp/mcpb/context7.mcpb | Bin packages/{ => ts}/mcp/mcpb/manifest.json | 0 packages/{ => ts}/mcp/package.json | 2 +- packages/{ => ts}/mcp/prettier.config.mjs | 0 packages/{ => ts}/mcp/schema/context7.json | 0 packages/{ => ts}/mcp/smithery.yaml | 0 packages/{ => ts}/mcp/src/index.ts | 0 packages/{ => ts}/mcp/src/lib/api.ts | 0 packages/{ => ts}/mcp/src/lib/encryption.ts | 0 packages/{ => ts}/mcp/src/lib/types.ts | 0 packages/{ => ts}/mcp/src/lib/utils.ts | 0 packages/{ => ts}/mcp/tsconfig.json | 2 +- packages/{ => ts}/sdk/CHANGELOG.md | 0 packages/{ => ts}/sdk/LICENSE | 0 packages/{ => ts}/sdk/README.md | 0 packages/{ => ts}/sdk/eslint.config.js | 0 packages/{ => ts}/sdk/package.json | 2 +- packages/{ => ts}/sdk/prettier.config.mjs | 0 packages/{ => ts}/sdk/src/client.test.ts | 0 packages/{ => ts}/sdk/src/client.ts | 0 packages/{ => ts}/sdk/src/commands/command.ts | 0 .../sdk/src/commands/get-docs/index.test.ts | 0 .../sdk/src/commands/get-docs/index.ts | 0 packages/{ => ts}/sdk/src/commands/index.ts | 0 .../src/commands/search-library/index.test.ts | 0 .../sdk/src/commands/search-library/index.ts | 0 packages/{ => ts}/sdk/src/commands/types.ts | 0 packages/{ => ts}/sdk/src/error/index.ts | 0 packages/{ => ts}/sdk/src/http/index.ts | 0 packages/{ => ts}/sdk/src/utils/test-utils.ts | 0 packages/{ => ts}/sdk/tsconfig.json | 0 packages/{ => ts}/sdk/tsup.config.ts | 0 packages/{ => ts}/sdk/vitest.config.ts | 0 pnpm-lock.yaml | 4 ++-- pnpm-workspace.yaml | 2 +- 47 files changed, 37 insertions(+), 13 deletions(-) rename packages/{ => ts}/mcp/.prettierignore (100%) rename packages/{ => ts}/mcp/CHANGELOG.md (100%) rename packages/{ => ts}/mcp/Dockerfile (100%) rename packages/{ => ts}/mcp/LICENSE (100%) rename packages/{ => ts}/mcp/README.md (100%) rename packages/{ => ts}/mcp/eslint.config.js (100%) rename packages/{ => ts}/mcp/mcpb/.mcpbignore (100%) rename packages/{ => ts}/mcp/mcpb/context7.mcpb (100%) rename packages/{ => ts}/mcp/mcpb/manifest.json (100%) rename packages/{ => ts}/mcp/package.json (98%) rename packages/{ => ts}/mcp/prettier.config.mjs (100%) rename packages/{ => ts}/mcp/schema/context7.json (100%) rename packages/{ => ts}/mcp/smithery.yaml (100%) rename packages/{ => ts}/mcp/src/index.ts (100%) rename packages/{ => ts}/mcp/src/lib/api.ts (100%) rename packages/{ => ts}/mcp/src/lib/encryption.ts (100%) rename packages/{ => ts}/mcp/src/lib/types.ts (100%) rename packages/{ => ts}/mcp/src/lib/utils.ts (100%) rename packages/{ => ts}/mcp/tsconfig.json (83%) rename packages/{ => ts}/sdk/CHANGELOG.md (100%) rename packages/{ => ts}/sdk/LICENSE (100%) rename packages/{ => ts}/sdk/README.md (100%) rename packages/{ => ts}/sdk/eslint.config.js (100%) rename packages/{ => ts}/sdk/package.json (97%) rename packages/{ => ts}/sdk/prettier.config.mjs (100%) rename packages/{ => ts}/sdk/src/client.test.ts (100%) rename packages/{ => ts}/sdk/src/client.ts (100%) rename packages/{ => ts}/sdk/src/commands/command.ts (100%) rename packages/{ => ts}/sdk/src/commands/get-docs/index.test.ts (100%) rename packages/{ => ts}/sdk/src/commands/get-docs/index.ts (100%) rename packages/{ => ts}/sdk/src/commands/index.ts (100%) rename packages/{ => ts}/sdk/src/commands/search-library/index.test.ts (100%) rename packages/{ => ts}/sdk/src/commands/search-library/index.ts (100%) rename packages/{ => ts}/sdk/src/commands/types.ts (100%) rename packages/{ => ts}/sdk/src/error/index.ts (100%) rename packages/{ => ts}/sdk/src/http/index.ts (100%) rename packages/{ => ts}/sdk/src/utils/test-utils.ts (100%) rename packages/{ => ts}/sdk/tsconfig.json (100%) rename packages/{ => ts}/sdk/tsup.config.ts (100%) rename packages/{ => ts}/sdk/vitest.config.ts (100%) diff --git a/.github/workflows/ecr-deploy.yml b/.github/workflows/ecr-deploy.yml index f998b2de7..be24f26ca 100644 --- a/.github/workflows/ecr-deploy.yml +++ b/.github/workflows/ecr-deploy.yml @@ -34,8 +34,8 @@ jobs: id: build-push uses: docker/build-push-action@v6 with: - context: packages/mcp - file: packages/mcp/Dockerfile + context: packages/ts/mcp + file: packages/ts/mcp/Dockerfile platforms: linux/amd64 push: true tags: | diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 34cd0a946..f88905297 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -31,7 +31,7 @@ jobs: # Remove 'v' prefix if it exists VERSION="${VERSION#v}" else - VERSION=$(node -p "require('./packages/mcp/package.json').version") + VERSION=$(node -p "require('./packages/ts/mcp/package.json').version") fi echo "VERSION=$VERSION" >> $GITHUB_ENV echo "Publishing version: $VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 760e15f80..1d6edc15c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,10 +65,10 @@ jobs: if: steps.check-mcp.outputs.mcp_published == 'true' run: | VERSION="${{ steps.check-mcp.outputs.mcp_version }}" - sed -i "s/version: \"[0-9]*\.[0-9]*\.[0-9]*\"/version: \"$VERSION\"/" packages/mcp/src/index.ts + sed -i "s/version: \"[0-9]*\.[0-9]*\.[0-9]*\"/version: \"$VERSION\"/" packages/ts/mcp/src/index.ts git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add packages/mcp/src/index.ts + git add packages/ts/mcp/src/index.ts git commit -m "chore: update MCP version in source to $VERSION" || true git push || true diff --git a/.gitignore b/.gitignore index 8ed737943..bf6f3bed8 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,20 @@ prompt.txt reports reports-old src/test/questions* + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +*.egg-info/ +dist/ +build/ +uv.lock diff --git a/package.json b/package.json index a777d6fda..62a9439c8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "description": "Context7 monorepo - Documentation tools and SDKs", "workspaces": [ - "packages/*" + "packages/ts/*" ], "scripts": { "build": "pnpm -r run build", @@ -18,7 +18,14 @@ "format": "pnpm -r run format", "format:check": "pnpm -r run format:check", "release": "pnpm build && changeset publish", - "release:snapshot": "changeset version --snapshot canary && pnpm build && changeset publish --tag canary --no-git-tag" + "release:snapshot": "changeset version --snapshot canary && pnpm build && changeset publish --tag canary --no-git-tag", + "py:install": "cd packages/py && uv sync --all-packages", + "py:test": "cd packages/py && uv run pytest sdk/tests -v", + "py:lint": "cd packages/py && uv run ruff check .", + "py:format": "cd packages/py && uv run ruff format .", + "py:typecheck": "cd packages/py && uv run mypy sdk/src/context7", + "py:build": "cd packages/py/sdk && uv build", + "py:clean": "rm -rf packages/py/.venv packages/py/uv.lock packages/py/sdk/dist" }, "repository": { "type": "git", diff --git a/packages/mcp/.prettierignore b/packages/ts/mcp/.prettierignore similarity index 100% rename from packages/mcp/.prettierignore rename to packages/ts/mcp/.prettierignore diff --git a/packages/mcp/CHANGELOG.md b/packages/ts/mcp/CHANGELOG.md similarity index 100% rename from packages/mcp/CHANGELOG.md rename to packages/ts/mcp/CHANGELOG.md diff --git a/packages/mcp/Dockerfile b/packages/ts/mcp/Dockerfile similarity index 100% rename from packages/mcp/Dockerfile rename to packages/ts/mcp/Dockerfile diff --git a/packages/mcp/LICENSE b/packages/ts/mcp/LICENSE similarity index 100% rename from packages/mcp/LICENSE rename to packages/ts/mcp/LICENSE diff --git a/packages/mcp/README.md b/packages/ts/mcp/README.md similarity index 100% rename from packages/mcp/README.md rename to packages/ts/mcp/README.md diff --git a/packages/mcp/eslint.config.js b/packages/ts/mcp/eslint.config.js similarity index 100% rename from packages/mcp/eslint.config.js rename to packages/ts/mcp/eslint.config.js diff --git a/packages/mcp/mcpb/.mcpbignore b/packages/ts/mcp/mcpb/.mcpbignore similarity index 100% rename from packages/mcp/mcpb/.mcpbignore rename to packages/ts/mcp/mcpb/.mcpbignore diff --git a/packages/mcp/mcpb/context7.mcpb b/packages/ts/mcp/mcpb/context7.mcpb similarity index 100% rename from packages/mcp/mcpb/context7.mcpb rename to packages/ts/mcp/mcpb/context7.mcpb diff --git a/packages/mcp/mcpb/manifest.json b/packages/ts/mcp/mcpb/manifest.json similarity index 100% rename from packages/mcp/mcpb/manifest.json rename to packages/ts/mcp/mcpb/manifest.json diff --git a/packages/mcp/package.json b/packages/ts/mcp/package.json similarity index 98% rename from packages/mcp/package.json rename to packages/ts/mcp/package.json index 8425abb85..16da37d01 100644 --- a/packages/mcp/package.json +++ b/packages/ts/mcp/package.json @@ -17,7 +17,7 @@ "repository": { "type": "git", "url": "git+https://github.com/upstash/context7.git", - "directory": "packages/mcp" + "directory": "packages/ts/mcp" }, "keywords": [ "modelcontextprotocol", diff --git a/packages/mcp/prettier.config.mjs b/packages/ts/mcp/prettier.config.mjs similarity index 100% rename from packages/mcp/prettier.config.mjs rename to packages/ts/mcp/prettier.config.mjs diff --git a/packages/mcp/schema/context7.json b/packages/ts/mcp/schema/context7.json similarity index 100% rename from packages/mcp/schema/context7.json rename to packages/ts/mcp/schema/context7.json diff --git a/packages/mcp/smithery.yaml b/packages/ts/mcp/smithery.yaml similarity index 100% rename from packages/mcp/smithery.yaml rename to packages/ts/mcp/smithery.yaml diff --git a/packages/mcp/src/index.ts b/packages/ts/mcp/src/index.ts similarity index 100% rename from packages/mcp/src/index.ts rename to packages/ts/mcp/src/index.ts diff --git a/packages/mcp/src/lib/api.ts b/packages/ts/mcp/src/lib/api.ts similarity index 100% rename from packages/mcp/src/lib/api.ts rename to packages/ts/mcp/src/lib/api.ts diff --git a/packages/mcp/src/lib/encryption.ts b/packages/ts/mcp/src/lib/encryption.ts similarity index 100% rename from packages/mcp/src/lib/encryption.ts rename to packages/ts/mcp/src/lib/encryption.ts diff --git a/packages/mcp/src/lib/types.ts b/packages/ts/mcp/src/lib/types.ts similarity index 100% rename from packages/mcp/src/lib/types.ts rename to packages/ts/mcp/src/lib/types.ts diff --git a/packages/mcp/src/lib/utils.ts b/packages/ts/mcp/src/lib/utils.ts similarity index 100% rename from packages/mcp/src/lib/utils.ts rename to packages/ts/mcp/src/lib/utils.ts diff --git a/packages/mcp/tsconfig.json b/packages/ts/mcp/tsconfig.json similarity index 83% rename from packages/mcp/tsconfig.json rename to packages/ts/mcp/tsconfig.json index 624d4ca37..c3aaa5f42 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/ts/mcp/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../../tsconfig.json", "compilerOptions": { "module": "Node16", "moduleResolution": "Node16", diff --git a/packages/sdk/CHANGELOG.md b/packages/ts/sdk/CHANGELOG.md similarity index 100% rename from packages/sdk/CHANGELOG.md rename to packages/ts/sdk/CHANGELOG.md diff --git a/packages/sdk/LICENSE b/packages/ts/sdk/LICENSE similarity index 100% rename from packages/sdk/LICENSE rename to packages/ts/sdk/LICENSE diff --git a/packages/sdk/README.md b/packages/ts/sdk/README.md similarity index 100% rename from packages/sdk/README.md rename to packages/ts/sdk/README.md diff --git a/packages/sdk/eslint.config.js b/packages/ts/sdk/eslint.config.js similarity index 100% rename from packages/sdk/eslint.config.js rename to packages/ts/sdk/eslint.config.js diff --git a/packages/sdk/package.json b/packages/ts/sdk/package.json similarity index 97% rename from packages/sdk/package.json rename to packages/ts/sdk/package.json index 9bc2006c1..f60b5f6c1 100644 --- a/packages/sdk/package.json +++ b/packages/ts/sdk/package.json @@ -14,7 +14,7 @@ "repository": { "type": "git", "url": "git+https://github.com/upstash/context7.git", - "directory": "packages/sdk" + "directory": "packages/ts/sdk" }, "keywords": [ "context7", diff --git a/packages/sdk/prettier.config.mjs b/packages/ts/sdk/prettier.config.mjs similarity index 100% rename from packages/sdk/prettier.config.mjs rename to packages/ts/sdk/prettier.config.mjs diff --git a/packages/sdk/src/client.test.ts b/packages/ts/sdk/src/client.test.ts similarity index 100% rename from packages/sdk/src/client.test.ts rename to packages/ts/sdk/src/client.test.ts diff --git a/packages/sdk/src/client.ts b/packages/ts/sdk/src/client.ts similarity index 100% rename from packages/sdk/src/client.ts rename to packages/ts/sdk/src/client.ts diff --git a/packages/sdk/src/commands/command.ts b/packages/ts/sdk/src/commands/command.ts similarity index 100% rename from packages/sdk/src/commands/command.ts rename to packages/ts/sdk/src/commands/command.ts diff --git a/packages/sdk/src/commands/get-docs/index.test.ts b/packages/ts/sdk/src/commands/get-docs/index.test.ts similarity index 100% rename from packages/sdk/src/commands/get-docs/index.test.ts rename to packages/ts/sdk/src/commands/get-docs/index.test.ts diff --git a/packages/sdk/src/commands/get-docs/index.ts b/packages/ts/sdk/src/commands/get-docs/index.ts similarity index 100% rename from packages/sdk/src/commands/get-docs/index.ts rename to packages/ts/sdk/src/commands/get-docs/index.ts diff --git a/packages/sdk/src/commands/index.ts b/packages/ts/sdk/src/commands/index.ts similarity index 100% rename from packages/sdk/src/commands/index.ts rename to packages/ts/sdk/src/commands/index.ts diff --git a/packages/sdk/src/commands/search-library/index.test.ts b/packages/ts/sdk/src/commands/search-library/index.test.ts similarity index 100% rename from packages/sdk/src/commands/search-library/index.test.ts rename to packages/ts/sdk/src/commands/search-library/index.test.ts diff --git a/packages/sdk/src/commands/search-library/index.ts b/packages/ts/sdk/src/commands/search-library/index.ts similarity index 100% rename from packages/sdk/src/commands/search-library/index.ts rename to packages/ts/sdk/src/commands/search-library/index.ts diff --git a/packages/sdk/src/commands/types.ts b/packages/ts/sdk/src/commands/types.ts similarity index 100% rename from packages/sdk/src/commands/types.ts rename to packages/ts/sdk/src/commands/types.ts diff --git a/packages/sdk/src/error/index.ts b/packages/ts/sdk/src/error/index.ts similarity index 100% rename from packages/sdk/src/error/index.ts rename to packages/ts/sdk/src/error/index.ts diff --git a/packages/sdk/src/http/index.ts b/packages/ts/sdk/src/http/index.ts similarity index 100% rename from packages/sdk/src/http/index.ts rename to packages/ts/sdk/src/http/index.ts diff --git a/packages/sdk/src/utils/test-utils.ts b/packages/ts/sdk/src/utils/test-utils.ts similarity index 100% rename from packages/sdk/src/utils/test-utils.ts rename to packages/ts/sdk/src/utils/test-utils.ts diff --git a/packages/sdk/tsconfig.json b/packages/ts/sdk/tsconfig.json similarity index 100% rename from packages/sdk/tsconfig.json rename to packages/ts/sdk/tsconfig.json diff --git a/packages/sdk/tsup.config.ts b/packages/ts/sdk/tsup.config.ts similarity index 100% rename from packages/sdk/tsup.config.ts rename to packages/ts/sdk/tsup.config.ts diff --git a/packages/sdk/vitest.config.ts b/packages/ts/sdk/vitest.config.ts similarity index 100% rename from packages/sdk/vitest.config.ts rename to packages/ts/sdk/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58cde6489..382d67e18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,7 +39,7 @@ importers: specifier: ^8.28.0 version: 8.47.0(eslint@9.39.1)(typescript@5.9.3) - packages/mcp: + packages/ts/mcp: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.17.5 @@ -67,7 +67,7 @@ importers: specifier: ^5.8.2 version: 5.9.3 - packages/sdk: + packages/ts/sdk: devDependencies: '@types/node': specifier: ^22.13.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e928..a37310463 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,2 @@ packages: - - "packages/*" + - "packages/ts/*" From 1fcce6505af0c2550589f11122bf5055d260e3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 15:13:01 +0300 Subject: [PATCH 02/22] feat: add python sdk --- .github/workflows/python-ci.yml | 96 ++++++++ packages/py/.python-version | 1 + packages/py/pyproject.toml | 48 ++++ packages/py/sdk/README.md | 67 ++++++ packages/py/sdk/pyproject.toml | 56 +++++ packages/py/sdk/src/context7/__init__.py | 71 ++++++ packages/py/sdk/src/context7/client.py | 281 +++++++++++++++++++++++ packages/py/sdk/src/context7/errors.py | 25 ++ packages/py/sdk/src/context7/http.py | 165 +++++++++++++ packages/py/sdk/src/context7/models.py | 155 +++++++++++++ packages/py/sdk/src/context7/py.typed | 0 packages/py/sdk/tests/__init__.py | 1 + packages/py/sdk/tests/conftest.py | 16 ++ packages/py/sdk/tests/test_client.py | 139 +++++++++++ 14 files changed, 1121 insertions(+) create mode 100644 .github/workflows/python-ci.yml create mode 100644 packages/py/.python-version create mode 100644 packages/py/pyproject.toml create mode 100644 packages/py/sdk/README.md create mode 100644 packages/py/sdk/pyproject.toml create mode 100644 packages/py/sdk/src/context7/__init__.py create mode 100644 packages/py/sdk/src/context7/client.py create mode 100644 packages/py/sdk/src/context7/errors.py create mode 100644 packages/py/sdk/src/context7/http.py create mode 100644 packages/py/sdk/src/context7/models.py create mode 100644 packages/py/sdk/src/context7/py.typed create mode 100644 packages/py/sdk/tests/__init__.py create mode 100644 packages/py/sdk/tests/conftest.py create mode 100644 packages/py/sdk/tests/test_client.py diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 000000000..132c397d1 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,96 @@ +name: Python CI + +on: + push: + branches: [main, master] + paths: + - "packages/py/**" + - ".github/workflows/python-ci.yml" + pull_request: + branches: [main, master] + paths: + - "packages/py/**" + - ".github/workflows/python-ci.yml" + +defaults: + run: + working-directory: packages/py + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-packages + + - name: Run linting + run: uv run ruff check . + + - name: Run formatting check + run: uv run ruff format --check . + + - name: Run type checking + run: uv run mypy sdk/src/context7 + + - name: Run tests + env: + CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }} + run: uv run pytest sdk/tests -v --cov=context7 --cov-report=xml + + build: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Build package + working-directory: packages/py/sdk + run: uv build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: python-sdk-dist + path: packages/py/sdk/dist/ + + publish: + runs-on: ubuntu-latest + needs: [test, build] + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + environment: pypi + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: python-sdk-dist + path: packages/py/sdk/dist/ + + - name: Publish to PyPI + working-directory: packages/py/sdk + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: uv publish diff --git a/packages/py/.python-version b/packages/py/.python-version new file mode 100644 index 000000000..2c0733315 --- /dev/null +++ b/packages/py/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/packages/py/pyproject.toml b/packages/py/pyproject.toml new file mode 100644 index 000000000..303a7d491 --- /dev/null +++ b/packages/py/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "context7-workspace" +version = "0.0.0" +description = "Context7 Python packages workspace" +requires-python = ">=3.9" +license = "MIT" + +[tool.uv] +package = false + +[tool.uv.workspace] +members = ["sdk"] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "TCH"] +ignore = ["E501"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pytest.ini_options] +testpaths = ["sdk/tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.mypy] +python_version = "3.9" +strict = true +warn_return_any = true +warn_unused_ignores = true + +[tool.pyright] +pythonVersion = "3.9" +typeCheckingMode = "strict" + +[dependency-groups] +dev = [ + "mypy>=1.19.0", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", + "python-dotenv>=1.2.1", + "ruff>=0.14.8", +] diff --git a/packages/py/sdk/README.md b/packages/py/sdk/README.md new file mode 100644 index 000000000..4add5371d --- /dev/null +++ b/packages/py/sdk/README.md @@ -0,0 +1,67 @@ +# Context7 Python SDK + +Python SDK for Context7 - Documentation retrieval for AI agents. + +## Installation + +```bash +pip install context7 +``` + +Or with uv: + +```bash +uv add context7 +``` + +## Quick Start + +```python +import asyncio +from context7 import Context7 + +async def main(): + # Initialize with API key (or set CONTEXT7_API_KEY env var) + async with Context7(api_key="ctx7sk_...") as client: + # Search for libraries + results = await client.search_library("react") + for lib in results.results: + print(f"{lib.id}: {lib.title}") + + # Get documentation + docs = await client.get_docs("/facebook/react") + for snippet in docs.snippets: + print(f"{snippet.code_title}: {snippet.code_description}") + +asyncio.run(main()) +``` + +## API Reference + +### `Context7(api_key=None, base_url=None)` + +Initialize the Context7 client. + +- `api_key`: API key for authentication. Falls back to `CONTEXT7_API_KEY` environment variable. +- `base_url`: Optional custom base URL for the API. + +### `await client.search_library(query: str) -> SearchLibraryResponse` + +Search for libraries by name or description. + +### `await client.get_docs(library_id: str, **options) -> DocsResponse` + +Get documentation for a library. + +**Parameters:** +- `library_id`: Library identifier in format `/owner/repo` (e.g., `/facebook/react`) +- `version`: Optional library version (e.g., `"18.0.0"`) +- `page`: Page number for pagination +- `topic`: Filter docs by topic +- `limit`: Number of results per page +- `mode`: Type of documentation - `"code"` (default) or `"info"` +- `format`: Response format - `"json"` (default) or `"txt"` + +## License + +MIT diff --git a/packages/py/sdk/pyproject.toml b/packages/py/sdk/pyproject.toml new file mode 100644 index 000000000..1aa886153 --- /dev/null +++ b/packages/py/sdk/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "context7" +version = "0.1.0" +description = "Python SDK for Context7 - Documentation retrieval for AI agents" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [ + { name = "Upstash", email = "support@upstash.com" } +] +keywords = ["context7", "sdk", "documentation", "ai", "llm", "mcp", "upstash"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] + +dependencies = [ + "httpx>=0.27.0", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", + "mypy>=1.11.0", + "ruff>=0.6.0", +] + +[project.urls] +Homepage = "https://context7.com" +Documentation = "https://context7.com/docs" +Repository = "https://github.com/upstash/context7" +Issues = "https://github.com/upstash/context7/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/context7"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] diff --git a/packages/py/sdk/src/context7/__init__.py b/packages/py/sdk/src/context7/__init__.py new file mode 100644 index 000000000..9fa290469 --- /dev/null +++ b/packages/py/sdk/src/context7/__init__.py @@ -0,0 +1,71 @@ +""" +Context7 Python SDK - Documentation retrieval for AI agents. + +This SDK provides a simple interface to search for libraries and retrieve +documentation from Context7, optimized for AI agents and LLMs. + +Example: + ```python + import asyncio + from context7 import Context7 + + async def main(): + async with Context7(api_key="ctx7sk_...") as client: + # Search for libraries + results = await client.search_library("react") + + # Get documentation + docs = await client.get_docs("/facebook/react") + + asyncio.run(main()) + ``` +""" + +from context7.client import Context7 +from context7.errors import Context7APIError, Context7Error, Context7ValidationError +from context7.models import ( + APIResponseMetadata, + AuthenticationType, + CodeDocsResponse, + CodeExample, + CodeSnippet, + DocsResponse, + DocsResponseBase, + GetDocsOptions, + InfoDocsResponse, + InfoSnippet, + LibraryState, + Pagination, + SearchLibraryResponse, + SearchResult, + TextDocsResponse, +) + +__all__ = [ + # Client + "Context7", + # Errors + "Context7Error", + "Context7APIError", + "Context7ValidationError", + # Models - Search + "SearchResult", + "SearchLibraryResponse", + "APIResponseMetadata", + # Models - Docs + "CodeSnippet", + "CodeExample", + "InfoSnippet", + "Pagination", + "DocsResponseBase", + "CodeDocsResponse", + "InfoDocsResponse", + "TextDocsResponse", + "DocsResponse", + "GetDocsOptions", + # Enums + "LibraryState", + "AuthenticationType", +] + +__version__ = "0.1.0" diff --git a/packages/py/sdk/src/context7/client.py b/packages/py/sdk/src/context7/client.py new file mode 100644 index 000000000..267d0194c --- /dev/null +++ b/packages/py/sdk/src/context7/client.py @@ -0,0 +1,281 @@ +"""Main Context7 client for interacting with the Context7 API.""" + +from __future__ import annotations + +import os +import warnings +from typing import Literal, overload + +from context7.errors import Context7Error, Context7ValidationError +from context7.http import HttpClient +from context7.models import ( + CodeDocsResponse, + InfoDocsResponse, + Pagination, + SearchLibraryResponse, + TextDocsResponse, +) + +DEFAULT_BASE_URL = "https://context7.com/api" +API_KEY_PREFIX = "ctx7sk" + + +class Context7: + """ + Context7 Python SDK client. + + The Context7 client provides methods to search for libraries and retrieve + documentation optimized for AI agents and LLMs. + + Usage: + ```python + import asyncio + from context7 import Context7 + + async def main(): + # Initialize with API key (or set CONTEXT7_API_KEY env var) + async with Context7(api_key="ctx7sk_...") as client: + # Search for libraries + results = await client.search_library("react") + for lib in results.results: + print(f"{lib.id}: {lib.title}") + + # Get documentation + docs = await client.get_docs("/facebook/react") + for snippet in docs.snippets: + print(f"{snippet.code_title}: {snippet.code_description}") + + asyncio.run(main()) + ``` + """ + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + ) -> None: + """ + Initialize the Context7 client. + + Args: + api_key: API key for authentication. Falls back to CONTEXT7_API_KEY + environment variable if not provided. + base_url: Optional custom base URL for the API. Defaults to + https://context7.com/api. + + Raises: + Context7Error: If no API key is provided or found in environment. + """ + resolved_api_key = api_key or os.environ.get("CONTEXT7_API_KEY") + + if not resolved_api_key: + raise Context7Error( + "API key is required. Pass it in the constructor or set " + "CONTEXT7_API_KEY environment variable." + ) + + if not resolved_api_key.startswith(API_KEY_PREFIX): + warnings.warn( + f"API key should start with '{API_KEY_PREFIX}'", + UserWarning, + stacklevel=2, + ) + + self._http = HttpClient( + base_url=base_url or DEFAULT_BASE_URL, + headers={"Authorization": f"Bearer {resolved_api_key}"}, + ) + + async def __aenter__(self) -> Context7: + """Enter async context manager.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Exit async context manager and close connections.""" + await self.close() + + async def close(self) -> None: + """Close the HTTP client connection.""" + await self._http.close() + + async def search_library(self, query: str) -> SearchLibraryResponse: + """ + Search for libraries by name or description. + + Args: + query: Search query string. + + Returns: + SearchLibraryResponse containing matching libraries and metadata. + + Example: + ```python + results = await client.search_library("react") + for lib in results.results: + print(f"{lib.id}: {lib.title} ({lib.total_tokens} tokens)") + ``` + """ + result, _ = await self._http.request( + method="GET", + path=["v2", "search"], + query={"query": query}, + ) + return SearchLibraryResponse.model_validate(result) + + @overload + async def get_docs( + self, + library_id: str, + *, + format: Literal["txt"], + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + mode: Literal["info", "code"] = "code", + ) -> TextDocsResponse: ... + + @overload + async def get_docs( + self, + library_id: str, + *, + mode: Literal["info"], + format: Literal["json"] = "json", + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + ) -> InfoDocsResponse: ... + + @overload + async def get_docs( + self, + library_id: str, + *, + mode: Literal["code"] = "code", + format: Literal["json"] = "json", + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + ) -> CodeDocsResponse: ... + + @overload + async def get_docs( + self, + library_id: str, + *, + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + mode: Literal["info", "code"] = "code", + format: Literal["json", "txt"] = "json", + ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: ... + + async def get_docs( + self, + library_id: str, + *, + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + mode: Literal["info", "code"] = "code", + format: Literal["json", "txt"] = "json", + ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: + """ + Get documentation for a library. + + Args: + library_id: Library identifier in format "/owner/repo" + (e.g., "/facebook/react", "/vercel/next.js"). + version: Optional library version (e.g., "18.0.0"). + page: Page number for pagination. + topic: Filter docs by topic. + limit: Number of results per page. + mode: Type of documentation to fetch: + - "code": Code snippets with examples (default) + - "info": Text content and explanations + format: Response format: + - "json": Structured JSON data (default) + - "txt": Plain text documentation + + Returns: + Documentation response. The type depends on mode and format: + - format="txt": TextDocsResponse with content string + - mode="code", format="json": CodeDocsResponse with code snippets + - mode="info", format="json": InfoDocsResponse with info snippets + + Raises: + Context7ValidationError: If library_id format is invalid. + + Example: + ```python + # Get code snippets (default) + docs = await client.get_docs("/facebook/react") + for snippet in docs.snippets: + print(snippet.code_title) + + # Get info documentation + info = await client.get_docs("/facebook/react", mode="info") + for snippet in info.snippets: + print(snippet.content) + + # Get plain text + text = await client.get_docs("/facebook/react", format="txt") + print(text.content) + + # With version + docs = await client.get_docs("/facebook/react", version="18.0.0") + ``` + """ + # Validate library_id format + if not library_id.startswith("/") or library_id.count("/") < 2: + raise Context7ValidationError( + f"Invalid library ID: {library_id}. Expected format: /owner/repo" + ) + + # Parse library_id: /owner/repo -> ["owner", "repo"] + parts = library_id.lstrip("/").split("/") + owner, repo = parts[0], "/".join(parts[1:]) + + # Build path + path = ["v2", "docs", mode, owner, repo] + if version: + path.append(version) + + # Build query params + query = { + "type": format, + "page": page, + "limit": limit, + "topic": topic, + } + + result, headers = await self._http.request( + method="GET", + path=path, + query=query, + ) + + # Handle text response + if format == "txt": + pagination = Pagination( + page=headers.page if headers else 1, + limit=headers.limit if headers else 0, + totalPages=headers.total_pages if headers else 1, + hasNext=headers.has_next if headers else False, + hasPrev=headers.has_prev if headers else False, + ) + return TextDocsResponse( + content=result, + pagination=pagination, + totalTokens=headers.total_tokens if headers else 0, + ) + + # Handle JSON response + if mode == "info": + return InfoDocsResponse.model_validate(result) + return CodeDocsResponse.model_validate(result) diff --git a/packages/py/sdk/src/context7/errors.py b/packages/py/sdk/src/context7/errors.py new file mode 100644 index 000000000..4a670678e --- /dev/null +++ b/packages/py/sdk/src/context7/errors.py @@ -0,0 +1,25 @@ +"""Custom exceptions for the Context7 SDK.""" + +from __future__ import annotations + + +class Context7Error(Exception): + """Base exception for Context7 SDK errors.""" + + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + +class Context7APIError(Context7Error): + """Raised when the API returns an error response.""" + + def __init__(self, message: str, status_code: int | None = None) -> None: + self.status_code = status_code + super().__init__(message) + + +class Context7ValidationError(Context7Error): + """Raised when input validation fails.""" + + pass diff --git a/packages/py/sdk/src/context7/http.py b/packages/py/sdk/src/context7/http.py new file mode 100644 index 000000000..500344a8e --- /dev/null +++ b/packages/py/sdk/src/context7/http.py @@ -0,0 +1,165 @@ +"""HTTP client with retry logic for Context7 API.""" + +from __future__ import annotations + +import asyncio +import math +from typing import Any + +import httpx +from pydantic import BaseModel + +from context7.errors import Context7APIError + + +class TxtResponseHeaders(BaseModel): + """Headers extracted from text response for pagination.""" + + page: int + limit: int + total_pages: int + has_next: bool + has_prev: bool + total_tokens: int + + +class HttpClient: + """HTTP client with retry logic for Context7 API.""" + + def __init__( + self, + base_url: str, + headers: dict[str, str] | None = None, + retries: int = 5, + timeout: float = 30.0, + ) -> None: + """ + Initialize the HTTP client. + + Args: + base_url: Base URL for the API. + headers: Optional headers to include in all requests. + retries: Number of retry attempts for failed requests. + timeout: Request timeout in seconds. + """ + self.base_url = base_url.rstrip("/") + self.headers = {"Content-Type": "application/json", **(headers or {})} + self.retries = retries + self.timeout = timeout + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create the async HTTP client.""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + base_url=self.base_url, + headers=self.headers, + timeout=self.timeout, + ) + return self._client + + async def close(self) -> None: + """Close the HTTP client connection.""" + if self._client is not None: + await self._client.aclose() + self._client = None + + def _backoff(self, retry_count: int) -> float: + """ + Calculate exponential backoff delay. + + Matches the TypeScript SDK: Math.exp(retryCount) * 50ms + """ + return math.exp(retry_count) * 0.05 + + async def request( + self, + method: str, + path: list[str] | None = None, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> tuple[Any, TxtResponseHeaders | None]: + """ + Make an HTTP request with retry logic. + + Args: + method: HTTP method (GET, POST, etc.). + path: URL path segments to append to base URL. + query: Query parameters for the request. + body: JSON body for POST requests. + + Returns: + Tuple of (response_data, headers) where headers is only present + for text responses. + + Raises: + Context7APIError: If the API returns an error response. + httpx.RequestError: If all retries are exhausted. + """ + url = "/".join([self.base_url, *(path or [])]) + + # Filter out None values from query params + params = {k: v for k, v in (query or {}).items() if v is not None} + + client = await self._get_client() + last_error: Exception | None = None + + for attempt in range(self.retries + 1): + try: + response = await client.request( + method=method, + url=url, + params=params if method == "GET" else None, + json=body if method == "POST" else None, + ) + + if not response.is_success: + try: + error_body = response.json() + message = ( + error_body.get("error") + or error_body.get("message") + or response.reason_phrase + ) + except Exception: + message = response.reason_phrase or "Unknown error" + raise Context7APIError(message, status_code=response.status_code) + + # Handle response based on content type + content_type = response.headers.get("content-type", "") + + if "application/json" in content_type: + return response.json(), None + else: + headers = self._extract_txt_headers(response.headers) + return response.text, headers + + except httpx.RequestError as e: + last_error = e + if attempt < self.retries: + await asyncio.sleep(self._backoff(attempt)) + continue + + raise last_error or Context7APIError("Exhausted all retries") + + def _extract_txt_headers(self, headers: httpx.Headers) -> TxtResponseHeaders | None: + """ + Extract pagination headers from text response. + + Args: + headers: Response headers from the HTTP response. + + Returns: + TxtResponseHeaders if all required headers are present, None otherwise. + """ + try: + return TxtResponseHeaders( + page=int(headers.get("x-context7-page", 0)), + limit=int(headers.get("x-context7-limit", 0)), + total_pages=int(headers.get("x-context7-total-pages", 0)), + has_next=headers.get("x-context7-has-next", "false").lower() == "true", + has_prev=headers.get("x-context7-has-prev", "false").lower() == "true", + total_tokens=int(headers.get("x-context7-total-tokens", 0)), + ) + except (ValueError, TypeError): + return None diff --git a/packages/py/sdk/src/context7/models.py b/packages/py/sdk/src/context7/models.py new file mode 100644 index 000000000..f49a8524c --- /dev/null +++ b/packages/py/sdk/src/context7/models.py @@ -0,0 +1,155 @@ +"""Pydantic models for Context7 API request/response types.""" + +from __future__ import annotations + +from enum import Enum +from typing import Literal, Union + +from pydantic import BaseModel, Field + + +class LibraryState(str, Enum): + """State of a library in the Context7 system.""" + + INITIAL = "initial" + FINALIZED = "finalized" + PROCESSING = "processing" + ERROR = "error" + DELETE = "delete" + + +class AuthenticationType(str, Enum): + """Type of authentication used for the API request.""" + + NONE = "none" + PERSONAL = "personal" + TEAM = "team" + + +class SearchResult(BaseModel): + """A single library search result.""" + + id: str + title: str + description: str + branch: str + last_update_date: str = Field(alias="lastUpdateDate") + state: LibraryState + total_tokens: int = Field(alias="totalTokens") + total_snippets: int = Field(alias="totalSnippets") + stars: int | None = None + trust_score: float | None = Field(default=None, alias="trustScore") + benchmark_score: float | None = Field(default=None, alias="benchmarkScore") + versions: list[str] | None = None + + model_config = {"populate_by_name": True} + + +class APIResponseMetadata(BaseModel): + """Metadata about the API response.""" + + authentication: AuthenticationType + + +class SearchLibraryResponse(BaseModel): + """Response from the search library endpoint.""" + + results: list[SearchResult] + metadata: APIResponseMetadata + + +class CodeExample(BaseModel): + """A code example in a specific language.""" + + language: str + code: str + + +class CodeSnippet(BaseModel): + """A code snippet from the documentation.""" + + code_title: str = Field(alias="codeTitle") + code_description: str = Field(alias="codeDescription") + code_language: str = Field(alias="codeLanguage") + code_tokens: int = Field(alias="codeTokens") + code_id: str = Field(alias="codeId") + page_title: str = Field(alias="pageTitle") + code_list: list[CodeExample] = Field(alias="codeList") + + model_config = {"populate_by_name": True} + + +class InfoSnippet(BaseModel): + """An information snippet from the documentation.""" + + page_id: str | None = Field(default=None, alias="pageId") + breadcrumb: str | None = None + content: str + content_tokens: int = Field(alias="contentTokens") + + model_config = {"populate_by_name": True} + + +class Pagination(BaseModel): + """Pagination information for paginated responses.""" + + page: int + limit: int + total_pages: int = Field(alias="totalPages") + has_next: bool = Field(alias="hasNext") + has_prev: bool = Field(alias="hasPrev") + + model_config = {"populate_by_name": True} + + +class DocsResponseBase(BaseModel): + """Base class for documentation responses.""" + + pagination: Pagination + total_tokens: int = Field(alias="totalTokens") + + model_config = {"populate_by_name": True} + + +class CodeDocsResponse(DocsResponseBase): + """Response containing code documentation snippets.""" + + snippets: list[CodeSnippet] + + +class InfoDocsResponse(DocsResponseBase): + """Response containing information documentation snippets.""" + + snippets: list[InfoSnippet] + + +class TextDocsResponse(DocsResponseBase): + """Response containing plain text documentation.""" + + content: str + + +class GetDocsOptions(BaseModel): + """Options for fetching documentation.""" + + version: str | None = None + """Library version to fetch docs for (e.g., "18.0.0").""" + + page: int | None = None + """Page number for pagination.""" + + topic: str | None = None + """Filter docs by topic.""" + + limit: int | None = None + """Number of results per page.""" + + mode: Literal["info", "code"] = "code" + """Type of documentation to fetch. Defaults to "code".""" + + format: Literal["json", "txt"] = "json" + """Response format. Defaults to "json".""" + + +# Type alias for any docs response +DocsResponse = Union[CodeDocsResponse, InfoDocsResponse, TextDocsResponse] diff --git a/packages/py/sdk/src/context7/py.typed b/packages/py/sdk/src/context7/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/packages/py/sdk/tests/__init__.py b/packages/py/sdk/tests/__init__.py new file mode 100644 index 000000000..8b6e6c7ec --- /dev/null +++ b/packages/py/sdk/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Context7 Python SDK.""" diff --git a/packages/py/sdk/tests/conftest.py b/packages/py/sdk/tests/conftest.py new file mode 100644 index 000000000..d4a8db62b --- /dev/null +++ b/packages/py/sdk/tests/conftest.py @@ -0,0 +1,16 @@ +"""Pytest configuration and fixtures for Context7 SDK tests.""" + +from pathlib import Path + +import pytest +from dotenv import load_dotenv + +# Load .env from the python workspace root (packages/python/.env) +# Path: sdk/tests/conftest.py -> sdk/tests -> sdk -> packages/python +env_path = Path(__file__).parent.parent.parent / ".env" +load_dotenv(env_path) + + +def pytest_configure(config: pytest.Config) -> None: + """Configure pytest markers.""" + config.addinivalue_line("markers", "asyncio: mark test as an async test") diff --git a/packages/py/sdk/tests/test_client.py b/packages/py/sdk/tests/test_client.py new file mode 100644 index 000000000..bb0f4fc6f --- /dev/null +++ b/packages/py/sdk/tests/test_client.py @@ -0,0 +1,139 @@ +"""Tests for the Context7 client.""" + +import os + +import pytest +from context7 import Context7, Context7Error, Context7ValidationError + + +@pytest.fixture +def api_key() -> str: + """Get API key from environment or skip test.""" + key = os.environ.get("CONTEXT7_API_KEY") + if not key: + pytest.skip("CONTEXT7_API_KEY not set") + return key + + +class TestContext7Init: + """Tests for Context7 client initialization.""" + + def test_init_with_api_key(self, api_key: str) -> None: + """Test client initialization with explicit API key.""" + client = Context7(api_key=api_key) + assert client is not None + + def test_init_without_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that missing API key raises an error.""" + monkeypatch.delenv("CONTEXT7_API_KEY", raising=False) + with pytest.raises(Context7Error, match="API key is required"): + Context7() + + def test_init_from_env(self, api_key: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Test client initialization from environment variable.""" + monkeypatch.setenv("CONTEXT7_API_KEY", api_key) + client = Context7() + assert client is not None + + def test_init_with_invalid_prefix_warns(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that invalid API key prefix produces a warning.""" + monkeypatch.delenv("CONTEXT7_API_KEY", raising=False) + with pytest.warns(UserWarning, match="API key should start with"): + Context7(api_key="invalid_key") + + +class TestSearchLibrary: + """Tests for the search_library method.""" + + @pytest.mark.asyncio + async def test_search_library(self, api_key: str) -> None: + """Test basic library search.""" + async with Context7(api_key=api_key) as client: + response = await client.search_library("react") + assert response.results is not None + assert len(response.results) > 0 + assert response.metadata is not None + + @pytest.mark.asyncio + async def test_search_library_result_fields(self, api_key: str) -> None: + """Test that search results have expected fields.""" + async with Context7(api_key=api_key) as client: + response = await client.search_library("react") + if response.results: + result = response.results[0] + assert result.id is not None + assert result.title is not None + assert result.total_tokens >= 0 + + +class TestGetDocs: + """Tests for the get_docs method.""" + + @pytest.mark.asyncio + async def test_get_docs_code_default(self, api_key: str) -> None: + """Test getting code documentation (default mode).""" + async with Context7(api_key=api_key) as client: + response = await client.get_docs("/facebook/react") + assert response.snippets is not None + assert response.pagination is not None + assert response.total_tokens >= 0 + + @pytest.mark.asyncio + async def test_get_docs_info_mode(self, api_key: str) -> None: + """Test getting info documentation.""" + async with Context7(api_key=api_key) as client: + response = await client.get_docs("/facebook/react", mode="info") + assert response.snippets is not None + assert response.pagination is not None + + @pytest.mark.asyncio + async def test_get_docs_txt_format(self, api_key: str) -> None: + """Test getting plain text documentation.""" + async with Context7(api_key=api_key) as client: + response = await client.get_docs("/facebook/react", format="txt") + assert response.content is not None + assert isinstance(response.content, str) + assert len(response.content) > 0 + + @pytest.mark.asyncio + async def test_get_docs_with_pagination(self, api_key: str) -> None: + """Test documentation pagination.""" + async with Context7(api_key=api_key) as client: + response = await client.get_docs("/facebook/react", page=1, limit=5) + assert response.pagination is not None + assert response.pagination.page == 1 + assert response.pagination.limit == 5 + + @pytest.mark.asyncio + async def test_get_docs_invalid_library_id(self, api_key: str) -> None: + """Test that invalid library ID raises validation error.""" + async with Context7(api_key=api_key) as client: + with pytest.raises(Context7ValidationError, match="Invalid library ID"): + await client.get_docs("invalid-id") + + @pytest.mark.asyncio + async def test_get_docs_invalid_library_id_no_slash(self, api_key: str) -> None: + """Test that library ID without leading slash raises error.""" + async with Context7(api_key=api_key) as client: + with pytest.raises(Context7ValidationError): + await client.get_docs("facebook/react") + + +class TestContextManager: + """Tests for async context manager behavior.""" + + @pytest.mark.asyncio + async def test_context_manager(self, api_key: str) -> None: + """Test that context manager properly opens and closes.""" + async with Context7(api_key=api_key) as client: + # Should work inside context + response = await client.search_library("react") + assert response is not None + + @pytest.mark.asyncio + async def test_manual_close(self, api_key: str) -> None: + """Test manual close method.""" + client = Context7(api_key=api_key) + response = await client.search_library("react") + assert response is not None + await client.close() From f3a18a05d805511ddf50f8a305b35739626a7934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 16:31:12 +0300 Subject: [PATCH 03/22] feat: add sync client --- packages/py/sdk/src/context7/client.py | 393 +++++++++++++++++++------ packages/py/sdk/src/context7/errors.py | 2 - packages/py/sdk/src/context7/http.py | 168 +++++++++-- packages/py/sdk/src/context7/models.py | 13 +- packages/py/sdk/tests/test_client.py | 159 +++++++--- 5 files changed, 577 insertions(+), 158 deletions(-) diff --git a/packages/py/sdk/src/context7/client.py b/packages/py/sdk/src/context7/client.py index 267d0194c..38ba291cf 100644 --- a/packages/py/sdk/src/context7/client.py +++ b/packages/py/sdk/src/context7/client.py @@ -7,7 +7,7 @@ from typing import Literal, overload from context7.errors import Context7Error, Context7ValidationError -from context7.http import HttpClient +from context7.http import AsyncHttpClient, SyncHttpClient from context7.models import ( CodeDocsResponse, InfoDocsResponse, @@ -20,6 +20,90 @@ API_KEY_PREFIX = "ctx7sk" +def _validate_api_key(api_key: str | None) -> str: + """Validate and resolve the API key.""" + resolved_api_key = api_key or os.environ.get("CONTEXT7_API_KEY") + + if not resolved_api_key: + raise Context7Error( + "API key is required. Pass it in the constructor or set " + "CONTEXT7_API_KEY environment variable." + ) + + if not resolved_api_key.startswith(API_KEY_PREFIX): + warnings.warn( + f"API key should start with '{API_KEY_PREFIX}'", + UserWarning, + stacklevel=3, + ) + + return resolved_api_key + + +def _validate_library_id(library_id: str) -> tuple[str, str]: + """Validate library_id format and return (owner, repo).""" + if not library_id.startswith("/") or library_id.count("/") < 2: + raise Context7ValidationError( + f"Invalid library ID: {library_id}. Expected format: /owner/repo" + ) + + parts = library_id.lstrip("/").split("/") + owner, repo = parts[0], "/".join(parts[1:]) + return owner, repo + + +def _build_docs_request( + library_id: str, + version: str | None, + page: int | None, + topic: str | None, + limit: int | None, + mode: Literal["info", "code"], + format: Literal["json", "txt"], # pylint: disable=redefined-builtin +) -> tuple[list[str], dict[str, str | int | None]]: + """Build path and query for docs request.""" + owner, repo = _validate_library_id(library_id) + + path = ["v2", "docs", mode, owner, repo] + if version: + path.append(version) + + query: dict[str, str | int | None] = { + "type": format, + "page": page, + "limit": limit, + "topic": topic, + } + + return path, query + + +def _process_docs_response( + result: str | dict, # type: ignore[type-arg] + headers: object | None, + mode: Literal["info", "code"], + format: Literal["json", "txt"], # pylint: disable=redefined-builtin +) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: + """Process the docs response based on format and mode.""" + if format == "txt": + pagination = Pagination( + page=headers.page if headers else 1, # type: ignore[union-attr] + limit=headers.limit if headers else 0, # type: ignore[union-attr] + totalPages=headers.total_pages if headers else 1, # type: ignore[union-attr] + hasNext=headers.has_next if headers else False, # type: ignore[union-attr] + hasPrev=headers.has_prev if headers else False, # type: ignore[union-attr] + ) + return TextDocsResponse( + content=result, # type: ignore[arg-type] + pagination=pagination, + totalTokens=headers.total_tokens if headers else 0, # type: ignore[union-attr] + ) + + if mode == "info": + return InfoDocsResponse.model_validate(result) + return CodeDocsResponse.model_validate(result) + + class Context7: """ Context7 Python SDK client. @@ -27,21 +111,37 @@ class Context7: The Context7 client provides methods to search for libraries and retrieve documentation optimized for AI agents and LLMs. - Usage: + Synchronous Usage: + ```python + from context7 import Context7 + + # Initialize with API key (or set CONTEXT7_API_KEY env var) + with Context7(api_key="ctx7sk_...") as client: + # Search for libraries + results = client.search_library("react") + for lib in results.results: + print(f"{lib.id}: {lib.title}") + + # Get documentation + docs = client.get_docs("/facebook/react") + for snippet in docs.snippets: + print(f"{snippet.code_title}: {snippet.code_description}") + ``` + + Asynchronous Usage: ```python import asyncio from context7 import Context7 async def main(): - # Initialize with API key (or set CONTEXT7_API_KEY env var) async with Context7(api_key="ctx7sk_...") as client: # Search for libraries - results = await client.search_library("react") + results = await client.search_library_async("react") for lib in results.results: print(f"{lib.id}: {lib.title}") # Get documentation - docs = await client.get_docs("/facebook/react") + docs = await client.get_docs_async("/facebook/react") for snippet in docs.snippets: print(f"{snippet.code_title}: {snippet.code_description}") @@ -66,39 +166,50 @@ def __init__( Raises: Context7Error: If no API key is provided or found in environment. """ - resolved_api_key = api_key or os.environ.get("CONTEXT7_API_KEY") - - if not resolved_api_key: - raise Context7Error( - "API key is required. Pass it in the constructor or set " - "CONTEXT7_API_KEY environment variable." - ) - - if not resolved_api_key.startswith(API_KEY_PREFIX): - warnings.warn( - f"API key should start with '{API_KEY_PREFIX}'", - UserWarning, - stacklevel=2, - ) - - self._http = HttpClient( - base_url=base_url or DEFAULT_BASE_URL, - headers={"Authorization": f"Bearer {resolved_api_key}"}, + resolved_api_key = _validate_api_key(api_key) + http_headers = {"Authorization": f"Bearer {resolved_api_key}"} + resolved_base_url = base_url or DEFAULT_BASE_URL + + self._sync_http = SyncHttpClient( + base_url=resolved_base_url, + headers=http_headers, + ) + self._async_http = AsyncHttpClient( + base_url=resolved_base_url, + headers=http_headers, ) + # Sync context manager + def __enter__(self) -> Context7: + """Enter sync context manager.""" + return self + + def __exit__(self, *args: object) -> None: + """Exit sync context manager and close connections.""" + self.close() + + # Async context manager async def __aenter__(self) -> Context7: """Enter async context manager.""" return self async def __aexit__(self, *args: object) -> None: """Exit async context manager and close connections.""" - await self.close() + await self.close_async() + + def close(self) -> None: + """Close the sync HTTP client connection.""" + self._sync_http.close() + + async def close_async(self) -> None: + """Close the async HTTP client connection.""" + await self._async_http.close() - async def close(self) -> None: - """Close the HTTP client connection.""" - await self._http.close() + # ------------------------------------------------------------------------- + # Synchronous Methods (default) + # ------------------------------------------------------------------------- - async def search_library(self, query: str) -> SearchLibraryResponse: + def search_library(self, query: str) -> SearchLibraryResponse: """ Search for libraries by name or description. @@ -110,12 +221,12 @@ async def search_library(self, query: str) -> SearchLibraryResponse: Example: ```python - results = await client.search_library("react") + results = client.search_library("react") for lib in results.results: print(f"{lib.id}: {lib.title} ({lib.total_tokens} tokens)") ``` """ - result, _ = await self._http.request( + result, _ = self._sync_http.request( method="GET", path=["v2", "search"], query={"query": query}, @@ -123,11 +234,11 @@ async def search_library(self, query: str) -> SearchLibraryResponse: return SearchLibraryResponse.model_validate(result) @overload - async def get_docs( + def get_docs( self, library_id: str, *, - format: Literal["txt"], + format: Literal["txt"], # pylint: disable=redefined-builtin version: str | None = None, page: int | None = None, topic: str | None = None, @@ -136,12 +247,12 @@ async def get_docs( ) -> TextDocsResponse: ... @overload - async def get_docs( + def get_docs( self, library_id: str, *, mode: Literal["info"], - format: Literal["json"] = "json", + format: Literal["json"] = "json", # pylint: disable=redefined-builtin version: str | None = None, page: int | None = None, topic: str | None = None, @@ -149,12 +260,12 @@ async def get_docs( ) -> InfoDocsResponse: ... @overload - async def get_docs( + def get_docs( self, library_id: str, *, mode: Literal["code"] = "code", - format: Literal["json"] = "json", + format: Literal["json"] = "json", # pylint: disable=redefined-builtin version: str | None = None, page: int | None = None, topic: str | None = None, @@ -162,7 +273,7 @@ async def get_docs( ) -> CodeDocsResponse: ... @overload - async def get_docs( + def get_docs( self, library_id: str, *, @@ -171,10 +282,10 @@ async def get_docs( topic: str | None = None, limit: int | None = None, mode: Literal["info", "code"] = "code", - format: Literal["json", "txt"] = "json", + format: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: ... - async def get_docs( + def get_docs( self, library_id: str, *, @@ -183,7 +294,7 @@ async def get_docs( topic: str | None = None, limit: int | None = None, mode: Literal["info", "code"] = "code", - format: Literal["json", "txt"] = "json", + format: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: """ Get documentation for a library. @@ -214,68 +325,176 @@ async def get_docs( Example: ```python # Get code snippets (default) - docs = await client.get_docs("/facebook/react") + docs = client.get_docs("/facebook/react") for snippet in docs.snippets: print(snippet.code_title) # Get info documentation - info = await client.get_docs("/facebook/react", mode="info") + info = client.get_docs("/facebook/react", mode="info") for snippet in info.snippets: print(snippet.content) # Get plain text - text = await client.get_docs("/facebook/react", format="txt") + text = client.get_docs("/facebook/react", format="txt") print(text.content) # With version - docs = await client.get_docs("/facebook/react", version="18.0.0") + docs = client.get_docs("/facebook/react", version="18.0.0") ``` """ - # Validate library_id format - if not library_id.startswith("/") or library_id.count("/") < 2: - raise Context7ValidationError( - f"Invalid library ID: {library_id}. Expected format: /owner/repo" - ) - - # Parse library_id: /owner/repo -> ["owner", "repo"] - parts = library_id.lstrip("/").split("/") - owner, repo = parts[0], "/".join(parts[1:]) - - # Build path - path = ["v2", "docs", mode, owner, repo] - if version: - path.append(version) - - # Build query params - query = { - "type": format, - "page": page, - "limit": limit, - "topic": topic, - } - - result, headers = await self._http.request( + path, query = _build_docs_request( + library_id, version, page, topic, limit, mode, format + ) + result, headers = self._sync_http.request( method="GET", path=path, query=query, ) + return _process_docs_response(result, headers, mode, format) + + # ------------------------------------------------------------------------- + # Asynchronous Methods (_async suffix) + # ------------------------------------------------------------------------- + + async def search_library_async(self, query: str) -> SearchLibraryResponse: + """ + Search for libraries by name or description (async version). + + Args: + query: Search query string. + + Returns: + SearchLibraryResponse containing matching libraries and metadata. + + Example: + ```python + results = await client.search_library_async("react") + for lib in results.results: + print(f"{lib.id}: {lib.title} ({lib.total_tokens} tokens)") + ``` + """ + result, _ = await self._async_http.request( + method="GET", + path=["v2", "search"], + query={"query": query}, + ) + return SearchLibraryResponse.model_validate(result) + + @overload + async def get_docs_async( + self, + library_id: str, + *, + format: Literal["txt"], # pylint: disable=redefined-builtin + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + mode: Literal["info", "code"] = "code", + ) -> TextDocsResponse: ... + + @overload + async def get_docs_async( + self, + library_id: str, + *, + mode: Literal["info"], + format: Literal["json"] = "json", # pylint: disable=redefined-builtin + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + ) -> InfoDocsResponse: ... + + @overload + async def get_docs_async( + self, + library_id: str, + *, + mode: Literal["code"] = "code", + format: Literal["json"] = "json", # pylint: disable=redefined-builtin + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + ) -> CodeDocsResponse: ... + + @overload + async def get_docs_async( + self, + library_id: str, + *, + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + mode: Literal["info", "code"] = "code", + format: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin + ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: ... - # Handle text response - if format == "txt": - pagination = Pagination( - page=headers.page if headers else 1, - limit=headers.limit if headers else 0, - totalPages=headers.total_pages if headers else 1, - hasNext=headers.has_next if headers else False, - hasPrev=headers.has_prev if headers else False, - ) - return TextDocsResponse( - content=result, - pagination=pagination, - totalTokens=headers.total_tokens if headers else 0, - ) - - # Handle JSON response - if mode == "info": - return InfoDocsResponse.model_validate(result) - return CodeDocsResponse.model_validate(result) + async def get_docs_async( + self, + library_id: str, + *, + version: str | None = None, + page: int | None = None, + topic: str | None = None, + limit: int | None = None, + mode: Literal["info", "code"] = "code", + format: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin + ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: + """ + Get documentation for a library (async version). + + Args: + library_id: Library identifier in format "/owner/repo" + (e.g., "/facebook/react", "/vercel/next.js"). + version: Optional library version (e.g., "18.0.0"). + page: Page number for pagination. + topic: Filter docs by topic. + limit: Number of results per page. + mode: Type of documentation to fetch: + - "code": Code snippets with examples (default) + - "info": Text content and explanations + format: Response format: + - "json": Structured JSON data (default) + - "txt": Plain text documentation + + Returns: + Documentation response. The type depends on mode and format: + - format="txt": TextDocsResponse with content string + - mode="code", format="json": CodeDocsResponse with code snippets + - mode="info", format="json": InfoDocsResponse with info snippets + + Raises: + Context7ValidationError: If library_id format is invalid. + + Example: + ```python + # Get code snippets (default) + docs = await client.get_docs_async("/facebook/react") + for snippet in docs.snippets: + print(snippet.code_title) + + # Get info documentation + info = await client.get_docs_async("/facebook/react", mode="info") + for snippet in info.snippets: + print(snippet.content) + + # Get plain text + text = await client.get_docs_async("/facebook/react", format="txt") + print(text.content) + + # With version + docs = await client.get_docs_async("/facebook/react", version="18.0.0") + ``` + """ + path, query = _build_docs_request( + library_id, version, page, topic, limit, mode, format + ) + result, headers = await self._async_http.request( + method="GET", + path=path, + query=query, + ) + return _process_docs_response(result, headers, mode, format) diff --git a/packages/py/sdk/src/context7/errors.py b/packages/py/sdk/src/context7/errors.py index 4a670678e..c9eee1675 100644 --- a/packages/py/sdk/src/context7/errors.py +++ b/packages/py/sdk/src/context7/errors.py @@ -21,5 +21,3 @@ def __init__(self, message: str, status_code: int | None = None) -> None: class Context7ValidationError(Context7Error): """Raised when input validation fails.""" - - pass diff --git a/packages/py/sdk/src/context7/http.py b/packages/py/sdk/src/context7/http.py index 500344a8e..ca4f89bfa 100644 --- a/packages/py/sdk/src/context7/http.py +++ b/packages/py/sdk/src/context7/http.py @@ -4,6 +4,7 @@ import asyncio import math +import time from typing import Any import httpx @@ -23,8 +24,126 @@ class TxtResponseHeaders(BaseModel): total_tokens: int -class HttpClient: - """HTTP client with retry logic for Context7 API.""" +class SyncHttpClient: + """Synchronous HTTP client with retry logic for Context7 API.""" + + def __init__( + self, + base_url: str, + headers: dict[str, str] | None = None, + retries: int = 5, + timeout: float = 30.0, + ) -> None: + """ + Initialize the HTTP client. + + Args: + base_url: Base URL for the API. + headers: Optional headers to include in all requests. + retries: Number of retry attempts for failed requests. + timeout: Request timeout in seconds. + """ + self.base_url = base_url.rstrip("/") + self.headers = {"Content-Type": "application/json", **(headers or {})} + self.retries = retries + self.timeout = timeout + self._client: httpx.Client | None = None + + def _get_client(self) -> httpx.Client: + """Get or create the sync HTTP client.""" + if self._client is None or self._client.is_closed: + self._client = httpx.Client( + base_url=self.base_url, + headers=self.headers, + timeout=self.timeout, + ) + return self._client + + def close(self) -> None: + """Close the HTTP client connection.""" + if self._client is not None: + self._client.close() + self._client = None + + def _backoff(self, retry_count: int) -> float: + """ + Calculate exponential backoff delay. + + Matches the TypeScript SDK: Math.exp(retryCount) * 50ms + """ + return math.exp(retry_count) * 0.05 + + def request( + self, + method: str, + path: list[str] | None = None, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> tuple[Any, TxtResponseHeaders | None]: + """ + Make an HTTP request with retry logic. + + Args: + method: HTTP method (GET, POST, etc.). + path: URL path segments to append to base URL. + query: Query parameters for the request. + body: JSON body for POST requests. + + Returns: + Tuple of (response_data, headers) where headers is only present + for text responses. + + Raises: + Context7APIError: If the API returns an error response. + httpx.RequestError: If all retries are exhausted. + """ + url = "/".join([self.base_url, *(path or [])]) + + params = {k: v for k, v in (query or {}).items() if v is not None} + + client = self._get_client() + last_error: Exception | None = None + + for attempt in range(self.retries + 1): + try: + response = client.request( + method=method, + url=url, + params=params if method == "GET" else None, + json=body if method == "POST" else None, + ) + + if not response.is_success: + try: + error_body = response.json() + message = ( + error_body.get("error") + or error_body.get("message") + or response.reason_phrase + ) + except (ValueError, KeyError, TypeError): + message = response.reason_phrase or "Unknown error" + raise Context7APIError(message, status_code=response.status_code) + + content_type = response.headers.get("content-type", "") + + if "application/json" in content_type: + return response.json(), None + else: + headers = _extract_txt_headers(response.headers) + return response.text, headers + + except httpx.RequestError as e: + last_error = e + if attempt < self.retries: + time.sleep(self._backoff(attempt)) + continue + + raise last_error or Context7APIError("Exhausted all retries") + + +class AsyncHttpClient: + """Asynchronous HTTP client with retry logic for Context7 API.""" def __init__( self, @@ -98,7 +217,6 @@ async def request( """ url = "/".join([self.base_url, *(path or [])]) - # Filter out None values from query params params = {k: v for k, v in (query or {}).items() if v is not None} client = await self._get_client() @@ -121,17 +239,16 @@ async def request( or error_body.get("message") or response.reason_phrase ) - except Exception: + except (ValueError, KeyError, TypeError): message = response.reason_phrase or "Unknown error" raise Context7APIError(message, status_code=response.status_code) - # Handle response based on content type content_type = response.headers.get("content-type", "") if "application/json" in content_type: return response.json(), None else: - headers = self._extract_txt_headers(response.headers) + headers = _extract_txt_headers(response.headers) return response.text, headers except httpx.RequestError as e: @@ -142,24 +259,25 @@ async def request( raise last_error or Context7APIError("Exhausted all retries") - def _extract_txt_headers(self, headers: httpx.Headers) -> TxtResponseHeaders | None: - """ - Extract pagination headers from text response. - Args: - headers: Response headers from the HTTP response. +def _extract_txt_headers(headers: httpx.Headers) -> TxtResponseHeaders | None: + """ + Extract pagination headers from text response. - Returns: - TxtResponseHeaders if all required headers are present, None otherwise. - """ - try: - return TxtResponseHeaders( - page=int(headers.get("x-context7-page", 0)), - limit=int(headers.get("x-context7-limit", 0)), - total_pages=int(headers.get("x-context7-total-pages", 0)), - has_next=headers.get("x-context7-has-next", "false").lower() == "true", - has_prev=headers.get("x-context7-has-prev", "false").lower() == "true", - total_tokens=int(headers.get("x-context7-total-tokens", 0)), - ) - except (ValueError, TypeError): - return None + Args: + headers: Response headers from the HTTP response. + + Returns: + TxtResponseHeaders if all required headers are present, None otherwise. + """ + try: + return TxtResponseHeaders( + page=int(headers.get("x-context7-page", 0)), + limit=int(headers.get("x-context7-limit", 0)), + total_pages=int(headers.get("x-context7-total-pages", 0)), + has_next=headers.get("x-context7-has-next", "false").lower() == "true", + has_prev=headers.get("x-context7-has-prev", "false").lower() == "true", + total_tokens=int(headers.get("x-context7-total-tokens", 0)), + ) + except (ValueError, TypeError): + return None diff --git a/packages/py/sdk/src/context7/models.py b/packages/py/sdk/src/context7/models.py index f49a8524c..85de1bdc8 100644 --- a/packages/py/sdk/src/context7/models.py +++ b/packages/py/sdk/src/context7/models.py @@ -5,7 +5,7 @@ from enum import Enum from typing import Literal, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class LibraryState(str, Enum): @@ -42,7 +42,7 @@ class SearchResult(BaseModel): benchmark_score: float | None = Field(default=None, alias="benchmarkScore") versions: list[str] | None = None - model_config = {"populate_by_name": True} + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) class APIResponseMetadata(BaseModel): @@ -76,7 +76,7 @@ class CodeSnippet(BaseModel): page_title: str = Field(alias="pageTitle") code_list: list[CodeExample] = Field(alias="codeList") - model_config = {"populate_by_name": True} + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) class InfoSnippet(BaseModel): @@ -87,7 +87,7 @@ class InfoSnippet(BaseModel): content: str content_tokens: int = Field(alias="contentTokens") - model_config = {"populate_by_name": True} + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) class Pagination(BaseModel): @@ -99,7 +99,7 @@ class Pagination(BaseModel): has_next: bool = Field(alias="hasNext") has_prev: bool = Field(alias="hasPrev") - model_config = {"populate_by_name": True} + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) class DocsResponseBase(BaseModel): @@ -108,7 +108,7 @@ class DocsResponseBase(BaseModel): pagination: Pagination total_tokens: int = Field(alias="totalTokens") - model_config = {"populate_by_name": True} + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) class CodeDocsResponse(DocsResponseBase): @@ -151,5 +151,4 @@ class GetDocsOptions(BaseModel): """Response format. Defaults to "json".""" -# Type alias for any docs response DocsResponse = Union[CodeDocsResponse, InfoDocsResponse, TextDocsResponse] diff --git a/packages/py/sdk/tests/test_client.py b/packages/py/sdk/tests/test_client.py index bb0f4fc6f..e522fa4a7 100644 --- a/packages/py/sdk/tests/test_client.py +++ b/packages/py/sdk/tests/test_client.py @@ -42,23 +42,45 @@ def test_init_with_invalid_prefix_warns(self, monkeypatch: pytest.MonkeyPatch) - Context7(api_key="invalid_key") -class TestSearchLibrary: - """Tests for the search_library method.""" +class TestSearchLibrarySync: + """Tests for the synchronous search_library method.""" + + def test_search_library(self, api_key: str) -> None: + """Test basic library search (sync).""" + with Context7(api_key=api_key) as client: + response = client.search_library("react") + assert response.results is not None + assert len(response.results) > 0 + assert response.metadata is not None + + def test_search_library_result_fields(self, api_key: str) -> None: + """Test that search results have expected fields (sync).""" + with Context7(api_key=api_key) as client: + response = client.search_library("react") + if response.results: + result = response.results[0] + assert result.id is not None + assert result.title is not None + assert result.total_tokens >= 0 + + +class TestSearchLibraryAsync: + """Tests for the asynchronous search_library_async method.""" @pytest.mark.asyncio - async def test_search_library(self, api_key: str) -> None: - """Test basic library search.""" + async def test_search_library_async(self, api_key: str) -> None: + """Test basic library search (async).""" async with Context7(api_key=api_key) as client: - response = await client.search_library("react") + response = await client.search_library_async("react") assert response.results is not None assert len(response.results) > 0 assert response.metadata is not None @pytest.mark.asyncio - async def test_search_library_result_fields(self, api_key: str) -> None: - """Test that search results have expected fields.""" + async def test_search_library_async_result_fields(self, api_key: str) -> None: + """Test that search results have expected fields (async).""" async with Context7(api_key=api_key) as client: - response = await client.search_library("react") + response = await client.search_library_async("react") if response.results: result = response.results[0] assert result.id is not None @@ -66,74 +88,137 @@ async def test_search_library_result_fields(self, api_key: str) -> None: assert result.total_tokens >= 0 -class TestGetDocs: - """Tests for the get_docs method.""" +class TestGetDocsSync: + """Tests for the synchronous get_docs method.""" + + def test_get_docs_code_default(self, api_key: str) -> None: + """Test getting code documentation (default mode, sync).""" + with Context7(api_key=api_key) as client: + response = client.get_docs("/facebook/react") + assert response.snippets is not None + assert response.pagination is not None + assert response.total_tokens >= 0 + + def test_get_docs_info_mode(self, api_key: str) -> None: + """Test getting info documentation (sync).""" + with Context7(api_key=api_key) as client: + response = client.get_docs("/facebook/react", mode="info") + assert response.snippets is not None + assert response.pagination is not None + + def test_get_docs_txt_format(self, api_key: str) -> None: + """Test getting plain text documentation (sync).""" + with Context7(api_key=api_key) as client: + response = client.get_docs("/facebook/react", format="txt") + assert response.content is not None + assert isinstance(response.content, str) + assert len(response.content) > 0 + + def test_get_docs_with_pagination(self, api_key: str) -> None: + """Test documentation pagination (sync).""" + with Context7(api_key=api_key) as client: + response = client.get_docs("/facebook/react", page=1, limit=5) + assert response.pagination is not None + assert response.pagination.page == 1 + assert response.pagination.limit == 5 + + def test_get_docs_invalid_library_id(self, api_key: str) -> None: + """Test that invalid library ID raises validation error (sync).""" + with Context7(api_key=api_key) as client: + with pytest.raises(Context7ValidationError, match="Invalid library ID"): + client.get_docs("invalid-id") + + def test_get_docs_invalid_library_id_no_slash(self, api_key: str) -> None: + """Test that library ID without leading slash raises error (sync).""" + with Context7(api_key=api_key) as client: + with pytest.raises(Context7ValidationError): + client.get_docs("facebook/react") + + +class TestGetDocsAsync: + """Tests for the asynchronous get_docs_async method.""" @pytest.mark.asyncio - async def test_get_docs_code_default(self, api_key: str) -> None: - """Test getting code documentation (default mode).""" + async def test_get_docs_async_code_default(self, api_key: str) -> None: + """Test getting code documentation (default mode, async).""" async with Context7(api_key=api_key) as client: - response = await client.get_docs("/facebook/react") + response = await client.get_docs_async("/facebook/react") assert response.snippets is not None assert response.pagination is not None assert response.total_tokens >= 0 @pytest.mark.asyncio - async def test_get_docs_info_mode(self, api_key: str) -> None: - """Test getting info documentation.""" + async def test_get_docs_async_info_mode(self, api_key: str) -> None: + """Test getting info documentation (async).""" async with Context7(api_key=api_key) as client: - response = await client.get_docs("/facebook/react", mode="info") + response = await client.get_docs_async("/facebook/react", mode="info") assert response.snippets is not None assert response.pagination is not None @pytest.mark.asyncio - async def test_get_docs_txt_format(self, api_key: str) -> None: - """Test getting plain text documentation.""" + async def test_get_docs_async_txt_format(self, api_key: str) -> None: + """Test getting plain text documentation (async).""" async with Context7(api_key=api_key) as client: - response = await client.get_docs("/facebook/react", format="txt") + response = await client.get_docs_async("/facebook/react", format="txt") assert response.content is not None assert isinstance(response.content, str) assert len(response.content) > 0 @pytest.mark.asyncio - async def test_get_docs_with_pagination(self, api_key: str) -> None: - """Test documentation pagination.""" + async def test_get_docs_async_with_pagination(self, api_key: str) -> None: + """Test documentation pagination (async).""" async with Context7(api_key=api_key) as client: - response = await client.get_docs("/facebook/react", page=1, limit=5) + response = await client.get_docs_async("/facebook/react", page=1, limit=5) assert response.pagination is not None assert response.pagination.page == 1 assert response.pagination.limit == 5 @pytest.mark.asyncio - async def test_get_docs_invalid_library_id(self, api_key: str) -> None: - """Test that invalid library ID raises validation error.""" + async def test_get_docs_async_invalid_library_id(self, api_key: str) -> None: + """Test that invalid library ID raises validation error (async).""" async with Context7(api_key=api_key) as client: with pytest.raises(Context7ValidationError, match="Invalid library ID"): - await client.get_docs("invalid-id") + await client.get_docs_async("invalid-id") @pytest.mark.asyncio - async def test_get_docs_invalid_library_id_no_slash(self, api_key: str) -> None: - """Test that library ID without leading slash raises error.""" + async def test_get_docs_async_invalid_library_id_no_slash(self, api_key: str) -> None: + """Test that library ID without leading slash raises error (async).""" async with Context7(api_key=api_key) as client: with pytest.raises(Context7ValidationError): - await client.get_docs("facebook/react") + await client.get_docs_async("facebook/react") + + +class TestSyncContextManager: + """Tests for sync context manager behavior.""" + + def test_context_manager(self, api_key: str) -> None: + """Test that sync context manager properly opens and closes.""" + with Context7(api_key=api_key) as client: + response = client.search_library("react") + assert response is not None + + def test_manual_close(self, api_key: str) -> None: + """Test manual close method (sync).""" + client = Context7(api_key=api_key) + response = client.search_library("react") + assert response is not None + client.close() -class TestContextManager: +class TestAsyncContextManager: """Tests for async context manager behavior.""" @pytest.mark.asyncio - async def test_context_manager(self, api_key: str) -> None: - """Test that context manager properly opens and closes.""" + async def test_context_manager_async(self, api_key: str) -> None: + """Test that async context manager properly opens and closes.""" async with Context7(api_key=api_key) as client: - # Should work inside context - response = await client.search_library("react") + response = await client.search_library_async("react") assert response is not None @pytest.mark.asyncio - async def test_manual_close(self, api_key: str) -> None: - """Test manual close method.""" + async def test_manual_close_async(self, api_key: str) -> None: + """Test manual close method (async).""" client = Context7(api_key=api_key) - response = await client.search_library("react") + response = await client.search_library_async("react") assert response is not None - await client.close() + await client.close_async() From 237bb99f93ec3862e240284f31ac1358b389529b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 17:15:19 +0300 Subject: [PATCH 04/22] finalize async-sync client seperation --- packages/py/sdk/README.md | 2 +- packages/py/sdk/src/context7/client.py | 46 ++--- packages/py/sdk/src/context7/http.py | 244 ++++++++----------------- packages/py/sdk/tests/test_client.py | 241 ++++++++++++++++++++++-- 4 files changed, 315 insertions(+), 218 deletions(-) diff --git a/packages/py/sdk/README.md b/packages/py/sdk/README.md index 4add5371d..eed1a9d3a 100644 --- a/packages/py/sdk/README.md +++ b/packages/py/sdk/README.md @@ -21,7 +21,6 @@ import asyncio from context7 import Context7 async def main(): - # Initialize with API key (or set CONTEXT7_API_KEY env var) async with Context7(api_key="ctx7sk_...") as client: # Search for libraries results = await client.search_library("react") @@ -54,6 +53,7 @@ Search for libraries by name or description. Get documentation for a library. **Parameters:** + - `library_id`: Library identifier in format `/owner/repo` (e.g., `/facebook/react`) - `version`: Optional library version (e.g., `"18.0.0"`) - `page`: Page number for pagination diff --git a/packages/py/sdk/src/context7/client.py b/packages/py/sdk/src/context7/client.py index 38ba291cf..6f89bd551 100644 --- a/packages/py/sdk/src/context7/client.py +++ b/packages/py/sdk/src/context7/client.py @@ -7,7 +7,7 @@ from typing import Literal, overload from context7.errors import Context7Error, Context7ValidationError -from context7.http import AsyncHttpClient, SyncHttpClient +from context7.http import HttpClient from context7.models import ( CodeDocsResponse, InfoDocsResponse, @@ -79,7 +79,7 @@ def _build_docs_request( def _process_docs_response( - result: str | dict, # type: ignore[type-arg] + result: str | dict, headers: object | None, mode: Literal["info", "code"], format: Literal["json", "txt"], # pylint: disable=redefined-builtin @@ -87,16 +87,16 @@ def _process_docs_response( """Process the docs response based on format and mode.""" if format == "txt": pagination = Pagination( - page=headers.page if headers else 1, # type: ignore[union-attr] - limit=headers.limit if headers else 0, # type: ignore[union-attr] - totalPages=headers.total_pages if headers else 1, # type: ignore[union-attr] - hasNext=headers.has_next if headers else False, # type: ignore[union-attr] - hasPrev=headers.has_prev if headers else False, # type: ignore[union-attr] + page=headers.page if headers else 1, + limit=headers.limit if headers else 0, + totalPages=headers.total_pages if headers else 1, + hasNext=headers.has_next if headers else False, + hasPrev=headers.has_prev if headers else False, ) return TextDocsResponse( - content=result, # type: ignore[arg-type] + content=result, pagination=pagination, - totalTokens=headers.total_tokens if headers else 0, # type: ignore[union-attr] + totalTokens=headers.total_tokens if headers else 0, ) if mode == "info": @@ -170,11 +170,7 @@ def __init__( http_headers = {"Authorization": f"Bearer {resolved_api_key}"} resolved_base_url = base_url or DEFAULT_BASE_URL - self._sync_http = SyncHttpClient( - base_url=resolved_base_url, - headers=http_headers, - ) - self._async_http = AsyncHttpClient( + self._http = HttpClient( base_url=resolved_base_url, headers=http_headers, ) @@ -199,16 +195,13 @@ async def __aexit__(self, *args: object) -> None: def close(self) -> None: """Close the sync HTTP client connection.""" - self._sync_http.close() + self._http.close() async def close_async(self) -> None: """Close the async HTTP client connection.""" - await self._async_http.close() - - # ------------------------------------------------------------------------- - # Synchronous Methods (default) - # ------------------------------------------------------------------------- + await self._http.close_async() + # Synchronous methods def search_library(self, query: str) -> SearchLibraryResponse: """ Search for libraries by name or description. @@ -226,7 +219,7 @@ def search_library(self, query: str) -> SearchLibraryResponse: print(f"{lib.id}: {lib.title} ({lib.total_tokens} tokens)") ``` """ - result, _ = self._sync_http.request( + result, _ = self._http.request( method="GET", path=["v2", "search"], query={"query": query}, @@ -345,17 +338,14 @@ def get_docs( path, query = _build_docs_request( library_id, version, page, topic, limit, mode, format ) - result, headers = self._sync_http.request( + result, headers = self._http.request( method="GET", path=path, query=query, ) return _process_docs_response(result, headers, mode, format) - # ------------------------------------------------------------------------- - # Asynchronous Methods (_async suffix) - # ------------------------------------------------------------------------- - + # Asynchronous methods async def search_library_async(self, query: str) -> SearchLibraryResponse: """ Search for libraries by name or description (async version). @@ -373,7 +363,7 @@ async def search_library_async(self, query: str) -> SearchLibraryResponse: print(f"{lib.id}: {lib.title} ({lib.total_tokens} tokens)") ``` """ - result, _ = await self._async_http.request( + result, _ = await self._http.request_async( method="GET", path=["v2", "search"], query={"query": query}, @@ -492,7 +482,7 @@ async def get_docs_async( path, query = _build_docs_request( library_id, version, page, topic, limit, mode, format ) - result, headers = await self._async_http.request( + result, headers = await self._http.request_async( method="GET", path=path, query=query, diff --git a/packages/py/sdk/src/context7/http.py b/packages/py/sdk/src/context7/http.py index ca4f89bfa..a62ffad81 100644 --- a/packages/py/sdk/src/context7/http.py +++ b/packages/py/sdk/src/context7/http.py @@ -24,8 +24,8 @@ class TxtResponseHeaders(BaseModel): total_tokens: int -class SyncHttpClient: - """Synchronous HTTP client with retry logic for Context7 API.""" +class HttpClient: + """HTTP client with retry logic for Context7 API (sync and async).""" def __init__( self, @@ -47,30 +47,43 @@ def __init__( self.headers = {"Content-Type": "application/json", **(headers or {})} self.retries = retries self.timeout = timeout - self._client: httpx.Client | None = None + self._sync_client: httpx.Client | None = None + self._async_client: httpx.AsyncClient | None = None - def _get_client(self) -> httpx.Client: + def _get_sync_client(self) -> httpx.Client: """Get or create the sync HTTP client.""" - if self._client is None or self._client.is_closed: - self._client = httpx.Client( + if self._sync_client is None or self._sync_client.is_closed: + self._sync_client = httpx.Client( base_url=self.base_url, headers=self.headers, timeout=self.timeout, ) - return self._client + return self._sync_client + + async def _get_async_client(self) -> httpx.AsyncClient: + """Get or create the async HTTP client.""" + if self._async_client is None or self._async_client.is_closed: + self._async_client = httpx.AsyncClient( + base_url=self.base_url, + headers=self.headers, + timeout=self.timeout, + ) + return self._async_client def close(self) -> None: - """Close the HTTP client connection.""" - if self._client is not None: - self._client.close() - self._client = None + """Close the sync HTTP client connection.""" + if self._sync_client is not None: + self._sync_client.close() + self._sync_client = None - def _backoff(self, retry_count: int) -> float: - """ - Calculate exponential backoff delay. + async def close_async(self) -> None: + """Close the async HTTP client connection.""" + if self._async_client is not None: + await self._async_client.aclose() + self._async_client = None - Matches the TypeScript SDK: Math.exp(retryCount) * 50ms - """ + def _backoff(self, retry_count: int) -> float: + """Calculate exponential backoff delay (matches TS SDK).""" return math.exp(retry_count) * 0.05 def request( @@ -80,28 +93,10 @@ def request( query: dict[str, Any] | None = None, body: dict[str, Any] | None = None, ) -> tuple[Any, TxtResponseHeaders | None]: - """ - Make an HTTP request with retry logic. - - Args: - method: HTTP method (GET, POST, etc.). - path: URL path segments to append to base URL. - query: Query parameters for the request. - body: JSON body for POST requests. - - Returns: - Tuple of (response_data, headers) where headers is only present - for text responses. - - Raises: - Context7APIError: If the API returns an error response. - httpx.RequestError: If all retries are exhausted. - """ + """Make a synchronous HTTP request with retry logic.""" url = "/".join([self.base_url, *(path or [])]) - params = {k: v for k, v in (query or {}).items() if v is not None} - - client = self._get_client() + client = self._get_sync_client() last_error: Exception | None = None for attempt in range(self.retries + 1): @@ -112,27 +107,7 @@ def request( params=params if method == "GET" else None, json=body if method == "POST" else None, ) - - if not response.is_success: - try: - error_body = response.json() - message = ( - error_body.get("error") - or error_body.get("message") - or response.reason_phrase - ) - except (ValueError, KeyError, TypeError): - message = response.reason_phrase or "Unknown error" - raise Context7APIError(message, status_code=response.status_code) - - content_type = response.headers.get("content-type", "") - - if "application/json" in content_type: - return response.json(), None - else: - headers = _extract_txt_headers(response.headers) - return response.text, headers - + return self._handle_response(response) except httpx.RequestError as e: last_error = e if attempt < self.retries: @@ -141,85 +116,17 @@ def request( raise last_error or Context7APIError("Exhausted all retries") - -class AsyncHttpClient: - """Asynchronous HTTP client with retry logic for Context7 API.""" - - def __init__( - self, - base_url: str, - headers: dict[str, str] | None = None, - retries: int = 5, - timeout: float = 30.0, - ) -> None: - """ - Initialize the HTTP client. - - Args: - base_url: Base URL for the API. - headers: Optional headers to include in all requests. - retries: Number of retry attempts for failed requests. - timeout: Request timeout in seconds. - """ - self.base_url = base_url.rstrip("/") - self.headers = {"Content-Type": "application/json", **(headers or {})} - self.retries = retries - self.timeout = timeout - self._client: httpx.AsyncClient | None = None - - async def _get_client(self) -> httpx.AsyncClient: - """Get or create the async HTTP client.""" - if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient( - base_url=self.base_url, - headers=self.headers, - timeout=self.timeout, - ) - return self._client - - async def close(self) -> None: - """Close the HTTP client connection.""" - if self._client is not None: - await self._client.aclose() - self._client = None - - def _backoff(self, retry_count: int) -> float: - """ - Calculate exponential backoff delay. - - Matches the TypeScript SDK: Math.exp(retryCount) * 50ms - """ - return math.exp(retry_count) * 0.05 - - async def request( + async def request_async( self, method: str, path: list[str] | None = None, query: dict[str, Any] | None = None, body: dict[str, Any] | None = None, ) -> tuple[Any, TxtResponseHeaders | None]: - """ - Make an HTTP request with retry logic. - - Args: - method: HTTP method (GET, POST, etc.). - path: URL path segments to append to base URL. - query: Query parameters for the request. - body: JSON body for POST requests. - - Returns: - Tuple of (response_data, headers) where headers is only present - for text responses. - - Raises: - Context7APIError: If the API returns an error response. - httpx.RequestError: If all retries are exhausted. - """ + """Make an asynchronous HTTP request with retry logic.""" url = "/".join([self.base_url, *(path or [])]) - params = {k: v for k, v in (query or {}).items() if v is not None} - - client = await self._get_client() + client = await self._get_async_client() last_error: Exception | None = None for attempt in range(self.retries + 1): @@ -230,27 +137,7 @@ async def request( params=params if method == "GET" else None, json=body if method == "POST" else None, ) - - if not response.is_success: - try: - error_body = response.json() - message = ( - error_body.get("error") - or error_body.get("message") - or response.reason_phrase - ) - except (ValueError, KeyError, TypeError): - message = response.reason_phrase or "Unknown error" - raise Context7APIError(message, status_code=response.status_code) - - content_type = response.headers.get("content-type", "") - - if "application/json" in content_type: - return response.json(), None - else: - headers = _extract_txt_headers(response.headers) - return response.text, headers - + return self._handle_response(response) except httpx.RequestError as e: last_error = e if attempt < self.retries: @@ -259,25 +146,40 @@ async def request( raise last_error or Context7APIError("Exhausted all retries") - -def _extract_txt_headers(headers: httpx.Headers) -> TxtResponseHeaders | None: - """ - Extract pagination headers from text response. - - Args: - headers: Response headers from the HTTP response. - - Returns: - TxtResponseHeaders if all required headers are present, None otherwise. - """ - try: - return TxtResponseHeaders( - page=int(headers.get("x-context7-page", 0)), - limit=int(headers.get("x-context7-limit", 0)), - total_pages=int(headers.get("x-context7-total-pages", 0)), - has_next=headers.get("x-context7-has-next", "false").lower() == "true", - has_prev=headers.get("x-context7-has-prev", "false").lower() == "true", - total_tokens=int(headers.get("x-context7-total-tokens", 0)), - ) - except (ValueError, TypeError): - return None + def _handle_response( + self, response: httpx.Response + ) -> tuple[Any, TxtResponseHeaders | None]: + """Handle HTTP response (shared between sync and async).""" + if not response.is_success: + try: + error_body = response.json() + message = ( + error_body.get("error") + or error_body.get("message") + or response.reason_phrase + ) + except (ValueError, KeyError, TypeError): + message = response.reason_phrase or "Unknown error" + raise Context7APIError(message, status_code=response.status_code) + + content_type = response.headers.get("content-type", "") + + if "application/json" in content_type: + return response.json(), None + else: + headers = self._extract_txt_headers(response.headers) + return response.text, headers + + def _extract_txt_headers(self, headers: httpx.Headers) -> TxtResponseHeaders | None: + """Extract pagination headers from text response.""" + try: + return TxtResponseHeaders( + page=int(headers.get("x-context7-page", 0)), + limit=int(headers.get("x-context7-limit", 0)), + total_pages=int(headers.get("x-context7-total-pages", 0)), + has_next=headers.get("x-context7-has-next", "false").lower() == "true", + has_prev=headers.get("x-context7-has-prev", "false").lower() == "true", + total_tokens=int(headers.get("x-context7-total-tokens", 0)), + ) + except (ValueError, TypeError): + return None diff --git a/packages/py/sdk/tests/test_client.py b/packages/py/sdk/tests/test_client.py index e522fa4a7..eb42a9f05 100644 --- a/packages/py/sdk/tests/test_client.py +++ b/packages/py/sdk/tests/test_client.py @@ -4,10 +4,18 @@ import pytest from context7 import Context7, Context7Error, Context7ValidationError - - -@pytest.fixture -def api_key() -> str: +from context7.models import ( + CodeDocsResponse, + CodeSnippet, + InfoDocsResponse, + InfoSnippet, + SearchResult, + TextDocsResponse, +) + + +@pytest.fixture(name="api_key") +def api_key_fixture() -> str: """Get API key from environment or skip test.""" key = os.environ.get("CONTEXT7_API_KEY") if not key: @@ -22,6 +30,7 @@ def test_init_with_api_key(self, api_key: str) -> None: """Test client initialization with explicit API key.""" client = Context7(api_key=api_key) assert client is not None + assert client._http is not None def test_init_without_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that missing API key raises an error.""" @@ -34,13 +43,23 @@ def test_init_from_env(self, api_key: str, monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.setenv("CONTEXT7_API_KEY", api_key) client = Context7() assert client is not None + assert client._http is not None - def test_init_with_invalid_prefix_warns(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_init_with_invalid_prefix_warns( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """Test that invalid API key prefix produces a warning.""" monkeypatch.delenv("CONTEXT7_API_KEY", raising=False) with pytest.warns(UserWarning, match="API key should start with"): Context7(api_key="invalid_key") + def test_init_with_custom_base_url(self, api_key: str) -> None: + """Test client initialization with custom base URL.""" + custom_url = "https://custom.context7.com/api" + client = Context7(api_key=api_key, base_url=custom_url) + assert client is not None + assert client._http.base_url == custom_url + class TestSearchLibrarySync: """Tests for the synchronous search_library method.""" @@ -52,16 +71,41 @@ def test_search_library(self, api_key: str) -> None: assert response.results is not None assert len(response.results) > 0 assert response.metadata is not None + assert response.metadata.authentication is not None def test_search_library_result_fields(self, api_key: str) -> None: """Test that search results have expected fields (sync).""" with Context7(api_key=api_key) as client: response = client.search_library("react") - if response.results: - result = response.results[0] - assert result.id is not None - assert result.title is not None - assert result.total_tokens >= 0 + assert len(response.results) > 0 + result = response.results[0] + assert isinstance(result, SearchResult) + assert result.id is not None + assert isinstance(result.id, str) + assert result.title is not None + assert isinstance(result.title, str) + assert result.description is not None + assert result.branch is not None + assert result.state is not None + assert result.total_tokens >= 0 + assert result.total_snippets >= 0 + + def test_search_library_different_queries(self, api_key: str) -> None: + """Test search with different queries returns different results.""" + with Context7(api_key=api_key) as client: + react_results = client.search_library("react") + vue_results = client.search_library("vue") + assert react_results.results[0].id != vue_results.results[0].id + + def test_search_library_specific_query(self, api_key: str) -> None: + """Test search with specific query returns relevant results.""" + with Context7(api_key=api_key) as client: + response = client.search_library("nextjs") + assert response.results is not None + assert len(response.results) > 0 + # Results should contain nextjs-related libraries + titles = [r.title.lower() for r in response.results] + assert any("next" in t for t in titles) class TestSearchLibraryAsync: @@ -75,17 +119,25 @@ async def test_search_library_async(self, api_key: str) -> None: assert response.results is not None assert len(response.results) > 0 assert response.metadata is not None + assert response.metadata.authentication is not None @pytest.mark.asyncio async def test_search_library_async_result_fields(self, api_key: str) -> None: """Test that search results have expected fields (async).""" async with Context7(api_key=api_key) as client: response = await client.search_library_async("react") - if response.results: - result = response.results[0] - assert result.id is not None - assert result.title is not None - assert result.total_tokens >= 0 + assert len(response.results) > 0 + result = response.results[0] + assert isinstance(result, SearchResult) + assert result.id is not None + assert isinstance(result.id, str) + assert result.title is not None + assert isinstance(result.title, str) + assert result.description is not None + assert result.branch is not None + assert result.state is not None + assert result.total_tokens >= 0 + assert result.total_snippets >= 0 class TestGetDocsSync: @@ -95,24 +147,60 @@ def test_get_docs_code_default(self, api_key: str) -> None: """Test getting code documentation (default mode, sync).""" with Context7(api_key=api_key) as client: response = client.get_docs("/facebook/react") + assert isinstance(response, CodeDocsResponse) assert response.snippets is not None + assert len(response.snippets) > 0 assert response.pagination is not None + assert response.pagination.page >= 1 assert response.total_tokens >= 0 + def test_get_docs_code_snippet_fields(self, api_key: str) -> None: + """Test that code snippets have all expected fields.""" + with Context7(api_key=api_key) as client: + response = client.get_docs("/facebook/react") + assert len(response.snippets) > 0 + snippet = response.snippets[0] + assert isinstance(snippet, CodeSnippet) + assert snippet.code_title is not None + assert snippet.code_description is not None + assert snippet.code_language is not None + assert snippet.code_tokens >= 0 + assert snippet.code_id is not None + assert snippet.page_title is not None + assert snippet.code_list is not None + assert len(snippet.code_list) > 0 + def test_get_docs_info_mode(self, api_key: str) -> None: """Test getting info documentation (sync).""" with Context7(api_key=api_key) as client: response = client.get_docs("/facebook/react", mode="info") + assert isinstance(response, InfoDocsResponse) assert response.snippets is not None + assert len(response.snippets) > 0 assert response.pagination is not None + assert response.total_tokens >= 0 + + def test_get_docs_info_snippet_fields(self, api_key: str) -> None: + """Test that info snippets have all expected fields.""" + with Context7(api_key=api_key) as client: + response = client.get_docs("/facebook/react", mode="info") + assert len(response.snippets) > 0 + snippet = response.snippets[0] + assert isinstance(snippet, InfoSnippet) + assert snippet.content is not None + assert isinstance(snippet.content, str) + assert snippet.content_tokens >= 0 def test_get_docs_txt_format(self, api_key: str) -> None: """Test getting plain text documentation (sync).""" with Context7(api_key=api_key) as client: response = client.get_docs("/facebook/react", format="txt") + assert isinstance(response, TextDocsResponse) assert response.content is not None assert isinstance(response.content, str) assert len(response.content) > 0 + assert response.pagination is not None + assert response.total_tokens >= 0 def test_get_docs_with_pagination(self, api_key: str) -> None: """Test documentation pagination (sync).""" @@ -121,6 +209,27 @@ def test_get_docs_with_pagination(self, api_key: str) -> None: assert response.pagination is not None assert response.pagination.page == 1 assert response.pagination.limit == 5 + assert isinstance(response.pagination.has_next, bool) + assert isinstance(response.pagination.has_prev, bool) + assert response.pagination.total_pages >= 1 + + def test_get_docs_pagination_page_2(self, api_key: str) -> None: + """Test fetching second page of documentation.""" + with Context7(api_key=api_key) as client: + page1 = client.get_docs("/facebook/react", page=1, limit=3) + page2 = client.get_docs("/facebook/react", page=2, limit=3) + assert page1.pagination.page == 1 + assert page2.pagination.page == 2 + # Content should be different between pages + if page1.snippets and page2.snippets: + assert page1.snippets[0].code_id != page2.snippets[0].code_id + + def test_get_docs_with_topic(self, api_key: str) -> None: + """Test filtering documentation by topic.""" + with Context7(api_key=api_key) as client: + response = client.get_docs("/facebook/react", topic="hooks") + assert response.snippets is not None + assert response.pagination is not None def test_get_docs_invalid_library_id(self, api_key: str) -> None: """Test that invalid library ID raises validation error (sync).""" @@ -131,9 +240,15 @@ def test_get_docs_invalid_library_id(self, api_key: str) -> None: def test_get_docs_invalid_library_id_no_slash(self, api_key: str) -> None: """Test that library ID without leading slash raises error (sync).""" with Context7(api_key=api_key) as client: - with pytest.raises(Context7ValidationError): + with pytest.raises(Context7ValidationError, match="Expected format"): client.get_docs("facebook/react") + def test_get_docs_invalid_library_id_single_segment(self, api_key: str) -> None: + """Test that single segment library ID raises error.""" + with Context7(api_key=api_key) as client: + with pytest.raises(Context7ValidationError): + client.get_docs("/react") + class TestGetDocsAsync: """Tests for the asynchronous get_docs_async method.""" @@ -143,26 +258,49 @@ async def test_get_docs_async_code_default(self, api_key: str) -> None: """Test getting code documentation (default mode, async).""" async with Context7(api_key=api_key) as client: response = await client.get_docs_async("/facebook/react") + assert isinstance(response, CodeDocsResponse) assert response.snippets is not None + assert len(response.snippets) > 0 assert response.pagination is not None + assert response.pagination.page >= 1 assert response.total_tokens >= 0 + @pytest.mark.asyncio + async def test_get_docs_async_code_snippet_fields(self, api_key: str) -> None: + """Test that code snippets have all expected fields (async).""" + async with Context7(api_key=api_key) as client: + response = await client.get_docs_async("/facebook/react") + assert len(response.snippets) > 0 + snippet = response.snippets[0] + assert isinstance(snippet, CodeSnippet) + assert snippet.code_title is not None + assert snippet.code_description is not None + assert snippet.code_language is not None + assert snippet.code_tokens >= 0 + assert snippet.code_id is not None + @pytest.mark.asyncio async def test_get_docs_async_info_mode(self, api_key: str) -> None: """Test getting info documentation (async).""" async with Context7(api_key=api_key) as client: response = await client.get_docs_async("/facebook/react", mode="info") + assert isinstance(response, InfoDocsResponse) assert response.snippets is not None + assert len(response.snippets) > 0 assert response.pagination is not None + assert response.total_tokens >= 0 @pytest.mark.asyncio async def test_get_docs_async_txt_format(self, api_key: str) -> None: """Test getting plain text documentation (async).""" async with Context7(api_key=api_key) as client: response = await client.get_docs_async("/facebook/react", format="txt") + assert isinstance(response, TextDocsResponse) assert response.content is not None assert isinstance(response.content, str) assert len(response.content) > 0 + assert response.pagination is not None + assert response.total_tokens >= 0 @pytest.mark.asyncio async def test_get_docs_async_with_pagination(self, api_key: str) -> None: @@ -172,6 +310,9 @@ async def test_get_docs_async_with_pagination(self, api_key: str) -> None: assert response.pagination is not None assert response.pagination.page == 1 assert response.pagination.limit == 5 + assert isinstance(response.pagination.has_next, bool) + assert isinstance(response.pagination.has_prev, bool) + assert response.pagination.total_pages >= 1 @pytest.mark.asyncio async def test_get_docs_async_invalid_library_id(self, api_key: str) -> None: @@ -181,10 +322,12 @@ async def test_get_docs_async_invalid_library_id(self, api_key: str) -> None: await client.get_docs_async("invalid-id") @pytest.mark.asyncio - async def test_get_docs_async_invalid_library_id_no_slash(self, api_key: str) -> None: + async def test_get_docs_async_invalid_library_id_no_slash( + self, api_key: str + ) -> None: """Test that library ID without leading slash raises error (async).""" async with Context7(api_key=api_key) as client: - with pytest.raises(Context7ValidationError): + with pytest.raises(Context7ValidationError, match="Expected format"): await client.get_docs_async("facebook/react") @@ -194,15 +337,30 @@ class TestSyncContextManager: def test_context_manager(self, api_key: str) -> None: """Test that sync context manager properly opens and closes.""" with Context7(api_key=api_key) as client: + assert client is not None response = client.search_library("react") assert response is not None + assert len(response.results) > 0 def test_manual_close(self, api_key: str) -> None: """Test manual close method (sync).""" client = Context7(api_key=api_key) response = client.search_library("react") assert response is not None + assert len(response.results) > 0 client.close() + # Client should still exist but HTTP client should be closed + assert client._http._sync_client is None + + def test_multiple_requests_same_client(self, api_key: str) -> None: + """Test making multiple requests with same client.""" + with Context7(api_key=api_key) as client: + response1 = client.search_library("react") + response2 = client.search_library("vue") + response3 = client.get_docs("/facebook/react", limit=2) + assert response1.results is not None + assert response2.results is not None + assert response3.snippets is not None class TestAsyncContextManager: @@ -212,8 +370,10 @@ class TestAsyncContextManager: async def test_context_manager_async(self, api_key: str) -> None: """Test that async context manager properly opens and closes.""" async with Context7(api_key=api_key) as client: + assert client is not None response = await client.search_library_async("react") assert response is not None + assert len(response.results) > 0 @pytest.mark.asyncio async def test_manual_close_async(self, api_key: str) -> None: @@ -221,4 +381,49 @@ async def test_manual_close_async(self, api_key: str) -> None: client = Context7(api_key=api_key) response = await client.search_library_async("react") assert response is not None + assert len(response.results) > 0 await client.close_async() + # Client should still exist but HTTP client should be closed + assert client._http._async_client is None + + @pytest.mark.asyncio + async def test_multiple_requests_same_client_async(self, api_key: str) -> None: + """Test making multiple async requests with same client.""" + async with Context7(api_key=api_key) as client: + response1 = await client.search_library_async("react") + response2 = await client.search_library_async("vue") + response3 = await client.get_docs_async("/facebook/react", limit=2) + assert response1.results is not None + assert response2.results is not None + assert response3.snippets is not None + + +class TestLibraryIdValidation: + """Tests for library ID validation edge cases.""" + + def test_valid_library_id_formats(self, api_key: str) -> None: + """Test various valid library ID formats.""" + with Context7(api_key=api_key) as client: + # Standard format + response = client.get_docs("/facebook/react", limit=1) + assert response is not None + + def test_library_id_with_nested_path(self, api_key: str) -> None: + """Test library ID with nested repo path.""" + with Context7(api_key=api_key) as client: + # Some repos have nested paths like /org/repo/subpath + # The SDK should handle this correctly + response = client.get_docs("/vercel/next.js", limit=1) + assert response is not None + + def test_invalid_library_id_formats(self, api_key: str) -> None: + """Test various invalid library ID formats.""" + with Context7(api_key=api_key) as client: + invalid_ids = [ + "no-slash", + "single/segment", + "/only-owner", + ] + for invalid_id in invalid_ids: + with pytest.raises(Context7ValidationError): + client.get_docs(invalid_id) From 124b6124deeaa6449848fa3e22c365c208d2c16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 17:23:07 +0300 Subject: [PATCH 05/22] fmt --- packages/py/sdk/tests/test_client.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/py/sdk/tests/test_client.py b/packages/py/sdk/tests/test_client.py index eb42a9f05..cfb8835ad 100644 --- a/packages/py/sdk/tests/test_client.py +++ b/packages/py/sdk/tests/test_client.py @@ -233,21 +233,24 @@ def test_get_docs_with_topic(self, api_key: str) -> None: def test_get_docs_invalid_library_id(self, api_key: str) -> None: """Test that invalid library ID raises validation error (sync).""" - with Context7(api_key=api_key) as client: - with pytest.raises(Context7ValidationError, match="Invalid library ID"): - client.get_docs("invalid-id") + with ( + Context7(api_key=api_key) as client, + pytest.raises(Context7ValidationError, match="Invalid library ID"), + ): + client.get_docs("invalid-id") def test_get_docs_invalid_library_id_no_slash(self, api_key: str) -> None: """Test that library ID without leading slash raises error (sync).""" - with Context7(api_key=api_key) as client: - with pytest.raises(Context7ValidationError, match="Expected format"): - client.get_docs("facebook/react") + with ( + Context7(api_key=api_key) as client, + pytest.raises(Context7ValidationError, match="Expected format"), + ): + client.get_docs("facebook/react") def test_get_docs_invalid_library_id_single_segment(self, api_key: str) -> None: """Test that single segment library ID raises error.""" - with Context7(api_key=api_key) as client: - with pytest.raises(Context7ValidationError): - client.get_docs("/react") + with Context7(api_key=api_key) as client, pytest.raises(Context7ValidationError): + client.get_docs("/react") class TestGetDocsAsync: From da7a086a6bcbe513f08a50f17c1eb91710ffe405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 17:24:18 +0300 Subject: [PATCH 06/22] fmt --- packages/py/sdk/src/context7/client.py | 8 ++------ packages/py/sdk/src/context7/http.py | 8 ++------ packages/py/sdk/tests/test_client.py | 8 ++------ 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/py/sdk/src/context7/client.py b/packages/py/sdk/src/context7/client.py index 6f89bd551..b8a9cd2c7 100644 --- a/packages/py/sdk/src/context7/client.py +++ b/packages/py/sdk/src/context7/client.py @@ -335,9 +335,7 @@ def get_docs( docs = client.get_docs("/facebook/react", version="18.0.0") ``` """ - path, query = _build_docs_request( - library_id, version, page, topic, limit, mode, format - ) + path, query = _build_docs_request(library_id, version, page, topic, limit, mode, format) result, headers = self._http.request( method="GET", path=path, @@ -479,9 +477,7 @@ async def get_docs_async( docs = await client.get_docs_async("/facebook/react", version="18.0.0") ``` """ - path, query = _build_docs_request( - library_id, version, page, topic, limit, mode, format - ) + path, query = _build_docs_request(library_id, version, page, topic, limit, mode, format) result, headers = await self._http.request_async( method="GET", path=path, diff --git a/packages/py/sdk/src/context7/http.py b/packages/py/sdk/src/context7/http.py index a62ffad81..d7f298881 100644 --- a/packages/py/sdk/src/context7/http.py +++ b/packages/py/sdk/src/context7/http.py @@ -146,17 +146,13 @@ async def request_async( raise last_error or Context7APIError("Exhausted all retries") - def _handle_response( - self, response: httpx.Response - ) -> tuple[Any, TxtResponseHeaders | None]: + def _handle_response(self, response: httpx.Response) -> tuple[Any, TxtResponseHeaders | None]: """Handle HTTP response (shared between sync and async).""" if not response.is_success: try: error_body = response.json() message = ( - error_body.get("error") - or error_body.get("message") - or response.reason_phrase + error_body.get("error") or error_body.get("message") or response.reason_phrase ) except (ValueError, KeyError, TypeError): message = response.reason_phrase or "Unknown error" diff --git a/packages/py/sdk/tests/test_client.py b/packages/py/sdk/tests/test_client.py index cfb8835ad..e1667eaa8 100644 --- a/packages/py/sdk/tests/test_client.py +++ b/packages/py/sdk/tests/test_client.py @@ -45,9 +45,7 @@ def test_init_from_env(self, api_key: str, monkeypatch: pytest.MonkeyPatch) -> N assert client is not None assert client._http is not None - def test_init_with_invalid_prefix_warns( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_init_with_invalid_prefix_warns(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that invalid API key prefix produces a warning.""" monkeypatch.delenv("CONTEXT7_API_KEY", raising=False) with pytest.warns(UserWarning, match="API key should start with"): @@ -325,9 +323,7 @@ async def test_get_docs_async_invalid_library_id(self, api_key: str) -> None: await client.get_docs_async("invalid-id") @pytest.mark.asyncio - async def test_get_docs_async_invalid_library_id_no_slash( - self, api_key: str - ) -> None: + async def test_get_docs_async_invalid_library_id_no_slash(self, api_key: str) -> None: """Test that library ID without leading slash raises error (async).""" async with Context7(api_key=api_key) as client: with pytest.raises(Context7ValidationError, match="Expected format"): From d930d814dd9afb268db589b3d9d8c9658ee1f228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 17:36:33 +0300 Subject: [PATCH 07/22] fix client tests types --- packages/py/sdk/src/context7/client.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/py/sdk/src/context7/client.py b/packages/py/sdk/src/context7/client.py index b8a9cd2c7..8346258cc 100644 --- a/packages/py/sdk/src/context7/client.py +++ b/packages/py/sdk/src/context7/client.py @@ -4,10 +4,10 @@ import os import warnings -from typing import Literal, overload +from typing import Any, Literal, overload from context7.errors import Context7Error, Context7ValidationError -from context7.http import HttpClient +from context7.http import HttpClient, TxtResponseHeaders from context7.models import ( CodeDocsResponse, InfoDocsResponse, @@ -79,8 +79,8 @@ def _build_docs_request( def _process_docs_response( - result: str | dict, - headers: object | None, + result: str | dict[str, Any], + headers: TxtResponseHeaders | None, mode: Literal["info", "code"], format: Literal["json", "txt"], # pylint: disable=redefined-builtin ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: @@ -93,8 +93,10 @@ def _process_docs_response( hasNext=headers.has_next if headers else False, hasPrev=headers.has_prev if headers else False, ) + # When format is "txt", result is always a string from the HTTP response + content = result if isinstance(result, str) else "" return TextDocsResponse( - content=result, + content=content, pagination=pagination, totalTokens=headers.total_tokens if headers else 0, ) From 1260d08c9f98add58738b93cd791908aac1a09fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 17:43:33 +0300 Subject: [PATCH 08/22] docs: update import package name --- packages/py/sdk/README.md | 2 +- packages/py/sdk/src/context7/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/py/sdk/README.md b/packages/py/sdk/README.md index eed1a9d3a..516b9df39 100644 --- a/packages/py/sdk/README.md +++ b/packages/py/sdk/README.md @@ -18,7 +18,7 @@ uv add context7 ```python import asyncio -from context7 import Context7 +from context7-sdk import Context7 async def main(): async with Context7(api_key="ctx7sk_...") as client: diff --git a/packages/py/sdk/src/context7/__init__.py b/packages/py/sdk/src/context7/__init__.py index 9fa290469..4ff22e779 100644 --- a/packages/py/sdk/src/context7/__init__.py +++ b/packages/py/sdk/src/context7/__init__.py @@ -7,7 +7,7 @@ Example: ```python import asyncio - from context7 import Context7 + from context7-sdk import Context7 async def main(): async with Context7(api_key="ctx7sk_...") as client: From 10c1baf53994660bf4112ed4eed445b793113c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 8 Dec 2025 17:49:08 +0300 Subject: [PATCH 09/22] docs: update import package name --- packages/py/sdk/src/context7/client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/py/sdk/src/context7/client.py b/packages/py/sdk/src/context7/client.py index 8346258cc..010959ea5 100644 --- a/packages/py/sdk/src/context7/client.py +++ b/packages/py/sdk/src/context7/client.py @@ -115,7 +115,7 @@ class Context7: Synchronous Usage: ```python - from context7 import Context7 + from context7-sdk import Context7 # Initialize with API key (or set CONTEXT7_API_KEY env var) with Context7(api_key="ctx7sk_...") as client: @@ -133,7 +133,7 @@ class Context7: Asynchronous Usage: ```python import asyncio - from context7 import Context7 + from context7-sdk import Context7 async def main(): async with Context7(api_key="ctx7sk_...") as client: @@ -337,7 +337,9 @@ def get_docs( docs = client.get_docs("/facebook/react", version="18.0.0") ``` """ - path, query = _build_docs_request(library_id, version, page, topic, limit, mode, format) + path, query = _build_docs_request( + library_id, version, page, topic, limit, mode, format + ) result, headers = self._http.request( method="GET", path=path, @@ -479,7 +481,9 @@ async def get_docs_async( docs = await client.get_docs_async("/facebook/react", version="18.0.0") ``` """ - path, query = _build_docs_request(library_id, version, page, topic, limit, mode, format) + path, query = _build_docs_request( + library_id, version, page, topic, limit, mode, format + ) result, headers = await self._http.request_async( method="GET", path=path, From 2f3db99dd176eb5df4066a25f5e780eca88a3753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 6 Jan 2026 16:06:38 +0300 Subject: [PATCH 10/22] refactor: restructure packages directory layout - Move packages/ts/mcp to packages/mcp - Move packages/ts/sdk to packages/sdk - Rename packages/py to packages/python - Update workflow files with new paths - Update pnpm-workspace.yaml and package.json --- .github/workflows/ecr-deploy.yml | 4 ++-- .github/workflows/publish-mcp.yml | 2 +- .github/workflows/release.yml | 4 ++-- package.json | 17 +++++++++-------- packages/{ts => }/mcp/.prettierignore | 0 packages/{ts => }/mcp/CHANGELOG.md | 0 packages/{ts => }/mcp/Dockerfile | 0 packages/{ts => }/mcp/LICENSE | 0 packages/{ts => }/mcp/README.md | 0 packages/{ts => }/mcp/eslint.config.js | 0 packages/{ts => }/mcp/mcpb/.mcpbignore | 0 packages/{ts => }/mcp/mcpb/context7.mcpb | Bin packages/{ts => }/mcp/mcpb/manifest.json | 0 packages/{ts => }/mcp/package.json | 0 packages/{ts => }/mcp/prettier.config.mjs | 0 packages/{ts => }/mcp/schema/context7.json | 0 packages/{ts => }/mcp/smithery.yaml | 0 packages/{ts => }/mcp/src/index.ts | 0 packages/{ts => }/mcp/src/lib/api.ts | 0 packages/{ts => }/mcp/src/lib/encryption.ts | 0 packages/{ts => }/mcp/src/lib/types.ts | 0 packages/{ts => }/mcp/src/lib/utils.ts | 0 packages/{ts => }/mcp/tsconfig.json | 0 packages/{py => python}/.python-version | 0 packages/{py => python}/pyproject.toml | 0 packages/{py => python}/sdk/README.md | 0 packages/{py => python}/sdk/pyproject.toml | 0 .../sdk/src/context7/__init__.py | 0 .../{py => python}/sdk/src/context7/client.py | 0 .../{py => python}/sdk/src/context7/errors.py | 0 .../{py => python}/sdk/src/context7/http.py | 0 .../{py => python}/sdk/src/context7/models.py | 0 .../{py => python}/sdk/src/context7/py.typed | 0 packages/{py => python}/sdk/tests/__init__.py | 0 packages/{py => python}/sdk/tests/conftest.py | 0 .../{py => python}/sdk/tests/test_client.py | 0 packages/{ts => }/sdk/CHANGELOG.md | 0 packages/{ts => }/sdk/LICENSE | 0 packages/{ts => }/sdk/README.md | 0 packages/{ts => }/sdk/eslint.config.js | 0 packages/{ts => }/sdk/package.json | 0 packages/{ts => }/sdk/prettier.config.mjs | 0 packages/{ts => }/sdk/src/client.test.ts | 0 packages/{ts => }/sdk/src/client.ts | 0 packages/{ts => }/sdk/src/commands/command.ts | 0 .../sdk/src/commands/get-docs/index.test.ts | 0 .../sdk/src/commands/get-docs/index.ts | 0 packages/{ts => }/sdk/src/commands/index.ts | 0 .../src/commands/search-library/index.test.ts | 0 .../sdk/src/commands/search-library/index.ts | 0 packages/{ts => }/sdk/src/commands/types.ts | 0 packages/{ts => }/sdk/src/error/index.ts | 0 packages/{ts => }/sdk/src/http/index.ts | 0 packages/{ts => }/sdk/src/utils/test-utils.ts | 0 packages/{ts => }/sdk/tsconfig.json | 0 packages/{ts => }/sdk/tsup.config.ts | 0 packages/{ts => }/sdk/vitest.config.ts | 0 pnpm-workspace.yaml | 3 ++- 58 files changed, 16 insertions(+), 14 deletions(-) rename packages/{ts => }/mcp/.prettierignore (100%) rename packages/{ts => }/mcp/CHANGELOG.md (100%) rename packages/{ts => }/mcp/Dockerfile (100%) rename packages/{ts => }/mcp/LICENSE (100%) rename packages/{ts => }/mcp/README.md (100%) rename packages/{ts => }/mcp/eslint.config.js (100%) rename packages/{ts => }/mcp/mcpb/.mcpbignore (100%) rename packages/{ts => }/mcp/mcpb/context7.mcpb (100%) rename packages/{ts => }/mcp/mcpb/manifest.json (100%) rename packages/{ts => }/mcp/package.json (100%) rename packages/{ts => }/mcp/prettier.config.mjs (100%) rename packages/{ts => }/mcp/schema/context7.json (100%) rename packages/{ts => }/mcp/smithery.yaml (100%) rename packages/{ts => }/mcp/src/index.ts (100%) rename packages/{ts => }/mcp/src/lib/api.ts (100%) rename packages/{ts => }/mcp/src/lib/encryption.ts (100%) rename packages/{ts => }/mcp/src/lib/types.ts (100%) rename packages/{ts => }/mcp/src/lib/utils.ts (100%) rename packages/{ts => }/mcp/tsconfig.json (100%) rename packages/{py => python}/.python-version (100%) rename packages/{py => python}/pyproject.toml (100%) rename packages/{py => python}/sdk/README.md (100%) rename packages/{py => python}/sdk/pyproject.toml (100%) rename packages/{py => python}/sdk/src/context7/__init__.py (100%) rename packages/{py => python}/sdk/src/context7/client.py (100%) rename packages/{py => python}/sdk/src/context7/errors.py (100%) rename packages/{py => python}/sdk/src/context7/http.py (100%) rename packages/{py => python}/sdk/src/context7/models.py (100%) rename packages/{py => python}/sdk/src/context7/py.typed (100%) rename packages/{py => python}/sdk/tests/__init__.py (100%) rename packages/{py => python}/sdk/tests/conftest.py (100%) rename packages/{py => python}/sdk/tests/test_client.py (100%) rename packages/{ts => }/sdk/CHANGELOG.md (100%) rename packages/{ts => }/sdk/LICENSE (100%) rename packages/{ts => }/sdk/README.md (100%) rename packages/{ts => }/sdk/eslint.config.js (100%) rename packages/{ts => }/sdk/package.json (100%) rename packages/{ts => }/sdk/prettier.config.mjs (100%) rename packages/{ts => }/sdk/src/client.test.ts (100%) rename packages/{ts => }/sdk/src/client.ts (100%) rename packages/{ts => }/sdk/src/commands/command.ts (100%) rename packages/{ts => }/sdk/src/commands/get-docs/index.test.ts (100%) rename packages/{ts => }/sdk/src/commands/get-docs/index.ts (100%) rename packages/{ts => }/sdk/src/commands/index.ts (100%) rename packages/{ts => }/sdk/src/commands/search-library/index.test.ts (100%) rename packages/{ts => }/sdk/src/commands/search-library/index.ts (100%) rename packages/{ts => }/sdk/src/commands/types.ts (100%) rename packages/{ts => }/sdk/src/error/index.ts (100%) rename packages/{ts => }/sdk/src/http/index.ts (100%) rename packages/{ts => }/sdk/src/utils/test-utils.ts (100%) rename packages/{ts => }/sdk/tsconfig.json (100%) rename packages/{ts => }/sdk/tsup.config.ts (100%) rename packages/{ts => }/sdk/vitest.config.ts (100%) diff --git a/.github/workflows/ecr-deploy.yml b/.github/workflows/ecr-deploy.yml index be24f26ca..f998b2de7 100644 --- a/.github/workflows/ecr-deploy.yml +++ b/.github/workflows/ecr-deploy.yml @@ -34,8 +34,8 @@ jobs: id: build-push uses: docker/build-push-action@v6 with: - context: packages/ts/mcp - file: packages/ts/mcp/Dockerfile + context: packages/mcp + file: packages/mcp/Dockerfile platforms: linux/amd64 push: true tags: | diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index f88905297..34cd0a946 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -31,7 +31,7 @@ jobs: # Remove 'v' prefix if it exists VERSION="${VERSION#v}" else - VERSION=$(node -p "require('./packages/ts/mcp/package.json').version") + VERSION=$(node -p "require('./packages/mcp/package.json').version") fi echo "VERSION=$VERSION" >> $GITHUB_ENV echo "Publishing version: $VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d6edc15c..760e15f80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,10 +65,10 @@ jobs: if: steps.check-mcp.outputs.mcp_published == 'true' run: | VERSION="${{ steps.check-mcp.outputs.mcp_version }}" - sed -i "s/version: \"[0-9]*\.[0-9]*\.[0-9]*\"/version: \"$VERSION\"/" packages/ts/mcp/src/index.ts + sed -i "s/version: \"[0-9]*\.[0-9]*\.[0-9]*\"/version: \"$VERSION\"/" packages/mcp/src/index.ts git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add packages/ts/mcp/src/index.ts + git add packages/mcp/src/index.ts git commit -m "chore: update MCP version in source to $VERSION" || true git push || true diff --git a/package.json b/package.json index 62a9439c8..2c7e74e8c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "version": "1.0.0", "description": "Context7 monorepo - Documentation tools and SDKs", "workspaces": [ - "packages/ts/*" + "packages/*", + "!packages/python" ], "scripts": { "build": "pnpm -r run build", @@ -19,13 +20,13 @@ "format:check": "pnpm -r run format:check", "release": "pnpm build && changeset publish", "release:snapshot": "changeset version --snapshot canary && pnpm build && changeset publish --tag canary --no-git-tag", - "py:install": "cd packages/py && uv sync --all-packages", - "py:test": "cd packages/py && uv run pytest sdk/tests -v", - "py:lint": "cd packages/py && uv run ruff check .", - "py:format": "cd packages/py && uv run ruff format .", - "py:typecheck": "cd packages/py && uv run mypy sdk/src/context7", - "py:build": "cd packages/py/sdk && uv build", - "py:clean": "rm -rf packages/py/.venv packages/py/uv.lock packages/py/sdk/dist" + "py:install": "cd packages/python && uv sync --all-packages", + "py:test": "cd packages/python && uv run pytest sdk/tests -v", + "py:lint": "cd packages/python && uv run ruff check .", + "py:format": "cd packages/python && uv run ruff format .", + "py:typecheck": "cd packages/python && uv run mypy sdk/src/context7", + "py:build": "cd packages/python/sdk && uv build", + "py:clean": "rm -rf packages/python/.venv packages/python/uv.lock packages/python/sdk/dist" }, "repository": { "type": "git", diff --git a/packages/ts/mcp/.prettierignore b/packages/mcp/.prettierignore similarity index 100% rename from packages/ts/mcp/.prettierignore rename to packages/mcp/.prettierignore diff --git a/packages/ts/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md similarity index 100% rename from packages/ts/mcp/CHANGELOG.md rename to packages/mcp/CHANGELOG.md diff --git a/packages/ts/mcp/Dockerfile b/packages/mcp/Dockerfile similarity index 100% rename from packages/ts/mcp/Dockerfile rename to packages/mcp/Dockerfile diff --git a/packages/ts/mcp/LICENSE b/packages/mcp/LICENSE similarity index 100% rename from packages/ts/mcp/LICENSE rename to packages/mcp/LICENSE diff --git a/packages/ts/mcp/README.md b/packages/mcp/README.md similarity index 100% rename from packages/ts/mcp/README.md rename to packages/mcp/README.md diff --git a/packages/ts/mcp/eslint.config.js b/packages/mcp/eslint.config.js similarity index 100% rename from packages/ts/mcp/eslint.config.js rename to packages/mcp/eslint.config.js diff --git a/packages/ts/mcp/mcpb/.mcpbignore b/packages/mcp/mcpb/.mcpbignore similarity index 100% rename from packages/ts/mcp/mcpb/.mcpbignore rename to packages/mcp/mcpb/.mcpbignore diff --git a/packages/ts/mcp/mcpb/context7.mcpb b/packages/mcp/mcpb/context7.mcpb similarity index 100% rename from packages/ts/mcp/mcpb/context7.mcpb rename to packages/mcp/mcpb/context7.mcpb diff --git a/packages/ts/mcp/mcpb/manifest.json b/packages/mcp/mcpb/manifest.json similarity index 100% rename from packages/ts/mcp/mcpb/manifest.json rename to packages/mcp/mcpb/manifest.json diff --git a/packages/ts/mcp/package.json b/packages/mcp/package.json similarity index 100% rename from packages/ts/mcp/package.json rename to packages/mcp/package.json diff --git a/packages/ts/mcp/prettier.config.mjs b/packages/mcp/prettier.config.mjs similarity index 100% rename from packages/ts/mcp/prettier.config.mjs rename to packages/mcp/prettier.config.mjs diff --git a/packages/ts/mcp/schema/context7.json b/packages/mcp/schema/context7.json similarity index 100% rename from packages/ts/mcp/schema/context7.json rename to packages/mcp/schema/context7.json diff --git a/packages/ts/mcp/smithery.yaml b/packages/mcp/smithery.yaml similarity index 100% rename from packages/ts/mcp/smithery.yaml rename to packages/mcp/smithery.yaml diff --git a/packages/ts/mcp/src/index.ts b/packages/mcp/src/index.ts similarity index 100% rename from packages/ts/mcp/src/index.ts rename to packages/mcp/src/index.ts diff --git a/packages/ts/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts similarity index 100% rename from packages/ts/mcp/src/lib/api.ts rename to packages/mcp/src/lib/api.ts diff --git a/packages/ts/mcp/src/lib/encryption.ts b/packages/mcp/src/lib/encryption.ts similarity index 100% rename from packages/ts/mcp/src/lib/encryption.ts rename to packages/mcp/src/lib/encryption.ts diff --git a/packages/ts/mcp/src/lib/types.ts b/packages/mcp/src/lib/types.ts similarity index 100% rename from packages/ts/mcp/src/lib/types.ts rename to packages/mcp/src/lib/types.ts diff --git a/packages/ts/mcp/src/lib/utils.ts b/packages/mcp/src/lib/utils.ts similarity index 100% rename from packages/ts/mcp/src/lib/utils.ts rename to packages/mcp/src/lib/utils.ts diff --git a/packages/ts/mcp/tsconfig.json b/packages/mcp/tsconfig.json similarity index 100% rename from packages/ts/mcp/tsconfig.json rename to packages/mcp/tsconfig.json diff --git a/packages/py/.python-version b/packages/python/.python-version similarity index 100% rename from packages/py/.python-version rename to packages/python/.python-version diff --git a/packages/py/pyproject.toml b/packages/python/pyproject.toml similarity index 100% rename from packages/py/pyproject.toml rename to packages/python/pyproject.toml diff --git a/packages/py/sdk/README.md b/packages/python/sdk/README.md similarity index 100% rename from packages/py/sdk/README.md rename to packages/python/sdk/README.md diff --git a/packages/py/sdk/pyproject.toml b/packages/python/sdk/pyproject.toml similarity index 100% rename from packages/py/sdk/pyproject.toml rename to packages/python/sdk/pyproject.toml diff --git a/packages/py/sdk/src/context7/__init__.py b/packages/python/sdk/src/context7/__init__.py similarity index 100% rename from packages/py/sdk/src/context7/__init__.py rename to packages/python/sdk/src/context7/__init__.py diff --git a/packages/py/sdk/src/context7/client.py b/packages/python/sdk/src/context7/client.py similarity index 100% rename from packages/py/sdk/src/context7/client.py rename to packages/python/sdk/src/context7/client.py diff --git a/packages/py/sdk/src/context7/errors.py b/packages/python/sdk/src/context7/errors.py similarity index 100% rename from packages/py/sdk/src/context7/errors.py rename to packages/python/sdk/src/context7/errors.py diff --git a/packages/py/sdk/src/context7/http.py b/packages/python/sdk/src/context7/http.py similarity index 100% rename from packages/py/sdk/src/context7/http.py rename to packages/python/sdk/src/context7/http.py diff --git a/packages/py/sdk/src/context7/models.py b/packages/python/sdk/src/context7/models.py similarity index 100% rename from packages/py/sdk/src/context7/models.py rename to packages/python/sdk/src/context7/models.py diff --git a/packages/py/sdk/src/context7/py.typed b/packages/python/sdk/src/context7/py.typed similarity index 100% rename from packages/py/sdk/src/context7/py.typed rename to packages/python/sdk/src/context7/py.typed diff --git a/packages/py/sdk/tests/__init__.py b/packages/python/sdk/tests/__init__.py similarity index 100% rename from packages/py/sdk/tests/__init__.py rename to packages/python/sdk/tests/__init__.py diff --git a/packages/py/sdk/tests/conftest.py b/packages/python/sdk/tests/conftest.py similarity index 100% rename from packages/py/sdk/tests/conftest.py rename to packages/python/sdk/tests/conftest.py diff --git a/packages/py/sdk/tests/test_client.py b/packages/python/sdk/tests/test_client.py similarity index 100% rename from packages/py/sdk/tests/test_client.py rename to packages/python/sdk/tests/test_client.py diff --git a/packages/ts/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md similarity index 100% rename from packages/ts/sdk/CHANGELOG.md rename to packages/sdk/CHANGELOG.md diff --git a/packages/ts/sdk/LICENSE b/packages/sdk/LICENSE similarity index 100% rename from packages/ts/sdk/LICENSE rename to packages/sdk/LICENSE diff --git a/packages/ts/sdk/README.md b/packages/sdk/README.md similarity index 100% rename from packages/ts/sdk/README.md rename to packages/sdk/README.md diff --git a/packages/ts/sdk/eslint.config.js b/packages/sdk/eslint.config.js similarity index 100% rename from packages/ts/sdk/eslint.config.js rename to packages/sdk/eslint.config.js diff --git a/packages/ts/sdk/package.json b/packages/sdk/package.json similarity index 100% rename from packages/ts/sdk/package.json rename to packages/sdk/package.json diff --git a/packages/ts/sdk/prettier.config.mjs b/packages/sdk/prettier.config.mjs similarity index 100% rename from packages/ts/sdk/prettier.config.mjs rename to packages/sdk/prettier.config.mjs diff --git a/packages/ts/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts similarity index 100% rename from packages/ts/sdk/src/client.test.ts rename to packages/sdk/src/client.test.ts diff --git a/packages/ts/sdk/src/client.ts b/packages/sdk/src/client.ts similarity index 100% rename from packages/ts/sdk/src/client.ts rename to packages/sdk/src/client.ts diff --git a/packages/ts/sdk/src/commands/command.ts b/packages/sdk/src/commands/command.ts similarity index 100% rename from packages/ts/sdk/src/commands/command.ts rename to packages/sdk/src/commands/command.ts diff --git a/packages/ts/sdk/src/commands/get-docs/index.test.ts b/packages/sdk/src/commands/get-docs/index.test.ts similarity index 100% rename from packages/ts/sdk/src/commands/get-docs/index.test.ts rename to packages/sdk/src/commands/get-docs/index.test.ts diff --git a/packages/ts/sdk/src/commands/get-docs/index.ts b/packages/sdk/src/commands/get-docs/index.ts similarity index 100% rename from packages/ts/sdk/src/commands/get-docs/index.ts rename to packages/sdk/src/commands/get-docs/index.ts diff --git a/packages/ts/sdk/src/commands/index.ts b/packages/sdk/src/commands/index.ts similarity index 100% rename from packages/ts/sdk/src/commands/index.ts rename to packages/sdk/src/commands/index.ts diff --git a/packages/ts/sdk/src/commands/search-library/index.test.ts b/packages/sdk/src/commands/search-library/index.test.ts similarity index 100% rename from packages/ts/sdk/src/commands/search-library/index.test.ts rename to packages/sdk/src/commands/search-library/index.test.ts diff --git a/packages/ts/sdk/src/commands/search-library/index.ts b/packages/sdk/src/commands/search-library/index.ts similarity index 100% rename from packages/ts/sdk/src/commands/search-library/index.ts rename to packages/sdk/src/commands/search-library/index.ts diff --git a/packages/ts/sdk/src/commands/types.ts b/packages/sdk/src/commands/types.ts similarity index 100% rename from packages/ts/sdk/src/commands/types.ts rename to packages/sdk/src/commands/types.ts diff --git a/packages/ts/sdk/src/error/index.ts b/packages/sdk/src/error/index.ts similarity index 100% rename from packages/ts/sdk/src/error/index.ts rename to packages/sdk/src/error/index.ts diff --git a/packages/ts/sdk/src/http/index.ts b/packages/sdk/src/http/index.ts similarity index 100% rename from packages/ts/sdk/src/http/index.ts rename to packages/sdk/src/http/index.ts diff --git a/packages/ts/sdk/src/utils/test-utils.ts b/packages/sdk/src/utils/test-utils.ts similarity index 100% rename from packages/ts/sdk/src/utils/test-utils.ts rename to packages/sdk/src/utils/test-utils.ts diff --git a/packages/ts/sdk/tsconfig.json b/packages/sdk/tsconfig.json similarity index 100% rename from packages/ts/sdk/tsconfig.json rename to packages/sdk/tsconfig.json diff --git a/packages/ts/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts similarity index 100% rename from packages/ts/sdk/tsup.config.ts rename to packages/sdk/tsup.config.ts diff --git a/packages/ts/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts similarity index 100% rename from packages/ts/sdk/vitest.config.ts rename to packages/sdk/vitest.config.ts diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a37310463..4fb638d6d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - - "packages/ts/*" + - "packages/*" + - "!packages/python" From a6a5fdf5af87cc878f76bc97e356673fefc54db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 6 Jan 2026 16:12:14 +0300 Subject: [PATCH 11/22] feat: simplify sdk api --- packages/python/sdk/src/context7/__init__.py | 53 +- packages/python/sdk/src/context7/client.py | 482 ++++++++----------- packages/python/sdk/src/context7/models.py | 165 +++---- packages/python/sdk/tests/test_client.py | 408 +++++----------- 4 files changed, 384 insertions(+), 724 deletions(-) diff --git a/packages/python/sdk/src/context7/__init__.py b/packages/python/sdk/src/context7/__init__.py index 4ff22e779..e8d405114 100644 --- a/packages/python/sdk/src/context7/__init__.py +++ b/packages/python/sdk/src/context7/__init__.py @@ -7,15 +7,20 @@ Example: ```python import asyncio - from context7-sdk import Context7 + from context7 import Context7 async def main(): async with Context7(api_key="ctx7sk_...") as client: # Search for libraries - results = await client.search_library("react") + libraries = await client.search_library_async( + "I need a UI library", "react" + ) - # Get documentation - docs = await client.get_docs("/facebook/react") + # Get documentation context + context = await client.get_context_async( + "How to use hooks", "/facebook/react" + ) + print(context) asyncio.run(main()) ``` @@ -24,21 +29,9 @@ async def main(): from context7.client import Context7 from context7.errors import Context7APIError, Context7Error, Context7ValidationError from context7.models import ( - APIResponseMetadata, - AuthenticationType, - CodeDocsResponse, - CodeExample, - CodeSnippet, - DocsResponse, - DocsResponseBase, - GetDocsOptions, - InfoDocsResponse, - InfoSnippet, - LibraryState, - Pagination, - SearchLibraryResponse, - SearchResult, - TextDocsResponse, + Documentation, + GetContextOptions, + Library, ) __all__ = [ @@ -48,24 +41,10 @@ async def main(): "Context7Error", "Context7APIError", "Context7ValidationError", - # Models - Search - "SearchResult", - "SearchLibraryResponse", - "APIResponseMetadata", - # Models - Docs - "CodeSnippet", - "CodeExample", - "InfoSnippet", - "Pagination", - "DocsResponseBase", - "CodeDocsResponse", - "InfoDocsResponse", - "TextDocsResponse", - "DocsResponse", - "GetDocsOptions", - # Enums - "LibraryState", - "AuthenticationType", + # Models + "Library", + "Documentation", + "GetContextOptions", ] __version__ = "0.1.0" diff --git a/packages/python/sdk/src/context7/client.py b/packages/python/sdk/src/context7/client.py index 010959ea5..33cc22ae6 100644 --- a/packages/python/sdk/src/context7/client.py +++ b/packages/python/sdk/src/context7/client.py @@ -6,14 +6,15 @@ import warnings from typing import Any, Literal, overload -from context7.errors import Context7Error, Context7ValidationError -from context7.http import HttpClient, TxtResponseHeaders +from context7.errors import Context7Error +from context7.http import HttpClient from context7.models import ( - CodeDocsResponse, - InfoDocsResponse, - Pagination, - SearchLibraryResponse, - TextDocsResponse, + ApiCodeSnippet, + ApiContextJsonResponse, + ApiInfoSnippet, + ApiSearchResult, + Documentation, + Library, ) DEFAULT_BASE_URL = "https://context7.com/api" @@ -40,70 +41,63 @@ def _validate_api_key(api_key: str | None) -> str: return resolved_api_key -def _validate_library_id(library_id: str) -> tuple[str, str]: - """Validate library_id format and return (owner, repo).""" - if not library_id.startswith("/") or library_id.count("/") < 2: - raise Context7ValidationError( - f"Invalid library ID: {library_id}. Expected format: /owner/repo" - ) - - parts = library_id.lstrip("/").split("/") - owner, repo = parts[0], "/".join(parts[1:]) - return owner, repo - - -def _build_docs_request( - library_id: str, - version: str | None, - page: int | None, - topic: str | None, - limit: int | None, - mode: Literal["info", "code"], - format: Literal["json", "txt"], # pylint: disable=redefined-builtin -) -> tuple[list[str], dict[str, str | int | None]]: - """Build path and query for docs request.""" - owner, repo = _validate_library_id(library_id) - - path = ["v2", "docs", mode, owner, repo] - if version: - path.append(version) - - query: dict[str, str | int | None] = { - "type": format, - "page": page, - "limit": limit, - "topic": topic, - } - - return path, query +def _format_code_snippet(snippet: ApiCodeSnippet) -> Documentation: + """Format a code snippet into a Documentation object.""" + code_blocks = "\n\n".join( + f"```{item.get('language', '')}\n{item.get('code', '')}\n```" + for item in snippet.code_list + ) + content = f"{snippet.code_description}\n\n{code_blocks}" + + return Documentation( + title=snippet.code_title, + content=content, + source=snippet.page_title or snippet.code_id, + ) + + +def _format_info_snippet(snippet: ApiInfoSnippet) -> Documentation: + """Format an info snippet into a Documentation object.""" + return Documentation( + title=snippet.breadcrumb or "Documentation", + content=snippet.content, + source=snippet.page_id, + ) + + +def _format_library(result: ApiSearchResult) -> Library: + """Format an API search result into a Library object.""" + return Library( + id=result.id, + name=result.title, + description=result.description, + total_snippets=result.total_snippets or 0, + trust_score=result.trust_score or 0.0, + benchmark_score=result.benchmark_score or 0.0, + versions=result.versions, + ) + + +def _process_context_response( + result: str | dict[str, Any], + response_type: Literal["json", "txt"], +) -> str | list[Documentation]: + """Process the context API response based on type.""" + if response_type == "txt": + if isinstance(result, str): + return result + return "" + # Parse JSON response + api_response = ApiContextJsonResponse.model_validate(result) -def _process_docs_response( - result: str | dict[str, Any], - headers: TxtResponseHeaders | None, - mode: Literal["info", "code"], - format: Literal["json", "txt"], # pylint: disable=redefined-builtin -) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: - """Process the docs response based on format and mode.""" - if format == "txt": - pagination = Pagination( - page=headers.page if headers else 1, - limit=headers.limit if headers else 0, - totalPages=headers.total_pages if headers else 1, - hasNext=headers.has_next if headers else False, - hasPrev=headers.has_prev if headers else False, - ) - # When format is "txt", result is always a string from the HTTP response - content = result if isinstance(result, str) else "" - return TextDocsResponse( - content=content, - pagination=pagination, - totalTokens=headers.total_tokens if headers else 0, - ) + docs: list[Documentation] = [] + for code_snippet in api_response.code_snippets: + docs.append(_format_code_snippet(code_snippet)) + for info_snippet in api_response.info_snippets: + docs.append(_format_info_snippet(info_snippet)) - if mode == "info": - return InfoDocsResponse.model_validate(result) - return CodeDocsResponse.model_validate(result) + return docs class Context7: @@ -115,37 +109,39 @@ class Context7: Synchronous Usage: ```python - from context7-sdk import Context7 + from context7 import Context7 # Initialize with API key (or set CONTEXT7_API_KEY env var) with Context7(api_key="ctx7sk_...") as client: # Search for libraries - results = client.search_library("react") - for lib in results.results: - print(f"{lib.id}: {lib.title}") - - # Get documentation - docs = client.get_docs("/facebook/react") - for snippet in docs.snippets: - print(f"{snippet.code_title}: {snippet.code_description}") + libraries = client.search_library("I need a UI library", "react") + for lib in libraries: + print(f"{lib.id}: {lib.name}") + + # Get documentation context + context = client.get_context("How to use hooks", "/facebook/react") + print(context) ``` Asynchronous Usage: ```python import asyncio - from context7-sdk import Context7 + from context7 import Context7 async def main(): async with Context7(api_key="ctx7sk_...") as client: # Search for libraries - results = await client.search_library_async("react") - for lib in results.results: - print(f"{lib.id}: {lib.title}") - - # Get documentation - docs = await client.get_docs_async("/facebook/react") - for snippet in docs.snippets: - print(f"{snippet.code_title}: {snippet.code_description}") + libraries = await client.search_library_async( + "I need a UI library", "react" + ) + for lib in libraries: + print(f"{lib.id}: {lib.name}") + + # Get documentation context + context = await client.get_context_async( + "How to use hooks", "/facebook/react" + ) + print(context) asyncio.run(main()) ``` @@ -204,289 +200,191 @@ async def close_async(self) -> None: await self._http.close_async() # Synchronous methods - def search_library(self, query: str) -> SearchLibraryResponse: + def search_library(self, query: str, library_name: str) -> list[Library]: """ - Search for libraries by name or description. + Search for libraries matching the given query. Args: - query: Search query string. + query: The user's question or task (used for relevance ranking). + library_name: The library name to search for. Returns: - SearchLibraryResponse containing matching libraries and metadata. + List of matching libraries. Example: ```python - results = client.search_library("react") - for lib in results.results: - print(f"{lib.id}: {lib.title} ({lib.total_tokens} tokens)") + libraries = client.search_library("I need a UI library", "react") + for lib in libraries: + print(f"{lib.id}: {lib.name} ({lib.total_snippets} snippets)") ``` """ result, _ = self._http.request( method="GET", - path=["v2", "search"], - query={"query": query}, + path=["v2", "libs", "search"], + query={"query": query, "libraryName": library_name}, ) - return SearchLibraryResponse.model_validate(result) - - @overload - def get_docs( - self, - library_id: str, - *, - format: Literal["txt"], # pylint: disable=redefined-builtin - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - mode: Literal["info", "code"] = "code", - ) -> TextDocsResponse: ... + api_results = [ApiSearchResult.model_validate(item) for item in result["results"]] + return [_format_library(r) for r in api_results] @overload - def get_docs( + def get_context( self, + query: str, library_id: str, *, - mode: Literal["info"], - format: Literal["json"] = "json", # pylint: disable=redefined-builtin - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - ) -> InfoDocsResponse: ... + type: Literal["txt"] = "txt", # pylint: disable=redefined-builtin + ) -> str: ... @overload - def get_docs( + def get_context( self, + query: str, library_id: str, *, - mode: Literal["code"] = "code", - format: Literal["json"] = "json", # pylint: disable=redefined-builtin - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - ) -> CodeDocsResponse: ... + type: Literal["json"], # pylint: disable=redefined-builtin + ) -> list[Documentation]: ... - @overload - def get_docs( + def get_context( self, + query: str, library_id: str, *, - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - mode: Literal["info", "code"] = "code", - format: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin - ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: ... - - def get_docs( - self, - library_id: str, - *, - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - mode: Literal["info", "code"] = "code", - format: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin - ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: + type: Literal["json", "txt"] = "txt", # pylint: disable=redefined-builtin + ) -> str | list[Documentation]: """ - Get documentation for a library. + Get documentation context for a library. Args: - library_id: Library identifier in format "/owner/repo" - (e.g., "/facebook/react", "/vercel/next.js"). - version: Optional library version (e.g., "18.0.0"). - page: Page number for pagination. - topic: Filter docs by topic. - limit: Number of results per page. - mode: Type of documentation to fetch: - - "code": Code snippets with examples (default) - - "info": Text content and explanations - format: Response format: - - "json": Structured JSON data (default) - - "txt": Plain text documentation + query: The user's question or task. + library_id: Context7 library ID (e.g., "/facebook/react"). + type: Response format: + - "txt": Plain text documentation (default) + - "json": List of Documentation objects Returns: - Documentation response. The type depends on mode and format: - - format="txt": TextDocsResponse with content string - - mode="code", format="json": CodeDocsResponse with code snippets - - mode="info", format="json": InfoDocsResponse with info snippets - - Raises: - Context7ValidationError: If library_id format is invalid. + Documentation as string (txt) or list of Documentation objects (json). Example: ```python - # Get code snippets (default) - docs = client.get_docs("/facebook/react") - for snippet in docs.snippets: - print(snippet.code_title) - - # Get info documentation - info = client.get_docs("/facebook/react", mode="info") - for snippet in info.snippets: - print(snippet.content) - - # Get plain text - text = client.get_docs("/facebook/react", format="txt") - print(text.content) - - # With version - docs = client.get_docs("/facebook/react", version="18.0.0") + # Get context as text (default) + context = client.get_context("How to use hooks", "/facebook/react") + print(context) + + # Get context as structured data + docs = client.get_context( + "How to use hooks", + "/facebook/react", + type="json" + ) + for doc in docs: + print(f"{doc.title}: {doc.content[:100]}...") ``` """ - path, query = _build_docs_request( - library_id, version, page, topic, limit, mode, format - ) - result, headers = self._http.request( + result, _ = self._http.request( method="GET", - path=path, - query=query, + path=["v2", "context"], + query={ + "query": query, + "libraryId": library_id, + "type": type, + }, ) - return _process_docs_response(result, headers, mode, format) + return _process_context_response(result, type) # Asynchronous methods - async def search_library_async(self, query: str) -> SearchLibraryResponse: + async def search_library_async( + self, query: str, library_name: str + ) -> list[Library]: """ - Search for libraries by name or description (async version). + Search for libraries matching the given query (async version). Args: - query: Search query string. + query: The user's question or task (used for relevance ranking). + library_name: The library name to search for. Returns: - SearchLibraryResponse containing matching libraries and metadata. + List of matching libraries. Example: ```python - results = await client.search_library_async("react") - for lib in results.results: - print(f"{lib.id}: {lib.title} ({lib.total_tokens} tokens)") + libraries = await client.search_library_async( + "I need a UI library", "react" + ) + for lib in libraries: + print(f"{lib.id}: {lib.name} ({lib.total_snippets} snippets)") ``` """ result, _ = await self._http.request_async( method="GET", - path=["v2", "search"], - query={"query": query}, + path=["v2", "libs", "search"], + query={"query": query, "libraryName": library_name}, ) - return SearchLibraryResponse.model_validate(result) + api_results = [ApiSearchResult.model_validate(item) for item in result["results"]] + return [_format_library(r) for r in api_results] @overload - async def get_docs_async( + async def get_context_async( self, + query: str, library_id: str, *, - format: Literal["txt"], # pylint: disable=redefined-builtin - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - mode: Literal["info", "code"] = "code", - ) -> TextDocsResponse: ... + type: Literal["txt"] = "txt", # pylint: disable=redefined-builtin + ) -> str: ... @overload - async def get_docs_async( + async def get_context_async( self, + query: str, library_id: str, *, - mode: Literal["info"], - format: Literal["json"] = "json", # pylint: disable=redefined-builtin - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - ) -> InfoDocsResponse: ... + type: Literal["json"], # pylint: disable=redefined-builtin + ) -> list[Documentation]: ... - @overload - async def get_docs_async( + async def get_context_async( self, + query: str, library_id: str, *, - mode: Literal["code"] = "code", - format: Literal["json"] = "json", # pylint: disable=redefined-builtin - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - ) -> CodeDocsResponse: ... - - @overload - async def get_docs_async( - self, - library_id: str, - *, - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - mode: Literal["info", "code"] = "code", - format: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin - ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: ... - - async def get_docs_async( - self, - library_id: str, - *, - version: str | None = None, - page: int | None = None, - topic: str | None = None, - limit: int | None = None, - mode: Literal["info", "code"] = "code", - format: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin - ) -> TextDocsResponse | CodeDocsResponse | InfoDocsResponse: + type: Literal["json", "txt"] = "txt", # pylint: disable=redefined-builtin + ) -> str | list[Documentation]: """ - Get documentation for a library (async version). + Get documentation context for a library (async version). Args: - library_id: Library identifier in format "/owner/repo" - (e.g., "/facebook/react", "/vercel/next.js"). - version: Optional library version (e.g., "18.0.0"). - page: Page number for pagination. - topic: Filter docs by topic. - limit: Number of results per page. - mode: Type of documentation to fetch: - - "code": Code snippets with examples (default) - - "info": Text content and explanations - format: Response format: - - "json": Structured JSON data (default) - - "txt": Plain text documentation + query: The user's question or task. + library_id: Context7 library ID (e.g., "/facebook/react"). + type: Response format: + - "txt": Plain text documentation (default) + - "json": List of Documentation objects Returns: - Documentation response. The type depends on mode and format: - - format="txt": TextDocsResponse with content string - - mode="code", format="json": CodeDocsResponse with code snippets - - mode="info", format="json": InfoDocsResponse with info snippets - - Raises: - Context7ValidationError: If library_id format is invalid. + Documentation as string (txt) or list of Documentation objects (json). Example: ```python - # Get code snippets (default) - docs = await client.get_docs_async("/facebook/react") - for snippet in docs.snippets: - print(snippet.code_title) - - # Get info documentation - info = await client.get_docs_async("/facebook/react", mode="info") - for snippet in info.snippets: - print(snippet.content) - - # Get plain text - text = await client.get_docs_async("/facebook/react", format="txt") - print(text.content) - - # With version - docs = await client.get_docs_async("/facebook/react", version="18.0.0") + # Get context as text (default) + context = await client.get_context_async( + "How to use hooks", "/facebook/react" + ) + print(context) + + # Get context as structured data + docs = await client.get_context_async( + "How to use hooks", + "/facebook/react", + type="json" + ) + for doc in docs: + print(f"{doc.title}: {doc.content[:100]}...") ``` """ - path, query = _build_docs_request( - library_id, version, page, topic, limit, mode, format - ) - result, headers = await self._http.request_async( + result, _ = await self._http.request_async( method="GET", - path=path, - query=query, + path=["v2", "context"], + query={ + "query": query, + "libraryId": library_id, + "type": type, + }, ) - return _process_docs_response(result, headers, mode, format) + return _process_context_response(result, type) diff --git a/packages/python/sdk/src/context7/models.py b/packages/python/sdk/src/context7/models.py index 85de1bdc8..fed345dd9 100644 --- a/packages/python/sdk/src/context7/models.py +++ b/packages/python/sdk/src/context7/models.py @@ -2,153 +2,100 @@ from __future__ import annotations -from enum import Enum -from typing import Literal, Union +from typing import Literal from pydantic import BaseModel, ConfigDict, Field -class LibraryState(str, Enum): - """State of a library in the Context7 system.""" +class Library(BaseModel): + """A library available in Context7.""" - INITIAL = "initial" - FINALIZED = "finalized" - PROCESSING = "processing" - ERROR = "error" - DELETE = "delete" + id: str + """Context7 library ID (e.g., "/facebook/react")""" + name: str + """Library display name""" -class AuthenticationType(str, Enum): - """Type of authentication used for the API request.""" + description: str + """Library description""" - NONE = "none" - PERSONAL = "personal" - TEAM = "team" + total_snippets: int = Field(default=0) + """Number of documentation snippets available""" + trust_score: float = Field(default=0.0) + """Source reputation score (0-10)""" -class SearchResult(BaseModel): - """A single library search result.""" + benchmark_score: float = Field(default=0.0) + """Quality indicator score (0-100)""" - id: str - title: str - description: str - branch: str - last_update_date: str = Field(alias="lastUpdateDate") - state: LibraryState - total_tokens: int = Field(alias="totalTokens") - total_snippets: int = Field(alias="totalSnippets") - stars: int | None = None - trust_score: float | None = Field(default=None, alias="trustScore") - benchmark_score: float | None = Field(default=None, alias="benchmarkScore") versions: list[str] | None = None - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + """Available versions/tags""" -class APIResponseMetadata(BaseModel): - """Metadata about the API response.""" - - authentication: AuthenticationType +class Documentation(BaseModel): + """A piece of documentation content.""" + title: str + """Title of the documentation snippet""" -class SearchLibraryResponse(BaseModel): - """Response from the search library endpoint.""" + content: str + """The documentation content (may include code blocks in markdown format)""" - results: list[SearchResult] - metadata: APIResponseMetadata + source: str + """Source URL or identifier for the snippet""" -class CodeExample(BaseModel): - """A code example in a specific language.""" +class GetContextOptions(BaseModel): + """Options for fetching documentation context.""" - language: str - code: str + type: Literal["json", "txt"] = "txt" + """Response format. Defaults to "txt".""" -class CodeSnippet(BaseModel): - """A code snippet from the documentation.""" +# Internal API response types (for JSON format parsing) +class ApiCodeSnippet(BaseModel): + """Internal: A code snippet from the API response.""" code_title: str = Field(alias="codeTitle") code_description: str = Field(alias="codeDescription") code_language: str = Field(alias="codeLanguage") - code_tokens: int = Field(alias="codeTokens") + code_list: list[dict[str, str]] = Field(alias="codeList") code_id: str = Field(alias="codeId") - page_title: str = Field(alias="pageTitle") - code_list: list[CodeExample] = Field(alias="codeList") + code_tokens: int | None = Field(default=None, alias="codeTokens") + page_title: str | None = Field(default=None, alias="pageTitle") - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + model_config = ConfigDict(populate_by_name=True) -class InfoSnippet(BaseModel): - """An information snippet from the documentation.""" +class ApiInfoSnippet(BaseModel): + """Internal: An info snippet from the API response.""" - page_id: str | None = Field(default=None, alias="pageId") - breadcrumb: str | None = None content: str - content_tokens: int = Field(alias="contentTokens") - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -class Pagination(BaseModel): - """Pagination information for paginated responses.""" - - page: int - limit: int - total_pages: int = Field(alias="totalPages") - has_next: bool = Field(alias="hasNext") - has_prev: bool = Field(alias="hasPrev") - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -class DocsResponseBase(BaseModel): - """Base class for documentation responses.""" - - pagination: Pagination - total_tokens: int = Field(alias="totalTokens") - - model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) - - -class CodeDocsResponse(DocsResponseBase): - """Response containing code documentation snippets.""" - - snippets: list[CodeSnippet] - - -class InfoDocsResponse(DocsResponseBase): - """Response containing information documentation snippets.""" - - snippets: list[InfoSnippet] - - -class TextDocsResponse(DocsResponseBase): - """Response containing plain text documentation.""" - - content: str - + breadcrumb: str | None = None + page_id: str = Field(alias="pageId") + content_tokens: int | None = Field(default=None, alias="contentTokens") -class GetDocsOptions(BaseModel): - """Options for fetching documentation.""" + model_config = ConfigDict(populate_by_name=True) - version: str | None = None - """Library version to fetch docs for (e.g., "18.0.0").""" - page: int | None = None - """Page number for pagination.""" +class ApiContextJsonResponse(BaseModel): + """Internal: JSON response from the context API.""" - topic: str | None = None - """Filter docs by topic.""" + code_snippets: list[ApiCodeSnippet] = Field(alias="codeSnippets") + info_snippets: list[ApiInfoSnippet] = Field(alias="infoSnippets") - limit: int | None = None - """Number of results per page.""" + model_config = ConfigDict(populate_by_name=True) - mode: Literal["info", "code"] = "code" - """Type of documentation to fetch. Defaults to "code".""" - format: Literal["json", "txt"] = "json" - """Response format. Defaults to "json".""" +class ApiSearchResult(BaseModel): + """Internal: A search result from the API response.""" + id: str + title: str + description: str + versions: list[str] | None = None + total_snippets: int | None = Field(default=None, alias="totalSnippets") + trust_score: float | None = Field(default=None, alias="trustScore") + benchmark_score: float | None = Field(default=None, alias="benchmarkScore") -DocsResponse = Union[CodeDocsResponse, InfoDocsResponse, TextDocsResponse] + model_config = ConfigDict(populate_by_name=True) diff --git a/packages/python/sdk/tests/test_client.py b/packages/python/sdk/tests/test_client.py index e1667eaa8..72980894d 100644 --- a/packages/python/sdk/tests/test_client.py +++ b/packages/python/sdk/tests/test_client.py @@ -3,15 +3,7 @@ import os import pytest -from context7 import Context7, Context7Error, Context7ValidationError -from context7.models import ( - CodeDocsResponse, - CodeSnippet, - InfoDocsResponse, - InfoSnippet, - SearchResult, - TextDocsResponse, -) +from context7 import Context7, Context7Error, Documentation, Library @pytest.fixture(name="api_key") @@ -65,45 +57,43 @@ class TestSearchLibrarySync: def test_search_library(self, api_key: str) -> None: """Test basic library search (sync).""" with Context7(api_key=api_key) as client: - response = client.search_library("react") - assert response.results is not None - assert len(response.results) > 0 - assert response.metadata is not None - assert response.metadata.authentication is not None + libraries = client.search_library("I need a UI library", "react") + assert libraries is not None + assert len(libraries) > 0 + assert isinstance(libraries, list) def test_search_library_result_fields(self, api_key: str) -> None: """Test that search results have expected fields (sync).""" with Context7(api_key=api_key) as client: - response = client.search_library("react") - assert len(response.results) > 0 - result = response.results[0] - assert isinstance(result, SearchResult) - assert result.id is not None - assert isinstance(result.id, str) - assert result.title is not None - assert isinstance(result.title, str) - assert result.description is not None - assert result.branch is not None - assert result.state is not None - assert result.total_tokens >= 0 - assert result.total_snippets >= 0 + libraries = client.search_library("I need a UI library", "react") + assert len(libraries) > 0 + lib = libraries[0] + assert isinstance(lib, Library) + assert lib.id is not None + assert isinstance(lib.id, str) + assert lib.name is not None + assert isinstance(lib.name, str) + assert lib.description is not None + assert lib.total_snippets >= 0 + assert lib.trust_score >= 0 + assert lib.benchmark_score >= 0 def test_search_library_different_queries(self, api_key: str) -> None: """Test search with different queries returns different results.""" with Context7(api_key=api_key) as client: - react_results = client.search_library("react") - vue_results = client.search_library("vue") - assert react_results.results[0].id != vue_results.results[0].id + react_results = client.search_library("I need a UI library", "react") + vue_results = client.search_library("I need a UI library", "vue") + assert react_results[0].id != vue_results[0].id def test_search_library_specific_query(self, api_key: str) -> None: """Test search with specific query returns relevant results.""" with Context7(api_key=api_key) as client: - response = client.search_library("nextjs") - assert response.results is not None - assert len(response.results) > 0 + libraries = client.search_library("I need a server framework", "nextjs") + assert libraries is not None + assert len(libraries) > 0 # Results should contain nextjs-related libraries - titles = [r.title.lower() for r in response.results] - assert any("next" in t for t in titles) + names = [lib.name.lower() for lib in libraries] + assert any("next" in n for n in names) class TestSearchLibraryAsync: @@ -113,221 +103,98 @@ class TestSearchLibraryAsync: async def test_search_library_async(self, api_key: str) -> None: """Test basic library search (async).""" async with Context7(api_key=api_key) as client: - response = await client.search_library_async("react") - assert response.results is not None - assert len(response.results) > 0 - assert response.metadata is not None - assert response.metadata.authentication is not None + libraries = await client.search_library_async("I need a UI library", "react") + assert libraries is not None + assert len(libraries) > 0 + assert isinstance(libraries, list) @pytest.mark.asyncio async def test_search_library_async_result_fields(self, api_key: str) -> None: """Test that search results have expected fields (async).""" async with Context7(api_key=api_key) as client: - response = await client.search_library_async("react") - assert len(response.results) > 0 - result = response.results[0] - assert isinstance(result, SearchResult) - assert result.id is not None - assert isinstance(result.id, str) - assert result.title is not None - assert isinstance(result.title, str) - assert result.description is not None - assert result.branch is not None - assert result.state is not None - assert result.total_tokens >= 0 - assert result.total_snippets >= 0 - - -class TestGetDocsSync: - """Tests for the synchronous get_docs method.""" - - def test_get_docs_code_default(self, api_key: str) -> None: - """Test getting code documentation (default mode, sync).""" + libraries = await client.search_library_async("I need a UI library", "react") + assert len(libraries) > 0 + lib = libraries[0] + assert isinstance(lib, Library) + assert lib.id is not None + assert isinstance(lib.id, str) + assert lib.name is not None + assert isinstance(lib.name, str) + assert lib.description is not None + assert lib.total_snippets >= 0 + assert lib.trust_score >= 0 + assert lib.benchmark_score >= 0 + + +class TestGetContextSync: + """Tests for the synchronous get_context method.""" + + def test_get_context_text_default(self, api_key: str) -> None: + """Test getting context as text (default, sync).""" with Context7(api_key=api_key) as client: - response = client.get_docs("/facebook/react") - assert isinstance(response, CodeDocsResponse) - assert response.snippets is not None - assert len(response.snippets) > 0 - assert response.pagination is not None - assert response.pagination.page >= 1 - assert response.total_tokens >= 0 - - def test_get_docs_code_snippet_fields(self, api_key: str) -> None: - """Test that code snippets have all expected fields.""" - with Context7(api_key=api_key) as client: - response = client.get_docs("/facebook/react") - assert len(response.snippets) > 0 - snippet = response.snippets[0] - assert isinstance(snippet, CodeSnippet) - assert snippet.code_title is not None - assert snippet.code_description is not None - assert snippet.code_language is not None - assert snippet.code_tokens >= 0 - assert snippet.code_id is not None - assert snippet.page_title is not None - assert snippet.code_list is not None - assert len(snippet.code_list) > 0 - - def test_get_docs_info_mode(self, api_key: str) -> None: - """Test getting info documentation (sync).""" - with Context7(api_key=api_key) as client: - response = client.get_docs("/facebook/react", mode="info") - assert isinstance(response, InfoDocsResponse) - assert response.snippets is not None - assert len(response.snippets) > 0 - assert response.pagination is not None - assert response.total_tokens >= 0 - - def test_get_docs_info_snippet_fields(self, api_key: str) -> None: - """Test that info snippets have all expected fields.""" - with Context7(api_key=api_key) as client: - response = client.get_docs("/facebook/react", mode="info") - assert len(response.snippets) > 0 - snippet = response.snippets[0] - assert isinstance(snippet, InfoSnippet) - assert snippet.content is not None - assert isinstance(snippet.content, str) - assert snippet.content_tokens >= 0 - - def test_get_docs_txt_format(self, api_key: str) -> None: - """Test getting plain text documentation (sync).""" - with Context7(api_key=api_key) as client: - response = client.get_docs("/facebook/react", format="txt") - assert isinstance(response, TextDocsResponse) - assert response.content is not None - assert isinstance(response.content, str) - assert len(response.content) > 0 - assert response.pagination is not None - assert response.total_tokens >= 0 - - def test_get_docs_with_pagination(self, api_key: str) -> None: - """Test documentation pagination (sync).""" - with Context7(api_key=api_key) as client: - response = client.get_docs("/facebook/react", page=1, limit=5) - assert response.pagination is not None - assert response.pagination.page == 1 - assert response.pagination.limit == 5 - assert isinstance(response.pagination.has_next, bool) - assert isinstance(response.pagination.has_prev, bool) - assert response.pagination.total_pages >= 1 - - def test_get_docs_pagination_page_2(self, api_key: str) -> None: - """Test fetching second page of documentation.""" + context = client.get_context("How to use hooks", "/facebook/react") + assert isinstance(context, str) + assert len(context) > 0 + + def test_get_context_json(self, api_key: str) -> None: + """Test getting context as JSON (sync).""" with Context7(api_key=api_key) as client: - page1 = client.get_docs("/facebook/react", page=1, limit=3) - page2 = client.get_docs("/facebook/react", page=2, limit=3) - assert page1.pagination.page == 1 - assert page2.pagination.page == 2 - # Content should be different between pages - if page1.snippets and page2.snippets: - assert page1.snippets[0].code_id != page2.snippets[0].code_id - - def test_get_docs_with_topic(self, api_key: str) -> None: - """Test filtering documentation by topic.""" + docs = client.get_context("How to use hooks", "/facebook/react", type="json") + assert isinstance(docs, list) + assert len(docs) > 0 + + def test_get_context_json_fields(self, api_key: str) -> None: + """Test that JSON documentation has all expected fields.""" with Context7(api_key=api_key) as client: - response = client.get_docs("/facebook/react", topic="hooks") - assert response.snippets is not None - assert response.pagination is not None - - def test_get_docs_invalid_library_id(self, api_key: str) -> None: - """Test that invalid library ID raises validation error (sync).""" - with ( - Context7(api_key=api_key) as client, - pytest.raises(Context7ValidationError, match="Invalid library ID"), - ): - client.get_docs("invalid-id") - - def test_get_docs_invalid_library_id_no_slash(self, api_key: str) -> None: - """Test that library ID without leading slash raises error (sync).""" - with ( - Context7(api_key=api_key) as client, - pytest.raises(Context7ValidationError, match="Expected format"), - ): - client.get_docs("facebook/react") - - def test_get_docs_invalid_library_id_single_segment(self, api_key: str) -> None: - """Test that single segment library ID raises error.""" - with Context7(api_key=api_key) as client, pytest.raises(Context7ValidationError): - client.get_docs("/react") - - -class TestGetDocsAsync: - """Tests for the asynchronous get_docs_async method.""" + docs = client.get_context("How to use hooks", "/facebook/react", type="json") + assert len(docs) > 0 + doc = docs[0] + assert isinstance(doc, Documentation) + assert doc.title is not None + assert isinstance(doc.title, str) + assert doc.content is not None + assert isinstance(doc.content, str) + assert doc.source is not None + assert isinstance(doc.source, str) - @pytest.mark.asyncio - async def test_get_docs_async_code_default(self, api_key: str) -> None: - """Test getting code documentation (default mode, async).""" - async with Context7(api_key=api_key) as client: - response = await client.get_docs_async("/facebook/react") - assert isinstance(response, CodeDocsResponse) - assert response.snippets is not None - assert len(response.snippets) > 0 - assert response.pagination is not None - assert response.pagination.page >= 1 - assert response.total_tokens >= 0 - @pytest.mark.asyncio - async def test_get_docs_async_code_snippet_fields(self, api_key: str) -> None: - """Test that code snippets have all expected fields (async).""" - async with Context7(api_key=api_key) as client: - response = await client.get_docs_async("/facebook/react") - assert len(response.snippets) > 0 - snippet = response.snippets[0] - assert isinstance(snippet, CodeSnippet) - assert snippet.code_title is not None - assert snippet.code_description is not None - assert snippet.code_language is not None - assert snippet.code_tokens >= 0 - assert snippet.code_id is not None +class TestGetContextAsync: + """Tests for the asynchronous get_context_async method.""" @pytest.mark.asyncio - async def test_get_docs_async_info_mode(self, api_key: str) -> None: - """Test getting info documentation (async).""" + async def test_get_context_async_text_default(self, api_key: str) -> None: + """Test getting context as text (default, async).""" async with Context7(api_key=api_key) as client: - response = await client.get_docs_async("/facebook/react", mode="info") - assert isinstance(response, InfoDocsResponse) - assert response.snippets is not None - assert len(response.snippets) > 0 - assert response.pagination is not None - assert response.total_tokens >= 0 + context = await client.get_context_async("How to use hooks", "/facebook/react") + assert isinstance(context, str) + assert len(context) > 0 @pytest.mark.asyncio - async def test_get_docs_async_txt_format(self, api_key: str) -> None: - """Test getting plain text documentation (async).""" + async def test_get_context_async_json(self, api_key: str) -> None: + """Test getting context as JSON (async).""" async with Context7(api_key=api_key) as client: - response = await client.get_docs_async("/facebook/react", format="txt") - assert isinstance(response, TextDocsResponse) - assert response.content is not None - assert isinstance(response.content, str) - assert len(response.content) > 0 - assert response.pagination is not None - assert response.total_tokens >= 0 + docs = await client.get_context_async( + "How to use hooks", "/facebook/react", type="json" + ) + assert isinstance(docs, list) + assert len(docs) > 0 @pytest.mark.asyncio - async def test_get_docs_async_with_pagination(self, api_key: str) -> None: - """Test documentation pagination (async).""" + async def test_get_context_async_json_fields(self, api_key: str) -> None: + """Test that JSON documentation has all expected fields (async).""" async with Context7(api_key=api_key) as client: - response = await client.get_docs_async("/facebook/react", page=1, limit=5) - assert response.pagination is not None - assert response.pagination.page == 1 - assert response.pagination.limit == 5 - assert isinstance(response.pagination.has_next, bool) - assert isinstance(response.pagination.has_prev, bool) - assert response.pagination.total_pages >= 1 - - @pytest.mark.asyncio - async def test_get_docs_async_invalid_library_id(self, api_key: str) -> None: - """Test that invalid library ID raises validation error (async).""" - async with Context7(api_key=api_key) as client: - with pytest.raises(Context7ValidationError, match="Invalid library ID"): - await client.get_docs_async("invalid-id") - - @pytest.mark.asyncio - async def test_get_docs_async_invalid_library_id_no_slash(self, api_key: str) -> None: - """Test that library ID without leading slash raises error (async).""" - async with Context7(api_key=api_key) as client: - with pytest.raises(Context7ValidationError, match="Expected format"): - await client.get_docs_async("facebook/react") + docs = await client.get_context_async( + "How to use hooks", "/facebook/react", type="json" + ) + assert len(docs) > 0 + doc = docs[0] + assert isinstance(doc, Documentation) + assert doc.title is not None + assert isinstance(doc.title, str) + assert doc.content is not None + assert isinstance(doc.content, str) + assert doc.source is not None + assert isinstance(doc.source, str) class TestSyncContextManager: @@ -337,16 +204,16 @@ def test_context_manager(self, api_key: str) -> None: """Test that sync context manager properly opens and closes.""" with Context7(api_key=api_key) as client: assert client is not None - response = client.search_library("react") - assert response is not None - assert len(response.results) > 0 + libraries = client.search_library("I need a UI library", "react") + assert libraries is not None + assert len(libraries) > 0 def test_manual_close(self, api_key: str) -> None: """Test manual close method (sync).""" client = Context7(api_key=api_key) - response = client.search_library("react") - assert response is not None - assert len(response.results) > 0 + libraries = client.search_library("I need a UI library", "react") + assert libraries is not None + assert len(libraries) > 0 client.close() # Client should still exist but HTTP client should be closed assert client._http._sync_client is None @@ -354,12 +221,12 @@ def test_manual_close(self, api_key: str) -> None: def test_multiple_requests_same_client(self, api_key: str) -> None: """Test making multiple requests with same client.""" with Context7(api_key=api_key) as client: - response1 = client.search_library("react") - response2 = client.search_library("vue") - response3 = client.get_docs("/facebook/react", limit=2) - assert response1.results is not None - assert response2.results is not None - assert response3.snippets is not None + libraries1 = client.search_library("I need a UI library", "react") + libraries2 = client.search_library("I need a UI library", "vue") + context = client.get_context("How to use hooks", "/facebook/react") + assert libraries1 is not None + assert libraries2 is not None + assert context is not None class TestAsyncContextManager: @@ -370,17 +237,17 @@ async def test_context_manager_async(self, api_key: str) -> None: """Test that async context manager properly opens and closes.""" async with Context7(api_key=api_key) as client: assert client is not None - response = await client.search_library_async("react") - assert response is not None - assert len(response.results) > 0 + libraries = await client.search_library_async("I need a UI library", "react") + assert libraries is not None + assert len(libraries) > 0 @pytest.mark.asyncio async def test_manual_close_async(self, api_key: str) -> None: """Test manual close method (async).""" client = Context7(api_key=api_key) - response = await client.search_library_async("react") - assert response is not None - assert len(response.results) > 0 + libraries = await client.search_library_async("I need a UI library", "react") + assert libraries is not None + assert len(libraries) > 0 await client.close_async() # Client should still exist but HTTP client should be closed assert client._http._async_client is None @@ -389,40 +256,9 @@ async def test_manual_close_async(self, api_key: str) -> None: async def test_multiple_requests_same_client_async(self, api_key: str) -> None: """Test making multiple async requests with same client.""" async with Context7(api_key=api_key) as client: - response1 = await client.search_library_async("react") - response2 = await client.search_library_async("vue") - response3 = await client.get_docs_async("/facebook/react", limit=2) - assert response1.results is not None - assert response2.results is not None - assert response3.snippets is not None - - -class TestLibraryIdValidation: - """Tests for library ID validation edge cases.""" - - def test_valid_library_id_formats(self, api_key: str) -> None: - """Test various valid library ID formats.""" - with Context7(api_key=api_key) as client: - # Standard format - response = client.get_docs("/facebook/react", limit=1) - assert response is not None - - def test_library_id_with_nested_path(self, api_key: str) -> None: - """Test library ID with nested repo path.""" - with Context7(api_key=api_key) as client: - # Some repos have nested paths like /org/repo/subpath - # The SDK should handle this correctly - response = client.get_docs("/vercel/next.js", limit=1) - assert response is not None - - def test_invalid_library_id_formats(self, api_key: str) -> None: - """Test various invalid library ID formats.""" - with Context7(api_key=api_key) as client: - invalid_ids = [ - "no-slash", - "single/segment", - "/only-owner", - ] - for invalid_id in invalid_ids: - with pytest.raises(Context7ValidationError): - client.get_docs(invalid_id) + libraries1 = await client.search_library_async("I need a UI library", "react") + libraries2 = await client.search_library_async("I need a UI library", "vue") + context = await client.get_context_async("How to use hooks", "/facebook/react") + assert libraries1 is not None + assert libraries2 is not None + assert context is not None From bdf8970c78b31dafb36c7ca849242d9831fe37ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 6 Jan 2026 16:22:30 +0300 Subject: [PATCH 12/22] fix: update python workflow paths and skip changeset for python changes --- .github/workflows/changeset-check.yml | 16 ++++++++++++++-- .github/workflows/python-ci.yml | 14 +++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index 3ce60c1e3..22875e0db 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -34,8 +34,20 @@ jobs: { encoding: 'utf-8' } ); - const hasChangeset = diff - .split('\n') + const changedFiles = diff.split('\n').filter(f => f.length > 0); + + // Skip changeset check if only Python files are changed + const onlyPythonChanges = changedFiles.every(file => + file.startsWith('packages/python/') || + file === '.github/workflows/python-ci.yml' + ); + + if (onlyPythonChanges && changedFiles.length > 0) { + console.log('Skipping changeset check - only Python package changes detected'); + return; + } + + const hasChangeset = changedFiles .some(file => file.startsWith('.changeset/') && file.endsWith('.md') && !file.includes('README')); if (!hasChangeset) { diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 132c397d1..4b208dbd0 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -4,17 +4,17 @@ on: push: branches: [main, master] paths: - - "packages/py/**" + - "packages/python/**" - ".github/workflows/python-ci.yml" pull_request: branches: [main, master] paths: - - "packages/py/**" + - "packages/python/**" - ".github/workflows/python-ci.yml" defaults: run: - working-directory: packages/py + working-directory: packages/python jobs: test: @@ -62,14 +62,14 @@ jobs: uses: astral-sh/setup-uv@v4 - name: Build package - working-directory: packages/py/sdk + working-directory: packages/python/sdk run: uv build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: python-sdk-dist - path: packages/py/sdk/dist/ + path: packages/python/sdk/dist/ publish: runs-on: ubuntu-latest @@ -87,10 +87,10 @@ jobs: uses: actions/download-artifact@v4 with: name: python-sdk-dist - path: packages/py/sdk/dist/ + path: packages/python/sdk/dist/ - name: Publish to PyPI - working-directory: packages/py/sdk + working-directory: packages/python/sdk env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} run: uv publish From 4609891b5e21fb22585325c85ccda4703d112aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 6 Jan 2026 16:25:25 +0300 Subject: [PATCH 13/22] fix: correct package directories and format python code --- .github/workflows/changeset-check.yml | 4 ++-- packages/mcp/package.json | 2 +- packages/python/sdk/src/context7/client.py | 7 ++----- packages/sdk/package.json | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index 22875e0db..65024e9ce 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -36,10 +36,10 @@ jobs: const changedFiles = diff.split('\n').filter(f => f.length > 0); - // Skip changeset check if only Python files are changed + // Skip changeset check if only Python files or workflow files are changed const onlyPythonChanges = changedFiles.every(file => file.startsWith('packages/python/') || - file === '.github/workflows/python-ci.yml' + file.startsWith('.github/workflows/') ); if (onlyPythonChanges && changedFiles.length > 0) { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 16da37d01..8425abb85 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -17,7 +17,7 @@ "repository": { "type": "git", "url": "git+https://github.com/upstash/context7.git", - "directory": "packages/ts/mcp" + "directory": "packages/mcp" }, "keywords": [ "modelcontextprotocol", diff --git a/packages/python/sdk/src/context7/client.py b/packages/python/sdk/src/context7/client.py index 33cc22ae6..d288a7584 100644 --- a/packages/python/sdk/src/context7/client.py +++ b/packages/python/sdk/src/context7/client.py @@ -44,8 +44,7 @@ def _validate_api_key(api_key: str | None) -> str: def _format_code_snippet(snippet: ApiCodeSnippet) -> Documentation: """Format a code snippet into a Documentation object.""" code_blocks = "\n\n".join( - f"```{item.get('language', '')}\n{item.get('code', '')}\n```" - for item in snippet.code_list + f"```{item.get('language', '')}\n{item.get('code', '')}\n```" for item in snippet.code_list ) content = f"{snippet.code_description}\n\n{code_blocks}" @@ -292,9 +291,7 @@ def get_context( return _process_context_response(result, type) # Asynchronous methods - async def search_library_async( - self, query: str, library_name: str - ) -> list[Library]: + async def search_library_async(self, query: str, library_name: str) -> list[Library]: """ Search for libraries matching the given query (async version). diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f60b5f6c1..9bc2006c1 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -14,7 +14,7 @@ "repository": { "type": "git", "url": "git+https://github.com/upstash/context7.git", - "directory": "packages/ts/sdk" + "directory": "packages/sdk" }, "keywords": [ "context7", From 8814e263d6b28792b67866b9daa078c6aa7ddedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 6 Jan 2026 16:26:31 +0300 Subject: [PATCH 14/22] chore: update pnpm lockfile --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 382d67e18..58cde6489 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,7 +39,7 @@ importers: specifier: ^8.28.0 version: 8.47.0(eslint@9.39.1)(typescript@5.9.3) - packages/ts/mcp: + packages/mcp: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.17.5 @@ -67,7 +67,7 @@ importers: specifier: ^5.8.2 version: 5.9.3 - packages/ts/sdk: + packages/sdk: devDependencies: '@types/node': specifier: ^22.13.14 From 3f05fa27d21238aff3e3ce6295905a1240597630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 6 Jan 2026 16:27:59 +0300 Subject: [PATCH 15/22] fix: correct tsconfig extends path for mcp package --- packages/mcp/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index c3aaa5f42..624d4ca37 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../tsconfig.json", "compilerOptions": { "module": "Node16", "moduleResolution": "Node16", From bc2ad96f8323d26b247ea53304df119d1a859b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 6 Jan 2026 16:52:00 +0300 Subject: [PATCH 16/22] refactor: remove unused pagination headers from Python SDK --- packages/python/sdk/src/context7/client.py | 8 ++--- packages/python/sdk/src/context7/http.py | 37 +++------------------- 2 files changed, 9 insertions(+), 36 deletions(-) diff --git a/packages/python/sdk/src/context7/client.py b/packages/python/sdk/src/context7/client.py index d288a7584..a13f89023 100644 --- a/packages/python/sdk/src/context7/client.py +++ b/packages/python/sdk/src/context7/client.py @@ -217,7 +217,7 @@ def search_library(self, query: str, library_name: str) -> list[Library]: print(f"{lib.id}: {lib.name} ({lib.total_snippets} snippets)") ``` """ - result, _ = self._http.request( + result = self._http.request( method="GET", path=["v2", "libs", "search"], query={"query": query, "libraryName": library_name}, @@ -279,7 +279,7 @@ def get_context( print(f"{doc.title}: {doc.content[:100]}...") ``` """ - result, _ = self._http.request( + result = self._http.request( method="GET", path=["v2", "context"], query={ @@ -311,7 +311,7 @@ async def search_library_async(self, query: str, library_name: str) -> list[Libr print(f"{lib.id}: {lib.name} ({lib.total_snippets} snippets)") ``` """ - result, _ = await self._http.request_async( + result = await self._http.request_async( method="GET", path=["v2", "libs", "search"], query={"query": query, "libraryName": library_name}, @@ -375,7 +375,7 @@ async def get_context_async( print(f"{doc.title}: {doc.content[:100]}...") ``` """ - result, _ = await self._http.request_async( + result = await self._http.request_async( method="GET", path=["v2", "context"], query={ diff --git a/packages/python/sdk/src/context7/http.py b/packages/python/sdk/src/context7/http.py index d7f298881..7fa1a90bc 100644 --- a/packages/python/sdk/src/context7/http.py +++ b/packages/python/sdk/src/context7/http.py @@ -8,22 +8,10 @@ from typing import Any import httpx -from pydantic import BaseModel from context7.errors import Context7APIError -class TxtResponseHeaders(BaseModel): - """Headers extracted from text response for pagination.""" - - page: int - limit: int - total_pages: int - has_next: bool - has_prev: bool - total_tokens: int - - class HttpClient: """HTTP client with retry logic for Context7 API (sync and async).""" @@ -92,7 +80,7 @@ def request( path: list[str] | None = None, query: dict[str, Any] | None = None, body: dict[str, Any] | None = None, - ) -> tuple[Any, TxtResponseHeaders | None]: + ) -> Any: """Make a synchronous HTTP request with retry logic.""" url = "/".join([self.base_url, *(path or [])]) params = {k: v for k, v in (query or {}).items() if v is not None} @@ -122,7 +110,7 @@ async def request_async( path: list[str] | None = None, query: dict[str, Any] | None = None, body: dict[str, Any] | None = None, - ) -> tuple[Any, TxtResponseHeaders | None]: + ) -> Any: """Make an asynchronous HTTP request with retry logic.""" url = "/".join([self.base_url, *(path or [])]) params = {k: v for k, v in (query or {}).items() if v is not None} @@ -146,7 +134,7 @@ async def request_async( raise last_error or Context7APIError("Exhausted all retries") - def _handle_response(self, response: httpx.Response) -> tuple[Any, TxtResponseHeaders | None]: + def _handle_response(self, response: httpx.Response) -> Any: """Handle HTTP response (shared between sync and async).""" if not response.is_success: try: @@ -161,21 +149,6 @@ def _handle_response(self, response: httpx.Response) -> tuple[Any, TxtResponseHe content_type = response.headers.get("content-type", "") if "application/json" in content_type: - return response.json(), None + return response.json() else: - headers = self._extract_txt_headers(response.headers) - return response.text, headers - - def _extract_txt_headers(self, headers: httpx.Headers) -> TxtResponseHeaders | None: - """Extract pagination headers from text response.""" - try: - return TxtResponseHeaders( - page=int(headers.get("x-context7-page", 0)), - limit=int(headers.get("x-context7-limit", 0)), - total_pages=int(headers.get("x-context7-total-pages", 0)), - has_next=headers.get("x-context7-has-next", "false").lower() == "true", - has_prev=headers.get("x-context7-has-prev", "false").lower() == "true", - total_tokens=int(headers.get("x-context7-total-tokens", 0)), - ) - except (ValueError, TypeError): - return None + return response.text From 9137d7eab6e0aabc61a987dc1da669024806b7be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 6 Jan 2026 16:54:43 +0300 Subject: [PATCH 17/22] refactor: remove unused GetContextOptions and Context7ValidationError --- packages/python/sdk/src/context7/__init__.py | 13 ++----------- packages/python/sdk/src/context7/errors.py | 4 ---- packages/python/sdk/src/context7/models.py | 10 ---------- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/packages/python/sdk/src/context7/__init__.py b/packages/python/sdk/src/context7/__init__.py index e8d405114..d6d581151 100644 --- a/packages/python/sdk/src/context7/__init__.py +++ b/packages/python/sdk/src/context7/__init__.py @@ -27,24 +27,15 @@ async def main(): """ from context7.client import Context7 -from context7.errors import Context7APIError, Context7Error, Context7ValidationError -from context7.models import ( - Documentation, - GetContextOptions, - Library, -) +from context7.errors import Context7APIError, Context7Error +from context7.models import Documentation, Library __all__ = [ - # Client "Context7", - # Errors "Context7Error", "Context7APIError", - "Context7ValidationError", - # Models "Library", "Documentation", - "GetContextOptions", ] __version__ = "0.1.0" diff --git a/packages/python/sdk/src/context7/errors.py b/packages/python/sdk/src/context7/errors.py index c9eee1675..f51c4f24f 100644 --- a/packages/python/sdk/src/context7/errors.py +++ b/packages/python/sdk/src/context7/errors.py @@ -17,7 +17,3 @@ class Context7APIError(Context7Error): def __init__(self, message: str, status_code: int | None = None) -> None: self.status_code = status_code super().__init__(message) - - -class Context7ValidationError(Context7Error): - """Raised when input validation fails.""" diff --git a/packages/python/sdk/src/context7/models.py b/packages/python/sdk/src/context7/models.py index fed345dd9..ff11706ae 100644 --- a/packages/python/sdk/src/context7/models.py +++ b/packages/python/sdk/src/context7/models.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Literal - from pydantic import BaseModel, ConfigDict, Field @@ -45,14 +43,6 @@ class Documentation(BaseModel): """Source URL or identifier for the snippet""" -class GetContextOptions(BaseModel): - """Options for fetching documentation context.""" - - type: Literal["json", "txt"] = "txt" - """Response format. Defaults to "txt".""" - - -# Internal API response types (for JSON format parsing) class ApiCodeSnippet(BaseModel): """Internal: A code snippet from the API response.""" From 36da4b86500dcb1d8eb81af07e6e5f6f6c9dc49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 9 Jan 2026 12:57:49 +0300 Subject: [PATCH 18/22] chore: update readme for python sdk --- .github/workflows/changeset-check.yml | 11 ++++---- packages/python/sdk/README.md | 40 ++++++++++++++------------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index 65024e9ce..e490614fc 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -36,14 +36,15 @@ jobs: const changedFiles = diff.split('\n').filter(f => f.length > 0); - // Skip changeset check if only Python files or workflow files are changed - const onlyPythonChanges = changedFiles.every(file => + // Skip changeset check if only Python files, workflow files, or markdown files are changed + const onlySkippableChanges = changedFiles.every(file => file.startsWith('packages/python/') || - file.startsWith('.github/workflows/') + file.startsWith('.github/workflows/') || + file.endsWith('.md') ); - if (onlyPythonChanges && changedFiles.length > 0) { - console.log('Skipping changeset check - only Python package changes detected'); + if (onlySkippableChanges && changedFiles.length > 0) { + console.log('Skipping changeset check - only Python package, workflow, or markdown changes detected'); return; } diff --git a/packages/python/sdk/README.md b/packages/python/sdk/README.md index 516b9df39..1826fcc51 100644 --- a/packages/python/sdk/README.md +++ b/packages/python/sdk/README.md @@ -18,19 +18,18 @@ uv add context7 ```python import asyncio -from context7-sdk import Context7 +from context7 import Context7 async def main(): async with Context7(api_key="ctx7sk_...") as client: # Search for libraries - results = await client.search_library("react") - for lib in results.results: - print(f"{lib.id}: {lib.title}") + libraries = await client.search_library_async("I need a UI library", "react") + for lib in libraries: + print(f"{lib.id}: {lib.name}") - # Get documentation - docs = await client.get_docs("/facebook/react") - for snippet in docs.snippets: - print(f"{snippet.code_title}: {snippet.code_description}") + # Get documentation context + context = await client.get_context_async("How to use hooks", "/facebook/react") + print(context) asyncio.run(main()) ``` @@ -44,23 +43,26 @@ Initialize the Context7 client. - `api_key`: API key for authentication. Falls back to `CONTEXT7_API_KEY` environment variable. - `base_url`: Optional custom base URL for the API. -### `await client.search_library(query: str) -> SearchLibraryResponse` +### `client.search_library(query: str, library_name: str) -> list[Library]` -Search for libraries by name or description. +Search for libraries matching the given query. -### `await client.get_docs(library_id: str, **options) -> DocsResponse` +- `query`: The user's question or task (used for relevance ranking) +- `library_name`: The library name to search for -Get documentation for a library. +Async version: `await client.search_library_async(query, library_name)` + +### `client.get_context(query: str, library_id: str, *, type="txt") -> str | list[Documentation]` + +Get documentation context for a library. **Parameters:** -- `library_id`: Library identifier in format `/owner/repo` (e.g., `/facebook/react`) -- `version`: Optional library version (e.g., `"18.0.0"`) -- `page`: Page number for pagination -- `topic`: Filter docs by topic -- `limit`: Number of results per page -- `mode`: Type of documentation - `"code"` (default) or `"info"` -- `format`: Response format - `"json"` (default) or `"txt"` +- `query`: The user's question or task +- `library_id`: Context7 library ID (e.g., `/facebook/react`) +- `type`: Response format - `"txt"` (default) returns plain text, `"json"` returns list of Documentation objects + +Async version: `await client.get_context_async(query, library_id, type=...)` ## License From 8e8ab4cac4ab5bc5de2c633a2fc183dce5cc2a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 9 Jan 2026 13:13:45 +0300 Subject: [PATCH 19/22] feat: update default response type to json from txt --- packages/python/sdk/src/context7/client.py | 78 +++++++++++----------- packages/python/sdk/tests/test_client.py | 56 ++++++++-------- 2 files changed, 69 insertions(+), 65 deletions(-) diff --git a/packages/python/sdk/src/context7/client.py b/packages/python/sdk/src/context7/client.py index a13f89023..985c57f81 100644 --- a/packages/python/sdk/src/context7/client.py +++ b/packages/python/sdk/src/context7/client.py @@ -118,8 +118,9 @@ class Context7: print(f"{lib.id}: {lib.name}") # Get documentation context - context = client.get_context("How to use hooks", "/facebook/react") - print(context) + docs = client.get_context("How to use hooks", "/facebook/react") + for doc in docs: + print(f"{doc.title}: {doc.content[:100]}...") ``` Asynchronous Usage: @@ -137,10 +138,11 @@ async def main(): print(f"{lib.id}: {lib.name}") # Get documentation context - context = await client.get_context_async( + docs = await client.get_context_async( "How to use hooks", "/facebook/react" ) - print(context) + for doc in docs: + print(f"{doc.title}: {doc.content[:100]}...") asyncio.run(main()) ``` @@ -231,8 +233,8 @@ def get_context( query: str, library_id: str, *, - type: Literal["txt"] = "txt", # pylint: disable=redefined-builtin - ) -> str: ... + type: Literal["json"] = "json", # pylint: disable=redefined-builtin + ) -> list[Documentation]: ... @overload def get_context( @@ -240,16 +242,16 @@ def get_context( query: str, library_id: str, *, - type: Literal["json"], # pylint: disable=redefined-builtin - ) -> list[Documentation]: ... + type: Literal["txt"], # pylint: disable=redefined-builtin + ) -> str: ... def get_context( self, query: str, library_id: str, *, - type: Literal["json", "txt"] = "txt", # pylint: disable=redefined-builtin - ) -> str | list[Documentation]: + type: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin + ) -> list[Documentation] | str: """ Get documentation context for a library. @@ -257,26 +259,26 @@ def get_context( query: The user's question or task. library_id: Context7 library ID (e.g., "/facebook/react"). type: Response format: - - "txt": Plain text documentation (default) - - "json": List of Documentation objects + - "json": List of Documentation objects (default) + - "txt": Plain text documentation Returns: - Documentation as string (txt) or list of Documentation objects (json). + Documentation as list of Documentation objects (json) or string (txt). Example: ```python - # Get context as text (default) - context = client.get_context("How to use hooks", "/facebook/react") - print(context) + # Get context as structured data (default) + docs = client.get_context("How to use hooks", "/facebook/react") + for doc in docs: + print(f"{doc.title}: {doc.content[:100]}...") - # Get context as structured data - docs = client.get_context( + # Get context as plain text + context = client.get_context( "How to use hooks", "/facebook/react", - type="json" + type="txt" ) - for doc in docs: - print(f"{doc.title}: {doc.content[:100]}...") + print(context) ``` """ result = self._http.request( @@ -325,8 +327,8 @@ async def get_context_async( query: str, library_id: str, *, - type: Literal["txt"] = "txt", # pylint: disable=redefined-builtin - ) -> str: ... + type: Literal["json"] = "json", # pylint: disable=redefined-builtin + ) -> list[Documentation]: ... @overload async def get_context_async( @@ -334,16 +336,16 @@ async def get_context_async( query: str, library_id: str, *, - type: Literal["json"], # pylint: disable=redefined-builtin - ) -> list[Documentation]: ... + type: Literal["txt"], # pylint: disable=redefined-builtin + ) -> str: ... async def get_context_async( self, query: str, library_id: str, *, - type: Literal["json", "txt"] = "txt", # pylint: disable=redefined-builtin - ) -> str | list[Documentation]: + type: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin + ) -> list[Documentation] | str: """ Get documentation context for a library (async version). @@ -351,28 +353,28 @@ async def get_context_async( query: The user's question or task. library_id: Context7 library ID (e.g., "/facebook/react"). type: Response format: - - "txt": Plain text documentation (default) - - "json": List of Documentation objects + - "json": List of Documentation objects (default) + - "txt": Plain text documentation Returns: - Documentation as string (txt) or list of Documentation objects (json). + Documentation as list of Documentation objects (json) or string (txt). Example: ```python - # Get context as text (default) - context = await client.get_context_async( + # Get context as structured data (default) + docs = await client.get_context_async( "How to use hooks", "/facebook/react" ) - print(context) + for doc in docs: + print(f"{doc.title}: {doc.content[:100]}...") - # Get context as structured data - docs = await client.get_context_async( + # Get context as plain text + context = await client.get_context_async( "How to use hooks", "/facebook/react", - type="json" + type="txt" ) - for doc in docs: - print(f"{doc.title}: {doc.content[:100]}...") + print(context) ``` """ result = await self._http.request_async( diff --git a/packages/python/sdk/tests/test_client.py b/packages/python/sdk/tests/test_client.py index 72980894d..c21e307dc 100644 --- a/packages/python/sdk/tests/test_client.py +++ b/packages/python/sdk/tests/test_client.py @@ -129,24 +129,24 @@ async def test_search_library_async_result_fields(self, api_key: str) -> None: class TestGetContextSync: """Tests for the synchronous get_context method.""" - def test_get_context_text_default(self, api_key: str) -> None: - """Test getting context as text (default, sync).""" + def test_get_context_json_default(self, api_key: str) -> None: + """Test getting context as JSON (default, sync).""" with Context7(api_key=api_key) as client: - context = client.get_context("How to use hooks", "/facebook/react") - assert isinstance(context, str) - assert len(context) > 0 - - def test_get_context_json(self, api_key: str) -> None: - """Test getting context as JSON (sync).""" - with Context7(api_key=api_key) as client: - docs = client.get_context("How to use hooks", "/facebook/react", type="json") + docs = client.get_context("How to use hooks", "/facebook/react") assert isinstance(docs, list) assert len(docs) > 0 + def test_get_context_text(self, api_key: str) -> None: + """Test getting context as text (sync).""" + with Context7(api_key=api_key) as client: + context = client.get_context("How to use hooks", "/facebook/react", type="txt") + assert isinstance(context, str) + assert len(context) > 0 + def test_get_context_json_fields(self, api_key: str) -> None: """Test that JSON documentation has all expected fields.""" with Context7(api_key=api_key) as client: - docs = client.get_context("How to use hooks", "/facebook/react", type="json") + docs = client.get_context("How to use hooks", "/facebook/react") assert len(docs) > 0 doc = docs[0] assert isinstance(doc, Documentation) @@ -162,29 +162,29 @@ class TestGetContextAsync: """Tests for the asynchronous get_context_async method.""" @pytest.mark.asyncio - async def test_get_context_async_text_default(self, api_key: str) -> None: - """Test getting context as text (default, async).""" + async def test_get_context_async_json_default(self, api_key: str) -> None: + """Test getting context as JSON (default, async).""" async with Context7(api_key=api_key) as client: - context = await client.get_context_async("How to use hooks", "/facebook/react") - assert isinstance(context, str) - assert len(context) > 0 + docs = await client.get_context_async("How to use hooks", "/facebook/react") + assert isinstance(docs, list) + assert len(docs) > 0 @pytest.mark.asyncio - async def test_get_context_async_json(self, api_key: str) -> None: - """Test getting context as JSON (async).""" + async def test_get_context_async_text(self, api_key: str) -> None: + """Test getting context as text (async).""" async with Context7(api_key=api_key) as client: - docs = await client.get_context_async( - "How to use hooks", "/facebook/react", type="json" + context = await client.get_context_async( + "How to use hooks", "/facebook/react", type="txt" ) - assert isinstance(docs, list) - assert len(docs) > 0 + assert isinstance(context, str) + assert len(context) > 0 @pytest.mark.asyncio async def test_get_context_async_json_fields(self, api_key: str) -> None: """Test that JSON documentation has all expected fields (async).""" async with Context7(api_key=api_key) as client: docs = await client.get_context_async( - "How to use hooks", "/facebook/react", type="json" + "How to use hooks", "/facebook/react" ) assert len(docs) > 0 doc = docs[0] @@ -223,10 +223,11 @@ def test_multiple_requests_same_client(self, api_key: str) -> None: with Context7(api_key=api_key) as client: libraries1 = client.search_library("I need a UI library", "react") libraries2 = client.search_library("I need a UI library", "vue") - context = client.get_context("How to use hooks", "/facebook/react") + docs = client.get_context("How to use hooks", "/facebook/react") assert libraries1 is not None assert libraries2 is not None - assert context is not None + assert docs is not None + assert isinstance(docs, list) class TestAsyncContextManager: @@ -258,7 +259,8 @@ async def test_multiple_requests_same_client_async(self, api_key: str) -> None: async with Context7(api_key=api_key) as client: libraries1 = await client.search_library_async("I need a UI library", "react") libraries2 = await client.search_library_async("I need a UI library", "vue") - context = await client.get_context_async("How to use hooks", "/facebook/react") + docs = await client.get_context_async("How to use hooks", "/facebook/react") assert libraries1 is not None assert libraries2 is not None - assert context is not None + assert docs is not None + assert isinstance(docs, list) From 22b9c041adc5187961576f7cf332d9d1b8a3dba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 9 Jan 2026 13:14:03 +0300 Subject: [PATCH 20/22] docs: add python sdk docs --- docs/docs.json | 10 ++ docs/sdks/python/commands/get-context.mdx | 150 +++++++++++++++++ docs/sdks/python/commands/search-library.mdx | 164 +++++++++++++++++++ docs/sdks/python/getting-started.mdx | 159 ++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100644 docs/sdks/python/commands/get-context.mdx create mode 100644 docs/sdks/python/commands/search-library.mdx create mode 100644 docs/sdks/python/getting-started.mdx diff --git a/docs/docs.json b/docs/docs.json index 5e323a0f3..0df601ffe 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -49,6 +49,16 @@ "pages": ["sdks/ts/commands/search-library", "sdks/ts/commands/get-context"] } ] + }, + { + "group": "Python", + "pages": [ + "sdks/python/getting-started", + { + "group": "Commands", + "pages": ["sdks/python/commands/search-library", "sdks/python/commands/get-context"] + } + ] } ] }, diff --git a/docs/sdks/python/commands/get-context.mdx b/docs/sdks/python/commands/get-context.mdx new file mode 100644 index 000000000..7c1e3262a --- /dev/null +++ b/docs/sdks/python/commands/get-context.mdx @@ -0,0 +1,150 @@ +--- +title: "Get Context" +description: "Retrieve documentation context for a library" +--- + +# Get Context + +Retrieve documentation context for a specific library. Returns documentation as a list of documentation objects (default) or as plain text. + +## Arguments + + + The user's question or task (used for relevance ranking) + + + + The library identifier (e.g., `/facebook/react`) + + + + Format of the response + - `json`: List of Documentation objects (default) + - `txt`: Plain text format + + Default: `"json"` + + +## Response + +The response type depends on the `type` parameter. + +### JSON Format (`type="json"` or default) + +Returns `list[Documentation]` - a list of documentation objects. + +### Text Format (`type="txt"`) + +Returns a `str` containing the documentation context ready to use in LLM prompts. + + + + + Title of the documentation snippet + + + The documentation content (includes code blocks in markdown format) + + + Source identifier for the snippet + + + + +## Examples + + +```python JSON Format (Default) +from context7 import Context7 + +with Context7() as client: + docs = client.get_context( + "How do I use hooks?", + "/facebook/react" + ) + + for doc in docs: + print(doc.title) + print(doc.content) + print(doc.source) +``` + +```python Text Format +from context7 import Context7 + +with Context7() as client: + context = client.get_context( + "How do I use hooks?", + "/facebook/react", + type="txt" + ) + print(context) +``` + +```python Async Usage +import asyncio +from context7 import Context7 + +async def main(): + async with Context7() as client: + context = await client.get_context_async( + "How do I use hooks?", + "/facebook/react" + ) + print(context) + +asyncio.run(main()) +``` + +```python Error Handling +from context7 import Context7, Context7Error + +with Context7() as client: + try: + context = client.get_context( + "How to get started?", + "/invalid/library" + ) + except Context7Error as e: + print(f"API Error: {e}") +``` + + +## Use Cases + +### Providing Context to LLMs + +```python +from context7 import Context7 + +def get_docs_for_prompt(library: str, question: str) -> str: + with Context7() as client: + context = client.get_context(question, library, type="txt") + + return f""" +Here is the relevant documentation: + +{context} + +User question: {question} +""" + +prompt = get_docs_for_prompt("/facebook/react", "How do I use useEffect?") +``` + +### Processing Individual Snippets + +```python +from context7 import Context7 + +with Context7() as client: + docs = client.get_context( + "How to create components?", + "/facebook/react" + ) + + for doc in docs: + print(f"Title: {doc.title}") + print(f"Source: {doc.source}") + print(f"Content length: {len(doc.content)} chars") +``` diff --git a/docs/sdks/python/commands/search-library.mdx b/docs/sdks/python/commands/search-library.mdx new file mode 100644 index 000000000..739b548f9 --- /dev/null +++ b/docs/sdks/python/commands/search-library.mdx @@ -0,0 +1,164 @@ +--- +title: "Search Library" +description: "Search available libraries" +--- + +# Search Library + +Search across available libraries. Returns a list of matching libraries with metadata useful for selection. + +## Arguments + + + The user's question or task (used for relevance ranking) + + + + The library name to search for + + +## Response + +Returns `list[Library]` - a list of library objects. + + + + + Unique identifier for the library (e.g., `/facebook/react`) + + + Display name of the library + + + Brief description of the library + + + Total number of documentation snippets available + + + Trust score of the library + + + Benchmark score indicating documentation quality + + + Available versions + + + + +## Examples + + +```python Basic Search +from context7 import Context7 + +with Context7() as client: + libraries = client.search_library( + "I need to build a UI", + "react" + ) + + print(f"Found {len(libraries)} libraries") + for library in libraries: + print(f"{library.name}: {library.description}") +``` + +```python Async Usage +import asyncio +from context7 import Context7 + +async def main(): + async with Context7() as client: + libraries = await client.search_library_async( + "I need to build a UI", + "react" + ) + + print(f"Found {len(libraries)} libraries") + for library in libraries: + print(f"{library.name}: {library.description}") + +asyncio.run(main()) +``` + +```python Selecting Best Library +from context7 import Context7 + +with Context7() as client: + libraries = client.search_library( + "I want to build a web app", + "next" + ) + + sorted_libs = sorted(libraries, key=lambda x: x.benchmark_score, reverse=True) + best = sorted_libs[0] + + print(f"Best match: {best.name} (score: {best.benchmark_score})") + print(f"Available snippets: {best.total_snippets}") +``` + +```python Error Handling +from context7 import Context7, Context7Error + +with Context7() as client: + try: + libraries = client.search_library("query", "express") + + if not libraries: + print("No libraries found") + else: + print(f"Found {len(libraries)} libraries") + except Context7Error as e: + print(f"API Error: {e}") +``` + + +## Use Cases + +### Finding Libraries by Score + +```python +from context7 import Context7 + +with Context7() as client: + libraries = client.search_library("state management", "redux") + + trusted = sorted(libraries, key=lambda x: x.trust_score, reverse=True) + print(f"Most trusted: {trusted[0].name}") +``` + +### Checking Available Versions + +```python +from context7 import Context7 + +with Context7() as client: + libraries = client.search_library("I want to use lodash", "lodash") + + library = libraries[0] + if library.versions: + print(f"Available versions: {', '.join(library.versions)}") +``` + +### Full Workflow + +```python +from context7 import Context7 + +with Context7() as client: + libraries = client.search_library( + "I want to build a React app", + "react" + ) + + library = libraries[0] + print(f"Using: {library.name} ({library.id})") + + context = client.get_context( + "How do I create a component?", + library.id + ) + + print(context) +``` diff --git a/docs/sdks/python/getting-started.mdx b/docs/sdks/python/getting-started.mdx new file mode 100644 index 000000000..0d5a653d8 --- /dev/null +++ b/docs/sdks/python/getting-started.mdx @@ -0,0 +1,159 @@ +--- +title: "Getting Started" +description: "Get started with the Context7 Python SDK" +--- + + + **Work in Progress**: This SDK is currently under active development. The API is subject to change and may introduce breaking changes in future releases. + + +# Getting Started + +`context7` is a Python SDK for Context7, enabling easier access to library documentation with full type hints. + +Using `context7` you can: + +- Search across available libraries +- Get documentation context for any library +- Access library metadata including trust scores and versions + +You can find the Github Repository [here](https://github.com/upstash/context7/tree/master/packages/python/sdk). + +## Install + + + ```shell pip + pip install context7 + ``` + +```shell uv +uv add context7 +``` + +```shell poetry +poetry add context7 +``` + + + +## Usage + +### Initializing the Client + +To use the Context7 SDK, you need an API key. You can get your API key from the [Context7 Dashboard](https://context7.com/dashboard). + +#### Using environment variables + +The SDK automatically reads from environment variables if no API key is provided: + +```bash +CONTEXT7_API_KEY="your_api_key_here" +``` + +When an environment variable is set, you can initialize the client without any parameters: + +```python +from context7 import Context7 + +with Context7() as client: + # Use the client + pass +``` + +#### Using a configuration parameter + +If you prefer to pass the API key in code: + +```python +from context7 import Context7 + +with Context7(api_key="ctx7sk_...") as client: + # Use the client + pass +``` + + + The SDK checks for API keys in this order: 1. `api_key` parameter (if provided) 2. + `CONTEXT7_API_KEY` environment variable + + +## Quick Start Example + +### Synchronous Usage + +```python +from context7 import Context7 + +with Context7() as client: + # Search for libraries + libraries = client.search_library( + "I need to build a UI with components", + "react" + ) + print(f"Found {len(libraries)} libraries") + print(libraries[0].id) # "/facebook/react" + + # Get documentation as JSON array (default) + docs = client.get_context( + "How do I use hooks?", + "/facebook/react" + ) + print(docs[0].title, docs[0].content) + + # Get documentation as plain text + context = client.get_context( + "How do I use hooks?", + "/facebook/react", + type="txt" + ) + print(context) +``` + +### Asynchronous Usage + +```python +import asyncio +from context7 import Context7 + +async def main(): + async with Context7() as client: + # Search for libraries + libraries = await client.search_library_async( + "I need to build a UI with components", + "react" + ) + print(f"Found {len(libraries)} libraries") + print(libraries[0].id) # "/facebook/react" + + # Get documentation context (JSON array by default) + docs = await client.get_context_async( + "How do I use hooks?", + "/facebook/react" + ) + print(docs[0].title, docs[0].content) + +asyncio.run(main()) +``` + +## Error Handling + +The SDK raises `Context7Error` for API errors: + +```python +from context7 import Context7, Context7Error + +with Context7() as client: + try: + context = client.get_context("query", "/invalid/library") + except Context7Error as e: + print(f"Context7 API Error: {e}") + except Exception as e: + print(f"Unexpected error: {e}") +``` + +## Next Steps + +Explore the SDK commands: + +- [Search Library](/sdks/python/commands/search-library) - Search for libraries +- [Get Context](/sdks/python/commands/get-context) - Retrieve library documentation context From 248226090445566d56ff1bf411967c7558383972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 9 Jan 2026 13:15:26 +0300 Subject: [PATCH 21/22] chore: remove changeset workflow --- .github/workflows/changeset-check.yml | 60 --------------------------- 1 file changed, 60 deletions(-) delete mode 100644 .github/workflows/changeset-check.yml diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml deleted file mode 100644 index e490614fc..000000000 --- a/.github/workflows/changeset-check.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Changeset Check - -on: - pull_request: - types: [opened, synchronize, reopened, labeled, unlabeled] - -jobs: - check: - name: Check for Changeset - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Check for changeset - uses: actions/github-script@v7 - with: - script: | - const { execSync } = require('child_process'); - - const labels = context.payload.pull_request.labels.map(l => l.name); - if (labels.includes('skip-changeset')) { - console.log('Skipping changeset check for PR with skip-changeset label'); - return; - } - - const baseSha = context.payload.pull_request.base.sha; - const headSha = context.payload.pull_request.head.sha; - - const diff = execSync( - `git diff --name-only ${baseSha}...${headSha}`, - { encoding: 'utf-8' } - ); - - const changedFiles = diff.split('\n').filter(f => f.length > 0); - - // Skip changeset check if only Python files, workflow files, or markdown files are changed - const onlySkippableChanges = changedFiles.every(file => - file.startsWith('packages/python/') || - file.startsWith('.github/workflows/') || - file.endsWith('.md') - ); - - if (onlySkippableChanges && changedFiles.length > 0) { - console.log('Skipping changeset check - only Python package, workflow, or markdown changes detected'); - return; - } - - const hasChangeset = changedFiles - .some(file => file.startsWith('.changeset/') && file.endsWith('.md') && !file.includes('README')); - - if (!hasChangeset) { - core.setFailed( - 'No changeset found. Please add a changeset using `pnpm changeset` or add the "skip-changeset" label if this PR doesn\'t need one (e.g., docs, CI changes).' - ); - } else { - console.log('Changeset found!'); - } From 1140e5976fe9ca82f8f21bd0beefafa27e80e4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 9 Jan 2026 13:15:58 +0300 Subject: [PATCH 22/22] fmt: python sdk --- packages/python/sdk/tests/test_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/python/sdk/tests/test_client.py b/packages/python/sdk/tests/test_client.py index c21e307dc..1742dc35b 100644 --- a/packages/python/sdk/tests/test_client.py +++ b/packages/python/sdk/tests/test_client.py @@ -183,9 +183,7 @@ async def test_get_context_async_text(self, api_key: str) -> None: async def test_get_context_async_json_fields(self, api_key: str) -> None: """Test that JSON documentation has all expected fields (async).""" async with Context7(api_key=api_key) as client: - docs = await client.get_context_async( - "How to use hooks", "/facebook/react" - ) + docs = await client.get_context_async("How to use hooks", "/facebook/react") assert len(docs) > 0 doc = docs[0] assert isinstance(doc, Documentation)