etlplus 0.15.0__py3-none-any.whl → 0.16.6__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 (130) hide show
  1. etlplus/README.md +25 -3
  2. etlplus/__init__.py +2 -0
  3. etlplus/api/README.md +31 -0
  4. etlplus/api/__init__.py +14 -14
  5. etlplus/api/auth.py +10 -7
  6. etlplus/api/config.py +8 -13
  7. etlplus/api/endpoint_client.py +20 -20
  8. etlplus/api/errors.py +4 -4
  9. etlplus/api/pagination/__init__.py +6 -6
  10. etlplus/api/pagination/config.py +12 -10
  11. etlplus/api/pagination/paginator.py +6 -7
  12. etlplus/api/rate_limiting/__init__.py +2 -2
  13. etlplus/api/rate_limiting/config.py +14 -14
  14. etlplus/api/rate_limiting/rate_limiter.py +3 -3
  15. etlplus/api/request_manager.py +4 -4
  16. etlplus/api/retry_manager.py +8 -8
  17. etlplus/api/transport.py +11 -11
  18. etlplus/api/types.py +131 -11
  19. etlplus/api/utils.py +50 -50
  20. etlplus/cli/commands.py +93 -60
  21. etlplus/cli/constants.py +1 -1
  22. etlplus/cli/handlers.py +43 -26
  23. etlplus/cli/io.py +2 -2
  24. etlplus/cli/main.py +2 -2
  25. etlplus/cli/state.py +4 -7
  26. etlplus/{workflow/pipeline.py → config.py} +62 -99
  27. etlplus/connector/__init__.py +43 -0
  28. etlplus/connector/api.py +161 -0
  29. etlplus/connector/connector.py +26 -0
  30. etlplus/connector/core.py +132 -0
  31. etlplus/connector/database.py +122 -0
  32. etlplus/connector/enums.py +52 -0
  33. etlplus/connector/file.py +120 -0
  34. etlplus/connector/types.py +40 -0
  35. etlplus/connector/utils.py +122 -0
  36. etlplus/database/ddl.py +2 -2
  37. etlplus/database/engine.py +19 -3
  38. etlplus/database/orm.py +2 -0
  39. etlplus/enums.py +36 -200
  40. etlplus/file/_imports.py +1 -0
  41. etlplus/file/_io.py +52 -4
  42. etlplus/file/accdb.py +3 -2
  43. etlplus/file/arrow.py +3 -2
  44. etlplus/file/avro.py +3 -2
  45. etlplus/file/bson.py +3 -2
  46. etlplus/file/cbor.py +3 -2
  47. etlplus/file/cfg.py +3 -2
  48. etlplus/file/conf.py +3 -2
  49. etlplus/file/core.py +11 -8
  50. etlplus/file/csv.py +3 -2
  51. etlplus/file/dat.py +3 -2
  52. etlplus/file/dta.py +3 -2
  53. etlplus/file/duckdb.py +3 -2
  54. etlplus/file/enums.py +1 -1
  55. etlplus/file/feather.py +3 -2
  56. etlplus/file/fwf.py +3 -2
  57. etlplus/file/gz.py +3 -2
  58. etlplus/file/hbs.py +3 -2
  59. etlplus/file/hdf5.py +3 -2
  60. etlplus/file/ini.py +3 -2
  61. etlplus/file/ion.py +3 -2
  62. etlplus/file/jinja2.py +3 -2
  63. etlplus/file/json.py +5 -16
  64. etlplus/file/log.py +3 -2
  65. etlplus/file/mat.py +3 -2
  66. etlplus/file/mdb.py +3 -2
  67. etlplus/file/msgpack.py +3 -2
  68. etlplus/file/mustache.py +3 -2
  69. etlplus/file/nc.py +3 -2
  70. etlplus/file/ndjson.py +3 -2
  71. etlplus/file/numbers.py +3 -2
  72. etlplus/file/ods.py +3 -2
  73. etlplus/file/orc.py +3 -2
  74. etlplus/file/parquet.py +3 -2
  75. etlplus/file/pb.py +3 -2
  76. etlplus/file/pbf.py +3 -2
  77. etlplus/file/properties.py +3 -2
  78. etlplus/file/proto.py +3 -2
  79. etlplus/file/psv.py +3 -2
  80. etlplus/file/rda.py +3 -2
  81. etlplus/file/rds.py +3 -2
  82. etlplus/file/sas7bdat.py +3 -2
  83. etlplus/file/sav.py +3 -2
  84. etlplus/file/sqlite.py +3 -2
  85. etlplus/file/stub.py +1 -0
  86. etlplus/file/sylk.py +3 -2
  87. etlplus/file/tab.py +3 -2
  88. etlplus/file/toml.py +3 -2
  89. etlplus/file/tsv.py +3 -2
  90. etlplus/file/txt.py +4 -3
  91. etlplus/file/vm.py +3 -2
  92. etlplus/file/wks.py +3 -2
  93. etlplus/file/xls.py +3 -2
  94. etlplus/file/xlsm.py +3 -2
  95. etlplus/file/xlsx.py +3 -2
  96. etlplus/file/xml.py +9 -3
  97. etlplus/file/xpt.py +3 -2
  98. etlplus/file/yaml.py +5 -16
  99. etlplus/file/zip.py +3 -2
  100. etlplus/file/zsav.py +3 -2
  101. etlplus/ops/__init__.py +1 -0
  102. etlplus/ops/enums.py +173 -0
  103. etlplus/ops/extract.py +222 -23
  104. etlplus/ops/load.py +155 -36
  105. etlplus/ops/run.py +92 -107
  106. etlplus/ops/transform.py +48 -29
  107. etlplus/ops/types.py +147 -0
  108. etlplus/ops/utils.py +11 -40
  109. etlplus/ops/validate.py +16 -16
  110. etlplus/types.py +6 -102
  111. etlplus/utils.py +163 -29
  112. etlplus/workflow/README.md +0 -24
  113. etlplus/workflow/__init__.py +2 -15
  114. etlplus/workflow/dag.py +23 -1
  115. etlplus/workflow/jobs.py +83 -39
  116. etlplus/workflow/profile.py +4 -2
  117. {etlplus-0.15.0.dist-info → etlplus-0.16.6.dist-info}/METADATA +4 -4
  118. etlplus-0.16.6.dist-info/RECORD +143 -0
  119. {etlplus-0.15.0.dist-info → etlplus-0.16.6.dist-info}/WHEEL +1 -1
  120. etlplus/config/README.md +0 -50
  121. etlplus/config/__init__.py +0 -33
  122. etlplus/config/types.py +0 -140
  123. etlplus/dag.py +0 -103
  124. etlplus/workflow/connector.py +0 -373
  125. etlplus/workflow/types.py +0 -115
  126. etlplus/workflow/utils.py +0 -120
  127. etlplus-0.15.0.dist-info/RECORD +0 -139
  128. {etlplus-0.15.0.dist-info → etlplus-0.16.6.dist-info}/entry_points.txt +0 -0
  129. {etlplus-0.15.0.dist-info → etlplus-0.16.6.dist-info}/licenses/LICENSE +0 -0
  130. {etlplus-0.15.0.dist-info → etlplus-0.16.6.dist-info}/top_level.txt +0 -0
