inthub-cli 0.1.4__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.
- inthub_cli-0.1.4/.gitignore +9 -0
- inthub_cli-0.1.4/Makefile +42 -0
- inthub_cli-0.1.4/PKG-INFO +17 -0
- inthub_cli-0.1.4/README.md +121 -0
- inthub_cli-0.1.4/inthub/__init__.py +0 -0
- inthub_cli-0.1.4/inthub/__main__.py +4 -0
- inthub_cli-0.1.4/inthub/bulk/__init__.py +0 -0
- inthub_cli-0.1.4/inthub/bulk/csv_state.py +118 -0
- inthub_cli-0.1.4/inthub/bulk/ddl_converter.py +86 -0
- inthub_cli-0.1.4/inthub/bulk/gcp_secrets.py +23 -0
- inthub_cli-0.1.4/inthub/bulk/snowflake_client.py +61 -0
- inthub_cli-0.1.4/inthub/bulk/sqlserver.py +51 -0
- inthub_cli-0.1.4/inthub/cli.py +27 -0
- inthub_cli-0.1.4/inthub/client.py +85 -0
- inthub_cli-0.1.4/inthub/commands/__init__.py +0 -0
- inthub_cli-0.1.4/inthub/commands/auth.py +57 -0
- inthub_cli-0.1.4/inthub/commands/bulk/__init__.py +11 -0
- inthub_cli-0.1.4/inthub/commands/bulk/manage.py +98 -0
- inthub_cli-0.1.4/inthub/commands/bulk/progress.py +46 -0
- inthub_cli-0.1.4/inthub/commands/bulk/src_sqlserver_sink_snowflake/__init__.py +123 -0
- inthub_cli-0.1.4/inthub/commands/bulk/src_sqlserver_sink_snowflake/connectors.py +134 -0
- inthub_cli-0.1.4/inthub/commands/bulk/src_sqlserver_sink_snowflake/orchestrator.py +326 -0
- inthub_cli-0.1.4/inthub/commands/connectors.py +186 -0
- inthub_cli-0.1.4/inthub/config.py +48 -0
- inthub_cli-0.1.4/plan.md +420 -0
- inthub_cli-0.1.4/plan_bulk.md +499 -0
- inthub_cli-0.1.4/pyproject.toml +47 -0
- inthub_cli-0.1.4/tests/__init__.py +0 -0
- inthub_cli-0.1.4/tests/bulk/__init__.py +0 -0
- inthub_cli-0.1.4/tests/bulk/test_csv_state.py +151 -0
- inthub_cli-0.1.4/tests/bulk/test_ddl_converter.py +100 -0
- inthub_cli-0.1.4/tests/test_config.py +76 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
.DEFAULT_GOAL := help
|
|
2
|
+
|
|
3
|
+
VENV := .venv
|
|
4
|
+
PYTHON := $(VENV)/bin/python
|
|
5
|
+
PIP := $(VENV)/bin/pip
|
|
6
|
+
|
|
7
|
+
## help: Show available targets
|
|
8
|
+
.PHONY: help
|
|
9
|
+
help:
|
|
10
|
+
@grep -h '##' $(MAKEFILE_LIST) | grep -v grep | sed 's/## //'
|
|
11
|
+
|
|
12
|
+
## local-install: Create virtualenv, install inthub CLI in editable mode, and link to /usr/local/bin
|
|
13
|
+
.PHONY: local-install
|
|
14
|
+
local-install:
|
|
15
|
+
@if [ ! -d "$(VENV)" ]; then \
|
|
16
|
+
echo "Creating virtualenv..."; \
|
|
17
|
+
python3 -m venv $(VENV); \
|
|
18
|
+
fi
|
|
19
|
+
$(PIP) install -e ".[dev]" -q
|
|
20
|
+
@mkdir -p "$(HOME)/.local/bin"
|
|
21
|
+
@ln -sf "$(CURDIR)/$(VENV)/bin/inthub" "$(HOME)/.local/bin/inthub"
|
|
22
|
+
@printf '#!/bin/sh\nINTHUB_BASE_URL=http://localhost:8080/api exec inthub "$$@"\n' \
|
|
23
|
+
> "$(HOME)/.local/bin/inthublocal"
|
|
24
|
+
@chmod +x "$(HOME)/.local/bin/inthublocal"
|
|
25
|
+
@echo "Done. 'inthub' and 'inthublocal' linked to ~/.local/bin/"
|
|
26
|
+
@echo "$(PATH)" | grep -q "$(HOME)/.local/bin" || \
|
|
27
|
+
echo "⚠️ Add ~/.local/bin to PATH: export PATH=\"\$$HOME/.local/bin:\$$PATH\""
|
|
28
|
+
|
|
29
|
+
## lint: Run ruff linter
|
|
30
|
+
.PHONY: lint
|
|
31
|
+
lint:
|
|
32
|
+
$(VENV)/bin/ruff check inthub/ tests/
|
|
33
|
+
|
|
34
|
+
## type-check: Run mypy type checker
|
|
35
|
+
.PHONY: type-check
|
|
36
|
+
type-check:
|
|
37
|
+
$(VENV)/bin/mypy inthub/
|
|
38
|
+
|
|
39
|
+
## test: Run pytest
|
|
40
|
+
.PHONY: test
|
|
41
|
+
test:
|
|
42
|
+
$(VENV)/bin/pytest tests/ -v
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: inthub-cli
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: inthub CLI — manage your inthub.io resources from the terminal
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: google-cloud-secret-manager==2.21.0
|
|
7
|
+
Requires-Dist: httpx==0.27.2
|
|
8
|
+
Requires-Dist: pyodbc==5.2.0
|
|
9
|
+
Requires-Dist: python-ulid==2.7.0
|
|
10
|
+
Requires-Dist: rich==13.9.4
|
|
11
|
+
Requires-Dist: snowflake-connector-python==3.12.0
|
|
12
|
+
Requires-Dist: typer==0.15.4
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy==1.11.2; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-mock==3.14.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest==8.3.3; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff==0.6.9; extra == 'dev'
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# inthub CLI
|
|
2
|
+
|
|
3
|
+
Command-line interface for [inthub.io](https://console.inthub.io). Manage connectors and more from your terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Requires Python 3.11+.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install inthub-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
inthub login
|
|
17
|
+
inthub connectors list
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
### `inthub login`
|
|
23
|
+
|
|
24
|
+
Authenticates and stores your token in `~/.inthub/config.json`.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
inthub login
|
|
28
|
+
# Username: admin
|
|
29
|
+
# Password:
|
|
30
|
+
# Select company:
|
|
31
|
+
# [1] Acme Corp (acme)
|
|
32
|
+
# Company [1]: 1
|
|
33
|
+
# Logged in as admin @ Acme Corp
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### `inthub connectors list`
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
inthub connectors list # all connectors
|
|
40
|
+
inthub connectors list --tag finance # filter by tag
|
|
41
|
+
inthub connectors list --tag finance --tag ops # AND filter (all tags must match)
|
|
42
|
+
inthub connectors list --output json # raw JSON
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### `inthub connectors create`
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
inthub connectors create \
|
|
49
|
+
--name my-connector \
|
|
50
|
+
--kafka-connect-cluster kcc-prod \
|
|
51
|
+
--plugin debezium-postgres \
|
|
52
|
+
--configs '{"database.hostname":"localhost"}' \
|
|
53
|
+
--tasks-max 1 \
|
|
54
|
+
--description "optional description"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `inthub connectors clone <id>`
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
inthub connectors clone 01J...
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `inthub connectors delete <id>`
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
inthub connectors delete 01J...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### `inthub connectors publish <id>`
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
inthub connectors publish 01J... \
|
|
73
|
+
--branch main \
|
|
74
|
+
--commit-message "Deploy my connector" \
|
|
75
|
+
--author "Jane Doe"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`--branch` defaults to `main`. `--author` defaults to your system username.
|
|
79
|
+
|
|
80
|
+
## Output formats
|
|
81
|
+
|
|
82
|
+
Every command supports `--output json` (or `-o json`) for scripting:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
ID=$(inthub connectors create --name foo ... --output json | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
|
86
|
+
inthub connectors publish "$ID"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Environment variables
|
|
90
|
+
|
|
91
|
+
| Variable | Description |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `INTHUB_TOKEN` | Skip `inthub login` — use this token directly (useful in CI). |
|
|
94
|
+
| `INTHUB_BASE_URL` | Override the API base URL (default: `https://console.inthub.io/api`). Use for local development. |
|
|
95
|
+
|
|
96
|
+
## Local development
|
|
97
|
+
|
|
98
|
+
Clone the repo and install in editable mode:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cd apps/cli
|
|
102
|
+
python -m venv .venv
|
|
103
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
104
|
+
pip install -e ".[dev]"
|
|
105
|
+
inthub --help
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Point the CLI at a local backend:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
export INTHUB_BASE_URL=http://localhost:8080/api
|
|
112
|
+
inthub login
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Run tests and linting
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pytest tests/ -v
|
|
119
|
+
ruff check inthub/ tests/
|
|
120
|
+
mypy inthub/
|
|
121
|
+
```
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
BULK_DIR = Path.home() / ".inthub" / "bulk"
|
|
8
|
+
|
|
9
|
+
REQUIRED_COLUMNS = [
|
|
10
|
+
"sqlserver_instance",
|
|
11
|
+
"sqlserver_port",
|
|
12
|
+
"sqlserver_db",
|
|
13
|
+
"sqlserver_gcp_secret",
|
|
14
|
+
"connector_name",
|
|
15
|
+
"tables",
|
|
16
|
+
"snowflake_db",
|
|
17
|
+
"snowflake_gcp_secret",
|
|
18
|
+
"kcc_source",
|
|
19
|
+
"kcc_sink",
|
|
20
|
+
"snowflake_wh",
|
|
21
|
+
"snowflake_account",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_STATUS_COL = "status"
|
|
25
|
+
_REASON_COL = "reason"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _progress_path(ulid: str) -> Path:
|
|
29
|
+
return BULK_DIR / f"csv_{ulid}.csv"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _state_path(ulid: str) -> Path:
|
|
33
|
+
return BULK_DIR / f"state_{ulid}.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_columns(path: Path) -> None:
|
|
37
|
+
with path.open(newline="") as fh:
|
|
38
|
+
reader = csv.DictReader(fh)
|
|
39
|
+
actual = set(reader.fieldnames or [])
|
|
40
|
+
missing = set(REQUIRED_COLUMNS) - actual
|
|
41
|
+
if missing:
|
|
42
|
+
raise ValueError(f"CSV missing columns: {', '.join(sorted(missing))}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def init_progress_csv(source: Path, ulid: str) -> Path:
|
|
46
|
+
BULK_DIR.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
progress = _progress_path(ulid)
|
|
48
|
+
with source.open(newline="") as src_fh:
|
|
49
|
+
reader = csv.DictReader(src_fh)
|
|
50
|
+
fieldnames = list(reader.fieldnames or []) + [_STATUS_COL, _REASON_COL]
|
|
51
|
+
rows = []
|
|
52
|
+
for row in reader:
|
|
53
|
+
row[_STATUS_COL] = "pending"
|
|
54
|
+
row[_REASON_COL] = ""
|
|
55
|
+
rows.append(row)
|
|
56
|
+
|
|
57
|
+
with progress.open("w", newline="") as dst_fh:
|
|
58
|
+
writer = csv.DictWriter(dst_fh, fieldnames=fieldnames)
|
|
59
|
+
writer.writeheader()
|
|
60
|
+
writer.writerows(rows)
|
|
61
|
+
|
|
62
|
+
return progress
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_rows(progress_path: Path) -> list[dict[str, str]]:
|
|
66
|
+
with progress_path.open(newline="") as fh:
|
|
67
|
+
return [{k: str(v) for k, v in row.items()} for row in csv.DictReader(fh)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def update_row(
|
|
71
|
+
progress_path: Path,
|
|
72
|
+
connector_name: str,
|
|
73
|
+
status: str,
|
|
74
|
+
reason: str = "",
|
|
75
|
+
) -> None:
|
|
76
|
+
rows = load_rows(progress_path)
|
|
77
|
+
fieldnames = list(rows[0].keys()) if rows else []
|
|
78
|
+
for row in rows:
|
|
79
|
+
if row["connector_name"] == connector_name:
|
|
80
|
+
row[_STATUS_COL] = status
|
|
81
|
+
row[_REASON_COL] = reason
|
|
82
|
+
|
|
83
|
+
fd, tmp = tempfile.mkstemp(dir=progress_path.parent, suffix=".tmp")
|
|
84
|
+
try:
|
|
85
|
+
with os.fdopen(fd, "w", newline="") as fh:
|
|
86
|
+
writer = csv.DictWriter(fh, fieldnames=fieldnames)
|
|
87
|
+
writer.writeheader()
|
|
88
|
+
writer.writerows(rows)
|
|
89
|
+
os.replace(tmp, progress_path)
|
|
90
|
+
except Exception:
|
|
91
|
+
try:
|
|
92
|
+
os.unlink(tmp)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_state(
|
|
99
|
+
ulid: str,
|
|
100
|
+
gcp_project: str,
|
|
101
|
+
sqlserver_source_plugin: str,
|
|
102
|
+
snowflake_sink_plugin: str,
|
|
103
|
+
tags: str,
|
|
104
|
+
) -> None:
|
|
105
|
+
BULK_DIR.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
_state_path(ulid).write_text(json.dumps({
|
|
107
|
+
"gcp_project": gcp_project,
|
|
108
|
+
"sqlserver_source_plugin": sqlserver_source_plugin,
|
|
109
|
+
"snowflake_sink_plugin": snowflake_sink_plugin,
|
|
110
|
+
"tags": tags,
|
|
111
|
+
}, indent=2))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_state(ulid: str) -> dict[str, str]:
|
|
115
|
+
path = _state_path(ulid)
|
|
116
|
+
if not path.exists():
|
|
117
|
+
raise FileNotFoundError(f"State file not found: {path}")
|
|
118
|
+
return json.loads(path.read_text()) # type: ignore[no-any-return]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
_TYPE_MAP: dict[str, str] = {
|
|
4
|
+
"int": "NUMBER",
|
|
5
|
+
"bigint": "NUMBER",
|
|
6
|
+
"smallint": "NUMBER",
|
|
7
|
+
"tinyint": "NUMBER",
|
|
8
|
+
"float": "FLOAT",
|
|
9
|
+
"real": "FLOAT",
|
|
10
|
+
"text": "TEXT",
|
|
11
|
+
"ntext": "TEXT",
|
|
12
|
+
"datetime": "TIMESTAMP_NTZ",
|
|
13
|
+
"datetime2": "TIMESTAMP_NTZ",
|
|
14
|
+
"smalldatetime": "TIMESTAMP_NTZ",
|
|
15
|
+
"date": "DATE",
|
|
16
|
+
"time": "TIME",
|
|
17
|
+
"bit": "BOOLEAN",
|
|
18
|
+
"varbinary": "BINARY",
|
|
19
|
+
"binary": "BINARY",
|
|
20
|
+
"image": "BINARY",
|
|
21
|
+
"uniqueidentifier": "VARCHAR(36)",
|
|
22
|
+
"xml": "VARIANT",
|
|
23
|
+
"money": "NUMBER(19,4)",
|
|
24
|
+
"smallmoney": "NUMBER(19,4)",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_IH_COLUMNS = (
|
|
28
|
+
" IH_TOPIC VARCHAR(255) NOT NULL,\n"
|
|
29
|
+
" IH_PARTITION INT NOT NULL,\n"
|
|
30
|
+
" IH_OFFSET INT NOT NULL,\n"
|
|
31
|
+
" IH_OP VARCHAR(1) NOT NULL,\n"
|
|
32
|
+
" CONSTRAINT PKEY PRIMARY KEY (IH_TOPIC, IH_PARTITION, IH_OFFSET)"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def convert_type(
|
|
37
|
+
sql_server_type: str,
|
|
38
|
+
max_length: int | None,
|
|
39
|
+
precision: int | None,
|
|
40
|
+
scale: int | None,
|
|
41
|
+
) -> str:
|
|
42
|
+
base = sql_server_type.lower().split("(")[0].strip()
|
|
43
|
+
|
|
44
|
+
if base in ("decimal", "numeric"):
|
|
45
|
+
if precision is not None and scale is not None:
|
|
46
|
+
return f"NUMBER({precision},{scale})"
|
|
47
|
+
return "NUMBER"
|
|
48
|
+
|
|
49
|
+
if base in ("varchar", "nvarchar", "char", "nchar"):
|
|
50
|
+
if max_length and max_length > 0:
|
|
51
|
+
return f"VARCHAR({max_length})"
|
|
52
|
+
return "TEXT"
|
|
53
|
+
|
|
54
|
+
return _TYPE_MAP.get(base, "VARCHAR(255)")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def to_create_table_ddl(
|
|
58
|
+
database: str,
|
|
59
|
+
schema: str,
|
|
60
|
+
table: str,
|
|
61
|
+
columns: list[dict[str, Any]],
|
|
62
|
+
) -> str:
|
|
63
|
+
col_defs: list[str] = []
|
|
64
|
+
for col in columns:
|
|
65
|
+
name = col["COLUMN_NAME"]
|
|
66
|
+
sql_type = str(col.get("DATA_TYPE", "varchar"))
|
|
67
|
+
max_len_raw = col.get("CHARACTER_MAXIMUM_LENGTH")
|
|
68
|
+
precision_raw = col.get("NUMERIC_PRECISION")
|
|
69
|
+
scale_raw = col.get("NUMERIC_SCALE")
|
|
70
|
+
nullable = str(col.get("IS_NULLABLE", "YES")).upper() != "NO"
|
|
71
|
+
|
|
72
|
+
max_len = int(str(max_len_raw)) if max_len_raw not in (None, "", -1) else None
|
|
73
|
+
precision = int(str(precision_raw)) if precision_raw not in (None, "") else None
|
|
74
|
+
scale = int(str(scale_raw)) if scale_raw not in (None, "") else None
|
|
75
|
+
|
|
76
|
+
sf_type = convert_type(sql_type, max_len, precision, scale)
|
|
77
|
+
null_clause = "" if nullable else " NOT NULL"
|
|
78
|
+
col_defs.append(f' "{name}" {sf_type}{null_clause}')
|
|
79
|
+
|
|
80
|
+
col_block = ",\n".join(col_defs)
|
|
81
|
+
return (
|
|
82
|
+
f'CREATE TABLE IF NOT EXISTS "{database}"."{schema}"."{table}_INGEST" (\n'
|
|
83
|
+
f"{col_block},\n"
|
|
84
|
+
f"{_IH_COLUMNS}\n"
|
|
85
|
+
f");"
|
|
86
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from google.cloud import secretmanager
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fetch_secret(project_id: str, secret_name: str) -> dict[str, str]:
|
|
7
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
8
|
+
resource = f"projects/{project_id}/secrets/{secret_name}/versions/latest"
|
|
9
|
+
response = client.access_secret_version(request={"name": resource})
|
|
10
|
+
payload = response.payload.data.decode("UTF-8")
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
data: dict[str, str] = json.loads(payload)
|
|
14
|
+
except json.JSONDecodeError as exc:
|
|
15
|
+
raise ValueError(f"Secret '{secret_name}' is not valid JSON") from exc
|
|
16
|
+
|
|
17
|
+
missing = {"username", "password"} - set(data.keys())
|
|
18
|
+
if missing:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"Secret '{secret_name}' missing required fields: {', '.join(sorted(missing))}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return {"username": data["username"], "password": data["password"]}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def connect(account: str, user: str, password: str) -> Any:
|
|
5
|
+
import snowflake.connector
|
|
6
|
+
|
|
7
|
+
return snowflake.connector.connect(
|
|
8
|
+
account=account,
|
|
9
|
+
user=user,
|
|
10
|
+
password=password,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ingest_table_exists(conn: Any, database: str, schema: str, table: str) -> bool:
|
|
15
|
+
cursor = conn.cursor()
|
|
16
|
+
cursor.execute(
|
|
17
|
+
"SELECT 1 FROM INFORMATION_SCHEMA.TABLES "
|
|
18
|
+
"WHERE TABLE_CATALOG = %s AND TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
|
19
|
+
(database.upper(), schema.upper(), f"{table.upper()}_INGEST"),
|
|
20
|
+
)
|
|
21
|
+
return cursor.fetchone() is not None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_ingest_table(conn: Any, ddl: str) -> None:
|
|
25
|
+
conn.cursor().execute(ddl)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_view(
|
|
29
|
+
conn: Any,
|
|
30
|
+
database: str,
|
|
31
|
+
schema: str,
|
|
32
|
+
table: str,
|
|
33
|
+
columns: list[str],
|
|
34
|
+
) -> None:
|
|
35
|
+
col_list = ", ".join(f'"{c}"' for c in columns)
|
|
36
|
+
sql = (
|
|
37
|
+
f'CREATE OR REPLACE VIEW "{database}"."{schema}"."{table}" AS '
|
|
38
|
+
f'SELECT {col_list} FROM "{database}"."{schema}"."{table}_INGEST"'
|
|
39
|
+
)
|
|
40
|
+
conn.cursor().execute(sql)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_stage(conn: Any, database: str, schema: str, table: str) -> None:
|
|
44
|
+
sql = (
|
|
45
|
+
f'CREATE OR REPLACE STAGE "{database}"."{schema}"."{table}" '
|
|
46
|
+
"FILE_FORMAT = ("
|
|
47
|
+
"TYPE = 'CSV', "
|
|
48
|
+
"FIELD_OPTIONALLY_ENCLOSED_BY = '\"', "
|
|
49
|
+
"SKIP_HEADER = 0, "
|
|
50
|
+
"FIELD_DELIMITER = ',', "
|
|
51
|
+
"NULL_IF = ('\\\\N', 'NULL')"
|
|
52
|
+
")"
|
|
53
|
+
)
|
|
54
|
+
conn.cursor().execute(sql)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def row_count(conn: Any, database: str, schema: str, table: str) -> int:
|
|
58
|
+
cursor = conn.cursor()
|
|
59
|
+
cursor.execute(f'SELECT COUNT(*) FROM "{database}"."{schema}"."{table}"')
|
|
60
|
+
row = cursor.fetchone()
|
|
61
|
+
return int(row[0]) if row else 0
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def connect(host: str, port: int, db: str, user: str, password: str) -> Any:
|
|
5
|
+
import pyodbc
|
|
6
|
+
|
|
7
|
+
conn_str = (
|
|
8
|
+
"DRIVER={ODBC Driver 17 for SQL Server};"
|
|
9
|
+
f"SERVER={host},{port};"
|
|
10
|
+
f"DATABASE={db};"
|
|
11
|
+
f"UID={user};"
|
|
12
|
+
f"PWD={password}"
|
|
13
|
+
)
|
|
14
|
+
return pyodbc.connect(conn_str)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def table_exists(conn: Any, db: str, table: str) -> bool:
|
|
18
|
+
cursor = conn.cursor()
|
|
19
|
+
cursor.execute(
|
|
20
|
+
"SELECT 1 FROM INFORMATION_SCHEMA.TABLES "
|
|
21
|
+
"WHERE TABLE_CATALOG = ? AND TABLE_NAME = ?",
|
|
22
|
+
db,
|
|
23
|
+
table,
|
|
24
|
+
)
|
|
25
|
+
return cursor.fetchone() is not None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cdc_enabled(conn: Any, db: str, table: str) -> bool:
|
|
29
|
+
cursor = conn.cursor()
|
|
30
|
+
cursor.execute(f"USE [{db}]")
|
|
31
|
+
cursor.execute(
|
|
32
|
+
"SELECT is_tracked_by_cdc FROM sys.tables WHERE name = ?",
|
|
33
|
+
table,
|
|
34
|
+
)
|
|
35
|
+
row = cursor.fetchone()
|
|
36
|
+
return bool(row and row[0])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_columns(conn: Any, db: str, table: str) -> list[dict[str, Any]]:
|
|
40
|
+
cursor = conn.cursor()
|
|
41
|
+
cursor.execute(
|
|
42
|
+
"SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, "
|
|
43
|
+
"NUMERIC_PRECISION, NUMERIC_SCALE, IS_NULLABLE "
|
|
44
|
+
"FROM INFORMATION_SCHEMA.COLUMNS "
|
|
45
|
+
"WHERE TABLE_CATALOG = ? AND TABLE_NAME = ? "
|
|
46
|
+
"ORDER BY ORDINAL_POSITION",
|
|
47
|
+
db,
|
|
48
|
+
table,
|
|
49
|
+
)
|
|
50
|
+
cols = cursor.description
|
|
51
|
+
return [dict(zip([c[0] for c in cols], row)) for row in cursor.fetchall()]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from importlib.metadata import version as pkg_version
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from inthub.commands import connectors
|
|
7
|
+
from inthub.commands.auth import login
|
|
8
|
+
from inthub.commands.bulk import app as bulk_app
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="inthub CLI — manage your inthub.io resources from the terminal.",
|
|
11
|
+
add_completion=False)
|
|
12
|
+
|
|
13
|
+
app.command("login")(login)
|
|
14
|
+
app.add_typer(connectors.app, name="connectors")
|
|
15
|
+
app.add_typer(bulk_app, name="bulk")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("version")
|
|
19
|
+
def version() -> None:
|
|
20
|
+
"""Show the CLI version."""
|
|
21
|
+
typer.echo(f"inthub {pkg_version('inthub-cli')}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> None:
|
|
25
|
+
for key in [k for k in os.environ if k.endswith("_COMPLETE")]:
|
|
26
|
+
del os.environ[key]
|
|
27
|
+
app()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json as json_mod
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://console.inthub.io/api"
|
|
11
|
+
|
|
12
|
+
_err = Console(stderr=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_base_url() -> str:
|
|
16
|
+
return os.environ.get("INTHUB_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InthubClient:
|
|
20
|
+
def __init__(self, token: str, debug: bool = False) -> None:
|
|
21
|
+
self._token = token
|
|
22
|
+
self._base = get_base_url()
|
|
23
|
+
self._debug = debug
|
|
24
|
+
|
|
25
|
+
def _headers(self) -> dict[str, str]:
|
|
26
|
+
return {"Authorization": f"Bearer {self._token}"}
|
|
27
|
+
|
|
28
|
+
def _handle(self, response: httpx.Response) -> httpx.Response:
|
|
29
|
+
if self._debug:
|
|
30
|
+
self._print_debug(response)
|
|
31
|
+
if response.is_error:
|
|
32
|
+
try:
|
|
33
|
+
msg = response.json().get("error", response.text)
|
|
34
|
+
except Exception:
|
|
35
|
+
msg = response.text
|
|
36
|
+
_err.print(f"[bold red]Error {response.status_code}:[/] {msg}")
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
return response
|
|
39
|
+
|
|
40
|
+
def _print_debug(self, response: httpx.Response) -> None:
|
|
41
|
+
req = response.request
|
|
42
|
+
|
|
43
|
+
_err.rule("[bold cyan]REQUEST[/]")
|
|
44
|
+
_err.print(f"[bold]{req.method}[/] [cyan]{req.url}[/]")
|
|
45
|
+
_err.print()
|
|
46
|
+
for name, value in req.headers.items():
|
|
47
|
+
_err.print(f" [dim]{name}:[/] {value}")
|
|
48
|
+
if req.content:
|
|
49
|
+
_err.print()
|
|
50
|
+
try:
|
|
51
|
+
pretty = json_mod.dumps(json_mod.loads(req.content), indent=2)
|
|
52
|
+
_err.print(Syntax(pretty, "json", theme="monokai"))
|
|
53
|
+
except Exception:
|
|
54
|
+
_err.print(req.content.decode(errors="replace"))
|
|
55
|
+
|
|
56
|
+
_err.print()
|
|
57
|
+
status_color = "green" if response.status_code < 400 else "red"
|
|
58
|
+
_err.rule("[bold cyan]RESPONSE[/]")
|
|
59
|
+
_err.print(f"[bold {status_color}]{response.status_code}[/]")
|
|
60
|
+
_err.print()
|
|
61
|
+
for name, value in response.headers.items():
|
|
62
|
+
_err.print(f" [dim]{name}:[/] {value}")
|
|
63
|
+
if response.text:
|
|
64
|
+
_err.print()
|
|
65
|
+
try:
|
|
66
|
+
pretty = json_mod.dumps(json_mod.loads(response.text), indent=2)
|
|
67
|
+
_err.print(Syntax(pretty, "json", theme="monokai"))
|
|
68
|
+
except Exception:
|
|
69
|
+
_err.print(response.text)
|
|
70
|
+
_err.print()
|
|
71
|
+
|
|
72
|
+
def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
73
|
+
return self._handle(
|
|
74
|
+
httpx.get(f"{self._base}{path}", headers=self._headers(), **kwargs)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def post(self, path: str, json: dict[str, Any] | None = None, **kwargs: Any) -> httpx.Response:
|
|
78
|
+
return self._handle(
|
|
79
|
+
httpx.post(f"{self._base}{path}", json=json, headers=self._headers(), **kwargs)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def delete(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
83
|
+
return self._handle(
|
|
84
|
+
httpx.delete(f"{self._base}{path}", headers=self._headers(), **kwargs)
|
|
85
|
+
)
|
|
File without changes
|