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.
Files changed (32) hide show
  1. inthub_cli-0.1.4/.gitignore +9 -0
  2. inthub_cli-0.1.4/Makefile +42 -0
  3. inthub_cli-0.1.4/PKG-INFO +17 -0
  4. inthub_cli-0.1.4/README.md +121 -0
  5. inthub_cli-0.1.4/inthub/__init__.py +0 -0
  6. inthub_cli-0.1.4/inthub/__main__.py +4 -0
  7. inthub_cli-0.1.4/inthub/bulk/__init__.py +0 -0
  8. inthub_cli-0.1.4/inthub/bulk/csv_state.py +118 -0
  9. inthub_cli-0.1.4/inthub/bulk/ddl_converter.py +86 -0
  10. inthub_cli-0.1.4/inthub/bulk/gcp_secrets.py +23 -0
  11. inthub_cli-0.1.4/inthub/bulk/snowflake_client.py +61 -0
  12. inthub_cli-0.1.4/inthub/bulk/sqlserver.py +51 -0
  13. inthub_cli-0.1.4/inthub/cli.py +27 -0
  14. inthub_cli-0.1.4/inthub/client.py +85 -0
  15. inthub_cli-0.1.4/inthub/commands/__init__.py +0 -0
  16. inthub_cli-0.1.4/inthub/commands/auth.py +57 -0
  17. inthub_cli-0.1.4/inthub/commands/bulk/__init__.py +11 -0
  18. inthub_cli-0.1.4/inthub/commands/bulk/manage.py +98 -0
  19. inthub_cli-0.1.4/inthub/commands/bulk/progress.py +46 -0
  20. inthub_cli-0.1.4/inthub/commands/bulk/src_sqlserver_sink_snowflake/__init__.py +123 -0
  21. inthub_cli-0.1.4/inthub/commands/bulk/src_sqlserver_sink_snowflake/connectors.py +134 -0
  22. inthub_cli-0.1.4/inthub/commands/bulk/src_sqlserver_sink_snowflake/orchestrator.py +326 -0
  23. inthub_cli-0.1.4/inthub/commands/connectors.py +186 -0
  24. inthub_cli-0.1.4/inthub/config.py +48 -0
  25. inthub_cli-0.1.4/plan.md +420 -0
  26. inthub_cli-0.1.4/plan_bulk.md +499 -0
  27. inthub_cli-0.1.4/pyproject.toml +47 -0
  28. inthub_cli-0.1.4/tests/__init__.py +0 -0
  29. inthub_cli-0.1.4/tests/bulk/__init__.py +0 -0
  30. inthub_cli-0.1.4/tests/bulk/test_csv_state.py +151 -0
  31. inthub_cli-0.1.4/tests/bulk/test_ddl_converter.py +100 -0
  32. inthub_cli-0.1.4/tests/test_config.py +76 -0
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .pytest_cache/
@@ -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
@@ -0,0 +1,4 @@
1
+ from inthub.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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