diff --git a/.github/workflows/ecr-deploy.yml b/.github/workflows/ecr-deploy.yml index f998b2de..be24f26c 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 34cd0a94..f8890529 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/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 00000000..132c397d --- /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/.github/workflows/release.yml b/.github/workflows/release.yml index 760e15f8..1d6edc15 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 8ed73794..bf6f3bed 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 a777d6fd..62a9439c 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/py/.python-version b/packages/py/.python-version new file mode 100644 index 00000000..2c073331 --- /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 00000000..303a7d49 --- /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 00000000..516b9df3 --- /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-sdk 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}") + + # 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 00000000..1aa88615 --- /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 00000000..4ff22e77 --- /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-sdk 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 00000000..010959ea --- /dev/null +++ b/packages/py/sdk/src/context7/client.py @@ -0,0 +1,492 @@ +"""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, Context7ValidationError +from context7.http import HttpClient, TxtResponseHeaders +from context7.models import ( + CodeDocsResponse, + InfoDocsResponse, + Pagination, + SearchLibraryResponse, + TextDocsResponse, +) + +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 _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[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, + ) + + if mode == "info": + return InfoDocsResponse.model_validate(result) + return CodeDocsResponse.model_validate(result) + + +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-sdk 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-sdk 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}") + + 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) -> SearchLibraryResponse: + """ + Search for libraries by name or description. + + Args: + query: Search query string. + + Returns: + SearchLibraryResponse containing matching libraries and metadata. + + Example: + ```python + results = client.search_library("react") + for lib in results.results: + print(f"{lib.id}: {lib.title} ({lib.total_tokens} tokens)") + ``` + """ + result, _ = self._http.request( + method="GET", + path=["v2", "search"], + query={"query": query}, + ) + 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: ... + + @overload + def get_docs( + 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 + def get_docs( + 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 + 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: ... + + 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: + """ + 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 = 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") + ``` + """ + path, query = _build_docs_request( + library_id, version, page, topic, limit, mode, format + ) + result, headers = self._http.request( + method="GET", + path=path, + query=query, + ) + return _process_docs_response(result, headers, mode, format) + + # Asynchronous methods + 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._http.request_async( + 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: ... + + 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._http.request_async( + 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 new file mode 100644 index 00000000..c9eee167 --- /dev/null +++ b/packages/py/sdk/src/context7/errors.py @@ -0,0 +1,23 @@ +"""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.""" diff --git a/packages/py/sdk/src/context7/http.py b/packages/py/sdk/src/context7/http.py new file mode 100644 index 00000000..d7f29888 --- /dev/null +++ b/packages/py/sdk/src/context7/http.py @@ -0,0 +1,181 @@ +"""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 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).""" + + 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, + ) -> tuple[Any, TxtResponseHeaders | None]: + """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, + ) -> tuple[Any, TxtResponseHeaders | None]: + """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) -> 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/src/context7/models.py b/packages/py/sdk/src/context7/models.py new file mode 100644 index 00000000..85de1bdc --- /dev/null +++ b/packages/py/sdk/src/context7/models.py @@ -0,0 +1,154 @@ +"""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, ConfigDict, 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 = ConfigDict(validate_by_name=True, validate_by_alias=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 = ConfigDict(validate_by_name=True, validate_by_alias=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 = 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 + + +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".""" + + +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 00000000..e69de29b diff --git a/packages/py/sdk/tests/__init__.py b/packages/py/sdk/tests/__init__.py new file mode 100644 index 00000000..8b6e6c7e --- /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 00000000..d4a8db62 --- /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 00000000..e1667eaa --- /dev/null +++ b/packages/py/sdk/tests/test_client.py @@ -0,0 +1,428 @@ +"""Tests for the Context7 client.""" + +import os + +import pytest +from context7 import Context7, Context7Error, Context7ValidationError +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: + 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: + 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 + + 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 + + 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: + """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: + 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 + + @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).""" + 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.""" + 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).""" + 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.""" + + @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 + + @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: + """Test documentation pagination (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") + + +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 + 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: + """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 + 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: + """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 + 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) 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 8425abb8..16da37d0 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 624d4ca3..c3aaa5f4 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 9bc2006c..f60b5f6c 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 58cde648..382d67e1 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 dee51e92..a3731046 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,2 @@ packages: - - "packages/*" + - "packages/ts/*"