dbression 0.1.2__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dbression-0.1.2 → dbression-0.3.0}/CHANGELOG.md +35 -4
- {dbression-0.1.2 → dbression-0.3.0}/PKG-INFO +21 -40
- {dbression-0.1.2 → dbression-0.3.0}/README.md +19 -38
- dbression-0.3.0/docs/dbression_head.png +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/pyproject.toml +2 -1
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/cli.py +72 -11
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/db/engine.py +27 -0
- dbression-0.3.0/src/dbression/parser/__init__.py +12 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/parser/wiki.py +63 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/report/__init__.py +9 -1
- dbression-0.3.0/src/dbression/report/progress.py +113 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/runner.py +61 -9
- dbression-0.3.0/tests/test_progress_and_single_file.py +128 -0
- dbression-0.3.0/tests/test_sqlite_engine.py +60 -0
- {dbression-0.1.2 → dbression-0.3.0}/uv.lock +1 -1
- dbression-0.1.2/src/dbression/parser/__init__.py +0 -4
- {dbression-0.1.2 → dbression-0.3.0}/.github/workflows/ci.yml +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/.github/workflows/publish.yml +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/.gitignore +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/.python-version +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/LICENSE +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/01-hello/HelloSql.test.md +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/01-hello/_root.wiki +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/01-hello/connection.properties +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/02-stored-procedure/BumpCounterTest.test.md +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/02-stored-procedure/SuiteSetUp.test.md +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/02-stored-procedure/SuiteTearDown.test.md +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/02-stored-procedure/_root.wiki +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/02-stored-procedure/connection.properties +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/03-schema-drift/SchemaSnapshotTest.test.md +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/03-schema-drift/SuiteSetUp.test.md +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/03-schema-drift/SuiteTearDown.test.md +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/03-schema-drift/_root.wiki +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/03-schema-drift/connection.properties +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/examples/README.md +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/__init__.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/db/__init__.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/db/connection.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/db/errors.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/fixtures/__init__.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/fixtures/base.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/fixtures/basic.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/fixtures/inspect_and_store.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/fixtures/plugins.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/fixtures/suite_fixtures.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/parser/ast.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/parser/markdown.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/parser/markdown_writer.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/parser/tokenizer.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/report/console.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/report/json_report.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/report/junit.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/src/dbression/symbols.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/conftest.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_connection.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_markdown_parser.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_markdown_roundtrip.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_parser.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_phase2_fixtures.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_plugins.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_postgres_live.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_report_junit.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_smoke.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_symbols.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_tokenizer.py +0 -0
- {dbression-0.1.2 → dbression-0.3.0}/tests/test_update_fixture.py +0 -0
|
@@ -7,17 +7,46 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] — 2026-06-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Single-file runs.** `dbression run path/to/MyTest.test.md` (or `.wiki`) now runs a
|
|
15
|
+
single test, mirroring DBFit: a file is one *Test* (many fixtures), a directory is a
|
|
16
|
+
*Suite*. The containing directory's `SuiteSetUp` / `SuiteTearDown` and `_root`
|
|
17
|
+
(connection + `DatabaseEnvironment`) are included automatically. A self-contained
|
|
18
|
+
`.test.md` carrying its own `<!-- dbression:env=… -->` / `<!-- dbression:connection=… -->`
|
|
19
|
+
directives runs standalone.
|
|
20
|
+
- **Live progress.** Runs now show a spinner, an `x/y fixtures` counter, elapsed time, and
|
|
21
|
+
a status line naming the fixture/query currently executing. On by default at a TTY;
|
|
22
|
+
silenced automatically when output is piped or in CI. Toggle with `--progress` /
|
|
23
|
+
`--no-progress`.
|
|
24
|
+
- **`--details` / `-d`.** Prints each fixture's result green/red as it runs (DBFit-web-UI
|
|
25
|
+
style), so a `.test.md` viewed in `glow` has a matching colored CLI run alongside it.
|
|
26
|
+
|
|
27
|
+
## [0.2.0] — 2026-06-01
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **SQLite as a `DatabaseEnvironment`.** Use `DatabaseEnvironment | sqlite` (alias
|
|
32
|
+
`sqlite3`) with `service=:memory:` or `service=<path-to-db-file>` in
|
|
33
|
+
`connection.properties`. No native deps — uses the Python stdlib `sqlite3`
|
|
34
|
+
driver via SQLAlchemy. `Execute Procedure` does not apply (SQLite has no
|
|
35
|
+
stored procedures); everything else (`Query`, `Execute`, `Insert`, `Delete`,
|
|
36
|
+
`Inspect *`, `Update`, captures, substitutions) works the same as on the
|
|
37
|
+
other backends.
|
|
38
|
+
|
|
10
39
|
## [0.1.2] — 2026-06-01
|
|
11
40
|
|
|
12
|
-
SQL Server live-verification release. A real-world MSSQL DBFit suite (
|
|
13
|
-
|
|
41
|
+
SQL Server live-verification release. A real-world MSSQL DBFit suite (regression tests,
|
|
42
|
+
stored-procedure-driven, square-bracket identifiers throughout)
|
|
14
43
|
runs end-to-end against an MSSQL Server 2019 via `pymssql` with no changes to the
|
|
15
44
|
underlying `.wiki` files.
|
|
16
45
|
|
|
17
46
|
### Fixed
|
|
18
47
|
|
|
19
48
|
- **Console reporter swallowed `[…]` in failure details.** Rich's inline-markup
|
|
20
|
-
parser interpreted MSSQL square-bracket identifiers like `[
|
|
49
|
+
parser interpreted MSSQL square-bracket identifiers like `[dbo].ORDER` as
|
|
21
50
|
style tags and stripped them from the displayed SQL — making failure output
|
|
22
51
|
misleading. Reporter now prints fixture details with `markup=False`, so the
|
|
23
52
|
SQL you see is the SQL that ran.
|
|
@@ -120,7 +149,9 @@ with code paths in place for SQL Server and Oracle.
|
|
|
120
149
|
reject `python-oracledb` thin-mode authentication. Configuration is via
|
|
121
150
|
`DBRESSION_ORACLE_CLIENT_LIB_DIR`.
|
|
122
151
|
|
|
123
|
-
[Unreleased]: https://github.com/angrydat/dbression/compare/v0.
|
|
152
|
+
[Unreleased]: https://github.com/angrydat/dbression/compare/v0.3.0...HEAD
|
|
153
|
+
[0.3.0]: https://github.com/angrydat/dbression/releases/tag/v0.3.0
|
|
154
|
+
[0.2.0]: https://github.com/angrydat/dbression/releases/tag/v0.2.0
|
|
124
155
|
[0.1.2]: https://github.com/angrydat/dbression/releases/tag/v0.1.2
|
|
125
156
|
[0.1.1]: https://github.com/angrydat/dbression/releases/tag/v0.1.1
|
|
126
157
|
[0.1.0]: https://github.com/angrydat/dbression/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dbression
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Rage-quit your flaky DB regressions — modern, lightweight, multi-DB regression testing.
|
|
5
5
|
Project-URL: Homepage, https://angrydata.info/dbression
|
|
6
6
|
Project-URL: Repository, https://github.com/angrydat/dbression
|
|
@@ -9,7 +9,7 @@ Project-URL: Changelog, https://github.com/angrydat/dbression/blob/main/CHANGELO
|
|
|
9
9
|
Author-email: Jürgen Zornig <info@angrydata.info>
|
|
10
10
|
License: MIT
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Keywords: database,dbfit,fitnesse,markdown,oracle,postgres,regression,sql,sqlserver,testing
|
|
12
|
+
Keywords: database,dbfit,fitnesse,markdown,oracle,postgres,regression,sql,sqlite,sqlserver,testing
|
|
13
13
|
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Environment :: Console
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
@@ -35,6 +35,10 @@ Requires-Dist: sqlalchemy>=2.0
|
|
|
35
35
|
Requires-Dist: typer>=0.12
|
|
36
36
|
Description-Content-Type: text/markdown
|
|
37
37
|
|
|
38
|
+
<p align="center">
|
|
39
|
+
<img src="https://raw.githubusercontent.com/angrydat/dbression/main/docs/dbression_head.png" alt="dbression — database regression testing for schema changes, migrations, and critical queries" width="820">
|
|
40
|
+
</p>
|
|
41
|
+
|
|
38
42
|
# dbression
|
|
39
43
|
|
|
40
44
|
> ### Rage-quit your flaky DB regressions.
|
|
@@ -48,19 +52,19 @@ anywhere. `dbression` is a Python re-implementation in the spirit of the fantast
|
|
|
48
52
|
|
|
49
53
|
```text
|
|
50
54
|
$ dbression run tests/
|
|
51
|
-
dbression 0.
|
|
55
|
+
dbression 0.3.0 — Suite: tests @ postgresql+psycopg://foo:***@db01/bar
|
|
52
56
|
|
|
53
57
|
✓ HelloSql 0.004s
|
|
54
58
|
CommonSuite/
|
|
55
|
-
|
|
59
|
+
ChangelistSuite/
|
|
56
60
|
✓ AAddBasicTest 0.027s
|
|
57
61
|
✓ BAddNormalizationTest 0.029s
|
|
58
62
|
✓ CAddWhitelistTest 0.025s
|
|
59
63
|
✓ DAddInvalidArgsTest 0.014s
|
|
60
64
|
✓ ERemTest 0.052s
|
|
61
|
-
✓
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
✓ FViewTest 1.236s
|
|
66
|
+
EventsSuite/
|
|
67
|
+
FireSuite/
|
|
64
68
|
✓ LookupTest 0.157s
|
|
65
69
|
✗ SchemaTest 0.013s
|
|
66
70
|
|
|
@@ -92,7 +96,8 @@ the model gets every detail it needs to suggest a fix, no screenshots, no contex
|
|
|
92
96
|
|
|
93
97
|
```bash
|
|
94
98
|
# install (we use uv, but pip works too)
|
|
95
|
-
uv tool install
|
|
99
|
+
uv tool install dbression # or: pipx install dbression
|
|
100
|
+
# or: pip install dbression
|
|
96
101
|
|
|
97
102
|
# run
|
|
98
103
|
dbression run tests/
|
|
@@ -272,7 +277,10 @@ dbression:
|
|
|
272
277
|
## CLI cheat sheet
|
|
273
278
|
|
|
274
279
|
```bash
|
|
275
|
-
dbression run <suite-path> # run an entire (sub-)suite
|
|
280
|
+
dbression run <suite-path> # run an entire (sub-)suite (a directory)
|
|
281
|
+
dbression run <path>/MyTest.test.md # run a single test file (its SuiteSetUp/_root come along)
|
|
282
|
+
dbression run <path> -d # --details: print each fixture green/red as it runs
|
|
283
|
+
dbression run <path> --no-progress # disable the live spinner / x-of-y counter
|
|
276
284
|
dbression run <path> -v # show every fixture table, not just the page line
|
|
277
285
|
dbression run <path> --tag critical # only run pages tagged `critical`
|
|
278
286
|
dbression run <path> --skip-tag NotOnCI # skip pages tagged `NotOnCI`
|
|
@@ -284,37 +292,9 @@ dbression convert file.wiki -o out.test.md # single file with explicit output
|
|
|
284
292
|
dbression version
|
|
285
293
|
```
|
|
286
294
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
┌─────────────────────────────────────────────────────────┐
|
|
291
|
-
│ CLI (typer) dbression run tests/ [--flags] │
|
|
292
|
-
└─────────────────────────────────────────────────────────┘
|
|
293
|
-
│
|
|
294
|
-
▼
|
|
295
|
-
┌─────────────────────┐ ┌───────────────────────────────┐
|
|
296
|
-
│ Parser │ │ Engine Factory (SQLAlchemy) │
|
|
297
|
-
│ Wiki → AST │ │ ┌──────────┐ ┌──────────┐ │
|
|
298
|
-
│ • !- -! escapes │ │ │ psycopg │ │ oracledb │ │
|
|
299
|
-
│ • q'~ ~' quoting │ │ │ (Postgres│ │ (thin) │ │
|
|
300
|
-
│ • Nested suites │ │ └──────────┘ └──────────┘ │
|
|
301
|
-
│ • YAML front matter│ │ ┌──────────────────────┐ │
|
|
302
|
-
└─────────┬───────────┘ │ │ pymssql (SQL Server) │ │
|
|
303
|
-
│ │ └──────────────────────┘ │
|
|
304
|
-
▼ └───────────┬───────────────────┘
|
|
305
|
-
┌─────────────────────────────────────┴───────────────────┐
|
|
306
|
-
│ Runner — one TX per suite, savepoints per test │
|
|
307
|
-
│ Symbol engine: >>capture, <<read, :bind, _:text-subst │
|
|
308
|
-
│ Fixture registry — pluggable via decorator │
|
|
309
|
-
└─────────────────────┬───────────────────────────────────┘
|
|
310
|
-
▼
|
|
311
|
-
┌─────────────────────────────────────────────────────────┐
|
|
312
|
-
│ Reporters │
|
|
313
|
-
│ • Rich console (default) │
|
|
314
|
-
│ • JUnit XML (--junit-xml) │
|
|
315
|
-
│ • JSON (--json) │
|
|
316
|
-
└─────────────────────────────────────────────────────────┘
|
|
317
|
-
```
|
|
295
|
+
A run shows a live spinner with an `x/y fixtures` counter and the currently-executing
|
|
296
|
+
query (auto-disabled when piped or in CI). Add `-d`/`--details` to stream a colored
|
|
297
|
+
pass/fail line per fixture — handy next to a `.test.md` you're previewing in `glow`.
|
|
318
298
|
|
|
319
299
|
## Status
|
|
320
300
|
|
|
@@ -328,6 +308,7 @@ Postgres, Oracle and SQL Server. Honest state of the parts:
|
|
|
328
308
|
| PostgreSQL | ✅ verified against a real-world suite |
|
|
329
309
|
| Oracle | ✅ verified against a real-world 19c suite via `oracledb` (thin) |
|
|
330
310
|
| SQL Server | ✅ verified against a real-world suite via `pymssql` |
|
|
311
|
+
| SQLite | ✅ via stdlib `sqlite3` — no procedures (the DB has none) |
|
|
331
312
|
| JUnit XML + JSON output | ✅ |
|
|
332
313
|
| `.test.md` native Markdown format + `dbression convert` | ✅ |
|
|
333
314
|
| Plugin entry-points for custom fixtures | ✅ |
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/angrydat/dbression/main/docs/dbression_head.png" alt="dbression — database regression testing for schema changes, migrations, and critical queries" width="820">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# dbression
|
|
2
6
|
|
|
3
7
|
> ### Rage-quit your flaky DB regressions.
|
|
@@ -11,19 +15,19 @@ anywhere. `dbression` is a Python re-implementation in the spirit of the fantast
|
|
|
11
15
|
|
|
12
16
|
```text
|
|
13
17
|
$ dbression run tests/
|
|
14
|
-
dbression 0.
|
|
18
|
+
dbression 0.3.0 — Suite: tests @ postgresql+psycopg://foo:***@db01/bar
|
|
15
19
|
|
|
16
20
|
✓ HelloSql 0.004s
|
|
17
21
|
CommonSuite/
|
|
18
|
-
|
|
22
|
+
ChangelistSuite/
|
|
19
23
|
✓ AAddBasicTest 0.027s
|
|
20
24
|
✓ BAddNormalizationTest 0.029s
|
|
21
25
|
✓ CAddWhitelistTest 0.025s
|
|
22
26
|
✓ DAddInvalidArgsTest 0.014s
|
|
23
27
|
✓ ERemTest 0.052s
|
|
24
|
-
✓
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
✓ FViewTest 1.236s
|
|
29
|
+
EventsSuite/
|
|
30
|
+
FireSuite/
|
|
27
31
|
✓ LookupTest 0.157s
|
|
28
32
|
✗ SchemaTest 0.013s
|
|
29
33
|
|
|
@@ -55,7 +59,8 @@ the model gets every detail it needs to suggest a fix, no screenshots, no contex
|
|
|
55
59
|
|
|
56
60
|
```bash
|
|
57
61
|
# install (we use uv, but pip works too)
|
|
58
|
-
uv tool install
|
|
62
|
+
uv tool install dbression # or: pipx install dbression
|
|
63
|
+
# or: pip install dbression
|
|
59
64
|
|
|
60
65
|
# run
|
|
61
66
|
dbression run tests/
|
|
@@ -235,7 +240,10 @@ dbression:
|
|
|
235
240
|
## CLI cheat sheet
|
|
236
241
|
|
|
237
242
|
```bash
|
|
238
|
-
dbression run <suite-path> # run an entire (sub-)suite
|
|
243
|
+
dbression run <suite-path> # run an entire (sub-)suite (a directory)
|
|
244
|
+
dbression run <path>/MyTest.test.md # run a single test file (its SuiteSetUp/_root come along)
|
|
245
|
+
dbression run <path> -d # --details: print each fixture green/red as it runs
|
|
246
|
+
dbression run <path> --no-progress # disable the live spinner / x-of-y counter
|
|
239
247
|
dbression run <path> -v # show every fixture table, not just the page line
|
|
240
248
|
dbression run <path> --tag critical # only run pages tagged `critical`
|
|
241
249
|
dbression run <path> --skip-tag NotOnCI # skip pages tagged `NotOnCI`
|
|
@@ -247,37 +255,9 @@ dbression convert file.wiki -o out.test.md # single file with explicit output
|
|
|
247
255
|
dbression version
|
|
248
256
|
```
|
|
249
257
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
┌─────────────────────────────────────────────────────────┐
|
|
254
|
-
│ CLI (typer) dbression run tests/ [--flags] │
|
|
255
|
-
└─────────────────────────────────────────────────────────┘
|
|
256
|
-
│
|
|
257
|
-
▼
|
|
258
|
-
┌─────────────────────┐ ┌───────────────────────────────┐
|
|
259
|
-
│ Parser │ │ Engine Factory (SQLAlchemy) │
|
|
260
|
-
│ Wiki → AST │ │ ┌──────────┐ ┌──────────┐ │
|
|
261
|
-
│ • !- -! escapes │ │ │ psycopg │ │ oracledb │ │
|
|
262
|
-
│ • q'~ ~' quoting │ │ │ (Postgres│ │ (thin) │ │
|
|
263
|
-
│ • Nested suites │ │ └──────────┘ └──────────┘ │
|
|
264
|
-
│ • YAML front matter│ │ ┌──────────────────────┐ │
|
|
265
|
-
└─────────┬───────────┘ │ │ pymssql (SQL Server) │ │
|
|
266
|
-
│ │ └──────────────────────┘ │
|
|
267
|
-
▼ └───────────┬───────────────────┘
|
|
268
|
-
┌─────────────────────────────────────┴───────────────────┐
|
|
269
|
-
│ Runner — one TX per suite, savepoints per test │
|
|
270
|
-
│ Symbol engine: >>capture, <<read, :bind, _:text-subst │
|
|
271
|
-
│ Fixture registry — pluggable via decorator │
|
|
272
|
-
└─────────────────────┬───────────────────────────────────┘
|
|
273
|
-
▼
|
|
274
|
-
┌─────────────────────────────────────────────────────────┐
|
|
275
|
-
│ Reporters │
|
|
276
|
-
│ • Rich console (default) │
|
|
277
|
-
│ • JUnit XML (--junit-xml) │
|
|
278
|
-
│ • JSON (--json) │
|
|
279
|
-
└─────────────────────────────────────────────────────────┘
|
|
280
|
-
```
|
|
258
|
+
A run shows a live spinner with an `x/y fixtures` counter and the currently-executing
|
|
259
|
+
query (auto-disabled when piped or in CI). Add `-d`/`--details` to stream a colored
|
|
260
|
+
pass/fail line per fixture — handy next to a `.test.md` you're previewing in `glow`.
|
|
281
261
|
|
|
282
262
|
## Status
|
|
283
263
|
|
|
@@ -291,6 +271,7 @@ Postgres, Oracle and SQL Server. Honest state of the parts:
|
|
|
291
271
|
| PostgreSQL | ✅ verified against a real-world suite |
|
|
292
272
|
| Oracle | ✅ verified against a real-world 19c suite via `oracledb` (thin) |
|
|
293
273
|
| SQL Server | ✅ verified against a real-world suite via `pymssql` |
|
|
274
|
+
| SQLite | ✅ via stdlib `sqlite3` — no procedures (the DB has none) |
|
|
294
275
|
| JUnit XML + JSON output | ✅ |
|
|
295
276
|
| `.test.md` native Markdown format + `dbression convert` | ✅ |
|
|
296
277
|
| Plugin entry-points for custom fixtures | ✅ |
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dbression"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Rage-quit your flaky DB regressions — modern, lightweight, multi-DB regression testing."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -16,6 +16,7 @@ keywords = [
|
|
|
16
16
|
"postgres",
|
|
17
17
|
"oracle",
|
|
18
18
|
"sqlserver",
|
|
19
|
+
"sqlite",
|
|
19
20
|
"sql",
|
|
20
21
|
"markdown",
|
|
21
22
|
"fitnesse",
|
|
@@ -8,11 +8,17 @@ import typer
|
|
|
8
8
|
from rich.console import Console
|
|
9
9
|
|
|
10
10
|
from dbression import __version__
|
|
11
|
-
from dbression.parser import parse_suite
|
|
11
|
+
from dbression.parser import parse_suite, parse_test_file
|
|
12
12
|
from dbression.parser.markdown_writer import page_to_markdown
|
|
13
13
|
from dbression.parser.wiki import parse_wiki
|
|
14
|
-
from dbression.report import
|
|
15
|
-
|
|
14
|
+
from dbression.report import (
|
|
15
|
+
ProgressObserver,
|
|
16
|
+
make_progress,
|
|
17
|
+
print_suite_result,
|
|
18
|
+
write_json_report,
|
|
19
|
+
write_junit_xml,
|
|
20
|
+
)
|
|
21
|
+
from dbression.runner import TagFilter, build_engine_for_suite, count_fixtures, run_suite
|
|
16
22
|
|
|
17
23
|
app = typer.Typer(
|
|
18
24
|
name="dbression",
|
|
@@ -37,7 +43,12 @@ def version() -> None:
|
|
|
37
43
|
|
|
38
44
|
@app.command()
|
|
39
45
|
def run(
|
|
40
|
-
path: Annotated[
|
|
46
|
+
path: Annotated[
|
|
47
|
+
Path,
|
|
48
|
+
typer.Argument(
|
|
49
|
+
help="A suite directory (with _root.wiki) OR a single test file (.test.md / .wiki)"
|
|
50
|
+
),
|
|
51
|
+
],
|
|
41
52
|
commit_mode: Annotated[
|
|
42
53
|
str,
|
|
43
54
|
typer.Option(
|
|
@@ -48,6 +59,21 @@ def run(
|
|
|
48
59
|
verbose: Annotated[
|
|
49
60
|
bool, typer.Option("-v", "--verbose", help="Print every fixture table, not just the page line")
|
|
50
61
|
] = False,
|
|
62
|
+
details: Annotated[
|
|
63
|
+
bool,
|
|
64
|
+
typer.Option(
|
|
65
|
+
"-d",
|
|
66
|
+
"--details",
|
|
67
|
+
help="Print each fixture's result (green/red) live as it runs, DBFit-web-UI style",
|
|
68
|
+
),
|
|
69
|
+
] = False,
|
|
70
|
+
progress: Annotated[
|
|
71
|
+
bool | None,
|
|
72
|
+
typer.Option(
|
|
73
|
+
"--progress/--no-progress",
|
|
74
|
+
help="Live spinner + x/y fixture counter (default: on when stdout is a terminal)",
|
|
75
|
+
),
|
|
76
|
+
] = None,
|
|
51
77
|
tag: Annotated[
|
|
52
78
|
list[str] | None,
|
|
53
79
|
typer.Option(
|
|
@@ -68,10 +94,7 @@ def run(
|
|
|
68
94
|
typer.Option("--json", help="Write a JSON report to this path (LLM / tooling)"),
|
|
69
95
|
] = None,
|
|
70
96
|
) -> None:
|
|
71
|
-
"""Run a dbression suite
|
|
72
|
-
if not path.is_dir():
|
|
73
|
-
console.print(f"[red]Not a directory:[/red] {path}")
|
|
74
|
-
raise typer.Exit(2)
|
|
97
|
+
"""Run a dbression test (single file) or suite (directory) with live progress."""
|
|
75
98
|
if commit_mode not in ("test", "page"):
|
|
76
99
|
console.print(
|
|
77
100
|
f"[red]Invalid commit-mode: {commit_mode!r} (allowed: test, page)[/red]"
|
|
@@ -80,14 +103,52 @@ def run(
|
|
|
80
103
|
|
|
81
104
|
tag_filter = TagFilter(only=tuple(tag or ()), skip=tuple(skip_tag or ()))
|
|
82
105
|
|
|
83
|
-
|
|
106
|
+
if path.is_dir():
|
|
107
|
+
suite = parse_suite(path)
|
|
108
|
+
kind = "Suite"
|
|
109
|
+
elif path.is_file():
|
|
110
|
+
try:
|
|
111
|
+
suite = parse_test_file(path)
|
|
112
|
+
except ValueError as e:
|
|
113
|
+
console.print(f"[red]{e}[/red]")
|
|
114
|
+
raise typer.Exit(2)
|
|
115
|
+
kind = "Test"
|
|
116
|
+
else:
|
|
117
|
+
console.print(f"[red]Path does not exist:[/red] {path}")
|
|
118
|
+
raise typer.Exit(2)
|
|
119
|
+
|
|
84
120
|
engine = build_engine_for_suite(suite)
|
|
85
121
|
console.print(
|
|
86
|
-
f"dbression {__version__} —
|
|
122
|
+
f"dbression {__version__} — {kind}: [bold]{suite.name}[/bold] @ "
|
|
87
123
|
f"{engine.url.render_as_string(hide_password=True)}\n"
|
|
88
124
|
)
|
|
125
|
+
|
|
126
|
+
# Progress is on by default at a TTY; --progress / --no-progress override.
|
|
127
|
+
use_progress = console.is_terminal if progress is None else progress
|
|
128
|
+
total = count_fixtures(suite, tag_filter)
|
|
129
|
+
|
|
89
130
|
try:
|
|
90
|
-
|
|
131
|
+
if use_progress:
|
|
132
|
+
with make_progress(console) as prog:
|
|
133
|
+
task = prog.add_task("starting…", total=total or None)
|
|
134
|
+
observer = ProgressObserver(console, prog, task, details=details)
|
|
135
|
+
result = run_suite(
|
|
136
|
+
suite,
|
|
137
|
+
engine,
|
|
138
|
+
commit_mode=commit_mode, # type: ignore[arg-type]
|
|
139
|
+
tag_filter=tag_filter,
|
|
140
|
+
observer=observer,
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
# No live bar (piped / CI). Still stream colored detail lines if asked.
|
|
144
|
+
observer = ProgressObserver(console, details=details) if details else None
|
|
145
|
+
result = run_suite(
|
|
146
|
+
suite,
|
|
147
|
+
engine,
|
|
148
|
+
commit_mode=commit_mode, # type: ignore[arg-type]
|
|
149
|
+
tag_filter=tag_filter,
|
|
150
|
+
observer=observer,
|
|
151
|
+
)
|
|
91
152
|
finally:
|
|
92
153
|
engine.dispose()
|
|
93
154
|
print_suite_result(result, console, verbose=verbose)
|
|
@@ -41,6 +41,7 @@ def _maybe_init_oracle_thick() -> None:
|
|
|
41
41
|
_POSTGRES_ALIASES = {"postgres", "postgresql", "pg"}
|
|
42
42
|
_ORACLE_ALIASES = {"oracle"}
|
|
43
43
|
_MSSQL_ALIASES = {"sqlserver", "mssql", "ms-sql"}
|
|
44
|
+
_SQLITE_ALIASES = {"sqlite", "sqlite3"}
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def make_engine(environment: str, config: ConnectionConfig) -> Engine:
|
|
@@ -55,6 +56,8 @@ def make_engine(environment: str, config: ConnectionConfig) -> Engine:
|
|
|
55
56
|
url = _build_oracle_url(config, extra)
|
|
56
57
|
elif env in _MSSQL_ALIASES:
|
|
57
58
|
url = _build_mssql_url(config, extra)
|
|
59
|
+
elif env in _SQLITE_ALIASES:
|
|
60
|
+
url = _build_sqlite_url(config, extra)
|
|
58
61
|
else:
|
|
59
62
|
raise ValueError(f"Unknown DatabaseEnvironment: {environment!r}")
|
|
60
63
|
|
|
@@ -116,6 +119,30 @@ def _build_mssql_url(cfg: ConnectionConfig, extra: dict[str, str]) -> URL | str:
|
|
|
116
119
|
)
|
|
117
120
|
|
|
118
121
|
|
|
122
|
+
def _build_sqlite_url(cfg: ConnectionConfig, extra: dict[str, str]) -> URL | str:
|
|
123
|
+
"""SQLite via the Python stdlib ``sqlite3`` driver. No native deps.
|
|
124
|
+
|
|
125
|
+
Path source order:
|
|
126
|
+
|
|
127
|
+
* ``connection-string=sqlite:///…`` — taken verbatim
|
|
128
|
+
* ``service=:memory:`` — in-memory DB
|
|
129
|
+
* ``service=<file-path>`` — file-backed DB (absolute or relative)
|
|
130
|
+
* ``database=<file-path>`` (extras) — fallback alias
|
|
131
|
+
|
|
132
|
+
SQLite has no auth — ``username`` / ``password`` in connection.properties are
|
|
133
|
+
ignored. Stored procedures and `Execute Procedure` won't work (SQLite has none).
|
|
134
|
+
"""
|
|
135
|
+
if cfg.connection_string:
|
|
136
|
+
return cfg.connection_string
|
|
137
|
+
path = cfg.service or extra.get("database") or ":memory:"
|
|
138
|
+
# SQLAlchemy SQLite URLs:
|
|
139
|
+
# sqlite:///:memory: — in-memory
|
|
140
|
+
# sqlite:///relative.db — relative to cwd (3 slashes)
|
|
141
|
+
# sqlite:////abs/path.db — absolute (4 slashes; the leading `/` of the path
|
|
142
|
+
# concatenates with the 3-slash prefix)
|
|
143
|
+
return f"sqlite:///{path}"
|
|
144
|
+
|
|
145
|
+
|
|
119
146
|
def _split_host_port(service: str | None) -> tuple[str | None, int | None]:
|
|
120
147
|
if not service:
|
|
121
148
|
return None, None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from dbression.parser.ast import Directive, Page, Suite, Table
|
|
2
|
+
from dbression.parser.wiki import parse_suite, parse_test_file, parse_wiki
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"Directive",
|
|
6
|
+
"Page",
|
|
7
|
+
"Suite",
|
|
8
|
+
"Table",
|
|
9
|
+
"parse_suite",
|
|
10
|
+
"parse_test_file",
|
|
11
|
+
"parse_wiki",
|
|
12
|
+
]
|
|
@@ -203,3 +203,66 @@ def parse_suite(root: Path) -> Suite:
|
|
|
203
203
|
# Stable, deterministic order of test pages (alphabetical).
|
|
204
204
|
suite.pages.sort(key=lambda p: p.name)
|
|
205
205
|
return suite
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _discover_special_pages(root: Path) -> dict[str, Page]:
|
|
209
|
+
"""Parse only ``_root`` / ``SuiteSetUp`` / ``SuiteTearDown`` in `root` (md > wiki).
|
|
210
|
+
|
|
211
|
+
Used by single-file runs to pull in the surrounding directory's connection config
|
|
212
|
+
and setup/teardown without dragging in sibling test pages.
|
|
213
|
+
"""
|
|
214
|
+
from dbression.parser.markdown import MARKDOWN_TEST_SUFFIX, parse_markdown
|
|
215
|
+
|
|
216
|
+
wanted = {"_root", "SuiteSetUp", "SuiteTearDown"}
|
|
217
|
+
if not root.is_dir():
|
|
218
|
+
return {}
|
|
219
|
+
found: dict[str, tuple[Path, str]] = {}
|
|
220
|
+
for entry in sorted(root.iterdir()):
|
|
221
|
+
if entry.is_dir():
|
|
222
|
+
continue
|
|
223
|
+
if entry.name.endswith(MARKDOWN_TEST_SUFFIX):
|
|
224
|
+
pn = entry.name[: -len(MARKDOWN_TEST_SUFFIX)]
|
|
225
|
+
if pn in wanted:
|
|
226
|
+
found[pn] = (entry, "md")
|
|
227
|
+
elif entry.suffix == ".wiki":
|
|
228
|
+
pn = entry.stem
|
|
229
|
+
if pn in wanted:
|
|
230
|
+
found.setdefault(pn, (entry, "wiki"))
|
|
231
|
+
out: dict[str, Page] = {}
|
|
232
|
+
for pn, (p, fmt) in found.items():
|
|
233
|
+
out[pn] = parse_markdown(p) if fmt == "md" else parse_wiki(p)
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def parse_test_file(path: Path) -> Suite:
|
|
238
|
+
"""Parse a single test FILE (``.test.md`` or ``.wiki``) into a runnable one-page Suite.
|
|
239
|
+
|
|
240
|
+
DBFit semantics: a file is one **Test** (many fixtures/assertions); a directory is a
|
|
241
|
+
**Suite**. When a single test is run, the surrounding directory's ``SuiteSetUp`` /
|
|
242
|
+
``SuiteTearDown`` and ``_root`` (``DatabaseEnvironment`` + ``ConnectUsingFile``) are
|
|
243
|
+
included — just like opening a single page in the DBFit web UI runs its SuiteSetUp.
|
|
244
|
+
|
|
245
|
+
A self-contained ``.test.md`` that carries its own ``<!-- dbression:env=… -->`` /
|
|
246
|
+
``<!-- dbression:connection=… -->`` directives runs standalone: those become the
|
|
247
|
+
suite's engine config (resolved relative to the file's directory).
|
|
248
|
+
"""
|
|
249
|
+
from dbression.parser.markdown import MARKDOWN_TEST_SUFFIX, parse_markdown
|
|
250
|
+
|
|
251
|
+
if not path.is_file():
|
|
252
|
+
raise ValueError(f"Not a file: {path}")
|
|
253
|
+
if path.name.endswith(MARKDOWN_TEST_SUFFIX):
|
|
254
|
+
page = parse_markdown(path)
|
|
255
|
+
elif path.suffix == ".wiki":
|
|
256
|
+
page = parse_wiki(path)
|
|
257
|
+
else:
|
|
258
|
+
raise ValueError(f"Not a runnable test file (expected .test.md or .wiki): {path}")
|
|
259
|
+
|
|
260
|
+
root = path.parent
|
|
261
|
+
suite = Suite(root=root, name=page.name, pages=[page])
|
|
262
|
+
specials = _discover_special_pages(root)
|
|
263
|
+
suite.setup = specials.get("SuiteSetUp")
|
|
264
|
+
suite.teardown = specials.get("SuiteTearDown")
|
|
265
|
+
# Engine config precedence: a real `_root` in the directory wins; otherwise the test
|
|
266
|
+
# file's own env/connection directives make it self-contained.
|
|
267
|
+
suite.root_page = specials.get("_root") or page
|
|
268
|
+
return suite
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
"""Reporters for SuiteResult data.
|
|
2
2
|
|
|
3
3
|
* `console` — pytest-style rich output (default CLI output)
|
|
4
|
+
* `progress` — live spinner + x/y counter + per-fixture detail lines (`--details`)
|
|
4
5
|
* `junit` — JUnit XML for Bitbucket Pipelines, Jenkins, GitLab, etc.
|
|
5
6
|
* `json_report` — JSON for LLM tooling, custom dashboards, diff analyses
|
|
6
7
|
"""
|
|
7
8
|
from dbression.report.console import print_suite_result
|
|
8
9
|
from dbression.report.json_report import write_json_report
|
|
9
10
|
from dbression.report.junit import write_junit_xml
|
|
11
|
+
from dbression.report.progress import ProgressObserver, make_progress
|
|
10
12
|
|
|
11
|
-
__all__ = [
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ProgressObserver",
|
|
15
|
+
"make_progress",
|
|
16
|
+
"print_suite_result",
|
|
17
|
+
"write_json_report",
|
|
18
|
+
"write_junit_xml",
|
|
19
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Live progress + optional per-fixture detail output for ``dbression run``.
|
|
2
|
+
|
|
3
|
+
Two things this module provides:
|
|
4
|
+
|
|
5
|
+
* :func:`make_progress` — a Rich ``Progress`` with a spinner, an ``x/y`` fixture counter,
|
|
6
|
+
the elapsed time, and a status line showing which fixture/query is currently running.
|
|
7
|
+
* :class:`ProgressObserver` — a :class:`~dbression.runner.RunObserver` that drives the
|
|
8
|
+
progress bar and, in ``--details`` mode, prints a colored pass/fail line per fixture
|
|
9
|
+
(DBFit-web-UI style) above the live bar.
|
|
10
|
+
|
|
11
|
+
The observer is deliberately defensive about Rich markup: fixture names and SQL contain
|
|
12
|
+
``[…]`` (MSSQL identifiers, array literals) which Rich would otherwise interpret as style
|
|
13
|
+
tags. All dynamic text is rendered through ``rich.text.Text`` (no markup parsing).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.progress import (
|
|
19
|
+
BarColumn,
|
|
20
|
+
MofNCompleteColumn,
|
|
21
|
+
Progress,
|
|
22
|
+
SpinnerColumn,
|
|
23
|
+
TextColumn,
|
|
24
|
+
TimeElapsedColumn,
|
|
25
|
+
)
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
|
|
28
|
+
from dbression.fixtures.base import FixtureResult
|
|
29
|
+
from dbression.parser.ast import Table
|
|
30
|
+
from dbression.runner import RunObserver
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_progress(console: Console) -> Progress:
|
|
34
|
+
"""Build the live progress display (spinner + status + x/y counter + elapsed)."""
|
|
35
|
+
return Progress(
|
|
36
|
+
SpinnerColumn(),
|
|
37
|
+
TextColumn("[progress.description]{task.description}"),
|
|
38
|
+
BarColumn(bar_width=None),
|
|
39
|
+
MofNCompleteColumn(),
|
|
40
|
+
TextColumn("fixtures"),
|
|
41
|
+
TimeElapsedColumn(),
|
|
42
|
+
console=console,
|
|
43
|
+
transient=True, # clear the bar when done so the summary stands alone
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fixture_preview(table: Table, maxlen: int = 72) -> str:
|
|
48
|
+
"""A one-line, whitespace-collapsed preview of what this fixture runs."""
|
|
49
|
+
text = " ".join(str(a) for a in table.header_args)
|
|
50
|
+
text = " ".join(text.split())
|
|
51
|
+
if len(text) > maxlen:
|
|
52
|
+
text = text[: maxlen - 1] + "…"
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ProgressObserver(RunObserver):
|
|
57
|
+
"""Drive a Rich ``Progress`` task and optionally print per-fixture detail lines.
|
|
58
|
+
|
|
59
|
+
Works in two modes:
|
|
60
|
+
|
|
61
|
+
* with a ``progress``/``task_id`` (TTY): updates the live bar and, if ``details``,
|
|
62
|
+
prints detail lines above it via the progress console.
|
|
63
|
+
* without a progress bar (non-TTY but ``details`` requested): prints detail lines
|
|
64
|
+
straight to ``console``.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
console: Console,
|
|
70
|
+
progress: Progress | None = None,
|
|
71
|
+
task_id: int | None = None,
|
|
72
|
+
*,
|
|
73
|
+
details: bool = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.console = console
|
|
76
|
+
self.progress = progress
|
|
77
|
+
self.task_id = task_id
|
|
78
|
+
self.details = details
|
|
79
|
+
|
|
80
|
+
def _out(self) -> Console:
|
|
81
|
+
return self.progress.console if self.progress is not None else self.console
|
|
82
|
+
|
|
83
|
+
def on_fixture_start(self, page_name: str, table: Table) -> None:
|
|
84
|
+
if self.progress is None or self.task_id is None:
|
|
85
|
+
return
|
|
86
|
+
preview = _fixture_preview(table)
|
|
87
|
+
label = f"{page_name} › {table.name}"
|
|
88
|
+
if preview:
|
|
89
|
+
label += f": {preview}"
|
|
90
|
+
# Description is rendered with markup; Text() keeps brackets literal.
|
|
91
|
+
self.progress.update(self.task_id, description=Text(label, style="cyan"))
|
|
92
|
+
|
|
93
|
+
def on_fixture_end(
|
|
94
|
+
self, page_name: str, table: Table, result: FixtureResult, duration: float
|
|
95
|
+
) -> None:
|
|
96
|
+
if self.progress is not None and self.task_id is not None:
|
|
97
|
+
self.progress.advance(self.task_id)
|
|
98
|
+
if not self.details:
|
|
99
|
+
return
|
|
100
|
+
line = Text()
|
|
101
|
+
if result.passed:
|
|
102
|
+
line.append("✓ ", style="bold green")
|
|
103
|
+
else:
|
|
104
|
+
line.append("✗ ", style="bold red")
|
|
105
|
+
line.append(page_name, style="bold")
|
|
106
|
+
line.append(" › ")
|
|
107
|
+
line.append(table.name, style="" if result.passed else "red")
|
|
108
|
+
line.append(f" {duration * 1000:.0f}ms", style="dim")
|
|
109
|
+
self._out().print(line)
|
|
110
|
+
if result.message:
|
|
111
|
+
self._out().print(
|
|
112
|
+
Text(" " + result.message, style="dim" if result.passed else "red")
|
|
113
|
+
)
|
|
@@ -18,6 +18,23 @@ from dbression.symbols import SymbolTable
|
|
|
18
18
|
CommitMode = Literal["test", "page"]
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class RunObserver:
|
|
22
|
+
"""Hook for live run feedback. Default methods are no-ops — subclass to react.
|
|
23
|
+
|
|
24
|
+
The runner calls ``on_fixture_start`` immediately before a fixture table is evaluated
|
|
25
|
+
and ``on_fixture_end`` once its result is known, for every fixture that actually runs
|
|
26
|
+
(suite directives like ``DatabaseEnvironment`` are skipped, matching ``count_fixtures``).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def on_fixture_start(self, page_name: str, table: Table) -> None: # noqa: D401
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def on_fixture_end(
|
|
33
|
+
self, page_name: str, table: Table, result: FixtureResult, duration: float
|
|
34
|
+
) -> None:
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
|
|
21
38
|
@dataclass(slots=True)
|
|
22
39
|
class TableResult:
|
|
23
40
|
name: str
|
|
@@ -70,6 +87,31 @@ def _is_suite_directive(table: Table) -> bool:
|
|
|
70
87
|
return norm in _SUITE_DIRECTIVE_FIXTURES
|
|
71
88
|
|
|
72
89
|
|
|
90
|
+
def _count_page_fixtures(page: Page | None) -> int:
|
|
91
|
+
if page is None:
|
|
92
|
+
return 0
|
|
93
|
+
return sum(1 for t in page.tables if not _is_suite_directive(t))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def count_fixtures(suite: Suite, tag_filter: "TagFilter | None" = None) -> int:
|
|
97
|
+
"""Count the fixtures that will actually run — for an ``x/y`` progress total.
|
|
98
|
+
|
|
99
|
+
Mirrors what ``_run_page_in_tx`` executes: SuiteSetUp + tag-allowed test pages +
|
|
100
|
+
SuiteTearDown, recursively through sub-suites, skipping suite-directive tables.
|
|
101
|
+
May slightly overcount when a page or setup fails early (remaining fixtures are then
|
|
102
|
+
skipped) — that only means the bar stops short of 100%, which is itself informative.
|
|
103
|
+
"""
|
|
104
|
+
n = _count_page_fixtures(suite.setup)
|
|
105
|
+
for page in suite.pages:
|
|
106
|
+
if tag_filter is not None and not tag_filter.page_allowed(page):
|
|
107
|
+
continue
|
|
108
|
+
n += _count_page_fixtures(page)
|
|
109
|
+
n += _count_page_fixtures(suite.teardown)
|
|
110
|
+
for sub in suite.subsuites:
|
|
111
|
+
n += count_fixtures(sub, tag_filter)
|
|
112
|
+
return n
|
|
113
|
+
|
|
114
|
+
|
|
73
115
|
def _scan_engine_config(page: Page | None) -> tuple[str | None, str | None]:
|
|
74
116
|
"""Scan a page for `DatabaseEnvironment` and `ConnectUsingFile` directives."""
|
|
75
117
|
if page is None:
|
|
@@ -164,6 +206,7 @@ def run_suite(
|
|
|
164
206
|
symbols: SymbolTable | None = None,
|
|
165
207
|
tag_filter: TagFilter | None = None,
|
|
166
208
|
stored: dict[str, StoredQuery] | None = None,
|
|
209
|
+
observer: RunObserver | None = None,
|
|
167
210
|
) -> SuiteResult:
|
|
168
211
|
"""Run a suite recursively (including sub-suites).
|
|
169
212
|
|
|
@@ -186,7 +229,7 @@ def run_suite(
|
|
|
186
229
|
# SuiteSetUp — runs inside the suite TX, no commit/rollback of its own.
|
|
187
230
|
if suite.setup is not None:
|
|
188
231
|
result.setup_result = _run_page_in_tx(
|
|
189
|
-
suite.setup, conn, symbols, stored, isolate=False
|
|
232
|
+
suite.setup, conn, symbols, stored, isolate=False, observer=observer
|
|
190
233
|
)
|
|
191
234
|
if not result.setup_result.passed:
|
|
192
235
|
result.error = f"SuiteSetUp failed: {result.setup_result.name}"
|
|
@@ -197,13 +240,15 @@ def run_suite(
|
|
|
197
240
|
if tag_filter is not None and not tag_filter.page_allowed(page):
|
|
198
241
|
continue
|
|
199
242
|
isolate = commit_mode == "test"
|
|
200
|
-
pr = _run_page_in_tx(
|
|
243
|
+
pr = _run_page_in_tx(
|
|
244
|
+
page, conn, symbols, stored, isolate=isolate, observer=observer
|
|
245
|
+
)
|
|
201
246
|
result.pages.append(pr)
|
|
202
247
|
|
|
203
248
|
# Sub-suites: recursive, with their own engine if directives differ.
|
|
204
249
|
for sub in suite.subsuites:
|
|
205
250
|
sub_res = _run_subsuite(
|
|
206
|
-
sub, engine, conn, commit_mode, symbols, tag_filter, stored
|
|
251
|
+
sub, engine, conn, commit_mode, symbols, tag_filter, stored, observer
|
|
207
252
|
)
|
|
208
253
|
result.subsuites.append(sub_res)
|
|
209
254
|
|
|
@@ -212,7 +257,7 @@ def run_suite(
|
|
|
212
257
|
# connection stays valid.
|
|
213
258
|
if suite.teardown is not None:
|
|
214
259
|
result.teardown_result = _run_page_in_tx(
|
|
215
|
-
suite.teardown, conn, symbols, stored, isolate=False
|
|
260
|
+
suite.teardown, conn, symbols, stored, isolate=False, observer=observer
|
|
216
261
|
)
|
|
217
262
|
finally:
|
|
218
263
|
# Defensive rollback of the entire suite TX. If TearDown already rolled
|
|
@@ -233,6 +278,7 @@ def _run_subsuite(
|
|
|
233
278
|
symbols: SymbolTable,
|
|
234
279
|
tag_filter: TagFilter | None = None,
|
|
235
280
|
stored: dict[str, StoredQuery] | None = None,
|
|
281
|
+
observer: RunObserver | None = None,
|
|
236
282
|
) -> SuiteResult:
|
|
237
283
|
"""Run a sub-suite. If it has its own engine directives, use a local engine; otherwise
|
|
238
284
|
run on the parent engine (with its own connection, for clean TX isolation).
|
|
@@ -251,11 +297,13 @@ def _run_subsuite(
|
|
|
251
297
|
except Exception as e:
|
|
252
298
|
return SuiteResult(name=sub.name, error=f"Engine build: {type(e).__name__}: {e}")
|
|
253
299
|
try:
|
|
254
|
-
return run_suite(
|
|
300
|
+
return run_suite(
|
|
301
|
+
sub, sub_engine, commit_mode, symbols, tag_filter, stored, observer
|
|
302
|
+
)
|
|
255
303
|
finally:
|
|
256
304
|
sub_engine.dispose()
|
|
257
305
|
|
|
258
|
-
return run_suite(sub, parent_engine, commit_mode, symbols, tag_filter, stored)
|
|
306
|
+
return run_suite(sub, parent_engine, commit_mode, symbols, tag_filter, stored, observer)
|
|
259
307
|
|
|
260
308
|
|
|
261
309
|
def _run_page_in_tx(
|
|
@@ -264,6 +312,7 @@ def _run_page_in_tx(
|
|
|
264
312
|
symbols: SymbolTable,
|
|
265
313
|
stored: dict[str, StoredQuery],
|
|
266
314
|
isolate: bool,
|
|
315
|
+
observer: RunObserver | None = None,
|
|
267
316
|
) -> PageResult:
|
|
268
317
|
"""Run all fixture tables of a page inside the existing suite TX.
|
|
269
318
|
|
|
@@ -278,6 +327,8 @@ def _run_page_in_tx(
|
|
|
278
327
|
for table in page.tables:
|
|
279
328
|
if _is_suite_directive(table):
|
|
280
329
|
continue
|
|
330
|
+
if observer is not None:
|
|
331
|
+
observer.on_fixture_start(page.name, table)
|
|
281
332
|
start = time.perf_counter()
|
|
282
333
|
fixture_cls = resolve_fixture(table.name)
|
|
283
334
|
if fixture_cls is None:
|
|
@@ -294,9 +345,10 @@ def _run_page_in_tx(
|
|
|
294
345
|
message=f"Fixture crash: {type(e).__name__}",
|
|
295
346
|
details=str(e),
|
|
296
347
|
)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
348
|
+
duration = time.perf_counter() - start
|
|
349
|
+
pr.tables.append(TableResult(name=table.name, result=res, duration=duration))
|
|
350
|
+
if observer is not None:
|
|
351
|
+
observer.on_fixture_end(page.name, table, res, duration)
|
|
300
352
|
if not res.passed:
|
|
301
353
|
if savepoint is not None and savepoint.is_active:
|
|
302
354
|
savepoint.rollback()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Tests for the run observer, fixture counting, single-file runs, and the progress
|
|
2
|
+
observer — all against in-memory SQLite (no server needed)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from dbression.fixtures.base import FixtureResult
|
|
11
|
+
from dbression.parser import parse_suite, parse_test_file
|
|
12
|
+
from dbression.parser.ast import Table
|
|
13
|
+
from dbression.report.progress import ProgressObserver, _fixture_preview, make_progress
|
|
14
|
+
from dbression.runner import (
|
|
15
|
+
RunObserver,
|
|
16
|
+
TagFilter,
|
|
17
|
+
build_engine_for_suite,
|
|
18
|
+
count_fixtures,
|
|
19
|
+
run_suite,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def demo_suite(tmp_path: Path) -> Path:
|
|
25
|
+
(tmp_path / "connection.properties").write_text("service=:memory:\n")
|
|
26
|
+
(tmp_path / "_root.wiki").write_text(
|
|
27
|
+
"!|DatabaseEnvironment|sqlite|\n|ConnectUsingFile|connection.properties|\n"
|
|
28
|
+
)
|
|
29
|
+
(tmp_path / "SuiteSetUp.test.md").write_text(
|
|
30
|
+
"# Setup\n\n### Execute\n```sql\ncreate table t (n int)\n```\n\n"
|
|
31
|
+
"### Execute\n```sql\ninsert into t values (1),(2),(3)\n```\n"
|
|
32
|
+
)
|
|
33
|
+
(tmp_path / "CountTest.test.md").write_text(
|
|
34
|
+
"# Count\n\n### Query\n```sql\nselect count(*) as c from t\n```\n\n| c |\n|---|\n| 3 |\n"
|
|
35
|
+
)
|
|
36
|
+
return tmp_path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_count_fixtures_matches_observed(demo_suite: Path) -> None:
|
|
40
|
+
suite = parse_suite(demo_suite)
|
|
41
|
+
total = count_fixtures(suite, TagFilter())
|
|
42
|
+
# 2 setup Execute + 1 page Query = 3 (DatabaseEnvironment/ConnectUsingFile excluded)
|
|
43
|
+
assert total == 3
|
|
44
|
+
|
|
45
|
+
class Recorder(RunObserver):
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.started: list[str] = []
|
|
48
|
+
self.ended: list[bool] = []
|
|
49
|
+
|
|
50
|
+
def on_fixture_start(self, page_name: str, table: Table) -> None:
|
|
51
|
+
self.started.append(f"{page_name}:{table.name}")
|
|
52
|
+
|
|
53
|
+
def on_fixture_end(self, page_name, table, result, duration) -> None:
|
|
54
|
+
self.ended.append(result.passed)
|
|
55
|
+
|
|
56
|
+
rec = Recorder()
|
|
57
|
+
engine = build_engine_for_suite(suite)
|
|
58
|
+
try:
|
|
59
|
+
result = run_suite(suite, engine, observer=rec)
|
|
60
|
+
finally:
|
|
61
|
+
engine.dispose()
|
|
62
|
+
|
|
63
|
+
assert result.passed_count == 1
|
|
64
|
+
assert len(rec.started) == total
|
|
65
|
+
assert len(rec.ended) == total
|
|
66
|
+
assert all(rec.ended)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_single_file_run_pulls_in_setup(demo_suite: Path) -> None:
|
|
70
|
+
"""Running just the test file must still run SuiteSetUp + use _root connection."""
|
|
71
|
+
suite = parse_test_file(demo_suite / "CountTest.test.md")
|
|
72
|
+
assert suite.name == "CountTest"
|
|
73
|
+
assert suite.setup is not None # SuiteSetUp pulled in
|
|
74
|
+
assert len(suite.pages) == 1 # exactly one test, no siblings
|
|
75
|
+
|
|
76
|
+
engine = build_engine_for_suite(suite)
|
|
77
|
+
try:
|
|
78
|
+
result = run_suite(suite, engine)
|
|
79
|
+
finally:
|
|
80
|
+
engine.dispose()
|
|
81
|
+
assert result.passed_count == 1, result.error
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_self_contained_markdown_file(tmp_path: Path) -> None:
|
|
85
|
+
(tmp_path / "connection.properties").write_text("service=:memory:\n")
|
|
86
|
+
f = tmp_path / "Solo.test.md"
|
|
87
|
+
f.write_text(
|
|
88
|
+
"# Solo\n\n<!-- dbression:env=sqlite -->\n"
|
|
89
|
+
"<!-- dbression:connection=connection.properties -->\n\n"
|
|
90
|
+
"### Query\n```sql\nselect 1 as n\n```\n\n| n |\n|---|\n| 1 |\n"
|
|
91
|
+
)
|
|
92
|
+
suite = parse_test_file(f)
|
|
93
|
+
assert suite.setup is None # no SuiteSetUp in dir
|
|
94
|
+
engine = build_engine_for_suite(suite) # config comes from the file's own directives
|
|
95
|
+
try:
|
|
96
|
+
result = run_suite(suite, engine)
|
|
97
|
+
finally:
|
|
98
|
+
engine.dispose()
|
|
99
|
+
assert result.passed_count == 1, result.error
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_parse_test_file_rejects_unknown_extension(tmp_path: Path) -> None:
|
|
103
|
+
bad = tmp_path / "notes.txt"
|
|
104
|
+
bad.write_text("hello")
|
|
105
|
+
with pytest.raises(ValueError):
|
|
106
|
+
parse_test_file(bad)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_fixture_preview_collapses_and_truncates() -> None:
|
|
110
|
+
t = Table(name="Query", header_args=["select *\n from foo"])
|
|
111
|
+
assert _fixture_preview(t) == "select * from foo"
|
|
112
|
+
long = Table(name="Query", header_args=["x" * 200])
|
|
113
|
+
assert len(_fixture_preview(long)) <= 72
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_progress_observer_handles_bracket_identifiers(demo_suite: Path) -> None:
|
|
117
|
+
"""A fixture name/SQL with [brackets] must not break Rich markup parsing."""
|
|
118
|
+
console = Console(force_terminal=True, width=100)
|
|
119
|
+
with make_progress(console) as prog:
|
|
120
|
+
task = prog.add_task("…", total=2)
|
|
121
|
+
obs = ProgressObserver(console, prog, task, details=True)
|
|
122
|
+
tbl = Table(name="Query", header_args=["select * from [dbo].[ORDER]"])
|
|
123
|
+
obs.on_fixture_start("PageA", tbl)
|
|
124
|
+
obs.on_fixture_end("PageA", tbl, FixtureResult(passed=True, message="OK [x]"), 0.01)
|
|
125
|
+
obs.on_fixture_end(
|
|
126
|
+
"PageA", tbl, FixtureResult(passed=False, message="boom [y]"), 0.02
|
|
127
|
+
)
|
|
128
|
+
# No exception == markup-safe.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""SQLite engine smoke test — confirm the DatabaseEnvironment dispatch and end-to-end
|
|
2
|
+
Query / Execute against an in-memory SQLite DB."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import text
|
|
6
|
+
|
|
7
|
+
from dbression.db.connection import ConnectionConfig
|
|
8
|
+
from dbression.db.engine import make_engine
|
|
9
|
+
from dbression.fixtures import resolve_fixture
|
|
10
|
+
from dbression.fixtures.base import FixtureContext
|
|
11
|
+
from dbression.parser.ast import Table
|
|
12
|
+
from dbression.symbols import SymbolTable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ctx(conn):
|
|
16
|
+
return FixtureContext(conn=conn, symbols=SymbolTable(), stored={})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_sqlite_memory_via_database_environment():
|
|
20
|
+
cfg = ConnectionConfig(service=":memory:")
|
|
21
|
+
eng = make_engine("sqlite", cfg)
|
|
22
|
+
with eng.connect() as c:
|
|
23
|
+
assert c.execute(text("select 1")).scalar() == 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_sqlite_file_path_url_shape(tmp_path):
|
|
27
|
+
db = tmp_path / "x.db"
|
|
28
|
+
cfg = ConnectionConfig(service=str(db))
|
|
29
|
+
eng = make_engine("sqlite", cfg)
|
|
30
|
+
# Absolute path → 4 slashes
|
|
31
|
+
assert str(eng.url).startswith("sqlite:////")
|
|
32
|
+
with eng.connect() as c:
|
|
33
|
+
c.execute(text("create table t (a int)"))
|
|
34
|
+
c.execute(text("insert into t values (42)"))
|
|
35
|
+
assert c.execute(text("select a from t")).scalar() == 42
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_sqlite_runs_query_fixture():
|
|
39
|
+
cfg = ConnectionConfig(service=":memory:")
|
|
40
|
+
eng = make_engine("sqlite", cfg)
|
|
41
|
+
with eng.begin() as conn:
|
|
42
|
+
conn.execute(text("create table t (n int, name text)"))
|
|
43
|
+
conn.execute(text("insert into t values (1, 'a'), (2, 'b')"))
|
|
44
|
+
with eng.connect() as conn:
|
|
45
|
+
cls = resolve_fixture("Query")
|
|
46
|
+
tbl = Table(
|
|
47
|
+
name="Query",
|
|
48
|
+
header_args=["select n, name from t order by n"],
|
|
49
|
+
headers=["n", "name"],
|
|
50
|
+
rows=[["1", "a"], ["2", "b"]],
|
|
51
|
+
)
|
|
52
|
+
res = cls().run(tbl, _ctx(conn))
|
|
53
|
+
assert res.passed, res.message
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_sqlite_alias_sqlite3_also_works():
|
|
57
|
+
cfg = ConnectionConfig(service=":memory:")
|
|
58
|
+
eng = make_engine("sqlite3", cfg)
|
|
59
|
+
with eng.connect() as c:
|
|
60
|
+
assert c.execute(text("select 1")).scalar() == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|