streamwright 0.1.5__tar.gz → 0.1.6__tar.gz

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 (60) hide show
  1. streamwright-0.1.6/PKG-INFO +122 -0
  2. streamwright-0.1.6/README.md +94 -0
  3. {streamwright-0.1.5 → streamwright-0.1.6}/pyproject.toml +5 -7
  4. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/catalog.py +6 -22
  5. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/cli.py +35 -13
  6. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/engine/runner.py +93 -27
  7. streamwright-0.1.6/src/streamwright/core/net/encoding.py +50 -0
  8. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/net/http.py +101 -79
  9. streamwright-0.1.6/src/streamwright/core/net/paginators.py +355 -0
  10. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/components.py +117 -33
  11. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/validation/schema.py +35 -14
  12. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/validation/source.py +98 -20
  13. streamwright-0.1.6/src/streamwright.egg-info/PKG-INFO +122 -0
  14. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/SOURCES.txt +3 -1
  15. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_catalog.py +3 -8
  16. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_connectors.py +26 -0
  17. streamwright-0.1.6/tests/test_http_engine.py +572 -0
  18. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_logging.py +3 -4
  19. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_validation.py +76 -3
  20. streamwright-0.1.5/PKG-INFO +0 -82
  21. streamwright-0.1.5/README.md +0 -54
  22. streamwright-0.1.5/src/streamwright/core/connectors.json +0 -91
  23. streamwright-0.1.5/src/streamwright.egg-info/PKG-INFO +0 -82
  24. {streamwright-0.1.5 → streamwright-0.1.6}/LICENSE +0 -0
  25. {streamwright-0.1.5 → streamwright-0.1.6}/setup.cfg +0 -0
  26. {streamwright-0.1.5 → streamwright-0.1.6}/setup.py +0 -0
  27. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/__init__.py +0 -0
  28. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/config/__init__.py +0 -0
  29. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/config/inputs.py +0 -0
  30. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/config/loader.py +0 -0
  31. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/config/reader.py +0 -0
  32. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/engine/__init__.py +0 -0
  33. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/engine/queries.py +0 -0
  34. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/engine/sql.py +0 -0
  35. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/net/__init__.py +0 -0
  36. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/net/downloads.py +0 -0
  37. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/__init__.py +0 -0
  38. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/dlt_output.py +0 -0
  39. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/exporter.py +0 -0
  40. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/output.py +0 -0
  41. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/staging.py +0 -0
  42. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/warehouse.py +0 -0
  43. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/__init__.py +0 -0
  44. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/logs.py +0 -0
  45. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/templates.py +0 -0
  46. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/testing.py +0 -0
  47. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/validation/__init__.py +0 -0
  48. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/validation/engine.py +0 -0
  49. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/dependency_links.txt +0 -0
  50. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/entry_points.txt +0 -0
  51. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/requires.txt +0 -0
  52. {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/top_level.txt +0 -0
  53. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_dlt_output.py +0 -0
  54. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_exact_decimals.py +0 -0
  55. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_exporter.py +0 -0
  56. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_output_formats.py +0 -0
  57. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_runner.py +0 -0
  58. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_sql.py +0 -0
  59. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_transform.py +0 -0
  60. {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_warehouse_output.py +0 -0
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamwright
3
+ Version: 0.1.6
4
+ Summary: Declarative data connectors — pull from APIs, files and databases with YAML, shape with SQL, write anywhere.
5
+ Author-email: Karthick Jaganathan <kjaganathan.sde@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/karthick-jaganathan/streamwright
8
+ Project-URL: Repository, https://github.com/karthick-jaganathan/streamwright
9
+ Project-URL: Documentation, https://streamwright.web.app/docs/
10
+ Keywords: data,pipeline,etl,connector,singer,yaml,duckdb,rest-api
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: pyyaml==6.0.2
22
+ Requires-Dist: requests<3,>=2.25
23
+ Requires-Dist: duckdb>=1.5.2
24
+ Requires-Dist: pytz
25
+ Provides-Extra: dlt
26
+ Requires-Dist: dlt>=1.0; extra == "dlt"
27
+ Dynamic: license-file
28
+
29
+ # StreamWright — Declarative Data Pipelines
30
+
31
+ **StreamWright turns a few YAML files into a working data pipeline.** You describe what to pull—from an API, a file store, or a database—and StreamWright handles the how: authentication, pagination, retries, shaping data with DuckDB SQL, and writing it to your destination.
32
+
33
+ Build reusable data pipelines without writing per-connector code. Define your sources declaratively, install connectors on demand, and run everything through a single CLI command.
34
+
35
+ ## How It Works
36
+
37
+ The `streamwright` package provides both the CLI tooling and execution runtime for declarative data sources.
38
+
39
+ Each stream follows a three-stage pipeline:
40
+
41
+ 1. **Fetch** — Retrieve records through declarative REST HTTP requests, storage readers (S3, GCS, and local files), database integrations, or vendor SDK connectors.
42
+ 2. **Shape** — Filter, transform, and normalize records on the fly using embedded DuckDB SQL.
43
+ 3. **Write** — Deliver data to JSONL, CSV, TSV, and Parquet files; DuckDB and DuckLake; Singer-compatible message streams; or supported `dlt` destinations.
44
+
45
+ ## CLI Commands
46
+
47
+ - **`streamwright run`** — Execute a source and stream records to the configured destination.
48
+ - **`streamwright validate`** — Validate source definitions, authentication specifications, and schemas without executing network requests.
49
+ - **`streamwright connectors`** — Browse, inspect, and install connectors from the StreamWright Hub.
50
+
51
+ ## Source Structure
52
+
53
+ A source is defined declaratively as a directory containing a `source.yaml` file and a `streams/` directory, or as a standalone YAML file where supported.
54
+
55
+ ```text
56
+ my_source/
57
+ ├── source.yaml
58
+ └── streams/
59
+ ├── customers.yaml
60
+ └── orders.yaml
61
+ ```
62
+
63
+ - **`source.yaml`** — Defines input parameters (`spec`), authentication (`auth`), and base HTTP configuration (`http`).
64
+ - **`streams/*.yaml`** — Defines stream endpoints, pagination, rate limits, schemas, and stream-specific configuration.
65
+
66
+ ## Installation
67
+
68
+ Install StreamWright using pip:
69
+
70
+ ```bash
71
+ pip install streamwright
72
+ streamwright --help
73
+ ```
74
+
75
+ This installs the CLI, execution runtime, and connector hub client. Install additional connectors on demand:
76
+
77
+ ```bash
78
+ # Browse available connectors
79
+ streamwright connectors list
80
+
81
+ # Install the generic REST API connector
82
+ streamwright connectors install restapi
83
+
84
+ # Install file and storage readers
85
+ streamwright connectors install files
86
+
87
+ # Install Google Ads API support
88
+ streamwright connectors install google_ads
89
+
90
+ # Install multiple connectors at once
91
+ streamwright connectors install google_ads meta_ads microsoft_ads
92
+ ```
93
+
94
+ The connector catalog is discovered from the live [StreamWright Hub](https://github.com/karthick-jaganathan/streamwright-hub) and cached locally in `~/.streamwright/connectors.json`.
95
+
96
+ ## Quickstart
97
+
98
+ The repository includes runnable examples to help you get started.
99
+
100
+ ```bash
101
+ # Install StreamWright and the required connector
102
+ pip install streamwright
103
+ streamwright connectors install files
104
+
105
+ # Clone the repository
106
+ git clone https://github.com/karthick-jaganathan/streamwright.git
107
+
108
+ # Validate the example sources
109
+ streamwright validate streamwright/examples
110
+
111
+ # Run the file-reader example
112
+ streamwright run streamwright/examples/sources/readers/files_demo \
113
+ --set data_root=streamwright/examples/sources/readers/files_demo/data \
114
+ --allow-connector files \
115
+ --output jsonl:out
116
+ ```
117
+
118
+ ## Documentation
119
+
120
+ - **Full documentation:** https://streamwright.web.app/docs/
121
+ - **Architecture Guide:** https://streamwright.web.app/docs/architecture/
122
+ - **Connectors & Readers:** https://streamwright.web.app/docs/connectors/
@@ -0,0 +1,94 @@
1
+ # StreamWright — Declarative Data Pipelines
2
+
3
+ **StreamWright turns a few YAML files into a working data pipeline.** You describe what to pull—from an API, a file store, or a database—and StreamWright handles the how: authentication, pagination, retries, shaping data with DuckDB SQL, and writing it to your destination.
4
+
5
+ Build reusable data pipelines without writing per-connector code. Define your sources declaratively, install connectors on demand, and run everything through a single CLI command.
6
+
7
+ ## How It Works
8
+
9
+ The `streamwright` package provides both the CLI tooling and execution runtime for declarative data sources.
10
+
11
+ Each stream follows a three-stage pipeline:
12
+
13
+ 1. **Fetch** — Retrieve records through declarative REST HTTP requests, storage readers (S3, GCS, and local files), database integrations, or vendor SDK connectors.
14
+ 2. **Shape** — Filter, transform, and normalize records on the fly using embedded DuckDB SQL.
15
+ 3. **Write** — Deliver data to JSONL, CSV, TSV, and Parquet files; DuckDB and DuckLake; Singer-compatible message streams; or supported `dlt` destinations.
16
+
17
+ ## CLI Commands
18
+
19
+ - **`streamwright run`** — Execute a source and stream records to the configured destination.
20
+ - **`streamwright validate`** — Validate source definitions, authentication specifications, and schemas without executing network requests.
21
+ - **`streamwright connectors`** — Browse, inspect, and install connectors from the StreamWright Hub.
22
+
23
+ ## Source Structure
24
+
25
+ A source is defined declaratively as a directory containing a `source.yaml` file and a `streams/` directory, or as a standalone YAML file where supported.
26
+
27
+ ```text
28
+ my_source/
29
+ ├── source.yaml
30
+ └── streams/
31
+ ├── customers.yaml
32
+ └── orders.yaml
33
+ ```
34
+
35
+ - **`source.yaml`** — Defines input parameters (`spec`), authentication (`auth`), and base HTTP configuration (`http`).
36
+ - **`streams/*.yaml`** — Defines stream endpoints, pagination, rate limits, schemas, and stream-specific configuration.
37
+
38
+ ## Installation
39
+
40
+ Install StreamWright using pip:
41
+
42
+ ```bash
43
+ pip install streamwright
44
+ streamwright --help
45
+ ```
46
+
47
+ This installs the CLI, execution runtime, and connector hub client. Install additional connectors on demand:
48
+
49
+ ```bash
50
+ # Browse available connectors
51
+ streamwright connectors list
52
+
53
+ # Install the generic REST API connector
54
+ streamwright connectors install restapi
55
+
56
+ # Install file and storage readers
57
+ streamwright connectors install files
58
+
59
+ # Install Google Ads API support
60
+ streamwright connectors install google_ads
61
+
62
+ # Install multiple connectors at once
63
+ streamwright connectors install google_ads meta_ads microsoft_ads
64
+ ```
65
+
66
+ The connector catalog is discovered from the live [StreamWright Hub](https://github.com/karthick-jaganathan/streamwright-hub) and cached locally in `~/.streamwright/connectors.json`.
67
+
68
+ ## Quickstart
69
+
70
+ The repository includes runnable examples to help you get started.
71
+
72
+ ```bash
73
+ # Install StreamWright and the required connector
74
+ pip install streamwright
75
+ streamwright connectors install files
76
+
77
+ # Clone the repository
78
+ git clone https://github.com/karthick-jaganathan/streamwright.git
79
+
80
+ # Validate the example sources
81
+ streamwright validate streamwright/examples
82
+
83
+ # Run the file-reader example
84
+ streamwright run streamwright/examples/sources/readers/files_demo \
85
+ --set data_root=streamwright/examples/sources/readers/files_demo/data \
86
+ --allow-connector files \
87
+ --output jsonl:out
88
+ ```
89
+
90
+ ## Documentation
91
+
92
+ - **Full documentation:** https://streamwright.web.app/docs/
93
+ - **Architecture Guide:** https://streamwright.web.app/docs/architecture/
94
+ - **Connectors & Readers:** https://streamwright.web.app/docs/connectors/
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "streamwright"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Declarative data connectors — pull from APIs, files and databases with YAML, shape with SQL, write anywhere."
9
9
  readme = "README.md"
10
- license = "Apache-2.0"
10
+ license = {text = "Apache-2.0"}
11
11
  authors = [
12
- {name = "Karthick Jaganathan"},
12
+ {name = "Karthick Jaganathan", email = "kjaganathan.sde@gmail.com"},
13
13
  ]
14
- keywords = ["data", "pipeline", "etl", "connector", "singer", "yaml"]
14
+ keywords = ["data", "pipeline", "etl", "connector", "singer", "yaml", "duckdb", "rest-api"]
15
15
  classifiers = [
16
16
  "Development Status :: 3 - Alpha",
17
17
  "Intended Audience :: Developers",
@@ -44,6 +44,7 @@ Documentation = "https://streamwright.web.app/docs/"
44
44
  # `streamwright run`, `streamwright validate`, `streamwright connectors` - one CLI (see streamwright.core.cli)
45
45
  streamwright = "streamwright.core.cli:main"
46
46
 
47
+
47
48
  [tool.setuptools]
48
49
  # src layout: `streamwright` is a namespace package shared with the connector distributions (streamwright.connectors.*)
49
50
  package-dir = {"" = "src"}
@@ -57,6 +58,3 @@ packages = [
57
58
  "streamwright.core.runtime",
58
59
  ]
59
60
 
60
- [tool.setuptools.package-data]
61
- # the bundled connector catalog (the fallback of `streamwright connectors` when the live hub is unreachable)
62
- "streamwright.core" = ["connectors.json"]
@@ -4,11 +4,10 @@
4
4
  A catalog is a static index that maps a connector key to how it's installed - a
5
5
  PyPI requirement, a pinned git URL, or an image. It is read from the live hub
6
6
  (DEFAULT_HUB, or $STREAMWRIGHT_HUB_URL / --hub-url); when the hub is unreachable,
7
- from the last copy fetched (~/.streamwright/connectors.json), else from the copy
8
- bundled with this release - with a warning either way. The hub URL "bundled" uses
9
- the bundled copy only (no network): for reproducible builds. The key is the same name used in
10
- source YAML (`provider`/`sdk`); the install source is just a delivery detail, so
11
- a package can move from git to PyPI without changing what users type.
7
+ from the last copy fetched (~/.streamwright/connectors.json) with a warning.
8
+ The key is the same name used in source YAML (`provider`/`sdk`); the install source
9
+ is just a delivery detail, so a package can move from git to PyPI without changing
10
+ what users type.
12
11
 
13
12
  Security: non-"official" connectors run third-party code, so `install` asks for
14
13
  confirmation (or `--yes`) and refuses non-interactive installs without it.
@@ -19,25 +18,14 @@ import subprocess
19
18
  import sys
20
19
  from pathlib import Path
21
20
 
22
- try:
23
- from importlib import resources
24
- except ImportError: # pragma: no cover
25
- import importlib_resources as resources
26
-
27
21
  from streamwright.core.runtime import components
28
22
 
29
23
  HUB_ENV = "STREAMWRIGHT_HUB_URL"
30
24
  DEFAULT_HUB = "https://karthick-jaganathan.github.io/streamwright-hub/index.json"
31
- BUNDLED = "bundled" # a hub URL that means: the catalog bundled with this release, no network
32
25
  CACHE = Path(os.path.expanduser("~/.streamwright/connectors.json"))
33
26
  TIMEOUT_S = 5
34
27
 
35
28
 
36
- def _bundled():
37
- text = resources.files("streamwright.core").joinpath("connectors.json").read_text()
38
- return json.loads(text)
39
-
40
-
41
29
  def _valid(data):
42
30
  return isinstance(data, dict) and isinstance(data.get("connectors"), dict)
43
31
 
@@ -61,11 +49,9 @@ def _remote(url):
61
49
  def load(hub_url=None):
62
50
  """
63
51
  The catalog: the live hub (`hub_url`, else $STREAMWRIGHT_HUB_URL, else DEFAULT_HUB; each fetch is cached). When the
64
- hub cannot be read, the cached copy, else the bundled one, with a warning on stderr. `bundled`: the bundled copy only.
52
+ hub cannot be read, the cached copy is used with a warning on stderr.
65
53
  """
66
54
  url = hub_url or os.environ.get(HUB_ENV) or DEFAULT_HUB
67
- if url == BUNDLED:
68
- return _bundled()
69
55
  try:
70
56
  return _remote(url)
71
57
  except Exception as exc: # offline, hub down or a bad publish: fall back, visibly
@@ -84,9 +70,7 @@ def load(hub_url=None):
84
70
  return data
85
71
  except Exception:
86
72
  pass
87
- sys.stderr.write("streamwright: the connector hub %s is unreachable (%s); using the catalog bundled with this "
88
- "release\n" % (url, reason))
89
- return _bundled()
73
+ raise RuntimeError("the connector hub %s is unreachable (%s) and no cached catalog is available" % (url, reason))
90
74
 
91
75
 
92
76
  def _mtime(path):
@@ -37,6 +37,7 @@ Exit status: 0 success, 1 the run failed, 2 invalid source or inputs.
37
37
 
38
38
  import argparse
39
39
  import datetime
40
+ import functools
40
41
  import json
41
42
  import logging
42
43
  import os
@@ -248,8 +249,7 @@ def _parser():
248
249
  conn.add_argument("--yes", action="store_true", help="skip the confirmation prompt when installing")
249
250
  conn.add_argument("--hub-url", metavar="URL",
250
251
  help="the connector hub (default: $STREAMWRIGHT_HUB_URL, else the live StreamWright hub; when it is "
251
- "unreachable, the last fetched or the bundled catalog, with a warning). 'bundled': the "
252
- "catalog bundled with this release, no network")
252
+ "unreachable, the cached catalog is used, with a warning)")
253
253
 
254
254
  validate = commands.add_parser("validate", help="check configuration files: streamwright-validate, plus the checks of "
255
255
  "the installed connectors and query builders", add_help=False)
@@ -509,8 +509,17 @@ def _log_error(message):
509
509
  return None
510
510
 
511
511
 
512
+ @functools.lru_cache(maxsize=None)
513
+ def _pkg_version(dist_name):
514
+ try:
515
+ from importlib import metadata
516
+ return metadata.version(dist_name)
517
+ except Exception:
518
+ return None
519
+
520
+
512
521
  def _connector_lines():
513
- """`streamwright connectors`: installed connectors grouped by category, each with its description and SDK loggers."""
522
+ """`streamwright connectors`: installed connectors grouped by category using connector.spec."""
514
523
  groups = {}
515
524
  for name in components.available():
516
525
  try:
@@ -518,18 +527,31 @@ def _connector_lines():
518
527
  except components.ComponentLoadError as exc:
519
528
  groups.setdefault("other", []).append(" %s — %s" % (name, exc))
520
529
  continue
521
- loggers = getattr(connector, "network_loggers", None) or ()
522
- loggers = (loggers,) if isinstance(loggers, str) else tuple(loggers)
523
- parts = [name]
524
- summary = getattr(connector, "summary", "") or ""
525
- if summary:
526
- parts.append("— " + summary)
527
- if loggers:
528
- parts.append("(SDK loggers: %s)" % ", ".join(loggers))
529
- category = getattr(connector, "category", "other") or "other"
530
- groups.setdefault(category, []).append(" " + " ".join(parts))
530
+ spec = getattr(connector, "spec", None)
531
+ if spec is None:
532
+ spec = components.ConnectorSpec(name=name)
533
+ prefix = " [installed] "
534
+ conn_name = spec.name or name
535
+ title = spec.title
536
+ client_tag = spec.badge(_pkg_version)
537
+ loggers = spec.effective_loggers()
538
+
539
+ if client_tag and loggers:
540
+ logger_str = ", ".join(loggers)
541
+ line = "%s%-15s%-23s%-32s [logger: %s]" % (prefix, conn_name, title, client_tag, logger_str)
542
+ elif client_tag:
543
+ line = "%s%-15s%-23s%s" % (prefix, conn_name, title, client_tag)
544
+ elif loggers:
545
+ logger_str = ", ".join(loggers)
546
+ line = "%s%-15s%-23s%-32s [logger: %s]" % (prefix, conn_name, title, "", logger_str)
547
+ else:
548
+ line = "%s%-15s%s" % (prefix, conn_name, title)
549
+
550
+ groups.setdefault(spec.category, []).append(line)
531
551
  lines = []
532
552
  for category in sorted(groups):
553
+ if lines:
554
+ lines.append("")
533
555
  lines.append("%s:" % category)
534
556
  lines.extend(sorted(groups[category]))
535
557
  return lines
@@ -39,7 +39,9 @@ import requests
39
39
  from streamwright.core.net import downloads
40
40
  from streamwright.core.runtime import components, logs
41
41
  from streamwright.core.engine import queries, sql
42
- from streamwright.core.net.http import Authenticator, HttpClient, HttpError, RateLimiter, Redactor, error_excerpt, paginate
42
+ from streamwright.core.net.http import Authenticator, HttpClient, HttpError, RateLimiter, Redactor, error_excerpt, paginate, merge_headers
43
+ from streamwright.core.net.encoding import encode_params
44
+ from streamwright.core.net.paginators import set_dotted_path
43
45
  from streamwright.core.config.inputs import as_date, parse_duration
44
46
  from streamwright.core.runtime.logs import RunMetrics
45
47
  from streamwright.core.runtime.components import ConnectorContext, ConnectorError, ComponentLoadError
@@ -236,6 +238,7 @@ class SourceRunner(object):
236
238
  clock=time.monotonic, sleep=time.sleep, redact=None, allowed_connectors=None, metrics=None):
237
239
  self.source = source
238
240
  self.config = config
241
+ self.secrets = secrets
239
242
  self.output = output
240
243
  self.today = today or datetime.date.today()
241
244
  self.state = copy.deepcopy(state) if state else {}
@@ -273,12 +276,30 @@ class SourceRunner(object):
273
276
  self.connector = components.load(self.provider, allowed_connectors)
274
277
  except ComponentLoadError as exc:
275
278
  raise SourceError(str(exc))
276
- self.connector_auth = render(dict((key, value) for key, value in auth.items() if key != "provider"),
277
- dict(self.scopes(), secrets=secrets))
279
+ if getattr(self.connector, "transport", "sdk") == "http":
280
+ auth_block = dict((key, value) for key, value in auth.items() if key != "provider")
281
+ rendered = render(auth_block, dict(self.scopes(), secrets=secrets))
282
+ self.authenticator = Authenticator(rendered, self.session, self.redact)
283
+ self.connector_auth = None
284
+ else:
285
+ self.connector_auth = render(dict((key, value) for key, value in auth.items() if key != "provider"),
286
+ dict(self.scopes(), secrets=secrets))
287
+ if self.allowed_connectors is not None:
288
+ extra = getattr(self.connector, "query_builders", ())
289
+ if extra:
290
+ self.allowed_connectors = list(self.allowed_connectors) + [b for b in extra if b not in self.allowed_connectors]
278
291
  else:
279
292
  rendered = render(auth, dict(self.scopes(), secrets=secrets))
280
293
  self.authenticator = Authenticator(rendered, self.session, self.redact)
281
- self.http = render(source.get("http") or {}, self.scopes())
294
+ source_http = dict(source.get("http") or {})
295
+ headers_spec = source_http.pop("headers", None)
296
+ self.http = render(source_http, self.scopes())
297
+ if headers_spec is not None:
298
+ rendered_headers = render(headers_spec, dict(self.scopes(), secrets=secrets))
299
+ if isinstance(rendered_headers, dict):
300
+ self.http["headers"] = dict((k, v) for k, v in rendered_headers.items() if v not in ("", None))
301
+ else:
302
+ self.http["headers"] = rendered_headers
282
303
  # the source-level limit is shared by every stream; a stream's own `rate_limit` replaces it
283
304
  self.shared_limiter = self.limiter(self.http.get("rate_limit"))
284
305
 
@@ -554,9 +575,14 @@ class SourceRunner(object):
554
575
  if "async_job" in request and "sdk" not in (request["async_job"].get("submit") or {}):
555
576
  problems.append("%s: an async job's `submit` must be an sdk request (its `poll` calls the same "
556
577
  "service)" % where)
557
- if self.connector is not None and _http_requests(request):
558
- problems.append("%s: http requests need a built-in `auth.type`; auth provider %r only authenticates "
559
- "sdk requests" % (where, self.provider))
578
+ if self.connector is not None:
579
+ if getattr(self.connector, "transport", "sdk") == "http":
580
+ if calls:
581
+ problems.append("%s: %s is a REST API connector: use an http request (http: {path, method, ...}), not sdk" % (where, self.provider))
582
+ else:
583
+ if _http_requests(request):
584
+ problems.append("%s: http requests need a built-in `auth.type`; auth provider %r only authenticates "
585
+ "sdk requests" % (where, self.provider))
560
586
  return problems
561
587
 
562
588
  def run_stream(self, stream):
@@ -586,9 +612,10 @@ class SourceRunner(object):
586
612
  client = self.client(stream, limiter)
587
613
  context = None
588
614
  if any(components.sdk_calls(components.request_body(item)) for item in stream.get("requests") or []):
589
- context = ConnectorContext(self.connector, self.redact, retry=self.retry_policy(stream),
590
- rate_limiter=limiter, sleep=self.sleep, metrics=self.metrics, clock=self.clock)
591
- self.connect(context)
615
+ if self.connector and getattr(self.connector, "transport", "sdk") != "http":
616
+ context = ConnectorContext(self.connector, self.redact, retry=self.retry_policy(stream),
617
+ rate_limiter=limiter, sleep=self.sleep, metrics=self.metrics, clock=self.clock)
618
+ self.connect(context)
592
619
  return client, context
593
620
 
594
621
  def parameters(self, plan, step):
@@ -808,7 +835,17 @@ class SourceRunner(object):
808
835
  def request_select(self, request, response):
809
836
  """The records in one response of a request: its `records.path` (or the response itself) as a list."""
810
837
  path = (request.get("records") or {}).get("path")
811
- data = response if not path else _lookup(response, path)
838
+ if not path:
839
+ data = response
840
+ else:
841
+ try:
842
+ data = get_path(response, path)
843
+ except (KeyError, TypeError, IndexError):
844
+ # a response without the path's first key means a wrong path; below it, an absent or null value
845
+ # (`{"data": null}`, an empty SOAP element) means no records
846
+ if isinstance(response, dict) and response and path.split(".")[0] not in response:
847
+ raise SourceError("records path %r not found in the response" % (path,))
848
+ return []
812
849
  if data is None:
813
850
  return []
814
851
  if isinstance(data, dict):
@@ -824,13 +861,16 @@ class SourceRunner(object):
824
861
  (RunMetrics.read) that counts the pages.
825
862
  """
826
863
  body = components.request_body(request)
827
- explode = (request.get("records") or {}).get("explode")
864
+ effective_records = request["records"] if "records" in request else self.http.get("records")
865
+ explode = (effective_records or {}).get("explode")
828
866
 
829
867
  def select(response):
830
- return self.request_select(request, response)
868
+ return self.request_select({"records": effective_records}, response)
831
869
  if "http" in body:
832
870
  pages = self.http_pages(body["http"], request, client, scopes, where)
833
871
  elif "sdk" in body:
872
+ if self.connector and getattr(self.connector, "transport", "sdk") == "http":
873
+ raise SourceError("%s is a REST API connector: use an http request (http: {path, method, ...}), not sdk" % self.provider)
834
874
  pages = self.sdk_responses(body, context, scopes, where, select)
835
875
  else:
836
876
  pages = self.async_job(stream, body["async_job"], client, context, scopes, select, where)
@@ -840,22 +880,48 @@ class SourceRunner(object):
840
880
 
841
881
  def http_pages(self, request, item, client, scopes, where=None):
842
882
  """The pages of an http request; `item`: its request item (`paginator`, `records`)."""
843
- http = self.build(request, render(request, scopes))
844
-
845
- def send(params, url):
846
- if url:
847
- return client.request(http.get("method", "GET"), client.follow(url), headers=http.get("headers"),
848
- fields=where)
849
- merged = dict(http.get("params") or {}, **params)
850
- return client.request(http.get("method", "GET"), http["path"], params=merged,
851
- headers=http.get("headers"), json_body=http.get("json"), fields=where)
852
-
853
- def paginator_for(response):
854
- if item.get("paginator") is None:
883
+ effective_paginator_spec = item["paginator"] if "paginator" in item else self.http.get("paginator")
884
+ effective_records = item["records"] if "records" in item else self.http.get("records")
885
+ effective_encoding = request.get("params_encoding") or self.http.get("params_encoding", "plain")
886
+
887
+ unrendered_req = dict(request)
888
+ req_headers_spec = unrendered_req.pop("headers", None)
889
+ http = self.build(unrendered_req, render(unrendered_req, scopes))
890
+
891
+ rendered_req_headers = render(req_headers_spec, dict(scopes, secrets=self.secrets)) if req_headers_spec else {}
892
+ merged_headers = merge_headers(self.http.get("headers"), rendered_req_headers)
893
+
894
+ def send(patch):
895
+ if patch.url:
896
+ url = client.follow(patch.url)
897
+ res = client.request(http.get("method", "GET"), url, headers=merged_headers, fields=where)
898
+ send.last_headers = client.last_headers
899
+ return res
900
+
901
+ params = dict(http.get("params") or {})
902
+ if patch.params:
903
+ params.update(patch.params)
904
+ encoded_params = encode_params(params, effective_encoding)
905
+
906
+ json_body = http.get("json")
907
+ if patch.body:
908
+ json_copy = copy.deepcopy(json_body) if json_body is not None else {}
909
+ for path, val in patch.body.items():
910
+ set_dotted_path(json_copy, path, val)
911
+ json_body = json_copy
912
+
913
+ res = client.request(http.get("method", "GET"), http["path"], params=encoded_params,
914
+ headers=merged_headers, json_body=json_body, fields=where)
915
+ send.last_headers = client.last_headers
916
+ return res
917
+
918
+ def paginator_for_resp(response):
919
+ if effective_paginator_spec is None:
855
920
  return None
856
- return render(item["paginator"], dict(scopes, response=response or {}))
921
+ return render(effective_paginator_spec, dict(scopes, response=response or {}))
857
922
 
858
- for _, records in paginate(send, paginator_for, lambda response: self.request_select(item, response)):
923
+ select_item = {"records": effective_records}
924
+ for _, records in paginate(send, paginator_for_resp, lambda response: self.request_select(select_item, response)):
859
925
  yield records
860
926
 
861
927
  def build(self, unrendered, rendered):
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env python
2
+ # /*************************************************************************
3
+ # * Copyright 2026 Karthick Jaganathan
4
+ # *
5
+ # * Licensed under the Apache License, Version 2.0 (the "License");
6
+ # * you may not use this file except in compliance with the License.
7
+ # * You may obtain a copy of the License at
8
+ # *
9
+ # * https://www.apache.org/licenses/LICENSE-2.0
10
+ # *
11
+ # * Unless required by applicable law or agreed to in writing, software
12
+ # * distributed under the License is distributed on an "AS IS" BASIS,
13
+ # * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # * See the License for the specific language governing permissions and
15
+ # * limitations under the License.
16
+ # **************************************************************************/
17
+
18
+ """Query parameter encodings: plain (default) and dotted (Rest.li / nested dictionary syntax)."""
19
+
20
+ from typing import Any, Dict
21
+
22
+ __all__ = ["flatten_dotted", "encode_params"]
23
+
24
+
25
+ def flatten_dotted(params: Dict[str, Any], prefix: str = "") -> Dict[str, Any]:
26
+ """
27
+ Flattens nested dictionaries and lists into dotted/bracket query parameter syntax.
28
+ e.g. {"dateRange": {"start": {"year": 2026}}} -> {"dateRange.start.year": 2026}
29
+ and {"accounts": ["a", "b"]} -> {"accounts[0]": "a", "accounts[1]": "b"}
30
+ """
31
+ items = {}
32
+ for key, val in (params or {}).items():
33
+ full_key = f"{prefix}.{key}" if prefix else str(key)
34
+ if isinstance(val, dict):
35
+ items.update(flatten_dotted(val, full_key))
36
+ elif isinstance(val, (list, tuple)):
37
+ for i, item in enumerate(val):
38
+ items[f"{full_key}[{i}]"] = item
39
+ else:
40
+ items[full_key] = val
41
+ return items
42
+
43
+
44
+ def encode_params(params: Dict[str, Any], encoding: str = "plain") -> Dict[str, Any]:
45
+ """Encodes query parameters according to the chosen encoding strategy."""
46
+ if not params:
47
+ return {}
48
+ if encoding == "dotted":
49
+ return flatten_dotted(params)
50
+ return dict(params)