Skip to content

Commit 0f59bf8

Browse files
authored
Merge branch 'main' into dependabot/github_actions/actions/upload-pages-artifact-5
2 parents 86aeb98 + 367c93d commit 0f59bf8

43 files changed

Lines changed: 1689 additions & 274 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.docker/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.11-slim-bookworm
1+
FROM python:3.14-slim-bookworm
22

33
ARG USER_ID=1000
44
ARG GROUP_ID=1000

.docker/docker-compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ services:
9090
profiles: ["oracle"]
9191
image: gvenzl/oracle-free:23-slim
9292
environment:
93-
ORACLE_PASSWORD: ${ORACLE_PASSWORD:-oraclepw}
93+
# SYS password. Not ORACLE_PASSWORD on the host side: the tests use that
94+
# name for the APP_USER password they connect with.
95+
ORACLE_PASSWORD: ${ORACLE_SYS_PASSWORD:-oraclepw}
9496
APP_USER: ${ORACLE_APP_USER:-apptest}
9597
APP_USER_PASSWORD: ${ORACLE_APP_PASSWORD:-apptestpw}
9698
ports:

.docker/oracle/init/01-schema.sql

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
-- Functional-test fixture for Oracle (gvenzl/oracle-free).
2-
-- Executed inside the FREEPDB1 pluggable database as the APP_USER.
32
-- Covers every Oracle source type referenced by
43
-- db2sql/infrastructure/emit/postgres/emitter.py:DEFAULT_TYPE_MAP, plus an
54
-- IDENTITY column (12c+), a foreign key and a non-unique index.
65
--
76
-- Notes:
8-
-- * The script targets the per-PDB connection set up by gvenzl/oracle-free,
9-
-- so the current user is APP_USER (APPTEST). Tables are created in that
10-
-- schema; the reader filters on owner = 'APPTEST'.
7+
-- * gvenzl runs *.sql init files with `sqlplus / as sysdba`, so we start out
8+
-- as SYS in the CDB root. Hence the two ALTER SESSION below: without them
9+
-- the tables end up in SYS and APPTEST stays empty.
10+
-- * PDB and schema names are hardcoded — keep them in sync with
11+
-- ORACLE_APP_USER in .docker/docker-compose.yml.
1112
-- * LONG can only be used once per table, so it lives in its own table.
1213

14+
WHENEVER SQLERROR EXIT SQL.ERROR;
15+
16+
ALTER SESSION SET CONTAINER = FREEPDB1;
17+
ALTER SESSION SET CURRENT_SCHEMA = APPTEST;
18+
1319
ALTER SESSION SET NLS_DATE_FORMAT='YYYY-MM-DD HH24:MI:SS';
1420
ALTER SESSION SET NLS_TIMESTAMP_FORMAT='YYYY-MM-DD HH24:MI:SS.FF';
1521
ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT='YYYY-MM-DD HH24:MI:SS.FF TZH:TZM';

.github/workflows/ci.yml

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
runs-on: ubuntu-latest
2020
steps:
2121
- uses: actions/checkout@v4
22-
- uses: actions/setup-python@v5
22+
- uses: actions/setup-python@v6
2323
with:
2424
python-version: "3.12"
2525
cache: 'pip'
@@ -45,7 +45,7 @@ jobs:
4545
python: ${{ fromJSON(github.event_name == 'pull_request' && '["3.12"]' || '["3.9","3.10","3.11","3.12","3.13","3.14"]') }}
4646
steps:
4747
- uses: actions/checkout@v4
48-
- uses: actions/setup-python@v5
48+
- uses: actions/setup-python@v6
4949
with:
5050
python-version: ${{ matrix.python }}
5151
cache: 'pip'
@@ -70,7 +70,7 @@ jobs:
7070
runs-on: ubuntu-latest
7171
steps:
7272
- uses: actions/checkout@v4
73-
- uses: actions/setup-python@v5
73+
- uses: actions/setup-python@v6
7474
with:
7575
python-version: "3.12"
7676
cache: 'pip'
@@ -115,7 +115,7 @@ jobs:
115115
steps:
116116
- uses: actions/checkout@v4
117117

