streamwright 0.1.3__tar.gz → 0.1.5__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.3/src/streamwright.egg-info → streamwright-0.1.5}/PKG-INFO +6 -1
- {streamwright-0.1.3 → streamwright-0.1.5}/README.md +5 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/pyproject.toml +2 -2
- streamwright-0.1.5/src/streamwright/core/catalog.py +205 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/cli.py +13 -8
- {streamwright-0.1.3 → streamwright-0.1.5/src/streamwright.egg-info}/PKG-INFO +6 -1
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/SOURCES.txt +1 -0
- streamwright-0.1.5/tests/test_catalog.py +204 -0
- streamwright-0.1.3/src/streamwright/core/catalog.py +0 -150
- {streamwright-0.1.3 → streamwright-0.1.5}/LICENSE +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/setup.cfg +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/setup.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/__init__.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/config/__init__.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/config/inputs.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/config/loader.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/config/reader.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/connectors.json +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/engine/__init__.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/engine/queries.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/engine/runner.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/engine/sql.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/net/__init__.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/net/downloads.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/net/http.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/__init__.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/dlt_output.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/exporter.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/output.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/staging.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/warehouse.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/__init__.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/components.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/logs.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/templates.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/testing.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/validation/__init__.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/validation/engine.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/validation/schema.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/validation/source.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/dependency_links.txt +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/entry_points.txt +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/requires.txt +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/top_level.txt +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_connectors.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_dlt_output.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_exact_decimals.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_exporter.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_logging.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_output_formats.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_runner.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_sql.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_transform.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_validation.py +0 -0
- {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_warehouse_output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: streamwright
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Declarative data connectors — pull from APIs, files and databases with YAML, shape with SQL, write anywhere.
|
|
5
5
|
Author: Karthick Jaganathan
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -54,8 +54,13 @@ connectors yet. Add the ones you need on demand:
|
|
|
54
54
|
streamwright connectors list # browse the hub
|
|
55
55
|
streamwright connectors install files # a reader (files, s3, gcs, postgres)
|
|
56
56
|
streamwright connectors install google_ads # an API connector (pulls in its SDK)
|
|
57
|
+
streamwright connectors install google_ads meta_ads microsoft_ads # several at once (one pip install)
|
|
57
58
|
```
|
|
58
59
|
|
|
60
|
+
The catalog comes from the live [StreamWright hub](https://github.com/karthick-jaganathan/streamwright-hub). When it is
|
|
61
|
+
unreachable, the CLI warns and uses the last copy it fetched, else the one bundled with this release;
|
|
62
|
+
`--hub-url bundled` uses the bundled copy only, with no network (reproducible builds).
|
|
63
|
+
|
|
59
64
|
## Quickstart
|
|
60
65
|
|
|
61
66
|
```bash
|
|
@@ -26,8 +26,13 @@ connectors yet. Add the ones you need on demand:
|
|
|
26
26
|
streamwright connectors list # browse the hub
|
|
27
27
|
streamwright connectors install files # a reader (files, s3, gcs, postgres)
|
|
28
28
|
streamwright connectors install google_ads # an API connector (pulls in its SDK)
|
|
29
|
+
streamwright connectors install google_ads meta_ads microsoft_ads # several at once (one pip install)
|
|
29
30
|
```
|
|
30
31
|
|
|
32
|
+
The catalog comes from the live [StreamWright hub](https://github.com/karthick-jaganathan/streamwright-hub). When it is
|
|
33
|
+
unreachable, the CLI warns and uses the last copy it fetched, else the one bundled with this release;
|
|
34
|
+
`--hub-url bundled` uses the bundled copy only, with no network (reproducible builds).
|
|
35
|
+
|
|
31
36
|
## Quickstart
|
|
32
37
|
|
|
33
38
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "streamwright"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
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
10
|
license = "Apache-2.0"
|
|
@@ -58,5 +58,5 @@ packages = [
|
|
|
58
58
|
]
|
|
59
59
|
|
|
60
60
|
[tool.setuptools.package-data]
|
|
61
|
-
# the bundled connector catalog (
|
|
61
|
+
# the bundled connector catalog (the fallback of `streamwright connectors` when the live hub is unreachable)
|
|
62
62
|
"streamwright.core" = ["connectors.json"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""The connector catalog: discover and install connectors from the StreamWright hub.
|
|
3
|
+
|
|
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), 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.
|
|
12
|
+
|
|
13
|
+
Security: non-"official" connectors run third-party code, so `install` asks for
|
|
14
|
+
confirmation (or `--yes`) and refuses non-interactive installs without it.
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from importlib import resources
|
|
24
|
+
except ImportError: # pragma: no cover
|
|
25
|
+
import importlib_resources as resources
|
|
26
|
+
|
|
27
|
+
from streamwright.core.runtime import components
|
|
28
|
+
|
|
29
|
+
HUB_ENV = "STREAMWRIGHT_HUB_URL"
|
|
30
|
+
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
|
+
CACHE = Path(os.path.expanduser("~/.streamwright/connectors.json"))
|
|
33
|
+
TIMEOUT_S = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bundled():
|
|
37
|
+
text = resources.files("streamwright.core").joinpath("connectors.json").read_text()
|
|
38
|
+
return json.loads(text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _valid(data):
|
|
42
|
+
return isinstance(data, dict) and isinstance(data.get("connectors"), dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _remote(url):
|
|
46
|
+
import requests
|
|
47
|
+
|
|
48
|
+
response = requests.get(url, timeout=TIMEOUT_S)
|
|
49
|
+
response.raise_for_status()
|
|
50
|
+
data = response.json()
|
|
51
|
+
if not _valid(data):
|
|
52
|
+
raise ValueError("not a connector catalog (no `connectors` mapping)")
|
|
53
|
+
try:
|
|
54
|
+
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
CACHE.write_text(json.dumps(data))
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load(hub_url=None):
|
|
62
|
+
"""
|
|
63
|
+
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.
|
|
65
|
+
"""
|
|
66
|
+
url = hub_url or os.environ.get(HUB_ENV) or DEFAULT_HUB
|
|
67
|
+
if url == BUNDLED:
|
|
68
|
+
return _bundled()
|
|
69
|
+
try:
|
|
70
|
+
return _remote(url)
|
|
71
|
+
except Exception as exc: # offline, hub down or a bad publish: fall back, visibly
|
|
72
|
+
reason = type(exc).__name__ # network errors: the type is enough; an HTTP status or a bad index says why
|
|
73
|
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
74
|
+
if status:
|
|
75
|
+
reason = "HTTP %s" % status
|
|
76
|
+
elif isinstance(exc, ValueError):
|
|
77
|
+
reason += ": %s" % str(exc)[:100]
|
|
78
|
+
if CACHE.exists():
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(CACHE.read_text())
|
|
81
|
+
if _valid(data):
|
|
82
|
+
sys.stderr.write("streamwright: the connector hub %s is unreachable (%s); using the copy fetched on %s\n"
|
|
83
|
+
% (url, reason, _mtime(CACHE)))
|
|
84
|
+
return data
|
|
85
|
+
except Exception:
|
|
86
|
+
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()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _mtime(path):
|
|
93
|
+
import datetime
|
|
94
|
+
return datetime.datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def connectors(hub_url=None):
|
|
98
|
+
return load(hub_url).get("connectors", {})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def installed():
|
|
102
|
+
try:
|
|
103
|
+
return set(components.available())
|
|
104
|
+
except Exception:
|
|
105
|
+
return set()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def search(text, hub_url=None):
|
|
109
|
+
"""The catalog connectors whose key, title, summary or family contains every word of `text`."""
|
|
110
|
+
words = (text or "").lower().split()
|
|
111
|
+
hits = {}
|
|
112
|
+
for key, entry in connectors(hub_url).items():
|
|
113
|
+
haystack = " ".join([key, entry.get("title", ""), entry.get("summary", ""),
|
|
114
|
+
entry.get("family", "")]).lower()
|
|
115
|
+
if all(word in haystack for word in words):
|
|
116
|
+
hits[key] = entry
|
|
117
|
+
return hits
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _source_of(install):
|
|
121
|
+
if "pip" in install:
|
|
122
|
+
return "PyPI: " + install["pip"]
|
|
123
|
+
if "git" in install:
|
|
124
|
+
return "git: " + install["git"]
|
|
125
|
+
if "image" in install:
|
|
126
|
+
return "image: " + install["image"]
|
|
127
|
+
return "unknown"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def listing(hub_url=None):
|
|
131
|
+
"""Lines for `streamwright connectors list`: every catalog connector with install state + trust."""
|
|
132
|
+
have = installed()
|
|
133
|
+
lines = []
|
|
134
|
+
by_family = {}
|
|
135
|
+
for key, entry in connectors(hub_url).items():
|
|
136
|
+
by_family.setdefault(entry.get("family", "other"), []).append((key, entry))
|
|
137
|
+
for family in sorted(by_family):
|
|
138
|
+
lines.append("%s:" % family)
|
|
139
|
+
for key, entry in sorted(by_family[family]):
|
|
140
|
+
mark = "[installed]" if key in have else " "
|
|
141
|
+
trust = entry.get("trust", "community")
|
|
142
|
+
lines.append(" %s %-16s %-10s %s" % (mark, key, trust, entry.get("summary", "")))
|
|
143
|
+
return lines
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def install(key, assume_yes=False, hub_url=None, pip_args=()):
|
|
147
|
+
"""Resolve one connector `key` in the catalog and install it (see install_many)."""
|
|
148
|
+
return install_many([key], assume_yes=assume_yes, hub_url=hub_url, pip_args=pip_args)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def install_many(keys, assume_yes=False, hub_url=None, pip_args=()):
|
|
152
|
+
"""
|
|
153
|
+
Resolve connector `keys` in the catalog and install them with ONE pip command, so pip resolves their
|
|
154
|
+
dependencies together. Every key is checked first: an unknown key, or a connector without an installable
|
|
155
|
+
source, installs nothing. Image-distributed connectors are reported, not installed. Non-"official"
|
|
156
|
+
connectors need one confirmation for all of them (or `assume_yes`), and are refused non-interactively.
|
|
157
|
+
"""
|
|
158
|
+
keys = list(dict.fromkeys(keys)) # in order, without repeats
|
|
159
|
+
if not keys:
|
|
160
|
+
sys.stderr.write("streamwright: 'connectors install' needs at least one connector KEY\n")
|
|
161
|
+
return 2
|
|
162
|
+
catalog = connectors(hub_url)
|
|
163
|
+
unknown = [key for key in keys if key not in catalog]
|
|
164
|
+
if unknown:
|
|
165
|
+
sys.stderr.write("streamwright: unknown connector%s %s (try: streamwright connectors list)\n" % (
|
|
166
|
+
"s" if len(unknown) > 1 else "", ", ".join(repr(key) for key in unknown)))
|
|
167
|
+
return 2
|
|
168
|
+
|
|
169
|
+
specs, untrusted = [], []
|
|
170
|
+
for key in keys:
|
|
171
|
+
entry = catalog[key]
|
|
172
|
+
inst = entry.get("install", {})
|
|
173
|
+
if "image" in inst:
|
|
174
|
+
print("%r is distributed as an image, not a Python package:" % key)
|
|
175
|
+
print(" %s" % inst["image"])
|
|
176
|
+
print("Run it with the docker or k8s execution mode (it is not pip-installed).")
|
|
177
|
+
continue
|
|
178
|
+
spec = inst.get("pip") or inst.get("git")
|
|
179
|
+
if not spec:
|
|
180
|
+
sys.stderr.write("streamwright: connector %r has no installable source\n" % key)
|
|
181
|
+
return 2
|
|
182
|
+
specs.append(spec)
|
|
183
|
+
trust = entry.get("trust", "community")
|
|
184
|
+
if trust != "official":
|
|
185
|
+
untrusted.append((key, trust, _source_of(inst)))
|
|
186
|
+
if not specs:
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
if untrusted and not assume_yes:
|
|
190
|
+
names = ", ".join("%s connector %r" % (trust, key) for key, trust, _ in untrusted)
|
|
191
|
+
if not sys.stdin.isatty():
|
|
192
|
+
sys.stderr.write("streamwright: refusing to install %s non-interactively; pass --yes to confirm (%s)\n"
|
|
193
|
+
% (names, "; ".join(source for _, _, source in untrusted)))
|
|
194
|
+
return 2
|
|
195
|
+
sys.stderr.write("\n WARNING: installing runs third-party code from:\n")
|
|
196
|
+
for key, trust, source in untrusted:
|
|
197
|
+
sys.stderr.write(" %s (%s): %s\n" % (key, trust, source))
|
|
198
|
+
answer = input(" Continue? [y/N] ").strip().lower()
|
|
199
|
+
if answer not in ("y", "yes"):
|
|
200
|
+
print("aborted")
|
|
201
|
+
return 1
|
|
202
|
+
|
|
203
|
+
cmd = [sys.executable, "-m", "pip", "install", *pip_args, *specs]
|
|
204
|
+
print("+ " + " ".join(cmd), flush=True)
|
|
205
|
+
return subprocess.call(cmd)
|
|
@@ -22,6 +22,8 @@ The `streamwright` command.
|
|
|
22
22
|
[--file-name TEMPLATE] [--allow-connector NAME] [--summary FILE] [logging options]
|
|
23
23
|
(SOURCE: a source folder - source.yaml and streams/ - or a source file)
|
|
24
24
|
streamwright connectors (installed connectors, with the names of their SDK loggers)
|
|
25
|
+
streamwright connectors list | search WORDS | install KEY [KEY ...] [--yes] [--hub-url URL]
|
|
26
|
+
(the catalog; install resolves every KEY, then runs one pip install)
|
|
25
27
|
streamwright validate PATH... (streamwright-validate, plus the checks of the installed connectors and query builders)
|
|
26
28
|
|
|
27
29
|
Logging options (streamwright run and streamwright validate): --log-level LEVEL, --log NAME=LEVEL, --log-format text|json,
|
|
@@ -240,12 +242,14 @@ def _parser():
|
|
|
240
242
|
help="list, search or install connectors (bare: the installed connectors and their "
|
|
241
243
|
"SDK loggers)")
|
|
242
244
|
conn.add_argument("action", nargs="?", choices=["list", "search", "install"],
|
|
243
|
-
help="list (catalog + installed), search TEXT, or install KEY")
|
|
244
|
-
conn.add_argument("query", nargs="
|
|
245
|
-
help="
|
|
245
|
+
help="list (catalog + installed), search TEXT, or install KEY [KEY ...]")
|
|
246
|
+
conn.add_argument("query", nargs="*", metavar="KEY|TEXT",
|
|
247
|
+
help="connector keys (install: one or more) or words (search)")
|
|
246
248
|
conn.add_argument("--yes", action="store_true", help="skip the confirmation prompt when installing")
|
|
247
249
|
conn.add_argument("--hub-url", metavar="URL",
|
|
248
|
-
help="
|
|
250
|
+
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")
|
|
249
253
|
|
|
250
254
|
validate = commands.add_parser("validate", help="check configuration files: streamwright-validate, plus the checks of "
|
|
251
255
|
"the installed connectors and query builders", add_help=False)
|
|
@@ -544,18 +548,19 @@ def _connectors_command(args):
|
|
|
544
548
|
print("\n".join(lines) if lines else "the catalog is empty")
|
|
545
549
|
return 0
|
|
546
550
|
if action == "search":
|
|
547
|
-
|
|
551
|
+
text = " ".join(args.query)
|
|
552
|
+
hits = catalog.search(text, args.hub_url)
|
|
548
553
|
if not hits:
|
|
549
|
-
print("no connectors match %r" %
|
|
554
|
+
print("no connectors match %r" % text)
|
|
550
555
|
return 0
|
|
551
556
|
for key, entry in sorted(hits.items()):
|
|
552
557
|
print(" %-16s %-10s %s" % (key, entry.get("trust", "community"), entry.get("summary", "")))
|
|
553
558
|
return 0
|
|
554
559
|
if action == "install":
|
|
555
560
|
if not args.query:
|
|
556
|
-
sys.stderr.write("streamwright: 'connectors install' needs
|
|
561
|
+
sys.stderr.write("streamwright: 'connectors install' needs at least one connector KEY\n")
|
|
557
562
|
return 2
|
|
558
|
-
return catalog.
|
|
563
|
+
return catalog.install_many(args.query, assume_yes=args.yes, hub_url=args.hub_url)
|
|
559
564
|
return 2
|
|
560
565
|
|
|
561
566
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: streamwright
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Declarative data connectors — pull from APIs, files and databases with YAML, shape with SQL, write anywhere.
|
|
5
5
|
Author: Karthick Jaganathan
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -54,8 +54,13 @@ connectors yet. Add the ones you need on demand:
|
|
|
54
54
|
streamwright connectors list # browse the hub
|
|
55
55
|
streamwright connectors install files # a reader (files, s3, gcs, postgres)
|
|
56
56
|
streamwright connectors install google_ads # an API connector (pulls in its SDK)
|
|
57
|
+
streamwright connectors install google_ads meta_ads microsoft_ads # several at once (one pip install)
|
|
57
58
|
```
|
|
58
59
|
|
|
60
|
+
The catalog comes from the live [StreamWright hub](https://github.com/karthick-jaganathan/streamwright-hub). When it is
|
|
61
|
+
unreachable, the CLI warns and uses the last copy it fetched, else the one bundled with this release;
|
|
62
|
+
`--hub-url bundled` uses the bundled copy only, with no network (reproducible builds).
|
|
63
|
+
|
|
59
64
|
## Quickstart
|
|
60
65
|
|
|
61
66
|
```bash
|
|
@@ -38,6 +38,7 @@ src/streamwright/core/validation/__init__.py
|
|
|
38
38
|
src/streamwright/core/validation/engine.py
|
|
39
39
|
src/streamwright/core/validation/schema.py
|
|
40
40
|
src/streamwright/core/validation/source.py
|
|
41
|
+
tests/test_catalog.py
|
|
41
42
|
tests/test_connectors.py
|
|
42
43
|
tests/test_dlt_output.py
|
|
43
44
|
tests/test_exact_decimals.py
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""`streamwright connectors install KEY [KEY ...]` and search: resolution, consent and the single pip command."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from streamwright.core import catalog, cli
|
|
10
|
+
|
|
11
|
+
CATALOG = {
|
|
12
|
+
"files": {"title": "Files", "family": "readers", "summary": "Local files", "trust": "official",
|
|
13
|
+
"install": {"git": "git+https://example.test/sw.git@abc#subdirectory=connectors/readers/files"}},
|
|
14
|
+
"google_ads": {"title": "Google Ads", "family": "ads", "summary": "Google Ads reports", "trust": "official",
|
|
15
|
+
"install": {"git": "git+https://example.test/sw.git@abc#subdirectory=connectors/ads/google_ads"}},
|
|
16
|
+
"meta_ads": {"title": "Meta Ads", "family": "ads", "summary": "Meta Marketing insights", "trust": "official",
|
|
17
|
+
"install": {"pip": "streamwright-meta-ads>=0.1,<0.2"}},
|
|
18
|
+
"acme": {"title": "Acme", "family": "ads", "summary": "Acme reports", "trust": "community",
|
|
19
|
+
"install": {"git": "git+https://example.test/acme.git@def"}},
|
|
20
|
+
"imaged": {"title": "Imaged", "family": "readers", "summary": "An image", "trust": "official",
|
|
21
|
+
"install": {"image": "ghcr.io/org/imaged@sha256:" + "0" * 64}},
|
|
22
|
+
"empty": {"title": "Empty", "family": "readers", "summary": "No source", "trust": "official", "install": {}},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TTY(io.StringIO):
|
|
27
|
+
def __init__(self, tty):
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.tty = tty
|
|
30
|
+
|
|
31
|
+
def isatty(self):
|
|
32
|
+
return self.tty
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def pip(monkeypatch):
|
|
37
|
+
"""The catalog above; records each pip command instead of running it."""
|
|
38
|
+
calls = []
|
|
39
|
+
monkeypatch.setattr(catalog, "connectors", lambda hub_url=None: CATALOG)
|
|
40
|
+
monkeypatch.setattr(catalog.subprocess, "call", lambda cmd: calls.append(cmd) or 0)
|
|
41
|
+
monkeypatch.setattr(sys, "stdin", TTY(False))
|
|
42
|
+
return calls
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def specs(cmd):
|
|
46
|
+
return cmd[cmd.index("install") + 1:]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_one_key_installs_its_source(pip):
|
|
50
|
+
assert catalog.install("files") == 0
|
|
51
|
+
assert [specs(cmd) for cmd in pip] == [[CATALOG["files"]["install"]["git"]]]
|
|
52
|
+
assert pip[0][:4] == [sys.executable, "-m", "pip", "install"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_several_keys_install_with_one_pip_command_in_order_without_repeats(pip):
|
|
56
|
+
assert catalog.install_many(["google_ads", "meta_ads", "files", "google_ads"]) == 0
|
|
57
|
+
assert len(pip) == 1
|
|
58
|
+
assert specs(pip[0]) == [CATALOG["google_ads"]["install"]["git"], "streamwright-meta-ads>=0.1,<0.2",
|
|
59
|
+
CATALOG["files"]["install"]["git"]]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_an_unknown_key_installs_nothing(pip, capsys):
|
|
63
|
+
assert catalog.install_many(["google_ads", "nope", "nada"]) == 2
|
|
64
|
+
assert pip == []
|
|
65
|
+
assert "unknown connectors 'nope', 'nada'" in capsys.readouterr().err
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_a_connector_without_a_source_installs_nothing(pip, capsys):
|
|
69
|
+
assert catalog.install_many(["files", "empty"]) == 2
|
|
70
|
+
assert pip == []
|
|
71
|
+
assert "'empty' has no installable source" in capsys.readouterr().err
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_no_keys_is_an_error(pip, capsys):
|
|
75
|
+
assert catalog.install_many([]) == 2
|
|
76
|
+
assert pip == []
|
|
77
|
+
assert "at least one connector KEY" in capsys.readouterr().err
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_a_community_connector_is_refused_non_interactively_without_yes(pip, capsys):
|
|
81
|
+
assert catalog.install_many(["files", "acme"]) == 2
|
|
82
|
+
assert pip == []
|
|
83
|
+
assert "refusing to install community connector 'acme' non-interactively" in capsys.readouterr().err
|
|
84
|
+
assert catalog.install_many(["files", "acme"], assume_yes=True) == 0
|
|
85
|
+
assert specs(pip[0]) == [CATALOG["files"]["install"]["git"], CATALOG["acme"]["install"]["git"]]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_one_confirmation_covers_every_untrusted_connector(pip, monkeypatch, capsys):
|
|
89
|
+
monkeypatch.setattr(sys, "stdin", TTY(True))
|
|
90
|
+
answers = []
|
|
91
|
+
monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "n")
|
|
92
|
+
assert catalog.install_many(["acme", "files"]) == 1
|
|
93
|
+
assert pip == [] and len(answers) == 1
|
|
94
|
+
monkeypatch.setattr("builtins.input", lambda prompt: "y")
|
|
95
|
+
assert catalog.install_many(["acme", "files"]) == 0
|
|
96
|
+
assert len(pip) == 1
|
|
97
|
+
assert "acme (community): git: git+https://example.test/acme.git@def" in capsys.readouterr().err
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_images_are_reported_not_installed(pip, capsys):
|
|
101
|
+
assert catalog.install_many(["imaged"]) == 0
|
|
102
|
+
assert pip == []
|
|
103
|
+
assert catalog.install_many(["imaged", "files"]) == 0
|
|
104
|
+
assert specs(pip[0]) == [CATALOG["files"]["install"]["git"]]
|
|
105
|
+
assert "'imaged' is distributed as an image" in capsys.readouterr().out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_search_matches_every_word(pip):
|
|
109
|
+
assert set(catalog.search("ads")) == {"google_ads", "meta_ads", "acme"}
|
|
110
|
+
assert set(catalog.search("google ads")) == {"google_ads"}
|
|
111
|
+
assert set(catalog.search("")) == set(CATALOG)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_cli_installs_several_connectors(pip):
|
|
115
|
+
assert cli.main(["connectors", "install", "google_ads", "meta_ads", "--yes"]) == 0
|
|
116
|
+
assert specs(pip[0]) == [CATALOG["google_ads"]["install"]["git"], "streamwright-meta-ads>=0.1,<0.2"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_cli_install_needs_a_key(pip, capsys):
|
|
120
|
+
assert cli.main(["connectors", "install"]) == 2
|
|
121
|
+
assert pip == []
|
|
122
|
+
assert "at least one connector KEY" in capsys.readouterr().err
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_cli_search_joins_its_words(pip, capsys):
|
|
126
|
+
assert cli.main(["connectors", "search", "meta", "insights"]) == 0
|
|
127
|
+
out = capsys.readouterr().out
|
|
128
|
+
assert "meta_ads" in out and "google_ads" not in out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- where the catalog comes from: the live hub, else the last fetched copy, else the bundled one ---
|
|
132
|
+
|
|
133
|
+
GOOD = {"schema_version": 1, "connectors": {"files": CATALOG["files"]}}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Response:
|
|
137
|
+
def __init__(self, data, status=200):
|
|
138
|
+
self.data, self.status = data, status
|
|
139
|
+
|
|
140
|
+
def raise_for_status(self):
|
|
141
|
+
if self.status >= 400:
|
|
142
|
+
raise RuntimeError("HTTP %d" % self.status)
|
|
143
|
+
|
|
144
|
+
def json(self):
|
|
145
|
+
return self.data
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.fixture
|
|
149
|
+
def hub(monkeypatch, tmp_path):
|
|
150
|
+
"""No $STREAMWRIGHT_HUB_URL, a private cache file, and requests.get answering from `responses` (url -> data)."""
|
|
151
|
+
monkeypatch.delenv(catalog.HUB_ENV, raising=False)
|
|
152
|
+
monkeypatch.setattr(catalog, "CACHE", tmp_path / "connectors.json")
|
|
153
|
+
state = {"responses": {}, "urls": []}
|
|
154
|
+
|
|
155
|
+
def get(url, timeout):
|
|
156
|
+
state["urls"].append(url)
|
|
157
|
+
if url not in state["responses"]:
|
|
158
|
+
raise ConnectionError("cannot reach %s" % url)
|
|
159
|
+
return Response(state["responses"][url])
|
|
160
|
+
monkeypatch.setattr("requests.get", get)
|
|
161
|
+
return state
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_the_live_hub_is_the_default_and_is_cached(hub, capsys):
|
|
165
|
+
hub["responses"][catalog.DEFAULT_HUB] = GOOD
|
|
166
|
+
assert catalog.load() == GOOD
|
|
167
|
+
assert hub["urls"] == [catalog.DEFAULT_HUB]
|
|
168
|
+
assert json.loads(catalog.CACHE.read_text()) == GOOD
|
|
169
|
+
assert capsys.readouterr().err == ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_the_hub_url_can_be_set(hub, monkeypatch):
|
|
173
|
+
hub["responses"]["https://hub.example/a.json"] = GOOD
|
|
174
|
+
hub["responses"]["https://hub.example/b.json"] = GOOD
|
|
175
|
+
monkeypatch.setenv(catalog.HUB_ENV, "https://hub.example/a.json")
|
|
176
|
+
catalog.load()
|
|
177
|
+
catalog.load("https://hub.example/b.json") # --hub-url wins over the environment
|
|
178
|
+
assert hub["urls"] == ["https://hub.example/a.json", "https://hub.example/b.json"]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_bundled_uses_no_network(hub):
|
|
182
|
+
data = catalog.load(catalog.BUNDLED)
|
|
183
|
+
assert hub["urls"] == [] and data == catalog._bundled()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_an_unreachable_hub_falls_back_to_the_last_fetched_copy_with_a_warning(hub, capsys):
|
|
187
|
+
catalog.CACHE.write_text(json.dumps(GOOD))
|
|
188
|
+
assert catalog.load() == GOOD
|
|
189
|
+
err = capsys.readouterr().err
|
|
190
|
+
assert "the connector hub %s is unreachable (ConnectionError" % catalog.DEFAULT_HUB in err
|
|
191
|
+
assert "using the copy fetched on" in err
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_with_no_fetched_copy_the_bundled_catalog_is_used_with_a_warning(hub, capsys):
|
|
195
|
+
assert catalog.load() == catalog._bundled()
|
|
196
|
+
assert "using the catalog bundled with this release" in capsys.readouterr().err
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_a_malformed_index_is_not_used_nor_cached(hub, capsys):
|
|
200
|
+
catalog.CACHE.write_text(json.dumps(GOOD))
|
|
201
|
+
hub["responses"][catalog.DEFAULT_HUB] = {"oops": True}
|
|
202
|
+
assert catalog.load() == GOOD # the good copy, not the bad publish
|
|
203
|
+
assert json.loads(catalog.CACHE.read_text()) == GOOD
|
|
204
|
+
assert "not a connector catalog" in capsys.readouterr().err
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python
|
|
2
|
-
"""The connector catalog: discover and install connectors from the StreamWright hub.
|
|
3
|
-
|
|
4
|
-
A catalog is a static index (bundled with streamwright, optionally refreshed from a
|
|
5
|
-
remote hub URL) that maps a connector key to how it's installed - a PyPI
|
|
6
|
-
requirement, a pinned git URL, or an image. The key is the same name used in
|
|
7
|
-
source YAML (`provider`/`sdk`); the install source is just a delivery detail, so
|
|
8
|
-
a package can move from git to PyPI without changing what users type.
|
|
9
|
-
|
|
10
|
-
Security: non-"official" connectors run third-party code, so `install` asks for
|
|
11
|
-
confirmation (or `--yes`) and refuses non-interactive installs without it.
|
|
12
|
-
"""
|
|
13
|
-
import json
|
|
14
|
-
import os
|
|
15
|
-
import subprocess
|
|
16
|
-
import sys
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
try:
|
|
20
|
-
from importlib import resources
|
|
21
|
-
except ImportError: # pragma: no cover
|
|
22
|
-
import importlib_resources as resources
|
|
23
|
-
|
|
24
|
-
from streamwright.core.runtime import components
|
|
25
|
-
|
|
26
|
-
HUB_ENV = "STREAMWRIGHT_HUB_URL"
|
|
27
|
-
CACHE = Path(os.path.expanduser("~/.streamwright/connectors.json"))
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _bundled():
|
|
31
|
-
text = resources.files("streamwright.core").joinpath("connectors.json").read_text()
|
|
32
|
-
return json.loads(text)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _remote(url):
|
|
36
|
-
import requests
|
|
37
|
-
|
|
38
|
-
response = requests.get(url, timeout=10)
|
|
39
|
-
response.raise_for_status()
|
|
40
|
-
data = response.json()
|
|
41
|
-
try:
|
|
42
|
-
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
-
CACHE.write_text(json.dumps(data))
|
|
44
|
-
except OSError:
|
|
45
|
-
pass
|
|
46
|
-
return data
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def load(hub_url=None):
|
|
50
|
-
"""The catalog: the remote hub if configured (cached), else the bundled default."""
|
|
51
|
-
url = hub_url or os.environ.get(HUB_ENV)
|
|
52
|
-
if url:
|
|
53
|
-
try:
|
|
54
|
-
return _remote(url)
|
|
55
|
-
except Exception: # offline or hub down: fall back
|
|
56
|
-
pass
|
|
57
|
-
if CACHE.exists():
|
|
58
|
-
try:
|
|
59
|
-
return json.loads(CACHE.read_text())
|
|
60
|
-
except Exception:
|
|
61
|
-
pass
|
|
62
|
-
return _bundled()
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def connectors(hub_url=None):
|
|
66
|
-
return load(hub_url).get("connectors", {})
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def installed():
|
|
70
|
-
try:
|
|
71
|
-
return set(components.available())
|
|
72
|
-
except Exception:
|
|
73
|
-
return set()
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def search(text, hub_url=None):
|
|
77
|
-
text = (text or "").lower()
|
|
78
|
-
hits = {}
|
|
79
|
-
for key, entry in connectors(hub_url).items():
|
|
80
|
-
haystack = " ".join([key, entry.get("title", ""), entry.get("summary", ""),
|
|
81
|
-
entry.get("family", "")]).lower()
|
|
82
|
-
if text in haystack:
|
|
83
|
-
hits[key] = entry
|
|
84
|
-
return hits
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _source_of(install):
|
|
88
|
-
if "pip" in install:
|
|
89
|
-
return "PyPI: " + install["pip"]
|
|
90
|
-
if "git" in install:
|
|
91
|
-
return "git: " + install["git"]
|
|
92
|
-
if "image" in install:
|
|
93
|
-
return "image: " + install["image"]
|
|
94
|
-
return "unknown"
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def listing(hub_url=None):
|
|
98
|
-
"""Lines for `streamwright connectors list`: every catalog connector with install state + trust."""
|
|
99
|
-
have = installed()
|
|
100
|
-
lines = []
|
|
101
|
-
by_family = {}
|
|
102
|
-
for key, entry in connectors(hub_url).items():
|
|
103
|
-
by_family.setdefault(entry.get("family", "other"), []).append((key, entry))
|
|
104
|
-
for family in sorted(by_family):
|
|
105
|
-
lines.append("%s:" % family)
|
|
106
|
-
for key, entry in sorted(by_family[family]):
|
|
107
|
-
mark = "[installed]" if key in have else " "
|
|
108
|
-
trust = entry.get("trust", "community")
|
|
109
|
-
lines.append(" %s %-16s %-10s %s" % (mark, key, trust, entry.get("summary", "")))
|
|
110
|
-
return lines
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def install(key, assume_yes=False, hub_url=None, pip_args=()):
|
|
114
|
-
"""Resolve `key` in the catalog and install it (pip for pip/git; images are run, not installed)."""
|
|
115
|
-
entry = connectors(hub_url).get(key)
|
|
116
|
-
if not entry:
|
|
117
|
-
sys.stderr.write("streamwright: unknown connector %r (try: streamwright connectors list)\n" % key)
|
|
118
|
-
return 2
|
|
119
|
-
inst = entry.get("install", {})
|
|
120
|
-
trust = entry.get("trust", "community")
|
|
121
|
-
|
|
122
|
-
if "image" in inst:
|
|
123
|
-
print("%r is distributed as an image, not a Python package:" % key)
|
|
124
|
-
print(" %s" % inst["image"])
|
|
125
|
-
print("Run it with the docker or k8s execution mode (it is not pip-installed).")
|
|
126
|
-
return 0
|
|
127
|
-
|
|
128
|
-
spec = inst.get("pip") or inst.get("git")
|
|
129
|
-
if not spec:
|
|
130
|
-
sys.stderr.write("streamwright: connector %r has no installable source\n" % key)
|
|
131
|
-
return 2
|
|
132
|
-
|
|
133
|
-
if trust != "official" and not assume_yes:
|
|
134
|
-
source = _source_of(inst)
|
|
135
|
-
if not sys.stdin.isatty():
|
|
136
|
-
sys.stderr.write(
|
|
137
|
-
"streamwright: refusing to install %s connector %r non-interactively; pass --yes to confirm (%s)\n"
|
|
138
|
-
% (trust, key, source))
|
|
139
|
-
return 2
|
|
140
|
-
sys.stderr.write(
|
|
141
|
-
"\n WARNING: %r is a %s connector. Installing runs third-party code from:\n %s\n"
|
|
142
|
-
% (key, trust, source))
|
|
143
|
-
answer = input(" Continue? [y/N] ").strip().lower()
|
|
144
|
-
if answer not in ("y", "yes"):
|
|
145
|
-
print("aborted")
|
|
146
|
-
return 1
|
|
147
|
-
|
|
148
|
-
cmd = [sys.executable, "-m", "pip", "install", *pip_args, spec]
|
|
149
|
-
print("+ " + " ".join(cmd))
|
|
150
|
-
return subprocess.call(cmd)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|