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,109 @@
1
+ """Loader registry and Protocol for behave-data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ from behave_data.config import Config
9
+ from behave_data.errors import LoaderNotFoundError
10
+
11
+ _EXTENSION_MAP: dict[str, str] = {
12
+ ".csv": "csv",
13
+ ".json": "json",
14
+ ".yaml": "yaml",
15
+ ".yml": "yaml",
16
+ ".xlsx": "xlsx",
17
+ }
18
+
19
+
20
+ @runtime_checkable
21
+ class Loader(Protocol):
22
+ """Protocol for data loaders."""
23
+
24
+ def load(self, source: str, config: Config) -> list[dict[str, Any]]:
25
+ """Load data from a source and return a list of dicts."""
26
+ ...
27
+
28
+
29
+ _LOADERS: dict[str, str] = {
30
+ "csv": "behave_data.loaders.csv:CsvLoader",
31
+ "json": "behave_data.loaders.json:JsonLoader",
32
+ "yaml": "behave_data.loaders.yaml:YamlLoader",
33
+ "xlsx": "behave_data.loaders.excel:ExcelLoader",
34
+ "sql": "behave_data.loaders.sql:SqlLoader",
35
+ "http": "behave_data.loaders.http:HttpLoader",
36
+ }
37
+
38
+
39
+ def _resolve_loader(schema: str) -> Loader:
40
+ """Lazy-import and return a loader instance for the given schema."""
41
+ if schema not in _LOADERS:
42
+ raise LoaderNotFoundError(schema)
43
+ module_path, class_name = _LOADERS[schema].split(":")
44
+ import importlib
45
+
46
+ module = importlib.import_module(module_path)
47
+ loader_cls = getattr(module, class_name)
48
+ return loader_cls()
49
+
50
+
51
+ def _detect_schema(source: str) -> str | None:
52
+ """Detect the schema prefix from a source string.
53
+
54
+ If source contains a schema prefix (e.g. ``csv:``), return it.
55
+ Otherwise, detect from file extension.
56
+ """
57
+ if ":" in source:
58
+ prefix = source.split(":", 1)[0].lower()
59
+ if prefix in _LOADERS:
60
+ return prefix
61
+ p = Path(source)
62
+ ext = p.suffix.lower()
63
+ if ext in _EXTENSION_MAP:
64
+ return _EXTENSION_MAP[ext]
65
+ return None
66
+
67
+
68
+ def _resolve_path(source: str, config: Config) -> str:
69
+ """Resolve a source path, using config.load_base_dir for relative paths."""
70
+ p = Path(source)
71
+ if p.is_absolute() or ":" in source:
72
+ return source
73
+ return str(Path(config.load_base_dir) / source)
74
+
75
+
76
+ def load(source: str, config: Config | None = None) -> list[dict[str, Any]]:
77
+ """Load data from a source string.
78
+
79
+ Detects the schema prefix (e.g. ``csv:``) or file extension,
80
+ resolves relative paths against ``config.load_base_dir``,
81
+ and delegates to the appropriate loader.
82
+
83
+ Args:
84
+ source: Source string with optional schema prefix.
85
+ config: Configuration with load_base_dir and connection settings.
86
+
87
+ Returns:
88
+ List of dicts representing loaded data.
89
+
90
+ Raises:
91
+ LoaderNotFoundError: If no loader matches the source.
92
+ """
93
+ cfg = config if config is not None else Config()
94
+
95
+ schema = _detect_schema(source)
96
+ if schema is None:
97
+ raise LoaderNotFoundError(source)
98
+
99
+ loader = _resolve_loader(schema)
100
+
101
+ if ":" in source and source.split(":", 1)[0].lower() == schema:
102
+ path = source.split(":", 1)[1]
103
+ else:
104
+ path = source
105
+
106
+ if schema not in ("sql", "http"):
107
+ path = _resolve_path(path, cfg)
108
+
109
+ return loader.load(path, cfg)
@@ -0,0 +1,26 @@
1
+ """CSV loader for behave-data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from typing import Any
7
+
8
+ from behave_data.config import Config
9
+
10
+
11
+ class CsvLoader:
12
+ """Load data from CSV files using csv.DictReader."""
13
+
14
+ def load(self, source: str, config: Config) -> list[dict[str, Any]]:
15
+ """Load CSV data from a file path.
16
+
17
+ Args:
18
+ source: Path to the CSV file.
19
+ config: Configuration (unused for CSV, but required by Protocol).
20
+
21
+ Returns:
22
+ List of dicts, one per row. Empty list if file is empty.
23
+ """
24
+ with open(source, encoding="utf-8") as f:
25
+ reader = csv.DictReader(f)
26
+ return list(reader)
@@ -0,0 +1,54 @@
1
+ """Excel loader for behave-data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from behave_data.config import Config
8
+ from behave_data.errors import OptionalDependencyError
9
+
10
+
11
+ class ExcelLoader:
12
+ """Load data from Excel (.xlsx) files using openpyxl."""
13
+
14
+ def load(self, source: str, config: Config) -> list[dict[str, Any]]:
15
+ """Load Excel data from a file path.
16
+
17
+ First row is treated as headers.
18
+
19
+ Args:
20
+ source: Path to the Excel file.
21
+ config: Configuration (unused for Excel, but required by Protocol).
22
+
23
+ Returns:
24
+ List of dicts, one per data row.
25
+
26
+ Raises:
27
+ OptionalDependencyError: If openpyxl is not installed.
28
+ """
29
+ try:
30
+ import openpyxl
31
+ except ImportError:
32
+ raise OptionalDependencyError(
33
+ "openpyxl",
34
+ "Excel loading",
35
+ "pip install behave-data[excel]",
36
+ ) from None
37
+
38
+ wb = openpyxl.load_workbook(source, read_only=True)
39
+ ws = wb.active
40
+ rows = list(ws.iter_rows(values_only=True))
41
+ wb.close()
42
+
43
+ if not rows:
44
+ return []
45
+
46
+ headers = [str(h) if h is not None else "" for h in rows[0]]
47
+ result: list[dict[str, Any]] = []
48
+ for row in rows[1:]:
49
+ row_dict: dict[str, Any] = {}
50
+ for i, cell in enumerate(row):
51
+ key = headers[i] if i < len(headers) else f"col_{i}"
52
+ row_dict[key] = cell
53
+ result.append(row_dict)
54
+ return result
@@ -0,0 +1,61 @@
1
+ """HTTP loader for behave-data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from behave_data.config import Config
8
+ from behave_data.errors import OptionalDependencyError
9
+
10
+
11
+ class HttpLoader:
12
+ """Load data from HTTP endpoints using requests."""
13
+
14
+ def load(self, source: str, config: Config) -> list[dict[str, Any]]:
15
+ """Fetch data from an HTTP endpoint.
16
+
17
+ Source format: ``GET https://api.example.com/users`` or
18
+ ``POST https://api.example.com/users``.
19
+
20
+ Args:
21
+ source: HTTP method + URL string.
22
+ config: Configuration (unused for HTTP, but required by Protocol).
23
+
24
+ Returns:
25
+ List of dicts from the JSON response.
26
+
27
+ Raises:
28
+ OptionalDependencyError: If requests is not installed.
29
+ """
30
+ try:
31
+ import requests
32
+ except ImportError:
33
+ raise OptionalDependencyError(
34
+ "requests",
35
+ "HTTP loading",
36
+ "pip install behave-data[http]",
37
+ ) from None
38
+
39
+ parts = source.strip().split(None, 1)
40
+ if len(parts) == 2:
41
+ method, url = parts
42
+ else:
43
+ method = "GET"
44
+ url = parts[0]
45
+
46
+ method = method.upper()
47
+ if method == "GET":
48
+ response = requests.get(url, timeout=30)
49
+ elif method == "POST":
50
+ response = requests.post(url, timeout=30)
51
+ else:
52
+ raise ValueError(f"Unsupported HTTP method: {method}")
53
+
54
+ response.raise_for_status()
55
+ data = response.json()
56
+
57
+ if isinstance(data, list):
58
+ return data
59
+ if isinstance(data, dict):
60
+ return [data]
61
+ raise ValueError(f"HTTP response must be a list or dict, got {type(data).__name__}")
@@ -0,0 +1,34 @@
1
+ """JSON loader for behave-data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from behave_data.config import Config
9
+
10
+
11
+ class JsonLoader:
12
+ """Load data from JSON files."""
13
+
14
+ def load(self, source: str, config: Config) -> list[dict[str, Any]]:
15
+ """Load JSON data from a file path.
16
+
17
+ Args:
18
+ source: Path to the JSON file.
19
+ config: Configuration (unused for JSON, but required by Protocol).
20
+
21
+ Returns:
22
+ List of dicts. If the JSON is a dict, wraps it in a list.
23
+
24
+ Raises:
25
+ ValueError: If the JSON data is not a list or dict.
26
+ """
27
+ with open(source, encoding="utf-8") as f:
28
+ data = json.load(f)
29
+
30
+ if isinstance(data, list):
31
+ return data
32
+ if isinstance(data, dict):
33
+ return [data]
34
+ raise ValueError(f"JSON data must be a list or dict, got {type(data).__name__}")
@@ -0,0 +1,46 @@
1
+ """SQL loader for behave-data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from behave_data.config import Config
8
+ from behave_data.errors import OptionalDependencyError
9
+
10
+
11
+ class SqlLoader:
12
+ """Load data from SQL queries using SQLAlchemy."""
13
+
14
+ def load(self, source: str, config: Config) -> list[dict[str, Any]]:
15
+ """Execute a SQL query and return results as list of dicts.
16
+
17
+ Args:
18
+ source: SQL query string.
19
+ config: Configuration with db_connections mapping.
20
+
21
+ Returns:
22
+ List of dicts, one per row.
23
+
24
+ Raises:
25
+ OptionalDependencyError: If SQLAlchemy is not installed.
26
+ KeyError: If the database name is not in config.db_connections.
27
+ """
28
+ try:
29
+ import sqlalchemy
30
+ except ImportError:
31
+ raise OptionalDependencyError(
32
+ "sqlalchemy",
33
+ "SQL loading",
34
+ "pip install behave-data[sql]",
35
+ ) from None
36
+
37
+ db_name = getattr(config, "_sql_db_name", None) or "default"
38
+ connection_string = config.db_connections.get(db_name)
39
+ if connection_string is None:
40
+ raise KeyError(f"Database '{db_name}' not found in config.db_connections")
41
+
42
+ engine = sqlalchemy.create_engine(connection_string)
43
+ with engine.connect() as conn:
44
+ result = conn.execute(sqlalchemy.text(source))
45
+ columns = list(result.keys())
46
+ return [dict(zip(columns, row, strict=False)) for row in result]
@@ -0,0 +1,45 @@
1
+ """YAML loader for behave-data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from behave_data.config import Config
8
+ from behave_data.errors import OptionalDependencyError
9
+
10
+
11
+ class YamlLoader:
12
+ """Load data from YAML files."""
13
+
14
+ def load(self, source: str, config: Config) -> list[dict[str, Any]]:
15
+ """Load YAML data from a file path.
16
+
17
+ Args:
18
+ source: Path to the YAML file.
19
+ config: Configuration (unused for YAML, but required by Protocol).
20
+
21
+ Returns:
22
+ List of dicts. If the YAML is a dict, wraps it in a list.
23
+
24
+ Raises:
25
+ OptionalDependencyError: If PyYAML is not installed.
26
+ """
27
+ try:
28
+ import yaml
29
+ except ImportError:
30
+ raise OptionalDependencyError(
31
+ "pyyaml",
32
+ "YAML loading",
33
+ "pip install behave-data[yaml]",
34
+ ) from None
35
+
36
+ with open(source, encoding="utf-8") as f:
37
+ data = yaml.safe_load(f)
38
+
39
+ if isinstance(data, list):
40
+ return data
41
+ if isinstance(data, dict):
42
+ return [data]
43
+ if data is None:
44
+ return []
45
+ raise ValueError(f"YAML data must be a list or dict, got {type(data).__name__}")
behave_data/manager.py ADDED
@@ -0,0 +1,20 @@
1
+ """Minimal DataManager stub for MVP.
2
+
3
+ The full implementation comes in Fase 3.3.
4
+ For now, it just stores the config.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from behave_data.config import Config
10
+
11
+
12
+ class DataManager:
13
+ """Minimal data manager that stores configuration.
14
+
15
+ Attributes:
16
+ config: The behave-data configuration.
17
+ """
18
+
19
+ def __init__(self, config: Config) -> None:
20
+ self.config = config
behave_data/null.py ADDED
@@ -0,0 +1,65 @@
1
+ """Null handling for behave-data table cells."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from behave_data.config import DEFAULT_NULL_MARKERS, Config
6
+
7
+
8
+ def get_column_markers(column_name: str, config: Config) -> frozenset[str] | None:
9
+ """Get the per-column null markers for a specific column.
10
+
11
+ Args:
12
+ column_name: The column name.
13
+ config: The Config to query.
14
+
15
+ Returns:
16
+ A frozenset of null markers for the column, or None if no
17
+ per-column markers are defined.
18
+ """
19
+ return config.null_markers_by_column.get(column_name)
20
+
21
+
22
+ def is_null(
23
+ value: str,
24
+ markers: frozenset[str] | None = None,
25
+ column_markers: frozenset[str] | None = None,
26
+ ) -> bool:
27
+ """Check if a value is a null marker.
28
+
29
+ Priority: ``column_markers`` > ``markers`` > ``DEFAULT_NULL_MARKERS``.
30
+
31
+ Args:
32
+ value: The string value to check.
33
+ markers: Global null markers. If None, defaults are used.
34
+ column_markers: Per-column markers that override global markers.
35
+
36
+ Returns:
37
+ True if the value is a null marker, False otherwise.
38
+ """
39
+ if column_markers is not None:
40
+ return value in column_markers
41
+ if markers is not None:
42
+ return value in markers
43
+ return value in DEFAULT_NULL_MARKERS
44
+
45
+
46
+ def resolve_null(
47
+ value: str,
48
+ markers: frozenset[str] | None = None,
49
+ column_markers: frozenset[str] | None = None,
50
+ ) -> str | None:
51
+ """Resolve a null marker to None, or return the value unchanged.
52
+
53
+ Priority: ``column_markers`` > ``markers`` > ``DEFAULT_NULL_MARKERS``.
54
+
55
+ Args:
56
+ value: The string value to resolve.
57
+ markers: Global null markers. If None, defaults are used.
58
+ column_markers: Per-column markers that override global markers.
59
+
60
+ Returns:
61
+ None if the value is a null marker, otherwise the original value.
62
+ """
63
+ if is_null(value, markers, column_markers):
64
+ return None
65
+ return value
behave_data/patch.py ADDED
@@ -0,0 +1,153 @@
1
+ """Patch behave.model.Table with behave-data convenience methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from behave_data.config import Config
8
+ from behave_data.diff import diff as _diff
9
+ from behave_data.raw_table import RawTable
10
+ from behave_data.typed_table import TypedTableWrapper
11
+ from behave_data.typed_table import typed_wrap as _typed_wrap
12
+
13
+ _ORIGINAL_METHODS: dict[str, Any] = {}
14
+ _IS_PATCHED = False
15
+
16
+ _METHOD_NAMES = [
17
+ "typed_wrap",
18
+ "typed_dicts",
19
+ "typed_objects",
20
+ "clean_headers",
21
+ "to_dict",
22
+ "to_lists",
23
+ "to_pandas",
24
+ "raw_table",
25
+ "diff",
26
+ ]
27
+
28
+
29
+ def _typed_wrap_method(self: Any, config: Config | None = None) -> TypedTableWrapper:
30
+ return _typed_wrap(self, config)
31
+
32
+
33
+ def _typed_dicts(self: Any, config: Config | None = None) -> list[dict[str, Any]]:
34
+ return TypedTableWrapper(self, config).typed_dicts()
35
+
36
+
37
+ def _typed_objects(self: Any, cls: type, config: Config | None = None) -> list[Any]:
38
+ return TypedTableWrapper(self, config).typed_objects(cls, config)
39
+
40
+
41
+ def _clean_headers(self: Any) -> list[str]:
42
+ return TypedTableWrapper(self).clean_headers()
43
+
44
+
45
+ def _to_dict(self: Any, config: Config | None = None) -> dict[Any, Any]:
46
+ return TypedTableWrapper(self, config).to_dict()
47
+
48
+
49
+ def _to_lists(self: Any, config: Config | None = None) -> list[list[Any]]:
50
+ return TypedTableWrapper(self, config).to_lists()
51
+
52
+
53
+ def _to_pandas(self: Any, config: Config | None = None) -> Any:
54
+ return TypedTableWrapper(self, config).to_pandas()
55
+
56
+
57
+ def _raw_table(self: Any) -> RawTable:
58
+ return RawTable(self)
59
+
60
+
61
+ def _diff_method(
62
+ self: Any,
63
+ other: Any,
64
+ *,
65
+ ordered: bool = True,
66
+ ignore_columns: list[str] | None = None,
67
+ surplus_columns: bool = True,
68
+ ) -> None:
69
+ _diff(
70
+ self,
71
+ other,
72
+ ordered=ordered,
73
+ ignore_columns=ignore_columns,
74
+ surplus_columns=surplus_columns,
75
+ )
76
+
77
+
78
+ _IMPLEMENTATIONS = {
79
+ "typed_wrap": _typed_wrap_method,
80
+ "typed_dicts": _typed_dicts,
81
+ "typed_objects": _typed_objects,
82
+ "clean_headers": _clean_headers,
83
+ "to_dict": _to_dict,
84
+ "to_lists": _to_lists,
85
+ "to_pandas": _to_pandas,
86
+ "raw_table": _raw_table,
87
+ "diff": _diff_method,
88
+ }
89
+
90
+
91
+ def apply_patches() -> None:
92
+ """Add behave-data convenience methods to behave.model.Table.
93
+
94
+ This function is idempotent: calling it multiple times has no effect.
95
+ Each method is added as a bound method to the Table class.
96
+
97
+ Adds the following methods:
98
+ - typed_wrap(config=None)
99
+ - typed_dicts(config=None)
100
+ - typed_objects(cls, config=None)
101
+ - clean_headers()
102
+ - to_dict(config=None)
103
+ - to_lists(config=None)
104
+ - to_pandas(config=None)
105
+ - raw_table()
106
+ - diff(other, *, ordered=True, ignore_columns=None, surplus_columns=True)
107
+
108
+ Sets ``Table._behave_data_patched = True``.
109
+ """
110
+ global _IS_PATCHED
111
+ if _IS_PATCHED:
112
+ return
113
+
114
+ try:
115
+ from behave.model import Table
116
+ except ImportError:
117
+ return
118
+
119
+ for name in _METHOD_NAMES:
120
+ if hasattr(Table, name):
121
+ _ORIGINAL_METHODS[name] = getattr(Table, name)
122
+ setattr(Table, name, _IMPLEMENTATIONS[name])
123
+
124
+ Table._behave_data_patched = True # type: ignore[attr-defined]
125
+ _IS_PATCHED = True
126
+
127
+
128
+ def revert_patches() -> None:
129
+ """Restore the original behave.model.Table methods.
130
+
131
+ This function is idempotent: calling it when not patched has no effect.
132
+ """
133
+ global _IS_PATCHED
134
+ if not _IS_PATCHED:
135
+ return
136
+
137
+ try:
138
+ from behave.model import Table
139
+ except ImportError:
140
+ return
141
+
142
+ for name in _METHOD_NAMES:
143
+ if name in _ORIGINAL_METHODS:
144
+ setattr(Table, name, _ORIGINAL_METHODS[name])
145
+ else:
146
+ if hasattr(Table, name):
147
+ delattr(Table, name)
148
+
149
+ if hasattr(Table, "_behave_data_patched"):
150
+ delattr(Table, "_behave_data_patched")
151
+
152
+ _ORIGINAL_METHODS.clear()
153
+ _IS_PATCHED = False
behave_data/py.typed ADDED
File without changes