118-
- uses: actions/setup-python@v5
118+
- uses: actions/setup-python@v6
119119
with:
120120
python-version: "3.12"
121121
cache: 'pip'
@@ -144,7 +144,7 @@ jobs:
144144
145145
- name: Upload stack logs artifact
146146
if: failure()
147-
uses: actions/upload-artifact@v4
147+
uses: actions/upload-artifact@v7
148148
with:
149149
name: docker-stack-logs-functional
150150
path: artifacts/
@@ -170,14 +170,17 @@ jobs:
170170
ORACLE_HOST: localhost
171171
ORACLE_PORT: "1521"
172172
ORACLE_SERVICE: FREEPDB1
173-
ORACLE_PASSWORD: oraclepw
173+
# Read by docker compose to bootstrap the container
174+
ORACLE_SYS_PASSWORD: oraclepw
174175
ORACLE_APP_USER: apptest
175176
ORACLE_APP_PASSWORD: apptestpw
177+
# Read by the pytest fixtures — they connect as the app user, not SYS
176178
ORACLE_USER: apptest
179+
ORACLE_PASSWORD: apptestpw
177180
steps:
178181
- uses: actions/checkout@v4
179182

180-
- uses: actions/setup-python@v5
183+
- uses: actions/setup-python@v6
181184
with:
182185
python-version: "3.12"
183186
cache: 'pip'
@@ -189,7 +192,22 @@ jobs:
189192
run: pip install -e ".[all]" pytest
190193

191194
- name: Start Oracle container and wait until healthy
192-
run: docker compose -f .docker/docker-compose.yml --profile oracle up -d --wait
195+
run: docker compose -f .docker/docker-compose.yml --profile oracle up -d --wait oracle
196+
197+
# The container is healthy even when the init SQL failed, and the tests
198+
# then fail on a missing schema. Cheaper to catch it here.
199+
- name: Verify Oracle fixture schema
200+
run: |
201+
docker compose -f .docker/docker-compose.yml --profile oracle exec -T oracle \
202+
sqlplus -s -L "${ORACLE_USER}/${ORACLE_PASSWORD}@//localhost:1521/${ORACLE_SERVICE}" <<'SQL' | tee fixture-check.txt
203+
set heading off pagesize 0 feedback off
204+
SELECT 'FIXTURE_TABLES=' || COUNT(*) FROM user_tables;
205+
exit;
206+
SQL
207+
grep -qE 'FIXTURE_TABLES=[1-9]' fixture-check.txt || {
208+
echo "::error::Oracle init script did not create the ${ORACLE_USER} fixture — see the container logs";
209+
exit 1;
210+
}
193211
194212
- name: Run Oracle functional tests
195213
run: pytest -m oracle tests/functional -v
@@ -203,7 +221,7 @@ jobs:
203221
204222
- name: Upload Oracle logs artifact
205223
if: failure()
206-
uses: actions/upload-artifact@v4
224+
uses: actions/upload-artifact@v7
207225
with:
208226
name: docker-stack-logs-oracle
209227
path: artifacts/

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
steps:
2121
- uses: actions/checkout@v4
2222

23-
- uses: actions/setup-python@v5
23+
- uses: actions/setup-python@v6
2424
with:
2525
python-version: "3.12"
2626

.github/workflows/release-binaries.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ jobs:
6060
# call setup-python on bare host runners (Windows / macOS).
6161
- name: Set up Python (host runners only)
6262
if: matrix.container == ''
63-
uses: actions/setup-python@v5
63+
uses: actions/setup-python@v6
6464
with:
6565
python-version: ${{ matrix.python }}
6666
cache: pip
@@ -95,7 +95,7 @@ jobs:
9595
run: python installer/build.py --archive
9696

9797
- name: Upload build artifact
98-
uses: actions/upload-artifact@v4
98+
uses: actions/upload-artifact@v7
9999
with:
100100
name: db2sql-${{ matrix.label }}
101101
path: ${{ matrix.archive-glob }}

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
6969
persist-credentials: true
7070

71-
- uses: actions/setup-python@v5
71+
- uses: actions/setup-python@v6
7272
with:
7373
python-version: "3.12"
7474
cache: pip
@@ -100,7 +100,7 @@ jobs:
100100