etlplus/ops/enums.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ :mod:`etlplus.ops.enums` module.
3
+
4
+ Operation-specific enums and helpers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import operator as _op
10
+ from statistics import fmean
11
+
12
+ from ..enums import CoercibleStrEnum
13
+ from ..types import StrStrMap
14
+ from .types import AggregateFunc
15
+ from .types import OperatorFunc
16
+
17
+ # SECTION: EXPORTS ========================================================= #
18
+
19
+
20
+ __all__ = [
21
+ # Enums
22
+ 'AggregateName',
23
+ 'OperatorName',
24
+ 'PipelineStep',
25
+ ]
26
+
27
+
28
+ # SECTION: ENUMS ============================================================ #
29
+
30
+
31
+ class AggregateName(CoercibleStrEnum):
32
+ """Supported aggregations with helpers."""
33
+
34
+ # -- Constants -- #
35
+
36
+ AVG = 'avg'
37
+ COUNT = 'count'
38
+ MAX = 'max'
39
+ MIN = 'min'
40
+ SUM = 'sum'
41
+
42
+ # -- Class Methods -- #
43
+
44
+ @property
45
+ def func(self) -> AggregateFunc:
46
+ """
47
+ Get the aggregation function for this aggregation type.
48
+
49
+ Returns
50
+ -------
51
+ AggregateFunc
52
+ The aggregation function corresponding to this aggregation type.
53
+ """
54
+ if self is AggregateName.COUNT:
55
+ return lambda xs, n: n
56
+ if self is AggregateName.MAX:
57
+ return lambda xs, n: (max(xs) if xs else None)
58
+ if self is AggregateName.MIN:
59
+ return lambda xs, n: (min(xs) if xs else None)
60
+ if self is AggregateName.SUM:
61
+ return lambda xs, n: sum(xs)
62
+
63
+ # AVG
64
+ return lambda xs, n: (fmean(xs) if xs else 0.0)
65
+
66
+
67
+ class OperatorName(CoercibleStrEnum):
68
+ """Supported comparison operators with helpers."""
69
+
70
+ # -- Constants -- #
71
+
72
+ EQ = 'eq'
73
+ NE = 'ne'
74
+ GT = 'gt'
75
+ GTE = 'gte'
76
+ LT = 'lt'
77
+ LTE = 'lte'
78
+ IN = 'in'
79
+ CONTAINS = 'contains'
80
+
81
+ # -- Getters -- #
82
+
83
+ @property
84
+ def func(self) -> OperatorFunc:
85
+ """
86
+ Get the comparison function for this operator.
87
+
88
+ Returns
89
+ -------
90
+ OperatorFunc
91
+ The comparison function corresponding to this operator.
92
+ """
93
+ match self:
94
+ case OperatorName.EQ:
95
+ return _op.eq
96
+ case OperatorName.NE:
97
+ return _op.ne
98
+ case OperatorName.GT:
99
+ return _op.gt
100
+ case OperatorName.GTE:
101
+ return _op.ge
102
+ case OperatorName.LT:
103
+ return _op.lt
104
+ case OperatorName.LTE:
105
+ return _op.le
106
+ case OperatorName.IN:
107
+ return lambda a, b: a in b
108
+ case OperatorName.CONTAINS:
109
+ return lambda a, b: b in a
110
+
111
+ # -- Class Methods -- #
112
+
113
+ @classmethod
114
+ def aliases(cls) -> StrStrMap:
115
+ """
116
+ Return a mapping of common aliases for each enum member.
117
+
118
+ Returns
119
+ -------
120
+ StrStrMap
121
+ A mapping of alias names to their corresponding enum member names.
122
+ """
123
+ return {
124
+ '==': 'eq',
125
+ '=': 'eq',
126
+ '!=': 'ne',
127
+ '<>': 'ne',
128
+ '>=': 'gte',
129
+ '≥': 'gte',
130
+ '<=': 'lte',
131
+ '≤': 'lte',
132
+ '>': 'gt',
133
+ '<': 'lt',
134
+ }
135
+
136
+
137
+ class PipelineStep(CoercibleStrEnum):
138
+ """Pipeline step names as an enum for internal orchestration."""
139
+
140
+ # -- Constants -- #
141
+
142
+ FILTER = 'filter'
143
+ MAP = 'map'
144
+ SELECT = 'select'
145
+ SORT = 'sort'
146
+ AGGREGATE = 'aggregate'
147
+
148
+ # -- Getters -- #
149
+
150
+ @property
151
+ def order(self) -> int:
152
+ """
153
+ Get the execution order of this pipeline step.
154
+
155
+ Returns
156
+ -------
157
+ int
158
+ The execution order of this pipeline step.
159
+ """
160
+ return _PIPELINE_ORDER_INDEX[self]
161
+
162
+
163
+ # SECTION: INTERNAL CONSTANTS ============================================== #
164
+
165
+
166
+ # Precomputed order index for PipelineStep; avoids recomputing on each access.
167
+ _PIPELINE_ORDER_INDEX: dict[PipelineStep, int] = {
168
+ PipelineStep.FILTER: 0,
169
+ PipelineStep.MAP: 1,
170
+ PipelineStep.SELECT: 2,
171
+ PipelineStep.SORT: 3,
172
+ PipelineStep.AGGREGATE: 4,
173
+ }
etlplus/ops/extract.py CHANGED
@@ -6,64 +6,199 @@ Helpers to extract data from files, databases, and REST APIs.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from collections.abc import Mapping
9
10
  from pathlib import Path
