behave-data 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.
@@ -0,0 +1,61 @@
1
+ """behave-data — Data management for Behave."""
2
+
3
+ from behave_tables import (
4
+ ColumnMismatchError,
5
+ TableLike,
6
+ TableWrapper,
7
+ wrap,
8
+ )
9
+
10
+ from behave_data.config import Config
11
+ from behave_data.diff import diff
12
+ from behave_data.errors import (
13
+ BehaveDataError,
14
+ BuilderNotFoundError,
15
+ FixtureNotFoundError,
16
+ LoaderNotFoundError,
17
+ OptionalDependencyError,
18
+ RawTableError,
19
+ TableDiffError,
20
+ TypeConversionError,
21
+ )
22
+ from behave_data.hooks import before_step_hook, setup_data
23
+ from behave_data.null import get_column_markers, is_null, resolve_null
24
+ from behave_data.patch import apply_patches, revert_patches
25
+ from behave_data.raw_table import RawTable, raw_table
26
+ from behave_data.typed_table import TypedTableWrapper, typed_wrap
27
+ from behave_data.types import TYPE_CONVERTERS, convert_cell, parse_column_header, register_type
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "BehaveDataError",
33
+ "BuilderNotFoundError",
34
+ "ColumnMismatchError",
35
+ "Config",
36
+ "FixtureNotFoundError",
37
+ "LoaderNotFoundError",
38
+ "OptionalDependencyError",
39
+ "RawTable",
40
+ "RawTableError",
41
+ "TableDiffError",
42
+ "TableLike",
43
+ "TableWrapper",
44
+ "TYPE_CONVERTERS",
45
+ "TypeConversionError",
46
+ "TypedTableWrapper",
47
+ "apply_patches",
48
+ "before_step_hook",
49
+ "convert_cell",
50
+ "diff",
51
+ "get_column_markers",
52
+ "is_null",
53
+ "parse_column_header",
54
+ "raw_table",
55
+ "register_type",
56
+ "resolve_null",
57
+ "revert_patches",
58
+ "setup_data",
59
+ "typed_wrap",
60
+ "wrap",
61
+ ]
behave_data/config.py ADDED
@@ -0,0 +1,137 @@
1
+ """Configuration for behave-data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from behave_data.errors import OptionalDependencyError
11
+
12
+ DEFAULT_NULL_MARKERS: frozenset[str] = frozenset({"", "null", "None", "N/A"})
13
+
14
+ _KNOWN_KEYS = frozenset(
15
+ {
16
+ "null_markers",
17
+ "null_markers_by_column",
18
+ "secret_backend",
19
+ "secret_path",
20
+ "load_base_dir",
21
+ "db_connections",
22
+ "type_overrides",
23
+ }
24
+ )
25
+
26
+
27
+ def _stringify_marker(m: object) -> str:
28
+ """Convert a marker value to string, handling YAML's null -> None."""
29
+ if m is None:
30
+ return "null"
31
+ return str(m)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Config:
36
+ """Configuration for behave-data.
37
+
38
+ Attributes:
39
+ null_markers: Global set of strings treated as null.
40
+ null_markers_by_column: Per-column null markers that override globals.
41
+ secret_backend: Backend for secret resolution (file, env, vault, aws).
42
+ secret_path: Directory path for file-based secrets.
43
+ load_base_dir: Base directory for relative file loading.
44
+ db_connections: Named database connection strings.
45
+ type_overrides: Per-column type overrides.
46
+ """
47
+
48
+ null_markers: frozenset[str] = DEFAULT_NULL_MARKERS
49
+ null_markers_by_column: dict[str, frozenset[str]] = field(default_factory=dict)
50
+ secret_backend: str = "file"
51
+ secret_path: str = "secrets/"
52
+ load_base_dir: str = "features/data/"
53
+ db_connections: dict[str, str] = field(default_factory=dict)
54
+ type_overrides: dict[str, str] = field(default_factory=dict)
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: dict[str, Any]) -> Config:
58
+ """Create a Config from a dict, filtering unknown keys and converting lists to frozensets.
59
+
60
+ Args:
61
+ data: A dict with optional configuration keys.
62
+
63
+ Returns:
64
+ A Config instance with values from data, defaults for missing keys.
65
+ """
66
+ filtered = {k: v for k, v in data.items() if k in _KNOWN_KEYS}
67
+
68
+ if "null_markers" in filtered:
69
+ val = filtered["null_markers"]
70
+ if isinstance(val, list):
71
+ filtered["null_markers"] = frozenset(_stringify_marker(m) for m in val)
72
+ elif isinstance(val, frozenset):
73
+ filtered["null_markers"] = val
74
+ else:
75
+ filtered["null_markers"] = frozenset(_stringify_marker(m) for m in val)
76
+
77
+ if "null_markers_by_column" in filtered:
78
+ raw = filtered["null_markers_by_column"]
79
+ converted: dict[str, frozenset[str]] = {}
80
+ for col, markers in raw.items():
81
+ if isinstance(markers, list):
82
+ converted[col] = frozenset(_stringify_marker(m) for m in markers)
83
+ elif isinstance(markers, frozenset):
84
+ converted[col] = markers
85
+ else:
86
+ converted[col] = frozenset(_stringify_marker(m) for m in markers)
87
+ filtered["null_markers_by_column"] = converted
88
+
89
+ return cls(**filtered)
90
+
91
+ @classmethod
92
+ def from_file(cls, path: str = "behave_data.yml") -> Config:
93
+ """Load Config from a YAML or JSON file.
94
+
95
+ If the file does not exist, returns default Config.
96
+ For ``.yml``/``.yaml`` files, uses PyYAML (lazy-import).
97
+ For ``.json`` files, uses ``json``.
98
+ Unknown extensions return default Config.
99
+
100
+ Args:
101
+ path: Path to the configuration file.
102
+
103
+ Returns:
104
+ A Config instance.
105
+
106
+ Raises:
107
+ OptionalDependencyError: If PyYAML is required but not installed.
108
+ """
109
+ p = Path(path)
110
+ if not p.exists():
111
+ return cls()
112
+
113
+ suffix = p.suffix.lower()
114
+
115
+ if suffix in (".yml", ".yaml"):
116
+ try:
117
+ import yaml
118
+ except ImportError:
119
+ raise OptionalDependencyError(
120
+ "pyyaml",
121
+ "YAML config loading",
122
+ "pip install behave-data[yaml]",
123
+ ) from None
124
+ with p.open(encoding="utf-8") as f:
125
+ data = yaml.safe_load(f)
126
+ if not isinstance(data, dict):
127
+ return cls()
128
+ return cls.from_dict(data)
129
+
130
+ if suffix == ".json":
131
+ with p.open(encoding="utf-8") as f:
132
+ data = json.load(f)
133
+ if not isinstance(data, dict):
134
+ return cls()
135
+ return cls.from_dict(data)
136
+
137
+ return cls()
behave_data/diff.py ADDED
@@ -0,0 +1,182 @@
1
+ """Table diff with Cucumber-style output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from behave_tables.wrapper import TableLike, TableWrapper
8
+
9
+ from behave_data.errors import TableDiffError
10
+
11
+
12
+ def diff(
13
+ expected: TableLike,
14
+ actual: Any,
15
+ *,
16
+ ordered: bool = True,
17
+ ignore_columns: list[str] | None = None,
18
+ surplus_columns: bool = True,
19
+ ) -> None:
20
+ """Compare two tables and raise TableDiffError if they differ.
21
+
22
+ Args:
23
+ expected: The expected table (table-like with ``headings`` and ``rows``).
24
+ actual: The actual data. Accepts a table-like object, ``list[dict]``,
25
+ or a single ``dict`` (wrapped in a list). ``list[list]`` raises
26
+ ``ValueError`` because headers are needed.
27
+ ordered: If True, rows must be in the same order.
28
+ ignore_columns: Column names to exclude from comparison.
29
+ surplus_columns: If False, extra columns in actual are removed before
30
+ comparison (they don't cause a diff).
31
+
32
+ Raises:
33
+ ValueError: If ``actual`` is a ``list[list]`` (needs headers).
34
+ TableDiffError: If the tables differ, with a Cucumber-style diff output.
35
+ """
36
+ ignore_set = set(ignore_columns) if ignore_columns else set()
37
+
38
+ exp_wrapper = TableWrapper(expected)
39
+ exp_headers = [h for h in exp_wrapper.headers if h not in ignore_set]
40
+ exp_rows = exp_wrapper.as_dicts()
41
+
42
+ act_headers, act_rows = _normalize_actual(actual, exp_headers)
43
+
44
+ # Filter ignore_columns from actual
45
+ act_headers = [h for h in act_headers if h not in ignore_set]
46
+ act_rows = [{k: v for k, v in row.items() if k not in ignore_set} for row in act_rows]
47
+
48
+ # Filter ignore_columns from expected
49
+ exp_rows = [{k: v for k, v in row.items() if k not in ignore_set} for row in exp_rows]
50
+
51
+ # Handle surplus columns
52
+ if not surplus_columns:
53
+ exp_set = set(exp_headers)
54
+ act_rows = [{k: v for k, v in row.items() if k in exp_set} for row in act_rows]
55
+ act_headers = [h for h in act_headers if h in exp_set]
56
+
57
+ # Build diff output
58
+ diff_lines: list[str] = []
59
+
60
+ # Header diff
61
+ if exp_headers != act_headers:
62
+ diff_lines.append("Tables were not identical:")
63
+ diff_lines.append(f" Headers differ: expected {exp_headers}, got {act_headers}")
64
+ # Use expected headers for row comparison
65
+ compare_headers = exp_headers
66
+ else:
67
+ compare_headers = exp_headers
68
+
69
+ # Row diff
70
+ if ordered:
71
+ row_diffs = _diff_ordered(exp_rows, act_rows, compare_headers)
72
+ else:
73
+ row_diffs = _diff_unordered(exp_rows, act_rows, compare_headers)
74
+
75
+ if row_diffs:
76
+ if not diff_lines:
77
+ diff_lines.append("Tables were not identical:")
78
+ diff_lines.extend(row_diffs)
79
+
80
+ if diff_lines:
81
+ output = "\n".join(diff_lines)
82
+ raise TableDiffError(output)
83
+
84
+
85
+ def _normalize_actual(
86
+ actual: Any,
87
+ exp_headers: list[str],
88
+ ) -> tuple[list[str], list[dict[str, str]]]:
89
+ """Normalize actual into (headers, rows).
90
+
91
+ Args:
92
+ actual: The actual data to normalize.
93
+ exp_headers: Expected headers, used as fallback for dict normalization.
94
+
95
+ Returns:
96
+ A tuple of (headers, list of row dicts).
97
+
98
+ Raises:
99
+ ValueError: If actual is a list[list] (needs headers).
100
+ """
101
+ if isinstance(actual, dict):
102
+ actual = [actual]
103
+
104
+ if isinstance(actual, list) and len(actual) > 0 and isinstance(actual[0], dict):
105
+ # list[dict] — infer headers from first dict keys
106
+ headers = list(actual[0].keys())
107
+ rows = [dict(d) for d in actual]
108
+ return headers, rows
109
+
110
+ if isinstance(actual, list) and len(actual) > 0 and isinstance(actual[0], (list, tuple)):
111
+ raise ValueError(
112
+ "Cannot diff list[list] — headers are required. "
113
+ "Provide a table-like object or list[dict] instead."
114
+ )
115
+
116
+ if isinstance(actual, list) and len(actual) == 0:
117
+ return list(exp_headers), []
118
+
119
+ # Table-like object with headings + rows
120
+ wrapper = TableWrapper(actual)
121
+ return list(wrapper.headers), wrapper.as_dicts()
122
+
123
+
124
+ def _diff_ordered(
125
+ expected: list[dict[str, str]],
126
+ actual: list[dict[str, str]],
127
+ headers: list[str],
128
+ ) -> list[str]:
129
+ """Compare tables in order and return diff lines if different."""
130
+ diff_lines: list[str] = []
131
+ max_rows = max(len(expected), len(actual))
132
+
133
+ for i in range(max_rows):
134
+ if i < len(expected) and i < len(actual):
135
+ exp_vals = [str(expected[i].get(h, "")) for h in headers]
136
+ act_vals = [str(actual[i].get(h, "")) for h in headers]
137
+ if exp_vals != act_vals:
138
+ diff_lines.append(f" - {_format_row(exp_vals)}")
139
+ diff_lines.append(f" + {_format_row(act_vals)}")
140
+ elif i < len(expected):
141
+ exp_vals = [str(expected[i].get(h, "")) for h in headers]
142
+ diff_lines.append(f" - {_format_row(exp_vals)}")
143
+ else:
144
+ act_vals = [str(actual[i].get(h, "")) for h in headers]
145
+ diff_lines.append(f" + {_format_row(act_vals)}")
146
+
147
+ return diff_lines
148
+
149
+
150
+ def _diff_unordered(
151
+ expected: list[dict[str, str]],
152
+ actual: list[dict[str, str]],
153
+ headers: list[str],
154
+ ) -> list[str]:
155
+ """Compare tables ignoring row order and return diff lines if different."""
156
+ exp_tuples = [tuple(str(row.get(h, "")) for h in headers) for row in expected]
157
+ act_tuples = [tuple(str(row.get(h, "")) for h in headers) for row in actual]
158
+
159
+ exp_remaining = list(exp_tuples)
160
+ act_remaining = list(act_tuples)
161
+
162
+ missing_rows: list[tuple[str, ...]] = []
163
+ for row in exp_remaining:
164
+ if row in act_remaining:
165
+ act_remaining.remove(row)
166
+ else:
167
+ missing_rows.append(row)
168
+
169
+ surplus_rows = list(act_remaining)
170
+
171
+ diff_lines: list[str] = []
172
+ for row in missing_rows:
173
+ diff_lines.append(f" - {_format_row(list(row))}")
174
+ for row in surplus_rows:
175
+ diff_lines.append(f" + {_format_row(list(row))}")
176
+
177
+ return diff_lines
178
+
179
+
180
+ def _format_row(values: list[str]) -> str:
181
+ """Format a single row as a Cucumber-style table row."""
182
+ return f"| {' | '.join(values)} |"
behave_data/errors.py ADDED
@@ -0,0 +1,100 @@
1
+ """Custom exceptions for behave-data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ logger = logging.getLogger("behave_data")
8
+ logger.addHandler(logging.NullHandler())
9
+
10
+
11
+ class BehaveDataError(Exception):
12
+ """Base class for all behave-data errors."""
13
+
14
+
15
+ class TypeConversionError(BehaveDataError):
16
+ """Raised when a cell value cannot be converted to the target type.
17
+
18
+ Attributes:
19
+ column: The column name where the conversion failed.
20
+ value: The original value that could not be converted.
21
+ target_type: The name of the target type.
22
+ cause: The underlying exception that caused the failure.
23
+ """
24
+
25
+ def __init__(self, column: str, value: object, target_type: str, cause: Exception) -> None:
26
+ self.column = column
27
+ self.value = value
28
+ self.target_type = target_type
29
+ self.cause = cause
30
+ super().__init__(
31
+ f"Cannot convert column '{column}' value {value!r} to {target_type}: {cause}"
32
+ )
33
+
34
+
35
+ class TableDiffError(AssertionError):
36
+ """Raised when two tables are not identical.
37
+
38
+ Attributes:
39
+ diff_output: The human-readable diff string.
40
+ """
41
+
42
+ def __init__(self, diff_output: str) -> None:
43
+ self.diff_output = diff_output
44
+ super().__init__(diff_output)
45
+
46
+
47
+ class FixtureNotFoundError(BehaveDataError):
48
+ """Raised when a requested fixture is not registered.
49
+
50
+ Attributes:
51
+ name: The fixture name that was not found.
52
+ """
53
+
54
+ def __init__(self, name: str) -> None:
55
+ self.name = name
56
+ super().__init__(f"Fixture '{name}' not registered.")
57
+
58
+
59
+ class BuilderNotFoundError(BehaveDataError):
60
+ """Raised when a requested builder is not registered.
61
+
62
+ Attributes:
63
+ name: The builder name that was not found.
64
+ """
65
+
66
+ def __init__(self, name: str) -> None:
67
+ self.name = name
68
+ super().__init__(f"Builder '{name}' not registered.")
69
+
70
+
71
+ class LoaderNotFoundError(BehaveDataError):
72
+ """Raised when no loader is found for a given source.
73
+
74
+ Attributes:
75
+ source: The source string that could not be matched to a loader.
76
+ """
77
+
78
+ def __init__(self, source: str) -> None:
79
+ self.source = source
80
+ super().__init__(f"No loader found for source: {source!r}")
81
+
82
+
83
+ class OptionalDependencyError(BehaveDataError):
84
+ """Raised when an optional dependency is required but not installed.
85
+
86
+ Attributes:
87
+ package: The name of the missing package.
88
+ feature: The feature that requires the package.
89
+ install_cmd: The command to install the package.
90
+ """
91
+
92
+ def __init__(self, package: str, feature: str, install_cmd: str) -> None:
93
+ self.package = package
94
+ self.feature = feature
95
+ self.install_cmd = install_cmd
96
+ super().__init__(f"{package} is required for {feature}. Install it with: {install_cmd}")
97
+
98
+
99
+ class RawTableError(BehaveDataError):
100
+ """Raised when raw table operations fail."""
@@ -0,0 +1,107 @@
1
+ """Dynamic Examples loading for Behave features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from behave_data.config import Config
9
+ from behave_data.loaders import load as _load
10
+
11
+ _LOAD_TAG_PATTERN = re.compile(r"@load_examples:(.+)")
12
+
13
+
14
+ def _find_load_tag(scenario: Any) -> str | None:
15
+ """Find a @load_examples:source tag on a scenario or its feature.
16
+
17
+ Args:
18
+ scenario: A Behave scenario object with ``tags`` and ``feature.tags``.
19
+
20
+ Returns:
21
+ The source string (trimmed) if found, else None.
22
+ """
23
+ tags = list(getattr(scenario, "tags", []))
24
+ feature = getattr(scenario, "feature", None)
25
+ if feature is not None:
26
+ tags.extend(getattr(feature, "tags", []))
27
+
28
+ for tag in tags:
29
+ match = _LOAD_TAG_PATTERN.match(tag.strip())
30
+ if match:
31
+ return match.group(1).strip()
32
+ return None
33
+
34
+
35
+ def _replace_example_rows(example: Any, data: list[dict[str, Any]]) -> None:
36
+ """Replace the rows in an Examples block with loaded data.
37
+
38
+ Creates new Row objects preserving line numbers.
39
+
40
+ Args:
41
+ example: A Behave Examples object with ``table``.
42
+ data: List of dicts from the loader.
43
+ """
44
+ if not data:
45
+ example.table.rows = []
46
+ return
47
+
48
+ headers = list(data[0].keys())
49
+
50
+ try:
51
+ from behave.model import Row
52
+ except ImportError:
53
+ example.table.headings = headers
54
+ example.table.rows = []
55
+ for row_data in data:
56
+ cells = [str(row_data.get(h, "")) for h in headers]
57
+ example.table.rows.append(_SimpleRow(cells))
58
+ return
59
+
60
+ example.table.headings = headers
61
+ example.table.rows = []
62
+ for row_data in data:
63
+ cells = [str(row_data.get(h, "")) for h in headers]
64
+ line = getattr(example, "line", 0)
65
+ example.table.rows.append(Row(headers, cells, line))
66
+
67
+
68
+ class _SimpleRow:
69
+ """Fallback Row when behave.model.Row is not available."""
70
+
71
+ def __init__(self, cells: list[str]) -> None:
72
+ self.cells = cells
73
+
74
+ def __getitem__(self, index: int) -> str:
75
+ return self.cells[index]
76
+
77
+ def as_dict(self) -> dict[str, str]:
78
+ return dict(zip(self._headers, self.cells, strict=False))
79
+
80
+
81
+ def load_examples_for_feature(feature: Any, config: Config) -> None:
82
+ """Load dynamic Examples for all scenarios in a feature.
83
+
84
+ Iterates ``feature.scenarios``, finds ``@load_examples:source`` tags,
85
+ and replaces Example rows with data loaded from the source.
86
+
87
+ Args:
88
+ feature: A Behave Feature object with ``scenarios``.
89
+ config: Configuration for loader resolution.
90
+ """
91
+ scenarios = getattr(feature, "scenarios", [])
92
+ if not scenarios:
93
+ return
94
+
95
+ for scenario in scenarios:
96
+ source = _find_load_tag(scenario)
97
+ if source is None:
98
+ continue
99
+
100
+ data = _load(source, config)
101
+
102
+ examples = getattr(scenario, "examples", [])
103
+ if not examples:
104
+ continue
105
+
106
+ for example in examples:
107
+ _replace_example_rows(example, data)
behave_data/hooks.py ADDED
@@ -0,0 +1,80 @@
1
+ """Behave hooks for behave-data MVP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from behave_data.config import Config
9
+ from behave_data.manager import DataManager
10
+ from behave_data.patch import apply_patches
11
+
12
+ _PLACEHOLDER_PATTERN = re.compile(r"\{([^}]+)\}")
13
+
14
+
15
+ def setup_data(context: Any, config: Config | None = None) -> None:
16
+ """Initialize behave-data for a Behave run.
17
+
18
+ Stores the configuration, creates a DataManager, and applies table patches.
19
+
20
+ Args:
21
+ context: The Behave context object.
22
+ config: Configuration to use. If None, loads from ``behave_data.yml``.
23
+ """
24
+ cfg = config if config is not None else Config.from_file("behave_data.yml")
25
+ context.data = DataManager(cfg)
26
+ apply_patches()
27
+
28
+
29
+ def _resolve_placeholders(value: str, context: Any) -> str:
30
+ """Resolve ``{placeholder}`` patterns in a string using context attributes.
31
+
32
+ Supports nested attribute access via dot notation, e.g. ``{user.name}``.
33
+
34
+ Args:
35
+ value: The string potentially containing placeholders.
36
+ context: The Behave context object with attributes.
37
+
38
+ Returns:
39
+ The string with placeholders replaced by resolved values.
40
+ """
41
+
42
+ def replacer(match: re.Match[str]) -> str:
43
+ key = match.group(1)
44
+ obj: Any = context
45
+ for part in key.split("."):
46
+ if hasattr(obj, part):
47
+ obj = getattr(obj, part)
48
+ else:
49
+ return match.group(0)
50
+ return str(obj)
51
+
52
+ return _PLACEHOLDER_PATTERN.sub(replacer, value)
53
+
54
+
55
+ def before_step_hook(context: Any, step: Any) -> None:
56
+ """Resolve placeholders in table cells before each step.
57
+
58
+ This hook does NOT mutate the original table. If the step has an
59
+ associated table, a copy of the table is created with resolved
60
+ placeholders and assigned to ``context.table``.
61
+
62
+ Args:
63
+ context: The Behave context object.
64
+ step: The step about to be executed.
65
+ """
66
+ table = getattr(step, "table", None)
67
+ if table is None:
68
+ return
69
+
70
+ from behave_data.raw_table import RawTable
71
+
72
+ raw = RawTable(table)
73
+ resolved_rows: list[list[str]] = []
74
+ for row in raw.rows:
75
+ resolved_rows.append([_resolve_placeholders(cell, context) for cell in row])
76
+
77
+ context.resolved_table = {
78
+ "headings": list(raw[0]),
79
+ "rows": resolved_rows,
80
+ }