openingest 2.5.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.
Files changed (91) hide show
  1. core/__init__.py +0 -0
  2. core/airflow/__init__.py +0 -0
  3. core/airflow/runner.py +132 -0
  4. core/airflow/task_factory.py +37 -0
  5. core/airflow_runner.py +55 -0
  6. core/connectors/__init__.py +10 -0
  7. core/connectors/api/__init__.py +1 -0
  8. core/connectors/api/rest_connector.py +338 -0
  9. core/connectors/base.py +52 -0
  10. core/connectors/cloud/__init__.py +1 -0
  11. core/connectors/cloud/azure_connector.py +185 -0
  12. core/connectors/cloud/gcs_connector.py +172 -0
  13. core/connectors/cloud/s3_connector.py +189 -0
  14. core/connectors/formats/__init__.py +1 -0
  15. core/connectors/formats/csv_connector.py +58 -0
  16. core/connectors/formats/excel_connector.py +106 -0
  17. core/connectors/formats/json_connector.py +145 -0
  18. core/connectors/formats/parquet_connector.py +92 -0
  19. core/connectors/registry.py +85 -0
  20. core/discovery.py +241 -0
  21. core/incremental.py +322 -0
  22. core/ingestion.py +227 -0
  23. core/lineage.py +234 -0
  24. core/metadata.py +12 -0
  25. core/notifications.py +229 -0
  26. core/observability.py +262 -0
  27. core/pipeline.py +126 -0
  28. core/quality.py +44 -0
  29. core/quality_report.py +32 -0
  30. core/quality_rules.py +301 -0
  31. core/reporting.py +82 -0
  32. core/scheduler.py +188 -0
  33. core/schema.py +276 -0
  34. core/validation.py +36 -0
  35. core/warehouse.py +5 -0
  36. models/__init__.py +0 -0
  37. models/dataset.py +49 -0
  38. models/pipeline_run.py +23 -0
  39. openingest/__init__.py +3 -0
  40. openingest/cli.py +5 -0
  41. openingest/templates/__init__.py +1 -0
  42. openingest/templates/project/.openingest +1 -0
  43. openingest/templates/project/README.md +29 -0
  44. openingest/templates/project/configs/datasets.yaml +19 -0
  45. openingest/templates/project/configs/pipeline.yaml +2 -0
  46. openingest/templates/project/configs/validation_rules.yaml +1 -0
  47. openingest/templates/project/configs/warehouse.yaml +2 -0
  48. openingest/templates/project/data/raw/.gitkeep +1 -0
  49. openingest/templates/project/docker-compose.yml +16 -0
  50. openingest/templates/project/plugins/.gitkeep +1 -0
  51. openingest/templates/project/reports/.gitkeep +1 -0
  52. openingest/templates/project/sql/.gitkeep +1 -0
  53. openingest-2.5.0.dist-info/METADATA +299 -0
  54. openingest-2.5.0.dist-info/RECORD +91 -0
  55. openingest-2.5.0.dist-info/WHEEL +5 -0
  56. openingest-2.5.0.dist-info/entry_points.txt +2 -0
  57. openingest-2.5.0.dist-info/licenses/LICENSE +21 -0
  58. openingest-2.5.0.dist-info/top_level.txt +5 -0
  59. scripts/__init__.py +0 -0
  60. scripts/commands/__init__.py +0 -0
  61. scripts/commands/add_dataset.py +102 -0
  62. scripts/commands/airflow_cmd.py +58 -0
  63. scripts/commands/discover.py +81 -0
  64. scripts/commands/docker_cmd.py +85 -0
  65. scripts/commands/doctor.py +108 -0
  66. scripts/commands/graph.py +59 -0
  67. scripts/commands/infer.py +110 -0
  68. scripts/commands/init.py +75 -0
  69. scripts/commands/profile.py +100 -0
  70. scripts/commands/schedule.py +65 -0
  71. scripts/commands/version.py +22 -0
  72. scripts/dashboard.py +149 -0
  73. scripts/data_quality_checks.py +69 -0
  74. scripts/ingest_customers.py +41 -0
  75. scripts/ingest_orders.py +40 -0
  76. scripts/ingest_products.py +36 -0
  77. scripts/load_warehouse.py +21 -0
  78. scripts/openingest.py +293 -0
  79. scripts/pipeline_history.py +34 -0
  80. scripts/report.py +5 -0
  81. scripts/run_pipeline.py +5 -0
  82. scripts/setup_database.py +40 -0
  83. scripts/transform_data.py +0 -0
  84. utils/__init__.py +0 -0
  85. utils/config.py +24 -0
  86. utils/config_loader.py +38 -0
  87. utils/db.py +11 -0
  88. utils/logger.py +0 -0
  89. utils/metadata_logger.py +195 -0
  90. utils/project.py +43 -0
  91. utils/schema_utils.py +15 -0
