Skip to content

Commit f708f51

Browse files
feat: Add Firebolt Core connection support (#106)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]>
1 parent fd01b3c commit f708f51

14 files changed

+413
-24
lines changed

.github/resources/core/config.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"nodes": [
3+
{
4+
"host": "firebolt-core"
5+
}
6+
]
7+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: firebolt-core
2+
3+
services:
4+
firebolt-core:
5+
image: ghcr.io/firebolt-db/firebolt-core:${IMAGE_TAG}
6+
container_name: firebolt-core
7+
command: --node 0
8+
privileged: true
9+
restart: no
10+
ulimits:
11+
memlock: 8589934592
12+
ports:
13+
- 3473:3473
14+
volumes:
15+
- ${BASE_DIR}/.github/resources/core/config.json:/firebolt-core/config.json:ro
16+
- ${BASE_DIR}/firebolt-core:/firebolt-core/data
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
name: Core integration tests
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
tag_version:
7+
description: 'The docker image tag for the firebolt core'
8+
required: false
9+
type: string
10+
default: 'preview-rc'
11+
python_version:
12+
description: 'Python version'
13+
required: false
14+
type: string
15+
default: '3.8'
16+
workflow_call:
17+
inputs:
18+
tag_version:
19+
description: 'The docker image tag for the firebolt core'
20+
required: false
21+
type: string
22+
default: 'preview-rc'
23+
python_version:
24+
description: 'Python version'
25+
required: false
26+
type: string
27+
default: '3.8'
28+
29+
jobs:
30+
run-core-integration-tests:
31+
runs-on: ubuntu-latest
32+
env:
33+
DOCKER_COMPOSE_FILE: ${{ github.workspace }}/.github/resources/core/docker-compose.yml
34+
SERVICE_PORT: 3473
35+
SERVICE_URL: http://localhost:3473
36+
MAX_RETRIES: 30
37+
RETRY_INTERVAL: 2
38+
steps:
39+
- name: Check out code
40+
uses: actions/checkout@v4
41+
42+
- name: Set up Python
43+
uses: actions/setup-python@v5
44+
with:
45+
python-version: ${{ inputs.python_version }}
46+
47+
- name: Install dependencies
48+
run: |
49+
python -m pip install --upgrade pip
50+
pip install ".[dev]"
51+
52+
- name: Log in to GitHub Container Registry
53+
uses: docker/login-action@v3
54+
with:
55+
registry: ghcr.io
56+
username: ${{ github.actor }}
57+
password: ${{ secrets.GITHUB_TOKEN }}
58+
59+
- name: Prepare docker-compose.yml
60+
run: |
61+
sed -i "s|\${IMAGE_TAG}|${{ inputs.tag_version }}|g" "$DOCKER_COMPOSE_FILE"
62+
sed -i "s|\${BASE_DIR}|${{ github.workspace }}|g" "$DOCKER_COMPOSE_FILE"
63+
64+
- name: Start service container
65+
run: |
66+
docker compose -f "$DOCKER_COMPOSE_FILE" up -d
67+
docker compose -f "$DOCKER_COMPOSE_FILE" ps
68+
69+
- name: Wait for service to be ready
70+
run: |
71+
for i in $(seq 1 $MAX_RETRIES); do
72+
if curl --silent --fail "$SERVICE_URL" --data-binary "SELECT 1" | grep -q "1"; then
73+
echo "Service is up and responding!"
74+
exit 0
75+
fi
76+
echo "Waiting for service... ($i/$MAX_RETRIES)"
77+
sleep $RETRY_INTERVAL
78+
done
79+
echo "Error: Service failed to start within timeout"
80+
docker compose -f "$DOCKER_COMPOSE_FILE" logs
81+
exit 1
82+
83+
- name: Run Core integration tests
84+
env:
85+
CORE_URL: "http://localhost:3473"
86+
run: |
87+
pytest -o log_cli=true -o log_cli_level=INFO tests/integration -k "core" --alluredir=allure-results
88+
89+
- name: Stop container
90+
if: always()
91+
run: |
92+
docker compose -f "$DOCKER_COMPOSE_FILE" down
93+
94+
# Need to pull the pages branch in order to fetch the previous runs
95+
- name: Get Allure history
96+
uses: actions/checkout@v4
97+
if: always()
98+
continue-on-error: true
99+
with:
100+
ref: gh-pages
101+
path: gh-pages
102+
103+
- name: Allure Report
104+
uses: firebolt-db/action-allure-report@v1
105+
if: always()
106+
with:
107+
github-key: ${{ secrets.GITHUB_TOKEN }}
108+
test-type: integration
109+
allure-dir: allure-results
110+
pages-branch: gh-pages
111+
repository-name: firebolt-sqlalchemy

.github/workflows/integration-tests-v1.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ jobs:
1616
- name: Check out code
1717
uses: actions/checkout@v2
1818

19-
- name: Set up Python 3.8
19+
- name: Set up Python 3.9
2020
uses: actions/setup-python@v5
2121
with:
22-
python-version: 3.8
22+
python-version: 3.9
2323

2424
- name: Install dependencies
2525
run: |
@@ -56,7 +56,7 @@ jobs:
5656
FIREBOLT_BASE_URL: "api.staging.firebolt.io"
5757
ACCOUNT_NAME: "firebolt"
5858
run: |
59-
pytest --last-failed -o log_cli=true -o log_cli_level=INFO tests/integration
59+
pytest --last-failed -o log_cli=true -o log_cli_level=INFO tests/integration -k "not core"
6060
6161
- name: Save failed tests
6262
id: cache-tests-save

.github/workflows/integration-tests-v2.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ jobs:
2828
with:
2929
repository: firebolt-db/firebolt-sqlalchemy
3030

31-
- name: Set up Python 3.8
31+
- name: Set up Python 3.9
3232
uses: actions/setup-python@v5
3333
with:
34-
python-version: 3.8
34+
python-version: 3.9
3535

3636
- name: Install dependencies
3737
run: |
@@ -65,7 +65,7 @@ jobs:
6565
FIREBOLT_BASE_URL: "api.staging.firebolt.io"
6666
ACCOUNT_NAME: ${{ vars.FIREBOLT_ACCOUNT }}
6767
run: |
68-
pytest --last-failed -o log_cli=true -o log_cli_level=INFO tests/integration --alluredir=allure-results
68+
pytest --last-failed -o log_cli=true -o log_cli_level=INFO tests/integration -k "not core" --alluredir=allure-results
6969
7070
- name: Save failed tests
7171
id: cache-tests-save

.github/workflows/python-integration-tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ jobs:
1919
integration-test-v2:
2020
uses: ./.github/workflows/integration-tests-v2.yml
2121
secrets: inherit
22+
integration-test-core:
23+
uses: ./.github/workflows/integration-tests-core.yml

pytest.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[pytest]
2+
trio_mode = true
3+
markers =
4+
core: mark test to run only on Firebolt Core.

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ project_urls =
2727
[options]
2828
packages = find:
2929
install_requires =
30-
firebolt-sdk>=1.5.0
30+
firebolt-sdk>=1.13.0
3131
sqlalchemy>=1.0.0
3232
python_requires = >=3.8
3333
package_dir =

src/firebolt_db/firebolt_dialect.py

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55

66
import firebolt.db as dbapi
77
import sqlalchemy.types as sqltypes
8-
from firebolt.client.auth import Auth, ClientCredentials, UsernamePassword
8+
from firebolt.client.auth import (
9+
Auth,
10+
ClientCredentials,
11+
FireboltCore,
12+
UsernamePassword,
13+
)
914
from firebolt.db import Cursor
1015
from sqlalchemy.engine import Connection as AlchemyConnection
1116
from sqlalchemy.engine import ExecutionContext, default
@@ -145,36 +150,95 @@ def create_connect_args(self, url: URL) -> Tuple[List, Dict]:
145150
"""
146151
Build firebolt-sdk compatible connection arguments.
147152
URL format : firebolt://id:secret@host:port/db_name
153+
For Core: firebolt://db_name?url=http://localhost:8080
154+
(full URL including scheme, host, port in url parameter)
148155
"""
149156
parameters = dict(url.query)
150-
# parameters are all passed as a string, we need to convert
151-
# bool flag to boolean for SDK compatibility
152-
token_cache_flag = bool(strtobool(parameters.pop("use_token_cache", "True")))
153-
auth = _determine_auth(url.username, url.password, token_cache_flag)
157+
is_core_connection = "url" in parameters
158+
159+
if is_core_connection:
160+
self._validate_core_connection(url, parameters)
161+
162+
token_cache_flag = self._parse_token_cache_flag(parameters)
163+
auth = _determine_auth(url, token_cache_flag)
164+
kwargs = self._build_connection_kwargs(
165+
url, parameters, auth, is_core_connection
166+
)
167+
168+
return ([], kwargs)
169+
170+
def _validate_core_connection(self, url: URL, parameters: Dict[str, str]) -> None:
171+
"""Validate that Core connection parameters are correct.
172+
173+
Only validates credentials since FireboltCore auth handles other parameters.
174+
"""
175+
if url.username or url.password:
176+
raise ArgumentError(
177+
"Core connections do not support username/password authentication"
178+
)
179+
180+
def _parse_token_cache_flag(self, parameters: Dict[str, str]) -> bool:
181+
"""Parse and remove token cache flag from parameters."""
182+
return bool(strtobool(parameters.pop("use_token_cache", "True")))
183+
184+
def _build_connection_kwargs(
185+
self, url: URL, parameters: Dict[str, str], auth: Auth, is_core_connection: bool
186+
) -> Dict[str, Union[str, Auth, Dict[str, Any], None]]:
187+
"""Build connection kwargs for the SDK.
188+
189+
SQLAlchemy URL mapping:
190+
- url.host -> database (Firebolt database name)
191+
- url.database -> engine_name (Firebolt engine name)
192+
"""
154193
kwargs: Dict[str, Union[str, Auth, Dict[str, Any], None]] = {
155194
"database": url.host or None,
156195
"auth": auth,
157196
"engine_name": url.database,
158197
"additional_parameters": {},
159198
}
160-
additional_parameters = {}
199+
200+
if is_core_connection:
201+
kwargs["url"] = parameters.pop("url")
202+
203+
self._handle_account_name(parameters, auth, kwargs)
204+
self._handle_environment_config(kwargs)
205+
kwargs["additional_parameters"] = self._build_additional_parameters(parameters)
206+
self._set_parameters = parameters
207+
208+
return kwargs
209+
210+
def _handle_account_name(
211+
self,
212+
parameters: Dict[str, str],
213+
auth: Auth,
214+
kwargs: Dict[str, Union[str, Auth, Dict[str, Any], None]],
215+
) -> None:
216+
"""Handle account_name parameter and validation."""
161217
if "account_name" in parameters:
162218
kwargs["account_name"] = parameters.pop("account_name")
163219
elif isinstance(auth, ClientCredentials):
164-
# account_name is required for client credentials authentication
165220
raise ArgumentError(
166221
"account_name parameter must be provided to authenticate"
167222
)
168-
self._set_parameters = parameters
169-
# If URL override is not provided leave it to the sdk to determine the endpoint
223+
224+
def _handle_environment_config(
225+
self, kwargs: Dict[str, Union[str, Auth, Dict[str, Any], None]]
226+
) -> None:
227+
"""Handle environment-based configuration."""
170228
if "FIREBOLT_BASE_URL" in os.environ:
171229
kwargs["api_endpoint"] = os.environ["FIREBOLT_BASE_URL"]
172-
# Tracking information
230+
231+
def _build_additional_parameters(
232+
self, parameters: Dict[str, str]
233+
) -> Dict[str, Any]:
234+
"""Build additional parameters including tracking information."""
235+
additional_parameters: Dict[str, Any] = {}
236+
173237
if "user_clients" in parameters or "user_drivers" in parameters:
174238
additional_parameters["user_drivers"] = parameters.pop("user_drivers", [])
175239
additional_parameters["user_clients"] = parameters.pop("user_clients", [])
176-
kwargs["additional_parameters"] = additional_parameters
177-
return ([], kwargs)
240+
241+
return additional_parameters
178242

179243
def get_schema_names(
180244
self, connection: AlchemyConnection, **kwargs: Any
@@ -366,8 +430,13 @@ def get_is_nullable(column_is_nullable: int) -> bool:
366430
return column_is_nullable == 1
367431

368432

369-
def _determine_auth(key: str, secret: str, token_cache_flag: bool = True) -> Auth:
370-
if "@" in key:
371-
return UsernamePassword(key, secret, token_cache_flag)
433+
def _determine_auth(url: URL, token_cache_flag: bool = True) -> Auth:
434+
parameters = dict(url.query)
435+
is_core_connection = "url" in parameters
436+
437+
if is_core_connection:
438+
return FireboltCore()
439+
elif "@" in (url.username or ""):
440+
return UsernamePassword(url.username, url.password, token_cache_flag)
372441
else:
373-
return ClientCredentials(key, secret, token_cache_flag)
442+
return ClientCredentials(url.username, url.password, token_cache_flag)

tests/integration/conftest.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def dimension_table_name() -> str:
190190
return "test_alchemy_dimension"
191191

192192

193-
@fixture(scope="class", autouse=True)
193+
@fixture(scope="class")
194194
def setup_test_tables(
195195
connection: Connection,
196196
engine: Engine,
@@ -233,3 +233,19 @@ def setup_test_tables(
233233
assert not engine.dialect.has_table(connection, fact_table_name)
234234
assert not engine.dialect.has_table(connection, dimension_table_name)
235235
assert not engine.dialect.has_table(connection, type_table_name)
236+
237+
238+
@fixture(scope="session")
239+
def core_url() -> str:
240+
return environ.get("CORE_URL", "http://localhost:3473")
241+
242+
243+
@fixture(scope="session")
244+
def core_engine(core_url: str) -> Engine:
245+
return create_engine(f"firebolt://firebolt?url={core_url}")
246+
247+
248+
@fixture(scope="session")
249+
def core_connection(core_engine: Engine) -> Connection:
250+
with core_engine.connect() as c:
251+
yield c

0 commit comments

Comments
 (0)