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.
- streamwright-0.1.6/PKG-INFO +122 -0
- streamwright-0.1.6/README.md +94 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/pyproject.toml +5 -7
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/catalog.py +39 -23
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/cli.py +36 -12
- {streamwright-0.1.4 → 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.4 → 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.4 → streamwright-0.1.6}/src/streamwright/core/runtime/components.py +117 -33
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/validation/schema.py +35 -14
- {streamwright-0.1.4 → 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.4 → streamwright-0.1.6}/src/streamwright.egg-info/SOURCES.txt +3 -1
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_catalog.py +72 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_connectors.py +26 -0
- streamwright-0.1.6/tests/test_http_engine.py +572 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_logging.py +3 -4
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_validation.py +76 -3
- streamwright-0.1.4/PKG-INFO +0 -78
- streamwright-0.1.4/README.md +0 -50
- streamwright-0.1.4/src/streamwright/core/connectors.json +0 -91
- streamwright-0.1.4/src/streamwright.egg-info/PKG-INFO +0 -78
- {streamwright-0.1.4 → streamwright-0.1.6}/LICENSE +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/setup.cfg +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/setup.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/__init__.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/config/__init__.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/config/inputs.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/config/loader.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/config/reader.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/engine/__init__.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/engine/queries.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/engine/sql.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/net/__init__.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/net/downloads.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/__init__.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/dlt_output.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/exporter.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/output.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/staging.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/outputs/warehouse.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/runtime/__init__.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/runtime/logs.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/runtime/templates.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/runtime/testing.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/validation/__init__.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright/core/validation/engine.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright.egg-info/dependency_links.txt +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright.egg-info/entry_points.txt +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright.egg-info/requires.txt +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/src/streamwright.egg-info/top_level.txt +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_dlt_output.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_exact_decimals.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_exporter.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_output_formats.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_runner.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_sql.py +0 -0
- {streamwright-0.1.4 → streamwright-0.1.6}/tests/test_transform.py +0 -0
- {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.
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
31
|
-
|
|
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=
|
|
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
|
-
"""
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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.
|
|
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):
|