dbression 0.2.0__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.
Files changed (66) hide show
  1. {dbression-0.2.0 → dbression-0.3.0}/CHANGELOG.md +22 -4
  2. {dbression-0.2.0 → dbression-0.3.0}/PKG-INFO +13 -38
  3. {dbression-0.2.0 → dbression-0.3.0}/README.md +12 -37
  4. {dbression-0.2.0 → dbression-0.3.0}/pyproject.toml +1 -1
  5. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/cli.py +72 -11
  6. dbression-0.3.0/src/dbression/parser/__init__.py +12 -0
  7. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/parser/wiki.py +63 -0
  8. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/report/__init__.py +9 -1
  9. dbression-0.3.0/src/dbression/report/progress.py +113 -0
  10. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/runner.py +61 -9
  11. dbression-0.3.0/tests/test_progress_and_single_file.py +128 -0
  12. {dbression-0.2.0 → dbression-0.3.0}/uv.lock +1 -1
  13. dbression-0.2.0/src/dbression/parser/__init__.py +0 -4
  14. {dbression-0.2.0 → dbression-0.3.0}/.github/workflows/ci.yml +0 -0
  15. {dbression-0.2.0 → dbression-0.3.0}/.github/workflows/publish.yml +0 -0
  16. {dbression-0.2.0 → dbression-0.3.0}/.gitignore +0 -0
  17. {dbression-0.2.0 → dbression-0.3.0}/.python-version +0 -0
  18. {dbression-0.2.0 → dbression-0.3.0}/LICENSE +0 -0
  19. {dbression-0.2.0 → dbression-0.3.0}/docs/dbression_head.png +0 -0
  20. {dbression-0.2.0 → dbression-0.3.0}/examples/01-hello/HelloSql.test.md +0 -0
  21. {dbression-0.2.0 → dbression-0.3.0}/examples/01-hello/_root.wiki +0 -0
  22. {dbression-0.2.0 → dbression-0.3.0}/examples/01-hello/connection.properties +0 -0
  23. {dbression-0.2.0 → dbression-0.3.0}/examples/02-stored-procedure/BumpCounterTest.test.md +0 -0
  24. {dbression-0.2.0 → dbression-0.3.0}/examples/02-stored-procedure/SuiteSetUp.test.md +0 -0
  25. {dbression-0.2.0 → dbression-0.3.0}/examples/02-stored-procedure/SuiteTearDown.test.md +0 -0
  26. {dbression-0.2.0 → dbression-0.3.0}/examples/02-stored-procedure/_root.wiki +0 -0
  27. {dbression-0.2.0 → dbression-0.3.0}/examples/02-stored-procedure/connection.properties +0 -0
  28. {dbression-0.2.0 → dbression-0.3.0}/examples/03-schema-drift/SchemaSnapshotTest.test.md +0 -0
  29. {dbression-0.2.0 → dbression-0.3.0}/examples/03-schema-drift/SuiteSetUp.test.md +0 -0
  30. {dbression-0.2.0 → dbression-0.3.0}/examples/03-schema-drift/SuiteTearDown.test.md +0 -0
  31. {dbression-0.2.0 → dbression-0.3.0}/examples/03-schema-drift/_root.wiki +0 -0
  32. {dbression-0.2.0 → dbression-0.3.0}/examples/03-schema-drift/connection.properties +0 -0
  33. {dbression-0.2.0 → dbression-0.3.0}/examples/README.md +0 -0
  34. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/__init__.py +0 -0
  35. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/db/__init__.py +0 -0
  36. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/db/connection.py +0 -0
  37. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/db/engine.py +0 -0
  38. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/db/errors.py +0 -0
  39. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/fixtures/__init__.py +0 -0
  40. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/fixtures/base.py +0 -0
  41. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/fixtures/basic.py +0 -0
  42. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/fixtures/inspect_and_store.py +0 -0
  43. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/fixtures/plugins.py +0 -0
  44. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/fixtures/suite_fixtures.py +0 -0
  45. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/parser/ast.py +0 -0
  46. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/parser/markdown.py +0 -0
  47. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/parser/markdown_writer.py +0 -0
  48. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/parser/tokenizer.py +0 -0
  49. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/report/console.py +0 -0
  50. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/report/json_report.py +0 -0
  51. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/report/junit.py +0 -0
  52. {dbression-0.2.0 → dbression-0.3.0}/src/dbression/symbols.py +0 -0
  53. {dbression-0.2.0 → dbression-0.3.0}/tests/conftest.py +0 -0
  54. {dbression-0.2.0 → dbression-0.3.0}/tests/test_connection.py +0 -0
  55. {dbression-0.2.0 → dbression-0.3.0}/tests/test_markdown_parser.py +0 -0
  56. {dbression-0.2.0 → dbression-0.3.0}/tests/test_markdown_roundtrip.py +0 -0
  57. {dbression-0.2.0 → dbression-0.3.0}/tests/test_parser.py +0 -0
  58. {dbression-0.2.0 → dbression-0.3.0}/tests/test_phase2_fixtures.py +0 -0
  59. {dbression-0.2.0 → dbression-0.3.0}/tests/test_plugins.py +0 -0
  60. {dbression-0.2.0 → dbression-0.3.0}/tests/test_postgres_live.py +0 -0
  61. {dbression-0.2.0 → dbression-0.3.0}/tests/test_report_junit.py +0 -0
  62. {dbression-0.2.0 → dbression-0.3.0}/tests/test_smoke.py +0 -0
  63. {dbression-0.2.0 → dbression-0.3.0}/tests/test_sqlite_engine.py +0 -0
  64. {dbression-0.2.0 → dbression-0.3.0}/tests/test_symbols.py +0 -0
  65. {dbression-0.2.0 → dbression-0.3.0}/tests/test_tokenizer.py +0 -0
  66. {dbression-0.2.0 → dbression-0.3.0}/tests/test_update_fixture.py +0 -0