101101
- name: Upload Python distributions
102102
if: steps.release.outputs.released == 'true' && inputs.dry-run == false
103-
uses: actions/upload-artifact@v4
103+
uses: actions/upload-artifact@v7
104104
with:
105105
name: python-dist
106106
path: dist/*

README.rst

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ db2sql
77

88
Two output modes are supported:
99

10-
* **Dump mode** (default) — write a SQL file (or stream to ``stdout``) that can later be replayed with ``psql -f`` or ``sqlcmd -i``.
11-
* **Migrate mode** — open a live connection to the target database and apply the same DDL and data directly, without an intermediate file. The DDL produced is byte-identical to dump mode: a single ``SqlEmitter`` is the source of truth in both paths.
10+
* ``db2sql dump`` (the default command) — write a SQL file (or stream to ``stdout``) that can later be replayed with ``psql -f`` or ``sqlcmd -i``.
11+
* ``db2sql migrate`` — open a live connection to the target database and apply the same DDL and data directly, without an intermediate file. The DDL produced is byte-identical to dump mode: a single ``SqlEmitter`` is the source of truth in both paths.
12+
13+
Two helper commands round out the CLI: ``db2sql init`` generates a configuration file through an interactive wizard, and ``db2sql validate`` checks one (optionally previewing the export plan) before a long run.
14+
15+
Running ``db2sql`` with dump options but no command is a shorthand for ``db2sql dump`` — both forms are supported and produce identical output.
1216

1317

1418
Installation
@@ -46,7 +50,7 @@ dump, but without the round-trip through a ``.sql`` file:
4650
.. code-block:: console
4751
4852
# SQLite source → live Postgres target
49-
$ db2sql --driver sqlite --dbname mydb.sqlite migrate \
53+
$ db2sql migrate --driver sqlite --dbname mydb.sqlite \
5054
--target-host localhost --target-port 5432 \
5155
--target-dbname mytarget --target-user postgres --target-password s3cr3t
5256
@@ -69,15 +73,43 @@ order:
6973

7074
.. code-block:: console
7175
72-
$ db2sql --driver sqlite --dbname mydb.sqlite --on-existing drop -f dump.sql
76+
$ db2sql dump --driver sqlite --dbname mydb.sqlite --on-existing drop -f dump.sql
7377
7478
Pass ``--on-existing truncate`` to produce a *data-only* script: no DDL is
7579
emitted, the dump just ``TRUNCATE``\s every managed table and reloads its
7680
rows. Use it to refresh data into a pre-existing schema:
7781

7882
.. code-block:: console
7983
80-
$ db2sql --driver sqlite --dbname mydb.sqlite --on-existing truncate -f refresh.sql
84+
$ db2sql dump --driver sqlite --dbname mydb.sqlite --on-existing truncate -f refresh.sql
85+
86+
87+
Connecting with a DSN
88+
---------------------
89+
90+
The discrete ``-H`` / ``-P`` / ``-d`` / ``-u`` / ``-p`` flags cover the common
91+
case. When you need something they cannot express — a TLS mode, a charset, an
92+
Oracle ``service_name``, an alternative DBAPI — pass a full SQLAlchemy URL
93+
instead:
94+
95+
.. code-block:: console
96+
97+
# prefer the environment: a DSN on the command line is visible in `ps`
98+
$ export DB2SQL_SOURCE_DSN='postgresql+psycopg2://app:s3cr3t@pg.example.com:5432/mydb?sslmode=require'
99+
$ db2sql dump --driver postgres -f dump.sql
100+
101+
# and its mirror for a live migration
102+
$ export DB2SQL_TARGET_DSN='postgresql+psycopg2://svc@target.internal:5432/stage'
103+
$ db2sql migrate --driver mysql -H mysql.example.com -d mydb -u app -W
104+
105+
A DSN **replaces** the connection rather than merging with it, and the URL
106+
dialect must match ``--driver`` / ``--target``. Passing a DSN together with
107+
``-H`` / ``-d`` / … on the same command line — or declaring both in the same
108+
config file — is rejected as a contradiction; a DSN overriding a connection
109+
that came from a config file or the environment is allowed, and warns about
110+
what it dropped. Passwords are always redacted in log output. See the
111+
`CLI reference <https://python-db2sql.readthedocs.org/en/stable/cli.html>`__
112+
for the full semantics.
81113

82114

83115
Validating a configuration

db2sql/const.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
"ENV_DB2SQL_HOST",
99
"ENV_DB2SQL_PORT",
1010
"ENV_DB2SQL_DBNAME",
11+
"ENV_DB2SQL_SOURCE_DSN",
1112
"ENV_DB2SQL_TARGET_HOST",
1213
"ENV_DB2SQL_TARGET_PORT",
1314
"ENV_DB2SQL_TARGET_USER",
1415
"ENV_DB2SQL_TARGET_PASSWORD",
1516
"ENV_DB2SQL_TARGET_DBNAME",
17+
"ENV_DB2SQL_TARGET_DSN",
1618
"ENV_NO_COLOR",
1719
"ENV_CLICOLOR_FORCE",
1820
"ENV_DB2SQL_COLOR_DARK",
@@ -40,6 +42,9 @@
4042
ENV_DB2SQL_DBNAME = "DB2SQL_DBNAME"
4143
"""Source database name."""
4244

45+
ENV_DB2SQL_SOURCE_DSN = "DB2SQL_SOURCE_DSN"
46+
"""Full SQLAlchemy URL for the source database; overrides the discrete fields."""
47+
4348
ENV_DB2SQL_TARGET_HOST = "DB2SQL_TARGET_HOST"
4449
"""Target database host (live migration)."""
4550

@@ -55,6 +60,9 @@
5560
ENV_DB2SQL_TARGET_DBNAME = "DB2SQL_TARGET_DBNAME"
5661
"""Target database name (live migration)."""
5762

63+
ENV_DB2SQL_TARGET_DSN = "DB2SQL_TARGET_DSN"
64+
"""Full SQLAlchemy URL for the target database (live migration)."""
65+
5866
ENV_NO_COLOR = "NO_COLOR"
5967
"""Disable ANSI colors."""
6068

db2sql/infrastructure/config/loader.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@
2727
str(Path.home() / "db2sql.yml"),
2828
]
2929

30-
_SERVER_FIELDS = {"hostname", "port", "username", "password", "dbname"}
30+
_SERVER_FIELDS = {"hostname", "port", "username", "password", "dbname", "dsn"}
3131
_TARGET_SERVER_FIELDS = {
3232
"target_hostname",
3333
"target_port",
3434
"target_username",
3535
"target_password",
3636
"target_dbname",
37+
"target_dsn",
3738
}
3839
_MIGRATE_FIELDS = {"on_existing", "transaction_mode", "batch_size", "use_transaction"}
3940
_DUMP_FIELDS = {
@@ -105,9 +106,30 @@ def load_config(config_file: Optional[PathLike] = None) -> AppConfig:
105106
if not data:
106107
return AppConfig()
107108
try:
108-
return AppConfig.model_validate(data)
109+
config = AppConfig.model_validate(data)
109110
except ValidationError as exc:
110111
raise ConfigInvalidError(f"Invalid configuration file {resolved}: {exc}") from exc
112+
_reject_dsn_conflicts(config, resolved)
113+
return config
114+
115+
116+
def _reject_dsn_conflicts(config: AppConfig, source: str) -> None:
117+
"""Refuse a ``dsn`` sitting next to discrete connection keys in the same file.
118+
119+
A DSN replaces the connection rather than merging with it, so declaring
120+
both in one file states two contradictory intents. A DSN passed on the
121+
command line while the file describes a host is a different matter — that
122+
is the documented precedence, and the runner only warns about it.
123+
"""
124+
for section in ("server", "target_server"):
125+
server = getattr(config, section)
126+
shadowed = server.fields_shadowed_by_dsn()
127+
if shadowed:
128+
keys = ", ".join(f"{section}.{name}" for name in shadowed)
129+
raise ConfigInvalidError(
130+
f"Invalid configuration file {source}: {section}.dsn cannot be combined "
131+
f"with {keys} — a DSN replaces the connection, it does not merge with it."
132+
)
111133

112134

113135
def merge_cli_overrides(config: AppConfig, options: Mapping[str, Any]) -> AppConfig:

0 commit comments

Comments
 (0)