diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9af7645 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,124 @@ +name: Release Binaries + +# Builds the self-contained `db` CLI binary for each supported platform and +# publishes them (with SHA256 checksums) to a GitHub Release. Triggered by +# pushing a version tag (e.g. v0.2.1); also runnable manually for dry runs. +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + +# Re-pushing a tag (or re-dispatching) cancels an in-flight run for the same +# ref so a stuck build can't pile up behind a newer attempt. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ matrix.target_os }}-${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + # Cap the wait so a capacity-starved runner fails this leg in minutes + # instead of hanging the release for GitHub's 24h queue limit. + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + # Built on the oldest practical glibc for forward compatibility. + - runner: ubuntu-22.04 + target_os: linux + arch: x86_64 + # GitHub-hosted ARM Linux runner: free for public repos; requires a + # Team/Enterprise plan for private repos. + - runner: ubuntu-22.04-arm + target_os: linux + arch: aarch64 + # macOS is Apple Silicon only: GitHub's Intel (macos-13) runners are + # being deprecated and their queue is unreliable. Intel-Mac users can + # still `pip install diffbot-python`. + - runner: macos-14 # Apple Silicon + target_os: darwin + arch: aarch64 + steps: + - uses: actions/checkout@v4 + + # Fail fast if the tag doesn't match pyproject.toml's version, so the + # GitHub Release name can never disagree with the version the binaries + # report (db --version) or the one published to PyPI. + - name: Check tag matches package version + if: startsWith(github.ref, 'refs/tags/') + run: | + pkg=$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2) + tag=${GITHUB_REF_NAME#v} + if [ "$pkg" != "$tag" ]; then + echo "::error::Tag v${tag} does not match pyproject.toml version ${pkg}. Bump pyproject.toml (e.g. 'make bump-patch') and re-tag." + exit 1 + fi + echo "Tag and package version agree: ${pkg}" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + + - name: Build binary + run: ./scripts/build_binary.sh --arch ${{ matrix.arch }} + + - name: Verify checksum + working-directory: dist + run: | + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "db-${{ matrix.target_os }}-${{ matrix.arch }}.sha256" + else + shasum -a 256 -c "db-${{ matrix.target_os }}-${{ matrix.arch }}.sha256" + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: db-${{ matrix.target_os }}-${{ matrix.arch }} + path: | + dist/db-${{ matrix.target_os }}-${{ matrix.arch }} + dist/db-${{ matrix.target_os }}-${{ matrix.arch }}.sha256 + if-no-files-found: error + + release: + name: Publish GitHub Release + needs: build + runs-on: ubuntu-latest + # Only publish for real tag pushes; workflow_dispatch runs just build + verify. + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: List artifacts + run: ls -lR dist + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + # This job doesn't check out the repo, so gh can't infer it from a local + # git remote — pass --repo explicitly on every gh call. + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + repo="${GITHUB_REPOSITORY}" + if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then + echo "Release $tag exists; uploading assets (clobbering)." + gh release upload "$tag" dist/* --repo "$repo" --clobber + else + echo "Creating release $tag." + gh release create "$tag" \ + --repo "$repo" \ + --title "$tag" \ + --generate-notes \ + dist/* + fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..276f88a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,70 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + 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@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies (locked) + run: uv sync --frozen --extra dev + + - name: Run tests + run: uv run pytest + + # Catch breakage against the lowest dependency versions our ranges allow, + # which the pinned lockfile would otherwise hide. + test-lowest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.9" + + - name: Install lowest allowed dependencies + run: uv sync --resolution lowest-direct --extra dev + + - name: Run tests + run: uv run pytest + + # Smoke-test the standalone binary build so scripts/build_binary.sh can't + # silently break between releases. The release workflow builds the full + # cross-platform matrix; here we just prove the x86_64 Linux build works. + build-binary: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + + - name: Build standalone db binary + run: ./scripts/build_binary.sh + + - name: Verify checksum + working-directory: dist + run: sha256sum -c db-linux-x86_64.sha256 + + - name: Smoke-test binary + run: ./dist/db-linux-x86_64 --version diff --git a/.gitignore b/.gitignore index 79907fe..976e306 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ __pycache__ .pytest_cache .env -uv.lock dist/ build/ *.egg-info/ diff --git a/README.md b/README.md index b66b122..a3fa3b1 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,19 @@ Python client library for [Diffbot](https://www.diffbot.com) APIs. ## Installation +Install the [standalone CLI binary](#standalone-binary) for [agentic use](#how-to-use-with-an-agent): + +```bash +curl -fsSL https://raw.githubusercontent.com/diffbot/diffbot-python/main/install.sh | sh +``` + +If you prefer, the full Python library can also be installed with pip: + ```bash -pip install git+https://github.com/diffbot/diffbot-python.git +python3 -m pip install diffbot-python ``` -Or, for local development: +For local development: ```bash pip install -e ".[dev]" @@ -67,6 +75,42 @@ for chunk in db.ask([{"role": "user", "content": "What's the capital of France?" print(chunk, end="") ``` +### Structured output +Pass a JSON Schema to constrain the answer. The model is held to the schema by a grammar during decoding, so the result parses reliably even though the answer is retrieved live from the web. + +```python +from diffbot import Diffbot + +db = Diffbot(token="YOUR_TOKEN") +schema = { + "type": "object", + "properties": { + "country": {"type": "string"}, + "capital": {"type": "string"}, + }, + "required": ["country", "capital"], +} +answer = db.ask_json([{"role": "user", "content": "What's the capital of France?"}], schema) +print(answer["capital"]) +``` + +Omit the schema to let the model choose the shape, or use `ask` with `response_format` to stream a constrained answer: + +```python +from diffbot import Diffbot, json_schema_format + +db = Diffbot(token="YOUR_TOKEN") +answer = db.ask_json([{"role": "user", "content": "What's the capital of France?"}]) + +for chunk in db.ask( + [{"role": "user", "content": "What's the capital of France?"}], + response_format=json_schema_format(schema), +): + print(chunk, end="") +``` + +> **Avoid `response_format={"type": "json_object"}`.** The endpoint accepts it, but applies no grammar to it — the RAG loop's internal tool call is itself a JSON object, so it can be returned as the final answer. This is reproducible whenever the request includes a system message. `ask_json` therefore defaults to a permissive JSON Schema instead, and raises `ValidationError` if it ever sees a tool call come back. + ### Crawl a site for structured content ```python from diffbot import Diffbot @@ -134,6 +178,30 @@ async def main(): asyncio.run(main()) ``` +### Structured output +```python +import asyncio +from diffbot import DiffbotAsync + +schema = { + "type": "object", + "properties": { + "country": {"type": "string"}, + "capital": {"type": "string"}, + }, + "required": ["country", "capital"], +} + +async def main(): + async with DiffbotAsync(token="YOUR_TOKEN") as db: + answer = await db.ask_json( + [{"role": "user", "content": "What's the capital of France?"}], schema + ) + print(answer["capital"]) + +asyncio.run(main()) +``` + ### Crawl a site for structured content ```python import asyncio @@ -202,11 +270,29 @@ uv tool install . This drops a `db` executable into `~/.local/bin` (ensure it is on your `PATH`). Use `--force` to reinstall or upgrade after changes, or `--editable` to have source edits take effect immediately. Alternatively, a plain `pip install .` (or `pip install -e .`) also installs the `db` entry point into the active environment. +### Standalone binary + +Every release also ships a self-contained `db` binary for Linux (x86_64 and aarch64) and macOS (Apple Silicon) as a Python-free option. The installer detects your platform, verifies the SHA256 checksum, and installs (or upgrades) `db` into `~/.local/bin`: + +```bash +curl -fsSL https://raw.githubusercontent.com/diffbot/diffbot-python/main/install.sh | sh +``` + +Pin a specific release or install location with flags (or the `DB_VERSION` / `DB_INSTALL_DIR` environment variables); re-running the installer upgrades an existing install in place: + +```bash +curl -fsSL https://raw.githubusercontent.com/diffbot/diffbot-python/main/install.sh | sh -s -- --version v0.2.1 --bin-dir ~/bin +``` + +### How to use + ```bash export DIFFBOT_API_TOKEN=your-token-here db extract https://www.example.com db ask "What's the capital of France?" +db ask "What's the capital of France?" --json +db ask "What's the capital of France?" --schema capital.json db crawl https://www.example.com --hops 1 db crawl-list-jobs db crawl-delete-job crawl-1234567890 @@ -216,6 +302,12 @@ db entities "Apple CEO Tim Cook announced record quarterly earnings." db entities "Apple CEO Tim Cook announced record quarterly earnings." -f dql ``` +### How to use with an agent +Once installed, this library will work alongside [`diffbot-skills`](https://github.com/diffbot/diffbot-skills) to enable your agent full access to structuring web knowledge with Diffbot. Diffbot Agent Skills even unlocks some additional skills like crafting DQL from natural language. + +`diffbot-skills` will pick up or install this library automatically. + + ## Tests Run the mock test suite: diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8763257 --- /dev/null +++ b/install.sh @@ -0,0 +1,171 @@ +#!/bin/sh +# +# install.sh — Install (or update) the standalone Diffbot `db` CLI binary. +# +# Detects your platform, downloads the matching binary from the latest GitHub +# release, verifies its SHA256 checksum, and installs it to a bin directory on +# your PATH. Re-running upgrades an existing install in place. +# +# Quick start: +# curl -fsSL https://raw.githubusercontent.com/diffbot/diffbot-python/main/install.sh | sh +# +# Options (also settable via env var): +# --version DB_VERSION Release tag to install (default: latest). +# --bin-dir DB_INSTALL_DIR Install location (default: ~/.local/bin). +# --repo DB_REPO Source repo (default: diffbot/diffbot-python). +# -h, --help +# +# Supported platforms: linux/darwin on x86_64 (x64/amd64) and aarch64 (arm64). +set -eu + +REPO="${DB_REPO:-diffbot/diffbot-python}" +VERSION="${DB_VERSION:-latest}" +BIN_DIR="${DB_INSTALL_DIR:-${HOME}/.local/bin}" +BIN_NAME="db" + +err() { printf 'error: %s\n' "$*" >&2; exit 1; } +info() { printf '%s\n' "$*" >&2; } + +# True if $1 is a Python console-script shim (pip / uv tool / pipx / venv), +# i.e. a text shebang wrapper pointing at python. Our standalone binary is an +# ELF/Mach-O file and never starts with "#!", so this never matches it. +is_python_console_script() { + [ -f "$1" ] || return 1 + [ "$(dd if="$1" bs=2 count=1 2>/dev/null)" = "#!" ] || return 1 + head -n1 "$1" 2>/dev/null | grep -q 'python' +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --version=*) VERSION="${1#*=}"; shift ;; + --bin-dir) BIN_DIR="$2"; shift 2 ;; + --bin-dir=*) BIN_DIR="${1#*=}"; shift ;; + --repo) REPO="$2"; shift 2 ;; + --repo=*) REPO="${1#*=}"; shift ;; + -h | --help) sed -n '2,/^[^#]/p' "$0" | sed 's/^# \{0,1\}//;$d'; exit 0 ;; + *) err "unknown argument: $1 (try --help)" ;; + esac +done + +# --- Detect platform ------------------------------------------------------- +os="$(uname -s)" +case "$os" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) err "unsupported OS: $os (this installer supports Linux and macOS)" ;; +esac + +arch="$(uname -m)" +case "$arch" in + x86_64 | x64 | amd64) arch="x86_64" ;; + aarch64 | arm64) arch="aarch64" ;; + *) err "unsupported architecture: $arch" ;; +esac + +asset="${BIN_NAME}-${os}-${arch}" + +# --- Pick a download tool -------------------------------------------------- +if command -v curl >/dev/null 2>&1; then + download() { curl -fsSL "$1" -o "$2"; } + fetch() { curl -fsSL "$1"; } +elif command -v wget >/dev/null 2>&1; then + download() { wget -qO "$2" "$1"; } + fetch() { wget -qO - "$1"; } +else + err "need curl or wget to download the binary" +fi + +# --- Resolve the release tag ---------------------------------------------- +if [ "$VERSION" = "latest" ]; then + info "Resolving latest release of ${REPO}..." + api="https://api.github.com/repos/${REPO}/releases/latest" + VERSION="$(fetch "$api" | grep -m1 '"tag_name"' \ + | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')" + [ -n "$VERSION" ] || err "could not determine the latest release tag from ${api}" +fi + +base="https://github.com/${REPO}/releases/download/${VERSION}" +info "Installing ${asset} from ${REPO} ${VERSION}" + +# --- Download binary + checksum into a temp dir ---------------------------- +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +info "Downloading binary..." +download "${base}/${asset}" "${tmp}/${asset}" \ + || err "failed to download ${base}/${asset} (no build for ${os}/${arch} in ${VERSION}?)" + +info "Downloading checksum..." +download "${base}/${asset}.sha256" "${tmp}/${asset}.sha256" \ + || err "failed to download checksum ${base}/${asset}.sha256" + +# --- Verify checksum ------------------------------------------------------- +info "Verifying SHA256 checksum..." +( + cd "$tmp" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "${asset}.sha256" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -c "${asset}.sha256" + else + err "need sha256sum or shasum to verify the download" + fi +) >/dev/null 2>&1 || err "checksum verification failed — refusing to install" +info "Checksum OK." + +# --- Install (atomically), updating any existing install ------------------- +mkdir -p "$BIN_DIR" +target="${BIN_DIR}/${BIN_NAME}" + +if [ -e "$target" ]; then + old="$("$target" --version 2>/dev/null || echo "unknown")" + if is_python_console_script "$target"; then + info "" + info "Warning: ${target} looks like a pip/uv-managed 'db' entry point (${old})," + info "not a binary installed by this script. Overwriting it replaces that launcher," + info "but your Python package manager still treats the file as its own — a later" + info "'pip install --upgrade' / 'uv tool upgrade' could clobber it again, and an" + info "uninstall would delete it. To avoid the conflict, remove the managed copy first:" + info " pip uninstall diffbot-python # or: uv tool uninstall diffbot-python" + info "" + info "Proceeding to overwrite ${target}..." + else + info "Updating existing install at ${target} (${old})" + fi +else + info "Installing to ${target}" +fi + +chmod +x "${tmp}/${asset}" +# mv within the same filesystem is atomic; fall back to cp for cross-device. +mv -f "${tmp}/${asset}" "$target" 2>/dev/null || cp -f "${tmp}/${asset}" "$target" + +new="$("$target" --version 2>/dev/null || echo "$VERSION")" +info "" +info "Installed ${new} -> ${target}" + +# --- PATH guidance --------------------------------------------------------- +case ":${PATH}:" in + *":${BIN_DIR}:"*) ;; + *) + info "" + info "Note: ${BIN_DIR} is not on your PATH. Add it, e.g.:" + info " export PATH=\"${BIN_DIR}:\$PATH\"" + ;; +esac + +# Warn if a different `db` shadows the one we just installed. +existing="$(command -v "$BIN_NAME" 2>/dev/null || true)" +if [ -n "$existing" ] && [ "$existing" != "$target" ]; then + info "" + info "Note: another '${BIN_NAME}' is first on your PATH and will take precedence:" + info " ${existing}" + if is_python_console_script "$existing"; then + info " (it looks pip/uv-managed; remove it with 'pip uninstall diffbot-python'" + info " or 'uv tool uninstall diffbot-python', or put ${BIN_DIR} earlier on PATH.)" + fi +fi + +info "" +info "Run '${BIN_NAME} --help' to get started." diff --git a/pyproject.toml b/pyproject.toml index a2bad9a..713212c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,12 @@ build-backend = "hatchling.build" [project] name = "diffbot-python" -version = "0.1.0" -description = "Python client library for the Diffbot APIs" +version = "0.2.3" +description = "Python client library for Diffbot APIs" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.9" license = "MIT" +license-files = ["LICEN[CS]E*"] authors = [ { name = "Jerome Choo", email = "jerome@diffbot.com" }, { name = "Mike Tung", email = "miket@diffbot.com" } @@ -29,11 +30,14 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", + "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", "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Indexing/Search", + "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Text Processing :: Markup :: HTML", "Typing :: Typed", diff --git a/scripts/build_binary.sh b/scripts/build_binary.sh new file mode 100755 index 0000000..5ab2c3f --- /dev/null +++ b/scripts/build_binary.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# +# build_binary.sh — Build a self-contained `db` CLI binary with PyInstaller and +# emit a matching SHA256 checksum. +# +# Usage: +# scripts/build_binary.sh [--arch ] [--name ] [--output-dir ] +# +# Accepted --arch aliases (normalized for the asset name): +# x86_64 | x64 | amd64 -> x86_64 +# aarch64 | arm64 -> aarch64 +# +# PyInstaller does not cross-compile, so --arch must match the host +# architecture; the flag exists only so callers can pass any accepted alias. +# Build on a matching machine (or CI runner) to target a different arch. +# +# Produces, in the output dir (default: ./dist): +# -- the executable +# --.sha256 `sha256sum -c`-compatible checksum +# +# Environment overrides: +# BUILD_PYTHON Python version for the isolated build venv (default 3.12). +set -euo pipefail + +BIN_NAME="db" +OUTPUT_DIR="" +REQUESTED_ARCH="" +BUILD_PYTHON="${BUILD_PYTHON:-3.12}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENTRY_SCRIPT="${REPO_ROOT}/scripts/db_entry.py" + +err() { printf 'error: %s\n' "$*" >&2; exit 1; } +log() { printf '==> %s\n' "$*" >&2; } + +normalize_arch() { + case "$1" in + x86_64 | x64 | amd64) echo "x86_64" ;; + aarch64 | arm64) echo "aarch64" ;; + *) err "unsupported architecture: $1 (expected x86_64|x64|amd64|aarch64|arm64)" ;; + esac +} + +detect_os() { + case "$(uname -s)" in + Linux) echo "linux" ;; + Darwin) echo "darwin" ;; + *) err "unsupported OS: $(uname -s) (expected Linux or Darwin)" ;; + esac +} + +while [ $# -gt 0 ]; do + case "$1" in + --arch) REQUESTED_ARCH="$2"; shift 2 ;; + --arch=*) REQUESTED_ARCH="${1#*=}"; shift ;; + --name) BIN_NAME="$2"; shift 2 ;; + --name=*) BIN_NAME="${1#*=}"; shift ;; + --output-dir) OUTPUT_DIR="$2"; shift 2 ;; + --output-dir=*) OUTPUT_DIR="${1#*=}"; shift ;; + -h | --help) sed -n '2,/^[^#]/p' "$0" | sed 's/^# \{0,1\}//;$d'; exit 0 ;; + *) err "unknown argument: $1 (try --help)" ;; + esac +done + +OS="$(detect_os)" +HOST_ARCH="$(normalize_arch "$(uname -m)")" +if [ -n "$REQUESTED_ARCH" ]; then + ARCH="$(normalize_arch "$REQUESTED_ARCH")" +else + ARCH="$HOST_ARCH" +fi + +if [ "$ARCH" != "$HOST_ARCH" ]; then + err "cannot build a '$ARCH' binary on a '$HOST_ARCH' host: PyInstaller does not + cross-compile. Run this on a '$ARCH' machine or a matching CI runner." +fi + +OUTPUT_DIR="${OUTPUT_DIR:-${REPO_ROOT}/dist}" +WORK_DIR="${REPO_ROOT}/build/pyinstaller" +VENV_DIR="${REPO_ROOT}/build/venv" +ASSET="${BIN_NAME}-${OS}-${ARCH}" + +mkdir -p "$OUTPUT_DIR" "$WORK_DIR" +log "Building ${ASSET} (os=${OS} arch=${ARCH}, python=${BUILD_PYTHON})" + +# --- Isolated build environment -------------------------------------------- +# Prefer uv (the project's standard); fall back to stdlib venv + pip. +if command -v uv >/dev/null 2>&1; then + log "Creating build venv with uv" + uv venv --python "$BUILD_PYTHON" "$VENV_DIR" >&2 + VENV_PY="${VENV_DIR}/bin/python" + uv pip install --python "$VENV_PY" pyinstaller "$REPO_ROOT" >&2 +else + log "uv not found; creating build venv with python -m venv" + python3 -m venv "$VENV_DIR" + VENV_PY="${VENV_DIR}/bin/python" + "$VENV_PY" -m pip install --upgrade pip >&2 + "$VENV_PY" -m pip install pyinstaller "$REPO_ROOT" >&2 +fi + +# --- Freeze the binary ----------------------------------------------------- +# --collect-submodules grabs the dynamically-imported cli subcommands; the +# library reads its own version via importlib.metadata, so bundle that too. +log "Running PyInstaller" +"$VENV_PY" -m PyInstaller \ + --onefile \ + --clean \ + --noconfirm \ + --name "$ASSET" \ + --distpath "$OUTPUT_DIR" \ + --workpath "$WORK_DIR" \ + --specpath "$WORK_DIR" \ + --collect-submodules diffbot \ + --copy-metadata diffbot-python \ + "$ENTRY_SCRIPT" >&2 + +BINARY="${OUTPUT_DIR}/${ASSET}" +[ -f "$BINARY" ] || err "expected binary not found at ${BINARY}" +chmod +x "$BINARY" + +# --- Smoke test ------------------------------------------------------------ +log "Smoke-testing binary (--version)" +"$BINARY" --version >&2 || err "binary failed to run" + +# --- Checksum -------------------------------------------------------------- +# Written with a bare filename (no path) so `sha256sum -c` works from the dir. +log "Generating SHA256 checksum" +( + cd "$OUTPUT_DIR" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$ASSET" > "${ASSET}.sha256" + else + shasum -a 256 "$ASSET" > "${ASSET}.sha256" + fi +) + +log "Done" +log " binary: ${BINARY}" +log " checksum: ${BINARY}.sha256" +cat "${BINARY}.sha256" >&2 diff --git a/scripts/db_entry.py b/scripts/db_entry.py new file mode 100644 index 0000000..ce1db0c --- /dev/null +++ b/scripts/db_entry.py @@ -0,0 +1,11 @@ +"""PyInstaller entry point for the standalone ``db`` CLI. + +PyInstaller freezes a *script*, not a ``console_scripts`` entry point, so this +thin wrapper mirrors :mod:`diffbot.cli.__main__` but with an absolute import +that resolves correctly inside the frozen bundle. +""" + +from diffbot.cli import main + +if __name__ == "__main__": + main() diff --git a/src/diffbot/__init__.py b/src/diffbot/__init__.py index 6560ae9..e5abeda 100644 --- a/src/diffbot/__init__.py +++ b/src/diffbot/__init__.py @@ -2,9 +2,15 @@ diffbot - Python client library for the Diffbot APIs. """ -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version as _version + +try: + __version__ = _version("diffbot-python") +except PackageNotFoundError: # not installed (e.g. running from a source tree) + __version__ = "0.0.0" from ._auth import resolve_token +from .ask import json_schema_format from .client import Diffbot, DiffbotAsync from .crawl import CrawlEvent, CrawlEventType from .errors import ( @@ -21,6 +27,7 @@ "Diffbot", "DiffbotAsync", "resolve_token", + "json_schema_format", "CrawlEvent", "CrawlEventType", "Ontology", diff --git a/src/diffbot/ask.py b/src/diffbot/ask.py index b93a78d..727ad25 100644 --- a/src/diffbot/ask.py +++ b/src/diffbot/ask.py @@ -1,15 +1,75 @@ """Diffbot LLM RAG API: stream a chat completion.""" import json -from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, List +import re +from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, List, Optional + +from .errors import ValidationError if TYPE_CHECKING: from .client import Diffbot, DiffbotAsync +MODEL = "diffbot-small-xl" + +#: `response_format` types accepted by the Diffbot LLM endpoint. +RESPONSE_FORMAT_TYPES = ("text", "json_object", "json_schema") + +# The RAG loop may prefix its final answer with a think block, and the model +# occasionally wraps JSON in a markdown fence despite being told not to. +_THINK_BLOCK = re.compile(r".*?", re.DOTALL) +_JSON_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE) + + +def json_schema_format(schema: Dict[str, Any], *, name: str = "response") -> Dict[str, Any]: + """Build a ``response_format`` value that constrains output to ``schema``. + + The endpoint requires the schema nested under ``json_schema.schema``; passing + it anywhere else is ignored server-side without an error, so prefer this + helper over hand-building the dict. + + Example: + >>> json_schema_format({"type": "object", "properties": {"city": {"type": "string"}}}) + {'type': 'json_schema', 'json_schema': {'name': 'response', 'schema': {...}}} + """ + if not isinstance(schema, dict): + raise ValidationError("schema must be a JSON Schema dict") + return {"type": "json_schema", "json_schema": {"name": name, "schema": schema}} + -def _build_payload(client: Any, messages: List[Dict[str, str]]) -> tuple: +def _validate_response_format(response_format: Optional[Dict[str, Any]]) -> None: + """Reject shapes the endpoint would accept but silently not enforce.""" + if response_format is None: + return + if not isinstance(response_format, dict): + raise ValidationError("response_format must be a dict") + + fmt_type = response_format.get("type", "text") + if fmt_type not in RESPONSE_FORMAT_TYPES: + raise ValidationError( + f"response_format type must be one of {', '.join(RESPONSE_FORMAT_TYPES)}; got {fmt_type!r}" + ) + + if fmt_type == "json_schema": + json_schema = response_format.get("json_schema") + if not isinstance(json_schema, dict) or "schema" not in json_schema: + raise ValidationError( + 'response_format {"type": "json_schema"} requires the schema nested as ' + '{"json_schema": {"schema": {...}}}. Without it the server returns 200 and ' + "ignores the constraint. Use diffbot.json_schema_format(schema) to build it." + ) + + +def _build_payload( + client: Any, + messages: List[Dict[str, str]], + *, + response_format: Optional[Dict[str, Any]] = None, +) -> tuple: + _validate_response_format(response_format) headers = {"Authorization": f"Bearer {client.token}"} - payload = {"model": "diffbot-small-xl", "messages": messages, "stream": True} + payload: Dict[str, Any] = {"model": MODEL, "messages": messages, "stream": True} + if response_format is not None: + payload["response_format"] = response_format return headers, payload @@ -24,9 +84,68 @@ def _parse_chunk(line: str): return None -def ask(client: "Diffbot", messages: List[Dict[str, str]]) -> Iterator[str]: - headers = {"Authorization": f"Bearer {client.token}"} - payload = {"model": "diffbot-small-xl", "messages": messages, "stream": True} +def _extract_json(text: str) -> Any: + """Parse the model's final answer as JSON, tolerating think blocks and fences.""" + cleaned = _THINK_BLOCK.sub("", text).strip() + cleaned = _JSON_FENCE.sub("", cleaned).strip() + + try: + return json.loads(cleaned) + except json.JSONDecodeError: + pass + + # Fall back to the outermost object or array span in the response. + spans = [] + for opener, closer in (("{", "}"), ("[", "]")): + start, end = cleaned.find(opener), cleaned.rfind(closer) + if start != -1 and end > start: + spans.append((start, cleaned[start : end + 1])) + for _, span in sorted(spans): + try: + return json.loads(span) + except json.JSONDecodeError: + continue + + raise ValidationError(f"could not parse JSON from the model response: {text[:200]!r}") + + +#: Schema used when the caller wants JSON but has no shape in mind. This goes +#: through the json_schema path rather than {"type": "json_object"} on purpose: +#: json_object applies no server-side grammar, so the RAG loop's internal +#: JSON satisfies it and gets returned as the final answer. That +#: is reproducible whenever the request carries a system message. +ANY_OBJECT_SCHEMA = {"type": "object"} + + +def _resolve_format( + schema: Optional[Dict[str, Any]], + response_format: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + if schema is not None and response_format is not None: + raise ValidationError("pass either schema or response_format, not both") + if response_format is not None: + return response_format + return json_schema_format(schema if schema is not None else ANY_OBJECT_SCHEMA) + + +def _check_tool_call_leak(parsed: Any) -> Any: + """Catch the internal tool call surfacing as the answer (see ANY_OBJECT_SCHEMA).""" + if isinstance(parsed, dict) and parsed.get("name") == "functioncall" and "arguments" in parsed: + raise ValidationError( + "the model returned its internal tool call instead of an answer; this happens with " + 'response_format {"type": "json_object"} because the server applies no grammar to it. ' + "Pass a schema instead." + ) + return parsed + + +def ask( + client: "Diffbot", + messages: List[Dict[str, str]], + *, + response_format: Optional[Dict[str, Any]] = None, +) -> Iterator[str]: + headers, payload = _build_payload(client, messages, response_format=response_format) with client._http.stream("POST", client.llm_url, headers=headers, json=payload) as response: client._raise_for_status(response) for line in response.iter_lines(): @@ -36,9 +155,13 @@ def ask(client: "Diffbot", messages: List[Dict[str, str]]) -> Iterator[str]: yield content -async def ask_async(client: "DiffbotAsync", messages: List[Dict[str, str]]) -> AsyncIterator[str]: - headers = {"Authorization": f"Bearer {client.token}"} - payload = {"model": "diffbot-small-xl", "messages": messages, "stream": True} +async def ask_async( + client: "DiffbotAsync", + messages: List[Dict[str, str]], + *, + response_format: Optional[Dict[str, Any]] = None, +) -> AsyncIterator[str]: + headers, payload = _build_payload(client, messages, response_format=response_format) async with client._http.stream("POST", client.llm_url, headers=headers, json=payload) as response: client._raise_for_status(response) async for line in response.aiter_lines(): @@ -46,3 +169,27 @@ async def ask_async(client: "DiffbotAsync", messages: List[Dict[str, str]]) -> A content = _parse_chunk(line) if content: yield content + + +def ask_json( + client: "Diffbot", + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + *, + response_format: Optional[Dict[str, Any]] = None, +) -> Any: + fmt = _resolve_format(schema, response_format) + text = "".join(ask(client, messages, response_format=fmt)) + return _check_tool_call_leak(_extract_json(text)) + + +async def ask_json_async( + client: "DiffbotAsync", + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + *, + response_format: Optional[Dict[str, Any]] = None, +) -> Any: + fmt = _resolve_format(schema, response_format) + chunks = [chunk async for chunk in ask_async(client, messages, response_format=fmt)] + return _check_tool_call_leak(_extract_json("".join(chunks))) diff --git a/src/diffbot/cli/__init__.py b/src/diffbot/cli/__init__.py index 6cac621..deb1ba7 100644 --- a/src/diffbot/cli/__init__.py +++ b/src/diffbot/cli/__init__.py @@ -11,7 +11,15 @@ from rich.progress import Progress, SpinnerColumn, TextColumn from datetime import datetime -from diffbot import CrawlEventType, AuthError, ExtractionError, APIError +from diffbot import ( + __version__, + CrawlEventType, + AuthError, + ExtractionError, + APIError, + ValidationError, + json_schema_format, +) from ._common import get_client @@ -28,6 +36,7 @@ def print_markdown(text): @click.group() +@click.version_option(__version__, "-V", "--version", prog_name="db") def main(): """ Diffbot 🤖 Structure the world's knowledge. @@ -85,10 +94,29 @@ def extract(url, output, fmt, api): @click.argument("prompt") @click.option("-o", "--output", type=click.Path(), help="Write output to file instead of stdout") @click.option("--json", "as_json", is_flag=True, help="Output from LLM as JSON") -def ask(prompt: str, output: str = None, as_json: bool = False): +@click.option( + "--schema", + "schema_path", + type=click.Path(exists=True, dir_okay=False), + help="JSON Schema file constraining the output (implies --json)", +) +def ask(prompt: str, output: str = None, as_json: bool = False, schema_path: str = None): """Ask a question to the Diffbot LLM""" db = get_client() stdin_content = sys.stdin.read() if not sys.stdin.isatty() else None + + response_format = None + if schema_path: + as_json = True + try: + with open(schema_path) as f: + response_format = json_schema_format(json.load(f)) + except json.JSONDecodeError as e: + click.echo(f"Error: {schema_path} is not valid JSON: {e}", err=True) + raise click.Abort() + + # Without --schema, response_format stays None and ask_json picks its own + # permissive default; --json alone must not become {"type": "json_object"}. interactive_mode = is_interactive and not output and not as_json messages = [ @@ -99,9 +127,6 @@ def ask(prompt: str, output: str = None, as_json: bool = False): {"role": "user", "content": prompt}, ] - if as_json: - messages[1]["content"] += "\nReturn the output as a JSON object. Do not include any other text outside of the JSON object." - if stdin_content: messages[1]["content"] = f"{stdin_content}\n" + messages[1]["content"] @@ -136,17 +161,20 @@ def ask(prompt: str, output: str = None, as_json: bool = False): else: response = "" if output: - for chunk in db.ask(messages): - response += chunk + if as_json: + response = json.dumps( + db.ask_json(messages, response_format=response_format), indent=2 + ) + else: + for chunk in db.ask(messages): + response += chunk with open(output, "w") as f: f.write(response) click.echo(f"Output written to {output}") elif as_json: - buffer = "" - for chunk in db.ask(messages): - buffer += chunk - buffer = buffer[buffer.find("{") : buffer.rfind("}") + 1] - sys.stdout.write(buffer) + sys.stdout.write( + json.dumps(db.ask_json(messages, response_format=response_format), indent=2) + ) else: for chunk in db.ask(messages): sys.stdout.write(chunk) @@ -155,6 +183,9 @@ def ask(prompt: str, output: str = None, as_json: bool = False): except AuthError: click.echo("Error: Invalid or unauthorized API token.", err=True) raise click.Abort() + except ValidationError as e: + click.echo(f"Error: {e}", err=True) + raise click.Abort() except APIError as e: click.echo(f"API error {e.status_code}: {e.message or e.body}", err=True) raise click.Abort() diff --git a/src/diffbot/client.py b/src/diffbot/client.py index d9e6221..88acf4a 100644 --- a/src/diffbot/client.py +++ b/src/diffbot/client.py @@ -9,7 +9,12 @@ from . import __version__ from .errors import APIError, AuthError, RateLimitError, ValidationError from .extract import extract as _extract, extract_async as _extract_async -from .ask import ask as _ask, ask_async as _ask_async +from .ask import ( + ask as _ask, + ask_async as _ask_async, + ask_json as _ask_json, + ask_json_async as _ask_json_async, +) from .crawl import ( CrawlEvent, crawl as _crawl, @@ -112,9 +117,32 @@ def extract(self, url: str, api: str = "analyze", fmt: str = "markdown") -> Dict """Extract structured content from a URL. Returns the raw Diffbot API response.""" return _extract(self, url, api=api, fmt=fmt) - def ask(self, messages: List[Dict[str, str]]) -> Iterator[str]: - """Stream a response from the Diffbot LLM RAG API.""" - yield from _ask(self, messages) + def ask( + self, + messages: List[Dict[str, str]], + *, + response_format: Optional[Dict[str, Any]] = None, + ) -> Iterator[str]: + """Stream a response from the Diffbot LLM RAG API. + + Pass ``response_format`` to constrain the output, e.g. + ``{"type": "json_object"}`` or ``diffbot.json_schema_format(schema)``. + """ + yield from _ask(self, messages, response_format=response_format) + + def ask_json( + self, + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + *, + response_format: Optional[Dict[str, Any]] = None, + ) -> Any: + """Ask the Diffbot LLM and return the answer parsed as JSON. + + With ``schema`` (a JSON Schema dict) the model is constrained to it by + grammar; without one it falls back to unconstrained JSON output. + """ + return _ask_json(self, messages, schema, response_format=response_format) def crawl(self, site: str, **kwargs: Any) -> Iterator[CrawlEvent]: """Start a crawl job.""" @@ -239,11 +267,34 @@ async def extract(self, url: str, api: str = "analyze", fmt: str = "markdown") - """Extract structured content from a URL. Returns the raw Diffbot API response.""" return await _extract_async(self, url, api=api, fmt=fmt) - async def ask(self, messages: List[Dict[str, str]]) -> AsyncIterator[str]: - """Stream a response from the Diffbot LLM RAG API.""" - async for chunk in _ask_async(self, messages): + async def ask( + self, + messages: List[Dict[str, str]], + *, + response_format: Optional[Dict[str, Any]] = None, + ) -> AsyncIterator[str]: + """Stream a response from the Diffbot LLM RAG API. + + Pass ``response_format`` to constrain the output, e.g. + ``{"type": "json_object"}`` or ``diffbot.json_schema_format(schema)``. + """ + async for chunk in _ask_async(self, messages, response_format=response_format): yield chunk + async def ask_json( + self, + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + *, + response_format: Optional[Dict[str, Any]] = None, + ) -> Any: + """Ask the Diffbot LLM and return the answer parsed as JSON. + + With ``schema`` (a JSON Schema dict) the model is constrained to it by + grammar; without one it falls back to unconstrained JSON output. + """ + return await _ask_json_async(self, messages, schema, response_format=response_format) + async def crawl(self, site: str, **kwargs: Any) -> AsyncIterator[CrawlEvent]: """Start a crawl job. Pass watch=True to poll until completion and yield URL_PROCESSED events.""" async for event in _crawl_async(self, site, **kwargs): diff --git a/src/diffbot/web_search.py b/src/diffbot/web_search.py index 726775e..72711e1 100644 --- a/src/diffbot/web_search.py +++ b/src/diffbot/web_search.py @@ -18,7 +18,7 @@ def web_search( headers = {"Authorization": f"Bearer {client.token}"} params: Dict[str, Any] = {"text": text} if num_results is not None: - params["num_results"] = num_results + params["size"] = num_results if max_tokens is not None: params["maxTokens"] = max_tokens response = client._http.get(client.web_search_url, headers=headers, params=params) @@ -36,7 +36,7 @@ async def web_search_async( headers = {"Authorization": f"Bearer {client.token}"} params: Dict[str, Any] = {"text": text} if num_results is not None: - params["num_results"] = num_results + params["size"] = num_results if max_tokens is not None: params["maxTokens"] = max_tokens response = await client._http.get(client.web_search_url, headers=headers, params=params) diff --git a/tests/test_ask.py b/tests/test_ask.py index 3ac9b8f..647c234 100644 --- a/tests/test_ask.py +++ b/tests/test_ask.py @@ -1,12 +1,274 @@ +import json + import pytest +from diffbot import DiffbotAsync, json_schema_format +from diffbot.ask import ( + ANY_OBJECT_SCHEMA, + _build_payload, + _check_tool_call_leak, + _extract_json, + _resolve_format, + _validate_response_format, +) +from diffbot.errors import ValidationError + + +class _FakeClient: + token = "test-token" + + +CITY_SCHEMA = { + "type": "object", + "properties": {"country": {"type": "string"}, "capital": {"type": "string"}}, + "required": ["country", "capital"], +} + + +""" +json_schema_format +""" + + +def test_json_schema_format_nests_schema_correctly(): + # The server only honours the constraint when it finds json_schema.schema. + fmt = json_schema_format(CITY_SCHEMA) + assert fmt["type"] == "json_schema" + assert fmt["json_schema"]["schema"] == CITY_SCHEMA + assert fmt["json_schema"]["name"] == "response" + + +def test_json_schema_format_custom_name(): + assert json_schema_format(CITY_SCHEMA, name="city")["json_schema"]["name"] == "city" + + +def test_json_schema_format_rejects_non_dict(): + with pytest.raises(ValidationError): + json_schema_format("not a schema") + + +""" +Validation +""" + + +def test_validate_allows_none_and_known_types(): + _validate_response_format(None) + _validate_response_format({"type": "text"}) + _validate_response_format({"type": "json_object"}) + _validate_response_format(json_schema_format(CITY_SCHEMA)) + + +def test_validate_rejects_unknown_type(): + with pytest.raises(ValidationError, match="text, json_object, json_schema"): + _validate_response_format({"type": "nope"}) + + +def test_validate_rejects_json_schema_without_nested_schema(): + # Server-side this returns 200 and silently ignores the constraint. + with pytest.raises(ValidationError, match="json_schema"): + _validate_response_format({"type": "json_schema", "json_schema": CITY_SCHEMA}) + + +def test_validate_rejects_json_schema_with_no_json_schema_key(): + with pytest.raises(ValidationError): + _validate_response_format({"type": "json_schema"}) + + +def test_validate_rejects_non_dict(): + with pytest.raises(ValidationError): + _validate_response_format("json") + + +""" +Payload +""" + + +def test_payload_omits_response_format_when_unset(): + _, payload = _build_payload(_FakeClient(), [{"role": "user", "content": "hi"}]) + assert "response_format" not in payload + assert payload["stream"] is True + + +def test_payload_includes_response_format(): + fmt = json_schema_format(CITY_SCHEMA) + headers, payload = _build_payload(_FakeClient(), [{"role": "user", "content": "hi"}], response_format=fmt) + assert payload["response_format"] == fmt + assert headers["Authorization"] == "Bearer test-token" + + +def test_payload_validates_before_sending(): + with pytest.raises(ValidationError): + _build_payload(_FakeClient(), [], response_format={"type": "json_schema", "json_schema": {}}) + + +""" +Format resolution +""" + + +def test_resolve_format_defaults_to_permissive_schema(): + # Deliberately not {"type": "json_object"} -- that mode has no server-side + # grammar and leaks the internal tool call as the answer. + assert _resolve_format(None, None) == json_schema_format(ANY_OBJECT_SCHEMA) + + +def test_resolve_format_wraps_bare_schema(): + assert _resolve_format(CITY_SCHEMA, None) == json_schema_format(CITY_SCHEMA) + + +def test_resolve_format_passes_through_explicit_format(): + fmt = {"type": "json_object"} + assert _resolve_format(None, fmt) is fmt + + +def test_resolve_format_rejects_both(): + with pytest.raises(ValidationError, match="not both"): + _resolve_format(CITY_SCHEMA, {"type": "json_object"}) + + +""" +JSON extraction +""" + + +def test_extract_plain_json(): + assert _extract_json('{"capital": "Paris"}') == {"capital": "Paris"} + + +def test_extract_strips_think_block(): + # The RAG grammar permits a think block before the final JSON answer. + raw = 'Sufficient information gathered.\n{"capital": "Paris"}' + assert _extract_json(raw) == {"capital": "Paris"} + + +def test_extract_strips_multiline_think_block(): + raw = 'line one\nline two{"a": 1}' + assert _extract_json(raw) == {"a": 1} + + +def test_extract_strips_markdown_fence(): + assert _extract_json('```json\n{"a": 1}\n```') == {"a": 1} + + +def test_extract_top_level_array(): + assert _extract_json('[{"city": "NYC"}]') == [{"city": "NYC"}] + + +def test_extract_falls_back_to_embedded_object(): + assert _extract_json('Here you go: {"a": 1} hope that helps') == {"a": 1} + + +def test_extract_raises_on_unparseable(): + with pytest.raises(ValidationError, match="could not parse JSON"): + _extract_json("I could not find an answer.") + + +""" +Tool call leak +""" + + +def test_leak_guard_rejects_functioncall(): + leaked = {"name": "functioncall", "arguments": {"name": "dql_v1", "arguments": {}}} + with pytest.raises(ValidationError, match="internal tool call"): + _check_tool_call_leak(leaked) + + +def test_leak_guard_passes_normal_answers(): + answer = {"country": "France", "capital": "Paris"} + assert _check_tool_call_leak(answer) is answer + + +def test_leak_guard_allows_name_without_arguments(): + # A legitimate answer may well have a "name" field. + answer = {"name": "functioncall"} + assert _check_tool_call_leak(answer) is answer + + +def test_leak_guard_passes_lists(): + answer = [{"city": "NYC"}] + assert _check_tool_call_leak(answer) is answer + """ Live """ + @pytest.mark.live def test_live_ask(db): chunks = list(db.ask([{"role": "user", "content": "What is Diffbot?"}])) response = "".join(chunks) assert len(response) > 0 + + +@pytest.mark.live +def test_live_ask_json_no_schema(db): + result = db.ask_json([{"role": "user", "content": "What is the capital of France?"}]) + assert isinstance(result, dict) + assert "paris" in json.dumps(result).lower() + + +@pytest.mark.live +def test_live_ask_json_no_schema_with_system_message(db): + # A system message reproducibly triggered the tool-call leak under + # {"type": "json_object"}; the default format must not regress to it. + messages = [ + {"role": "system", "content": "Current local time: Friday, August 14, 2026"}, + {"role": "user", "content": "What is the capital of France?"}, + ] + result = db.ask_json(messages) + assert "paris" in json.dumps(result).lower() + + +@pytest.mark.live +def test_live_ask_json_schema(db): + result = db.ask_json( + [{"role": "user", "content": "What is the capital of France?"}], + CITY_SCHEMA, + ) + assert result["country"].lower() == "france" + assert result["capital"].lower() == "paris" + + +@pytest.mark.live +def test_live_ask_streams_with_response_format(db): + chunks = list( + db.ask( + [{"role": "user", "content": "What is the capital of France?"}], + response_format=json_schema_format(CITY_SCHEMA), + ) + ) + assert json.loads("".join(chunks))["capital"].lower() == "paris" + + +@pytest.mark.live +def test_live_ask_json_nested_schema(db): + schema = { + "type": "object", + "properties": { + "company": { + "type": "object", + "properties": {"name": {"type": "string"}, "founded": {"type": "integer"}}, + "required": ["name", "founded"], + }, + "products": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["company", "products"], + } + result = db.ask_json([{"role": "user", "content": "Tell me about Diffbot the company."}], schema) + assert "diffbot" in result["company"]["name"].lower() + assert isinstance(result["products"], list) + + +@pytest.mark.live +@pytest.mark.anyio +async def test_live_ask_json_async(live_token): + async with DiffbotAsync(token=live_token) as db: + result = await db.ask_json( + [{"role": "user", "content": "What is the capital of France?"}], + CITY_SCHEMA, + ) + assert result["capital"].lower() == "paris" diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index d3d5a7b..c3ae6c6 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -1,11 +1,27 @@ """Exercises the code patterns shown in the README Usage and Async Usage sections.""" +import json + import httpx import pytest -from diffbot import CrawlEventType, Diffbot, DiffbotAsync, resolve_token +from diffbot import CrawlEventType, Diffbot, DiffbotAsync, json_schema_format, resolve_token SSE_PARIS = 'data: {"choices": [{"delta": {"content": "Paris"}}]}\n' +CAPITAL_SCHEMA = { + "type": "object", + "properties": { + "country": {"type": "string"}, + "capital": {"type": "string"}, + }, + "required": ["country", "capital"], +} +SSE_CAPITAL_JSON = ( + "data: " + + json.dumps({"choices": [{"delta": {"content": '{"country": "France", "capital": "Paris"}'}}]}) + + "\n" +) + # --------------------------------------------------------------------------- # Sync Usage @@ -31,6 +47,48 @@ def handler(request: httpx.Request) -> httpx.Response: assert "Paris" in "".join(chunks) +def test_readme_sync_ask_json_with_schema(): + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert body["response_format"]["type"] == "json_schema" + assert body["response_format"]["json_schema"]["schema"] == CAPITAL_SCHEMA + return httpx.Response(200, text=SSE_CAPITAL_JSON) + + db = Diffbot(token="test-token", transport=httpx.MockTransport(handler)) + answer = db.ask_json( + [{"role": "user", "content": "What's the capital of France?"}], CAPITAL_SCHEMA + ) + assert answer["capital"] == "Paris" + + +def test_readme_sync_ask_json_without_schema(): + def handler(request: httpx.Request) -> httpx.Response: + # Defaults to a permissive json_schema, never {"type": "json_object"}. + fmt = json.loads(request.content)["response_format"] + assert fmt["type"] == "json_schema" + assert fmt["json_schema"]["schema"] == {"type": "object"} + return httpx.Response(200, text=SSE_CAPITAL_JSON) + + db = Diffbot(token="test-token", transport=httpx.MockTransport(handler)) + answer = db.ask_json([{"role": "user", "content": "What's the capital of France?"}]) + assert answer["country"] == "France" + + +def test_readme_sync_ask_streaming_with_response_format(): + def handler(request: httpx.Request) -> httpx.Response: + assert json.loads(request.content)["response_format"]["type"] == "json_schema" + return httpx.Response(200, text=SSE_CAPITAL_JSON) + + db = Diffbot(token="test-token", transport=httpx.MockTransport(handler)) + chunks = list( + db.ask( + [{"role": "user", "content": "What's the capital of France?"}], + response_format=json_schema_format(CAPITAL_SCHEMA), + ) + ) + assert json.loads("".join(chunks))["capital"] == "Paris" + + def test_readme_sync_crawl(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"jobs": [{"name": "test-job"}]}) @@ -75,6 +133,20 @@ def handler(request: httpx.Request) -> httpx.Response: assert "Paris" in "".join(chunks) +@pytest.mark.anyio +async def test_readme_async_ask_json_with_schema(): + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert body["response_format"]["json_schema"]["schema"] == CAPITAL_SCHEMA + return httpx.Response(200, text=SSE_CAPITAL_JSON) + + async with DiffbotAsync(token="test-token", transport=httpx.MockTransport(handler)) as db: + answer = await db.ask_json( + [{"role": "user", "content": "What's the capital of France?"}], CAPITAL_SCHEMA + ) + assert answer["capital"] == "Paris" + + @pytest.mark.anyio async def test_readme_async_crawl(): def handler(request: httpx.Request) -> httpx.Response: diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..60611a8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,383 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "diffbot-python" +version = "0.2.3" +source = { editable = "." } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "httpx" }, + { name = "rich" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "rich", specifier = ">=13.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]