@@ -7,6 +7,23 @@ 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
+
10
27
  ## [0.2.0] — 2026-06-01
11
28
 
12
29
  ### Added
@@ -21,15 +38,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
21
38
 
22
39
  ## [0.1.2] — 2026-06-01
23
40
 
24
- SQL Server live-verification release. A real-world MSSQL DBFit suite (WD-Gehsteig
25
- regression tests, stored-procedure-driven, square-bracket identifiers throughout)
41
+ SQL Server live-verification release. A real-world MSSQL DBFit suite (regression tests,
42
+ stored-procedure-driven, square-bracket identifiers throughout)
26
43
  runs end-to-end against an MSSQL Server 2019 via `pymssql` with no changes to the
27
44
  underlying `.wiki` files.
28
45
 
29
46
  ### Fixed
30
47
 
31
48
  - **Console reporter swallowed `[…]` in failure details.** Rich's inline-markup
32
- parser interpreted MSSQL square-bracket identifiers like `[wdg].PLAENE` as
49
+ parser interpreted MSSQL square-bracket identifiers like `[dbo].ORDER` as
33
50
  style tags and stripped them from the displayed SQL — making failure output
34
51
  misleading. Reporter now prints fixture details with `markup=False`, so the
35
52
  SQL you see is the SQL that ran.
@@ -132,7 +149,8 @@ with code paths in place for SQL Server and Oracle.
132
149
  reject `python-oracledb` thin-mode authentication. Configuration is via
133
150
  `DBRESSION_ORACLE_CLIENT_LIB_DIR`.
134
151
 
135
- [Unreleased]: https://github.com/angrydat/dbression/compare/v0.2.0...HEAD
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
136
154
  [0.2.0]: https://github.com/angrydat/dbression/releases/tag/v0.2.0
137
155
  [0.1.2]: https://github.com/angrydat/dbression/releases/tag/v0.1.2
138
156
  [0.1.1]: https://github.com/angrydat/dbression/releases/tag/v0.1.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbression
3
- Version: 0.2.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
@@ -52,19 +52,19 @@ anywhere. `dbression` is a Python re-implementation in the spirit of the fantast
52
52
 
