honeyframeapi 0.1.0__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.
@@ -0,0 +1,63 @@
1
+ """honeyframeapi — a ``dataikuapi``-style Python client for the Honeyframe platform.
2
+
3
+ import honeyframeapi
4
+ client = honeyframeapi.HoneyframeClient(
5
+ "https://platform.hubstudio.id",
6
+ username="you@hubstudio.id", password="…", project_id=1)
7
+
8
+ df = client.sql("SELECT * FROM marts.fact_appointment FETCH FIRST 100 ROWS ONLY")
9
+ proj = client.get_project(1)
10
+ df2 = proj.get_dataset("fact_appointment").get_dataframe()
11
+
12
+ dash = proj.create_dashboard("Ops Overview")
13
+ dash.add_card("Total Visits", "kpi", "SELECT COUNT(*) AS n FROM marts.fact_appointment")
14
+ dash.execute_all()
15
+ """
16
+ from .client import HoneyframeClient
17
+ from .project import Project
18
+ from .dataset import Dataset
19
+ from .dashboard import Dashboard, Card
20
+ from .connector import Connector
21
+ from .scenario import Scenario
22
+ from .recipe import Recipe
23
+ from .pipeline import JobsAPI
24
+ from .publishing import PublishedAsset, ApiKey
25
+ from .webapp import Webapp
26
+ from .agent import Agent
27
+ from .auth import AuthBase, LoginAuth, TokenAuth
28
+ from .exceptions import (
29
+ HoneyframeError,
30
+ HoneyframeConfigError,
31
+ HoneyframeAPIError,
32
+ HoneyframeAuthError,
33
+ HoneyframeNotFoundError,
34
+ HoneyframeValidationError,
35
+ )
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ "HoneyframeClient",
41
+ "Project",
42
+ "Dataset",
43
+ "Dashboard",
44
+ "Card",
45
+ "Connector",
46
+ "Scenario",
47
+ "Recipe",
48
+ "JobsAPI",
49
+ "PublishedAsset",
50
+ "ApiKey",
51
+ "Webapp",
52
+ "Agent",
53
+ "AuthBase",
54
+ "LoginAuth",
55
+ "TokenAuth",
56
+ "HoneyframeError",
57
+ "HoneyframeConfigError",
58
+ "HoneyframeAPIError",
59
+ "HoneyframeAuthError",
60
+ "HoneyframeNotFoundError",
61
+ "HoneyframeValidationError",
62
+ "__version__",
63
+ ]
@@ -0,0 +1,20 @@
1
+ """Optional-pandas plumbing.
2
+
3
+ The core SDK has no hard pandas dependency. Helpers here return a DataFrame
4
+ when pandas is importable, and otherwise fall back to a plain dict so scripts
5
+ that only need raw rows still work on a bare install.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ try:
10
+ import pandas as _pd # noqa: F401
11
+ HAS_PANDAS = True
12
+ except Exception: # pragma: no cover - exercised only on bare installs
13
+ HAS_PANDAS = False
14
+
15
+
16
+ def rows_to_dataframe(columns, rows):
17
+ """Build a DataFrame from columns + positional rows, or a dict fallback."""
18
+ if HAS_PANDAS:
19
+ return _pd.DataFrame(rows, columns=columns)
20
+ return {"columns": columns, "rows": rows}
honeyframeapi/_sql.py ADDED
@@ -0,0 +1,143 @@
1
+ """Ad-hoc SQL execution → DataFrame.
2
+
3
+ Two execution paths, tried in order:
4
+
5
+ 1. **Native** ``POST /api/sql`` — the first-class governed query endpoint
6
+ (services/query_executor). This is the preferred path: one request, full
7
+ result set, project-scoped, SELECT-guarded.
8
+ 2. **Scratch-card fallback** — on deployments that predate ``/api/sql`` (the
9
+ endpoint 404s), fall back to executing the SQL through a hidden per-project
10
+ scratch dashboard card. Same result shape, zero backend dependency, so the
11
+ SDK keeps working against older servers.
12
+
13
+ Both return a pandas DataFrame (or a ``{columns, rows}`` dict on a bare,
14
+ pandas-less install).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+ from ._compat import rows_to_dataframe
21
+ from .exceptions import HoneyframeAPIError, HoneyframeAuthError, HoneyframeNotFoundError
22
+
23
+ SCRATCH_TITLE = "__honeyframe_sdk_scratch__"
24
+
25
+
26
+ def sql_to_dataframe(project, query: str, *, connector_id: Optional[int] = None,
27
+ row_limit: int = 10000):
28
+ """Execute ``query`` and return a DataFrame, preferring POST /api/sql."""
29
+ try:
30
+ res = project._req("POST", "/api/sql", json={
31
+ "sql": query,
32
+ "connector_id": connector_id,
33
+ "row_limit": row_limit,
34
+ })
35
+ except HoneyframeNotFoundError:
36
+ # Endpoint not on this deployment yet — use the scratch-card path.
37
+ return _scratch_card_sql(project, query, connector_id=connector_id, row_limit=row_limit)
38
+
39
+ if res.get("error"):
40
+ raise HoneyframeAPIError(422, res["error"], method="POST", url="/api/sql")
41
+ columns = res.get("columns", [])
42
+ dict_rows = res.get("rows", [])
43
+ rows = [[r.get(c) for c in columns] for r in dict_rows]
44
+ return rows_to_dataframe(columns, rows)
45
+
46
+
47
+ # ── scratch-card fallback ─────────────────────────────────────────────────────
48
+ def _ensure_scratch_dashboard(project) -> tuple[int, bool]:
49
+ """Return ``(dashboard_id, created_now)`` for this project's scratch dashboard.
50
+
51
+ ``created_now`` is True only when this call had to POST a new dashboard, so
52
+ the caller knows whether it owns the dashboard and must clean it up if the
53
+ query then fails (otherwise a failed query would leave an orphan scratch
54
+ dashboard behind — the leak this hardening fixes)."""
55
+ cached = getattr(project, "_scratch_dashboard_id", None)
56
+ if cached is not None:
57
+ return cached, False
58
+
59
+ for d in project.list_dashboards():
60
+ if d.get("title") == SCRATCH_TITLE:
61
+ project._scratch_dashboard_id = d["dashboard_id"]
62
+ return project._scratch_dashboard_id, False
63
+
64
+ res = project._req("POST", "/api/dashboards", json={
65
+ "title": SCRATCH_TITLE,
66
+ "description": "Auto-created by honeyframeapi for ad-hoc SQL. Safe to ignore.",
67
+ "is_public": False,
68
+ })
69
+ project._scratch_dashboard_id = res["dashboard_id"]
70
+ return project._scratch_dashboard_id, True
71
+
72
+
73
+ def _scratch_card_sql(project, query: str, *, connector_id: Optional[int] = None,
74
+ row_limit: int = 10000):
75
+ """Execute ``query`` via a temporary scratch dashboard card.
76
+
77
+ Cleanup is total: the temp card is always deleted, and if THIS call created
78
+ the scratch dashboard, that dashboard is deleted too on any failure (so an
79
+ under-privileged caller whose query 500s doesn't leave an orphan behind).
80
+ Auth failures surface as :class:`HoneyframeAuthError` with a hint that
81
+ ad-hoc SQL needs ``project.edit``."""
82
+ dash_id, created_dash = _ensure_scratch_dashboard(project)
83
+ card_id = None
84
+
85
+ def _cleanup():
86
+ if card_id is not None:
87
+ try:
88
+ project._req("DELETE", f"/api/dashboards/{dash_id}/cards/{card_id}")
89
+ except Exception:
90
+ pass
91
+
92
+ def _drop_owned_dashboard():
93
+ # Only delete the dashboard if we created it this call — never nuke a
94
+ # pre-existing/shared scratch dashboard out from under a concurrent caller.
95
+ if created_dash:
96
+ try:
97
+ project._req("DELETE", f"/api/dashboards/{dash_id}")
98
+ except Exception:
99
+ pass
100
+ project._scratch_dashboard_id = None
101
+
102
+ try:
103
+ card_config = {}
104
+ if connector_id is not None:
105
+ card_config["connector_id"] = connector_id
106
+
107
+ card = project._req("POST", f"/api/dashboards/{dash_id}/cards", json={
108
+ "title": "sdk_query",
109
+ "card_type": "table",
110
+ "sql_query": query,
111
+ "card_config": card_config,
112
+ "size_x": 24,
113
+ "size_y": 6,
114
+ })
115
+ card_id = card.get("card_id")
116
+
117
+ res = project._req(
118
+ "POST", f"/api/dashboards/{dash_id}/cards/{card_id}/execute", json={},
119
+ )
120
+ if res.get("error"):
121
+ raise HoneyframeAPIError(422, res["error"], method="POST",
122
+ url=f"/api/dashboards/{dash_id}/cards/{card_id}/execute")
123
+ columns = res.get("columns", [])
124
+ dict_rows = res.get("rows", [])
125
+ rows = [[r.get(c) for c in columns] for r in dict_rows]
126
+ if row_limit is not None:
127
+ rows = rows[:row_limit]
128
+ result = rows_to_dataframe(columns, rows)
129
+ _cleanup()
130
+ return result
131
+ except HoneyframeAuthError as e:
132
+ _cleanup()
133
+ _drop_owned_dashboard()
134
+ # Re-raise with a clearer hint than a bare 403.
135
+ raise HoneyframeAuthError(
136
+ e.status_code,
137
+ f"{e.detail} — ad-hoc SQL needs project.edit on this project",
138
+ method=e.method, url=e.url, response=e.response,
139
+ ) from None
140
+ except Exception:
141
+ _cleanup()
142
+ _drop_owned_dashboard()
143
+ raise
honeyframeapi/agent.py ADDED
@@ -0,0 +1,49 @@
1
+ """Agent handle — chat with / run a Honeyframe agent from code.
2
+
3
+ agent = client.get_agent(agent_id=42)
4
+ reply = agent.ask("How many appointments were cancelled last month?")
5
+ print(reply["answer"])
6
+
7
+ # multi-turn: reuse the returned session_id
8
+ r1 = agent.ask("Top 5 hospitals by volume?")
9
+ r2 = agent.ask("…and their cancellation rate?", session_id=r1["session_id"])
10
+
11
+ The platform exposes the agent library at ``GET /api/agents/library`` and
12
+ invokes agents through ``POST /api/chat`` (JWT auth), where ``agent_id`` selects
13
+ a published agent's tools/config.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+
20
+ class Agent:
21
+ def __init__(self, client, agent_id: int, *, project=None, _data: Optional[dict] = None):
22
+ self.client = client
23
+ self.agent_id = agent_id
24
+ self.project = project
25
+ self._data = _data or {}
26
+
27
+ def __repr__(self) -> str:
28
+ return f"<Agent id={self.agent_id} {self._data.get('title')!r}>"
29
+
30
+ @property
31
+ def title(self) -> str:
32
+ return self._data["title"]
33
+
34
+ def _req(self, method, path, **kw):
35
+ if self.project is not None:
36
+ kw.setdefault("project_id", self.project.project_id)
37
+ return self.client._request(method, path, **kw)
38
+
39
+ def ask(self, message: str, *, session_id: Optional[int] = None,
40
+ model: Optional[str] = None) -> dict:
41
+ """Send a message to this agent. Returns the chat response dict
42
+ (``answer``, ``session_id``, ``sql``, ``rows``, ``follow_ups``, …).
43
+ Pass ``session_id`` from a prior reply to continue the conversation."""
44
+ body = {"message": message, "agent_id": self.agent_id}
45
+ if session_id is not None:
46
+ body["session_id"] = session_id
47
+ if model is not None:
48
+ body["model"] = model
49
+ return self._req("POST", "/api/chat", json=body)
honeyframeapi/auth.py ADDED
@@ -0,0 +1,141 @@
1
+ """Authentication strategies for the Honeyframe SDK.
2
+
3
+ Two strategies ship today, both injected into :class:`~honeyframeapi.client.HoneyframeClient`:
4
+
5
+ * :class:`LoginAuth` — email/password. Exchanges credentials for a short-lived
6
+ (8h) JWT via ``POST /api/auth/login`` and transparently re-logs-in when the
7
+ token is rejected with a 401. This is the path that works against any
8
+ Honeyframe deployment **today**, with zero backend changes.
9
+ * :class:`TokenAuth` — a pre-minted bearer token (a JWT you already have, or a
10
+ future long-lived control-plane Personal Access Token). No auto-refresh: a
11
+ 401 surfaces to the caller.
12
+
13
+ The client calls :meth:`AuthBase.token` before every request to obtain the
14
+ current bearer string, and :meth:`AuthBase.refresh` once on a 401 before
15
+ retrying. An auth strategy that cannot refresh returns ``False`` so the client
16
+ stops retrying and raises.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from typing import Optional
22
+
23
+ import httpx
24
+
25
+ from .exceptions import HoneyframeAuthError, HoneyframeConfigError
26
+
27
+
28
+ class AuthBase:
29
+ """Interface every auth strategy implements."""
30
+
31
+ def token(self, http: httpx.Client) -> str:
32
+ """Return the current bearer token, acquiring one if needed."""
33
+ raise NotImplementedError
34
+
35
+ def refresh(self, http: httpx.Client) -> bool:
36
+ """Force-acquire a new token after a 401.
37
+
38
+ Returns ``True`` if a fresh token was obtained (caller should retry),
39
+ ``False`` if this strategy cannot refresh (caller should raise).
40
+ """
41
+ return False
42
+
43
+
44
+ class TokenAuth(AuthBase):
45
+ """Authenticate with a token you already hold (JWT or future PAT)."""
46
+
47
+ def __init__(self, token: str):
48
+ if not token:
49
+ raise HoneyframeConfigError("TokenAuth requires a non-empty token")
50
+ self._token = token
51
+
52
+ def token(self, http: httpx.Client) -> str:
53
+ return self._token
54
+
55
+ # No refresh — a static token that 401s is a hard error.
56
+
57
+
58
+ class LoginAuth(AuthBase):
59
+ """Authenticate with email + password, caching the JWT and re-logging-in on 401."""
60
+
61
+ def __init__(self, username: str, password: str, *, login_path: str = "/api/auth/login"):
62
+ if not username or not password:
63
+ raise HoneyframeConfigError("LoginAuth requires username and password")
64
+ self.username = username
65
+ self.password = password
66
+ self.login_path = login_path
67
+ self._token: Optional[str] = None
68
+ self.must_reset_password: bool = False
69
+
70
+ def token(self, http: httpx.Client) -> str:
71
+ if self._token is None:
72
+ self._login(http)
73
+ return self._token # type: ignore[return-value]
74
+
75
+ def refresh(self, http: httpx.Client) -> bool:
76
+ self._token = None
77
+ self._login(http)
78
+ return True
79
+
80
+ def _login(self, http: httpx.Client) -> None:
81
+ resp = http.post(self.login_path, json={
82
+ "username": self.username,
83
+ "password": self.password,
84
+ })
85
+ if resp.status_code != 200:
86
+ detail = _safe_detail(resp)
87
+ raise HoneyframeAuthError(
88
+ resp.status_code, detail,
89
+ method="POST", url=str(resp.request.url), response=resp,
90
+ )
91
+ body = resp.json()
92
+ self._token = body.get("access_token")
93
+ self.must_reset_password = bool(body.get("must_reset_password", False))
94
+ if not self._token:
95
+ raise HoneyframeAuthError(
96
+ resp.status_code, "login succeeded but no access_token in response",
97
+ method="POST", url=str(resp.request.url), response=resp,
98
+ )
99
+
100
+
101
+ def auth_from_kwargs(
102
+ *,
103
+ token: Optional[str] = None,
104
+ username: Optional[str] = None,
105
+ password: Optional[str] = None,
106
+ auth: Optional[AuthBase] = None,
107
+ ) -> AuthBase:
108
+ """Resolve the auth strategy from the assorted client kwargs.
109
+
110
+ Precedence: explicit ``auth`` object > ``token`` > ``username``/``password``
111
+ > environment variables (``HONEYFRAME_TOKEN`` then
112
+ ``HONEYFRAME_USERNAME``/``HONEYFRAME_PASSWORD``, with ``HUB_*`` fallbacks
113
+ for continuity with existing dwh-adira .env files).
114
+ """
115
+ if auth is not None:
116
+ return auth
117
+ if token:
118
+ return TokenAuth(token)
119
+ if username and password:
120
+ return LoginAuth(username, password)
121
+
122
+ env_token = os.environ.get("HONEYFRAME_TOKEN")
123
+ if env_token:
124
+ return TokenAuth(env_token)
125
+
126
+ env_user = os.environ.get("HONEYFRAME_USERNAME") or os.environ.get("HUB_USERNAME")
127
+ env_pass = os.environ.get("HONEYFRAME_PASSWORD") or os.environ.get("HUB_PASSWORD")
128
+ if env_user and env_pass:
129
+ return LoginAuth(env_user, env_pass)
130
+
131
+ raise HoneyframeConfigError(
132
+ "No credentials. Pass token=..., username=/password=, or an auth= "
133
+ "strategy, or set HONEYFRAME_TOKEN / HONEYFRAME_USERNAME+PASSWORD."
134
+ )
135
+
136
+
137
+ def _safe_detail(resp: httpx.Response):
138
+ try:
139
+ return resp.json().get("detail", resp.text)
140
+ except Exception:
141
+ return resp.text
honeyframeapi/cli.py ADDED
@@ -0,0 +1,218 @@
1
+ """``honeyframe`` — a thin CLI over the SDK for shell use.
2
+
3
+ Reads connection + credentials from the environment (same vars as
4
+ ``HoneyframeClient.from_env``): ``HONEYFRAME_URL`` (or ``HUB_API_BASE``),
5
+ ``HONEYFRAME_TOKEN`` (a JWT or ``hf_…`` PAT), or
6
+ ``HONEYFRAME_USERNAME``/``HONEYFRAME_PASSWORD``, plus optional
7
+ ``HONEYFRAME_ORG_ID`` / ``HONEYFRAME_PROJECT_ID``. Flags override env.
8
+
9
+ Examples
10
+ --------
11
+ export HONEYFRAME_URL=https://platform.hubstudio.id
12
+ export HONEYFRAME_TOKEN=hf_…
13
+ honeyframe whoami
14
+ honeyframe projects
15
+ honeyframe datasets -p 1
16
+ honeyframe sql "SELECT COUNT(*) FROM marts.fact_appointment" -p 1 -f csv
17
+ honeyframe pat create --name laptop --expires-days 90
18
+ honeyframe scenario run daily_pipeline -p 1 --wait
19
+
20
+ Flags go after the subcommand (kubectl/gh style): `honeyframe <cmd> [flags]`.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import csv
26
+ import json
27
+ import os
28
+ import sys
29
+
30
+ from . import HoneyframeClient, __version__
31
+ from .exceptions import HoneyframeError
32
+
33
+
34
+ def _client(args) -> HoneyframeClient:
35
+ kw = {}
36
+ if args.url:
37
+ kw["base_url"] = args.url
38
+ if args.token:
39
+ kw["token"] = args.token
40
+ if args.org is not None:
41
+ kw["org_id"] = args.org
42
+ if args.project is not None:
43
+ kw["project_id"] = args.project
44
+ return HoneyframeClient.from_env(**kw)
45
+
46
+
47
+ def _emit(obj, fmt: str) -> None:
48
+ """Print a result as json (default) or csv (for row lists / DataFrames)."""
49
+ if fmt == "json":
50
+ print(json.dumps(obj, indent=2, default=str))
51
+ return
52
+ # csv: accept a DataFrame, a {columns,rows} dict, or a list of dicts
53
+ rows, cols = None, None
54
+ if hasattr(obj, "to_csv"): # pandas DataFrame
55
+ sys.stdout.write(obj.to_csv(index=False))
56
+ return
57
+ if isinstance(obj, dict) and "columns" in obj and "rows" in obj:
58
+ cols, rows = obj["columns"], obj["rows"]
59
+ w = csv.writer(sys.stdout)
60
+ w.writerow(cols)
61
+ w.writerows(rows)
62
+ return
63
+ if isinstance(obj, list) and obj and isinstance(obj[0], dict):
64
+ cols = list(obj[0].keys())
65
+ dw = csv.DictWriter(sys.stdout, fieldnames=cols)
66
+ dw.writeheader()
67
+ dw.writerows(obj)
68
+ return
69
+ print(json.dumps(obj, indent=2, default=str))
70
+
71
+
72
+ # ── command handlers ─────────────────────────────────────────────────────────
73
+ def cmd_whoami(c, args):
74
+ return c.get_auth_info()
75
+
76
+
77
+ def cmd_version(c, args):
78
+ return c.get_instance_info()
79
+
80
+
81
+ def cmd_projects(c, args):
82
+ return c.list_projects()
83
+
84
+
85
+ def cmd_datasets(c, args):
86
+ return c.list_datasets()
87
+
88
+
89
+ def cmd_sql(c, args):
90
+ df = c.sql(args.query)
91
+ return df
92
+
93
+
94
+ def cmd_pat_create(c, args):
95
+ return c.create_pat(args.name or "", expires_days=args.expires_days)
96
+
97
+
98
+ def cmd_pat_list(c, args):
99
+ return c.list_pats()
100
+
101
+
102
+ def cmd_pat_revoke(c, args):
103
+ return c.revoke_pat(args.id)
104
+
105
+
106
+ def cmd_scenario_list(c, args):
107
+ return c.get_project(c.project_id).list_scenarios()
108
+
109
+
110
+ def cmd_scenario_run(c, args):
111
+ sc = c.get_project(c.project_id).get_scenario(args.name)
112
+ return sc.run_and_wait() if args.wait else sc.run()
113
+
114
+
115
+ def cmd_engine(c, args):
116
+ proj = c.get_project(c.project_id)
117
+ return {"engine": proj.engine(), "uses_dbt": proj.uses_dbt()}
118
+
119
+
120
+ def cmd_lineage(c, args):
121
+ ds = c.get_project(c.project_id).get_dataset(args.dataset)
122
+ return ds.downstream() if args.down else ds.upstream()
123
+
124
+
125
+ def cmd_upload(c, args):
126
+ ds = c.get_project(c.project_id).upload_file(
127
+ args.name, args.file, replace=args.replace, materialize=args.materialize,
128
+ )
129
+ return {"uploaded": ds.name, "materialized": args.materialize}
130
+
131
+
132
+ def cmd_export(c, args):
133
+ ds = c.get_project(c.project_id).get_dataset(args.dataset)
134
+ path = str(args.path)
135
+ out = (ds.to_parquet(path, max_rows=args.max_rows)
136
+ if path.lower().endswith(".parquet")
137
+ else ds.to_csv(path, max_rows=args.max_rows))
138
+ return {"exported": out}
139
+
140
+
141
+ def build_parser() -> argparse.ArgumentParser:
142
+ # Connection + output flags live on a shared parent attached to every
143
+ # (leaf) subparser, so they go AFTER the subcommand — the kubectl/gh/docker
144
+ # convention (`honeyframe projects -f csv`, `honeyframe sql "…" -p 1`).
145
+ common = argparse.ArgumentParser(add_help=False)
146
+ common.add_argument("--url", default=None, help="base URL (or $HONEYFRAME_URL)")
147
+ common.add_argument("--token", default=None, help="JWT or hf_ PAT (or $HONEYFRAME_TOKEN)")
148
+ common.add_argument("-o", "--org", type=int, default=None, help="org id (X-Org-Id)")
149
+ common.add_argument("-p", "--project", type=int, default=None, help="project id (X-Project-Id)")
150
+ common.add_argument("-f", "--format", choices=["json", "csv"], default="json", help="output format")
151
+
152
+ p = argparse.ArgumentParser(prog="honeyframe", description="Honeyframe platform CLI")
153
+ p.add_argument("--version", action="version", version=f"honeyframeapi {__version__}")
154
+ sub = p.add_subparsers(dest="cmd", required=True)
155
+
156
+ def add(name, fn, **kw):
157
+ sp = sub.add_parser(name, parents=[common], **kw)
158
+ sp.set_defaults(fn=fn)
159
+ return sp
160
+
161
+ add("whoami", cmd_whoami, help="current user")
162
+ add("version", cmd_version, help="platform version")
163
+ add("projects", cmd_projects, help="list projects")
164
+ add("datasets", cmd_datasets, help="list datasets in --project")
165
+ add("sql", cmd_sql, help="run ad-hoc SQL in --project").add_argument("query")
166
+ add("engine", cmd_engine, help="show the catalog engine (dbt|native) for --project")
167
+
168
+ lin = add("lineage", cmd_lineage, help="upstream (or --down) lineage of a dataset")
169
+ lin.add_argument("dataset")
170
+ lin.add_argument("--down", action="store_true", help="downstream dependents instead of upstream")
171
+
172
+ up = add("upload", cmd_upload, help="upload a local .csv/.xlsx as a dataset in --project")
173
+ up.add_argument("name")
174
+ up.add_argument("file")
175
+ up.add_argument("--replace", action="store_true", help="overwrite if it already exists")
176
+ up.add_argument("--materialize", action="store_true", help="materialize after upload")
177
+
178
+ exp = add("export", cmd_export, help="export a dataset to a local .csv/.parquet file")
179
+ exp.add_argument("dataset")
180
+ exp.add_argument("path")
181
+ exp.add_argument("--max-rows", type=int, default=None, help="cap the number of rows pulled")
182
+
183
+ pat = sub.add_parser("pat", help="personal access tokens").add_subparsers(dest="pat_cmd", required=True)
184
+ pc = pat.add_parser("create", parents=[common], help="mint a token")
185
+ pc.add_argument("--name", default="")
186
+ pc.add_argument("--expires-days", type=int, default=None)
187
+ pc.set_defaults(fn=cmd_pat_create)
188
+ pat.add_parser("list", parents=[common], help="list tokens").set_defaults(fn=cmd_pat_list)
189
+ pr = pat.add_parser("revoke", parents=[common], help="revoke a token")
190
+ pr.add_argument("id", type=int)
191
+ pr.set_defaults(fn=cmd_pat_revoke)
192
+
193
+ scn = sub.add_parser("scenario", help="scenarios").add_subparsers(dest="scn_cmd", required=True)
194
+ scn.add_parser("list", parents=[common], help="list scenarios in --project").set_defaults(fn=cmd_scenario_list)
195
+ sr = scn.add_parser("run", parents=[common], help="run a scenario by id/name")
196
+ sr.add_argument("name")
197
+ sr.add_argument("--wait", action="store_true", help="block until the run finishes")
198
+ sr.set_defaults(fn=cmd_scenario_run)
199
+
200
+ return p
201
+
202
+
203
+ def main(argv=None) -> int:
204
+ args = build_parser().parse_args(argv)
205
+ try:
206
+ c = _client(args)
207
+ result = args.fn(c, args)
208
+ _emit(result, args.format)
209
+ return 0
210
+ except HoneyframeError as e:
211
+ print(f"error: {e}", file=sys.stderr)
212
+ return 1
213
+ except KeyboardInterrupt:
214
+ return 130
215
+
216
+
217
+ if __name__ == "__main__":
218
+ sys.exit(main())