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.
Files changed (55) hide show
  1. {streamwright-0.1.3/src/streamwright.egg-info → streamwright-0.1.5}/PKG-INFO +6 -1
  2. {streamwright-0.1.3 → streamwright-0.1.5}/README.md +5 -0
  3. {streamwright-0.1.3 → streamwright-0.1.5}/pyproject.toml +2 -2
  4. streamwright-0.1.5/src/streamwright/core/catalog.py +205 -0
  5. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/cli.py +13 -8
  6. {streamwright-0.1.3 → streamwright-0.1.5/src/streamwright.egg-info}/PKG-INFO +6 -1
  7. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/SOURCES.txt +1 -0
  8. streamwright-0.1.5/tests/test_catalog.py +204 -0
  9. streamwright-0.1.3/src/streamwright/core/catalog.py +0 -150
  10. {streamwright-0.1.3 → streamwright-0.1.5}/LICENSE +0 -0
  11. {streamwright-0.1.3 → streamwright-0.1.5}/setup.cfg +0 -0
  12. {streamwright-0.1.3 → streamwright-0.1.5}/setup.py +0 -0
  13. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/__init__.py +0 -0
  14. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/config/__init__.py +0 -0
  15. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/config/inputs.py +0 -0
  16. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/config/loader.py +0 -0
  17. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/config/reader.py +0 -0
  18. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/connectors.json +0 -0
  19. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/engine/__init__.py +0 -0
  20. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/engine/queries.py +0 -0
  21. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/engine/runner.py +0 -0
  22. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/engine/sql.py +0 -0
  23. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/net/__init__.py +0 -0
  24. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/net/downloads.py +0 -0
  25. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/net/http.py +0 -0
  26. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/__init__.py +0 -0
  27. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/dlt_output.py +0 -0
  28. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/exporter.py +0 -0
  29. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/output.py +0 -0
  30. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/staging.py +0 -0
  31. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/outputs/warehouse.py +0 -0
  32. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/__init__.py +0 -0
  33. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/components.py +0 -0
  34. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/logs.py +0 -0
  35. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/templates.py +0 -0
  36. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/runtime/testing.py +0 -0
  37. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/validation/__init__.py +0 -0
  38. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/validation/engine.py +0 -0
  39. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/validation/schema.py +0 -0
  40. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright/core/validation/source.py +0 -0
  41. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/dependency_links.txt +0 -0
  42. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/entry_points.txt +0 -0
  43. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/requires.txt +0 -0
  44. {streamwright-0.1.3 → streamwright-0.1.5}/src/streamwright.egg-info/top_level.txt +0 -0
  45. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_connectors.py +0 -0
  46. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_dlt_output.py +0 -0
  47. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_exact_decimals.py +0 -0
  48. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_exporter.py +0 -0
  49. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_logging.py +0 -0
  50. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_output_formats.py +0 -0
  51. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_runner.py +0 -0
  52. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_sql.py +0 -0
  53. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_transform.py +0 -0
  54. {streamwright-0.1.3 → streamwright-0.1.5}/tests/test_validation.py +0 -0
  55. {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
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.3"
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 (default for `streamwright connectors`, refreshed from $STREAMWRIGHT_HUB_URL)
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="?", metavar="KEY|TEXT",
245
- help="a connector key (install) or text (search)")
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="catalog URL to use (default: the bundled catalog, or $STREAMWRIGHT_HUB_URL)")
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
- hits = catalog.search(args.query or "", args.hub_url)
551
+ text = " ".join(args.query)
552
+ hits = catalog.search(text, args.hub_url)
548
553
  if not hits:
549
- print("no connectors match %r" % (args.query or ""))
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 a connector KEY\n")
561
+ sys.stderr.write("streamwright: 'connectors install' needs at least one connector KEY\n")
557
562
  return 2
558
- return catalog.install(args.query, assume_yes=args.yes, hub_url=args.hub_url)
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
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