diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml deleted file mode 100644 index 3ce60c1e3..000000000 --- a/.github/workflows/changeset-check.yml +++ /dev/null @@ -1,47 +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 hasChangeset = diff - .split('\n') - .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!'); - } diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 000000000..4b208dbd0 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,96 @@ +name: Python CI + +on: + push: + branches: [main, master] + paths: + - "packages/python/**" + - ".github/workflows/python-ci.yml" + pull_request: + branches: [main, master] + paths: + - "packages/python/**" + - ".github/workflows/python-ci.yml" + +defaults: + run: + working-directory: packages/python + +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/python/sdk + run: uv build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: python-sdk-dist + path: packages/python/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/python/sdk/dist/ + + - name: Publish to PyPI + working-directory: packages/python/sdk + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: uv publish 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/docs/docs.json b/docs/docs.json index dd3b9eb95..9fe142756 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -53,6 +53,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 diff --git a/package.json b/package.json index 8e2f2accc..bc515508a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "version": "1.0.0", "description": "Context7 monorepo - Documentation tools and SDKs", "workspaces": [ - "packages/*" + "packages/*", + "!packages/python" ], "scripts": { "build": "pnpm -r run build", @@ -21,7 +22,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/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/python/.python-version b/packages/python/.python-version new file mode 100644 index 000000000..2c0733315 --- /dev/null +++ b/packages/python/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml new file mode 100644 index 000000000..303a7d491 --- /dev/null +++ b/packages/python/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/python/sdk/README.md b/packages/python/sdk/README.md new file mode 100644 index 000000000..1826fcc51 --- /dev/null +++ b/packages/python/sdk/README.md @@ -0,0 +1,69 @@ +# 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(): + async with Context7(api_key="ctx7sk_...") as client: + # Search for libraries + 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()) +``` + +## 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. + +### `client.search_library(query: str, library_name: str) -> list[Library]` + +Search for libraries matching the given query. + +- `query`: The user's question or task (used for relevance ranking) +- `library_name`: The library name to search for + +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:** + +- `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 + +MIT diff --git a/packages/python/sdk/pyproject.toml b/packages/python/sdk/pyproject.toml new file mode 100644 index 000000000..1aa886153 --- /dev/null +++ b/packages/python/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/python/sdk/src/context7/__init__.py b/packages/python/sdk/src/context7/__init__.py new file mode 100644 index 000000000..d6d581151 --- /dev/null +++ b/packages/python/sdk/src/context7/__init__.py @@ -0,0 +1,41 @@ +""" +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 + libraries = await client.search_library_async( + "I need a UI library", "react" + ) + + # Get documentation context + context = await client.get_context_async( + "How to use hooks", "/facebook/react" + ) + print(context) + + asyncio.run(main()) + ``` +""" + +from context7.client import Context7 +from context7.errors import Context7APIError, Context7Error +from context7.models import Documentation, Library + +__all__ = [ + "Context7", + "Context7Error", + "Context7APIError", + "Library", + "Documentation", +] + +__version__ = "0.1.0" diff --git a/packages/python/sdk/src/context7/client.py b/packages/python/sdk/src/context7/client.py new file mode 100644 index 000000000..985c57f81 --- /dev/null +++ b/packages/python/sdk/src/context7/client.py @@ -0,0 +1,389 @@ +"""Main Context7 client for interacting with the Context7 API.""" + +from __future__ import annotations + +import os +import warnings +from typing import Any, Literal, overload + +from context7.errors import Context7Error +from context7.http import HttpClient +from context7.models import ( + ApiCodeSnippet, + ApiContextJsonResponse, + ApiInfoSnippet, + ApiSearchResult, + Documentation, + Library, +) + +DEFAULT_BASE_URL = "https://context7.com/api" +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 _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) + + 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)) + + return docs + + +class Context7: + """ + Context7 Python SDK client. + + The Context7 client provides methods to search for libraries and retrieve + documentation optimized for AI agents and LLMs. + + 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 + libraries = client.search_library("I need a UI library", "react") + for lib in libraries: + print(f"{lib.id}: {lib.name}") + + # Get documentation context + docs = client.get_context("How to use hooks", "/facebook/react") + for doc in docs: + print(f"{doc.title}: {doc.content[:100]}...") + ``` + + Asynchronous Usage: + ```python + import asyncio + from context7 import Context7 + + async def main(): + async with Context7(api_key="ctx7sk_...") as client: + # Search for libraries + 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 + docs = await client.get_context_async( + "How to use hooks", "/facebook/react" + ) + for doc in docs: + print(f"{doc.title}: {doc.content[:100]}...") + + 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 = _validate_api_key(api_key) + http_headers = {"Authorization": f"Bearer {resolved_api_key}"} + resolved_base_url = base_url or DEFAULT_BASE_URL + + self._http = HttpClient( + 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_async() + + def close(self) -> None: + """Close the sync HTTP client connection.""" + self._http.close() + + async def close_async(self) -> None: + """Close the async HTTP client connection.""" + await self._http.close_async() + + # Synchronous methods + def search_library(self, query: str, library_name: str) -> list[Library]: + """ + Search for libraries matching the given query. + + Args: + query: The user's question or task (used for relevance ranking). + library_name: The library name to search for. + + Returns: + List of matching libraries. + + Example: + ```python + 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", "libs", "search"], + query={"query": query, "libraryName": library_name}, + ) + api_results = [ApiSearchResult.model_validate(item) for item in result["results"]] + return [_format_library(r) for r in api_results] + + @overload + def get_context( + self, + query: str, + library_id: str, + *, + type: Literal["json"] = "json", # pylint: disable=redefined-builtin + ) -> list[Documentation]: ... + + @overload + def get_context( + self, + query: str, + library_id: str, + *, + type: Literal["txt"], # pylint: disable=redefined-builtin + ) -> str: ... + + def get_context( + self, + query: str, + library_id: str, + *, + type: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin + ) -> list[Documentation] | str: + """ + Get documentation context for a library. + + Args: + query: The user's question or task. + library_id: Context7 library ID (e.g., "/facebook/react"). + type: Response format: + - "json": List of Documentation objects (default) + - "txt": Plain text documentation + + Returns: + Documentation as list of Documentation objects (json) or string (txt). + + Example: + ```python + # 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 plain text + context = client.get_context( + "How to use hooks", + "/facebook/react", + type="txt" + ) + print(context) + ``` + """ + result = self._http.request( + method="GET", + path=["v2", "context"], + query={ + "query": query, + "libraryId": library_id, + "type": type, + }, + ) + return _process_context_response(result, type) + + # Asynchronous methods + async def search_library_async(self, query: str, library_name: str) -> list[Library]: + """ + Search for libraries matching the given query (async version). + + Args: + query: The user's question or task (used for relevance ranking). + library_name: The library name to search for. + + Returns: + List of matching libraries. + + Example: + ```python + 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", "libs", "search"], + query={"query": query, "libraryName": library_name}, + ) + api_results = [ApiSearchResult.model_validate(item) for item in result["results"]] + return [_format_library(r) for r in api_results] + + @overload + async def get_context_async( + self, + query: str, + library_id: str, + *, + type: Literal["json"] = "json", # pylint: disable=redefined-builtin + ) -> list[Documentation]: ... + + @overload + async def get_context_async( + self, + query: str, + library_id: str, + *, + type: Literal["txt"], # pylint: disable=redefined-builtin + ) -> str: ... + + async def get_context_async( + self, + query: str, + library_id: str, + *, + type: Literal["json", "txt"] = "json", # pylint: disable=redefined-builtin + ) -> list[Documentation] | str: + """ + Get documentation context for a library (async version). + + Args: + query: The user's question or task. + library_id: Context7 library ID (e.g., "/facebook/react"). + type: Response format: + - "json": List of Documentation objects (default) + - "txt": Plain text documentation + + Returns: + Documentation as list of Documentation objects (json) or string (txt). + + Example: + ```python + # Get context as structured data (default) + docs = await client.get_context_async( + "How to use hooks", "/facebook/react" + ) + for doc in docs: + print(f"{doc.title}: {doc.content[:100]}...") + + # Get context as plain text + context = await client.get_context_async( + "How to use hooks", + "/facebook/react", + type="txt" + ) + print(context) + ``` + """ + result = await self._http.request_async( + method="GET", + path=["v2", "context"], + query={ + "query": query, + "libraryId": library_id, + "type": type, + }, + ) + return _process_context_response(result, type) diff --git a/packages/python/sdk/src/context7/errors.py b/packages/python/sdk/src/context7/errors.py new file mode 100644 index 000000000..f51c4f24f --- /dev/null +++ b/packages/python/sdk/src/context7/errors.py @@ -0,0 +1,19 @@ +"""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) diff --git a/packages/python/sdk/src/context7/http.py b/packages/python/sdk/src/context7/http.py new file mode 100644 index 000000000..7fa1a90bc --- /dev/null +++ b/packages/python/sdk/src/context7/http.py @@ -0,0 +1,154 @@ +"""HTTP client with retry logic for Context7 API.""" + +from __future__ import annotations + +import asyncio +import math +import time +from typing import Any + +import httpx + +from context7.errors import Context7APIError + + +class HttpClient: + """HTTP client with retry logic for Context7 API (sync and async).""" + + 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._sync_client: httpx.Client | None = None + self._async_client: httpx.AsyncClient | None = None + + def _get_sync_client(self) -> httpx.Client: + """Get or create the sync HTTP 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._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 sync HTTP client connection.""" + if self._sync_client is not None: + self._sync_client.close() + self._sync_client = None + + 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 + + def _backoff(self, retry_count: int) -> float: + """Calculate exponential backoff delay (matches TS SDK).""" + 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, + ) -> 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} + client = self._get_sync_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, + ) + return self._handle_response(response) + 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") + + async def request_async( + self, + method: str, + path: list[str] | None = None, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = 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} + client = await self._get_async_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, + ) + return self._handle_response(response) + 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 _handle_response(self, response: httpx.Response) -> Any: + """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() + else: + return response.text diff --git a/packages/python/sdk/src/context7/models.py b/packages/python/sdk/src/context7/models.py new file mode 100644 index 000000000..ff11706ae --- /dev/null +++ b/packages/python/sdk/src/context7/models.py @@ -0,0 +1,91 @@ +"""Pydantic models for Context7 API request/response types.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class Library(BaseModel): + """A library available in Context7.""" + + id: str + """Context7 library ID (e.g., "/facebook/react")""" + + name: str + """Library display name""" + + description: str + """Library description""" + + total_snippets: int = Field(default=0) + """Number of documentation snippets available""" + + trust_score: float = Field(default=0.0) + """Source reputation score (0-10)""" + + benchmark_score: float = Field(default=0.0) + """Quality indicator score (0-100)""" + + versions: list[str] | None = None + """Available versions/tags""" + + +class Documentation(BaseModel): + """A piece of documentation content.""" + + title: str + """Title of the documentation snippet""" + + content: str + """The documentation content (may include code blocks in markdown format)""" + + source: str + """Source URL or identifier for the snippet""" + + +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_list: list[dict[str, str]] = Field(alias="codeList") + code_id: str = Field(alias="codeId") + code_tokens: int | None = Field(default=None, alias="codeTokens") + page_title: str | None = Field(default=None, alias="pageTitle") + + model_config = ConfigDict(populate_by_name=True) + + +class ApiInfoSnippet(BaseModel): + """Internal: An info snippet from the API response.""" + + content: str + breadcrumb: str | None = None + page_id: str = Field(alias="pageId") + content_tokens: int | None = Field(default=None, alias="contentTokens") + + model_config = ConfigDict(populate_by_name=True) + + +class ApiContextJsonResponse(BaseModel): + """Internal: JSON response from the context API.""" + + code_snippets: list[ApiCodeSnippet] = Field(alias="codeSnippets") + info_snippets: list[ApiInfoSnippet] = Field(alias="infoSnippets") + + model_config = ConfigDict(populate_by_name=True) + + +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") + + model_config = ConfigDict(populate_by_name=True) diff --git a/packages/python/sdk/src/context7/py.typed b/packages/python/sdk/src/context7/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/packages/python/sdk/tests/__init__.py b/packages/python/sdk/tests/__init__.py new file mode 100644 index 000000000..8b6e6c7ec --- /dev/null +++ b/packages/python/sdk/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Context7 Python SDK.""" diff --git a/packages/python/sdk/tests/conftest.py b/packages/python/sdk/tests/conftest.py new file mode 100644 index 000000000..d4a8db62b --- /dev/null +++ b/packages/python/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/python/sdk/tests/test_client.py b/packages/python/sdk/tests/test_client.py new file mode 100644 index 000000000..1742dc35b --- /dev/null +++ b/packages/python/sdk/tests/test_client.py @@ -0,0 +1,264 @@ +"""Tests for the Context7 client.""" + +import os + +import pytest +from context7 import Context7, Context7Error, Documentation, Library + + +@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: + 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 + 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.""" + 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 + assert client._http 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") + + 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.""" + + def test_search_library(self, api_key: str) -> None: + """Test basic library search (sync).""" + with Context7(api_key=api_key) as client: + 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: + 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("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: + 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 + names = [lib.name.lower() for lib in libraries] + assert any("next" in n for n in names) + + +class TestSearchLibraryAsync: + """Tests for the asynchronous search_library_async method.""" + + @pytest.mark.asyncio + 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: + 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: + 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_json_default(self, api_key: str) -> None: + """Test getting context as JSON (default, sync).""" + with Context7(api_key=api_key) as client: + 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") + 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 TestGetContextAsync: + """Tests for the asynchronous get_context_async method.""" + + @pytest.mark.asyncio + 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: + 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_text(self, api_key: str) -> None: + """Test getting context as text (async).""" + async with Context7(api_key=api_key) as client: + context = await client.get_context_async( + "How to use hooks", "/facebook/react", type="txt" + ) + 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") + 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: + """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: + assert client is not None + 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) + 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 + + 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: + libraries1 = client.search_library("I need a UI library", "react") + libraries2 = client.search_library("I need a UI library", "vue") + docs = client.get_context("How to use hooks", "/facebook/react") + assert libraries1 is not None + assert libraries2 is not None + assert docs is not None + assert isinstance(docs, list) + + +class TestAsyncContextManager: + """Tests for async context manager behavior.""" + + @pytest.mark.asyncio + 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 + 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) + 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 + + @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: + libraries1 = await client.search_library_async("I need a UI library", "react") + libraries2 = await client.search_library_async("I need a UI library", "vue") + docs = await client.get_context_async("How to use hooks", "/facebook/react") + assert libraries1 is not None + assert libraries2 is not None + assert docs is not None + assert isinstance(docs, list) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e928..4fb638d6d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - "packages/*" + - "!packages/python"