inthub-cli 0.1.4__py3-none-any.whl
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/__init__.py +0 -0
- inthub/__main__.py +4 -0
- inthub/bulk/__init__.py +0 -0
- inthub/bulk/csv_state.py +118 -0
- inthub/bulk/ddl_converter.py +86 -0
- inthub/bulk/gcp_secrets.py +23 -0
- inthub/bulk/snowflake_client.py +61 -0
- inthub/bulk/sqlserver.py +51 -0
- inthub/cli.py +27 -0
- inthub/client.py +85 -0
- inthub/commands/__init__.py +0 -0
- inthub/commands/auth.py +57 -0
- inthub/commands/bulk/__init__.py +11 -0
- inthub/commands/bulk/manage.py +98 -0
- inthub/commands/bulk/progress.py +46 -0
- inthub/commands/bulk/src_sqlserver_sink_snowflake/__init__.py +123 -0
- inthub/commands/bulk/src_sqlserver_sink_snowflake/connectors.py +134 -0
- inthub/commands/bulk/src_sqlserver_sink_snowflake/orchestrator.py +326 -0
- inthub/commands/connectors.py +186 -0
- inthub/config.py +48 -0
- inthub_cli-0.1.4.dist-info/METADATA +17 -0
- inthub_cli-0.1.4.dist-info/RECORD +24 -0
- inthub_cli-0.1.4.dist-info/WHEEL +4 -0
- inthub_cli-0.1.4.dist-info/entry_points.txt +2 -0
inthub/__init__.py
ADDED
|
File without changes
|
inthub/__main__.py
ADDED
inthub/bulk/__init__.py
ADDED
|
File without changes
|
inthub/bulk/csv_state.py
ADDED
|
@@ -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
|
inthub/bulk/sqlserver.py
ADDED
|
@@ -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()]
|
inthub/cli.py
ADDED
|
@@ -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()
|
inthub/client.py
ADDED
|
@@ -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
|
inthub/commands/auth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
|
|
2
|
+
import httpx
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from inthub import config
|
|
7
|
+
from inthub.client import get_base_url
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
err = Console(stderr=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def login() -> None:
|
|
14
|
+
"""Authenticate with inthub and store credentials locally."""
|
|
15
|
+
base = get_base_url()
|
|
16
|
+
|
|
17
|
+
username = typer.prompt("Username")
|
|
18
|
+
password = typer.prompt("Password", hide_input=True)
|
|
19
|
+
|
|
20
|
+
r1 = httpx.post(f"{base}/auth/login", json={"username": username, "password": password})
|
|
21
|
+
if r1.is_error:
|
|
22
|
+
err.print(f"[bold red]Login failed ({r1.status_code}):[/] {r1.text}")
|
|
23
|
+
raise typer.Exit(1)
|
|
24
|
+
|
|
25
|
+
body1 = r1.json()
|
|
26
|
+
temp_token: str = body1["tempToken"]
|
|
27
|
+
companies: list[dict[str, str]] = body1["companies"]
|
|
28
|
+
|
|
29
|
+
if not companies:
|
|
30
|
+
err.print("[bold red]No companies found for this user.[/]")
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
console.print("Select company:")
|
|
34
|
+
for i, c in enumerate(companies, 1):
|
|
35
|
+
console.print(f" [{i}] {c['displayName']} ({c['slug']})")
|
|
36
|
+
|
|
37
|
+
choice = typer.prompt("Company", default="1")
|
|
38
|
+
try:
|
|
39
|
+
selected = companies[int(choice) - 1]
|
|
40
|
+
except (ValueError, IndexError):
|
|
41
|
+
err.print("[bold red]Invalid selection.[/]")
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
|
|
44
|
+
r2 = httpx.post(
|
|
45
|
+
f"{base}/auth/select-company",
|
|
46
|
+
json={"companyId": selected["id"]},
|
|
47
|
+
headers={"Authorization": f"Bearer {temp_token}"},
|
|
48
|
+
)
|
|
49
|
+
if r2.is_error:
|
|
50
|
+
err.print(f"[bold red]Company selection failed ({r2.status_code}):[/] {r2.text}")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
token: str = r2.json()["token"]
|
|
54
|
+
config.save(token=token, company_id=selected["id"], company_slug=selected["slug"])
|
|
55
|
+
console.print(
|
|
56
|
+
f"[green]Logged in as[/] [bold]{username}[/] @ [bold]{selected['displayName']}[/]"
|
|
57
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from inthub.commands.bulk import manage, progress
|
|
4
|
+
from inthub.commands.bulk.src_sqlserver_sink_snowflake import app as _src_sf_app
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="Bulk connector provisioning commands.", add_completion=False)
|
|
7
|
+
app.command("progress")(progress.progress)
|
|
8
|
+
app.command("list")(manage.list_bulks)
|
|
9
|
+
app.command("delete")(manage.delete_bulk)
|
|
10
|
+
app.command("cleanup")(manage.cleanup)
|
|
11
|
+
app.add_typer(_src_sf_app, name="src-sqlserver-sink-snowflake")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections import Counter
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from inthub.bulk.csv_state import BULK_DIR, _progress_path, _state_path, load_rows
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
err = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _state_files() -> list[tuple[str, dict[str, str]]]:
|
|
15
|
+
if not BULK_DIR.exists():
|
|
16
|
+
return []
|
|
17
|
+
results = []
|
|
18
|
+
for f in sorted(BULK_DIR.glob("state_*.json")):
|
|
19
|
+
ulid = f.stem.removeprefix("state_")
|
|
20
|
+
try:
|
|
21
|
+
state: dict[str, str] = json.loads(f.read_text())
|
|
22
|
+
except (json.JSONDecodeError, OSError):
|
|
23
|
+
continue
|
|
24
|
+
results.append((ulid, state))
|
|
25
|
+
return results
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _delete_bulk(ulid: str) -> bool:
|
|
29
|
+
csv_file = _progress_path(ulid)
|
|
30
|
+
state_file = _state_path(ulid)
|
|
31
|
+
found = False
|
|
32
|
+
for path in (csv_file, state_file):
|
|
33
|
+
if path.exists():
|
|
34
|
+
path.unlink()
|
|
35
|
+
found = True
|
|
36
|
+
return found
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def list_bulks() -> None:
|
|
40
|
+
"""List all bulk runs."""
|
|
41
|
+
entries = _state_files()
|
|
42
|
+
if not entries:
|
|
43
|
+
console.print("No bulk runs found.")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
table = Table(show_header=True, header_style="bold", show_lines=True, expand=True)
|
|
47
|
+
table.add_column("ID", overflow="fold")
|
|
48
|
+
table.add_column("GCP PROJECT", overflow="fold")
|
|
49
|
+
table.add_column("SOURCE PLUGIN", overflow="fold")
|
|
50
|
+
table.add_column("SINK PLUGIN", overflow="fold")
|
|
51
|
+
table.add_column("TAGS", overflow="fold")
|
|
52
|
+
table.add_column("DONE", justify="right")
|
|
53
|
+
table.add_column("ERROR", justify="right")
|
|
54
|
+
table.add_column("PENDING", justify="right")
|
|
55
|
+
|
|
56
|
+
for ulid, state in entries:
|
|
57
|
+
counts: Counter[str] = Counter()
|
|
58
|
+
csv_file = _progress_path(ulid)
|
|
59
|
+
if csv_file.exists():
|
|
60
|
+
try:
|
|
61
|
+
counts.update(r.get("status", "") for r in load_rows(csv_file))
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
table.add_row(
|
|
66
|
+
ulid,
|
|
67
|
+
state.get("gcp_project", ""),
|
|
68
|
+
state.get("sqlserver_source_plugin", ""),
|
|
69
|
+
state.get("snowflake_sink_plugin", ""),
|
|
70
|
+
state.get("tags", ""),
|
|
71
|
+
f"[green]{counts['done']}[/]" if counts["done"] else "0",
|
|
72
|
+
f"[red]{counts['error']}[/]" if counts["error"] else "0",
|
|
73
|
+
str(counts["pending"]),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
console.print(table)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def delete_bulk(ulid: str = typer.Argument(..., help="ULID of the bulk run to delete.")) -> None:
|
|
80
|
+
"""Delete a bulk run (CSV + state)."""
|
|
81
|
+
if _delete_bulk(ulid):
|
|
82
|
+
console.print(f"Deleted bulk run [bold]{ulid}[/].")
|
|
83
|
+
else:
|
|
84
|
+
err.print(f"[bold red]Not found:[/] no bulk run with ID '{ulid}'.")
|
|
85
|
+
raise typer.Exit(1)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cleanup() -> None:
|
|
89
|
+
"""Delete all bulk runs."""
|
|
90
|
+
entries = _state_files()
|
|
91
|
+
if not entries:
|
|
92
|
+
console.print("Nothing to clean up.")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
for ulid, _ in entries:
|
|
96
|
+
_delete_bulk(ulid)
|
|
97
|
+
|
|
98
|
+
console.print(f"Deleted {len(entries)} bulk run(s).")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
from inthub.bulk.csv_state import _progress_path, load_rows
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
_STATUS_STYLE = {
|
|
10
|
+
"pending": "dim",
|
|
11
|
+
"working": "yellow",
|
|
12
|
+
"error": "red",
|
|
13
|
+
"done": "green",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def progress(ulid: str = typer.Argument(..., help="ULID of the bulk run.")) -> None:
|
|
18
|
+
"""Show the current status of a bulk provisioning run."""
|
|
19
|
+
path = _progress_path(ulid)
|
|
20
|
+
if not path.exists():
|
|
21
|
+
console.print(f"[bold red]File not found:[/] {path}")
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
|
|
24
|
+
rows = load_rows(path)
|
|
25
|
+
if not rows:
|
|
26
|
+
console.print("No rows found.")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
table = Table(show_header=True, header_style="bold", show_lines=True, expand=True)
|
|
30
|
+
table.add_column("CONNECTOR", overflow="fold")
|
|
31
|
+
table.add_column("TABLES", overflow="fold")
|
|
32
|
+
table.add_column("STATUS", no_wrap=True)
|
|
33
|
+
table.add_column("REASON", overflow="fold")
|
|
34
|
+
|
|
35
|
+
for row in rows:
|
|
36
|
+
status = row.get("status", "")
|
|
37
|
+
style = _STATUS_STYLE.get(status, "")
|
|
38
|
+
status_cell = f"[{style}]{status}[/]" if style else status
|
|
39
|
+
table.add_row(
|
|
40
|
+
row.get("connector_name", ""),
|
|
41
|
+
row.get("tables", ""),
|
|
42
|
+
status_cell,
|
|
43
|
+
row.get("reason", ""),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
console.print(table)
|