Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Install dependencies
run: uv sync --group dev

- name: Run pre-commit
uses: pre-commit/action@v3.0.1

test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Install dependencies
run: uv sync --group dev

- name: Run unit tests
run: uv run pytest tests/test_client.py tests/test_dbapi.py -v

integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Install dependencies
run: uv sync --group dev

- name: Run integration tests
run: uv run pytest tests/test_integration.py -v -s -m integration
29 changes: 29 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Publish to PyPI

on:
push:
tags:
- "v*"

jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for trusted publishing
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for hatch-vcs

- uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install build tools
run: pip install build

- name: Build package
run: python -m build

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ wheels/
# Virtual environments
.venv
.idea

# Auto-generated version file
src/flink_gateway/_version.py
36 changes: 30 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# py-flink-sql-gateway

[![CI](https://github.com/exness/py-flink-sql-gateway/actions/workflows/ci.yml/badge.svg)](https://github.com/exness/py-flink-sql-gateway/actions/workflows/ci.yml)
[![PyPI version](https://img.shields.io/pypi/v/py-flink-sql-gateway)](https://pypi.org/project/py-flink-sql-gateway/)
[![Python](https://img.shields.io/pypi/pyversions/py-flink-sql-gateway)](https://pypi.org/project/py-flink-sql-gateway/)

A lightweight Python driver for the **Apache Flink SQL Gateway**, implementing [PEP 249 (DB-API 2.0)](https://peps.python.org/pep-0249/).

## Installation
Expand Down Expand Up @@ -61,6 +65,18 @@ with connect("http://localhost:8083") as conn:

> **Tip:** `ROW` and `MAP` types arrive as Python `dict`, `ARRAY` arrives as `list`. Binary data is passed through as-is.

### Connection options

```python
# Pass Flink session properties
with connect("http://localhost:8083", properties={"pipeline.name": "my-job"}) as conn:
...

# Set a query timeout (default: 300s)
with conn.cursor(query_timeout=60.0) as cur:
cur.execute("SELECT ...")
```

### Low-level REST access

If you need features beyond DB-API, use the exported client directly:
Expand All @@ -78,7 +94,7 @@ with FlinkSqlGatewayClient("http://localhost:8083") as client:
Python uses `None` for SQL NULLs natively — no wrapper types needed.

| Flink Type | Python Type |
|---------------------------|--------------------|
|---------------------------|:------------------:|
| TINYINT / SMALLINT / INT | `int` |
| BIGINT / INTERVAL | `int` |
| FLOAT / DOUBLE | `float` |
Expand All @@ -93,21 +109,29 @@ Python uses `None` for SQL NULLs natively — no wrapper types needed.
| MAP | `dict` |
| ARRAY | `list` |

Nested complex types (e.g. `MAP<STRING, ROW<..., MAP<STRING, ROW<TIMESTAMP>>>>`) are recursively decoded to the correct Python types.

---

## Development & tests

Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).

```bash
# Install dependencies
uv sync
uv sync --group dev

# Set up pre-commit hooks (runs on every push)
pre-commit install --hook-type pre-push

# Run unit tests (no Docker needed)
uv run pytest tests/test_client.py tests/test_dbapi.py -v

# Run all tests including integration (requires Docker)
uv run pytest tests/ -v
# Run integration tests (requires Docker)
uv run pytest tests/test_integration.py -v -s -m integration

# Run all pre-commit checks manually
pre-commit run --all-files
```

Integration tests spin up a Flink cluster (JobManager + TaskManager + SQL Gateway) via [testcontainers-python](https://testcontainers-python.readthedocs.io/).
Expand All @@ -116,4 +140,4 @@ Integration tests spin up a Flink cluster (JobManager + TaskManager + SQL Gatewa

## License

MIT (see LICENSE)
MIT
19 changes: 16 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
[project]
name = "py-flink-sql-gateway"
version = "0.1.0"
description = "A Python client for the Apache Flink SQL Gateway REST API"
dynamic = ["version"]
description = "A lightweight Python driver for the Apache Flink SQL Gateway, implementing PEP 249 (DB-API 2.0)."
readme = "README.md"
authors = [
{ name = "Ilya Soin", email = "ilya.soin@exness.com" }
]
requires-python = ">=3.11"
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"httpx>=0.28.1",
]
Expand All @@ -18,9 +24,16 @@ dev = [
]

[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[tool.hatch.version]
source = "vcs"
raw-options = { version_scheme = "guess-next-dev" }

[tool.hatch.build.hooks.vcs]
version-file = "src/flink_gateway/_version.py"

[tool.hatch.build.targets.wheel]
packages = ["src/flink_gateway"]

Expand Down
35 changes: 31 additions & 4 deletions src/flink_gateway/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,20 +112,47 @@ class LogicalType:
precision: int | None = None
scale: int | None = None
resolution: str | None = None
children: list[ColumnInfo] = field(default_factory=list)
# ARRAY element type
element_type: LogicalType | None = None
# MAP key/value types
key_type: LogicalType | None = None
value_type: LogicalType | None = None
# ROW field descriptors
fields: list[ColumnInfo] = field(default_factory=list)

@classmethod
def from_dict(cls, data: dict[str, Any]) -> LogicalType:
children_raw = data.get("children", [])
children = [ColumnInfo.from_dict(c) for c in children_raw]
element_type = None
key_type = None
value_type = None
fields: list[ColumnInfo] = []

if "elementType" in data:
element_type = LogicalType.from_dict(data["elementType"])
if "keyType" in data:
key_type = LogicalType.from_dict(data["keyType"])
if "valueType" in data:
value_type = LogicalType.from_dict(data["valueType"])
if "fields" in data:
for f in data["fields"]:
fields.append(
ColumnInfo(
name=f.get("name", ""),
logical_type=LogicalType.from_dict(f.get("fieldType", {})),
)
)

return cls(
type=data.get("type", ""),
nullable=data.get("nullable", True),
length=data.get("length"),
precision=data.get("precision"),
scale=data.get("scale"),
resolution=data.get("resolution"),
children=children,
element_type=element_type,
key_type=key_type,
value_type=value_type,
fields=fields,
)


Expand Down
57 changes: 31 additions & 26 deletions src/flink_gateway/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def decode_field(
value: The raw JSON value from the gateway response.
flink_type: The normalized Flink type.
precision: Optional precision from column metadata.
logical_type: Optional full ``LogicalType`` with children for
logical_type: Optional full ``LogicalType`` with element_type/fields for
recursive decoding of ROW / MAP types.

Returns:
Expand Down Expand Up @@ -314,18 +314,22 @@ def _decode_array_value(
value: Any,
logical_type: LogicalType | None,
) -> Any:
"""Decode an ARRAY value using its element child schema."""
if not isinstance(value, list) or logical_type is None or not logical_type.children:
"""Decode an ARRAY value using its element type schema."""
if (
not isinstance(value, list)
or logical_type is None
or logical_type.element_type is None
):
return value

elem_child = logical_type.children[0]
elem_ft = normalize_flink_type(elem_child.logical_type.type)
elem_lt = logical_type.element_type
elem_ft = normalize_flink_type(elem_lt.type)
return [
decode_field(
item,
elem_ft,
precision=elem_child.logical_type.precision,
logical_type=elem_child.logical_type,
precision=elem_lt.precision,
logical_type=elem_lt,
)
for item in value
]
Expand All @@ -335,21 +339,21 @@ def _decode_row_value(
value: Any,
logical_type: LogicalType | None,
) -> Any:
"""Decode a ROW value using its child schema."""
if not isinstance(value, dict) or logical_type is None or not logical_type.children:
"""Decode a ROW value using its field schema."""
if not isinstance(value, dict) or logical_type is None or not logical_type.fields:
return value

children_by_name = {child.name: child for child in logical_type.children}
fields_by_name = {f.name: f for f in logical_type.fields}
decoded: dict[str, Any] = {}
for key, raw_val in value.items():
child = children_by_name.get(key)
if child is not None:
child_ft = normalize_flink_type(child.logical_type.type)
fld = fields_by_name.get(key)
if fld is not None:
fld_ft = normalize_flink_type(fld.logical_type.type)
decoded[key] = decode_field(
raw_val,
child_ft,
precision=child.logical_type.precision,
logical_type=child.logical_type,
fld_ft,
precision=fld.logical_type.precision,
logical_type=fld.logical_type,
)
else:
decoded[key] = raw_val
Expand All @@ -360,32 +364,33 @@ def _decode_map_value(
value: Any,
logical_type: LogicalType | None,
) -> Any:
"""Decode a MAP value using its key/value child schemas."""
"""Decode a MAP value using its key/value type schemas."""
if (
not isinstance(value, dict)
or logical_type is None
or len(logical_type.children) < 2
or logical_type.key_type is None
or logical_type.value_type is None
):
return value

key_child = logical_type.children[0]
val_child = logical_type.children[1]
key_ft = normalize_flink_type(key_child.logical_type.type)
val_ft = normalize_flink_type(val_child.logical_type.type)
key_lt = logical_type.key_type
val_lt = logical_type.value_type
key_ft = normalize_flink_type(key_lt.type)
val_ft = normalize_flink_type(val_lt.type)

decoded: dict[Any, Any] = {}
for raw_key, raw_val in value.items():
dk = decode_field(
raw_key,
key_ft,
precision=key_child.logical_type.precision,
logical_type=key_child.logical_type,
precision=key_lt.precision,
logical_type=key_lt,
)
dv = decode_field(
raw_val,
val_ft,
precision=val_child.logical_type.precision,
logical_type=val_child.logical_type,
precision=val_lt.precision,
logical_type=val_lt,
)
decoded[dk] = dv
return decoded
Loading
Loading