core/__init__.py ADDED
File without changes
File without changes
core/airflow/runner.py ADDED
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ def _load_env() -> None:
9
+ """
10
+ Load .env into os.environ before any task runs.
11
+ Airflow workers don't inherit the host .env, so we do it explicitly.
12
+ The .env is mounted into the container at /opt/airflow/.env.
13
+ """
14
+ candidates = [
15
+ Path("/opt/airflow/.env"),
16
+ Path(__file__).resolve().parents[2] / ".env",
17
+ ]
18
+ for env_path in candidates:
19
+ if env_path.exists():
20
+ for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
21
+ line = line.strip()
22
+ if line and not line.startswith("#") and "=" in line:
23
+ k, _, v = line.partition("=")
24
+ k = k.strip()
25
+ v = v.strip().strip('"').strip("'")
26
+ if k and k not in os.environ:
27
+ os.environ[k] = v
28
+ break
29
+
30
+
31
+ # Load on module import so all tasks in this worker process get the env
32
+ _load_env()
33
+
34
+ from core.discovery import discover_datasets # noqa: E402
35
+ from core.quality import run_quality_checks # noqa: E402
36
+ from core.reporting import pipeline_report # noqa: E402
37
+ from core.validation import validate_dataset # noqa: E402
38
+ from core.ingestion import ingest_dataset, _read_dataset # noqa: E402
39
+ from models.dataset import Dataset # noqa: E402
40
+ from utils.metadata_logger import MetadataLogger # noqa: E402
41
+
42
+
43
+ def _get_dataset(dataset_name: str) -> Dataset:
44
+ dataset = next(
45
+ (d for d in discover_datasets() if d.name == dataset_name),
46
+ None,
47
+ )
48
+ if dataset is None:
49
+ raise ValueError(f"Dataset '{dataset_name}' not found.")
50
+ if not dataset.registered:
51
+ raise ValueError(f"Dataset '{dataset_name}' is not registered in datasets.yaml.")
52
+ return dataset
53
+
54
+
55
+ def run_discover(dataset_name: str) -> Dict[str, Any]:
56
+ """Discover dataset and confirm registration."""
57
+ dataset = _get_dataset(dataset_name)
58
+ return {
59
+ "dataset": dataset.name,
60
+ "file": str(dataset.file),
61
+ "registered": dataset.registered,
62
+ "columns": dataset.columns,
63
+ "rows": dataset.rows,
64
+ }
65
+
66
+
67
+ def run_schema_validation(dataset_name: str) -> Dict[str, Any]:
68
+ """Validate required columns against datasets.yaml config."""
69
+ dataset = _get_dataset(dataset_name)
70
+ result = validate_dataset(dataset)
71
+
72
+ if not result["valid"]:
73
+ raise ValueError(
74
+ f"Schema validation failed for '{dataset_name}'. "
75
+ f"Missing: {result['missing']}. Extra: {result['extra']}."
76
+ )
77
+
78
+ return {
79
+ "dataset": dataset_name,
80
+ "valid": True,
81
+ "missing": result["missing"],
82
+ "extra": result["extra"],
83
+ }
84
+
85
+
86
+ def run_quality_check(dataset_name: str, run_id: Optional[str] = None) -> Dict[str, Any]:
87
+ """Run quality checks. Reads data once and passes df to avoid double download."""
88
+ dataset = _get_dataset(dataset_name)
89
+
90
+ df = _read_dataset(dataset)
91
+ dataset.columns = list(df.columns)
92
+ dataset.rows = len(df)
93
+
94
+ result = run_quality_checks(dataset, df=df)
95
+
96
+ if run_id:
97
+ MetadataLogger().log_quality_result(run_id, dataset, result)
98
+
99
+ if not result["passed"]:
100
+ raise ValueError(
101
+ f"Quality check FAILED for '{dataset_name}': "
102
+ f"score={result['score']:.2f}%, "
103
+ f"failed_checks={result['checks_failed']}"
104
+ )
105
+
106
+ return result
107
+
108
+
109
+ def run_ingest(dataset_name: str, run_id: Optional[str] = None) -> Dict[str, Any]:
110
+ """Ingest dataset using configured load strategy."""
111
+ dataset = _get_dataset(dataset_name)
112
+ dataset = ingest_dataset(dataset)
113
+
114
+ if run_id:
115
+ logger = MetadataLogger()
116
+ run = logger.create_pipeline_run()
117
+ run.run_id = run_id
118
+ logger.log_dataset(run, dataset)
119
+
120
+ return {
121
+ "dataset": dataset.name,
122
+ "rows_loaded": dataset.rows_loaded,
123
+ "load_strategy": dataset.load_strategy,
124
+ "load_mode": dataset.load_mode,
125
+ "watermark_value": dataset.watermark_value,
126
+ "status": dataset.load_status,
127
+ }
128
+
129
+
130
+ def run_pipeline_report() -> None:
131
+ """Print execution report for the latest pipeline run."""
132
+ pipeline_report()
@@ -0,0 +1,37 @@
1
+ from typing import Any
2
+
3
+ from airflow.operators.python import PythonOperator
4
+
5
+ from core.airflow.runner import run_ingest, run_quality_check
6
+
7
+
8
+ def create_ingestion_task(dag: Any, dataset: Any) -> PythonOperator:
9
+ """
10
+ Creates one Airflow task for one dataset.
11
+ """
12
+
13
+ return PythonOperator(
14
+ task_id=f"ingest_{dataset.name}",
15
+ python_callable=run_ingest,
16
+ op_kwargs={
17
+ "dataset_name": dataset.name,
18
+ "run_id": "{{ dag_run.run_id }}",
19
+ },
20
+ dag=dag,
21
+ )
22
+
23
+
24
+ def create_quality_task(dag: Any, dataset: Any) -> PythonOperator:
25
+ """
26
+ Creates one Airflow quality task for one dataset.
27
+ """
28
+
29
+ return PythonOperator(
30
+ task_id=f"quality_{dataset.name}",
31
+ python_callable=run_quality_check,
32
+ op_kwargs={
33
+ "dataset_name": dataset.name,
34
+ "run_id": "{{ dag_run.run_id }}",
35
+ },
36
+ dag=dag,
37
+ )
core/airflow_runner.py ADDED
@@ -0,0 +1,55 @@
1
+ from typing import Any, Dict
2
+
3
+ from core.discovery import discover_datasets
4
+ from core.ingestion import ingest_dataset
5
+
6
+
7
+ def run_dataset(dataset_name: str) -> Dict[str, Any]:
8
+ """
9
+ Airflow entrypoint.
10
+
11
+ Executes ingestion for one dataset.
12
+ """
13
+
14
+ datasets = discover_datasets()
15
+
16
+ dataset = next(
17
+ (
18
+ d
19
+ for d in datasets
20
+ if d.name == dataset_name
21
+ ),
22
+ None,
23
+ )
24
+
25
+ if dataset is None:
26
+ raise ValueError(
27
+ f"Dataset '{dataset_name}' not found."
28
+ )
29
+
30
+ if not dataset.registered:
31
+ print(
32
+ f"Skipping '{dataset_name}' "
33
+ f"(dataset not registered)"
34
+ )
35
+
36
+ return {
37
+ "dataset": dataset_name,
38
+ "status": "SKIPPED",
39
+ "rows_loaded": 0,
40
+ }
41
+
42
+ dataset = ingest_dataset(dataset)
43
+
44
+ print(
45
+ f"\nFinished {dataset.name}"
46
+ f"\nRows Loaded : {dataset.rows_loaded:,}"
47
+ f"\nStatus : {dataset.load_status}"
48
+ )
49
+
50
+ # Airflow XCom supports JSON only
51
+ return {
52
+ "dataset": dataset.name,
53
+ "rows_loaded": dataset.rows_loaded,
54
+ "status": dataset.load_status,
55
+ }
@@ -0,0 +1,10 @@
1
+ """
2
+ OpenIngest connector package.
3
+
4
+ Exposes the ConnectorRegistry and all built-in source connectors.
5
+ """
6
+
7
+ from core.connectors.registry import ConnectorRegistry
8
+ from core.connectors.base import BaseConnector
9
+
10
+ __all__ = ["ConnectorRegistry", "BaseConnector"]
@@ -0,0 +1 @@
1
+ # API connectors: REST
@@ -0,0 +1,338 @@
1
+ """
2
+ REST API connector.
3
+
4
+ Fetches data from any HTTP/HTTPS endpoint and returns a DataFrame.
5
+
6
+ Supports:
7
+ - GET and POST requests
8
+ - Bearer token / API key authentication
9
+ - Environment variable expansion in headers and params
10
+ - Automatic pagination (offset-based or cursor-based)
11
+ - JSON response normalization via record_path
12
+
13
+ Requires: requests
14
+ Install with: pip install requests
15
+
16
+ Config example
17
+ --------------
18
+ source:
19
+ type: rest
20
+ url: https://api.company.com/orders
21
+ method: GET
22
+ headers:
23
+ Authorization: Bearer ${ORDERS_API_TOKEN}
24
+ Accept: application/json
25
+ params:
26
+ limit: 500
27
+ status: active
28
+ record_path: data # path to the array in the JSON response
29
+ pagination:
30
+ type: offset # 'offset' or 'cursor'
31
+ param: offset # query param name for offset
32
+ limit_param: limit # query param name for page size
33
+ limit: 500 # rows per page
34
+ max_pages: 20 # safety limit
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import os
40
+ import time
41
+ from typing import Any, Dict, List, Optional
42
+
43
+ import pandas as pd
44
+
45
+ from core.connectors.base import BaseConnector, ConnectorError
46
+
47
+
48
+ def _resolve_env_in_str(value: str) -> str:
49
+ """Expand ${VAR_NAME} references in a string."""
50
+ if not isinstance(value, str):
51
+ return value
52
+ if value.startswith("${") and value.endswith("}"):
53
+ var = value[2:-1]
54
+ resolved = os.environ.get(var)
55
+ if resolved is None:
56
+ raise ConnectorError(
57
+ f"Environment variable '{var}' is not set. "
58
+ f"Set it before running OpenIngest: export {var}=..."
59
+ )
60
+ return resolved
61
+ return value
62
+
63
+
64
+ def _resolve_env_in_dict(d: Dict[str, Any]) -> Dict[str, Any]:
65
+ """Recursively expand ${VAR} references in all string values of a dict."""
66
+ return {k: _resolve_env_in_str(str(v)) if isinstance(v, str) else v for k, v in d.items()}
67
+
68
+
69
+ def _get_nested(obj: Any, path: str) -> Any:
70
+ """Navigate a dot-separated key path through a nested dict/list."""
71
+ current = obj
72
+ for part in path.split("."):
73
+ if isinstance(current, dict):
74
+ current = current.get(part)
75
+ elif isinstance(current, list) and part.isdigit():
76
+ current = current[int(part)]
77
+ else:
78
+ return None
79
+ return current
80
+
81
+
82
+ class RestApiConnector(BaseConnector):
83
+ """
84
+ Fetch data from a REST API endpoint into a DataFrame.
85
+
86
+ Config keys
87
+ -----------
88
+ url : str
89
+ Full URL of the endpoint.
90
+ method : str, optional
91
+ HTTP method: 'GET' (default) or 'POST'.
92
+ headers : dict, optional
93
+ Request headers. Values may use ${ENV_VAR} expansion.
94
+ params : dict, optional
95
+ Query parameters for GET requests.
96
+ body : dict, optional
97
+ JSON request body for POST requests.
98
+ record_path : str, optional
99
+ Dot-separated path to the array of records in the JSON response.
100
+ Example: "data" for {"data": [...]}
101
+ Example: "results.items" for {"results": {"items": [...]}}
102
+ timeout : int, optional
103
+ Request timeout in seconds. Defaults to 30.
104
+ retry_count : int, optional
105
+ Number of retries on network errors. Defaults to 3.
106
+ retry_delay : float, optional
107
+ Seconds to wait between retries. Defaults to 1.0.
108
+ pagination : dict, optional
109
+ Pagination config (see below).
110
+ verify_ssl : bool, optional
111
+ Whether to verify SSL certificates. Defaults to True.
112
+
113
+ Pagination config (offset-based)
114
+ ---------------------------------
115
+ pagination:
116
+ type: offset
117
+ param: offset # query param name for the offset counter
118
+ limit_param: limit # query param name for page size
119
+ limit: 500 # rows per page
120
+ max_pages: 50 # safety limit to prevent infinite loops
121
+
122
+ Pagination config (cursor-based)
123
+ ---------------------------------
124
+ pagination:
125
+ type: cursor
126
+ cursor_path: meta.next_cursor # path to next cursor in response JSON
127
+ param: cursor # query param name to pass cursor
128
+ max_pages: 50
129
+
130
+ Example
131
+ -------
132
+ source:
133
+ type: rest
134
+ url: https://api.stripe.com/v1/charges
135
+ method: GET
136
+ headers:
137
+ Authorization: Bearer ${STRIPE_API_KEY}
138
+ record_path: data
139
+ pagination:
140
+ type: cursor
141
+ cursor_path: has_more
142
+ param: starting_after
143
+ max_pages: 100
144
+ """
145
+
146
+ RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
147
+
148
+ def read(self) -> pd.DataFrame:
149
+ self.validate_config()
150
+
151
+ try:
152
+ import requests # type: ignore[import]
153
+ except ImportError:
154
+ raise ConnectorError(
155
+ "requests is required for REST API connectors. "
156
+ "Install with: pip install requests"
157
+ )
158
+
159
+ url: str = self.config["url"]
160
+ method: str = self.config.get("method", "GET").upper()
161
+ headers: Dict[str, str] = _resolve_env_in_dict(self.config.get("headers", {}))
162
+ params: Dict[str, Any] = dict(self.config.get("params", {}))
163
+ body: Optional[Dict[str, Any]] = self.config.get("body")
164
+ record_path: Optional[str] = self.config.get("record_path")
165
+ timeout: int = int(self.config.get("timeout", 30))
166
+ retry_count: int = int(self.config.get("retry_count", 3))
167
+ retry_delay: float = float(self.config.get("retry_delay", 1.0))
168
+ verify_ssl: bool = bool(self.config.get("verify_ssl", True))
169
+
170
+ pagination = self.config.get("pagination")
171
+
172
+ if pagination:
173
+ all_records = self._fetch_paginated(
174
+ requests=requests,
175
+ url=url,
176
+ method=method,
177
+ headers=headers,
178
+ params=params,
179
+ body=body,
180
+ record_path=record_path,
181
+ pagination=pagination,
182
+ timeout=timeout,
183
+ verify_ssl=verify_ssl,
184
+ )
185
+ return pd.json_normalize(all_records) if all_records else pd.DataFrame()
186
+
187
+ # Single-page fetch
188
+ raw = self._fetch_with_retry(
189
+ requests=requests,
190
+ url=url,
191
+ method=method,
192
+ headers=headers,
193
+ params=params,
194
+ body=body,
195
+ timeout=timeout,
196
+ retry_count=retry_count,
197
+ retry_delay=retry_delay,
198
+ verify_ssl=verify_ssl,
199
+ )
200
+
201
+ return self._to_dataframe(raw, record_path)
202
+
203
+ def _fetch_with_retry(
204
+ self,
205
+ requests: Any,
206
+ url: str,
207
+ method: str,
208
+ headers: Dict[str, str],
209
+ params: Dict[str, Any],
210
+ body: Optional[Dict[str, Any]],
211
+ timeout: int,
212
+ retry_count: int,
213
+ retry_delay: float,
214
+ verify_ssl: bool,
215
+ ) -> Any:
216
+ last_exc: Optional[Exception] = None
217
+
218
+ for attempt in range(retry_count + 1):
219
+ try:
220
+ if method == "GET":
221
+ resp = requests.get(url, headers=headers, params=params, timeout=timeout, verify=verify_ssl)
222
+ elif method == "POST":
223
+ resp = requests.post(url, headers=headers, json=body or {}, timeout=timeout, verify=verify_ssl)
224
+ else:
225
+ raise ConnectorError(f"Unsupported HTTP method: '{method}'. Use GET or POST.")
226
+
227
+ if resp.status_code in self.RETRY_STATUS_CODES and attempt < retry_count:
228
+ time.sleep(retry_delay * (attempt + 1))
229
+ continue
230
+
231
+ if not resp.ok:
232
+ raise ConnectorError(
233
+ f"API request failed: {resp.status_code} {resp.reason} — {url}\n"
234
+ f"Response: {resp.text[:500]}"
235
+ )
236
+
237
+ return resp.json()
238
+
239
+ except ConnectorError:
240
+ raise
241
+ except Exception as exc:
242
+ last_exc = exc
243
+ if attempt < retry_count:
244
+ time.sleep(retry_delay)
245
+ continue
246
+ raise ConnectorError(
247
+ f"Network error fetching '{url}' (attempt {attempt + 1}): {exc}"
248
+ ) from exc
249
+
250
+ raise ConnectorError(f"All {retry_count + 1} attempts failed for '{url}'.") from last_exc
251
+
252
+ def _fetch_paginated(
253
+ self,
254
+ requests: Any,
255
+ url: str,
256
+ method: str,
257
+ headers: Dict[str, str],
258
+ params: Dict[str, Any],
259
+ body: Optional[Dict[str, Any]],
260
+ record_path: Optional[str],
261
+ pagination: Dict[str, Any],
262
+ timeout: int,
263
+ verify_ssl: bool,
264
+ ) -> List[Any]:
265
+ page_type: str = pagination.get("type", "offset")
266
+ max_pages: int = int(pagination.get("max_pages", 50))
267
+ all_records: List[Any] = []
268
+
269
+ if page_type == "offset":
270
+ limit: int = int(pagination.get("limit", 500))
271
+ offset_param: str = pagination.get("param", "offset")
272
+ limit_param: str = pagination.get("limit_param", "limit")
273
+ current_offset = 0
274
+
275
+ for page in range(max_pages):
276
+ page_params = {**params, limit_param: limit, offset_param: current_offset}
277
+ raw = self._fetch_with_retry(
278
+ requests=requests, url=url, method=method, headers=headers,
279
+ params=page_params, body=body, timeout=timeout,
280
+ retry_count=3, retry_delay=1.0, verify_ssl=verify_ssl,
281
+ )
282
+ records = self._extract_records(raw, record_path)
283
+ all_records.extend(records)
284
+
285
+ if not records or len(records) < limit:
286
+ break # Last page
287
+
288
+ current_offset += limit
289
+
290
+ elif page_type == "cursor":
291
+ cursor_path: str = pagination.get("cursor_path", "next_cursor")
292
+ cursor_param: str = pagination.get("param", "cursor")
293
+ cursor: Optional[str] = None
294
+
295
+ for page in range(max_pages):
296
+ page_params = {**params}
297
+ if cursor:
298
+ page_params[cursor_param] = cursor
299
+
300
+ raw = self._fetch_with_retry(
301
+ requests=requests, url=url, method=method, headers=headers,
302
+ params=page_params, body=body, timeout=timeout,
303
+ retry_count=3, retry_delay=1.0, verify_ssl=verify_ssl,
304
+ )
305
+ records = self._extract_records(raw, record_path)
306
+ all_records.extend(records)
307
+
308
+ next_cursor = _get_nested(raw, cursor_path)
309
+ if not next_cursor or not records:
310
+ break
311
+ cursor = str(next_cursor)
312
+
313
+ return all_records
314
+
315
+ def _extract_records(self, raw: Any, record_path: Optional[str]) -> List[Any]:
316
+ if record_path:
317
+ data = _get_nested(raw, record_path)
318
+ if data is None:
319
+ return []
320
+ if isinstance(data, list):
321
+ return data
322
+ return [data]
323
+ if isinstance(raw, list):
324
+ return raw
325
+ return [raw]
326
+
327
+ def _to_dataframe(self, raw: Any, record_path: Optional[str]) -> pd.DataFrame:
328
+ records = self._extract_records(raw, record_path)
329
+ if not records:
330
+ return pd.DataFrame()
331
+ try:
332
+ return pd.json_normalize(records)
333
+ except Exception as exc:
334
+ raise ConnectorError(f"Failed to normalize API response to DataFrame: {exc}") from exc
335
+
336
+ def validate_config(self) -> None:
337
+ if not self.config.get("url"):
338
+ raise ConnectorError("RestApiConnector requires 'url' in source config.")
@@ -0,0 +1,52 @@
1
+ """
2
+ Base connector interface.
3
+
4
+ All connectors must implement `read() -> pd.DataFrame`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from typing import Any, Dict
11
+
12
+ import pandas as pd
13
+
14
+
15
+ class BaseConnector(ABC):
16
+ """
17
+ Abstract base class for all OpenIngest source connectors.
18
+
19
+ Subclass this and implement `read()` to create a custom connector.
20
+ Register it with:
21
+
22
+ ConnectorRegistry.register("my_type", MyConnector)
23
+
24
+ Then use it in datasets.yaml:
25
+
26
+ source:
27
+ type: my_type
28
+ ...
29
+ """
30
+
31
+ def __init__(self, source_config: Dict[str, Any]) -> None:
32
+ self.config = source_config
33
+
34
+ @abstractmethod
35
+ def read(self) -> pd.DataFrame:
36
+ """
37
+ Load data from the source and return a DataFrame.
38
+ Raise ConnectorError on any failure.
39
+ """
40
+ ...
41
+
42
+ def validate_config(self) -> None:
43
+ """
44
+ Optional: validate required config keys before read().
45
+ Override to add connector-specific checks.
46
+ """
47
+ pass
48
+
49
+
50
+ class ConnectorError(Exception):
51
+ """Raised when a connector fails to read data."""
52
+ pass
@@ -0,0 +1 @@
1
+ # Cloud storage connectors: S3, Azure Blob, GCS