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.
- honeyframeapi/__init__.py +63 -0
- honeyframeapi/_compat.py +20 -0
- honeyframeapi/_sql.py +143 -0
- honeyframeapi/agent.py +49 -0
- honeyframeapi/auth.py +141 -0
- honeyframeapi/cli.py +218 -0
- honeyframeapi/client.py +438 -0
- honeyframeapi/connector.py +52 -0
- honeyframeapi/dashboard.py +187 -0
- honeyframeapi/dataset.py +146 -0
- honeyframeapi/dbt.py +113 -0
- honeyframeapi/exceptions.py +66 -0
- honeyframeapi/pipeline.py +70 -0
- honeyframeapi/project.py +283 -0
- honeyframeapi/publishing.py +102 -0
- honeyframeapi/py.typed +0 -0
- honeyframeapi/recipe.py +110 -0
- honeyframeapi/scenario.py +65 -0
- honeyframeapi/webapp.py +110 -0
- honeyframeapi-0.1.0.dist-info/METADATA +272 -0
- honeyframeapi-0.1.0.dist-info/RECORD +24 -0
- honeyframeapi-0.1.0.dist-info/WHEEL +5 -0
- honeyframeapi-0.1.0.dist-info/entry_points.txt +2 -0
- honeyframeapi-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
honeyframeapi/_compat.py
ADDED
|
@@ -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())
|