streamwright 0.1.4__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.4 → streamwright-0.1.6}/pyproject.toml +5 -7
  4. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/catalog.py +39 -23
  5. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/cli.py +36 -12
  6. {streamwright-0.1.4 → 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.4 → 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.4 → streamwright-0.1.6}/src/streamwright/core/runtime/components.py +117 -33
  11. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/validation/schema.py +35 -14
  12. {streamwright-0.1.4 → 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.4 → streamwright-0.1.6}/src/streamwright.egg-info/SOURCES.txt +3 -1
  15. {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_catalog.py +72 -0
  16. {streamwright-0.1.4 → 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.4 → streamwright-0.1.6}/tests/test_logging.py +3 -4
  19. {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_validation.py +76 -3
  20. streamwright-0.1.4/PKG-INFO +0 -78
  21. streamwright-0.1.4/README.md +0 -50
  22. streamwright-0.1.4/src/streamwright/core/connectors.json +0 -91
  23. streamwright-0.1.4/src/streamwright.egg-info/PKG-INFO +0 -78
  24. {streamwright-0.1.4 → streamwright-0.1.6}/LICENSE +0 -0
  25. {streamwright-0.1.4 → streamwright-0.1.6}/setup.cfg +0 -0
  26. {streamwright-0.1.4 → streamwright-0.1.6}/setup.py +0 -0
  27. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/__init__.py +0 -0
  28. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/config/__init__.py +0 -0
  29. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/config/inputs.py +0 -0
  30. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/config/loader.py +0 -0
  31. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/config/reader.py +0 -0
  32. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/engine/__init__.py +0 -0
  33. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/engine/queries.py +0 -0
  34. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/engine/sql.py +0 -0
  35. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/net/__init__.py +0 -0
  36. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/net/downloads.py +0 -0
  37. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/__init__.py +0 -0
  38. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/dlt_output.py +0 -0
  39. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/exporter.py +0 -0
  40. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/output.py +0 -0
  41. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/staging.py +0 -0
  42. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/warehouse.py +0 -0
  43. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/runtime/__init__.py +0 -0
  44. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/runtime/logs.py +0 -0
  45. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/runtime/templates.py +0 -0
  46. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/runtime/testing.py +0 -0
  47. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/validation/__init__.py +0 -0
  48. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/validation/engine.py +0 -0
  49. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright.egg-info/dependency_links.txt +0 -0
  50. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright.egg-info/entry_points.txt +0 -0
  51. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright.egg-info/requires.txt +0 -0
  52. {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright.egg-info/top_level.txt +0 -0
  53. {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_dlt_output.py +0 -0
  54. {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_exact_decimals.py +0 -0
  55. {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_exporter.py +0 -0
  56. {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_output_formats.py +0 -0
  57. {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_runner.py +0 -0
  58. {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_sql.py +0 -0
  59. {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_transform.py +0 -0
  60. {streamwright-0.1.4 → 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.4"
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 (default for `streamwright connectors`, refreshed from $STREAMWRIGHT_HUB_URL)
62
- "streamwright.core" = ["connectors.json"]
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env python
2
2
  """The connector catalog: discover and install connectors from the StreamWright hub.
3
3
 
4
- A catalog is a static index (bundled with streamwright, optionally refreshed from a
5
- remote hub URL) that maps a connector key to how it's installed - a PyPI
6
- requirement, a pinned git URL, or an image. The key is the same name used in
7
- source YAML (`provider`/`sdk`); the install source is just a delivery detail, so
8
- a package can move from git to PyPI without changing what users type.
4
+ A catalog is a static index that maps a connector key to how it's installed - a
5
+ PyPI requirement, a pinned git URL, or an image. It is read from the live hub
6
+ (DEFAULT_HUB, or $STREAMWRIGHT_HUB_URL / --hub-url); when the hub is unreachable,
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.
9
11
 
10
12
  Security: non-"official" connectors run third-party code, so `install` asks for
11
13
  confirmation (or `--yes`) and refuses non-interactive installs without it.
@@ -16,28 +18,26 @@ import subprocess
16
18
  import sys
17
19
  from pathlib import Path
18
20
 
19
- try:
20
- from importlib import resources
21
- except ImportError: # pragma: no cover
22
- import importlib_resources as resources
23
-
24
21
  from streamwright.core.runtime import components
25
22
 
26
23
  HUB_ENV = "STREAMWRIGHT_HUB_URL"
24
+ DEFAULT_HUB = "https://karthick-jaganathan.github.io/streamwright-hub/index.json"
27
25
  CACHE = Path(os.path.expanduser("~/.streamwright/connectors.json"))
26
+ TIMEOUT_S = 5
28
27
 
29
28
 
30
- def _bundled():
31
- text = resources.files("streamwright.core").joinpath("connectors.json").read_text()
32
- return json.loads(text)
29
+ def _valid(data):
30
+ return isinstance(data, dict) and isinstance(data.get("connectors"), dict)
33
31
 
34
32
 
35
33
  def _remote(url):
36
34
  import requests
37
35
 
38
- response = requests.get(url, timeout=10)
36
+ response = requests.get(url, timeout=TIMEOUT_S)
39
37
  response.raise_for_status()
40
38
  data = response.json()
39
+ if not _valid(data):
40
+ raise ValueError("not a connector catalog (no `connectors` mapping)")
41
41
  try:
42
42
  CACHE.parent.mkdir(parents=True, exist_ok=True)
43
43
  CACHE.write_text(json.dumps(data))
@@ -47,19 +47,35 @@ def _remote(url):
47
47
 
48
48
 
49
49
  def load(hub_url=None):
50
- """The catalog: the remote hub if configured (cached), else the bundled default."""
51
- url = hub_url or os.environ.get(HUB_ENV)
52
- if url:
53
- try:
54
- return _remote(url)
55
- except Exception: # offline or hub down: fall back
56
- pass
50
+ """
51
+ The catalog: the live hub (`hub_url`, else $STREAMWRIGHT_HUB_URL, else DEFAULT_HUB; each fetch is cached). When the
52
+ hub cannot be read, the cached copy is used with a warning on stderr.
53
+ """
54
+ url = hub_url or os.environ.get(HUB_ENV) or DEFAULT_HUB
55
+ try:
56
+ return _remote(url)
57
+ except Exception as exc: # offline, hub down or a bad publish: fall back, visibly
58
+ reason = type(exc).__name__ # network errors: the type is enough; an HTTP status or a bad index says why
59
+ status = getattr(getattr(exc, "response", None), "status_code", None)
60
+ if status:
61
+ reason = "HTTP %s" % status
62
+ elif isinstance(exc, ValueError):
63
+ reason += ": %s" % str(exc)[:100]
57
64
  if CACHE.exists():
58
65
  try:
59
- return json.loads(CACHE.read_text())
66
+ data = json.loads(CACHE.read_text())
67
+ if _valid(data):
68
+ sys.stderr.write("streamwright: the connector hub %s is unreachable (%s); using the copy fetched on %s\n"
69
+ % (url, reason, _mtime(CACHE)))
70
+ return data
60
71
  except Exception:
61
72
  pass
62
- return _bundled()
73
+ raise RuntimeError("the connector hub %s is unreachable (%s) and no cached catalog is available" % (url, reason))
74
+
75
+
76
+ def _mtime(path):
77
+ import datetime
78
+ return datetime.datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
63
79
 
64
80
 
65
81
  def connectors(hub_url=None):
@@ -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
@@ -247,7 +248,8 @@ def _parser():
247
248
  help="connector keys (install: one or more) or words (search)")
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
- help="catalog URL to use (default: the bundled catalog, or $STREAMWRIGHT_HUB_URL)")
251
+ help="the connector hub (default: $STREAMWRIGHT_HUB_URL, else the live StreamWright hub; when it is "
252
+ "unreachable, the cached catalog is used, with a warning)")
251
253
 
252
254
  validate = commands.add_parser("validate", help="check configuration files: streamwright-validate, plus the checks of "
253
255
  "the installed connectors and query builders", add_help=False)
@@ -507,8 +509,17 @@ def _log_error(message):
507
509
  return None
508
510
 
509
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
+
510
521
  def _connector_lines():
511
- """`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."""
512
523
  groups = {}
513
524
  for name in components.available():
514
525
  try:
@@ -516,18 +527,31 @@ def _connector_lines():
516
527
  except components.ComponentLoadError as exc:
517
528
  groups.setdefault("other", []).append(" %s — %s" % (name, exc))
518
529
  continue
519
- loggers = getattr(connector, "network_loggers", None) or ()
520
- loggers = (loggers,) if isinstance(loggers, str) else tuple(loggers)
521
- parts = [name]
522
- summary = getattr(connector, "summary", "") or ""
523
- if summary:
524
- parts.append("— " + summary)
525
- if loggers:
526
- parts.append("(SDK loggers: %s)" % ", ".join(loggers))
527
- category = getattr(connector, "category", "other") or "other"
528
- 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)
529
551
  lines = []
530
552
  for category in sorted(groups):
553
+ if lines:
554
+ lines.append("")
531
555
  lines.append("%s:" % category)
532
556
  lines.extend(sorted(groups[category]))
533
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):