dbression 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- dbression/cli.py +173 -0
- dbression/db/__init__.py +12 -0
- dbression/db/connection.py +104 -0
- dbression/db/engine.py +131 -0
- dbression/db/errors.py +63 -0
- dbression/fixtures/__init__.py +32 -0
- dbression/fixtures/base.py +89 -0
- dbression/fixtures/basic.py +628 -0
- dbression/fixtures/inspect_and_store.py +292 -0
- dbression/fixtures/plugins.py +92 -0
- dbression/fixtures/suite_fixtures.py +28 -0
- dbression/parser/__init__.py +4 -0
- dbression/parser/ast.py +66 -0
- dbression/parser/markdown.py +281 -0
- dbression/parser/markdown_writer.py +84 -0
- dbression/parser/tokenizer.py +208 -0
- dbression/parser/wiki.py +204 -0
- dbression/report/__init__.py +11 -0
- dbression/report/console.py +103 -0
- dbression/report/json_report.py +126 -0
- dbression/report/junit.py +188 -0
- dbression/runner.py +310 -0
- dbression/symbols.py +138 -0
- dbression-0.1.0.dist-info/METADATA +390 -0
- dbression-0.1.0.dist-info/RECORD +29 -0
- dbression-0.1.0.dist-info/WHEEL +4 -0
- dbression-0.1.0.dist-info/entry_points.txt +2 -0
- dbression-0.1.0.dist-info/licenses/LICENSE +21 -0
dbression/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
dbression/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from dbression import __version__
|
|
11
|
+
from dbression.parser import parse_suite
|
|
12
|
+
from dbression.parser.markdown_writer import page_to_markdown
|
|
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
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="dbression",
|
|
19
|
+
help="Modern Python port of DBFit — regression tests for database schemas and business logic.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
add_completion=False,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback()
|
|
28
|
+
def _main_callback() -> None:
|
|
29
|
+
"""Force multi-command mode so `dbression <subcommand>` works."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command()
|
|
33
|
+
def version() -> None:
|
|
34
|
+
"""Print the installed dbression version."""
|
|
35
|
+
console.print(f"dbression {__version__}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def run(
|
|
40
|
+
path: Annotated[Path, typer.Argument(help="Path to the suite (directory containing _root.wiki)")],
|
|
41
|
+
commit_mode: Annotated[
|
|
42
|
+
str,
|
|
43
|
+
typer.Option(
|
|
44
|
+
"--commit-mode",
|
|
45
|
+
help="'test' = rollback per test (default), 'page' = commit per page (DBFit-compatible)",
|
|
46
|
+
),
|
|
47
|
+
] = "test",
|
|
48
|
+
verbose: Annotated[
|
|
49
|
+
bool, typer.Option("-v", "--verbose", help="Print every fixture table, not just the page line")
|
|
50
|
+
] = False,
|
|
51
|
+
tag: Annotated[
|
|
52
|
+
list[str] | None,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--tag",
|
|
55
|
+
help="Only run pages carrying this tag (front-matter `Suites:`); may be passed multiple times",
|
|
56
|
+
),
|
|
57
|
+
] = None,
|
|
58
|
+
skip_tag: Annotated[
|
|
59
|
+
list[str] | None,
|
|
60
|
+
typer.Option("--skip-tag", help="Skip pages carrying this tag; may be passed multiple times"),
|
|
61
|
+
] = None,
|
|
62
|
+
junit_xml: Annotated[
|
|
63
|
+
Path | None,
|
|
64
|
+
typer.Option("--junit-xml", help="Write a JUnit-XML report to this path (CI: Bitbucket/Jenkins)"),
|
|
65
|
+
] = None,
|
|
66
|
+
json_report: Annotated[
|
|
67
|
+
Path | None,
|
|
68
|
+
typer.Option("--json", help="Write a JSON report to this path (LLM / tooling)"),
|
|
69
|
+
] = None,
|
|
70
|
+
) -> 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)
|
|
75
|
+
if commit_mode not in ("test", "page"):
|
|
76
|
+
console.print(
|
|
77
|
+
f"[red]Invalid commit-mode: {commit_mode!r} (allowed: test, page)[/red]"
|
|
78
|
+
)
|
|
79
|
+
raise typer.Exit(2)
|
|
80
|
+
|
|
81
|
+
tag_filter = TagFilter(only=tuple(tag or ()), skip=tuple(skip_tag or ()))
|
|
82
|
+
|
|
83
|
+
suite = parse_suite(path)
|
|
84
|
+
engine = build_engine_for_suite(suite)
|
|
85
|
+
console.print(
|
|
86
|
+
f"dbression {__version__} — Suite: [bold]{suite.name}[/bold] @ "
|
|
87
|
+
f"{engine.url.render_as_string(hide_password=True)}\n"
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
result = run_suite(suite, engine, commit_mode=commit_mode, tag_filter=tag_filter) # type: ignore[arg-type]
|
|
91
|
+
finally:
|
|
92
|
+
engine.dispose()
|
|
93
|
+
print_suite_result(result, console, verbose=verbose)
|
|
94
|
+
|
|
95
|
+
if junit_xml is not None:
|
|
96
|
+
write_junit_xml(result, junit_xml)
|
|
97
|
+
console.print(f"[dim]JUnit-XML: {junit_xml}[/dim]")
|
|
98
|
+
if json_report is not None:
|
|
99
|
+
write_json_report(result, json_report)
|
|
100
|
+
console.print(f"[dim]JSON report: {json_report}[/dim]")
|
|
101
|
+
|
|
102
|
+
if result.error or result.failed_count > 0:
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def convert(
|
|
108
|
+
path: Annotated[Path, typer.Argument(help="Path to a .wiki file OR a directory")],
|
|
109
|
+
output: Annotated[
|
|
110
|
+
Path | None,
|
|
111
|
+
typer.Option(
|
|
112
|
+
"-o",
|
|
113
|
+
"--output",
|
|
114
|
+
help="Destination path (file or directory); default: in-place next to the source",
|
|
115
|
+
),
|
|
116
|
+
] = None,
|
|
117
|
+
force: Annotated[bool, typer.Option("-f", "--force", help="Overwrite existing .test.md")] = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Convert ``.wiki`` files (DBFit format) to ``.test.md`` (dbression's own format)."""
|
|
120
|
+
if not path.exists():
|
|
121
|
+
console.print(f"[red]Path does not exist:[/red] {path}")
|
|
122
|
+
raise typer.Exit(2)
|
|
123
|
+
|
|
124
|
+
wiki_files: list[Path] = []
|
|
125
|
+
if path.is_file():
|
|
126
|
+
if path.suffix != ".wiki":
|
|
127
|
+
console.print(f"[red]Expected a .wiki file, got:[/red] {path}")
|
|
128
|
+
raise typer.Exit(2)
|
|
129
|
+
wiki_files.append(path)
|
|
130
|
+
else:
|
|
131
|
+
wiki_files = sorted(path.rglob("*.wiki"))
|
|
132
|
+
if not wiki_files:
|
|
133
|
+
console.print(f"[yellow]No .wiki files found under[/yellow] {path}")
|
|
134
|
+
raise typer.Exit(0)
|
|
135
|
+
|
|
136
|
+
written = 0
|
|
137
|
+
skipped = 0
|
|
138
|
+
for wf in wiki_files:
|
|
139
|
+
target = _convert_target(wf, output, single_input=path.is_file())
|
|
140
|
+
if target.exists() and not force:
|
|
141
|
+
console.print(f"[yellow]⏭ exists, use --force to overwrite:[/yellow] {target}")
|
|
142
|
+
skipped += 1
|
|
143
|
+
continue
|
|
144
|
+
page = parse_wiki(wf)
|
|
145
|
+
md = page_to_markdown(page)
|
|
146
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
target.write_text(md, encoding="utf-8")
|
|
148
|
+
console.print(f"[green]✓[/green] {wf} → {target}")
|
|
149
|
+
written += 1
|
|
150
|
+
|
|
151
|
+
console.print(f"\n[bold]{written} file(s) written, {skipped} skipped[/bold]")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _convert_target(wiki_file: Path, output: Path | None, single_input: bool) -> Path:
|
|
155
|
+
"""Compute the destination path for a converted .wiki file."""
|
|
156
|
+
md_name = wiki_file.stem + ".test.md"
|
|
157
|
+
if output is None:
|
|
158
|
+
return wiki_file.parent / md_name
|
|
159
|
+
if single_input:
|
|
160
|
+
# output is either a file or a directory
|
|
161
|
+
if output.suffix == ".md" or output.name.endswith(".test.md"):
|
|
162
|
+
return output
|
|
163
|
+
return output / md_name
|
|
164
|
+
# Directory mode: output is the destination root, mirror the structure
|
|
165
|
+
return output / md_name
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> None:
|
|
169
|
+
app()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|
dbression/db/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from dbression.db.connection import ConnectionConfig, load_connection_properties, resolve_connection_file
|
|
2
|
+
from dbression.db.engine import make_engine
|
|
3
|
+
from dbression.db.errors import DBError, wrap_dbapi_error
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ConnectionConfig",
|
|
7
|
+
"DBError",
|
|
8
|
+
"load_connection_properties",
|
|
9
|
+
"make_engine",
|
|
10
|
+
"resolve_connection_file",
|
|
11
|
+
"wrap_dbapi_error",
|
|
12
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Load DBFit-compatible ``*.connection.properties`` files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _expand_vars(value: str) -> str:
|
|
13
|
+
"""Replace ``${VAR}`` placeholders with ``os.environ[VAR]``.
|
|
14
|
+
|
|
15
|
+
If a variable is missing we raise KeyError with an informative message — silently
|
|
16
|
+
inserting an empty string would just hide the misconfiguration.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def repl(m: re.Match[str]) -> str:
|
|
20
|
+
name = m.group(1)
|
|
21
|
+
try:
|
|
22
|
+
return os.environ[name]
|
|
23
|
+
except KeyError as exc:
|
|
24
|
+
raise KeyError(
|
|
25
|
+
f"connection.properties references undefined environment variable: ${{{name}}}"
|
|
26
|
+
) from exc
|
|
27
|
+
|
|
28
|
+
return _VAR_RE.sub(repl, value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class ConnectionConfig:
|
|
33
|
+
"""DBFit-compatible connection parameters.
|
|
34
|
+
|
|
35
|
+
Either `connection_string` (Easy Connect / TNS) OR (`service`, `username`, `password`).
|
|
36
|
+
`username` / `password` are still populated when a `connection_string` is set —
|
|
37
|
+
the driver decides what to use.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
connection_string: str | None = None
|
|
41
|
+
service: str | None = None
|
|
42
|
+
username: str | None = None
|
|
43
|
+
password: str | None = None
|
|
44
|
+
extra: dict[str, str] | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_connection_properties(path: Path) -> ConnectionConfig:
|
|
48
|
+
"""Parse a Java-style ``.properties`` file.
|
|
49
|
+
|
|
50
|
+
Recognized keys: `connection-string`, `service`, `username`, `password`.
|
|
51
|
+
Unknown keys land in `extra`. Lines starting with `#` or `!` are comments.
|
|
52
|
+
"""
|
|
53
|
+
cfg = ConnectionConfig(extra={})
|
|
54
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
55
|
+
line = raw.strip()
|
|
56
|
+
if not line or line.startswith("#") or line.startswith("!"):
|
|
57
|
+
continue
|
|
58
|
+
if "=" not in line:
|
|
59
|
+
continue
|
|
60
|
+
key, _, value = line.partition("=")
|
|
61
|
+
key = key.strip().lower()
|
|
62
|
+
value = value.strip()
|
|
63
|
+
value = _expand_vars(value)
|
|
64
|
+
if key == "connection-string":
|
|
65
|
+
cfg.connection_string = value
|
|
66
|
+
elif key == "service":
|
|
67
|
+
cfg.service = value
|
|
68
|
+
elif key == "username":
|
|
69
|
+
cfg.username = value
|
|
70
|
+
elif key == "password":
|
|
71
|
+
cfg.password = value
|
|
72
|
+
else:
|
|
73
|
+
assert cfg.extra is not None
|
|
74
|
+
cfg.extra[key] = value
|
|
75
|
+
return cfg
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve_connection_file(declared: str, suite_root: Path) -> Path:
|
|
79
|
+
"""Find the actual connection.properties file on disk.
|
|
80
|
+
|
|
81
|
+
DBFit suites often carry absolute paths from inside the container
|
|
82
|
+
(``/dbfit/FitNesseRoot/...``). Strategy:
|
|
83
|
+
|
|
84
|
+
1. If the absolute path exists, use it.
|
|
85
|
+
2. Otherwise take the basename and look in `suite_root` and all of its ancestors up
|
|
86
|
+
to the filesystem root.
|
|
87
|
+
|
|
88
|
+
Raises FileNotFoundError if nothing matches.
|
|
89
|
+
"""
|
|
90
|
+
p = Path(declared)
|
|
91
|
+
if p.is_absolute() and p.exists():
|
|
92
|
+
return p
|
|
93
|
+
basename = p.name
|
|
94
|
+
candidate = suite_root / basename
|
|
95
|
+
if candidate.exists():
|
|
96
|
+
return candidate
|
|
97
|
+
for parent in suite_root.parents:
|
|
98
|
+
candidate = parent / basename
|
|
99
|
+
if candidate.exists():
|
|
100
|
+
return candidate
|
|
101
|
+
raise FileNotFoundError(
|
|
102
|
+
f"connection.properties file not found — declared: {declared!r}, "
|
|
103
|
+
f"searched from suite root: {suite_root}"
|
|
104
|
+
)
|
dbression/db/engine.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Build SQLAlchemy engines from DBFit connection properties.
|
|
2
|
+
|
|
3
|
+
DBFit suites carry directives of the form ``DatabaseEnvironment | <name>`` (e.g. `oracle`,
|
|
4
|
+
`postgres`). This function maps that name + the loaded `ConnectionConfig` to the
|
|
5
|
+
appropriate SQLAlchemy URL.
|
|
6
|
+
|
|
7
|
+
For Oracle we additionally — if the environment variable ``DBRESSION_ORACLE_CLIENT_LIB_DIR``
|
|
8
|
+
is set — switch to thick mode before building the engine (InstantClient is required for
|
|
9
|
+
Oracle servers that reject python-oracledb's thin-mode authentication).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from sqlalchemy import URL, Engine, create_engine
|
|
18
|
+
|
|
19
|
+
from dbression.db.connection import ConnectionConfig
|
|
20
|
+
|
|
21
|
+
_THICK_INIT_LOCK = threading.Lock()
|
|
22
|
+
_THICK_INITIALIZED = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _maybe_init_oracle_thick() -> None:
|
|
26
|
+
global _THICK_INITIALIZED
|
|
27
|
+
if _THICK_INITIALIZED:
|
|
28
|
+
return
|
|
29
|
+
lib_dir = os.environ.get("DBRESSION_ORACLE_CLIENT_LIB_DIR")
|
|
30
|
+
if not lib_dir:
|
|
31
|
+
return
|
|
32
|
+
with _THICK_INIT_LOCK:
|
|
33
|
+
if _THICK_INITIALIZED:
|
|
34
|
+
return
|
|
35
|
+
import oracledb # noqa: PLC0415 — keep the driver import local
|
|
36
|
+
|
|
37
|
+
oracledb.init_oracle_client(lib_dir=lib_dir)
|
|
38
|
+
_THICK_INITIALIZED = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_POSTGRES_ALIASES = {"postgres", "postgresql", "pg"}
|
|
42
|
+
_ORACLE_ALIASES = {"oracle"}
|
|
43
|
+
_MSSQL_ALIASES = {"sqlserver", "mssql", "ms-sql"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def make_engine(environment: str, config: ConnectionConfig) -> Engine:
|
|
47
|
+
"""Build a SQLAlchemy engine. No autocommit — the runner manages transactions."""
|
|
48
|
+
env = environment.strip().lower()
|
|
49
|
+
extra: dict[str, str] = config.extra or {}
|
|
50
|
+
|
|
51
|
+
if env in _POSTGRES_ALIASES:
|
|
52
|
+
url = _build_postgres_url(config, extra)
|
|
53
|
+
elif env in _ORACLE_ALIASES:
|
|
54
|
+
_maybe_init_oracle_thick()
|
|
55
|
+
url = _build_oracle_url(config, extra)
|
|
56
|
+
elif env in _MSSQL_ALIASES:
|
|
57
|
+
url = _build_mssql_url(config, extra)
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(f"Unknown DatabaseEnvironment: {environment!r}")
|
|
60
|
+
|
|
61
|
+
# `future=True` is the default in 2.0; `pool_pre_ping` makes long-lived sessions
|
|
62
|
+
# more robust.
|
|
63
|
+
return create_engine(url, pool_pre_ping=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_postgres_url(cfg: ConnectionConfig, extra: dict[str, str]) -> URL | str:
|
|
67
|
+
if cfg.connection_string:
|
|
68
|
+
cs = cfg.connection_string
|
|
69
|
+
if cs.startswith("jdbc:postgresql://"):
|
|
70
|
+
cs = cs[len("jdbc:postgresql://") :]
|
|
71
|
+
return f"postgresql+psycopg://{cs}"
|
|
72
|
+
if cs.startswith("postgresql://") or cs.startswith("postgres://"):
|
|
73
|
+
scheme, _, rest = cs.partition("://")
|
|
74
|
+
return f"postgresql+psycopg://{rest}"
|
|
75
|
+
return cs # caller's responsibility
|
|
76
|
+
host, port = _split_host_port(cfg.service)
|
|
77
|
+
return URL.create(
|
|
78
|
+
drivername="postgresql+psycopg",
|
|
79
|
+
username=cfg.username,
|
|
80
|
+
password=cfg.password,
|
|
81
|
+
host=host,
|
|
82
|
+
port=port,
|
|
83
|
+
database=extra.get("database"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_oracle_url(cfg: ConnectionConfig, extra: dict[str, str]) -> URL | str:
|
|
88
|
+
if cfg.connection_string:
|
|
89
|
+
cs = cfg.connection_string
|
|
90
|
+
return f"oracle+oracledb://{cfg.username or ''}:{cfg.password or ''}@" + cs.replace(
|
|
91
|
+
"jdbc:oracle:thin:@", ""
|
|
92
|
+
)
|
|
93
|
+
# DBFit's `service` is Easy Connect: host:port/service_name
|
|
94
|
+
return URL.create(
|
|
95
|
+
drivername="oracle+oracledb",
|
|
96
|
+
username=cfg.username,
|
|
97
|
+
password=cfg.password,
|
|
98
|
+
host=None,
|
|
99
|
+
database=None,
|
|
100
|
+
query={"dsn": cfg.service or ""},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_mssql_url(cfg: ConnectionConfig, extra: dict[str, str]) -> URL | str:
|
|
105
|
+
"""SQL Server via `pymssql` — pure-Python, no ODBC driver required."""
|
|
106
|
+
if cfg.connection_string:
|
|
107
|
+
return cfg.connection_string
|
|
108
|
+
host, port = _split_host_port(cfg.service)
|
|
109
|
+
return URL.create(
|
|
110
|
+
drivername="mssql+pymssql",
|
|
111
|
+
username=cfg.username,
|
|
112
|
+
password=cfg.password,
|
|
113
|
+
host=host,
|
|
114
|
+
port=port,
|
|
115
|
+
database=extra.get("database"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _split_host_port(service: str | None) -> tuple[str | None, int | None]:
|
|
120
|
+
if not service:
|
|
121
|
+
return None, None
|
|
122
|
+
if ":" in service:
|
|
123
|
+
host, _, port_str = service.partition(":")
|
|
124
|
+
# Easy Connect: `host:port/service_name` — we ignore the service_name here for
|
|
125
|
+
# Oracle (this function is only called for PG / MSSQL).
|
|
126
|
+
port_str = port_str.split("/", 1)[0]
|
|
127
|
+
try:
|
|
128
|
+
return host, int(port_str)
|
|
129
|
+
except ValueError:
|
|
130
|
+
return service, None
|
|
131
|
+
return service, None
|
dbression/db/errors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Database error representation and platform-code extraction from SQLAlchemy exceptions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.exc import DBAPIError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class DBError(Exception):
|
|
12
|
+
"""Driver-agnostic database error representation.
|
|
13
|
+
|
|
14
|
+
``code`` is a numeric platform code (Oracle: ORA-NNNNN without the prefix, as int;
|
|
15
|
+
Postgres: 0 because Postgres uses SQLSTATE instead of codes).
|
|
16
|
+
``sqlstate`` is the 5-character SQLSTATE (native in Postgres, where available in Oracle).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
code: int = 0
|
|
20
|
+
sqlstate: str = ""
|
|
21
|
+
message: str = ""
|
|
22
|
+
sql: str = ""
|
|
23
|
+
binds: dict[str, Any] | None = None
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
26
|
+
prefix = f"[{self.sqlstate}] " if self.sqlstate else ""
|
|
27
|
+
if self.code:
|
|
28
|
+
prefix = f"{prefix}ORA-{self.code:05d} "
|
|
29
|
+
return f"{prefix}{self.message}".strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def wrap_dbapi_error(
|
|
33
|
+
exc: DBAPIError, sql: str = "", binds: dict[str, Any] | None = None
|
|
34
|
+
) -> DBError:
|
|
35
|
+
"""Extract platform codes from the DBAPI error wrapped by SQLAlchemy.
|
|
36
|
+
|
|
37
|
+
Works for ``oracledb.DatabaseError``, ``psycopg.Error`` and most other PEP-249 drivers.
|
|
38
|
+
"""
|
|
39
|
+
orig = exc.orig
|
|
40
|
+
code = 0
|
|
41
|
+
sqlstate = ""
|
|
42
|
+
message = str(orig) if orig is not None else str(exc)
|
|
43
|
+
|
|
44
|
+
# Oracle: oracledb.DatabaseError → args[0] carries .code and .message
|
|
45
|
+
if orig is not None and hasattr(orig, "args") and orig.args:
|
|
46
|
+
inner = orig.args[0]
|
|
47
|
+
if hasattr(inner, "code") and hasattr(inner, "message"):
|
|
48
|
+
code = int(getattr(inner, "code", 0) or 0)
|
|
49
|
+
message = (getattr(inner, "message", "") or "").strip()
|
|
50
|
+
|
|
51
|
+
# Postgres (psycopg): orig.diag.sqlstate + .message_primary
|
|
52
|
+
diag = getattr(orig, "diag", None) if orig is not None else None
|
|
53
|
+
if diag is not None:
|
|
54
|
+
sqlstate = getattr(diag, "sqlstate", "") or ""
|
|
55
|
+
pg_msg = getattr(diag, "message_primary", None)
|
|
56
|
+
if pg_msg:
|
|
57
|
+
message = pg_msg.strip()
|
|
58
|
+
|
|
59
|
+
# Generic sqlstate (SQLAlchemy attaches it directly for some dialects).
|
|
60
|
+
if not sqlstate:
|
|
61
|
+
sqlstate = getattr(exc, "code", "") or ""
|
|
62
|
+
|
|
63
|
+
return DBError(code=code, sqlstate=sqlstate, message=message, sql=sql, binds=binds)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Fixture registry and base class."""
|
|
2
|
+
from dbression.fixtures.base import (
|
|
3
|
+
Fixture,
|
|
4
|
+
FixtureContext,
|
|
5
|
+
FixtureResult,
|
|
6
|
+
REGISTRY,
|
|
7
|
+
register,
|
|
8
|
+
resolve_fixture,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# Importing the modules registers their fixtures via the decorator.
|
|
12
|
+
from dbression.fixtures import suite_fixtures # noqa: F401
|
|
13
|
+
from dbression.fixtures import basic # noqa: F401
|
|
14
|
+
from dbression.fixtures import inspect_and_store # noqa: F401
|
|
15
|
+
|
|
16
|
+
# Load third-party fixtures via entry-points / the DBRESSION_PLUGINS env var.
|
|
17
|
+
# Important: AFTER the built-in imports, so that `register` & friends are already
|
|
18
|
+
# available in the package namespace if a plugin uses
|
|
19
|
+
# `from dbression.fixtures import register` instead of importing directly from
|
|
20
|
+
# `dbression.fixtures.base`.
|
|
21
|
+
from dbression.fixtures.plugins import load_plugins as _load_plugins # noqa: E402
|
|
22
|
+
|
|
23
|
+
_load_plugins()
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Fixture",
|
|
27
|
+
"FixtureContext",
|
|
28
|
+
"FixtureResult",
|
|
29
|
+
"REGISTRY",
|
|
30
|
+
"register",
|
|
31
|
+
"resolve_fixture",
|
|
32
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Fixture base class, context, and registry.
|
|
2
|
+
|
|
3
|
+
DBFit names fixtures like ``Execute Procedure``, ``Execute Procedure Expect Exception``,
|
|
4
|
+
etc. We map the fixture name (case-insensitive, normalized to lower + single spaces) to a
|
|
5
|
+
Python class that implements ``run()``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Callable, ClassVar
|
|
11
|
+
|
|
12
|
+
from sqlalchemy.engine import Connection
|
|
13
|
+
|
|
14
|
+
from dbression.parser.ast import Table
|
|
15
|
+
from dbression.symbols import SymbolTable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class FixtureResult:
|
|
20
|
+
"""Result of a single fixture invocation.
|
|
21
|
+
|
|
22
|
+
`passed` is True when the fixture met its expectation.
|
|
23
|
+
`message` is a short status line (single line).
|
|
24
|
+
`details` carries the verbose output on failure (exception, row diff, etc.).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
passed: bool
|
|
28
|
+
message: str = ""
|
|
29
|
+
details: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class StoredQuery:
|
|
34
|
+
"""Stored query result for `Store Query` / `Compare Stored Queries`."""
|
|
35
|
+
|
|
36
|
+
columns: list[str]
|
|
37
|
+
rows: list[tuple]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class FixtureContext:
|
|
42
|
+
"""Runtime context the runner passes to each fixture."""
|
|
43
|
+
|
|
44
|
+
conn: Connection
|
|
45
|
+
symbols: SymbolTable
|
|
46
|
+
# Stash for `Store Query` — spans the whole suite so `Compare Stored Queries` can
|
|
47
|
+
# reference snapshots across multiple pages.
|
|
48
|
+
stored: dict[str, StoredQuery] = field(default_factory=dict)
|
|
49
|
+
# Reserved for future fixture side effects.
|
|
50
|
+
notes: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Fixture:
|
|
54
|
+
"""Abstract fixture base class.
|
|
55
|
+
|
|
56
|
+
Concrete fixtures implement ``run(table, context)``. The constructor takes no
|
|
57
|
+
arguments — instances are stateless throwaway objects, one per table evaluation.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name: ClassVar[str] = ""
|
|
61
|
+
|
|
62
|
+
def run(self, table: Table, ctx: FixtureContext) -> FixtureResult: # pragma: no cover
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
REGISTRY: dict[str, type[Fixture]] = {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _normalize(name: str) -> str:
|
|
70
|
+
return " ".join(name.strip().lower().split())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def register(name: str) -> Callable[[type[Fixture]], type[Fixture]]:
|
|
74
|
+
"""Decorator: register a fixture class under a display name.
|
|
75
|
+
|
|
76
|
+
Multiple registrations (aliases) are allowed — just apply the decorator multiple times.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def deco(cls: type[Fixture]) -> type[Fixture]:
|
|
80
|
+
REGISTRY[_normalize(name)] = cls
|
|
81
|
+
if not cls.name:
|
|
82
|
+
cls.name = name
|
|
83
|
+
return cls
|
|
84
|
+
|
|
85
|
+
return deco
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_fixture(name: str) -> type[Fixture] | None:
|
|
89
|
+
return REGISTRY.get(_normalize(name))
|