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 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()
@@ -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))