Skip to content
Open
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
171 changes: 171 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
name: CI

on:
push:
branches: [ '**' ]
tags: [ 'v*' ]

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
firebird-version: [3, 4, 5]
include:
- firebird-version: 3
db-file: fbtest30.fdb
docker-image: firebirdsql/firebird:3
- firebird-version: 4
db-file: fbtest40.fdb
docker-image: firebirdsql/firebird:4
- firebird-version: 5
db-file: fbtest50.fdb
docker-image: firebirdsql/firebird:5

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install Hatch
run: pip install hatch

- name: Start Firebird Docker container
run: |
# Create a tmp directory that will be bind-mounted to the container
# This ensures test files are accessible at the same paths inside and outside the container
mkdir -p /tmp/firebird-test

# Start Firebird container with /tmp bind-mounted
# Configure RemoteAuxPort for Firebird events support
docker run -d \
--name firebird \
-e FIREBIRD_ROOT_PASSWORD=masterkey \
-e ISC_PASSWORD=masterkey \
-e FIREBIRD_CONF_RemoteAuxPort=3051 \
-p 3050:3050 \
-p 3051:3051 \
-v /tmp:/tmp:rw \
${{ matrix.docker-image }}

- name: Wait for Firebird to be ready
run: |
echo "Waiting for Firebird to be fully ready..."
for i in {1..30}; do
if docker exec firebird /bin/bash -c "echo 'SELECT 1 FROM RDB\$DATABASE;' | /opt/firebird/bin/isql -u SYSDBA -p masterkey employee" &>/dev/null 2>&1; then
echo "Firebird is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "Firebird failed to start"
docker logs firebird
exit 1

- name: Extract and install Firebird client library from container
run: |
# List available libraries in container for debugging
echo "Available libraries in container:"
docker exec firebird ls -la /opt/firebird/lib/ || docker exec firebird ls -la /usr/lib/

# Find and extract client library from the running container
# Different versions may have different file names or locations
LIB_PATH=""
if docker exec firebird test -f /opt/firebird/lib/libfbclient.so.2; then
# Get the actual file path by resolving the symlink
LIB_PATH=$(docker exec firebird readlink -f /opt/firebird/lib/libfbclient.so.2)
elif docker exec firebird test -f /opt/firebird/lib/libfbclient.so; then
LIB_PATH=$(docker exec firebird readlink -f /opt/firebird/lib/libfbclient.so)
elif docker exec firebird test -f /usr/lib/libfbclient.so; then
LIB_PATH=$(docker exec firebird readlink -f /usr/lib/libfbclient.so)
elif docker exec firebird test -f /usr/lib/x86_64-linux-gnu/libfbclient.so.2; then
LIB_PATH=$(docker exec firebird readlink -f /usr/lib/x86_64-linux-gnu/libfbclient.so.2)
else
echo "Could not find libfbclient.so in container"
exit 1
fi

echo "Copying library from: $LIB_PATH"
# Copy the actual library file (not the symlink)
docker cp firebird:$LIB_PATH ${{ github.workspace }}/libfbclient.so

# Install to system
sudo cp ${{ github.workspace }}/libfbclient.so /usr/lib/x86_64-linux-gnu/
sudo ldconfig

# Verify installation
ldconfig -p | grep libfbclient

- name: Run tests
run: hatch test -- --host=localhost --port=3050 -v
env:
FIREBIRD_VERSION: ${{ matrix.firebird-version }}

- name: Stop Firebird container
if: always()
run: |
docker logs firebird
docker stop firebird
docker rm firebird

build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install Hatch
run: pip install hatch

- name: Build package
run: hatch build

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

release:
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write # Required for trusted publishing to PyPI
steps:
- uses: actions/checkout@v4

- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
generate_release_notes: true
files: dist/*

# GitHub Packages does not currently support Python packages -- https://github.com/orgs/community/discussions/8542

# - name: Publish to GitHub Packages
# uses: pypa/gh-action-pypi-publish@release/v1
# with:
# repository-url: https://pypi.pkg.github.com/fdcastel

- name: Publish to PyPI
if: github.repository == 'FirebirdSQL/python3-driver'
uses: pypa/gh-action-pypi-publish@release/v1
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
Expand Down Expand Up @@ -40,7 +40,7 @@ Funding = "https://github.com/sponsors/pcisar"
Source = "https://github.com/FirebirdSQL/python3-driver"

[tool.hatch.version]
path = "src/firebird/driver/__init__.py"
source = "vcs"

[tool.hatch.build.targets.sdist]
include = ["src"]
Expand Down
6 changes: 5 additions & 1 deletion src/firebird/driver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,8 @@
)

#: Current driver version, SEMVER string.
__VERSION__ = '2.0.2'
try:
from importlib.metadata import version as _get_version
__VERSION__ = _get_version('firebird-driver')
except Exception:
__VERSION__ = 'unknown'
35 changes: 28 additions & 7 deletions src/firebird/driver/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2164,9 +2164,31 @@ def _connect_helper(dsn: str, host: str, port: str, database: str, protocol: Net
if protocol is not None:
dsn = f'{protocol.name.lower()}://'
if host and port:
dsn += f'{host}:{port}/'
dsn += f'{host}:{port}'
elif host:
dsn += f'{host}/'
dsn += host
# Add database path
# When there's a host, URLs need proper path formatting:
# - Unix absolute paths (start with '/') - need double slash to preserve the leading /
# because Firebird URL parsing strips one /
# - Windows absolute paths (contain ':') - concatenate directly without separator
# - Aliases/relative paths - need '/' separator
# When there's no host (loopback), the path is used as-is
if host:
# For URLs with host
if database.startswith('/'):
# Unix absolute path - use double slash so Firebird keeps the leading /
dsn += f'/{database}' # Results in inet://host//absolute/path
elif ':' in database: # Windows path (e.g., C:\...)
dsn += database # Concatenate directly without separator
else: # Relative/alias
dsn += f'/{database}'
else:
# Loopback - path is used as-is after ://
if database.startswith('/') or ':' in database:
dsn += database
else:
dsn += f'/{database}'
else:
dsn = ''
if host and host.startswith('\\\\'): # Windows Named Pipes
Expand All @@ -2178,7 +2200,7 @@ def _connect_helper(dsn: str, host: str, port: str, database: str, protocol: Net
dsn += f'{host}/{port}:'
elif host:
dsn += f'{host}:'
dsn += database
dsn += database
return dsn

def _is_dsn(value: str) -> bool:
Expand Down Expand Up @@ -2401,10 +2423,9 @@ def create_database(database: str | Path, *, user: str | None=None, password: st
if db_config is None:
db_config = driver_config.db_defaults
srv_config = driver_config.server_defaults
if _is_dsn(database):
dsn = database
database = None
srv_config.host.clear()
dsn = database
database = None
srv_config.host.clear()
else:
database = db_config.database.value
dsn = db_config.dsn.value
Expand Down
10 changes: 7 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ def tmp_dir(tmp_path_factory):

@pytest.fixture(scope='session', autouse=True)
def db_file(tmp_dir):
test_db_filename: Path = tmp_dir / 'test-db.fdb'
# Always use local tmp_dir - in CI, this will be bind-mounted to the container
test_db_filename = tmp_dir / 'test-db.fdb'
copyfile(_vars_['source_db'], test_db_filename)
if _platform != 'Windows':
test_db_filename.chmod(33206)
Expand All @@ -208,7 +209,9 @@ def dsn(db_file):
if host is None:
result = str(db_file)
else:
result = f'{host}/{port}:{db_file}' if port else f'{host}:{db_file}'
# For remote servers, use the absolute path string of the local file
# which will be the same path inside the container due to bind mount
result = f'{host}/{port}:{str(db_file)}' if port else f'{host}:{str(db_file)}'
yield result

@pytest.fixture()
Expand All @@ -233,8 +236,9 @@ def db_cleanup(db_connection):
cur.execute("delete from t")
cur.execute("delete from t2")
cur.execute("delete from FB4")
db_connection.commit()
db_connection.commit()
except Exception as e:
db_connection.rollback()
# Ignore errors if tables don't exist, log others
if "Table unknown" not in str(e):
print(f"Warning: Error during pre-test cleanup: {e}")
Expand Down
1 change: 1 addition & 0 deletions tests/test_blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def test_stream_blob_basic(db_connection):
def test_stream_blob_extended(db_connection):
blob_content = "Another test blob content." * 5 # Make it slightly longer
with db_connection.cursor() as cur:
cur.execute('delete from T2 where C1 in (1, 2)')
cur.execute('insert into T2 (C1,C9) values (?,?)', [1, StringIO(blob_content)])
cur.execute('insert into T2 (C1,C9) values (?,?)', [2, StringIO(blob_content)])
db_connection.commit()
Expand Down
Loading