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.
- streamwright-0.1.6/PKG-INFO +122 -0
- streamwright-0.1.6/README.md +94 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/pyproject.toml +5 -7
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/catalog.py +6 -22
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/cli.py +35 -13
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/engine/runner.py +93 -27
- streamwright-0.1.6/src/streamwright/core/net/encoding.py +50 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/net/http.py +101 -79
- streamwright-0.1.6/src/streamwright/core/net/paginators.py +355 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/components.py +117 -33
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/validation/schema.py +35 -14
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/validation/source.py +98 -20
- streamwright-0.1.6/src/streamwright.egg-info/PKG-INFO +122 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/SOURCES.txt +3 -1
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_catalog.py +3 -8
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_connectors.py +26 -0
- streamwright-0.1.6/tests/test_http_engine.py +572 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_logging.py +3 -4
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_validation.py +76 -3
- streamwright-0.1.5/PKG-INFO +0 -82
- streamwright-0.1.5/README.md +0 -54
- streamwright-0.1.5/src/streamwright/core/connectors.json +0 -91
- streamwright-0.1.5/src/streamwright.egg-info/PKG-INFO +0 -82
- {streamwright-0.1.5 → streamwright-0.1.6}/LICENSE +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/setup.cfg +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/setup.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/__init__.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/config/__init__.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/config/inputs.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/config/loader.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/config/reader.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/engine/__init__.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/engine/queries.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/engine/sql.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/net/__init__.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/net/downloads.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/__init__.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/dlt_output.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/exporter.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/output.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/staging.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/outputs/warehouse.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/__init__.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/logs.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/templates.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/runtime/testing.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/validation/__init__.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright/core/validation/engine.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/dependency_links.txt +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/entry_points.txt +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/requires.txt +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/src/streamwright.egg-info/top_level.txt +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_dlt_output.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_exact_decimals.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_exporter.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_output_formats.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_runner.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_sql.py +0 -0
- {streamwright-0.1.5 → streamwright-0.1.6}/tests/test_transform.py +0 -0
- {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.
|
|
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)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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.
|
|
277
|
-
|
|
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
|
-
|
|
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
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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(
|
|
921
|
+
return render(effective_paginator_spec, dict(scopes, response=response or {}))
|
|
857
922
|
|
|
858
|
-
|
|
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)
|