diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0a5fe39..81d64c7 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -10,126 +10,246 @@ on: permissions: contents: read +env: + GREPTIMEDB_IMAGE: greptime/greptimedb-nightly@sha256:bcd83379d1d2475859eb3db2b4690573c949027802a2419d0fc42597d97b9969 + jobs: + facade: + name: Pure Python facade / no backend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + version: "0.9.17" + python-version: "3.10" + - run: uv sync --locked + - name: Base installation tests + run: uv run --locked pytest + - name: Build facade wheel and source archive + run: uv build + - name: Test installed base wheel + run: | + wheel=$(find "$PWD/dist" -maxdepth 1 -name '*.whl' -print -quit) + uv run --no-cache --isolated --no-project --python 3.10 --with "$wheel" --with pytest python -m pytest -m 'not integration' -o addopts= -v + uv run --no-cache --isolated --no-project --python 3.10 --with "$wheel" python -c "from importlib.util import find_spec; import greptimedb_ingester; assert find_spec('psycopg2') is None; assert find_spec('greptimedb_ingester_grpc') is None" + - uses: actions/upload-artifact@v4 + with: + name: facade-distributions + path: | + dist/*.whl + dist/*.tar.gz + if-no-files-found: error + build: - name: ${{ matrix.platform.name }} + name: gRPC and combined / ${{ matrix.platform.name }} + needs: facade runs-on: ${{ matrix.platform.os }} strategy: fail-fast: false matrix: platform: - - name: linux-x86_64 - os: ubuntu-latest - target: x86_64 - manylinux: "2_28" - - name: linux-aarch64 - os: ubuntu-24.04-arm - target: aarch64 - manylinux: "2_28" - - name: windows-x64 - os: windows-latest - target: x64 - manylinux: "off" - - name: macos-arm64 - os: macos-latest - target: aarch64 - manylinux: "off" + - { name: linux-x86_64, os: ubuntu-latest, target: x86_64, manylinux: "2_28" } + - { name: linux-aarch64, os: ubuntu-24.04-arm, target: aarch64, manylinux: "2_28" } + - { name: windows-x64, os: windows-latest, target: x64, manylinux: "off" } + - { name: macos-arm64, os: macos-latest, target: aarch64, manylinux: "off" } steps: - uses: actions/checkout@v4 - - - name: Build wheel + - uses: actions/download-artifact@v4 + with: + name: facade-distributions + path: dist/core + # Create the output directory as the runner before the root-owned manylinux build. + - name: Build native source archive + if: matrix.platform.name == 'linux-x86_64' + uses: PyO3/maturin-action@v1 + env: + RUSTC_WRAPPER: "" + with: + working-directory: packages/grpc + command: sdist + args: --out ../../dist/grpc + - name: Build native plugin on its target platform uses: PyO3/maturin-action@v1 with: + working-directory: packages/grpc target: ${{ matrix.platform.target }} manylinux: ${{ matrix.platform.manylinux }} - args: --release --locked --out dist --compatibility pypi + args: --release --locked --out ../../dist/grpc --compatibility pypi sccache: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/') }} - - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: "3.10" - - - name: Smoke test + - name: Install gRPC-only wheels run: | - python -m pip install --no-index --find-links dist greptimedb-ingester - python -m pip install pytest - python -m pytest -o addopts= - + python -c "import glob,subprocess,sys; subprocess.check_call([sys.executable,'-m','pip','install',glob.glob('dist/core/*.whl')[0],glob.glob('dist/grpc/*.whl')[0],'pytest'])" + - name: Test gRPC-only installation + run: | + python -c "from importlib.util import find_spec; assert find_spec('psycopg2') is None" + python -m pytest -m "not integration" -o addopts= -v + - name: Add postgres extra and test both backends + run: | + python -c "import glob,subprocess,sys; subprocess.check_call([sys.executable,'-m','pip','install',glob.glob('dist/core/*.whl')[0]+'[postgres]'])" + python -m pytest -m "not integration" -o addopts= -v - uses: actions/upload-artifact@v4 with: - name: wheels-${{ matrix.platform.name }} - path: dist/*.whl + name: grpc-wheels-${{ matrix.platform.name }} + path: dist/grpc/* + if-no-files-found: error + + postgres: + name: Postgres-only / ${{ matrix.os }} + needs: facade + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: facade-distributions + path: dist/core + - uses: astral-sh/setup-uv@v5 + with: + python-version: "3.10" + enable-cache: false + - name: Test installed postgres extra without Rust SDK + run: | + wheel=$(python -c "from pathlib import Path; print(next(Path('dist/core').glob('*.whl')).resolve(), end='')") + uv run --no-cache --isolated --no-project --python 3.10 --with "${wheel}[postgres]" --with pytest python -m pytest -m 'not integration' -o addopts= -v + uv run --no-cache --isolated --no-project --python 3.10 --with "${wheel}[postgres]" python -c "from importlib.util import find_spec; import greptimedb_ingester; assert find_spec('greptimedb_ingester_grpc') is None" integration: - name: integration + name: gRPC integration / installed wheels + needs: [facade, build] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - + - uses: astral-sh/setup-uv@v5 + with: + python-version: "3.10" + enable-cache: false + - uses: actions/download-artifact@v4 + with: + name: facade-distributions + path: dist/core + - uses: actions/download-artifact@v4 + with: + name: grpc-wheels-linux-x86_64 + path: dist/grpc - name: Start GreptimeDB run: | - docker run -d --name greptimedb -p 4000:4000 -p 4001:4001 \ - greptime/greptimedb:latest \ - standalone start --http-addr 0.0.0.0:4000 --rpc-bind-addr 0.0.0.0:4001 - for _ in $(seq 1 30); do - if curl -sf http://127.0.0.1:4000/health; then - exit 0 - fi + docker run -d --name greptimedb -p 4000:4000 -p 4001:4001 "$GREPTIMEDB_IMAGE" standalone start --http-addr 0.0.0.0:4000 --grpc-bind-addr 0.0.0.0:4001 + for _ in $(seq 1 60); do + if curl -sf http://127.0.0.1:4000/health; then exit 0; fi sleep 2 done docker logs greptimedb exit 1 - - - uses: dtolnay/rust-toolchain@stable - - uses: astral-sh/setup-uv@v5 - - - name: Integration tests + - name: Existing gRPC integration tests env: GREPTIMEDB_GRPC: 127.0.0.1:4001 GREPTIMEDB_HTTP: http://127.0.0.1:4000 run: | - uv python pin 3.10 - uv sync --group dev - uv run maturin develop - uv run pytest -m integration -o addopts= + core=$(find "$PWD/dist/core" -name '*.whl' -print -quit) + native=$(find "$PWD/dist/grpc" -name '*.whl' -print -quit) + uv run --no-cache --isolated --no-project --python 3.10 --with "$core" --with "$native" --with pytest python -m pytest tests/test_integration.py -m integration -o addopts= -v + - if: failure() + run: docker logs greptimedb + - if: always() + run: docker rm -f greptimedb - release: - if: startsWith(github.ref, 'refs/tags/') - needs: build + postgres-integration: + name: COPY integration / postgres extra + needs: [facade, postgres] runs-on: ubuntu-latest - permissions: - contents: write steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: "3.10" + enable-cache: false - uses: actions/download-artifact@v4 with: - pattern: wheels-* - merge-multiple: true - path: dist - - - name: Attach wheels to the GitHub release + name: facade-distributions + path: dist/core + - name: Start COPY-capable GreptimeDB + run: | + docker run -d --name greptimedb-copy -p 4000:4000 -p 4003:4003 "$GREPTIMEDB_IMAGE" standalone start --http-addr 0.0.0.0:4000 --postgres-addr 0.0.0.0:4003 + for _ in $(seq 1 60); do + if curl -sf http://127.0.0.1:4000/health; then exit 0; fi + sleep 2 + done + docker logs greptimedb-copy + exit 1 + - name: COPY integration tests env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} + GREPTIMEDB_POSTGRES: 127.0.0.1:4003 run: | - tag="${GITHUB_REF_NAME}" - if gh release view "$tag" >/dev/null 2>&1; then - gh release upload "$tag" dist/*.whl --clobber - else - gh release create "$tag" dist/*.whl --title "$tag" --generate-notes - fi + core=$(find "$PWD/dist/core" -name '*.whl' -print -quit) + uv run --no-cache --isolated --no-project --python 3.10 --with "${core}[postgres]" --with pytest python -m pytest tests/test_postgres_integration.py -m integration -o addopts= -v + - if: failure() + run: docker logs greptimedb-copy + - if: always() + run: docker rm -f greptimedb-copy pypi: - if: startsWith(github.ref, 'refs/tags/v') - needs: build + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [facade, build, postgres, integration, postgres-integration] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 with: - pattern: wheels-* + name: facade-distributions + path: dist/core + - uses: actions/download-artifact@v4 + with: + pattern: grpc-wheels-* merge-multiple: true - path: dist - - - uses: pypa/gh-action-pypi-publish@release/v1 + path: dist/grpc + - name: Publish optional native dependency first + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_TOKEN }} + packages-dir: dist/grpc/ + attestations: false + skip-existing: true + - name: Publish public facade with extras + uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_TOKEN }} + packages-dir: dist/core/ + attestations: false + skip-existing: true + + release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [facade, build, postgres, integration, postgres-integration] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: facade-distributions + path: dist + - uses: actions/download-artifact@v4 + with: + pattern: grpc-wheels-* + merge-multiple: true + path: dist + - name: Attach tested distributions to release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" dist/* --clobber + else + gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes + fi diff --git a/.gitignore b/.gitignore index f61bf3a..adbe10c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ build/ *.pyd .maturin/ test/ + +/packages/grpc/target/ +.DS_Store +/docs diff --git a/AGENTS.md b/AGENTS.md index 2cbd797..ec1d3f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent notes -Python write client for GreptimeDB. Package name `greptimedb-ingester`, import `greptimedb_ingester`. Binds `greptimedb-ingester` 0.19.0. Python 3.10+, sync API, PyO3 `abi3-py310`, maturin, uv. +Python write client for GreptimeDB. Package name `greptimedb-ingester`, import `greptimedb_ingester`. Python 3.10+, sync API, uv. Root is a pure Python setuptools facade with optional `[grpc]` and `[postgres]` extras. The native plugin `greptimedb-ingester-grpc` in `packages/grpc` binds `greptimedb-ingester` 0.19.0 using PyO3 `abi3-py310` and maturin. Rust sources and Cargo files live there, not at the root. Postgres-only installations must not depend on the native plugin. ## Write paths @@ -18,6 +18,10 @@ Python write client for GreptimeDB. Package name `greptimedb-ingester`, import ` ## Build and release +- Root `uv build` builds the pure Python facade. Build the native dependency from `packages/grpc` with maturin. Keep facade/plugin versions, the exact grpc extra pin and lock files synchronized with `scripts/set-version.py`. +- Test base, postgres-only, gRPC-only and combined installed wheels. Default root Client prefers installed gRPC, otherwise postgres; explicit `.grpc.Client` and `.postgres.Client` select a backend. +- `v*` tags publish the native dependency first, then the public facade, using `PYPI_TOKEN` with access to both distributions. Both backend integrations must pass. + - Wheels are built on the target OS: Linux x86_64, Linux aarch64, Windows x64, macOS Apple Silicon. Do not cross-compile those wheels. - After a wheel build, install it and run `pytest`. Tests must not require a running GreptimeDB. - Publish to PyPI only from a `v*` tag, using `secrets.PYPI_TOKEN`. The uploaded version is `version` in `pyproject.toml`, not the tag text. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a7dc9c0 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include .python-version +include README.zh-CN.md +prune docs +include docs/api.md docs/usage.md docs/testing.md +recursive-include examples *.py +recursive-include tests *.py +prune packages +prune target diff --git a/README.md b/README.md index c090430..57821fb 100644 --- a/README.md +++ b/README.md @@ -2,104 +2,60 @@ [中文](README.zh-CN.md) -Python write client for GreptimeDB, binding [greptimedb-ingester](https://github.com/GreptimeTeam/greptimedb-ingester-rust) 0.19.0. Python 3.10 and later. Calls are synchronous. - -- `Client.insert`: gRPC row writes. If the table does not exist, GreptimeDB creates it from the schema in the request. -- `Client.bulk_writer`: Arrow Flight bulk writes. The table must already exist. - -The client connects to the gRPC port, `127.0.0.1:4001` by default. - -See [docs/usage.md](docs/usage.md) for the full guide and [docs/api.md](docs/api.md) for the API reference. +Synchronous Python write client for GreptimeDB. Python 3.10+. ## Install -pip: - -```bash -pip install greptimedb-ingester -``` - -uv: - ```bash -uv add greptimedb-ingester +uv add "greptimedb-ingester[postgres]" # PostgreSQL COPY +uv add "greptimedb-ingester[grpc]" # gRPC +uv add "greptimedb-ingester[grpc,postgres]" # Both ``` -`requirements.txt`: +Or use `pip install` with the same requirements. In `requirements.txt`, omit quotes. +Choose at least one backend. `[postgres]` uses psycopg2 without the Rust SDK. -```text -greptimedb-ingester -``` +## PostgreSQL COPY -```bash -pip install -r requirements.txt -``` - -or: - -```bash -uv pip install -r requirements.txt +```python +from greptimedb_ingester import Column, ColumnDataType as D, SemanticType as S +from greptimedb_ingester.postgres import Client + +columns = [ + Column("host", D.STRING, S.TAG), + Column("ts", D.TIMESTAMP_MILLISECOND, S.TIMESTAMP), + Column("reading", D.FLOAT64, S.FIELD), +] +with Client(["127.0.0.1:4003"], batch_size=5000) as client: + affected = client.insert("sensor_data", columns, [ + ["edge", 1793066400000, 23.5], + ]) +print(affected) ``` -The installer downloads the wheel for the local OS and CPU. One wheel covers Python 3.10, 3.11, 3.12, and later on that platform. You do not download a wheel yourself. - -Supported architectures: - -- Linux x86_64, glibc 2.28 or later -- Linux aarch64, glibc 2.28 or later -- Windows x64 -- macOS arm64 (Apple Silicon) +`insert` creates missing tables and writes COPY batches (default: 10,000 rows). +Batches commit separately. The server must support `COPY FROM STDIN`. +See the [API reference](docs/api.md#postgresql-copy-后端) for supported types and timestamp rules. -Published wheels: - -| Platform | wheel | -| --- | --- | -| Linux x86_64, glibc 2.28 or later | `greptimedb_ingester-0.1.0-cp310-abi3-manylinux_2_28_x86_64.whl` | -| Linux aarch64, glibc 2.28 or later | `greptimedb_ingester-0.1.0-cp310-abi3-manylinux_2_28_aarch64.whl` | -| Windows x64 | `greptimedb_ingester-0.1.0-cp310-abi3-win_amd64.whl` | -| macOS Apple Silicon | `greptimedb_ingester-0.1.0-cp310-abi3-macosx_11_0_arm64.whl` | - -Building from source requires Rust 1.85 or later: - -```bash -pip install maturin -maturin build --release -pip install dist/greptimedb_ingester-*.whl -``` - -## Write +## gRPC ```python -from datetime import datetime, timezone - -from greptimedb_ingester import Client, Column, ColumnDataType, SemanticType +from greptimedb_ingester.grpc import Client client = Client(["127.0.0.1:4001"], database="public") - -affected = client.insert( - "sensor_data", - columns=[ - Column("device_id", ColumnDataType.STRING, SemanticType.TAG), - Column("ts", ColumnDataType.TIMESTAMP_MILLISECOND, SemanticType.TIMESTAMP), - Column("temperature", ColumnDataType.FLOAT64, SemanticType.FIELD), - ], - rows=[ - ["device_001", datetime(2009, 2, 13, 23, 31, 30, tzinfo=timezone.utc), 23.5], - {"device_id": "device_002", "ts": 1_234_567_890_001, "temperature": 24.0}, - ], -) -print(affected) +# client.insert(table, columns, rows) ``` -If `sensor_data` does not exist, this `insert` creates it. `examples/insert_example.py` also writes into a table that already exists. Bulk writes are in `examples/bulk_example.py`; the table must already exist. +Supports `insert`, `delete`, and `bulk_writer`. See [usage](docs/usage.md) and [examples](examples). +With both backends installed, the root `Client` defaults to gRPC; use the explicit +imports above to select a backend. ## Development ```bash -uv python pin 3.10 -uv sync --group dev -uv run maturin develop -uv run pytest +uv sync --python 3.10 --locked --extra postgres +uv run --extra postgres pytest ``` -`pytest` checks value conversion only. It does not connect to GreptimeDB. +For both backends, use `--all-extras` instead of `--extra postgres` (requires Rust 1.85+). +See [testing and release](docs/testing.md) for integration tests and publishing. diff --git a/README.zh-CN.md b/README.zh-CN.md index be64bab..126b67f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,104 +2,60 @@ [English](README.md) -GreptimeDB 的 Python 写入客户端,绑定 [greptimedb-ingester](https://github.com/GreptimeTeam/greptimedb-ingester-rust) 0.19.0。支持 Python 3.10 及以上。调用是同步的。 - -- `Client.insert`:gRPC 行写入。表不存在时,按请求里的 schema 建表。 -- `Client.bulk_writer`:Arrow Flight 批量写入。表必须已经存在。 - -连接的是 gRPC 端口,默认 `127.0.0.1:4001`。 - -完整用法见 [docs/usage.md](docs/usage.md)。接口说明见 [docs/api.md](docs/api.md)。 +GreptimeDB 同步 Python 写入客户端,支持 Python 3.10+。 ## 安装 -pip: - -```bash -pip install greptimedb-ingester -``` - -uv: - ```bash -uv add greptimedb-ingester +uv add "greptimedb-ingester[postgres]" # PostgreSQL COPY +uv add "greptimedb-ingester[grpc]" # gRPC +uv add "greptimedb-ingester[grpc,postgres]" # 两种后端 ``` -`requirements.txt`: +也可用 `pip install` 安装相同依赖,写入 `requirements.txt` 时不加引号。 +至少选择一种后端;`[postgres]` 使用 psycopg2,不依赖 Rust SDK。 -```text -greptimedb-ingester -``` +## PostgreSQL COPY -```bash -pip install -r requirements.txt -``` - -或: - -```bash -uv pip install -r requirements.txt +```python +from greptimedb_ingester import Column, ColumnDataType as D, SemanticType as S +from greptimedb_ingester.postgres import Client + +columns = [ + Column("host", D.STRING, S.TAG), + Column("ts", D.TIMESTAMP_MILLISECOND, S.TIMESTAMP), + Column("reading", D.FLOAT64, S.FIELD), +] +with Client(["127.0.0.1:4003"], batch_size=5000) as client: + affected = client.insert("sensor_data", columns, [ + ["edge", 1793066400000, 23.5], + ]) +print(affected) ``` -安装器会下载和本机系统、CPU 匹配的 wheel。一个 wheel 可以给该系统上的 Python 3.10、3.11、3.12……使用。不用自己下载 wheel。 - -支持的架构: - -- Linux x86_64,glibc 2.28 及以上 -- Linux aarch64,glibc 2.28 及以上 -- Windows x64 -- macOS arm64(Apple Silicon) +`insert` 自动创建不存在的表并分批 COPY,默认每批 10,000 行。 +各批独立提交。服务端须支持 `COPY FROM STDIN`。 +支持类型和时间戳规则见 [API](docs/api.md#postgresql-copy-后端)。 -已发布的 wheel: - -| 系统 | wheel | -| --- | --- | -| Linux x86_64,glibc 2.28 及以上 | `greptimedb_ingester-0.1.0-cp310-abi3-manylinux_2_28_x86_64.whl` | -| Linux aarch64,glibc 2.28 及以上 | `greptimedb_ingester-0.1.0-cp310-abi3-manylinux_2_28_aarch64.whl` | -| Windows x64 | `greptimedb_ingester-0.1.0-cp310-abi3-win_amd64.whl` | -| macOS Apple Silicon | `greptimedb_ingester-0.1.0-cp310-abi3-macosx_11_0_arm64.whl` | - -从源码安装需要 Rust 1.85 及以上: - -```bash -pip install maturin -maturin build --release -pip install dist/greptimedb_ingester-*.whl -``` - -## 写入 +## gRPC ```python -from datetime import datetime, timezone - -from greptimedb_ingester import Client, Column, ColumnDataType, SemanticType +from greptimedb_ingester.grpc import Client client = Client(["127.0.0.1:4001"], database="public") - -affected = client.insert( - "sensor_data", - columns=[ - Column("device_id", ColumnDataType.STRING, SemanticType.TAG), - Column("ts", ColumnDataType.TIMESTAMP_MILLISECOND, SemanticType.TIMESTAMP), - Column("temperature", ColumnDataType.FLOAT64, SemanticType.FIELD), - ], - rows=[ - ["device_001", datetime(2009, 2, 13, 23, 31, 30, tzinfo=timezone.utc), 23.5], - {"device_id": "device_002", "ts": 1_234_567_890_001, "temperature": 24.0}, - ], -) -print(affected) +# client.insert(table, columns, rows) ``` -表不存在时,这次 `insert` 会建表。`examples/insert_example.py` 里还有写入已有表的例子。批量写入见 `examples/bulk_example.py`,要先有表。 +支持 `insert`、`delete` 和 `bulk_writer`,详见 [使用指南](docs/usage.md) 和 [示例](examples)。 +两种后端都安装时,顶层 `Client` 默认 gRPC; +使用上面的显式导入选择后端。 ## 开发 ```bash -uv python pin 3.10 -uv sync --group dev -uv run maturin develop -uv run pytest +uv sync --python 3.10 --locked --extra postgres +uv run --extra postgres pytest ``` -`pytest` 只检查值转换,不连接 GreptimeDB。 +开发两种后端时,将 `--extra postgres` 换成 `--all-extras`,需要 Rust 1.85+。 +集成测试与发布流程见 [测试文档](docs/testing.md)。 diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index efef00d..0000000 --- a/docs/api.md +++ /dev/null @@ -1,213 +0,0 @@ -# API - -`greptimedb_ingester` 的公开对象。值怎么对应列类型见 [usage.md](usage.md)。 - -```python -import greptimedb_ingester -greptimedb_ingester.__version__ # "0.1.0" -``` - -## Client - -```text -Client( - urls, - database="public", - username=None, - password=None, - timeout_secs=None, - connect_timeout_secs=None, - send_compression=None, - accept_compression=None, - tls_server_ca=None, - tls_client_cert=None, - tls_client_key=None, -) -``` - -构造时不连接 GreptimeDB。`urls` 是 gRPC 地址列表,例如 `["127.0.0.1:4001"]`,不能为空。 - -| 参数 | 说明 | -| --- | --- | -| `database` | 数据库名,默认 `"public"` | -| `username`、`password` | 一起写,或一起不写 | -| `timeout_secs` | 单次 gRPC 调用超时,秒。不写则用 Rust client 的默认值 | -| `connect_timeout_secs` | 建连超时,秒 | -| `send_compression`、`accept_compression` | `"gzip"` 或 `"zstd"` | -| `tls_server_ca`、`tls_client_cert`、`tls_client_key` | 三个路径一起写,或一起不写 | - -`client.database` 返回数据库名。没有 `close`。 - -### health_check - -```text -health_check() -> None -``` - -连上 gRPC 并做健康检查。失败抛 `GreptimeError`。 - -### insert - -```text -insert(table, columns, rows, hints=None) -> int -``` - -行写入。表不存在时,GreptimeDB 按 `columns` 建表。返回写入行数。 - -`rows` 是多行。每一行是 list、tuple,或按列名给的 dict。列顺序以 `columns` 为准。 - -`hints` 是 `(key, value)` 列表,对应请求头 `x-greptime-hints`。例如 `[("insert_skip_wal", "true")]`。 - -### delete - -```text -delete(table, columns, rows) -> int -``` - -按 `columns` 和 `rows` 删除。返回删除行数。`columns` 是 TAG 和时间索引,不是 FIELD。每一行的写法和 `insert` 相同。 - -### bulk_writer - -```text -bulk_writer(table, columns, options=None) -> BulkWriter -``` - -打开一个批量写入。表必须已经存在。`columns` 不能为空。`options` 默认是 `WriteOptions()`。 - -## BulkWriter - -`with client.bulk_writer(...) as writer` 正常退出时调用 `finish()`。发生异常时也会 `finish()`,结束失败不会盖住原来的异常。 - -| 属性 | 说明 | -| --- | --- | -| `table_name` | 表名 | -| `columns` | 构造时传入的列 | - -### alloc_rows - -```text -alloc_rows(capacity) -> Rows -``` - -分配一个行缓冲。`capacity` 是预留行数,不是必须写满的行数。 - -### write - -```text -write(rows) -> BulkResponse -``` - -提交 `rows` 并等待这一批的 ack。`rows` 在提交后被清空。空缓冲会失败。`rows` 必须由这个 writer 的 `alloc_rows` 创建。 - -### write_async - -```text -write_async(rows) -> list[int] -``` - -提交 `rows`,不等待 ack。返回 request id 列表。一次提交若被按时间窗口拆开,会有多个 id。之后用 `wait` 或 `wait_all` 收结果。 - -### wait - -```text -wait(request_id) -> BulkResponse -``` - -等待一个 request id。 - -### wait_all - -```text -wait_all() -> list[BulkResponse] -``` - -等待当前还没返回的全部请求。 - -### flush - -```text -flush() -> list[BulkResponse] -``` - -取回已经完成、还没被收走的响应。不等待仍在进行的请求。 - -### finish - -```text -finish() -> None -``` - -结束写入并关掉流。结束后再调用写入方法会失败。对象被回收时也会结束。 - -### finish_with_responses - -```text -finish_with_responses() -> list[BulkResponse] -``` - -结束写入,并返回收尾时还没被收走的响应。 - -## Rows - -由 `BulkWriter.alloc_rows` 创建,不能自己构造。 - -```text -add_row(row) -> None -add_rows(rows) -> int -``` - -`add_row` 追加一行。`add_rows` 一次追加一个可迭代对象里的全部行,返回追加的行数。某一行不合法时,这一批都不会追加。 - -`len(rows)` 是当前缓冲里的行数。 - -一行是 list、tuple,或按列名给的 dict。 - -## WriteOptions - -```text -WriteOptions(compression="lz4", timeout_secs=60.0, parallelism=4) -``` - -| 参数 | 说明 | -| --- | --- | -| `compression` | `"lz4"`、`"zstd"` 或 `"none"` | -| `timeout_secs` | 批量写入超时,秒,必须 `>= 0` | -| `parallelism` | 在途请求数,必须 `>= 1` | - -三个字段都可以读:`options.compression`、`options.timeout_secs`、`options.parallelism`。 - -## Column - -```text -Column(name, data_type, semantic_type, precision=None, scale=None) -``` - -| 参数 | 说明 | -| --- | --- | -| `name` | 列名,不能为空 | -| `data_type` | `ColumnDataType` | -| `semantic_type` | `SemanticType` | -| `precision`、`scale` | 只用于 `DECIMAL128`。`precision` 是 1 到 38,`scale` 是 0 到 `precision` | - -可读字段:`name`、`data_type`、`semantic_type`、`precision`、`scale`。 - -## SemanticType - -`SemanticType.TAG`、`SemanticType.FIELD`、`SemanticType.TIMESTAMP`。一张表用一个 `TIMESTAMP` 列做时间索引。 - -## ColumnDataType - -`BOOLEAN`、`INT8`、`INT16`、`INT32`、`INT64`、`UINT8`、`UINT16`、`UINT32`、`UINT64`、`FLOAT32`、`FLOAT64`、`BINARY`、`STRING`、`DATE`、`TIMESTAMP_SECOND`、`TIMESTAMP_MILLISECOND`、`TIMESTAMP_MICROSECOND`、`TIMESTAMP_NANOSECOND`、`TIME_SECOND`、`TIME_MILLISECOND`、`TIME_MICROSECOND`、`TIME_NANOSECOND`、`DECIMAL128`、`JSON`。 - -`JSON` 列存的是 JSONB。`insert` 传 JSON 字符串或 `dict` / `list`,服务端写成 JSONB。`bulk` 只接受 `bytes`,按原样写入,不转换。 - -## BulkResponse - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `request_id` | `int` | 这一批的 id | -| `affected_rows` | `int` | 写入行数 | - -## GreptimeError - -失败时抛出。`error.retriable` 为 `False` 表示参数不合法,重试没用。为 `True` 表示多数传输失败,可以再试。 diff --git a/docs/usage.md b/docs/usage.md index 4e948fc..623a533 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,7 +2,7 @@ GreptimeDB 的 Python 写入客户端。支持 Python 3.10 及以上。调用是同步的。 -两条写入路径: +gRPC 后端的两条写入路径(COPY 见文末): - `Client.insert`:gRPC 行写入。服务端可以按请求里的 schema 自动建表。 - `Client.bulk_writer`:Arrow Flight 批量写入。不会建表,表必须已经存在。 @@ -11,25 +11,19 @@ gRPC 端口默认是 `127.0.0.1:4001`,不是 HTTP 的 `4000`。接口说明见 ## 安装 -按自己的系统安装对应的 wheel。同一个操作系统和 CPU 上的 Python 3.10 及以上共用一个 wheel。 +用 extra 选择后端:`[grpc]`、`[postgres]` 或 `[grpc,postgres]`。 +不带 extra 的主包只含 schema 定义。这里先演示 gRPC;COPY 不需要 Rust。 -```bash -pip install greptimedb_ingester-0.1.0-cp310-abi3-manylinux_2_28_x86_64.whl -``` - -装完之后: +安装器会下载和当前操作系统、CPU 匹配的 wheel。同一个操作系统和 CPU 上的 Python 3.10 及以上共用一个 wheel。 -```python -import greptimedb_ingester -print(greptimedb_ingester.__version__) +```bash +pip install "greptimedb-ingester[grpc]" ``` 从源码装时,机器上要有 Rust 1.85+: ```bash -pip install maturin -maturin build --release -pip install dist/greptimedb_ingester-*.whl +uv sync --python 3.10 --extra grpc ``` ## 连接 @@ -235,7 +229,39 @@ except GreptimeError as err: - `examples/bench_write.py`:对本机 GreptimeDB 测写入速度 ```bash -uv run python examples/insert_example.py -uv run python examples/bulk_example.py -uv run python examples/bench_write.py --seconds 8 +uv run --extra grpc python examples/insert_example.py +uv run --extra grpc python examples/bulk_example.py +uv run --extra grpc python examples/bench_write.py --seconds 8 +``` + + +## PostgreSQL COPY 可选后端 + +同一个主包通过 extra 选择依赖;只装 `[postgres]` 不会安装 Rust SDK。 +上面的连接、转换和 bulk 示例针对 `[grpc]` 后端,COPY 的规则如下。 + +```bash +uv add "greptimedb-ingester[postgres]" +# 或 pip install "greptimedb-ingester[postgres]" +``` + +```python +from greptimedb_ingester import Column, ColumnDataType, SemanticType +from greptimedb_ingester.postgres import Client + +columns = [ + Column("host", ColumnDataType.STRING, SemanticType.TAG), + Column("ts", ColumnDataType.TIMESTAMP_MILLISECOND, SemanticType.TIMESTAMP), + Column("reading", ColumnDataType.FLOAT64, SemanticType.FIELD), +] +rows = (("edge", 1793066400000 + i, i / 10) for i in range(20003)) +with Client(["127.0.0.1:4003"], batch_size=5000) as client: + client.health_check() + affected = client.insert("copy_data", columns, rows) ``` + +只安装 `[postgres]` 时,也可直接导入顶层 `Client`。两种后端都安装时, +顶层 `Client` 默认 gRPC,COPY 用显式导入;schema 和 `GreptimeError` 共用。 +COPY 只提供 insert、health_check、close 和 with;不提供 bulk_writer、delete 或 gRPC hints。 +支持类型、时间转换、逐批提交和失败行为见 [README](../README.zh-CN.md) +和 [API](api.md#postgresql-copy-后端)。完整例子见 `examples/postgres_example.py`。 diff --git a/examples/postgres_example.py b/examples/postgres_example.py new file mode 100644 index 0000000..b709d8d --- /dev/null +++ b/examples/postgres_example.py @@ -0,0 +1,31 @@ +"""CSV COPY insert through the PostgreSQL extra (port 4003).""" + +import os +from greptimedb_ingester import Column, ColumnDataType, SemanticType +from greptimedb_ingester.postgres import Client + + +def main(): + columns = [ + Column("host", ColumnDataType.STRING, SemanticType.TAG), + Column("ts", ColumnDataType.TIMESTAMP_MILLISECOND, SemanticType.TIMESTAMP), + Column("reading", ColumnDataType.FLOAT64, SemanticType.FIELD), + ] + rows = (("edge", 1793066400000 + i, i / 10) for i in range(20003)) + with Client( + [os.getenv("GREPTIMEDB_POSTGRES", "127.0.0.1:4003")], batch_size=5000 + ) as client: + client.health_check() + affected = client.insert("py_postgres_example", columns, rows) + affected += client.insert( + "py_postgres_example", + columns, + [ + {"host": "edge", "ts": 1793066420003, "reading": None}, + ], + ) + print(f"COPY affected={affected}") + + +if __name__ == "__main__": + main() diff --git a/packages/grpc/.python-version b/packages/grpc/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/packages/grpc/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/Cargo.lock b/packages/grpc/Cargo.lock similarity index 99% rename from Cargo.lock rename to packages/grpc/Cargo.lock index b9e7702..aca1f56 100644 --- a/Cargo.lock +++ b/packages/grpc/Cargo.lock @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "greptimedb-ingester-python" -version = "0.1.1" +version = "0.1.2" dependencies = [ "greptimedb-ingester", "pyo3", diff --git a/Cargo.toml b/packages/grpc/Cargo.toml similarity index 96% rename from Cargo.toml rename to packages/grpc/Cargo.toml index 63ebe37..5bb516c 100644 --- a/Cargo.toml +++ b/packages/grpc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "greptimedb-ingester-python" -version = "0.1.1" +version = "0.1.2" edition = "2021" rust-version = "1.85" license = "Apache-2.0" diff --git a/packages/grpc/LICENSE b/packages/grpc/LICENSE new file mode 100644 index 0000000..11d287d --- /dev/null +++ b/packages/grpc/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Greptime Team + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/grpc/README.md b/packages/grpc/README.md new file mode 100644 index 0000000..eb0a542 --- /dev/null +++ b/packages/grpc/README.md @@ -0,0 +1,6 @@ +# greptimedb-ingester-grpc + +Internal native backend for `greptimedb-ingester[grpc]`. Users install the public +facade with that extra. This distribution contains the Rust/PyO3 extension only; +it has no dependency on the public facade, so the dependency graph has no cycle. +Native wheels use abi3-py310 and are built on their target OS/architecture. diff --git a/packages/grpc/README.zh-CN.md b/packages/grpc/README.zh-CN.md new file mode 100644 index 0000000..e7d9169 --- /dev/null +++ b/packages/grpc/README.zh-CN.md @@ -0,0 +1,5 @@ +# greptimedb-ingester-grpc + +`greptimedb-ingester[grpc]` 的内部原生后端。用户通过公共包的 grpc extra 安装。 +本发行包只包含 Rust/PyO3 扩展,不反向依赖公共包,避免依赖环。 +原生 wheel 使用 abi3-py310,在对应系统和架构构建。 diff --git a/packages/grpc/pyproject.toml b/packages/grpc/pyproject.toml new file mode 100644 index 0000000..dbcc500 --- /dev/null +++ b/packages/grpc/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" + +[project] +name = "greptimedb-ingester-grpc" +version = "0.1.2" +description = "Internal native backend for greptimedb-ingester[grpc]" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" + +[dependency-groups] +dev = ["maturin>=1.8,<2.0"] + +[tool.maturin] +python-source = "python" +module-name = "greptimedb_ingester_grpc._native" +features = ["pyo3/extension-module"] + +[tool.uv] +cache-keys = [{ file = "pyproject.toml" }, { file = "Cargo.toml" }, { file = "Cargo.lock" }, { file = "src/**/*.rs" }, { file = "python/**/*.py" }] diff --git a/packages/grpc/python/greptimedb_ingester_grpc/__init__.py b/packages/grpc/python/greptimedb_ingester_grpc/__init__.py new file mode 100644 index 0000000..3cc06b9 --- /dev/null +++ b/packages/grpc/python/greptimedb_ingester_grpc/__init__.py @@ -0,0 +1,3 @@ +"""Internal native backend for greptimedb-ingester[grpc].""" + +__version__ = "0.1.2" diff --git a/src/bulk.rs b/packages/grpc/src/bulk.rs similarity index 100% rename from src/bulk.rs rename to packages/grpc/src/bulk.rs diff --git a/src/cell.rs b/packages/grpc/src/cell.rs similarity index 100% rename from src/cell.rs rename to packages/grpc/src/cell.rs diff --git a/src/client.rs b/packages/grpc/src/client.rs similarity index 100% rename from src/client.rs rename to packages/grpc/src/client.rs diff --git a/src/convert.rs b/packages/grpc/src/convert.rs similarity index 100% rename from src/convert.rs rename to packages/grpc/src/convert.rs diff --git a/src/error.rs b/packages/grpc/src/error.rs similarity index 100% rename from src/error.rs rename to packages/grpc/src/error.rs diff --git a/src/lib.rs b/packages/grpc/src/lib.rs similarity index 100% rename from src/lib.rs rename to packages/grpc/src/lib.rs diff --git a/src/types.rs b/packages/grpc/src/types.rs similarity index 100% rename from src/types.rs rename to packages/grpc/src/types.rs diff --git a/packages/grpc/uv.lock b/packages/grpc/uv.lock new file mode 100644 index 0000000..d625e90 --- /dev/null +++ b/packages/grpc/uv.lock @@ -0,0 +1,114 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "greptimedb-ingester-grpc" +version = "0.1.2" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "maturin" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "maturin", specifier = ">=1.8,<2.0" }] + +[[package]] +name = "maturin" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/c8/22e5e21b2679c9bce6415ca578034ca2cc9316be0642ae21e051a2d5198c/maturin-1.15.0.tar.gz", hash = "sha256:94b26cc8e8aba61a5f2099715fe640e18c5f678e9a500408b38761263954228a", size = 385504, upload-time = "2026-08-24T12:11:22.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/69/5c01b461044eb1f45ddcce006706eb88110c793cdb11c7ae0b5e08492e94/maturin-1.15.0-py3-none-linux_armv6l.whl", hash = "sha256:6bf6dc62e22d4dcfd5a51244ff0d58975fa4979c48209fe84159617648956d82", size = 10206220, upload-time = "2026-08-24T12:10:53.327Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1f/2b431554e11687cdb1077e0cdadcc118c53f611086b3af00c8545a67c6a5/maturin-1.15.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cd35772633f489841132bc8e71d6fc7f842df30b9c05cd5cdf1ee1ddcb744cc7", size = 19416513, upload-time = "2026-08-24T12:10:56.126Z" }, + { url = "https://files.pythonhosted.org/packages/51/36/e23a21cb34a648b711036b9b2fe1d4f3f4ee24f8db54215d73f1a9a3a3ec/maturin-1.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c40b4eae7bf5ef1f4b1af8d623fe4105016f93578fb15b764e741d08ec3b92dd", size = 10014962, upload-time = "2026-08-24T12:10:58.486Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/33b15cb2d8f30f12c807955e8f2fd775027692904e30ec0784744ce8cd83/maturin-1.15.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:7eb066372f541f8eb4909c79c5d9bd0b9e8125980bdf1ec9e8aba23c6c8d6c55", size = 10196223, upload-time = "2026-08-24T12:11:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/b495e19e2f5c503b540452b2039115e7b2363867e8c5ad4179eb752fa92c/maturin-1.15.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:653020a63525bb224e5ab0adf02e17a2e08bc86dbea7fc1399c9a56d7529b99e", size = 10541186, upload-time = "2026-08-24T12:11:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2b/2abff58037188d852b124871b1f0d720e1c2bfb3d4f1b03d87c52cd66488/maturin-1.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0ebf9767892725083138e671c34482c660317a2f3d6a29fc0e0f34e9d8c99136", size = 10083468, upload-time = "2026-08-24T12:11:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/b7e9f8be99a6627849e81ac7b6694876bce8f50a92995fe17e3cf2610f0a/maturin-1.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7ab7eebffd7b8debca2265985de4eaeb332141276d24b9560b5ad484d4b3add1", size = 10047786, upload-time = "2026-08-24T12:11:07.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ab/167e3cb7accee11b507dbe53e0e87aeccb376d44ae66284c96ee4df3a9fd/maturin-1.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:126e12e618b4db42f68c779a56d41f82a390145ba36ac3f621d057eb34f5ad9d", size = 13315332, upload-time = "2026-08-24T12:11:09.433Z" }, + { url = "https://files.pythonhosted.org/packages/14/4d/801379f646cbc6b00998e5289b0630a886be3a4ee4c75b6bc9b87478a7f1/maturin-1.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f9d33e6c3f9615c8caceecbbbd440f8eb25a3ddeb687077682cd5eca2e9ae15", size = 10807183, upload-time = "2026-08-24T12:11:11.73Z" }, + { url = "https://files.pythonhosted.org/packages/89/27/2e612e1cbd1580e9e94d4722c227b5180dca27b32b955a34b79918aa1292/maturin-1.15.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:bf29beddd0c6708f112db51d5275fc28b28b9e9c9c5faae387eaef662918b176", size = 10413274, upload-time = "2026-08-24T12:11:14.04Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/202a7b4d75a51f20f84ec9ce3b7345b12b822207072164e2b1c6ef665125/maturin-1.15.0-py3-none-win32.whl", hash = "sha256:da649988be98e87e009e51b1bf0d301b6a301bc0cecbdd60d40d8ba60748d1ca", size = 8928744, upload-time = "2026-08-24T12:11:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/40/dc/4e90da594986ba78dd3bc8a5921ecdcdb11085b22b02a412caab3b225601/maturin-1.15.0-py3-none-win_amd64.whl", hash = "sha256:552c2be4afd43fe8d5c9f3ec8d4c4756d973b8dcbe94c14084390301f50243e1", size = 10335085, upload-time = "2026-08-24T12:11:18.326Z" }, + { url = "https://files.pythonhosted.org/packages/8b/10/15d4314edf130955edf2dc237aa393a8a7c10f2b9b57b89fa2f61f915659/maturin-1.15.0-py3-none-win_arm64.whl", hash = "sha256:c7dc0c66c78d3debdd9c5aa807e861fbcbf07f3505d34b125df74c03986b0f48", size = 9713795, upload-time = "2026-08-24T12:11:20.83Z" }, +] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index 44ed602..92d7ee0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,40 +1,39 @@ [build-system] -requires = ["maturin>=1.8,<2.0"] -build-backend = "maturin" +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" [project] name = "greptimedb-ingester" -version = "0.1.1" -description = "Python bindings for the GreptimeDB Rust ingester" +version = "0.1.2" +description = "GreptimeDB Python client with optional gRPC and PostgreSQL COPY backends" readme = "README.md" requires-python = ">=3.10" license = "Apache-2.0" -keywords = ["greptimedb", "database", "ingest", "grpc"] -classifiers = [ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Rust", -] +license-files = ["LICENSE"] +keywords = ["greptimedb", "database", "ingest", "grpc", "postgres"] [project.urls] -Repository = "https://github.com/GreptimeTeam/greptimedb-ingester-rust" +Repository = "https://github.com/GreptimeTeam/greptimedb-python-sdk" + +[project.optional-dependencies] +grpc = ["greptimedb-ingester-grpc==0.1.2"] +postgres = ["psycopg2-binary>=2.9.10,<3"] [dependency-groups] -dev = [ - "maturin>=1.8,<2.0", - "pytest>=8.0", -] +dev = ["pytest>=8.0"] + +[tool.setuptools.packages.find] +where = ["python"] +include = ["greptimedb_ingester*"] -[tool.maturin] -python-source = "python" -module-name = "greptimedb_ingester._native" -features = ["pyo3/extension-module"] +[tool.setuptools.package-data] +greptimedb_ingester = ["py.typed"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["examples"] addopts = "-m 'not integration'" -markers = ["integration: write tests that need GREPTIMEDB_GRPC"] +markers = ["integration: writes requiring GREPTIMEDB_GRPC or GREPTIMEDB_POSTGRES"] -[tool.uv] -cache-keys = [{ file = "pyproject.toml" }, { file = "Cargo.toml" }, { file = "Cargo.lock" }, { file = "src/**/*.rs" }] +[tool.uv.sources] +greptimedb-ingester-grpc = { path = "packages/grpc", editable = true } diff --git a/python/greptimedb_ingester/__init__.py b/python/greptimedb_ingester/__init__.py index 1c568ce..b0b470a 100644 --- a/python/greptimedb_ingester/__init__.py +++ b/python/greptimedb_ingester/__init__.py @@ -1,27 +1,40 @@ -"""Python bindings for the GreptimeDB Rust ingester.""" +"""Pure Python facade. Choose optional backends with [grpc] and [postgres].""" -from greptimedb_ingester._native import ( - BulkResponse, - BulkWriter, - Client, - Column, - ColumnDataType, - GreptimeError, - Rows, - SemanticType, - WriteOptions, -) +from importlib.util import find_spec -__version__ = "0.1.1" +from ._errors import GreptimeError +from ._types import Column, ColumnDataType, SemanticType +__version__ = "0.1.2" __all__ = [ - "BulkResponse", - "BulkWriter", "Client", "Column", "ColumnDataType", - "GreptimeError", - "Rows", "SemanticType", - "WriteOptions", + "GreptimeError", + "__version__", ] + +if find_spec("greptimedb_ingester_grpc") is not None: + # Preserve the existing gRPC Client and native schema classes when installed. + from .grpc import ( + BulkResponse, + BulkWriter, + Client, + Column, + ColumnDataType, + Rows, + SemanticType, + WriteOptions, + ) + + __all__ += ["BulkResponse", "BulkWriter", "Rows", "WriteOptions"] +elif find_spec("psycopg2") is not None: + from .postgres import Client +else: + + class Client: + def __init__(self, *args, **kwargs): + raise ImportError( + "Install greptimedb-ingester[grpc] or greptimedb-ingester[postgres]" + ) diff --git a/python/greptimedb_ingester/_errors.py b/python/greptimedb_ingester/_errors.py new file mode 100644 index 0000000..26b8cf4 --- /dev/null +++ b/python/greptimedb_ingester/_errors.py @@ -0,0 +1,12 @@ +"""One public error type shared by all installed backends.""" + +try: + from greptimedb_ingester_grpc._native import GreptimeError +except ModuleNotFoundError as exc: + if not exc.name.startswith("greptimedb_ingester_grpc"): + raise + + class GreptimeError(Exception): + def __init__(self, message, retriable=False): + super().__init__(message) + self.retriable = retriable diff --git a/python/greptimedb_ingester/_native.py b/python/greptimedb_ingester/_native.py new file mode 100644 index 0000000..4fa9cf3 --- /dev/null +++ b/python/greptimedb_ingester/_native.py @@ -0,0 +1,8 @@ +"""Compatibility import for callers of the former native module.""" + +try: + from greptimedb_ingester_grpc._native import * # noqa: F403 +except ModuleNotFoundError as exc: + raise ImportError( + "Install greptimedb-ingester[grpc] for the native extension" + ) from exc diff --git a/python/greptimedb_ingester/_types.py b/python/greptimedb_ingester/_types.py new file mode 100644 index 0000000..d2ecc44 --- /dev/null +++ b/python/greptimedb_ingester/_types.py @@ -0,0 +1,79 @@ +"""Python schema values matching the gRPC SDK's public names and enum values.""" + +from dataclasses import dataclass +from enum import IntEnum + + +from ._errors import GreptimeError + + +def _schema_error(message): + error = GreptimeError(message) + error.retriable = False + return error + + +class _NamedEnum(IntEnum): + def __repr__(self): + return f"{type(self).__name__}.{self.name}" + + +class SemanticType(_NamedEnum): + TAG = 0 + FIELD = 1 + TIMESTAMP = 2 + + +class ColumnDataType(_NamedEnum): + BOOLEAN = 0 + INT8 = 1 + INT16 = 2 + INT32 = 3 + INT64 = 4 + UINT8 = 5 + UINT16 = 6 + UINT32 = 7 + UINT64 = 8 + FLOAT32 = 9 + FLOAT64 = 10 + BINARY = 11 + STRING = 12 + DATE = 13 + TIMESTAMP_SECOND = 15 + TIMESTAMP_MILLISECOND = 16 + TIMESTAMP_MICROSECOND = 17 + TIMESTAMP_NANOSECOND = 18 + TIME_SECOND = 19 + TIME_MILLISECOND = 20 + TIME_MICROSECOND = 21 + TIME_NANOSECOND = 22 + DECIMAL128 = 30 + JSON = 31 + + +@dataclass(frozen=True) +class Column: + name: str + data_type: ColumnDataType + semantic_type: SemanticType + precision: int | None = None + scale: int | None = None + + def __post_init__(self): + if not isinstance(self.name, str) or not self.name or "\x00" in self.name: + raise _schema_error("column name must be a nonempty string without NUL") + if not isinstance(self.data_type, ColumnDataType): + raise _schema_error("data_type must be ColumnDataType") + if not isinstance(self.semantic_type, SemanticType): + raise _schema_error("semantic_type must be SemanticType") + if self.data_type == ColumnDataType.DECIMAL128: + if self.precision is None or self.scale is None: + raise _schema_error("decimal128 columns require precision and scale") + if type(self.precision) is not int or not 1 <= self.precision <= 38: + raise _schema_error("decimal128 precision must be between 1 and 38") + if type(self.scale) is not int or not 0 <= self.scale <= self.precision: + raise _schema_error("decimal128 scale must be between 0 and precision") + elif self.precision is not None or self.scale is not None: + raise _schema_error( + "precision and scale are only valid for decimal128 columns" + ) diff --git a/python/greptimedb_ingester/grpc.py b/python/greptimedb_ingester/grpc.py new file mode 100644 index 0000000..e5156db --- /dev/null +++ b/python/greptimedb_ingester/grpc.py @@ -0,0 +1,28 @@ +"""Explicit gRPC backend, installed by greptimedb-ingester[grpc].""" + +try: + from greptimedb_ingester_grpc._native import ( + BulkResponse, + BulkWriter, + Client, + Column, + ColumnDataType, + GreptimeError, + Rows, + SemanticType, + WriteOptions, + ) +except ModuleNotFoundError as exc: + raise ImportError("Install greptimedb-ingester[grpc] for gRPC support") from exc + +__all__ = [ + "Client", + "Column", + "ColumnDataType", + "SemanticType", + "GreptimeError", + "WriteOptions", + "Rows", + "BulkWriter", + "BulkResponse", +] diff --git a/python/greptimedb_ingester/postgres.py b/python/greptimedb_ingester/postgres.py new file mode 100644 index 0000000..dd956c2 --- /dev/null +++ b/python/greptimedb_ingester/postgres.py @@ -0,0 +1,514 @@ +"""Synchronous PostgreSQL COPY client, without a Rust or gRPC dependency.""" + +import csv +import io +import math +import threading +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from enum import Enum +from functools import lru_cache +from itertools import islice + +try: + import psycopg2 + from psycopg2 import sql + from psycopg2.extensions import parse_dsn +except ModuleNotFoundError as exc: + raise ImportError("Install greptimedb-ingester[postgres] for COPY support") from exc + + +from ._errors import GreptimeError + + +def _error(message, retriable=False, *, acknowledged_rows=None): + error = GreptimeError(message) + error.retriable = retriable + if acknowledged_rows is not None: + error.acknowledged_rows = acknowledged_rows + return error + + +class _SemanticType(Enum): + TAG = "TAG" + FIELD = "FIELD" + TIMESTAMP = "TIMESTAMP" + + +class _DataType(Enum): + BOOLEAN = "BOOLEAN" + INT8 = "TINYINT" + INT16 = "SMALLINT" + INT32 = "INT" + INT64 = "BIGINT" + UINT8 = "TINYINT UNSIGNED" + UINT16 = "SMALLINT UNSIGNED" + UINT32 = "INT UNSIGNED" + UINT64 = "BIGINT UNSIGNED" + FLOAT32 = "FLOAT" + FLOAT64 = "DOUBLE" + STRING = "STRING" + TIMESTAMP_SECOND = "TIMESTAMP(0)" + TIMESTAMP_MILLISECOND = "TIMESTAMP(3)" + TIMESTAMP_MICROSECOND = "TIMESTAMP(6)" + TIMESTAMP_NANOSECOND = "TIMESTAMP(9)" + + +_UNITS = { + _DataType.TIMESTAMP_SECOND: 0, + _DataType.TIMESTAMP_MILLISECOND: 3, + _DataType.TIMESTAMP_MICROSECOND: 6, + _DataType.TIMESTAMP_NANOSECOND: 9, +} +_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + + +@dataclass(frozen=True) +class _Column: + name: str + data_type: _DataType + semantic_type: _SemanticType + + def __post_init__(self): + if not isinstance(self.name, str) or not self.name or "\x00" in self.name: + raise ValueError("Column name must be a nonempty string without NUL") + if not isinstance(self.data_type, _DataType): + raise TypeError("Invalid column data type") + if not isinstance(self.semantic_type, _SemanticType): + raise TypeError("Invalid column semantic type") + if ( + self.semantic_type == _SemanticType.TIMESTAMP + and self.data_type not in _UNITS + ): + raise ValueError("Time index must have a timestamp data type") + if ( + self.semantic_type == _SemanticType.TAG + and self.data_type != _DataType.STRING + ): + raise ValueError("This client supports STRING tags") + + +def _normalize_columns(columns): + result = [] + for column in columns: + try: + name = repr(column.data_type).split(".")[-1] + column_name = column.name + semantic_name = repr(column.semantic_type).split(".")[-1] + except AttributeError as exc: + raise TypeError("columns must contain Column objects") from exc + try: + dtype = _DataType[name] + except KeyError: + raise ValueError(f"PostgreSQL COPY does not support {name}") from None + try: + semantic = _SemanticType[semantic_name] + except KeyError: + raise ValueError(f"Invalid column semantic type: {semantic_name}") from None + result.append(_Column(column_name, dtype, semantic)) + return _columns(result) + + +def _columns(columns): + columns = tuple(columns) + if not columns or any(not isinstance(c, _Column) for c in columns): + raise ValueError("columns must contain Column objects") + if len({c.name for c in columns}) != len(columns): + raise ValueError("Duplicate column names") + if sum(c.semantic_type == _SemanticType.TIMESTAMP for c in columns) != 1: + raise ValueError("Exactly one time index is required") + return columns + + +def _validate_schema(columns, description): + actual = {row[0]: (row[1], row[5]) for row in description} + for column in columns: + found = actual.get(column.name) + expected_type = column.data_type.name.replace("_", "") + expected_semantic = column.semantic_type.value + if found is None: + raise _error(f"Table schema mismatch: missing column {column.name!r}") + if found[0].upper() != expected_type or found[1] != expected_semantic: + raise _error( + f"Table schema mismatch for column {column.name!r}: " + f"expected {column.data_type.name} {expected_semantic}, " + f"found {found[0]} {found[1]}" + ) + + +def _table(table): + if not isinstance(table, str) or not table or "\x00" in table: + raise ValueError("table must be a nonempty string without NUL") + # Treat as a single identifier. The database comes from Client(database=...). + return sql.Identifier(table) + + +def _second_text(seconds): + # Convert each timestamp directly; no input-data cache or locality assumptions. + return (_EPOCH + timedelta(seconds=seconds)).isoformat(timespec="seconds")[:-6] + + +@lru_cache(maxsize=4) +def _timestamp_encoder(precision): + unit = 10**precision + divisor = 10 ** max(6 - precision, 0) + multiplier = 10 ** max(precision - 6, 0) + fraction_format = f"0{precision}d" + + def encode(value): + if type(value) is int: + units = value + elif isinstance(value, datetime): + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("datetime must include timezone information") + delta = value.astimezone(timezone.utc) - _EPOCH + micros = (delta.days * 86400 + delta.seconds) * 1000000 + delta.microseconds + if micros % divisor: + raise ValueError("Timestamp precision would lose data") + units = micros // divisor * multiplier + elif isinstance(value, int) and not isinstance(value, bool): + units = value + else: + raise TypeError( + "Timestamp must be an integer in the column's unit or an aware datetime" + ) + seconds, fraction = divmod(units, unit) + text = _second_text(seconds) + return ( + text + + ("." + format(fraction, fraction_format) if precision else "") + + "+00:00" + ) + + return encode + + +def _timestamp(value, precision): + return _timestamp_encoder(precision)(value) + + +def _column_encoder(column): + # Select types and calculate bounds once per schema, not for each field. + dtype = column.data_type + if dtype in _UNITS: + timestamp = _timestamp_encoder(_UNITS[dtype]) + required = column.semantic_type == _SemanticType.TIMESTAMP + + def encode(value): + if value is None: + if required: + raise ValueError("Time index cannot be NULL") + return None + return timestamp(value) + + return encode + if dtype == _DataType.STRING: + + def encode(value): + if value is None: + return None + if not isinstance(value, str): + raise TypeError(f"{column.name}: expected str") + # Quoted empty strings also become NULL in this CSV path. + if value == "" or "\x00" in value: + raise ValueError( + f"{column.name}: empty strings/NUL are unsupported by this COPY path; use None for NULL" + ) + return value + + return encode + if dtype == _DataType.BOOLEAN: + + def encode(value): + if value is None: + return None + if not isinstance(value, bool): + raise TypeError(f"{column.name}: expected bool") + return "true" if value else "false" + + return encode + if dtype.name.startswith(("INT", "UINT")): + unsigned = dtype.name.startswith("UINT") + bits = int(dtype.name[4:] if unsigned else dtype.name[3:]) + lower, upper = ( + (0, 2**bits) if unsigned else (-(2 ** (bits - 1)), 2 ** (bits - 1)) + ) + name = dtype.name + + def encode(value): + if value is None: + return None + if type(value) is not int and ( + not isinstance(value, int) or isinstance(value, bool) + ): + raise TypeError(f"{column.name}: expected int") + if not lower <= value < upper: + raise ValueError(f"{column.name}: integer outside {name} range") + # Python 3.10 IntEnum/IntFlag stringify as names, not CSV numbers. + return value if type(value) is int else int(value) + + return encode + if dtype in (_DataType.FLOAT32, _DataType.FLOAT64): + limit = 3.4028234663852886e38 if dtype == _DataType.FLOAT32 else math.inf + + def encode(value): + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{column.name}: expected float") + value = float(value) + if not math.isfinite(value) or abs(value) > limit: + raise ValueError(f"{column.name}: float outside supported range") + return value + + return encode + raise TypeError(f"Unsupported type: {dtype}") + + +@lru_cache(maxsize=128) +def _row_encoder(columns): + converters = tuple(_column_encoder(c) for c in columns) + names = tuple(c.name for c in columns) + keys = frozenset(names) + width = len(columns) + + def encode(row): + # Avoid the Mapping ABC check for the common positional row path. + if not isinstance(row, (list, tuple)): + if not isinstance(row, Mapping): + raise TypeError("Each row must be a list, tuple, or mapping") + if set(row) != keys: + raise ValueError("Row keys must exactly match the column names") + row = [row[name] for name in names] + if len(row) != width: + raise ValueError("Row length must match column count") + return [convert(value) for convert, value in zip(converters, row)] + + return encode + + +def _encode_row(columns, row): + return _row_encoder(tuple(columns))(row) + + +class Client: + """Lazy, single-connection writer. Calls are serialized, without retries.""" + + def __init__( + self, + urls, + database=None, + username=None, + password=None, + *, + batch_size=10000, + connect_timeout_secs=5, + ): + if isinstance(urls, str): + urls = [urls] + if not isinstance(urls, (list, tuple)) or len(urls) != 1: + raise _error( + "Provide exactly one PostgreSQL endpoint (typically port 4003)" + ) + if ( + isinstance(batch_size, bool) + or not isinstance(batch_size, int) + or batch_size <= 0 + ): + raise _error("batch_size must be a positive integer") + if database is not None and ( + not isinstance(database, str) or not database or "\x00" in database + ): + raise _error("database must be a nonempty string without NUL") + if ( + isinstance(connect_timeout_secs, bool) + or not isinstance(connect_timeout_secs, int) + or connect_timeout_secs < 0 + ): + raise _error("connect_timeout_secs must be a nonnegative integer") + for name, value in (("username", username), ("password", password)): + if value is not None and (not isinstance(value, str) or "\x00" in value): + raise _error(f"{name} must be a string without NUL") + endpoint = urls[0] + if not isinstance(endpoint, str) or not endpoint or "\x00" in endpoint: + raise _error("endpoint must be a nonempty string without NUL") + if not endpoint.startswith(("postgres://", "postgresql://")): + endpoint = "postgresql://" + endpoint + try: + self._params = parse_dsn(endpoint) + except psycopg2.Error as exc: + raise _error(str(exc)) from exc + if database is None: + database = self._params.get("dbname") or "public" + self._params.update(dbname=database, connect_timeout=connect_timeout_secs) + if username is not None: + self._params["user"] = username + if password is not None: + self._params["password"] = password + self.database = database + self.batch_size = batch_size + self._conn = None + self._closed = False + self._lock = threading.RLock() + self._schemas = {} + + def _connection(self): + if self._closed: + raise _error("Client is closed") + if self._conn is not None and self._conn.closed: + # Reconnect only on a new call, never by replaying a failed batch. + self._conn.close() + self._conn = None + self._schemas.clear() + if self._conn is None: + self._conn = psycopg2.connect(**self._params) + self._conn.autocommit = True + return self._conn + + def health_check(self): + with self._lock: + try: + with self._connection().cursor() as cur: + cur.execute("SELECT 1") + cur.fetchone() + except psycopg2.Error as exc: + raise _error( + str(exc), + isinstance( + exc, (psycopg2.OperationalError, psycopg2.InterfaceError) + ), + ) from exc + + def insert(self, table, columns, rows): + """Create if absent and COPY batches; return acknowledged row count. + + Batches are independently acknowledged. Failure may leave partial writes; + neither validation nor transport failures are retried automatically. + """ + try: + return self._insert(table, _normalize_columns(columns), rows) + except (ValueError, TypeError, OverflowError) as exc: + raise _error( + f"Input failed after 0 acknowledged rows: {exc}", acknowledged_rows=0 + ) from exc + + def _insert(self, table, columns, rows): + encode_row = _row_encoder(columns) + identifier = _table(table) + command = sql.SQL("COPY {} ({}) FROM STDIN WITH (FORMAT csv)").format( + identifier, sql.SQL(", ").join(sql.Identifier(c.name) for c in columns) + ) + copy_command = None + try: + iterator = iter(rows) + except Exception as exc: + raise _error( + f"Input failed after 0 acknowledged rows: {exc}", acknowledged_rows=0 + ) from exc + affected = 0 + with self._lock: + if self._closed: + raise _error("Client is closed") + while True: + try: + # Snapshot each encoded row before advancing a source that + # may reuse the same mutable list or dict for its next row. + batch = [ + encode_row(row) for row in islice(iterator, self.batch_size) + ] + except Exception as exc: + raise _error( + f"Input failed after {affected} acknowledged rows: {exc}", + acknowledged_rows=affected, + ) from exc + if not batch: + return affected + buffer = io.StringIO(newline="") + writer = csv.writer(buffer) + first_row = batch[0] + # The CSV decoder strips a BOM at the start of each COPY stream. + # Quote that row so a leading U+FEFF remains part of the value. + if isinstance(first_row[0], str) and first_row[0].startswith("\ufeff"): + csv.writer(buffer, quoting=csv.QUOTE_ALL).writerow(first_row) + else: + writer.writerow(first_row) + for row in islice(batch, 1, None): + writer.writerow(row) + buffer.seek(0) + try: + conn = self._connection() + with conn.cursor() as cur: + if self._schemas.get(table) != columns: + self._schemas.pop(table, None) + definitions = [ + sql.SQL("{} {}").format( + sql.Identifier(c.name), sql.SQL(c.data_type.value) + ) + for c in columns + ] + time_col = next( + c + for c in columns + if c.semantic_type == _SemanticType.TIMESTAMP + ) + definitions.append( + sql.SQL("TIME INDEX ({})").format( + sql.Identifier(time_col.name) + ) + ) + tags = [ + sql.Identifier(c.name) + for c in columns + if c.semantic_type == _SemanticType.TAG + ] + if tags: + definitions.append( + sql.SQL("PRIMARY KEY ({})").format( + sql.SQL(", ").join(tags) + ) + ) + cur.execute( + sql.SQL("CREATE TABLE IF NOT EXISTS {} ({})").format( + identifier, sql.SQL(", ").join(definitions) + ) + ) + cur.execute(sql.SQL("DESC TABLE {}").format(identifier)) + _validate_schema(columns, cur.fetchall()) + self._schemas[table] = columns + if copy_command is None: + copy_command = command.as_string(conn) + cur.copy_expert(copy_command, buffer) + if cur.rowcount != len(batch): + raise _error( + f"COPY row-count mismatch after {affected} acknowledged rows; " + f"current batch outcome is uncertain: " + f"Expected {len(batch)} acknowledged rows, received {cur.rowcount}", + acknowledged_rows=affected, + ) + except psycopg2.Error as exc: + raise _error( + f"COPY failed after {affected} acknowledged rows; current batch may be partial: {exc}", + isinstance( + exc, (psycopg2.OperationalError, psycopg2.InterfaceError) + ), + acknowledged_rows=affected, + ) from exc + affected += len(batch) + + def close(self): + with self._lock: + if self._conn is not None: + self._conn.close() + self._closed = True + + def __enter__(self): + if self._closed: + raise _error("Client is closed") + return self + + def __exit__(self, *_): + self.close() + + +__all__ = ["Client"] diff --git a/scripts/set-version.py b/scripts/set-version.py index 1fc534f..b5bb3d1 100755 --- a/scripts/set-version.py +++ b/scripts/set-version.py @@ -1,9 +1,5 @@ #!/usr/bin/env python3 -"""Set this package's version in three fixed lines, then compile. - -Cargo rewrites Cargo.lock from Cargo.toml during that compile. This script -does not edit the lock file itself. -""" +"""Keep facade, native plugin and their exact optional dependency in sync.""" import subprocess import sys @@ -25,19 +21,32 @@ def set_first_line(path, prefix, new_line): def main(): if len(sys.argv) != 2: - raise SystemExit("usage: uv run python scripts/set-version.py 0.1.1") + raise SystemExit("usage: uv run python scripts/set-version.py 0.1.2") version = sys.argv[1] parts = version.split(".") if len(parts) != 3 or not all(part.isdigit() for part in parts): - raise SystemExit("version must look like 0.1.1") - set_first_line("pyproject.toml", 'version = "', f'version = "{version}"') - set_first_line("Cargo.toml", 'version = "', f'version = "{version}"') - set_first_line( + raise SystemExit("version must look like 0.1.2") + for path in [ + "pyproject.toml", + "packages/grpc/pyproject.toml", + "packages/grpc/Cargo.toml", + ]: + set_first_line(path, 'version = "', f'version = "{version}"') + for path in [ "python/greptimedb_ingester/__init__.py", - '__version__ = "', - f'__version__ = "{version}"', + "packages/grpc/python/greptimedb_ingester_grpc/__init__.py", + ]: + set_first_line(path, '__version__ = "', f'__version__ = "{version}"') + set_first_line( + "pyproject.toml", "grpc = [", f'grpc = ["greptimedb-ingester-grpc=={version}"]' + ) + subprocess.run( + ["cargo", "check", "--manifest-path", "packages/grpc/Cargo.toml", "--offline"], + cwd=ROOT, + check=True, ) - subprocess.run(["cargo", "check", "--offline"], cwd=ROOT, check=True) + subprocess.run(["uv", "lock"], cwd=ROOT, check=True) + subprocess.run(["uv", "lock", "--project", "packages/grpc"], cwd=ROOT, check=True) print(version) diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..85c5714 --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,86 @@ +"""The same public package supports base, grpc, postgres, and combined installs.""" + +from importlib import metadata, util +import subprocess +import sys +import pytest +from packaging.requirements import Requirement +import greptimedb_ingester as sdk + + +def test_extras_are_the_only_runtime_dependencies(): + dependencies = [ + Requirement(value) for value in metadata.requires("greptimedb-ingester") or [] + ] + + def selected(extra): + return { + req.name + for req in dependencies + if req.marker is None or req.marker.evaluate({"extra": extra}) + } + + assert selected("") == set() + assert selected("grpc") == {"greptimedb-ingester-grpc"} + assert selected("postgres") == {"psycopg2-binary"} + native = next(req for req in dependencies if req.name == "greptimedb-ingester-grpc") + assert sdk.__version__ in native.specifier + + +def test_public_schema_is_available_without_a_backend(): + column = sdk.Column("host", sdk.ColumnDataType.STRING, sdk.SemanticType.TAG) + assert column.name == "host" + assert repr(column.data_type) == "ColumnDataType.STRING" + assert repr(column.semantic_type) == "SemanticType.TAG" + + +def test_default_client_matches_installed_backend(): + if util.find_spec("greptimedb_ingester_grpc") is not None: + from greptimedb_ingester.grpc import Client, Column, GreptimeError + + assert sdk.Client is Client and sdk.Column is Column + assert sdk.GreptimeError is GreptimeError + elif util.find_spec("psycopg2") is not None: + from greptimedb_ingester.postgres import Client + + assert sdk.Client is Client + else: + with pytest.raises(ImportError, match=r"\[grpc\].*\[postgres\]"): + sdk.Client(["localhost:4003"]) + + +def test_postgres_errors_are_the_public_error_type(): + pytest.importorskip("psycopg2") + from greptimedb_ingester.postgres import Client + + with pytest.raises(sdk.GreptimeError) as exc: + Client(["localhost:4003"], batch_size=0) + assert exc.value.retriable is False + + +def test_missing_grpc_extra_has_a_clear_error(): + if util.find_spec("greptimedb_ingester_grpc") is not None: + pytest.skip("gRPC is installed") + with pytest.raises(ImportError, match=r"\[grpc\]"): + __import__("greptimedb_ingester.grpc") + + +def test_missing_postgres_extra_has_a_clear_error(): + if util.find_spec("psycopg2") is not None: + pytest.skip("Postgres is installed") + with pytest.raises(ImportError, match=r"\[postgres\]"): + __import__("greptimedb_ingester.postgres") + + +def test_grpc_import_does_not_load_the_postgres_driver(): + if util.find_spec("greptimedb_ingester_grpc") is None: + pytest.skip("gRPC is not installed") + code = """ +import sys +import greptimedb_ingester as sdk +from greptimedb_ingester.grpc import Client +assert sdk.Client is Client +assert "psycopg2" not in sys.modules +assert "greptimedb_ingester.postgres" not in sys.modules +""" + subprocess.run([sys.executable, "-c", code], check=True) diff --git a/tests/test_convert.py b/tests/test_convert.py index 44b68c9..9f1ffcd 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -3,6 +3,8 @@ import pytest +pytest.importorskip("greptimedb_ingester_grpc") + from greptimedb_ingester import ( Client, Column, diff --git a/tests/test_integration.py b/tests/test_integration.py index dc5f193..c3e83bc 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -13,6 +13,8 @@ import pytest +pytest.importorskip("greptimedb_ingester_grpc") + from greptimedb_ingester import Client, Column, ColumnDataType, GreptimeError, SemanticType, WriteOptions pytestmark = pytest.mark.integration diff --git a/tests/test_postgres.py b/tests/test_postgres.py new file mode 100644 index 0000000..fa58431 --- /dev/null +++ b/tests/test_postgres.py @@ -0,0 +1,871 @@ +"""Offline tests for the PostgreSQL COPY backend.""" + +import csv +import io +import os +import subprocess +import sys +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from enum import IntEnum, IntFlag +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("psycopg2") +from greptimedb_ingester import ( + Column, + ColumnDataType as D, + GreptimeError, + SemanticType as S, +) +from greptimedb_ingester import postgres as pg + +COLUMNS = [ + Column("host", D.STRING, S.TAG), + Column("ts", D.TIMESTAMP_NANOSECOND, S.TIMESTAMP), + Column("value", D.INT64, S.FIELD), +] + + +TABLE_SCHEMA = [ + ("host", "String", "PRI", "YES", "", "TAG"), + ("ts", "TimestampNanosecond", "PRI", "NO", "", "TIMESTAMP"), + ("value", "Int64", "", "YES", "", "FIELD"), +] + + +@pytest.fixture +def database(monkeypatch): + connection = MagicMock(closed=0) + cursor = connection.cursor.return_value.__enter__.return_value + payloads = [] + cursor.fetchall.return_value = list(TABLE_SCHEMA) + + def copy(command, data): + rows = list(csv.reader(io.StringIO(data.read()))) + payloads.append((command, rows)) + cursor.rowcount = len(rows) + + cursor.copy_expert.side_effect = copy + monkeypatch.setattr( + pg.psycopg2.extensions, + "quote_ident", + lambda text, _: '"' + text.replace('"', '""') + '"', + ) + connect = MagicMock(return_value=connection) + monkeypatch.setattr(pg.psycopg2, "connect", connect) + return connection, cursor, connect, payloads + + +def test_constructor_is_lazy_and_connection_options_reach_driver(database): + conn, cur, connect, _ = database + with pg.Client( + "postgresql://uri:old@localhost:4003/other?sslmode=require", + database="public", + username="user", + password="secret", + connect_timeout_secs=3, + ) as client: + connect.assert_not_called() + assert client.database == "public" + client.health_check() + client.health_check() + connect.assert_called_once_with( + user="user", + password="secret", + host="localhost", + port="4003", + dbname="public", + sslmode="require", + connect_timeout=3, + ) + assert conn.autocommit is True + assert cur.execute.call_args.args == ("SELECT 1",) + conn.close.assert_called_once() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"urls": []}, + {"urls": ["a", "b"]}, + {"urls": [None]}, + {"urls": [""]}, + {"urls": ["bad\x00host"]}, + {"batch_size": 0}, + {"batch_size": True}, + {"batch_size": 1.5}, + {"database": ""}, + {"connect_timeout_secs": -1}, + {"connect_timeout_secs": 0.5}, + {"password": 12}, + ], +) +def test_invalid_connection_options_are_local_errors(kwargs): + with pytest.raises(GreptimeError) as exc: + pg.Client(**({"urls": ["127.0.0.1:4003"]} | kwargs)) + assert exc.value.retriable is False + + +def test_empty_insert_never_connects(database): + _, _, connect, _ = database + with pg.Client(["localhost:4003"]) as client: + assert client.insert("empty", COLUMNS, iter(())) == 0 + connect.assert_not_called() + + +def test_batches_dict_rows_csv_escaping_and_inputs_are_preserved(database): + _, cur, _, payloads = database + rows = [ + ['设备,"一"\n行', 1, 3], + {"value": None, "host": "b", "ts": -1}, + ("c", 1000000001, 5), + ] + before = deepcopy(rows) + with pg.Client(["localhost:4003"], batch_size=2) as client: + assert client.insert('table"name', COLUMNS, (r for r in rows)) == 3 + assert client.insert('table"name', COLUMNS, [["d", 2, 6]]) == 1 + assert [len(rows) for _, rows in payloads] == [2, 1, 1] + assert payloads[0][1][0] == [rows[0][0], "1970-01-01T00:00:00.000000001+00:00", "3"] + assert payloads[0][1][1] == ["b", "1969-12-31T23:59:59.999999999+00:00", ""] + assert '"table""name"' in payloads[0][0] + assert ( + cur.execute.call_count == 2 + ) # One create-and-validate, then cached existing-table writes. + assert "TIME INDEX" in cur.execute.call_args_list[0].args[0].as_string(None) + assert rows == before + + +@pytest.mark.parametrize("batch_size", [1, 2, 3]) +def test_bom_at_copy_batch_boundaries_preserves_values(database, batch_size): + _, cur, _, _ = database + actual = [] + + def copy(command, data): + # GreptimeDB consumes a UTF-8 BOM at the start of each CSV stream. + text = data.read().encode("utf-8").decode("utf-8-sig") + batch = list(csv.reader(io.StringIO(text))) + actual.extend(batch) + cur.rowcount = len(batch) + + cur.copy_expert.side_effect = copy + hosts = [ + "\ufeffvalue", + "\ufeff", + "随意\ufeff", + '\ufeff"quoted",\nline', + None, + "value", + "\ufefftail", + ] + rows = [{"host": host, "ts": 0, "value": i} for i, host in enumerate(hosts)] + before = deepcopy(rows) + with pg.Client("localhost:4003", batch_size=batch_size) as client: + assert client.insert("bom_values", COLUMNS, iter(rows)) == len(rows) + assert [row[0] for row in actual] == [ + host if host is not None else "" for host in hosts + ] + assert [row[2] for row in actual] == [str(i) for i in range(len(rows))] + assert rows == before + + +@pytest.mark.parametrize( + "rows", + [ + [["", 0, 1]], + [["bad\x00tag", 0, 1]], + [[123, 0, 1]], + [["a", None, 1]], + [["a", True, 1]], + [["a", 1, True]], + [["a", 1]], + [{"host": "a", "ts": 1, "value": 1, "extra": 0}], + [42], + ], +) +def test_invalid_first_batch_is_rejected_without_connecting(database, rows): + _, _, connect, _ = database + before = deepcopy(rows) + with pytest.raises(GreptimeError) as exc: + pg.Client(["localhost:4003"]).insert("bad", COLUMNS, rows) + assert exc.value.retriable is False + connect.assert_not_called() + assert rows == before + + +@pytest.mark.parametrize( + "columns", + [ + [], + [COLUMNS[0]], + [COLUMNS[1], COLUMNS[1]], + [object()], + [Column("ts", D.INT64, S.TIMESTAMP)], + [Column("tag", D.INT64, S.TAG), COLUMNS[1]], + ], +) +def test_invalid_schema_is_rejected_without_connecting(database, columns): + _, _, connect, _ = database + with pytest.raises(GreptimeError) as exc: + pg.Client(["localhost:4003"]).insert("bad", columns, []) + assert exc.value.retriable is False + connect.assert_not_called() + + +@pytest.mark.parametrize( + "name", + [ + "BINARY", + "DATE", + "TIME_SECOND", + "TIME_MILLISECOND", + "TIME_MICROSECOND", + "TIME_NANOSECOND", + "DECIMAL128", + "JSON", + ], +) +def test_unsupported_copy_types_fail_explicitly(database, name): + kwargs = {"precision": 10, "scale": 2} if name == "DECIMAL128" else {} + columns = COLUMNS[:2] + [Column("field", getattr(D, name), S.FIELD, **kwargs)] + with pytest.raises(GreptimeError, match="does not support") as exc: + pg.Client(["localhost:4003"]).insert("bad", columns, []) + assert exc.value.retriable is False + database[2].assert_not_called() + + +def test_all_declared_types_have_an_explicit_supported_or_rejected_case(): + declared = {name for name in dir(D) if name.isupper()} + supported = { + "BOOLEAN", + "STRING", + "INT8", + "INT16", + "INT32", + "INT64", + "UINT8", + "UINT16", + "UINT32", + "UINT64", + "FLOAT32", + "FLOAT64", + "TIMESTAMP_SECOND", + "TIMESTAMP_MILLISECOND", + "TIMESTAMP_MICROSECOND", + "TIMESTAMP_NANOSECOND", + } + rejected = { + "BINARY", + "DATE", + "TIME_SECOND", + "TIME_MILLISECOND", + "TIME_MICROSECOND", + "TIME_NANOSECOND", + "DECIMAL128", + "JSON", + } + assert declared == supported | rejected + for name in supported: + value = { + "BOOLEAN": True, + "STRING": "text", + "FLOAT32": 1.25, + "FLOAT64": 1.25, + }.get(name, 1) + cols = [COLUMNS[1], Column("field", getattr(D, name), S.FIELD)] + normalized = pg._normalize_columns(cols) + assert pg._row_encoder(normalized)([0, value])[-1] is not None + assert pg._row_encoder(normalized)([0, None])[-1] is None + + +@pytest.mark.parametrize( + "name,bits,unsigned", + [ + (f"{prefix}{bits}", bits, prefix == "UINT") + for prefix in ["INT", "UINT"] + for bits in [8, 16, 32, 64] + ], +) +def test_every_integer_width_checks_bounds(name, bits, unsigned): + low, high = ( + (0, 2**bits - 1) if unsigned else (-(2 ** (bits - 1)), 2 ** (bits - 1) - 1) + ) + cols = pg._normalize_columns( + COLUMNS[:2] + [Column("value", getattr(D, name), S.FIELD)] + ) + encode = pg._row_encoder(cols) + for value in [low, high]: + assert encode(["a", 0, value])[-1] == value + for value in [low - 1, high + 1, True, 1.0]: + with pytest.raises((ValueError, TypeError)): + encode(["a", 0, value]) + + +@pytest.mark.parametrize("dtype", [D.FLOAT32, D.FLOAT64]) +def test_float_validation(dtype): + encode = pg._row_encoder( + pg._normalize_columns([COLUMNS[1], Column("f", dtype, S.FIELD)]) + ) + for value in [float("inf"), float("nan"), True, "1"]: + with pytest.raises((TypeError, ValueError)): + encode([0, value]) + if dtype == D.FLOAT32: + with pytest.raises(ValueError): + encode([0, 3.5e38]) + + +@pytest.mark.parametrize( + "precision,expected", + [ + (0, "1969-12-31T23:59:59+00:00"), + (3, "1969-12-31T23:59:59.999+00:00"), + (6, "1969-12-31T23:59:59.999999+00:00"), + (9, "1969-12-31T23:59:59.999999999+00:00"), + ], +) +def test_timestamp_units_keep_negative_integer_precision(precision, expected): + assert pg._timestamp(-1, precision) == expected + + +def test_datetime_offsets_precision_and_nanoseconds(): + aware = datetime( + 2026, 10, 27, 10, 0, 0, 123456, tzinfo=timezone(timedelta(hours=8)) + ) + assert pg._timestamp(aware, 9) == "2026-10-27T02:00:00.123456000+00:00" + assert ( + pg._timestamp(1793066400123456789, 9) == "2026-10-27T02:00:00.123456789+00:00" + ) + for value in [aware, aware.replace(tzinfo=None)]: + with pytest.raises(ValueError): + pg._timestamp(value, 3) + + +def test_transport_failure_keeps_acknowledged_prefix_and_does_not_retry(database): + _, cur, _, _ = database + success = cur.copy_expert.side_effect + attempts = 0 + pulled = [] + + def copy(command, data): + nonlocal attempts + attempts += 1 + if attempts == 2: + raise pg.psycopg2.OperationalError("connection lost") + success(command, data) + + def rows(): + for i in range(10): + pulled.append(i) + yield ["a", i, i] + + cur.copy_expert.side_effect = copy + with pytest.raises(GreptimeError, match="after 2 acknowledged rows") as exc: + pg.Client(["localhost:4003"], batch_size=2).insert("partial", COLUMNS, rows()) + assert exc.value.retriable is True + assert exc.value.acknowledged_rows == 2 + assert attempts == 2 and pulled == [0, 1, 2, 3] + + +def test_validation_failure_in_later_batch_does_not_resend_prefix(database): + client = pg.Client(["localhost:4003"], batch_size=1) + with pytest.raises(GreptimeError, match="after 1 acknowledged rows") as exc: + client.insert("partial", COLUMNS, [["a", 1, 1], ["a", None, 2]]) + assert exc.value.acknowledged_rows == 1 + assert isinstance(exc.value.__cause__, ValueError) + assert len(database[3]) == 1 + + +def test_server_error_and_ack_count_mismatch(database): + _, cur, _, _ = database + cur.copy_expert.side_effect = pg.psycopg2.DataError("invalid data") + with pytest.raises(GreptimeError) as exc: + pg.Client(["localhost:4003"]).insert("bad", COLUMNS, [["a", 0, 1]]) + assert exc.value.retriable is False + cur.copy_expert.side_effect = None + cur.rowcount = 0 + with pytest.raises(GreptimeError, match="acknowledged"): + pg.Client(["localhost:4003"]).insert("bad", COLUMNS, [["a", 0, 1]]) + + +def test_health_errors_and_close_lifecycle(database): + conn, cur, connect, _ = database + client = pg.Client(["localhost:4003"]) + cur.execute.side_effect = pg.psycopg2.OperationalError("unavailable") + with pytest.raises(GreptimeError) as exc: + client.health_check() + assert exc.value.retriable is True + client.close() + client.close() + for operation in [ + client.health_check, + lambda: client.insert("x", COLUMNS, []), + client.__enter__, + ]: + with pytest.raises(GreptimeError, match="closed"): + operation() + assert connect.call_count == 1 + assert conn.close.call_count >= 1 + + +def test_context_exit_closes_and_preserves_user_exception(database): + with pytest.raises(LookupError, match="user error"): + with pg.Client(["localhost:4003"]) as client: + client.health_check() + raise LookupError("user error") + database[0].close.assert_called_once() + + +def test_postgres_only_import_has_no_native_dependency(): + import importlib.util + + if importlib.util.find_spec("greptimedb_ingester_grpc") is not None: + pytest.skip("No-native assertion is exercised by postgres-only CI") + code = """ +import sys +import greptimedb_ingester +import greptimedb_ingester.postgres +assert "greptimedb_ingester_grpc" not in sys.modules +assert not any(name.endswith("._native") for name in sys.modules) +""" + subprocess.run([sys.executable, "-c", code], check=True, env=os.environ.copy()) + + +@pytest.mark.parametrize( + "batch_size,size,expected", + [ + (1, 3, [1, 1, 1]), + (3, 3, [3]), + (3, 4, [3, 1]), + (8, 3, [3]), + (None, 10001, [10000, 1]), + ], +) +def test_batch_boundaries_preserve_row_order_and_acknowledged_count( + database, batch_size, size, expected +): + _, cur, connect, payloads = database + options = {} if batch_size is None else {"batch_size": batch_size} + rows = (("a", i, i) for i in range(size)) + with pg.Client(["localhost:4003"], **options) as client: + assert client.insert("batch_edges", COLUMNS, rows) == size + assert [len(batch) for _, batch in payloads] == expected + assert [int(row[2]) for _, batch in payloads for row in batch] == list(range(size)) + connect.assert_called_once() + assert cur.execute.call_count == 2 + + +@pytest.mark.parametrize("table", [None, "", 42, "bad\x00name"]) +def test_invalid_table_does_not_consume_rows_or_connect(database, table): + _, _, connect, _ = database + consumed = [] + + def rows(): + consumed.append(True) + yield ["a", 0, 1] + + with pg.Client(["localhost:4003"]) as client: + with pytest.raises(GreptimeError) as exc: + client.insert(table, COLUMNS, rows()) + assert exc.value.retriable is False + assert consumed == [] + connect.assert_not_called() + + +@pytest.mark.parametrize("operation", ["health_check", "insert"]) +def test_initial_connection_failure_is_not_retried_and_next_call_can_connect( + database, operation +): + conn, _, connect, payloads = database + failure = pg.psycopg2.OperationalError("connection refused") + connect.side_effect = [failure, conn] + with pg.Client(["localhost:4003"]) as client: + with pytest.raises(GreptimeError, match="connection refused") as exc: + if operation == "health_check": + client.health_check() + else: + client.insert("connect_failure", COLUMNS, [["a", 0, 1]]) + assert exc.value.retriable is True + assert exc.value.__cause__ is failure + assert connect.call_count == 1 + assert payloads == [] + # A new user call can connect; the failed call itself is never retried. + assert client.insert("connect_failure", COLUMNS, [["a", 0, 1]]) == 1 + assert connect.call_count == 2 + assert len(payloads) == 1 + + +def test_failed_table_creation_is_repeated_only_on_a_new_call(database): + _, cur, connect, payloads = database + failure = pg.psycopg2.ProgrammingError("create permission denied") + cur.execute.side_effect = [failure, None, None] + with pg.Client(["localhost:4003"]) as client: + with pytest.raises(GreptimeError, match="create permission denied") as exc: + client.insert("create_failure", COLUMNS, [["a", 0, 1]]) + assert exc.value.retriable is False + assert exc.value.__cause__ is failure + assert cur.execute.call_count == 1 + cur.copy_expert.assert_not_called() + # A failed CREATE must not mark the table as already initialized. + assert client.insert("create_failure", COLUMNS, [["a", 0, 1]]) == 1 + assert cur.execute.call_count == 3 + assert len(payloads) == 1 + connect.assert_called_once() + + +@pytest.mark.parametrize("yielded,acknowledged", [(1, 0), (3, 2)]) +def test_generator_error_keeps_completed_batches_and_client_is_reusable( + database, yielded, acknowledged +): + _, _, connect, payloads = database + failure = LookupError("source reader failed") + + def rows(): + for i in range(yielded): + yield ["a", i, i] + raise failure + + with pg.Client(["localhost:4003"], batch_size=2) as client: + with pytest.raises( + GreptimeError, match=f"after {acknowledged} acknowledged rows" + ) as exc: + client.insert("source_failure", COLUMNS, rows()) + assert exc.value.__cause__ is failure + assert exc.value.acknowledged_rows == acknowledged + assert exc.value.retriable is False + assert [int(row[2]) for _, batch in payloads for row in batch] == list( + range(acknowledged) + ) + if acknowledged == 0: + connect.assert_not_called() + # The incomplete batch is never sent, and the client remains usable. + assert client.insert("source_failure", COLUMNS, [["a", 100, 100]]) == 1 + assert sum(len(batch) for _, batch in payloads) == acknowledged + 1 + connect.assert_called_once() + + +def test_different_tables_keep_their_own_schema_and_encoder(database): + _, cur, connect, payloads = database + text_columns = COLUMNS[:2] + [Column("value", D.STRING, S.FIELD)] + cur.fetchall.side_effect = [ + TABLE_SCHEMA, + TABLE_SCHEMA[:2] + [("value", "String", "", "YES", "", "FIELD")], + ] + with pg.Client(["localhost:4003"]) as client: + assert client.insert("numbers", COLUMNS, [["a", 0, 1]]) == 1 + assert client.insert("strings", text_columns, [["a", 1, 'text,"value"']]) == 1 + assert client.insert("numbers", COLUMNS, [["a", 2, 3]]) == 1 + assert [batch[0][2] for _, batch in payloads] == ["1", 'text,"value"', "3"] + assert [command.split()[1] for command, _ in payloads] == [ + '"numbers"', + '"strings"', + '"numbers"', + ] + statements = [call.args[0].as_string(None) for call in cur.execute.call_args_list] + creates = [ + statement for statement in statements if statement.startswith("CREATE TABLE") + ] + assert len(creates) == 2 + assert '"numbers"' in creates[0] and '"value" BIGINT' in creates[0] + assert '"strings"' in creates[1] and '"value" STRING' in creates[1] + connect.assert_called_once() + + +def test_closing_unused_client_does_not_create_a_connection(database): + conn, _, connect, _ = database + client = pg.Client(["localhost:4003"]) + client.close() + client.close() + with pytest.raises(GreptimeError, match="closed"): + client.health_check() + with pytest.raises(GreptimeError, match="closed"): + client.insert("closed", COLUMNS, [["a", 0, 1]]) + connect.assert_not_called() + conn.close.assert_not_called() + + +@pytest.mark.parametrize( + "endpoint,options,expected", + [ + ("postgresql://localhost:4003/production", {}, "production"), + ("postgresql://localhost:4003/production", {"database": None}, "production"), + ("postgresql://localhost:4003/production", {"database": "public"}, "public"), + ("localhost:4003", {}, "public"), + ("postgresql://localhost:4003/", {}, "public"), + ("localhost:4003", {"database": "production"}, "production"), + ], +) +def test_database_uses_explicit_argument_then_uri_then_default( + database, endpoint, options, expected +): + _, _, connect, _ = database + with pg.Client(endpoint, **options) as client: + assert client.database == expected + client.health_check() + assert connect.call_args.kwargs["dbname"] == expected + + +def test_broken_copy_connection_reopens_on_next_call_without_resending(database): + conn, cur, connect, payloads = database + healthy = MagicMock(closed=0) + healthy_cur = healthy.cursor.return_value.__enter__.return_value + healthy_cur.rowcount = 1 + healthy_cur.fetchall.return_value = TABLE_SCHEMA + recovered_payloads = [] + healthy_cur.copy_expert.side_effect = lambda command, data: ( + recovered_payloads.extend(csv.reader(io.StringIO(data.read()))) + ) + connect.side_effect = [conn, healthy] + success = cur.copy_expert.side_effect + pulled = [] + + def copy(command, data): + if payloads: + conn.closed = 2 + raise pg.psycopg2.OperationalError("connection lost") + success(command, data) + + def rows(): + for i in range(3): + pulled.append(i) + yield ["a", i, i] + + cur.copy_expert.side_effect = copy + with pg.Client(["localhost:4003"], batch_size=1) as client: + with pytest.raises(GreptimeError, match="after 1 acknowledged rows") as exc: + client.insert("recover", COLUMNS, rows()) + assert exc.value.retriable is True + assert connect.call_count == 1 + assert pulled == [0, 1] + assert len(payloads) == 1 + assert client.insert("recover", COLUMNS, [["a", 10, 10]]) == 1 + assert connect.call_count == 2 + assert [row[2] for row in recovered_payloads] == ["10"] + # A new connection must also recheck the table, which may have been lost + # in a server restart. Neither the failed batch nor its tail is replayed. + assert healthy_cur.execute.call_count == 2 + healthy_cur.fetchall.assert_called_once() + assert "CREATE TABLE IF NOT EXISTS" in healthy_cur.execute.call_args_list[ + 0 + ].args[0].as_string(None) + conn.close.assert_called_once() + healthy.close.assert_called_once() + + +def test_health_check_reconnects_after_an_established_connection_breaks(database): + conn, cur, connect, _ = database + healthy = MagicMock(closed=0) + connect.side_effect = [conn, healthy] + + def disconnect(*args): + conn.closed = 2 + raise pg.psycopg2.OperationalError("connection lost") + + with pg.Client(["localhost:4003"]) as client: + client.health_check() + cur.execute.side_effect = disconnect + with pytest.raises(GreptimeError) as exc: + client.health_check() + assert exc.value.retriable is True + assert connect.call_count == 1 + client.health_check() + assert connect.call_count == 2 + healthy.cursor.return_value.__enter__.return_value.execute.assert_called_once_with( + "SELECT 1" + ) + + +@pytest.mark.parametrize("enum_type", [IntEnum, IntFlag]) +@pytest.mark.parametrize( + "dtype", [D.INT8, D.INT16, D.INT32, D.INT64, D.UINT8, D.UINT16, D.UINT32, D.UINT64] +) +def test_integer_enum_encodes_as_builtin_int(dtype, enum_type): + state = enum_type("State", {"OK": 1}) + columns = pg._normalize_columns([COLUMNS[1], Column("value", dtype, S.FIELD)]) + encoded = pg._row_encoder(columns)([0, state.OK]) + assert type(encoded[1]) is int + assert encoded[1] == 1 + buffer = io.StringIO() + csv.writer(buffer).writerow(encoded) + assert list(csv.reader(io.StringIO(buffer.getvalue())))[0][1] == "1" + + +@pytest.mark.parametrize( + "description", + [ + TABLE_SCHEMA[1:], + [("host", "String", "", "YES", "", "FIELD")] + TABLE_SCHEMA[1:], + [ + TABLE_SCHEMA[0], + ("ts", "TimestampMillisecond", "PRI", "NO", "", "TIMESTAMP"), + TABLE_SCHEMA[2], + ], + [ + TABLE_SCHEMA[0], + ("ts", "TimestampNanosecond", "", "YES", "", "FIELD"), + TABLE_SCHEMA[2], + ], + TABLE_SCHEMA[:2] + [("value", "String", "", "YES", "", "FIELD")], + ], + ids=["missing-column", "tag", "precision", "time-index", "data-type"], +) +def test_schema_mismatch_never_copies_or_caches_and_can_be_rechecked( + database, description +): + _, cur, _, payloads = database + cur.fetchall.return_value = description + with pg.Client("localhost:4003") as client: + for _ in range(2): + with pytest.raises(GreptimeError, match="schema mismatch") as exc: + client.insert("existing", COLUMNS, [["a", 0, 1]]) + assert exc.value.retriable is False + assert "existing" not in client._schemas + assert cur.fetchall.call_count == 2 + cur.copy_expert.assert_not_called() + assert payloads == [] + cur.fetchall.return_value = TABLE_SCHEMA + assert client.insert("existing", COLUMNS, [["a", 0, 1]]) == 1 + assert cur.fetchall.call_count == 3 + assert len(payloads) == 1 + + +def test_schema_validation_matches_by_name_and_allows_extra_columns(database): + _, cur, _, _ = database + cur.fetchall.return_value = list(reversed(TABLE_SCHEMA)) + [ + ("extra", "String", "", "YES", "", "FIELD") + ] + with pg.Client("localhost:4003", batch_size=1) as client: + assert client.insert("existing", COLUMNS, [["a", 0, 1], ["b", 1, 2]]) == 2 + assert client.insert("existing", COLUMNS, [["c", 2, 3]]) == 1 + # Metadata is checked only once for an unchanged schema and connection. + cur.fetchall.assert_called_once() + + +def test_schema_query_failure_does_not_cache_and_next_call_rechecks(database): + _, cur, _, _ = database + failure = pg.psycopg2.ProgrammingError("metadata unavailable") + cur.fetchall.side_effect = [failure, TABLE_SCHEMA] + with pg.Client("localhost:4003") as client: + with pytest.raises(GreptimeError, match="metadata unavailable"): + client.insert("existing", COLUMNS, [["a", 0, 1]]) + assert "existing" not in client._schemas + cur.copy_expert.assert_not_called() + assert client.insert("existing", COLUMNS, [["a", 0, 1]]) == 1 + assert cur.fetchall.call_count == 2 + + +def test_changed_request_schema_is_revalidated_before_copy(database): + _, cur, _, payloads = database + with pg.Client("localhost:4003") as client: + assert client.insert("existing", COLUMNS, [["a", 0, 1]]) == 1 + wrong = COLUMNS[:2] + [Column("value", D.STRING, S.FIELD)] + with pytest.raises(GreptimeError, match="schema mismatch"): + client.insert("existing", wrong, [["b", 1, "002"]]) + assert "existing" not in client._schemas + assert len(payloads) == 1 + assert client.insert("existing", COLUMNS, [["b", 1, 2]]) == 1 + assert cur.fetchall.call_count == 3 + + +@pytest.mark.parametrize("row_kind", ["list", "dict"]) +@pytest.mark.parametrize("batch_size", [1, 2, 5]) +def test_generator_reusing_mutable_row_is_snapshotted_before_advance( + database, row_kind, batch_size +): + _, _, _, payloads = database + shared = ["a", 0, 0] if row_kind == "list" else {"host": "a", "ts": 0, "value": 0} + + def rows(): + for i in range(7): + if row_kind == "list": + shared[1:] = [i, i] + else: + shared.update(ts=i, value=i) + yield shared + + with pg.Client("localhost:4003", batch_size=batch_size) as client: + assert client.insert("reused", COLUMNS, rows()) == 7 + actual = [row for _, batch in payloads for row in batch] + assert [int(row[2]) for row in actual] == list(range(7)) + assert len({row[1] for row in actual}) == 7 + + +@pytest.mark.parametrize( + "failure", + [ + ValueError("bad value"), + TypeError("bad row"), + OverflowError("too large"), + LookupError("reader failed"), + ], +) +@pytest.mark.parametrize("yielded,acknowledged", [(1, 0), (3, 2), (4, 4)]) +def test_input_errors_report_only_fully_acknowledged_batches( + database, failure, yielded, acknowledged +): + def rows(): + for i in range(yielded): + yield ["a", i, i] + raise failure + + with pg.Client("localhost:4003", batch_size=2) as client: + with pytest.raises( + GreptimeError, match=f"after {acknowledged} acknowledged rows" + ) as exc: + client.insert("input_error", COLUMNS, rows()) + assert exc.value.acknowledged_rows == acknowledged + assert exc.value.__cause__ is failure + assert exc.value.retriable is False + assert sum(len(batch) for _, batch in database[3]) == acknowledged + + +def test_invalid_iterator_reports_zero_acknowledged_rows(database): + class BrokenSource: + def __iter__(self): + raise LookupError("cannot start") + + with pytest.raises(GreptimeError, match="after 0 acknowledged rows") as exc: + pg.Client("localhost:4003").insert("broken", COLUMNS, BrokenSource()) + assert exc.value.acknowledged_rows == 0 + assert isinstance(exc.value.__cause__, LookupError) + database[2].assert_not_called() + + +@pytest.mark.parametrize("failed_batch", [1, 2]) +@pytest.mark.parametrize("reported_rows", [-1, 0, 1, 3]) +def test_ack_count_mismatch_reports_prefix_and_uncertain_batch( + database, failed_batch, reported_rows +): + _, cur, _, _ = database + successful_copy = cur.copy_expert.side_effect + attempts = 0 + consumed = [] + + def copy(command, data): + nonlocal attempts + attempts += 1 + successful_copy(command, data) + if attempts == failed_batch: + cur.rowcount = reported_rows + + def rows(): + for i in range(6): + consumed.append(i) + yield ["a", i, i] + + cur.copy_expert.side_effect = copy + acknowledged = (failed_batch - 1) * 2 + with pg.Client("localhost:4003", batch_size=2) as client: + with pytest.raises(GreptimeError) as exc: + client.insert("ack_mismatch", COLUMNS, rows()) + assert exc.value.acknowledged_rows == acknowledged + assert f"after {acknowledged} acknowledged rows" in str(exc.value) + assert f"Expected 2 acknowledged rows, received {reported_rows}" in str( + exc.value + ) + assert "current batch outcome is uncertain" in str(exc.value) + assert exc.value.retriable is False + assert attempts == failed_batch + assert consumed == list(range(failed_batch * 2)) + cur.copy_expert.side_effect = successful_copy + assert client.insert("ack_mismatch", COLUMNS, [["a", 10, 10]]) == 1 diff --git a/tests/test_postgres_integration.py b/tests/test_postgres_integration.py new file mode 100644 index 0000000..2360267 --- /dev/null +++ b/tests/test_postgres_integration.py @@ -0,0 +1,489 @@ +"""COPY insert integration tests; require GREPTIMEDB_POSTGRES.""" + +import os +import socket +import uuid +from datetime import datetime, timedelta, timezone +from enum import IntEnum, IntFlag + +import pytest + +pytest.importorskip("psycopg2") +import psycopg2 +from psycopg2 import sql +from greptimedb_ingester import ( + Column, + ColumnDataType as D, + GreptimeError, + SemanticType as S, +) +from greptimedb_ingester.postgres import Client + +pytestmark = pytest.mark.integration +BASE_MS = 1793066400000 +INTEGER_TYPES = [ + (prefix + str(bits), bits, prefix == "UINT") + for prefix in ["INT", "UINT"] + for bits in [8, 16, 32, 64] +] +COLUMNS = [ + Column("host", D.STRING, S.TAG), + Column("ts", D.TIMESTAMP_MILLISECOND, S.TIMESTAMP), + Column("seq", D.INT64, S.FIELD), + Column("ok", D.BOOLEAN, S.FIELD), +] +COLUMNS += [ + Column(name.lower(), getattr(D, name), S.FIELD) for name, _, _ in INTEGER_TYPES +] +COLUMNS += [ + Column("f32", D.FLOAT32, S.FIELD), + Column("f64", D.FLOAT64, S.FIELD), + Column("note", D.STRING, S.FIELD), +] +COLUMNS += [ + Column("time_" + unit, getattr(D, "TIMESTAMP_" + unit.upper()), S.FIELD) + for unit in ["second", "millisecond", "microsecond", "nanosecond"] +] + + +def row(i): + values = ["设备,一", BASE_MS + i, i, i % 2 == 0] + for _, bits, unsigned in INTEGER_TYPES: + low, high = ( + (0, 2**bits - 1) if unsigned else (-(2 ** (bits - 1)), 2 ** (bits - 1) - 1) + ) + values.append(low if i % 2 else high) + values += [i + 0.5, i + 0.25, '中文,"引号"\n换行'] + values += [ + -1, + BASE_MS + i, + (BASE_MS + i) * 1000 + 123, + (BASE_MS + i) * 1000000 + 123456, + ] + if i % 17 == 0: + values[3:] = [None] * (len(values) - 3) + return values + + +@pytest.fixture +def query(): + endpoint = os.getenv("GREPTIMEDB_POSTGRES") + if not endpoint: + pytest.skip("GREPTIMEDB_POSTGRES is not set") + uri = ( + endpoint + if endpoint.startswith(("postgres://", "postgresql://")) + else "postgresql://" + endpoint + ) + conn = psycopg2.connect(uri, dbname="public", connect_timeout=5) + conn.autocommit = True + yield conn + conn.close() + + +@pytest.fixture +def client(query): + with Client([os.environ["GREPTIMEDB_POSTGRES"]], batch_size=5000) as instance: + instance.health_check() + yield instance + + +@pytest.fixture +def table(query): + name = "py_copy_it_" + uuid.uuid4().hex + yield name + with query.cursor() as cur: + cur.execute(sql.SQL("DROP TABLE IF EXISTS {}").format(sql.Identifier(name))) + + +def records(query, statement): + with query.cursor() as cur: + cur.execute(statement) + return cur.fetchall() + + +def count(query, table): + return records( + query, sql.SQL("SELECT COUNT(*) FROM {}").format(sql.Identifier(table)) + )[0][0] + + +def test_insert_20k_all_types_creates_table_tags_and_reads_every_row( + client, query, table +): + supported = { + "BOOLEAN", + "STRING", + "INT8", + "INT16", + "INT32", + "INT64", + "UINT8", + "UINT16", + "UINT32", + "UINT64", + "FLOAT32", + "FLOAT64", + "TIMESTAMP_SECOND", + "TIMESTAMP_MILLISECOND", + "TIMESTAMP_MICROSECOND", + "TIMESTAMP_NANOSECOND", + } + assert {repr(c.data_type).split(".")[-1] for c in COLUMNS} == supported + size = 20003 # Four full batches and a nonempty tail. + assert client.insert(table, COLUMNS, (row(i) for i in range(size))) == size + described = records(query, sql.SQL("DESC TABLE {}").format(sql.Identifier(table))) + by_name = {item[0]: item for item in described} + assert by_name["host"][2] == "PRI" and by_name["host"][5] == "TAG" + assert by_name["ts"][5] == "TIMESTAMP" and by_name["f64"][5] == "FIELD" + expressions = [ + sql.SQL("CAST({} AS BIGINT) AS {}").format( + sql.Identifier(c.name), sql.Identifier("epoch_" + c.name) + ) + if repr(c.data_type).split(".")[-1].startswith("TIMESTAMP") + else sql.Identifier(c.name) + for c in COLUMNS + ] + actual = records( + query, + sql.SQL("SELECT {} FROM {} ORDER BY seq").format( + sql.SQL(", ").join(expressions), sql.Identifier(table) + ), + ) + assert actual == [tuple(row(i)) for i in range(size)] + named = dict(zip([c.name for c in COLUMNS], row(size))) + assert client.insert(table, COLUMNS, [named]) == 1 + assert count(query, table) == size + 1 + + +@pytest.mark.parametrize("batch_size", [1, 2, 3]) +@pytest.mark.parametrize("semantic_type", [S.TAG, S.FIELD]) +def test_bom_strings_round_trip_across_batches(query, table, batch_size, semantic_type): + columns = [ + Column("text", D.STRING, semantic_type), + Column("ts", D.TIMESTAMP_MILLISECOND, S.TIMESTAMP), + Column("seq", D.INT64, S.FIELD), + Column("note", D.STRING, S.FIELD), + ] + texts = [ + "\ufeffvalue", + "\ufeff", + "中间\ufeff字符", + '\ufeff"quoted",\nline', + None, + "value", + "\ufefftail", + ] + rows = [ + [text, BASE_MS + i, i, None if i % 2 == 0 else "\ufeffnote"] + for i, text in enumerate(texts) + ] + if semantic_type == S.TAG: + # Distinct tags at the same timestamp must not collapse to one row. + rows[5][1] = rows[0][1] + with Client(os.environ["GREPTIMEDB_POSTGRES"], batch_size=batch_size) as client: + assert client.insert(table, columns, iter(rows)) == len(rows) + actual = records( + query, + sql.SQL( + "SELECT text, CAST(ts AS BIGINT) AS epoch_ms, seq, note FROM {} ORDER BY seq" + ).format(sql.Identifier(table)), + ) + assert actual == [tuple(row) for row in rows] + + +def test_existing_table_schema_is_preserved(client, query, table): + with query.cursor() as cur: + cur.execute( + sql.SQL( + "CREATE TABLE {} (host STRING, ts TIMESTAMP(3) TIME INDEX, reading DOUBLE, note STRING, PRIMARY KEY(host))" + ).format(sql.Identifier(table)) + ) + before = records(query, sql.SQL("DESC TABLE {}").format(sql.Identifier(table))) + columns = [COLUMNS[0], COLUMNS[1], Column("reading", D.FLOAT64, S.FIELD)] + assert client.insert(table, columns, [["a", BASE_MS, 1.25]]) == 1 + assert ( + records(query, sql.SQL("DESC TABLE {}").format(sql.Identifier(table))) == before + ) + assert records( + query, sql.SQL("SELECT reading, note FROM {}").format(sql.Identifier(table)) + ) == [(1.25, None)] + + +def test_empty_insert_does_not_create_table(client, query, table): + assert client.insert(table, COLUMNS, iter(())) == 0 + found = records( + query, + sql.SQL( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name={}" + ).format(sql.Literal(table)), + ) + assert found == [(0,)] + + +def test_quoted_identifiers_and_csv_values(client, query, table): + columns = [ + Column("host id", D.STRING, S.TAG), + Column("event time", D.TIMESTAMP_NANOSECOND, S.TIMESTAMP), + Column("select", D.INT64, S.FIELD), + ] + values = [['中文,"引号"\n换行', -1, 1], ["other", 1793066400123456789, None]] + assert client.insert(table, columns, values) == 2 + actual = records( + query, + sql.SQL( + "SELECT {}, CAST({} AS BIGINT) AS epoch_ns, {} FROM {} ORDER BY {}" + ).format( + sql.Identifier(columns[0].name), + sql.Identifier(columns[1].name), + sql.Identifier(columns[2].name), + sql.Identifier(table), + sql.Identifier(columns[1].name), + ), + ) + assert actual == [tuple(v) for v in values] + + +@pytest.mark.parametrize( + "name", + [ + "TIMESTAMP_SECOND", + "TIMESTAMP_MILLISECOND", + "TIMESTAMP_MICROSECOND", + "TIMESTAMP_NANOSECOND", + ], +) +def test_each_time_index_unit_without_tags(client, query, table, name): + columns = [ + Column("ts", getattr(D, name), S.TIMESTAMP), + Column("value", D.INT64, S.FIELD), + ] + values = [[-1, 1], [0, 2], [1, 3]] + assert client.insert(table, columns, values) == 3 + assert records( + query, + sql.SQL( + 'SELECT CAST(ts AS BIGINT) AS epoch_value, "value" FROM {} ORDER BY ts' + ).format(sql.Identifier(table)), + ) == [tuple(v) for v in values] + + +def test_aware_datetimes_and_nullable_timestamp_fields(client, query, table): + columns = [ + COLUMNS[0], + COLUMNS[1], + Column("seen_at", D.TIMESTAMP_NANOSECOND, S.FIELD), + ] + when = datetime(2026, 10, 27, 10, tzinfo=timezone(timedelta(hours=8))) + assert client.insert(table, columns, [["a", when, None]]) == 1 + assert records( + query, + sql.SQL("SELECT CAST(ts AS BIGINT) AS ts_ms, seen_at FROM {}").format( + sql.Identifier(table) + ), + ) == [(BASE_MS, None)] + + +def test_later_validation_failure_leaves_acknowledged_prefix(query, table): + with Client([os.environ["GREPTIMEDB_POSTGRES"]], batch_size=2) as client: + with pytest.raises(GreptimeError) as exc: + client.insert( + table, + COLUMNS[:3], + [["a", BASE_MS, 0], ["a", BASE_MS + 1, 1], ["a", None, 2]], + ) + assert exc.value.retriable is False + assert count(query, table) == 2 + client.health_check() + + +def test_copy_server_error_is_reported_and_connection_recovers(client, query, table): + with query.cursor() as cur: + cur.execute( + sql.SQL( + "CREATE TABLE {} (host STRING, ts TIMESTAMP(3) TIME INDEX, reading BIGINT NOT NULL, PRIMARY KEY(host))" + ).format(sql.Identifier(table)) + ) + columns = [COLUMNS[0], COLUMNS[1], Column("reading", D.INT64, S.FIELD)] + assert client.insert(table, columns, [["a", BASE_MS, 1]]) == 1 + with pytest.raises(GreptimeError, match="COPY failed") as exc: + client.insert(table, columns, [["b", BASE_MS + 1, None]]) + assert exc.value.retriable is False + assert count(query, table) == 1 + client.health_check() + assert client.insert(table, columns, [["b", BASE_MS + 1, 2]]) == 1 + assert count(query, table) == 2 + + +def test_tag_time_keys_and_overwrite(client, query, table): + columns = COLUMNS[:3] + assert client.insert(table, columns, [["a", BASE_MS, 1], ["b", BASE_MS, 2]]) == 2 + assert client.insert(table, columns, [["a", BASE_MS, 3]]) == 1 + assert records( + query, + sql.SQL("SELECT host, seq FROM {} ORDER BY host").format(sql.Identifier(table)), + ) == [("a", 3), ("b", 2)] + + +def test_client_reconnects_after_socket_disconnection(client, query, table): + columns = COLUMNS[:3] + assert client.insert(table, columns, [["a", BASE_MS, 1]]) == 1 + # Interrupt only this client's TCP stream; leave the server and query + # connection running. psycopg2 retains ownership of the file descriptor. + stream = socket.socket(fileno=client._conn.fileno()) + try: + stream.shutdown(socket.SHUT_RDWR) + finally: + stream.detach() + with pytest.raises(GreptimeError) as exc: + client.health_check() + assert exc.value.retriable is True + assert client.insert(table, columns, [["a", BASE_MS + 1, 2]]) == 1 + client.health_check() + assert records( + query, + sql.SQL("SELECT seq FROM {} ORDER BY seq").format(sql.Identifier(table)), + ) == [(1,), (2,)] + + +@pytest.mark.parametrize( + "definition, columns, rows", + [ + ( + "ts TIMESTAMP(3) TIME INDEX, host STRING, reading BIGINT", + [COLUMNS[0], COLUMNS[1], Column("reading", D.INT64, S.FIELD)], + [["a", BASE_MS, 1], ["b", BASE_MS, 2]], + ), + ( + "ts TIMESTAMP(3) TIME INDEX, host STRING, PRIMARY KEY(host)", + [Column("host", D.STRING, S.FIELD), COLUMNS[1]], + [["a", BASE_MS]], + ), + ( + "ts TIMESTAMP(3) TIME INDEX, reading BIGINT", + [ + Column("ts", D.TIMESTAMP_NANOSECOND, S.TIMESTAMP), + Column("reading", D.INT64, S.FIELD), + ], + [[BASE_MS * 1000000 + 123456, 1]], + ), + ( + "ts TIMESTAMP(3) TIME INDEX, reading BIGINT", + [COLUMNS[1], Column("reading", D.STRING, S.FIELD)], + [[BASE_MS, "00123"]], + ), + ( + "ts TIMESTAMP(3) TIME INDEX", + [COLUMNS[1], Column("missing", D.INT64, S.FIELD)], + [[BASE_MS, 1]], + ), + ( + "ts TIMESTAMP(3), actual_time TIMESTAMP(3) TIME INDEX", + [COLUMNS[1], Column("actual_time", D.TIMESTAMP_MILLISECOND, S.FIELD)], + [[BASE_MS, BASE_MS]], + ), + ], + ids=[ + "field-as-tag", + "tag-as-field", + "timestamp-precision", + "string-as-int", + "missing-column", + "wrong-time-index", + ], +) +def test_existing_schema_mismatch_rejected_before_copy( + client, query, table, definition, columns, rows +): + with query.cursor() as cur: + cur.execute( + sql.SQL("CREATE TABLE {} (" + definition + ")").format( + sql.Identifier(table) + ) + ) + with pytest.raises(GreptimeError, match="schema mismatch") as exc: + client.insert(table, columns, rows) + assert exc.value.retriable is False + assert count(query, table) == 0 + assert table not in client._schemas + client.health_check() + + +def test_changed_request_schema_does_not_reuse_cached_validation(client, query, table): + columns = [COLUMNS[0], COLUMNS[1], Column("reading", D.INT64, S.FIELD)] + assert client.insert(table, columns, [["a", BASE_MS, 1]]) == 1 + wrong = columns[:2] + [Column("reading", D.STRING, S.FIELD)] + with pytest.raises(GreptimeError, match="schema mismatch"): + client.insert(table, wrong, [["b", BASE_MS + 1, "002"]]) + assert count(query, table) == 1 + assert client.insert(table, columns, [["b", BASE_MS + 1, 2]]) == 1 + assert count(query, table) == 2 + + +@pytest.mark.parametrize("enum_type", [IntEnum, IntFlag]) +def test_integer_enums_round_trip(client, query, table, enum_type): + state = enum_type("State", {"OK": 1}) + columns = [COLUMNS[1]] + [ + Column(name.lower(), getattr(D, name), S.FIELD) for name, _, _ in INTEGER_TYPES + ] + assert ( + client.insert(table, columns, [[BASE_MS] + [state.OK] * len(INTEGER_TYPES)]) + == 1 + ) + projection = sql.SQL(", ").join(sql.Identifier(c.name) for c in columns[1:]) + assert records( + query, sql.SQL("SELECT {} FROM {}").format(projection, sql.Identifier(table)) + ) == [(1,) * len(INTEGER_TYPES)] + + +@pytest.mark.parametrize("row_kind", ["list", "dict"]) +@pytest.mark.parametrize("batch_size", [2, 5]) +def test_reused_generator_rows_are_stored_independently( + query, table, row_kind, batch_size +): + columns = COLUMNS[:3] + shared = ( + ["a", BASE_MS, 0] + if row_kind == "list" + else {"host": "a", "ts": BASE_MS, "seq": 0} + ) + + def rows(): + for i in range(7): + if row_kind == "list": + shared[1:] = [BASE_MS + i, i] + else: + shared.update(ts=BASE_MS + i, seq=i) + yield shared + + with Client(os.environ["GREPTIMEDB_POSTGRES"], batch_size=batch_size) as client: + assert client.insert(table, columns, rows()) == 7 + assert records( + query, sql.SQL("SELECT seq FROM {} ORDER BY ts").format(sql.Identifier(table)) + ) == [(i,) for i in range(7)] + + +@pytest.mark.parametrize("source_error", [False, True]) +def test_late_input_failure_reports_persisted_prefix(query, table, source_error): + failure = LookupError("source unavailable") + + def rows(): + yield ["a", BASE_MS, 0] + yield ["a", BASE_MS + 1, 1] + yield ["a", BASE_MS + 2, 2] + if source_error: + raise failure + yield ["a", None, 3] + + with Client(os.environ["GREPTIMEDB_POSTGRES"], batch_size=2) as client: + with pytest.raises(GreptimeError, match="after 2 acknowledged rows") as exc: + client.insert(table, COLUMNS[:3], rows()) + assert exc.value.acknowledged_rows == 2 + assert exc.value.retriable is False + if source_error: + assert exc.value.__cause__ is failure + assert records( + query, + sql.SQL("SELECT seq FROM {} ORDER BY ts").format(sql.Identifier(table)), + ) == [(0,), (1,)] + assert client.insert(table, COLUMNS[:3], [["a", BASE_MS + 3, 3]]) == 1 diff --git a/tests/test_postgres_schema_compat.py b/tests/test_postgres_schema_compat.py new file mode 100644 index 0000000..73f645d --- /dev/null +++ b/tests/test_postgres_schema_compat.py @@ -0,0 +1,41 @@ +"""Keep the COPY backend's schema names compatible with gRPC.""" + +import pytest + +pytest.importorskip("greptimedb_ingester_grpc") +from greptimedb_ingester import Column, ColumnDataType, SemanticType +from greptimedb_ingester import _types as pg + + +def test_copy_schema_enum_names_and_values_match_native(): + for native, pure in [ + (ColumnDataType, pg.ColumnDataType), + (SemanticType, pg.SemanticType), + ]: + assert {name for name in dir(native) if name.isupper()} == set(pure.__members__) + for name, member in pure.__members__.items(): + assert getattr(native, name) == member.value + assert repr(getattr(native, name)) == repr(member) + + +def test_copy_column_shape_matches_native(): + native = Column("ts", ColumnDataType.TIMESTAMP_NANOSECOND, SemanticType.TIMESTAMP) + pure = pg.Column( + "ts", pg.ColumnDataType.TIMESTAMP_NANOSECOND, pg.SemanticType.TIMESTAMP + ) + for field in ["name", "precision", "scale"]: + assert getattr(native, field) == getattr(pure, field) + for field in ["data_type", "semantic_type"]: + assert repr(getattr(native, field)) == repr(getattr(pure, field)) + + +def test_installed_copy_client_accepts_native_columns(): + import pytest + + pytest.importorskip("psycopg2") + from greptimedb_ingester.postgres import _normalize_columns, _row_encoder + + native = [Column("ts", ColumnDataType.TIMESTAMP_NANOSECOND, SemanticType.TIMESTAMP)] + assert _row_encoder(_normalize_columns(native))([1]) == [ + "1970-01-01T00:00:00.000000001+00:00" + ] diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..415de42 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,52 @@ +"""Schema validation has no dependency on the Rust package.""" + +from dataclasses import FrozenInstanceError +from types import SimpleNamespace +import pytest +from greptimedb_ingester._types import ( + Column, + ColumnDataType as D, + SemanticType as S, + GreptimeError, +) + + +@pytest.mark.parametrize( + "args,kwargs", + [ + (("", D.STRING, S.TAG), {}), + (("bad\x00name", D.STRING, S.TAG), {}), + (("name", "STRING", S.TAG), {}), + (("name", D.STRING, "TAG"), {}), + (("price", D.DECIMAL128, S.FIELD), {}), + (("price", D.DECIMAL128, S.FIELD), {"precision": 0, "scale": 0}), + (("price", D.DECIMAL128, S.FIELD), {"precision": 39, "scale": 0}), + (("price", D.DECIMAL128, S.FIELD), {"precision": 10, "scale": -1}), + (("price", D.DECIMAL128, S.FIELD), {"precision": 10, "scale": 11}), + (("price", D.FLOAT64, S.FIELD), {"precision": 10, "scale": 2}), + ], +) +def test_invalid_column(args, kwargs): + with pytest.raises(GreptimeError) as exc: + Column(*args, **kwargs) + assert exc.value.retriable is False + + +def test_column_is_immutable_and_decimal_metadata_is_preserved(): + column = Column("price", D.DECIMAL128, S.FIELD, precision=10, scale=2) + assert column.precision == 10 and column.scale == 2 + with pytest.raises(FrozenInstanceError): + column.name = "changed" + + +def test_compatible_column_protocol_needs_no_native_import(): + pytest.importorskip("psycopg2") + from greptimedb_ingester.postgres import _normalize_columns + + column = SimpleNamespace( + name="ts", data_type=D.TIMESTAMP_NANOSECOND, semantic_type=S.TIMESTAMP + ) + assert _normalize_columns([column])[0].name == "ts" + column.semantic_type = "invalid" + with pytest.raises(ValueError, match="semantic"): + _normalize_columns([column]) diff --git a/uv.lock b/uv.lock index 4af237a..22fdef8 100644 --- a/uv.lock +++ b/uv.lock @@ -25,22 +25,41 @@ wheels = [ [[package]] name = "greptimedb-ingester" -version = "0.1.0" +version = "0.1.2" source = { editable = "." } +[package.optional-dependencies] +grpc = [ + { name = "greptimedb-ingester-grpc" }, +] +postgres = [ + { name = "psycopg2-binary" }, +] + [package.dev-dependencies] dev = [ - { name = "maturin" }, { name = "pytest" }, ] [package.metadata] +requires-dist = [ + { name = "greptimedb-ingester-grpc", marker = "extra == 'grpc'", editable = "packages/grpc" }, + { name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.9.10,<3" }, +] +provides-extras = ["grpc", "postgres"] [package.metadata.requires-dev] -dev = [ - { name = "maturin", specifier = ">=1.8,<2.0" }, - { name = "pytest", specifier = ">=8.0" }, -] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "greptimedb-ingester-grpc" +version = "0.1.2" +source = { editable = "packages/grpc" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "maturin", specifier = ">=1.8,<2.0" }] [[package]] name = "iniconfig" @@ -51,30 +70,6 @@ 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 = "maturin" -version = "1.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/c8/22e5e21b2679c9bce6415ca578034ca2cc9316be0642ae21e051a2d5198c/maturin-1.15.0.tar.gz", hash = "sha256:94b26cc8e8aba61a5f2099715fe640e18c5f678e9a500408b38761263954228a", size = 385504, upload-time = "2026-08-24T12:11:22.665Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/69/5c01b461044eb1f45ddcce006706eb88110c793cdb11c7ae0b5e08492e94/maturin-1.15.0-py3-none-linux_armv6l.whl", hash = "sha256:6bf6dc62e22d4dcfd5a51244ff0d58975fa4979c48209fe84159617648956d82", size = 10206220, upload-time = "2026-08-24T12:10:53.327Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1f/2b431554e11687cdb1077e0cdadcc118c53f611086b3af00c8545a67c6a5/maturin-1.15.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cd35772633f489841132bc8e71d6fc7f842df30b9c05cd5cdf1ee1ddcb744cc7", size = 19416513, upload-time = "2026-08-24T12:10:56.126Z" }, - { url = "https://files.pythonhosted.org/packages/51/36/e23a21cb34a648b711036b9b2fe1d4f3f4ee24f8db54215d73f1a9a3a3ec/maturin-1.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c40b4eae7bf5ef1f4b1af8d623fe4105016f93578fb15b764e741d08ec3b92dd", size = 10014962, upload-time = "2026-08-24T12:10:58.486Z" }, - { url = "https://files.pythonhosted.org/packages/8c/80/33b15cb2d8f30f12c807955e8f2fd775027692904e30ec0784744ce8cd83/maturin-1.15.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:7eb066372f541f8eb4909c79c5d9bd0b9e8125980bdf1ec9e8aba23c6c8d6c55", size = 10196223, upload-time = "2026-08-24T12:11:00.696Z" }, - { url = "https://files.pythonhosted.org/packages/fe/91/b495e19e2f5c503b540452b2039115e7b2363867e8c5ad4179eb752fa92c/maturin-1.15.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:653020a63525bb224e5ab0adf02e17a2e08bc86dbea7fc1399c9a56d7529b99e", size = 10541186, upload-time = "2026-08-24T12:11:02.857Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2b/2abff58037188d852b124871b1f0d720e1c2bfb3d4f1b03d87c52cd66488/maturin-1.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0ebf9767892725083138e671c34482c660317a2f3d6a29fc0e0f34e9d8c99136", size = 10083468, upload-time = "2026-08-24T12:11:05.012Z" }, - { url = "https://files.pythonhosted.org/packages/3f/07/b7e9f8be99a6627849e81ac7b6694876bce8f50a92995fe17e3cf2610f0a/maturin-1.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7ab7eebffd7b8debca2265985de4eaeb332141276d24b9560b5ad484d4b3add1", size = 10047786, upload-time = "2026-08-24T12:11:07.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ab/167e3cb7accee11b507dbe53e0e87aeccb376d44ae66284c96ee4df3a9fd/maturin-1.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:126e12e618b4db42f68c779a56d41f82a390145ba36ac3f621d057eb34f5ad9d", size = 13315332, upload-time = "2026-08-24T12:11:09.433Z" }, - { url = "https://files.pythonhosted.org/packages/14/4d/801379f646cbc6b00998e5289b0630a886be3a4ee4c75b6bc9b87478a7f1/maturin-1.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f9d33e6c3f9615c8caceecbbbd440f8eb25a3ddeb687077682cd5eca2e9ae15", size = 10807183, upload-time = "2026-08-24T12:11:11.73Z" }, - { url = "https://files.pythonhosted.org/packages/89/27/2e612e1cbd1580e9e94d4722c227b5180dca27b32b955a34b79918aa1292/maturin-1.15.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:bf29beddd0c6708f112db51d5275fc28b28b9e9c9c5faae387eaef662918b176", size = 10413274, upload-time = "2026-08-24T12:11:14.04Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/202a7b4d75a51f20f84ec9ce3b7345b12b822207072164e2b1c6ef665125/maturin-1.15.0-py3-none-win32.whl", hash = "sha256:da649988be98e87e009e51b1bf0d301b6a301bc0cecbdd60d40d8ba60748d1ca", size = 8928744, upload-time = "2026-08-24T12:11:16.269Z" }, - { url = "https://files.pythonhosted.org/packages/40/dc/4e90da594986ba78dd3bc8a5921ecdcdb11085b22b02a412caab3b225601/maturin-1.15.0-py3-none-win_amd64.whl", hash = "sha256:552c2be4afd43fe8d5c9f3ec8d4c4756d973b8dcbe94c14084390301f50243e1", size = 10335085, upload-time = "2026-08-24T12:11:18.326Z" }, - { url = "https://files.pythonhosted.org/packages/8b/10/15d4314edf130955edf2dc237aa393a8a7c10f2b9b57b89fa2f61f915659/maturin-1.15.0-py3-none-win_arm64.whl", hash = "sha256:c7dc0c66c78d3debdd9c5aa807e861fbcbf07f3505d34b125df74c03986b0f48", size = 9713795, upload-time = "2026-08-24T12:11:20.83Z" }, -] - [[package]] name = "packaging" version = "26.3" @@ -93,6 +88,80 @@ 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 = "psycopg2-binary" +version = "2.9.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/76/7b4383014be0fcc6c1c0e24292845a14e1672cf17fca62ca0a2bd5f4563d/psycopg2_binary-2.9.13.tar.gz", hash = "sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373", size = 378112, upload-time = "2026-09-10T00:06:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/5d/4a82fcded401927a320ba4e0fd341f75ec7c83e92f5c1388ba27ccb21f91/psycopg2_binary-2.9.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c519e406287085f43aa0d3061936edf1ba51286093532f215315c6ab8ba92c3b", size = 3725660, upload-time = "2026-09-09T23:54:05.509Z" }, + { url = "https://files.pythonhosted.org/packages/27/ef/e6f9280c2327e52f6c139ab549e57a21202b724384d4697dce5794c59f64/psycopg2_binary-2.9.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:086659ab083119f7ee87a779e31b94211cf162b708fc9a6bec771f75c73ac3e6", size = 3817358, upload-time = "2026-09-09T23:54:07.829Z" }, + { url = "https://files.pythonhosted.org/packages/03/b2/412c92a391472644e4059dc68cb20bc9bc40be2ca61eeb5882aa86b092d7/psycopg2_binary-2.9.13-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1f4c7bdbafdf9dc018efbc29213b73f8308332888ba76a4cf503f560bfd21705", size = 4585517, upload-time = "2026-09-09T23:54:10.588Z" }, + { url = "https://files.pythonhosted.org/packages/fd/96/ce330aa1959250e6fd254678e6489b3b39eb956669122ad4b45dde3b02c3/psycopg2_binary-2.9.13-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d2fc9342aad969b9a28490a4c3eaba94b35beb2d26e9a39b31d1430378aa71b2", size = 4281153, upload-time = "2026-09-09T23:54:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/ee/29/b41c62219b232c9631db1a8cfb99a93abb9afb8a00291579b830f756ce89/psycopg2_binary-2.9.13-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f124954a32640dfb5c000d33028f48053930d7ff226bc74cde5fb316f9c6fcb6", size = 5900983, upload-time = "2026-09-09T23:54:14.873Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e9/40febef96f95ebc08754d4a70332fa8da1c280c9fcda420b8896b2f8d855/psycopg2_binary-2.9.13-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c24c98fe1a113db287dfb1958771eafca97b7db812f23b7897c2a12b6b904c22", size = 4118632, upload-time = "2026-09-09T23:54:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/c7/68/5fef23ee856d70b152a46fe3d33264971bc0daab658d88a76a9e6cab7a28/psycopg2_binary-2.9.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4cdfe41149dcc5583a3b7a2f0ad433f75bb3afd1c7a7332e63df89b05e34666", size = 3659631, upload-time = "2026-09-09T23:54:19.396Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/7b1fe00770083c303a835267dafbcbd7a346ba7b79909652f4ec484ca7fc/psycopg2_binary-2.9.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33a6d3c47f9655b481b2cdc1b4bf71c235e054e55663d3066036b6ce5fbe5165", size = 3305176, upload-time = "2026-09-09T23:54:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/76/fe/ef2e4adcb7cf20572235da4d2d24b98788466d4e62ce2db72be865859d0c/psycopg2_binary-2.9.13-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:202dedd5cadb3e5dfd4d0415ab2fc5d5b44f4208de5308938e3e74ae222b638e", size = 3051629, upload-time = "2026-09-09T23:54:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/42/49/a040b3d596cf4128656d24a5b735b0628bb196d7ab73c0164898ca90e420/psycopg2_binary-2.9.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db31cf7f617a51625f1473d8a66fc35dac159af8b28e80bc014ed3ee994a9fbf", size = 3353122, upload-time = "2026-09-09T23:54:25.552Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5a/c4b1cdba78d3f60389d768db580cb9255b6bd1f01df98e5131b26322133c/psycopg2_binary-2.9.13-cp310-cp310-win_amd64.whl", hash = "sha256:28eb30bf4a52c1117406f45771038faa96f882fdeeeb0ce43b960a1dbc6c1fd2", size = 2767600, upload-time = "2026-09-09T23:54:27.553Z" }, + { url = "https://files.pythonhosted.org/packages/7b/03/639c96ff8ffb933868252308a9917ff4170d8c7f4bd16cd0ea2536814126/psycopg2_binary-2.9.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9", size = 3725651, upload-time = "2026-09-09T23:54:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/d50eb688e7e6dfd1499e68cf2d02f44a348b88f381cbc2bebeed15e345f4/psycopg2_binary-2.9.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0", size = 3817384, upload-time = "2026-09-09T23:54:31.118Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f3/4004cfbbfc52b9b13ffd499f2103eb05246e92828c7237d2c198e028b95c/psycopg2_binary-2.9.13-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6", size = 4585497, upload-time = "2026-09-09T23:54:33.169Z" }, + { url = "https://files.pythonhosted.org/packages/97/63/057c65532bd12cdf9d4f568e59c2a078a38e9ba8f7f251292968dc781905/psycopg2_binary-2.9.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b", size = 4281172, upload-time = "2026-09-09T23:54:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/43/4b/9fd928eaea9ec1e8d74fed83c9e82826f830506ba0d8c58a8fd41ca93656/psycopg2_binary-2.9.13-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29", size = 5900973, upload-time = "2026-09-09T23:54:37.866Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2b/59e1519a22622169e2244f12227b3acde6114ea531a349292f455ab8503f/psycopg2_binary-2.9.13-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24", size = 4118611, upload-time = "2026-09-09T23:54:39.739Z" }, + { url = "https://files.pythonhosted.org/packages/87/c7/c3d84e330d1584efa0560b756914f9128b1b6fa2aba93fde54bf101d4f65/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964", size = 3659643, upload-time = "2026-09-09T23:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/af/fc/317d248503aa29a5051ee49a7253daffe1c57e43ba174020e00bbd87c479/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac", size = 3305145, upload-time = "2026-09-09T23:54:43.818Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d2/8b23c57591c6d29a463748ffc634401ce14719dba7e78ab91bcbeac70934/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8", size = 3051614, upload-time = "2026-09-09T23:54:45.548Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f2/b10a046cc19ab1e01b92226eeb6e8653be7e6691939991b2f347995831b1/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee", size = 3353133, upload-time = "2026-09-09T23:54:47.285Z" }, + { url = "https://files.pythonhosted.org/packages/40/2c/dd379facaa4bd41d7b04711ff30931ca62981c00a91502250ffb49da08a7/psycopg2_binary-2.9.13-cp311-cp311-win_amd64.whl", hash = "sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020", size = 2771441, upload-time = "2026-09-09T23:54:49.016Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/d0125c56b865e3bc9f318d84930b2df71a729229dbb0ce12de748a82a6d7/psycopg2_binary-2.9.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26", size = 3725735, upload-time = "2026-09-09T23:54:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/54/a5/b5a73d0910555e38ee12c49c1740855f8a1e9776e87d65f0c51e1bab762a/psycopg2_binary-2.9.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf", size = 3818000, upload-time = "2026-09-09T23:54:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/3d/43/3e4783f62ae3f4fc19a5acf8d1c394df54458f1336febe188f317556d2a7/psycopg2_binary-2.9.13-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce", size = 4585760, upload-time = "2026-09-09T23:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c4/a9a67ae65ad3d567eb0fc9cdf9a5a2783b779aecdcdc8945f1807b13d99e/psycopg2_binary-2.9.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60", size = 4282380, upload-time = "2026-09-09T23:54:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/9d459d3da12e0b841cf1596579455aaa27e9e593e3e9a5a4ded5a55a7c15/psycopg2_binary-2.9.13-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a", size = 5902039, upload-time = "2026-09-09T23:55:01.955Z" }, + { url = "https://files.pythonhosted.org/packages/d6/53/21079c10a581c50b6817498eda7c3481c1b3cdb41482bd08ebfccd3664c4/psycopg2_binary-2.9.13-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528", size = 4119840, upload-time = "2026-09-09T23:55:04.336Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/71e8d9e1b4f3e78157b49a5abdff50d915e95f2812550f6c9b4f2e4d5e94/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2", size = 3661077, upload-time = "2026-09-09T23:55:06.118Z" }, + { url = "https://files.pythonhosted.org/packages/d9/54/b17616472f09a0fae96f8852692948b7eaa7c971d7629696da0e5932d996/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7", size = 3307202, upload-time = "2026-09-09T23:55:08.061Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/d9737e222a377dac67a0ce0a2c73e7231a57f5cf18bb35a65d5c8d45d5d2/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c", size = 3052893, upload-time = "2026-09-09T23:55:10.209Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3d/c406c9f698f518c264381192c2bdf8952ee84e469ffa9f82db1411f57385/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7", size = 3355210, upload-time = "2026-09-09T23:55:11.883Z" }, + { url = "https://files.pythonhosted.org/packages/27/64/6e3a96699770af2d0d49a2002f722c69b656fc27623ff89281cf2b109644/psycopg2_binary-2.9.13-cp312-cp312-win_amd64.whl", hash = "sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008", size = 2767784, upload-time = "2026-09-09T23:55:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/82/0a/795f2869788373cf7d08410341a444196e8ccebbac07a70a8f9a1f60e72f/psycopg2_binary-2.9.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c", size = 3725723, upload-time = "2026-09-09T23:55:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/5a9633f4563a73beba69b20a846ddd14c1c6ac072f5e8aab0da97ffabc2a/psycopg2_binary-2.9.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1", size = 3817976, upload-time = "2026-09-09T23:55:18.025Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e2/b2e3b3a4331dc8b58e328cda30f3d0cc43a94b7aaf0c8383efd53dd10e95/psycopg2_binary-2.9.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98", size = 4585813, upload-time = "2026-09-09T23:55:20.112Z" }, + { url = "https://files.pythonhosted.org/packages/56/5c/87daea77c4132114d1a5da3a4928dd59446c3b3cc73d288cae08cf0b91a6/psycopg2_binary-2.9.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e", size = 4282438, upload-time = "2026-09-09T23:55:22.329Z" }, + { url = "https://files.pythonhosted.org/packages/91/e5/56f9efdc9337acbd1a75798d97163183b63a1babc17602f7163009506c96/psycopg2_binary-2.9.13-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292", size = 5902064, upload-time = "2026-09-09T23:55:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/f7ed0b90b47b73a9087306b42267eccfd919f92c0fb057e46bd2fa2efa4d/psycopg2_binary-2.9.13-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955", size = 4119848, upload-time = "2026-09-09T23:55:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/42/08/3091347b9fc5766e979aba6b0756ad14ce867a6bb245f3d69ac71fb768c6/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69", size = 3661129, upload-time = "2026-09-09T23:55:28.449Z" }, + { url = "https://files.pythonhosted.org/packages/34/c4/4f9a84d55484c9794b364548eb6e1fe10a57f123afd19729e5a1cc8ad7fc/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22", size = 3307262, upload-time = "2026-09-09T23:55:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/83/42/6eba8306a61dc890805ae475a9e71790a1c5461ccacbd4f0a1f3f57b40f0/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2", size = 3052898, upload-time = "2026-09-09T23:55:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5d/42a8935ab280e8dcd7c07a655c0c3d25d62e9e242be1961ac14630f1294a/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389", size = 3355265, upload-time = "2026-09-09T23:55:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/87/c2/0e0ffb4caeb651631cbc6c8ead83e2a16457750b1d2eb7f5ef111c1f4d36/psycopg2_binary-2.9.13-cp313-cp313-win_amd64.whl", hash = "sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8", size = 2767914, upload-time = "2026-09-09T23:55:37.14Z" }, + { url = "https://files.pythonhosted.org/packages/5f/32/897c074cb99fbdda7d34b0a2546097a59162bb3d04c0d546ae4ec82345e3/psycopg2_binary-2.9.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798", size = 3725788, upload-time = "2026-09-09T23:55:39.04Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/e3a789de34c9ac25d20b25c2be583da16394a2ba0926da1c863653831f41/psycopg2_binary-2.9.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720", size = 3818111, upload-time = "2026-09-09T23:55:40.979Z" }, + { url = "https://files.pythonhosted.org/packages/72/29/647724c43ac510dbc59b80e20e85d439deb94f5d5a024153c32330fa041d/psycopg2_binary-2.9.13-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f", size = 4586235, upload-time = "2026-09-09T23:55:43.012Z" }, + { url = "https://files.pythonhosted.org/packages/91/ad/7f52f92cc65c23778daff7eec4ee2099236694a0a4723a5f180d0708b607/psycopg2_binary-2.9.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076", size = 4282424, upload-time = "2026-09-09T23:55:44.843Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2a/1a472059b198942d99651656e2bc610575584478bfe68d297ecabbd4887f/psycopg2_binary-2.9.13-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c", size = 5902365, upload-time = "2026-09-09T23:55:46.619Z" }, + { url = "https://files.pythonhosted.org/packages/91/1a/171ea5dac7b3a0fa57b3cb59c2ad6d7b8bc60732368fecfd2ed1f1288392/psycopg2_binary-2.9.13-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916", size = 4119938, upload-time = "2026-09-09T23:55:49.381Z" }, + { url = "https://files.pythonhosted.org/packages/41/ce/3c6d4ad71853a59eee6a575fe36df4bb40752a9735a27bd62af66b454ed5/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c", size = 3661557, upload-time = "2026-09-09T23:55:51.269Z" }, + { url = "https://files.pythonhosted.org/packages/10/a3/1819a01bf951eab2afb5ca2a3d11f50500bf536fecff088154372a8d1985/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b", size = 3307770, upload-time = "2026-09-09T23:55:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/4e/df/22f4aec952cd5b2dd02f438399583ed69f7d04b90e7c31659d9571bbe188/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1", size = 3052935, upload-time = "2026-09-09T23:55:55.117Z" }, + { url = "https://files.pythonhosted.org/packages/95/42/aab651bc22bafa961806ca3b21027bb0739a2730b0e6f7f0778baeb95e67/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed", size = 3355387, upload-time = "2026-09-09T23:55:57.366Z" }, + { url = "https://files.pythonhosted.org/packages/bc/af/3b8220633eaf955e95ea7be67d76e81a0d1cd3c76362ea504b91ffa079db/psycopg2_binary-2.9.13-cp314-cp314-win_amd64.whl", hash = "sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0", size = 2863890, upload-time = "2026-09-09T23:55:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/377d17fc8425220d17552691cd2b97aa232da92173f5dead71278b83f8ab/psycopg2_binary-2.9.13-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50", size = 3725913, upload-time = "2026-09-09T23:56:00.736Z" }, + { url = "https://files.pythonhosted.org/packages/67/64/27208e67cd6e663f69bf7bf905cf69db066a015c90ac9ca948a56a8e9d78/psycopg2_binary-2.9.13-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8", size = 3818303, upload-time = "2026-09-09T23:56:02.551Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/67d2f34a1d18367b5f655bdd101759f8474286c74ffe701b7d6e3abd7fda/psycopg2_binary-2.9.13-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf", size = 4586508, upload-time = "2026-09-09T23:56:04.706Z" }, + { url = "https://files.pythonhosted.org/packages/bb/47/46c227deaf322dceafa0b7b321b4e5de9cc797014b7a353349b2e09b1118/psycopg2_binary-2.9.13-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8", size = 4282383, upload-time = "2026-09-09T23:56:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3c/e8705ffa381160d842eaf06a8446e8416f1a2497dd70a7e62277f3be6e7a/psycopg2_binary-2.9.13-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3", size = 5902765, upload-time = "2026-09-09T23:56:08.634Z" }, + { url = "https://files.pythonhosted.org/packages/53/cc/359821c18317228b8032456a3740c98045b719ed003a594b9ebac9330b86/psycopg2_binary-2.9.13-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9", size = 4122849, upload-time = "2026-09-09T23:56:10.671Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/4d935acb6d3258c7a767b3d527e54c0b537649101b55002a5dbcfe747e2a/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff", size = 3662084, upload-time = "2026-09-09T23:56:12.316Z" }, + { url = "https://files.pythonhosted.org/packages/89/56/9e9bbc7c773c5de7bb25dd35d7f041c2a6f0fcfa9207a1ceaf01a1bc687c/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0", size = 3308077, upload-time = "2026-09-09T23:56:15.262Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/ed742cd4e5dbddcb44702f9c4a97f7f5b62d97e3d9d00907ecc8ac750ef4/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b", size = 3055143, upload-time = "2026-09-09T23:56:17.168Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3a/5c2cb71a844ee236be2ce91b286d797e34a21489909357c7cfba0f5c0197/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5", size = 3355374, upload-time = "2026-09-09T23:56:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/e8/30/3991c9fdcca90a5a1e55435292f4d74d176da2be15f3998f6858da3658cc/psycopg2_binary-2.9.13-cp315-cp315-win_amd64.whl", hash = "sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba", size = 2867246, upload-time = "2026-09-09T23:56:20.501Z" }, +] + [[package]] name = "pygments" version = "2.21.0"