10
11
  from typing import Any
11
12
  from typing import cast
13
+ from urllib.parse import urlsplit
14
+ from urllib.parse import urlunsplit
12
15
 
16
+ from ..api import EndpointClient
13
17
  from ..api import HttpMethod
18
+ from ..api import PaginationConfigDict
19
+ from ..api import RequestOptions
20
+ from ..api import compose_api_request_env
21
+ from ..api import paginate_with_client
14
22
  from ..api.utils import resolve_request
15
- from ..enums import DataConnectorType
23
+ from ..connector import DataConnectorType
16
24
  from ..file import File
17
25
  from ..file import FileFormat
18
26
  from ..types import JSONData
19
27
  from ..types import JSONDict
20
28
  from ..types import JSONList
21
29
  from ..types import StrPath
30
+ from ..types import Timeout
22
31
 
23
- # SECTION: FUNCTIONS ======================================================== #
32
+ # SECTION: EXPORTS ========================================================== #
24
33
 
25
34
 
26
- def extract_from_api(
27
- url: str,
28
- method: HttpMethod | str = HttpMethod.GET,
29
- **kwargs: Any,
35
+ __all__ = [
36
+ # Functions
37
+ 'extract',
38
+ 'extract_from_api',
39
+ 'extract_from_database',
40
+ 'extract_from_file',
41
+ ]
42
+
43
+
44
+ # SECTION: INTERNAL FUNCTIONS =============================================== #
45
+
46
+
47
+ def _build_client(
48
+ *,
49
+ base_url: str,
50
+ base_path: str | None,
51
+ endpoints: dict[str, str],
52
+ retry: Any,
53
+ retry_network_errors: bool,
54
+ session: Any,
55
+ ) -> EndpointClient:
56
+ """
57
+ Construct an API client with shared defaults.
58
+
59
+ Parameters
60
+ ----------
61
+ base_url : str
62
+ API base URL.
63
+ base_path : str | None
64
+ Base path to prepend for endpoints.
65
+ endpoints : dict[str, str]
66
+ Endpoint name to path mappings.
67
+ retry : Any
68
+ Retry policy configuration.
69
+ retry_network_errors : bool
70
+ Whether to retry on network errors.
71
+ session : Any
72
+ Optional requests session.
73
+
74
+ Returns
75
+ -------
76
+ EndpointClient
77
+ Configured endpoint client instance.
78
+ """
79
+ ClientClass = EndpointClient # noqa: N806
80
+ return ClientClass(
81
+ base_url=base_url,
82
+ base_path=base_path,
83
+ endpoints=endpoints,
84
+ retry=retry,
85
+ retry_network_errors=retry_network_errors,
86
+ session=session,
87
+ )
88
+
89
+
90
+ def _extract_from_api_env(
91
+ env: Mapping[str, Any],
92
+ *,
93
+ use_client: bool,
30
94
  ) -> JSONData:
31
95
  """
32
- Extract data from a REST API.
96
+ Extract API data from a normalized request environment.
33
97
 
34
98
  Parameters
35
99
  ----------
36
- url : str
37
- API endpoint URL.
38
- method : HttpMethod | str, optional
39
- HTTP method to use. Defaults to ``GET``.
40
- **kwargs : Any
41
- Extra arguments forwarded to the underlying ``requests`` call
42
- (for example, ``timeout``). To use a pre-configured
43
- :class:`requests.Session`, provide it via ``session``.
44
- When omitted, ``timeout`` defaults to 10 seconds.
100
+ env : Mapping[str, Any]
101
+ Normalized environment describing API request parameters.
102
+ use_client : bool
103
+ Whether to use the endpoint client/pagination machinery.
45
104
 
46
105
  Returns
47
106
  -------
48
107
  JSONData
49
- Parsed JSON payload, or a fallback object with raw text.
108
+ Extracted payload.
50
109
 
51
110
  Raises
52
111
  ------
53
- TypeError
54
- If a provided ``session`` does not expose the required HTTP
55
- method (for example, ``get``).
112
+ ValueError
113
+ If required parameters are missing.
56
114
  """
57
- timeout = kwargs.pop('timeout', None)
58
- session = kwargs.pop('session', None)
115
+ if (
116
+ use_client
117
+ and env.get('use_endpoints')
118
+ and env.get('base_url')
119
+ and env.get('endpoints_map')
120
+ and env.get('endpoint_key')
121
+ ):
122
+ client = _build_client(
123
+ base_url=cast(str, env.get('base_url')),
124
+ base_path=cast(str | None, env.get('base_path')),
125
+ endpoints=cast(dict[str, str], env.get('endpoints_map', {})),
126
+ retry=env.get('retry'),
127
+ retry_network_errors=bool(env.get('retry_network_errors', False)),
128
+ session=env.get('session'),
129
+ )
130
+ return paginate_with_client(
131
+ client,
132
+ cast(str, env.get('endpoint_key')),
133
+ env.get('params'),
134
+ env.get('headers'),
135
+ env.get('timeout'),
136
+ env.get('pagination'),
137
+ cast(float | None, env.get('sleep_seconds')),
138
+ )
139
+
140
+ url = env.get('url')
141
+ if not url:
142
+ raise ValueError('API source missing URL')
143
+
144
+ if use_client:
145
+ parts = urlsplit(cast(str, url))
146
+ base = urlunsplit((parts.scheme, parts.netloc, '', '', ''))
147
+ client = _build_client(
148
+ base_url=base,
149
+ base_path=None,
150
+ endpoints={},
151
+ retry=env.get('retry'),
152
+ retry_network_errors=bool(env.get('retry_network_errors', False)),
153
+ session=env.get('session'),
154
+ )
155
+ request_options = RequestOptions(
156
+ params=cast(Mapping[str, Any] | None, env.get('params')),
157
+ headers=cast(Mapping[str, str] | None, env.get('headers')),
158
+ timeout=cast(Timeout | None, env.get('timeout')),
159
+ )
160
+
161
+ return client.paginate_url(
162
+ cast(str, url),
163
+ cast(PaginationConfigDict | None, env.get('pagination')),
164
+ request=request_options,
165
+ sleep_seconds=cast(float, env.get('sleep_seconds', 0.0)),
166
+ )
167
+
168
+ method = env.get('method', HttpMethod.GET)
169
+ timeout = env.get('timeout', None)
170
+ session = env.get('session', None)
171
+ request_kwargs = dict(env.get('request_kwargs') or {})
59
172
  request_callable, timeout, _ = resolve_request(
60
173
  method,
61
174
  session=session,
62
175
  timeout=timeout,
63
176
  )
64
- response = request_callable(url, timeout=timeout, **kwargs)
177
+ response = request_callable(
178
+ cast(str, url),
179
+ timeout=timeout,
180
+ **request_kwargs,
181
+ )
65
182
  response.raise_for_status()
183
+ return _parse_api_response(response)
66
184
 
185
+
186
+ def _parse_api_response(
187
+ response: Any,
188
+ ) -> JSONData:
189
+ """
190
+ Parse API responses into a consistent JSON payload.
191
+
192
+ Parameters
193
+ ----------
194
+ response : Any
195
+ HTTP response object exposing ``headers``, ``json()``, and ``text``.
196
+
197
+ Returns
198
+ -------
199
+ JSONData
200
+ Parsed JSON payload, or a fallback object with raw text.
201
+ """
67
202
  content_type = response.headers.get('content-type', '').lower()
68
203
  if 'application/json' in content_type:
69
204
  try:
@@ -87,6 +222,70 @@ def extract_from_api(
87
222
  return {'content': response.text, 'content_type': content_type}
88
223
 
89
224
 
225
+ # SECTION: FUNCTIONS ======================================================== #
226
+
227
+
228
+ def extract_from_api(
229
+ url: str,
230
+ method: HttpMethod | str = HttpMethod.GET,
231
+ **kwargs: Any,
232
+ ) -> JSONData:
233
+ """
234
+ Extract data from a REST API.
235
+
236
+ Parameters
237
+ ----------
238
+ url : str
239
+ API endpoint URL.
240
+ method : HttpMethod | str, optional
241
+ HTTP method to use. Defaults to ``GET``.
242
+ **kwargs : Any
243
+ Extra arguments forwarded to the underlying ``requests`` call
244
+ (for example, ``timeout``). To use a pre-configured
245
+ :class:`requests.Session`, provide it via ``session``.
246
+ When omitted, ``timeout`` defaults to 10 seconds.
247
+
248
+ Returns
249
+ -------
250
+ JSONData
251
+ Parsed JSON payload, or a fallback object with raw text.
252
+ """
253
+ env = {
254
+ 'url': url,
255
+ 'method': method,
256
+ 'timeout': kwargs.pop('timeout', None),
257
+ 'session': kwargs.pop('session', None),
258
+ 'request_kwargs': kwargs,
259
+ }
260
+ return _extract_from_api_env(env, use_client=False)
261
+
262
+
263
+ def extract_from_api_source(
264
+ cfg: Any,
265
+ source_obj: Any,
266
+ overrides: dict[str, Any],
267
+ ) -> JSONData:
268
+ """
269
+ Extract data from a REST API source connector.
270
+
271
+ Parameters
272
+ ----------
273
+ cfg : Any
274
+ Pipeline configuration.
275
+ source_obj : Any
276
+ Connector configuration.
277
+ overrides : dict[str, Any]
278
+ Extract-time overrides.
279
+
280
+ Returns
281
+ -------
282
+ JSONData
283
+ Extracted payload.
284
+ """
285
+ env = compose_api_request_env(cfg, source_obj, overrides)
286
+ return _extract_from_api_env(env, use_client=True)
287
+
288
+
90
289
  def extract_from_database(
91
290
  connection_string: str,
92
291
  ) -> JSONList:
etlplus/ops/load.py CHANGED
@@ -8,13 +8,15 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import sys
11
+ from collections.abc import Mapping
11
12
  from pathlib import Path
12
13
  from typing import Any
13
14
  from typing import cast
14
15
 
15
16
  from ..api import HttpMethod
17
+ from ..api import compose_api_target_env
16
18
  from ..api.utils import resolve_request
17
- from ..enums import DataConnectorType
19
+ from ..connector import DataConnectorType
18
20
  from ..file import File
19
21
  from ..file import FileFormat
20
22
  from ..types import JSONData
@@ -23,14 +25,129 @@ from ..types import JSONList
23
25
  from ..types import StrPath
24
26
  from ..utils import count_records
25
27
 
28
+ # SECTION: EXPORTS ========================================================== #
29
+
30
+
31
+ __all__ = [
32
+ # Functions
33
+ 'load',
34
+ 'load_data',
35
+ 'load_to_api',
36
+ 'load_to_database',
37
+ 'load_to_file',
38
+ ]
39
+
40
+
26
41
  # SECTION: INTERNAL FUNCTIONS ============================================== #
27
42
 
28
43
 
44
+ def _load_data_from_str(
45
+ source: str,
46
+ ) -> JSONData:
47
+ """
48
+ Load JSON data from a string or file path.
49
+
50
+ Parameters
51
+ ----------
52
+ source : str
53
+ Input string representing a file path or JSON payload.
54
+
55
+ Returns
56
+ -------
57
+ JSONData
58
+ Parsed JSON payload.
59
+ """
60
+ # Special case: '-' means read JSON from STDIN (Unix convention).
61
+ if source == '-':
62
+ raw = sys.stdin.read()
63
+ return _parse_json_string(raw)
64
+
65
+ candidate = Path(source)
66
+ if candidate.exists():
67
+ try:
68
+ return File(candidate, FileFormat.JSON).read()
69
+ except (OSError, json.JSONDecodeError, ValueError):
70
+ # Fall back to treating the string as raw JSON content.
71
+ pass
72
+ return _parse_json_string(source)
73
+
74
+
75
+ def _load_to_api_env(
76
+ data: JSONData,
77
+ env: Mapping[str, Any],
78
+ ) -> JSONDict:
79
+ """
80
+ Load data to an API target using a normalized environment.
81
+
82
+ Parameters
83
+ ----------
84
+ data : JSONData
85
+ Payload to load.
86
+ env : Mapping[str, Any]
87
+ Normalized request environment.
88
+
89
+ Returns
90
+ -------
91
+ JSONDict
92
+ Load result payload.
93
+
94
+ Raises
95
+ ------
96
+ ValueError
97
+ If required parameters are missing.
98
+ """
99
+ url = env.get('url')
100
+ if not url:
101
+ raise ValueError('API target missing "url"')
102
+ method = env.get('method') or 'post'
103
+ kwargs: dict[str, Any] = {}
104
+ headers = env.get('headers')
105
+ if headers:
106
+ kwargs['headers'] = cast(dict[str, str], headers)
107
+ if env.get('timeout') is not None:
108
+ kwargs['timeout'] = env.get('timeout')
109
+ session = env.get('session')
110
+ if session is not None:
111
+ kwargs['session'] = session
112
+ extra_kwargs = env.get('request_kwargs')
113
+ if isinstance(extra_kwargs, Mapping):
114
+ kwargs.update(extra_kwargs)
115
+ timeout = kwargs.pop('timeout', 10.0)
116
+ session = kwargs.pop('session', None)
117
+ request_callable, timeout, http_method = resolve_request(
118
+ method,
119
+ session=session,
120
+ timeout=timeout,
121
+ )
122
+ response = request_callable(
123
+ cast(str, url),
124
+ json=data,
125
+ timeout=timeout,
126
+ **kwargs,
127
+ )
128
+ response.raise_for_status()
129
+
130
+ # Try JSON first, fall back to text.
131
+ try:
132
+ payload: Any = response.json()
133
+ except ValueError:
134
+ payload = response.text
135
+
136
+ return {
137
+ 'status': 'success',
138
+ 'status_code': response.status_code,
139
+ 'message': f'Data loaded to {url}',
140
+ 'response': payload,
141
+ 'records': count_records(data),
142
+ 'method': http_method.value.upper(),
143
+ }
144
+
145
+
29
146
  def _parse_json_string(
30
147
  raw: str,
31
148
  ) -> JSONData:
32
149
  """
33
- Parse JSON data from ``raw`` text.
150
+ Parse JSON data from *raw* text.
34
151
 
35
152
  Parameters
36
153
  ----------
@@ -100,18 +217,7 @@ def load_data(
100
217
  return File(source, FileFormat.JSON).read()
101
218
 
102
219
  if isinstance(source, str):
103
- # Special case: '-' means read JSON from STDIN (Unix convention).
104
- if source == '-':
105
- raw = sys.stdin.read()
106
- return _parse_json_string(raw)
107
- candidate = Path(source)
108
- if candidate.exists():
109
- try:
110
- return File(candidate, FileFormat.JSON).read()
111
- except (OSError, json.JSONDecodeError, ValueError):
112
- # Fall back to treating the string as raw JSON content.
113
- pass
114
- return _parse_json_string(source)
220
+ return _load_data_from_str(source)
115
221
 
116
222
  raise TypeError(
117
223
  'source must be a mapping, sequence of mappings, path, or JSON string',
@@ -145,30 +251,43 @@ def load_to_api(
145
251
  Result dictionary including response payload or text.
146
252
  """
147
253
  # Apply a conservative timeout to guard against hanging requests.
148
- timeout = kwargs.pop('timeout', 10.0)
149
- session = kwargs.pop('session', None)
150
- request_callable, timeout, http_method = resolve_request(
151
- method,
152
- session=session,
153
- timeout=timeout,
154
- )
155
- response = request_callable(url, json=data, timeout=timeout, **kwargs)
156
- response.raise_for_status()
254
+ env = {
255
+ 'url': url,
256
+ 'method': method,
257
+ 'timeout': kwargs.pop('timeout', 10.0),
258
+ 'session': kwargs.pop('session', None),
259
+ 'request_kwargs': kwargs,
260
+ }
261
+ return _load_to_api_env(data, env)
157
262
 
158
- # Try JSON first, fall back to text.
159
- try:
160
- payload: Any = response.json()
161
- except ValueError:
162
- payload = response.text
163
263
 
164
- return {
165
- 'status': 'success',
166
- 'status_code': response.status_code,
167
- 'message': f'Data loaded to {url}',
168
- 'response': payload,
169
- 'records': count_records(data),
170
- 'method': http_method.value.upper(),
171
- }
264
+ def load_to_api_target(
265
+ cfg: Any,
266
+ target_obj: Any,
267
+ overrides: dict[str, Any],
268
+ data: JSONData,
269
+ ) -> JSONDict:
270
+ """
271
+ Load data to an API target connector.
272
+
273
+ Parameters
274
+ ----------
275
+ cfg : Any
276
+ Pipeline configuration.
277
+ target_obj : Any
278
+ Connector configuration.
279
+ overrides : dict[str, Any]
280
+ Load-time overrides.
281
+ data : JSONData
282
+ Payload to load.
283
+
284
+ Returns
285
+ -------
286
+ JSONDict
287
+ Load result.
288
+ """
289
+ env = compose_api_target_env(cfg, target_obj, overrides)
290
+ return _load_to_api_env(data, env)
172
291
 
173
292
 
174
293
  def load_to_database(