53
53
  ```text
54
54
  $ dbression run tests/
55
- dbression 0.2.0 — Suite: tests @ postgresql+psycopg://wlk:***@db01/wlk
55
+ dbression 0.3.0 — Suite: tests @ postgresql+psycopg://foo:***@db01/bar
56
56
 
57
57
  ✓ HelloSql 0.004s
58
58
  CommonSuite/
59
- MerklisteSuite/
59
+ ChangelistSuite/
60
60
  ✓ AAddBasicTest 0.027s
61
61
  ✓ BAddNormalizationTest 0.029s
62
62
  ✓ CAddWhitelistTest 0.025s
63
63
  ✓ DAddInvalidArgsTest 0.014s
64
64
  ✓ ERemTest 0.052s
65
- FViewWbTest 1.236s
66
- EreignisSuite/
67
- WaldbrandSuite/
65
+ FViewTest 1.236s
66
+ EventsSuite/
67
+ FireSuite/
68
68
  ✓ LookupTest 0.157s
69
69
  ✗ SchemaTest 0.013s
70
70
 
@@ -277,7 +277,10 @@ dbression:
277
277
  ## CLI cheat sheet
278
278
 
279
279
  ```bash
280
- 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
281
284
  dbression run <path> -v # show every fixture table, not just the page line
282
285
  dbression run <path> --tag critical # only run pages tagged `critical`
283
286
  dbression run <path> --skip-tag NotOnCI # skip pages tagged `NotOnCI`
@@ -289,37 +292,9 @@ dbression convert file.wiki -o out.test.md # single file with explicit output
289
292
  dbression version
