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.
- core/__init__.py +0 -0
- core/airflow/__init__.py +0 -0
- core/airflow/runner.py +132 -0
- core/airflow/task_factory.py +37 -0
- core/airflow_runner.py +55 -0
- core/connectors/__init__.py +10 -0
- core/connectors/api/__init__.py +1 -0
- core/connectors/api/rest_connector.py +338 -0
- core/connectors/base.py +52 -0
- core/connectors/cloud/__init__.py +1 -0
- core/connectors/cloud/azure_connector.py +185 -0
- core/connectors/cloud/gcs_connector.py +172 -0
- core/connectors/cloud/s3_connector.py +189 -0
- core/connectors/formats/__init__.py +1 -0
- core/connectors/formats/csv_connector.py +58 -0
- core/connectors/formats/excel_connector.py +106 -0
- core/connectors/formats/json_connector.py +145 -0
- core/connectors/formats/parquet_connector.py +92 -0
- core/connectors/registry.py +85 -0
- core/discovery.py +241 -0
- core/incremental.py +322 -0
- core/ingestion.py +227 -0
- core/lineage.py +234 -0
- core/metadata.py +12 -0
- core/notifications.py +229 -0
- core/observability.py +262 -0
- core/pipeline.py +126 -0
- core/quality.py +44 -0
- core/quality_report.py +32 -0
- core/quality_rules.py +301 -0
- core/reporting.py +82 -0
- core/scheduler.py +188 -0
- core/schema.py +276 -0
- core/validation.py +36 -0
- core/warehouse.py +5 -0
- models/__init__.py +0 -0
- models/dataset.py +49 -0
- models/pipeline_run.py +23 -0
- openingest/__init__.py +3 -0
- openingest/cli.py +5 -0
- openingest/templates/__init__.py +1 -0
- openingest/templates/project/.openingest +1 -0
- openingest/templates/project/README.md +29 -0
- openingest/templates/project/configs/datasets.yaml +19 -0
- openingest/templates/project/configs/pipeline.yaml +2 -0
- openingest/templates/project/configs/validation_rules.yaml +1 -0
- openingest/templates/project/configs/warehouse.yaml +2 -0
- openingest/templates/project/data/raw/.gitkeep +1 -0
- openingest/templates/project/docker-compose.yml +16 -0
- openingest/templates/project/plugins/.gitkeep +1 -0
- openingest/templates/project/reports/.gitkeep +1 -0
- openingest/templates/project/sql/.gitkeep +1 -0
- openingest-2.5.0.dist-info/METADATA +299 -0
- openingest-2.5.0.dist-info/RECORD +91 -0
- openingest-2.5.0.dist-info/WHEEL +5 -0
- openingest-2.5.0.dist-info/entry_points.txt +2 -0
- openingest-2.5.0.dist-info/licenses/LICENSE +21 -0
- openingest-2.5.0.dist-info/top_level.txt +5 -0
- scripts/__init__.py +0 -0
- scripts/commands/__init__.py +0 -0
- scripts/commands/add_dataset.py +102 -0
- scripts/commands/airflow_cmd.py +58 -0
- scripts/commands/discover.py +81 -0
- scripts/commands/docker_cmd.py +85 -0
- scripts/commands/doctor.py +108 -0
- scripts/commands/graph.py +59 -0
- scripts/commands/infer.py +110 -0
- scripts/commands/init.py +75 -0
- scripts/commands/profile.py +100 -0
- scripts/commands/schedule.py +65 -0
- scripts/commands/version.py +22 -0
- scripts/dashboard.py +149 -0
- scripts/data_quality_checks.py +69 -0
- scripts/ingest_customers.py +41 -0
- scripts/ingest_orders.py +40 -0
- scripts/ingest_products.py +36 -0
- scripts/load_warehouse.py +21 -0
- scripts/openingest.py +293 -0
- scripts/pipeline_history.py +34 -0
- scripts/report.py +5 -0
- scripts/run_pipeline.py +5 -0
- scripts/setup_database.py +40 -0
- scripts/transform_data.py +0 -0
- utils/__init__.py +0 -0
- utils/config.py +24 -0
- utils/config_loader.py +38 -0
- utils/db.py +11 -0
- utils/logger.py +0 -0
- utils/metadata_logger.py +195 -0
- utils/project.py +43 -0
- utils/schema_utils.py +15 -0
core/__init__.py
ADDED
|
File without changes
|
core/airflow/__init__.py
ADDED
|
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.")
|
core/connectors/base.py
ADDED
|
@@ -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
|