streamwright 0.1.5__tar.gz → 0.1.7__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 (61) hide show
  1. streamwright-0.1.7/PKG-INFO +127 -0
  2. streamwright-0.1.7/README.md +98 -0
  3. {streamwright-0.1.5 → streamwright-0.1.7}/pyproject.toml +9 -9
  4. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/catalog.py +6 -22
  5. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/cli.py +47 -14
  6. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/engine/runner.py +93 -27
  7. streamwright-0.1.7/src/streamwright/core/net/encoding.py +50 -0
  8. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/net/http.py +101 -79
  9. streamwright-0.1.7/src/streamwright/core/net/paginators.py +355 -0
  10. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/runtime/components.py +117 -33
  11. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/validation/schema.py +129 -19
  12. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/validation/source.py +98 -20
  13. streamwright-0.1.7/src/streamwright.egg-info/PKG-INFO +127 -0
  14. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright.egg-info/SOURCES.txt +4 -1
  15. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright.egg-info/entry_points.txt +1 -0
  16. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_catalog.py +3 -8
  17. streamwright-0.1.7/tests/test_cli_subcommands.py +32 -0
  18. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_connectors.py +26 -0
  19. streamwright-0.1.7/tests/test_http_engine.py +572 -0
  20. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_logging.py +3 -4
  21. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_validation.py +84 -3
  22. streamwright-0.1.5/PKG-INFO +0 -82
  23. streamwright-0.1.5/README.md +0 -54
  24. streamwright-0.1.5/src/streamwright/core/connectors.json +0 -91
  25. streamwright-0.1.5/src/streamwright.egg-info/PKG-INFO +0 -82
  26. {streamwright-0.1.5 → streamwright-0.1.7}/LICENSE +0 -0
  27. {streamwright-0.1.5 → streamwright-0.1.7}/setup.cfg +0 -0
  28. {streamwright-0.1.5 → streamwright-0.1.7}/setup.py +0 -0
  29. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/__init__.py +0 -0
  30. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/config/__init__.py +0 -0
  31. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/config/inputs.py +0 -0
  32. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/config/loader.py +0 -0
  33. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/config/reader.py +0 -0
  34. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/engine/__init__.py +0 -0
  35. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/engine/queries.py +0 -0
  36. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/engine/sql.py +0 -0
  37. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/net/__init__.py +0 -0
  38. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/net/downloads.py +0 -0
  39. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/outputs/__init__.py +0 -0
  40. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/outputs/dlt_output.py +0 -0
  41. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/outputs/exporter.py +0 -0
  42. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/outputs/output.py +0 -0
  43. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/outputs/staging.py +0 -0
  44. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/outputs/warehouse.py +0 -0
  45. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/runtime/__init__.py +0 -0
  46. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/runtime/logs.py +0 -0
  47. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/runtime/templates.py +0 -0
  48. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/runtime/testing.py +0 -0
  49. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/validation/__init__.py +0 -0
  50. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright/core/validation/engine.py +0 -0
  51. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright.egg-info/dependency_links.txt +0 -0
  52. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright.egg-info/requires.txt +0 -0
  53. {streamwright-0.1.5 → streamwright-0.1.7}/src/streamwright.egg-info/top_level.txt +0 -0
  54. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_dlt_output.py +0 -0
  55. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_exact_decimals.py +0 -0
  56. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_exporter.py +0 -0
  57. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_output_formats.py +0 -0
  58. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_runner.py +0 -0
  59. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_sql.py +0 -0
  60. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_transform.py +0 -0
  61. {streamwright-0.1.5 → streamwright-0.1.7}/tests/test_warehouse_output.py +0 -0
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamwright
3
+ Version: 0.1.7
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://streamwright.web.app
8
+ Project-URL: Documentation, https://streamwright.web.app/docs/
9
+ Project-URL: Repository, https://github.com/karthick-jaganathan/streamwright
10
+ Project-URL: Issue Tracker, https://github.com/karthick-jaganathan/streamwright/issues
11
+ Keywords: data,pipeline,etl,connector,singer,yaml,duckdb,rest-api
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: pyyaml==6.0.2
23
+ Requires-Dist: requests<3,>=2.25
24
+ Requires-Dist: duckdb>=1.5.2
25
+ Requires-Dist: pytz
26
+ Provides-Extra: dlt
27
+ Requires-Dist: dlt>=1.0; extra == "dlt"
28
+ Dynamic: license-file
29
+
30
+ # StreamWright — Declarative Data Pipelines
31
+
32
+ **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.
33
+
34
+ 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.
35
+
36
+ ## How It Works
37
+
38
+ The `streamwright` package provides both the CLI tooling and execution runtime for declarative data sources.
39
+
40
+ Each stream follows a three-stage pipeline:
41
+
42
+ 1. **Fetch** — Retrieve records through declarative REST HTTP requests, storage readers (S3, GCS, and local files), database integrations, or vendor SDK connectors.
43
+ 2. **Shape** — Filter, transform, and normalize records on the fly using embedded DuckDB SQL.
44
+ 3. **Write** — Deliver data to JSONL, CSV, TSV, and Parquet files; DuckDB and DuckLake; Singer-compatible message streams; or supported `dlt` destinations.
45
+
46
+ ## CLI Commands
47
+
48
+ All commands can be invoked interchangeably using `streamwright` or the shorthand `sw` alias (e.g. `sw run`, `sw validate`, `sw connectors`, `sw mcp`).
49
+
50
+ - **`streamwright run`** (or **`sw run`**) — Execute a source and stream records to the configured destination.
51
+ - **`streamwright validate`** (or **`sw validate`**) — Validate source definitions, authentication specifications, and schemas without executing network requests.
52
+ - **`streamwright connectors`** (or **`sw connectors`**) — Browse, inspect, and install connectors from the StreamWright Hub.
53
+ - **`streamwright mcp`** (or **`sw mcp`**) — Launch the Model Context Protocol (MCP) server for AI coding assistants.
54
+
55
+ ## Source Structure
56
+
57
+ 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.
58
+
59
+ ```text
60
+ my_source/
61
+ ├── source.yaml
62
+ └── streams/
63
+ ├── customers.yaml
64
+ └── orders.yaml
65
+ ```
66
+
67
+ - **`source.yaml`** — Defines input parameters (`spec`), authentication (`auth`), and base HTTP configuration (`http`).
68
+ - **`streams/*.yaml`** — Defines stream endpoints, pagination, rate limits, schemas, and stream-specific configuration.
69
+
70
+ ## Installation
71
+
72
+ Install StreamWright using pip:
73
+
74
+ ```bash
75
+ pip install streamwright
76
+ streamwright --help
77
+ ```
78
+
79
+ This installs the CLI, execution runtime, and connector hub client. Install additional connectors on demand:
80
+
81
+ ```bash
82
+ # Browse available connectors
83
+ streamwright connectors list
84
+
85
+ # Install the generic REST API connector
86
+ streamwright connectors install restapi
87
+
88
+ # Install file and storage readers
89
+ streamwright connectors install files
90
+
91
+ # Install Google Ads API support
92
+ streamwright connectors install google_ads
93
+
94
+ # Install multiple connectors at once
95
+ streamwright connectors install google_ads meta_ads microsoft_ads
96
+ ```
97
+
98
+ The connector catalog is discovered from the live [StreamWright Hub](https://github.com/karthick-jaganathan/streamwright-hub) and cached locally in `~/.streamwright/connectors.json`.
99
+
100
+ ## Quickstart
101
+
102
+ The repository includes runnable examples to help you get started.
103
+
104
+ ```bash
105
+ # Install StreamWright and the required connector
106
+ pip install streamwright
107
+ streamwright connectors install files
108
+
109
+ # Clone the repository
110
+ git clone https://github.com/karthick-jaganathan/streamwright.git
111
+
112
+ # Validate the example sources
113
+ streamwright validate streamwright/examples
114
+
115
+ # Run the file-reader example
116
+ streamwright run streamwright/examples/sources/readers/files_demo \
117
+ --set data_root=streamwright/examples/sources/readers/files_demo/data \
118
+ --allow-connector files \
119
+ --output jsonl:out
120
+ ```
121
+
122
+ ## Documentation
123
+
124
+ - **Full documentation:** https://streamwright.web.app/docs/
125
+ - **Architecture Guide:** https://streamwright.web.app/docs/architecture/
126
+ - **Connectors & Readers:** https://streamwright.web.app/docs/connectors/
127
+ - **MCP Server Guide:** https://streamwright.web.app/docs/ai/mcp/
@@ -0,0 +1,98 @@
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
+ All commands can be invoked interchangeably using `streamwright` or the shorthand `sw` alias (e.g. `sw run`, `sw validate`, `sw connectors`, `sw mcp`).
20
+
21
+ - **`streamwright run`** (or **`sw run`**) — Execute a source and stream records to the configured destination.
22
+ - **`streamwright validate`** (or **`sw validate`**) — Validate source definitions, authentication specifications, and schemas without executing network requests.
23
+ - **`streamwright connectors`** (or **`sw connectors`**) — Browse, inspect, and install connectors from the StreamWright Hub.
24
+ - **`streamwright mcp`** (or **`sw mcp`**) — Launch the Model Context Protocol (MCP) server for AI coding assistants.
25
+
26
+ ## Source Structure
27
+
28
+ 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.
29
+
30
+ ```text
31
+ my_source/
32
+ ├── source.yaml
33
+ └── streams/
34
+ ├── customers.yaml
35
+ └── orders.yaml
36
+ ```
37
+
38
+ - **`source.yaml`** — Defines input parameters (`spec`), authentication (`auth`), and base HTTP configuration (`http`).
39
+ - **`streams/*.yaml`** — Defines stream endpoints, pagination, rate limits, schemas, and stream-specific configuration.
40
+
41
+ ## Installation
42
+
43
+ Install StreamWright using pip:
44
+
45
+ ```bash
46
+ pip install streamwright
47
+ streamwright --help
48
+ ```
49
+
50
+ This installs the CLI, execution runtime, and connector hub client. Install additional connectors on demand:
51
+
52
+ ```bash
53
+ # Browse available connectors
54
+ streamwright connectors list
55
+
56
+ # Install the generic REST API connector
57
+ streamwright connectors install restapi
58
+
59
+ # Install file and storage readers
60
+ streamwright connectors install files
61
+
62
+ # Install Google Ads API support
63
+ streamwright connectors install google_ads
64
+
65
+ # Install multiple connectors at once
66
+ streamwright connectors install google_ads meta_ads microsoft_ads
67
+ ```
68
+
69
+ The connector catalog is discovered from the live [StreamWright Hub](https://github.com/karthick-jaganathan/streamwright-hub) and cached locally in `~/.streamwright/connectors.json`.
70
+
71
+ ## Quickstart
72
+
73
+ The repository includes runnable examples to help you get started.
74
+
75
+ ```bash
76
+ # Install StreamWright and the required connector
77
+ pip install streamwright
78
+ streamwright connectors install files
79
+
80
+ # Clone the repository
81
+ git clone https://github.com/karthick-jaganathan/streamwright.git
82
+
83
+ # Validate the example sources
84
+ streamwright validate streamwright/examples
85
+
86
+ # Run the file-reader example
87
+ streamwright run streamwright/examples/sources/readers/files_demo \
88
+ --set data_root=streamwright/examples/sources/readers/files_demo/data \
89
+ --allow-connector files \
90
+ --output jsonl:out
91
+ ```
92
+
93
+ ## Documentation
94
+
95
+ - **Full documentation:** https://streamwright.web.app/docs/
96
+ - **Architecture Guide:** https://streamwright.web.app/docs/architecture/
97
+ - **Connectors & Readers:** https://streamwright.web.app/docs/connectors/
98
+ - **MCP Server Guide:** https://streamwright.web.app/docs/ai/mcp/
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "streamwright"
7
- version = "0.1.5"
7
+ version = "0.1.7"
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",
@@ -36,13 +36,16 @@ dependencies = [
36
36
  dlt = ["dlt>=1.0"]
37
37
 
38
38
  [project.urls]
39
- Homepage = "https://github.com/karthick-jaganathan/streamwright"
40
- Repository = "https://github.com/karthick-jaganathan/streamwright"
39
+ Homepage = "https://streamwright.web.app"
41
40
  Documentation = "https://streamwright.web.app/docs/"
41
+ Repository = "https://github.com/karthick-jaganathan/streamwright"
42
+ "Issue Tracker" = "https://github.com/karthick-jaganathan/streamwright/issues"
42
43
 
43
44
  [project.scripts]
44
45
  # `streamwright run`, `streamwright validate`, `streamwright connectors` - one CLI (see streamwright.core.cli)
45
46
  streamwright = "streamwright.core.cli:main"
47
+ sw = "streamwright.core.cli:main"
48
+
46
49
 
47
50
  [tool.setuptools]
48
51
  # src layout: `streamwright` is a namespace package shared with the connector distributions (streamwright.connectors.*)
@@ -57,6 +60,3 @@ packages = [
57
60
  "streamwright.core.runtime",
58
61
  ]
59
62
 
60
- [tool.setuptools.package-data]
61
- # the bundled connector catalog (the fallback of `streamwright connectors` when the live hub is unreachable)
62
- "streamwright.core" = ["connectors.json"]
@@ -4,11 +4,10 @@
4
4
  A catalog is a static index that maps a connector key to how it's installed - a
5
5
  PyPI requirement, a pinned git URL, or an image. It is read from the live hub
6
6
  (DEFAULT_HUB, or $STREAMWRIGHT_HUB_URL / --hub-url); when the hub is unreachable,
7
- from the last copy fetched (~/.streamwright/connectors.json), else from the copy
8
- bundled with this release - with a warning either way. The hub URL "bundled" uses
9
- the bundled copy only (no network): for reproducible builds. The key is the same name used in
10
- source YAML (`provider`/`sdk`); the install source is just a delivery detail, so
11
- a package can move from git to PyPI without changing what users type.
7
+ from the last copy fetched (~/.streamwright/connectors.json) with a warning.
8
+ The key is the same name used in source YAML (`provider`/`sdk`); the install source
9
+ is just a delivery detail, so a package can move from git to PyPI without changing
10
+ what users type.
12
11
 
13
12
  Security: non-"official" connectors run third-party code, so `install` asks for
14
13
  confirmation (or `--yes`) and refuses non-interactive installs without it.
@@ -19,25 +18,14 @@ import subprocess
19
18
  import sys
20
19
  from pathlib import Path
21
20
 
22
- try:
23
- from importlib import resources
24
- except ImportError: # pragma: no cover
25
- import importlib_resources as resources
26
-
27
21
  from streamwright.core.runtime import components
28
22
 
29
23
  HUB_ENV = "STREAMWRIGHT_HUB_URL"
30
24
  DEFAULT_HUB = "https://karthick-jaganathan.github.io/streamwright-hub/index.json"
31
- BUNDLED = "bundled" # a hub URL that means: the catalog bundled with this release, no network
32
25
  CACHE = Path(os.path.expanduser("~/.streamwright/connectors.json"))
33
26
  TIMEOUT_S = 5
34
27
 
35
28
 
36
- def _bundled():
37
- text = resources.files("streamwright.core").joinpath("connectors.json").read_text()
38
- return json.loads(text)
39
-
40
-
41
29
  def _valid(data):
42
30
  return isinstance(data, dict) and isinstance(data.get("connectors"), dict)
43
31
 
@@ -61,11 +49,9 @@ def _remote(url):
61
49
  def load(hub_url=None):
62
50
  """
63
51
  The catalog: the live hub (`hub_url`, else $STREAMWRIGHT_HUB_URL, else DEFAULT_HUB; each fetch is cached). When the
64
- hub cannot be read, the cached copy, else the bundled one, with a warning on stderr. `bundled`: the bundled copy only.
52
+ hub cannot be read, the cached copy is used with a warning on stderr.
65
53
  """
66
54
  url = hub_url or os.environ.get(HUB_ENV) or DEFAULT_HUB
67
- if url == BUNDLED:
68
- return _bundled()
69
55
  try:
70
56
  return _remote(url)
71
57
  except Exception as exc: # offline, hub down or a bad publish: fall back, visibly
@@ -84,9 +70,7 @@ def load(hub_url=None):
84
70
  return data
85
71
  except Exception:
86
72
  pass
87
- sys.stderr.write("streamwright: the connector hub %s is unreachable (%s); using the catalog bundled with this "
88
- "release\n" % (url, reason))
89
- return _bundled()
73
+ raise RuntimeError("the connector hub %s is unreachable (%s) and no cached catalog is available" % (url, reason))
90
74
 
91
75
 
92
76
  def _mtime(path):
@@ -16,7 +16,7 @@
16
16
  # **************************************************************************/
17
17
 
18
18
  """
19
- The `streamwright` command.
19
+ The `streamwright` command (shorthand: `sw`).
20
20
 
21
21
  streamwright run SOURCE [--config FILE] [--set NAME=VALUE] [--secrets FILE] [--state FILE] [--stream NAME] [--output ...]
22
22
  [--file-name TEMPLATE] [--allow-connector NAME] [--summary FILE] [logging options]
@@ -25,6 +25,7 @@ The `streamwright` command.
25
25
  streamwright connectors list | search WORDS | install KEY [KEY ...] [--yes] [--hub-url URL]
26
26
  (the catalog; install resolves every KEY, then runs one pip install)
27
27
  streamwright validate PATH... (streamwright-validate, plus the checks of the installed connectors and query builders)
28
+ streamwright mcp [--transport stdio|sse] (Model Context Protocol server for AI assistants)
28
29
 
29
30
  Logging options (streamwright run and streamwright validate): --log-level LEVEL, --log NAME=LEVEL, --log-format text|json,
30
31
  --log-config FILE, --log-max-chars N. Loggers: streamwright.source (progress, summaries, warnings), streamwright.network (INFO: a
@@ -37,6 +38,7 @@ Exit status: 0 success, 1 the run failed, 2 invalid source or inputs.
37
38
 
38
39
  import argparse
39
40
  import datetime
41
+ import functools
40
42
  import json
41
43
  import logging
42
44
  import os
@@ -248,12 +250,14 @@ def _parser():
248
250
  conn.add_argument("--yes", action="store_true", help="skip the confirmation prompt when installing")
249
251
  conn.add_argument("--hub-url", metavar="URL",
250
252
  help="the connector hub (default: $STREAMWRIGHT_HUB_URL, else the live StreamWright hub; when it is "
251
- "unreachable, the last fetched or the bundled catalog, with a warning). 'bundled': the "
252
- "catalog bundled with this release, no network")
253
+ "unreachable, the cached catalog is used, with a warning)")
253
254
 
254
255
  validate = commands.add_parser("validate", help="check configuration files: streamwright-validate, plus the checks of "
255
256
  "the installed connectors and query builders", add_help=False)
256
257
  validate.add_argument("arguments", nargs=argparse.REMAINDER)
258
+
259
+ mcp_cmd = commands.add_parser("mcp", help="run the StreamWright Model Context Protocol (MCP) server", add_help=False)
260
+ mcp_cmd.add_argument("arguments", nargs=argparse.REMAINDER)
257
261
  return parser
258
262
 
259
263
 
@@ -509,8 +513,17 @@ def _log_error(message):
509
513
  return None
510
514
 
511
515
 
516
+ @functools.lru_cache(maxsize=None)
517
+ def _pkg_version(dist_name):
518
+ try:
519
+ from importlib import metadata
520
+ return metadata.version(dist_name)
521
+ except Exception:
522
+ return None
523
+
524
+
512
525
  def _connector_lines():
513
- """`streamwright connectors`: installed connectors grouped by category, each with its description and SDK loggers."""
526
+ """`streamwright connectors`: installed connectors grouped by category using connector.spec."""
514
527
  groups = {}
515
528
  for name in components.available():
516
529
  try:
@@ -518,18 +531,31 @@ def _connector_lines():
518
531
  except components.ComponentLoadError as exc:
519
532
  groups.setdefault("other", []).append(" %s — %s" % (name, exc))
520
533
  continue
521
- loggers = getattr(connector, "network_loggers", None) or ()
522
- loggers = (loggers,) if isinstance(loggers, str) else tuple(loggers)
523
- parts = [name]
524
- summary = getattr(connector, "summary", "") or ""
525
- if summary:
526
- parts.append("— " + summary)
527
- if loggers:
528
- parts.append("(SDK loggers: %s)" % ", ".join(loggers))
529
- category = getattr(connector, "category", "other") or "other"
530
- groups.setdefault(category, []).append(" " + " ".join(parts))
534
+ spec = getattr(connector, "spec", None)
535
+ if spec is None:
536
+ spec = components.ConnectorSpec(name=name)
537
+ prefix = " [installed] "
538
+ conn_name = spec.name or name
539
+ title = spec.title
540
+ client_tag = spec.badge(_pkg_version)
541
+ loggers = spec.effective_loggers()
542
+
543
+ if client_tag and loggers:
544
+ logger_str = ", ".join(loggers)
545
+ line = "%s%-15s%-23s%-32s [logger: %s]" % (prefix, conn_name, title, client_tag, logger_str)
546
+ elif client_tag:
547
+ line = "%s%-15s%-23s%s" % (prefix, conn_name, title, client_tag)
548
+ elif loggers:
549
+ logger_str = ", ".join(loggers)
550
+ line = "%s%-15s%-23s%-32s [logger: %s]" % (prefix, conn_name, title, "", logger_str)
551
+ else:
552
+ line = "%s%-15s%s" % (prefix, conn_name, title)
553
+
554
+ groups.setdefault(spec.category, []).append(line)
531
555
  lines = []
532
556
  for category in sorted(groups):
557
+ if lines:
558
+ lines.append("")
533
559
  lines.append("%s:" % category)
534
560
  lines.extend(sorted(groups[category]))
535
561
  return lines
@@ -579,6 +605,13 @@ def main(argv=None):
579
605
  non_strict_codes=("connector-not-installed",))
580
606
  finally:
581
607
  setup.close()
608
+ if argv[:1] == ["mcp"]:
609
+ try:
610
+ from streamwright_mcp.cli import main as mcp_main
611
+ except ImportError:
612
+ sys.stderr.write("streamwright: 'streamwright-mcp' is not installed. Install it with: pip install streamwright-mcp\n")
613
+ return 1
614
+ return mcp_main(argv[1:]) or 0
582
615
  args = _parser().parse_args(argv)
583
616
  if args.command == "connectors":
584
617
  return _connectors_command(args)
@@ -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):