290
293
  ```
291
294
 
292
- ## How it works
293
-
294
- ```
295
- ┌─────────────────────────────────────────────────────────┐
296
- │ CLI (typer) dbression run tests/ [--flags] │
297
- └─────────────────────────────────────────────────────────┘
298
-
299
-
300
- ┌─────────────────────┐ ┌───────────────────────────────┐
301
- │ Parser │ │ Engine Factory (SQLAlchemy) │
302
- │ Wiki → AST │ │ ┌──────────┐ ┌──────────┐ │
303
- │ • !- -! escapes │ │ │ psycopg │ │ oracledb │ │
304
- │ • q'~ ~' quoting │ │ │ (Postgres│ │ (thin) │ │
305
- │ • Nested suites │ │ └──────────┘ └──────────┘ │
306
- │ • YAML front matter│ │ ┌──────────────────────┐ │
307
- └─────────┬───────────┘ │ │ pymssql (SQL Server) │ │
308
- │ │ └──────────────────────┘ │
309
- ▼ └───────────┬───────────────────┘
310
- ┌─────────────────────────────────────┴───────────────────┐
311
- │ Runner — one TX per suite, savepoints per test │
312
- │ Symbol engine: >>capture, <<read, :bind, _:text-subst │
313
- │ Fixture registry — pluggable via decorator │
314
- └─────────────────────┬───────────────────────────────────┘
315
-
316
- ┌─────────────────────────────────────────────────────────┐
317
- │ Reporters │
318
- │ • Rich console (default) │
319
- │ • JUnit XML (--junit-xml) │
320
- │ • JSON (--json) │
321
- └─────────────────────────────────────────────────────────┘
322
- ```
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`.
323
298
 
324
299
  ## Status
325
300
 
@@ -15,19 +15,19 @@ anywhere. `dbression` is a Python re-implementation in the spirit of the fantast
15
15
 
16
16
  ```text
17
17
  $ dbression run tests/
18
- dbression 0.2.0 — Suite: tests @ postgresql+psycopg://wlk:***@db01/wlk
18
+ dbression 0.3.0 — Suite: tests @ postgresql+psycopg://foo:***@db01/bar
19
19
 
20
20
  ✓ HelloSql 0.004s
21
21
  CommonSuite/
22
- MerklisteSuite/
22
+ ChangelistSuite/
23
23
  ✓ AAddBasicTest 0.027s
24
24
  ✓ BAddNormalizationTest 0.029s
25
25
  ✓ CAddWhitelistTest 0.025s
26
26
  ✓ DAddInvalidArgsTest 0.014s
27
27
  ✓ ERemTest 0.052s
28
- FViewWbTest 1.236s
29
- EreignisSuite/
30
- WaldbrandSuite/
28
+ FViewTest 1.236s
29
+ EventsSuite/
30
+ FireSuite/
31
31
  ✓ LookupTest 0.157s
32
32
  ✗ SchemaTest 0.013s
33
33
 
@@ -240,7 +240,10 @@ dbression:
240
240
  ## CLI cheat sheet
241
241
 
242
242
  ```bash
243
- 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
244
247
  dbression run <path> -v # show every fixture table, not just the page line
245
248
  dbression run <path> --tag critical # only run pages tagged `critical`
246
249
  dbression run <path> --skip-tag NotOnCI # skip pages tagged `NotOnCI`
@@ -252,37 +255,9 @@ dbression convert file.wiki -o out.test.md # single file with explicit output
252
255
  dbression version
253
256
  ```
254
257
 
255
- ## How it works
256
-
257
- ```
258
- ┌─────────────────────────────────────────────────────────┐
259
- │ CLI (typer) dbression run tests/ [--flags] │
260
- └─────────────────────────────────────────────────────────┘
261
-
262
-
263
- ┌─────────────────────┐ ┌───────────────────────────────┐
264
- │ Parser │ │ Engine Factory (SQLAlchemy) │
265
- │ Wiki → AST │ │ ┌──────────┐ ┌──────────┐ │
266
- │ • !- -! escapes │ │ │ psycopg │ │ oracledb │ │
267
- │ • q'~ ~' quoting │ │ │ (Postgres│ │ (thin) │ │
268
- │ • Nested suites │ │ └──────────┘ └──────────┘ │
269
- │ • YAML front matter│ │ ┌──────────────────────┐ │
270
- └─────────┬───────────┘ │ │ pymssql (SQL Server) │ │
271
- │ │ └──────────────────────┘ │
272
- ▼ └───────────┬───────────────────┘
273
- ┌─────────────────────────────────────┴───────────────────┐
274
- │ Runner — one TX per suite, savepoints per test │
275
- │ Symbol engine: >>capture, <<read, :bind, _:text-subst │
276
- │ Fixture registry — pluggable via decorator │
277
- └─────────────────────┬───────────────────────────────────┘
278
-
279
- ┌─────────────────────────────────────────────────────────┐
280
- │ Reporters │
281
- │ • Rich console (default) │
282
- │ • JUnit XML (--junit-xml) │
283
- │ • JSON (--json) │
284
- └─────────────────────────────────────────────────────────┘
285
- ```
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`.
286
261
 
287
262
  ## Status
288
263
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbression"
3
- version = "0.2.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" }
@@ -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 print_suite_result, write_json_report, write_junit_xml
15
- from dbression.runner import TagFilter, build_engine_for_suite, run_suite
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[Path, typer.Argument(help="Path to the suite (directory containing _root.wiki)")],
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 and produce pytest-style reporting."""
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
- suite = parse_suite(path)
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__} — Suite: [bold]{suite.name}[/bold] @ "
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
- result = run_suite(suite, engine, commit_mode=commit_mode, tag_filter=tag_filter) # type: ignore[arg-type]
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)
@@ -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__ = ["print_suite_result", "write_json_report", "write_junit_xml"]
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(page, conn, symbols, stored, isolate=isolate)
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(sub, sub_engine, commit_mode, symbols, tag_filter, stored)
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
- pr.tables.append(
298
- TableResult(name=table.name, result=res, duration=time.perf_counter() - start)
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.
@@ -132,7 +132,7 @@ wheels = [
132
132
 
133
133
  [[package]]
134
134
  name = "dbression"
135
- version = "0.2.0"
135
+ version = "0.3.0"
136
136
  source = { editable = "." }
137
137
  dependencies = [
138
138
  { name = "markdown-it-py" },
@@ -1,4 +0,0 @@
1
- from dbression.parser.ast import Directive, Page, Suite, Table
2
- from dbression.parser.wiki import parse_suite, parse_wiki
3
-
4
- __all__ = ["Directive", "Page", "Suite", "Table", "parse_suite", "parse_wiki"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes