huly-cli 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.
- huly_cli/__init__.py +1 -0
- huly_cli/auth.py +174 -0
- huly_cli/client.py +319 -0
- huly_cli/commands/__init__.py +0 -0
- huly_cli/commands/auth_cmd.py +94 -0
- huly_cli/commands/components.py +369 -0
- huly_cli/commands/documents.py +486 -0
- huly_cli/commands/issues.py +740 -0
- huly_cli/commands/labels.py +65 -0
- huly_cli/commands/members.py +68 -0
- huly_cli/commands/milestones.py +379 -0
- huly_cli/commands/projects.py +115 -0
- huly_cli/commands/templates.py +342 -0
- huly_cli/config.py +124 -0
- huly_cli/errors.py +70 -0
- huly_cli/issue_utils.py +215 -0
- huly_cli/main.py +75 -0
- huly_cli/markup.py +407 -0
- huly_cli/models.py +244 -0
- huly_cli/output.py +85 -0
- huly_cli-0.1.0.dist-info/METADATA +345 -0
- huly_cli-0.1.0.dist-info/RECORD +25 -0
- huly_cli-0.1.0.dist-info/WHEEL +4 -0
- huly_cli-0.1.0.dist-info/entry_points.txt +2 -0
- huly_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
huly_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
huly_cli/auth.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Authentication logic for Huly CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from huly_cli.config import AuthCache, HulyConfig, load_auth, save_auth
|
|
10
|
+
from huly_cli.errors import AuthError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def login(config: HulyConfig) -> AuthCache:
|
|
14
|
+
"""Perform full auth flow and cache tokens.
|
|
15
|
+
|
|
16
|
+
Steps:
|
|
17
|
+
1. POST /_accounts → login → account_token
|
|
18
|
+
2. POST /_accounts with account_token → selectWorkspace → workspace_token + workspace_id
|
|
19
|
+
3. POST /_accounts with account_token → getUserWorkspaces → workspace_uuid
|
|
20
|
+
4. GET /_transactor/api/v1/account/{workspace_id} → account_id
|
|
21
|
+
5. Verify with ping
|
|
22
|
+
"""
|
|
23
|
+
if not config.email:
|
|
24
|
+
raise AuthError("Email is required for login. Set HULY_EMAIL env var.")
|
|
25
|
+
if not config.password:
|
|
26
|
+
raise AuthError("Password is required for login. Set HULY_PASSWORD env var.")
|
|
27
|
+
if not config.workspace:
|
|
28
|
+
raise AuthError("Workspace is required for login. Set HULY_WORKSPACE env var.")
|
|
29
|
+
|
|
30
|
+
accounts_url = f"{config.url}/_accounts"
|
|
31
|
+
transactor_base = f"{config.url}/_transactor"
|
|
32
|
+
|
|
33
|
+
async with httpx.AsyncClient(timeout=30.0) as http:
|
|
34
|
+
# Step 1: Login
|
|
35
|
+
resp = await http.post(
|
|
36
|
+
accounts_url,
|
|
37
|
+
json={"id": "1", "method": "login", "params": [config.email, config.password]},
|
|
38
|
+
)
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
body = resp.json()
|
|
41
|
+
if "error" in body:
|
|
42
|
+
raise AuthError(f"Login failed: {body['error']}")
|
|
43
|
+
account_token: str = body["result"]["token"]
|
|
44
|
+
|
|
45
|
+
# Step 2: Select workspace
|
|
46
|
+
resp = await http.post(
|
|
47
|
+
accounts_url,
|
|
48
|
+
headers={"Authorization": f"Bearer {account_token}"},
|
|
49
|
+
json={
|
|
50
|
+
"id": "2",
|
|
51
|
+
"method": "selectWorkspace",
|
|
52
|
+
"params": [config.workspace, "external"],
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
body = resp.json()
|
|
57
|
+
if "error" in body:
|
|
58
|
+
raise AuthError(f"Select workspace failed: {body['error']}")
|
|
59
|
+
ws_result = body["result"]
|
|
60
|
+
workspace_token: str = ws_result["token"]
|
|
61
|
+
workspace_id: str = ws_result["workspaceId"]
|
|
62
|
+
|
|
63
|
+
# Step 3: Get workspace UUID via getUserWorkspaces
|
|
64
|
+
resp = await http.post(
|
|
65
|
+
accounts_url,
|
|
66
|
+
headers={"Authorization": f"Bearer {account_token}"},
|
|
67
|
+
json={"id": "3", "method": "getUserWorkspaces", "params": []},
|
|
68
|
+
)
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
body = resp.json()
|
|
71
|
+
if "error" in body:
|
|
72
|
+
raise AuthError(f"getUserWorkspaces failed: {body['error']}")
|
|
73
|
+
workspace_uuid = ""
|
|
74
|
+
for ws in body.get("result", []):
|
|
75
|
+
if ws.get("workspaceUrl") == config.workspace:
|
|
76
|
+
workspace_uuid = ws.get("uuid", "")
|
|
77
|
+
break
|
|
78
|
+
if not workspace_uuid:
|
|
79
|
+
raise AuthError(
|
|
80
|
+
f"Could not find UUID for workspace '{config.workspace}' in getUserWorkspaces response."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Step 4: Get account ID
|
|
84
|
+
resp = await http.get(
|
|
85
|
+
f"{transactor_base}/api/v1/account/{workspace_id}",
|
|
86
|
+
headers={"Authorization": f"Bearer {workspace_token}"},
|
|
87
|
+
)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
account_data = resp.json()
|
|
90
|
+
# account endpoint returns the account object; _id is the account ID
|
|
91
|
+
account_id: str = account_data.get("_id", account_data.get("id", ""))
|
|
92
|
+
|
|
93
|
+
# Step 5: Ping to verify
|
|
94
|
+
ping_resp = await http.get(
|
|
95
|
+
f"{transactor_base}/api/v1/ping/{workspace_id}",
|
|
96
|
+
headers={"Authorization": f"Bearer {workspace_token}"},
|
|
97
|
+
)
|
|
98
|
+
ping_resp.raise_for_status()
|
|
99
|
+
|
|
100
|
+
auth = AuthCache(
|
|
101
|
+
account_token=account_token,
|
|
102
|
+
workspace_token=workspace_token,
|
|
103
|
+
workspace_id=workspace_id,
|
|
104
|
+
workspace_uuid=workspace_uuid,
|
|
105
|
+
email=config.email,
|
|
106
|
+
workspace_slug=config.workspace,
|
|
107
|
+
account_id=account_id,
|
|
108
|
+
cached_at=time.time(),
|
|
109
|
+
)
|
|
110
|
+
save_auth(auth)
|
|
111
|
+
return auth
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _ping(config: HulyConfig, auth: AuthCache) -> bool:
|
|
115
|
+
"""Ping the transactor to verify the cached token is still valid."""
|
|
116
|
+
transactor_base = f"{config.url}/_transactor"
|
|
117
|
+
try:
|
|
118
|
+
async with httpx.AsyncClient(timeout=10.0) as http:
|
|
119
|
+
resp = await http.get(
|
|
120
|
+
f"{transactor_base}/api/v1/ping/{auth.workspace_id}",
|
|
121
|
+
headers={"Authorization": f"Bearer {auth.workspace_token}"},
|
|
122
|
+
)
|
|
123
|
+
return resp.status_code == 200
|
|
124
|
+
except Exception:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def ensure_auth(config: HulyConfig) -> AuthCache:
|
|
129
|
+
"""Return valid auth — use cache if fresh, otherwise re-login."""
|
|
130
|
+
cached = load_auth()
|
|
131
|
+
if cached is not None:
|
|
132
|
+
if await _ping(config, cached):
|
|
133
|
+
return cached
|
|
134
|
+
# Token stale — re-login if we have credentials
|
|
135
|
+
if not config.email or not config.password:
|
|
136
|
+
raise AuthError(
|
|
137
|
+
"Cached token is invalid and no credentials available to re-authenticate. "
|
|
138
|
+
"Run 'huly auth login'."
|
|
139
|
+
)
|
|
140
|
+
elif not config.email or not config.password:
|
|
141
|
+
raise AuthError("Not authenticated. Run 'huly auth login'.")
|
|
142
|
+
|
|
143
|
+
return await login(config)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def check_auth_status(config: HulyConfig) -> dict:
|
|
147
|
+
"""Return a dict describing current auth status."""
|
|
148
|
+
import time as time_mod
|
|
149
|
+
|
|
150
|
+
cached = load_auth()
|
|
151
|
+
if cached is None:
|
|
152
|
+
return {"authenticated": False, "email": None, "workspace": None, "token_age": None}
|
|
153
|
+
|
|
154
|
+
is_valid = await _ping(config, cached)
|
|
155
|
+
age_seconds = time_mod.time() - cached.cached_at
|
|
156
|
+
return {
|
|
157
|
+
"authenticated": is_valid,
|
|
158
|
+
"email": cached.email,
|
|
159
|
+
"workspace": cached.workspace_slug,
|
|
160
|
+
"workspace_id": cached.workspace_id,
|
|
161
|
+
"workspace_uuid": cached.workspace_uuid,
|
|
162
|
+
"token_age_seconds": int(age_seconds),
|
|
163
|
+
"token_age_human": _humanize_age(age_seconds),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _humanize_age(seconds: float) -> str:
|
|
168
|
+
if seconds < 60:
|
|
169
|
+
return f"{int(seconds)}s"
|
|
170
|
+
if seconds < 3600:
|
|
171
|
+
return f"{int(seconds // 60)}m"
|
|
172
|
+
if seconds < 86400:
|
|
173
|
+
return f"{int(seconds // 3600)}h"
|
|
174
|
+
return f"{int(seconds // 86400)}d"
|
huly_cli/client.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""HTTP client for Huly Transactor + Collaborator APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as json_mod
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from huly_cli.config import AuthCache, HulyConfig
|
|
13
|
+
from huly_cli.errors import AuthError, RateLimitError, ServerError
|
|
14
|
+
from huly_cli.output import print_warning
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HulyClient:
|
|
18
|
+
def __init__(self, config: HulyConfig, auth: AuthCache) -> None:
|
|
19
|
+
self._config = config
|
|
20
|
+
self._auth = auth
|
|
21
|
+
self._transactor_base = f"{config.url}/_transactor"
|
|
22
|
+
self._http = httpx.AsyncClient(
|
|
23
|
+
base_url=self._transactor_base,
|
|
24
|
+
headers={"Authorization": f"Bearer {auth.workspace_token}"},
|
|
25
|
+
timeout=30.0,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
async def find_all(
|
|
29
|
+
self,
|
|
30
|
+
class_id: str,
|
|
31
|
+
query: dict[str, Any] | None = None,
|
|
32
|
+
options: dict[str, Any] | None = None,
|
|
33
|
+
) -> list[dict[str, Any]]:
|
|
34
|
+
"""Query documents via POST /api/v1/find-all/{workspace_id}."""
|
|
35
|
+
query = query or {}
|
|
36
|
+
body = {
|
|
37
|
+
"_class": class_id,
|
|
38
|
+
"query": query,
|
|
39
|
+
"options": options or {},
|
|
40
|
+
}
|
|
41
|
+
resp = await self._http.post(
|
|
42
|
+
f"/api/v1/find-all/{self._auth.workspace_id}",
|
|
43
|
+
json=body,
|
|
44
|
+
)
|
|
45
|
+
data = self._handle_response(resp)
|
|
46
|
+
return self._normalize_find_all_result(class_id, query, data)
|
|
47
|
+
|
|
48
|
+
async def tx(self, transaction: dict[str, Any]) -> dict[str, Any]:
|
|
49
|
+
"""Execute a transaction via POST /api/v1/tx/{workspace_id}."""
|
|
50
|
+
transaction = dict(transaction)
|
|
51
|
+
transaction.setdefault("modifiedBy", self._auth.account_id)
|
|
52
|
+
transaction.setdefault("modifiedOn", int(time.time() * 1000))
|
|
53
|
+
resp = await self._http.post(
|
|
54
|
+
f"/api/v1/tx/{self._auth.workspace_id}",
|
|
55
|
+
json=transaction,
|
|
56
|
+
)
|
|
57
|
+
return self._handle_response(resp)
|
|
58
|
+
|
|
59
|
+
async def ping(self) -> bool:
|
|
60
|
+
"""GET /api/v1/ping/{workspace_id} — returns True if server responds OK."""
|
|
61
|
+
try:
|
|
62
|
+
resp = await self._http.get(f"/api/v1/ping/{self._auth.workspace_id}")
|
|
63
|
+
return resp.status_code == 200
|
|
64
|
+
except Exception:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
async def get_account(self) -> dict[str, Any]:
|
|
68
|
+
"""GET /api/v1/account/{workspace_id} — current user account info."""
|
|
69
|
+
resp = await self._http.get(f"/api/v1/account/{self._auth.workspace_id}")
|
|
70
|
+
return self._handle_response(resp)
|
|
71
|
+
|
|
72
|
+
# ── Collaborator RPC ──────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
async def get_content(
|
|
75
|
+
self, class_id: str, object_id: str, field: str, blob_ref: str
|
|
76
|
+
) -> str | None:
|
|
77
|
+
"""Fetch entity content ProseMirror JSON via Collaborator RPC.
|
|
78
|
+
|
|
79
|
+
Works for any entity class and field, e.g.:
|
|
80
|
+
- class_id="tracker:class:Issue", field="description"
|
|
81
|
+
- class_id="document:class:Document", field="content"
|
|
82
|
+
"""
|
|
83
|
+
doc_id = self._build_doc_id(class_id, object_id, field)
|
|
84
|
+
url = f"{self._config.url}/_collaborator/rpc/{doc_id}"
|
|
85
|
+
try:
|
|
86
|
+
async with httpx.AsyncClient(timeout=15.0) as http:
|
|
87
|
+
resp = await http.post(
|
|
88
|
+
url,
|
|
89
|
+
headers={
|
|
90
|
+
"Authorization": f"Bearer {self._auth.workspace_token}",
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
},
|
|
93
|
+
json={"method": "getContent", "payload": {"source": blob_ref}},
|
|
94
|
+
)
|
|
95
|
+
if resp.status_code != 200:
|
|
96
|
+
print_warning(f"getContent returned HTTP {resp.status_code}: {resp.text[:200]}")
|
|
97
|
+
return None
|
|
98
|
+
data = resp.json()
|
|
99
|
+
content = data.get("content", {}).get(field)
|
|
100
|
+
if content is None:
|
|
101
|
+
return None
|
|
102
|
+
if isinstance(content, (dict, list)):
|
|
103
|
+
return json_mod.dumps(content)
|
|
104
|
+
return str(content)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
print_warning(f"getContent error: {e}")
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
async def create_content(
|
|
110
|
+
self,
|
|
111
|
+
class_id: str,
|
|
112
|
+
object_id: str,
|
|
113
|
+
field: str,
|
|
114
|
+
markup_json: str,
|
|
115
|
+
*,
|
|
116
|
+
warn_on_error: bool = True,
|
|
117
|
+
) -> str | None:
|
|
118
|
+
"""Create entity content via Collaborator RPC and return its blob ref."""
|
|
119
|
+
doc_id = self._build_doc_id(class_id, object_id, field)
|
|
120
|
+
url = f"{self._config.url}/_collaborator/rpc/{doc_id}"
|
|
121
|
+
try:
|
|
122
|
+
async with httpx.AsyncClient(timeout=15.0) as http:
|
|
123
|
+
resp = await http.post(
|
|
124
|
+
url,
|
|
125
|
+
headers={
|
|
126
|
+
"Authorization": f"Bearer {self._auth.workspace_token}",
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
},
|
|
129
|
+
json={
|
|
130
|
+
"method": "createContent",
|
|
131
|
+
"payload": {
|
|
132
|
+
"content": {field: markup_json},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
if resp.status_code != 200:
|
|
137
|
+
if warn_on_error:
|
|
138
|
+
print_warning(
|
|
139
|
+
f"createContent failed (HTTP {resp.status_code}): {resp.text[:200]}"
|
|
140
|
+
)
|
|
141
|
+
return None
|
|
142
|
+
data = resp.json()
|
|
143
|
+
blob_ref = data.get("content", {}).get(field)
|
|
144
|
+
if blob_ref is None:
|
|
145
|
+
return None
|
|
146
|
+
return str(blob_ref)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
if warn_on_error:
|
|
149
|
+
print_warning(f"createContent error: {e}")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
async def set_content(
|
|
153
|
+
self,
|
|
154
|
+
class_id: str,
|
|
155
|
+
object_id: str,
|
|
156
|
+
field: str,
|
|
157
|
+
blob_ref: str,
|
|
158
|
+
markup_json: str,
|
|
159
|
+
*,
|
|
160
|
+
warn_on_error: bool = True,
|
|
161
|
+
) -> bool:
|
|
162
|
+
"""Update entity content via Collaborator RPC.
|
|
163
|
+
|
|
164
|
+
Works for any entity class and field.
|
|
165
|
+
"""
|
|
166
|
+
doc_id = self._build_doc_id(class_id, object_id, field)
|
|
167
|
+
url = f"{self._config.url}/_collaborator/rpc/{doc_id}"
|
|
168
|
+
try:
|
|
169
|
+
async with httpx.AsyncClient(timeout=15.0) as http:
|
|
170
|
+
resp = await http.post(
|
|
171
|
+
url,
|
|
172
|
+
headers={
|
|
173
|
+
"Authorization": f"Bearer {self._auth.workspace_token}",
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
},
|
|
176
|
+
json={
|
|
177
|
+
"method": "updateContent",
|
|
178
|
+
"payload": {
|
|
179
|
+
"source": blob_ref,
|
|
180
|
+
"content": {field: markup_json},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
if resp.status_code != 200:
|
|
185
|
+
if warn_on_error:
|
|
186
|
+
print_warning(
|
|
187
|
+
f"updateContent failed (HTTP {resp.status_code}): {resp.text[:200]}"
|
|
188
|
+
)
|
|
189
|
+
return False
|
|
190
|
+
return True
|
|
191
|
+
except Exception as e:
|
|
192
|
+
if warn_on_error:
|
|
193
|
+
print_warning(f"updateContent error: {e}")
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
async def get_description(self, issue_id: str, blob_ref: str) -> str | None:
|
|
197
|
+
"""Fetch issue description ProseMirror JSON via Collaborator RPC.
|
|
198
|
+
|
|
199
|
+
Thin wrapper around get_content for tracker:class:Issue / description.
|
|
200
|
+
"""
|
|
201
|
+
return await self.get_content("tracker:class:Issue", issue_id, "description", blob_ref)
|
|
202
|
+
|
|
203
|
+
async def create_description(
|
|
204
|
+
self,
|
|
205
|
+
issue_id: str,
|
|
206
|
+
markup_json: str,
|
|
207
|
+
*,
|
|
208
|
+
warn_on_error: bool = True,
|
|
209
|
+
) -> str | None:
|
|
210
|
+
"""Create issue description content and return the blob ref."""
|
|
211
|
+
return await self.create_content(
|
|
212
|
+
"tracker:class:Issue",
|
|
213
|
+
issue_id,
|
|
214
|
+
"description",
|
|
215
|
+
markup_json,
|
|
216
|
+
warn_on_error=warn_on_error,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def set_description(
|
|
220
|
+
self,
|
|
221
|
+
issue_id: str,
|
|
222
|
+
blob_ref: str,
|
|
223
|
+
markup_json: str,
|
|
224
|
+
*,
|
|
225
|
+
warn_on_error: bool = True,
|
|
226
|
+
) -> bool:
|
|
227
|
+
"""Update issue description via Collaborator RPC.
|
|
228
|
+
|
|
229
|
+
Thin wrapper around set_content for tracker:class:Issue / description.
|
|
230
|
+
"""
|
|
231
|
+
return await self.set_content(
|
|
232
|
+
"tracker:class:Issue",
|
|
233
|
+
issue_id,
|
|
234
|
+
"description",
|
|
235
|
+
blob_ref,
|
|
236
|
+
markup_json,
|
|
237
|
+
warn_on_error=warn_on_error,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _build_doc_id(self, class_id: str, object_id: str, field: str) -> str:
|
|
241
|
+
"""URL-encode the collaborator document ID for any entity class and field."""
|
|
242
|
+
raw = f"{self._auth.workspace_uuid}|{class_id}|{object_id}|{field}"
|
|
243
|
+
return urllib.parse.quote(raw, safe="")
|
|
244
|
+
|
|
245
|
+
# ── Response handling ─────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
def _handle_response(self, resp: httpx.Response) -> dict[str, Any]:
|
|
248
|
+
if resp.status_code in (401, 403):
|
|
249
|
+
raise AuthError(
|
|
250
|
+
f"Authentication failed (HTTP {resp.status_code}). Run 'huly auth login'."
|
|
251
|
+
)
|
|
252
|
+
if resp.status_code == 429:
|
|
253
|
+
retry_after = float(
|
|
254
|
+
resp.headers.get("Retry-After") or resp.headers.get("Retry-After-ms", 0)
|
|
255
|
+
)
|
|
256
|
+
if retry_after > 1000:
|
|
257
|
+
retry_after /= 1000 # convert ms to seconds
|
|
258
|
+
raise RateLimitError("Rate limit exceeded.", retry_after=retry_after)
|
|
259
|
+
if resp.status_code >= 500:
|
|
260
|
+
raise ServerError(f"Server error (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
261
|
+
resp.raise_for_status()
|
|
262
|
+
try:
|
|
263
|
+
return resp.json()
|
|
264
|
+
except Exception:
|
|
265
|
+
return {}
|
|
266
|
+
|
|
267
|
+
def _normalize_find_all_result(
|
|
268
|
+
self,
|
|
269
|
+
class_id: str,
|
|
270
|
+
query: dict[str, Any],
|
|
271
|
+
data: dict[str, Any] | list[dict[str, Any]],
|
|
272
|
+
) -> list[dict[str, Any]]:
|
|
273
|
+
"""Mirror the upstream REST client normalization for `find-all`.
|
|
274
|
+
|
|
275
|
+
The platform client restores scalar query values that may be omitted in
|
|
276
|
+
filtered results and resolves lookup references using the response's
|
|
277
|
+
`lookupMap`.
|
|
278
|
+
"""
|
|
279
|
+
if isinstance(data, dict):
|
|
280
|
+
lookup_map = data.pop("lookupMap", None)
|
|
281
|
+
rows = data.get("value", [])
|
|
282
|
+
else:
|
|
283
|
+
lookup_map = None
|
|
284
|
+
rows = data
|
|
285
|
+
|
|
286
|
+
if not isinstance(rows, list):
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
if isinstance(lookup_map, dict):
|
|
290
|
+
for row in rows:
|
|
291
|
+
if not isinstance(row, dict):
|
|
292
|
+
continue
|
|
293
|
+
lookup = row.get("$lookup")
|
|
294
|
+
if not isinstance(lookup, dict):
|
|
295
|
+
continue
|
|
296
|
+
for key, value in list(lookup.items()):
|
|
297
|
+
if isinstance(value, list):
|
|
298
|
+
lookup[key] = [lookup_map.get(item) for item in value]
|
|
299
|
+
else:
|
|
300
|
+
lookup[key] = lookup_map.get(value)
|
|
301
|
+
|
|
302
|
+
for row in rows:
|
|
303
|
+
if not isinstance(row, dict):
|
|
304
|
+
continue
|
|
305
|
+
if row.get("_class") is None:
|
|
306
|
+
row["_class"] = class_id
|
|
307
|
+
for key, value in query.items():
|
|
308
|
+
if isinstance(value, (str, int, bool)) and row.get(key) is None:
|
|
309
|
+
row[key] = value
|
|
310
|
+
|
|
311
|
+
return rows
|
|
312
|
+
|
|
313
|
+
# ── Context manager ───────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
async def __aenter__(self) -> HulyClient:
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
319
|
+
await self._http.aclose()
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Auth commands: login, status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from huly_cli.auth import check_auth_status, login
|
|
12
|
+
from huly_cli.config import load_config, save_config
|
|
13
|
+
from huly_cli.errors import AuthError
|
|
14
|
+
from huly_cli.output import print_error, print_item, print_success
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Authentication commands.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("login")
|
|
20
|
+
def auth_login(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
email: Annotated[
|
|
23
|
+
str | None,
|
|
24
|
+
typer.Option("--email", "-e", help="Huly account email.", envvar="HULY_EMAIL"),
|
|
25
|
+
] = None,
|
|
26
|
+
password: Annotated[
|
|
27
|
+
str | None,
|
|
28
|
+
typer.Option(
|
|
29
|
+
"--password",
|
|
30
|
+
"-p",
|
|
31
|
+
help="Huly account password.",
|
|
32
|
+
envvar="HULY_PASSWORD",
|
|
33
|
+
),
|
|
34
|
+
] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Log in to Huly and cache authentication tokens."""
|
|
37
|
+
overrides: dict = ctx.obj or {}
|
|
38
|
+
config = load_config(
|
|
39
|
+
url_override=overrides.get("url"),
|
|
40
|
+
workspace_override=overrides.get("workspace"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Resolve email
|
|
44
|
+
resolved_email = email or config.email
|
|
45
|
+
if not resolved_email:
|
|
46
|
+
if sys.stdin.isatty():
|
|
47
|
+
resolved_email = typer.prompt("Email")
|
|
48
|
+
else:
|
|
49
|
+
raise AuthError("Email required. Set --email or HULY_EMAIL env var.")
|
|
50
|
+
|
|
51
|
+
# Resolve password
|
|
52
|
+
resolved_password = password or config.password
|
|
53
|
+
if not resolved_password:
|
|
54
|
+
if sys.stdin.isatty():
|
|
55
|
+
resolved_password = typer.prompt("Password", hide_input=True)
|
|
56
|
+
else:
|
|
57
|
+
raise AuthError("Password required. Set --password or HULY_PASSWORD env var.")
|
|
58
|
+
|
|
59
|
+
# Resolve workspace
|
|
60
|
+
if not config.workspace:
|
|
61
|
+
if sys.stdin.isatty():
|
|
62
|
+
config.workspace = typer.prompt("Workspace slug", default="efs")
|
|
63
|
+
else:
|
|
64
|
+
raise AuthError("Workspace required. Set HULY_WORKSPACE env var.")
|
|
65
|
+
|
|
66
|
+
config.email = resolved_email
|
|
67
|
+
config.password = resolved_password
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
auth = asyncio.run(login(config))
|
|
71
|
+
except AuthError as e:
|
|
72
|
+
print_error(e.message)
|
|
73
|
+
raise typer.Exit(2) from e
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print_error(f"Login failed: {e}")
|
|
76
|
+
raise typer.Exit(1) from e
|
|
77
|
+
|
|
78
|
+
save_config(config)
|
|
79
|
+
print_success(
|
|
80
|
+
f"Logged in as [bold]{auth.email}[/bold] to workspace [bold]{auth.workspace_slug}[/bold]"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("status")
|
|
85
|
+
def auth_status(ctx: typer.Context) -> None:
|
|
86
|
+
"""Show current authentication status."""
|
|
87
|
+
overrides: dict = ctx.obj or {}
|
|
88
|
+
config = load_config(
|
|
89
|
+
url_override=overrides.get("url"),
|
|
90
|
+
workspace_override=overrides.get("workspace"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
result = asyncio.run(check_auth_status(config))
|
|
94
|
+
print_item(result, title="Auth Status")
|