sanka-cli 0.1.5__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.
- sanka_cli/__init__.py +3 -0
- sanka_cli/__main__.py +4 -0
- sanka_cli/bundle.py +244 -0
- sanka_cli/client.py +136 -0
- sanka_cli/commands/__init__.py +1 -0
- sanka_cli/commands/ai.py +106 -0
- sanka_cli/commands/auth.py +111 -0
- sanka_cli/commands/code.py +588 -0
- sanka_cli/commands/profiles.py +35 -0
- sanka_cli/commands/resources.py +112 -0
- sanka_cli/commands/workflows.py +111 -0
- sanka_cli/config.py +285 -0
- sanka_cli/main.py +51 -0
- sanka_cli/output.py +84 -0
- sanka_cli/runtime.py +177 -0
- sanka_cli/state.py +10 -0
- sanka_cli-0.1.5.dist-info/METADATA +173 -0
- sanka_cli-0.1.5.dist-info/RECORD +21 -0
- sanka_cli-0.1.5.dist-info/WHEEL +4 -0
- sanka_cli-0.1.5.dist-info/entry_points.txt +2 -0
- sanka_cli-0.1.5.dist-info/licenses/LICENSE +21 -0
sanka_cli/__init__.py
ADDED
sanka_cli/__main__.py
ADDED
sanka_cli/bundle.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Deterministic bundling for ``sanka code``.
|
|
2
|
+
|
|
3
|
+
The server stores versions content-addressed: pushing identical bytes returns the
|
|
4
|
+
existing version instead of minting a new one. That property is only worth anything if
|
|
5
|
+
identical *source* reliably produces identical *bytes* -- otherwise CI mints a new
|
|
6
|
+
version on every merge and the version list becomes noise.
|
|
7
|
+
|
|
8
|
+
Ordinary ``tar`` does not give you that. It records mtimes, uid/gid, usernames, and
|
|
9
|
+
directory order from whatever machine happened to run it, so the same tree tars to
|
|
10
|
+
different bytes on a developer laptop and a CI runner. Everything here exists to strip
|
|
11
|
+
that ambient state out:
|
|
12
|
+
|
|
13
|
+
* entries sorted by path, so filesystem iteration order cannot leak in;
|
|
14
|
+
* mtime, uid, gid, uname, gname all pinned to zero/empty;
|
|
15
|
+
* mode normalized to 0644 (0755 for directories) -- we never honour the executable bit
|
|
16
|
+
anyway, and preserving it would make ``chmod`` look like a code change;
|
|
17
|
+
* gzip's own mtime header zeroed.
|
|
18
|
+
|
|
19
|
+
The result: same files in, same sha256 out, on any machine.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import fnmatch
|
|
25
|
+
import gzip
|
|
26
|
+
import hashlib
|
|
27
|
+
import io
|
|
28
|
+
import tarfile
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
MANIFEST_FILENAME = "sanka.json"
|
|
33
|
+
IGNORE_FILENAME = ".sankaignore"
|
|
34
|
+
|
|
35
|
+
# Mirrors the server's ingest ceilings (app/service/custom_code/bundle.py). Checking
|
|
36
|
+
# locally turns a 422 round trip into an immediate, specific error.
|
|
37
|
+
MAX_COMPRESSED_BYTES = 10 * 1024 * 1024
|
|
38
|
+
MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024
|
|
39
|
+
MAX_FILE_COUNT = 2000
|
|
40
|
+
|
|
41
|
+
DEFAULT_IGNORE_PATTERNS: tuple[str, ...] = (
|
|
42
|
+
".git",
|
|
43
|
+
".git/**",
|
|
44
|
+
".gitignore",
|
|
45
|
+
".sankaignore",
|
|
46
|
+
".venv",
|
|
47
|
+
".venv/**",
|
|
48
|
+
"venv",
|
|
49
|
+
"venv/**",
|
|
50
|
+
"node_modules",
|
|
51
|
+
"node_modules/**",
|
|
52
|
+
"__pycache__",
|
|
53
|
+
"__pycache__/**",
|
|
54
|
+
"**/__pycache__/**",
|
|
55
|
+
"*.pyc",
|
|
56
|
+
".DS_Store",
|
|
57
|
+
"**/.DS_Store",
|
|
58
|
+
".env",
|
|
59
|
+
".env.*",
|
|
60
|
+
"*.log",
|
|
61
|
+
"dist",
|
|
62
|
+
"dist/**",
|
|
63
|
+
"build",
|
|
64
|
+
"build/**",
|
|
65
|
+
".pytest_cache",
|
|
66
|
+
".pytest_cache/**",
|
|
67
|
+
".mypy_cache",
|
|
68
|
+
".mypy_cache/**",
|
|
69
|
+
".ruff_cache",
|
|
70
|
+
".ruff_cache/**",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BundleError(Exception):
|
|
75
|
+
"""Raised for anything that would be rejected locally before upload."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class BuiltBundle:
|
|
80
|
+
raw: bytes
|
|
81
|
+
sha256: str
|
|
82
|
+
paths: tuple[str, ...]
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def size_bytes(self) -> int:
|
|
86
|
+
return len(self.raw)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_ignore_patterns(root: Path) -> tuple[str, ...]:
|
|
90
|
+
patterns = list(DEFAULT_IGNORE_PATTERNS)
|
|
91
|
+
ignore_file = root / IGNORE_FILENAME
|
|
92
|
+
if ignore_file.is_file():
|
|
93
|
+
for line in ignore_file.read_text(encoding="utf-8").splitlines():
|
|
94
|
+
entry = line.strip()
|
|
95
|
+
if not entry or entry.startswith("#"):
|
|
96
|
+
continue
|
|
97
|
+
patterns.append(entry)
|
|
98
|
+
# A bare directory name should exclude its contents too, which is what users
|
|
99
|
+
# mean by "fixtures/" and never "just the directory entry".
|
|
100
|
+
if not entry.endswith("/**"):
|
|
101
|
+
patterns.append(f"{entry.rstrip('/')}/**")
|
|
102
|
+
return tuple(patterns)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_ignored(relative_path: str, patterns: tuple[str, ...]) -> bool:
|
|
106
|
+
return any(fnmatch.fnmatch(relative_path, pattern) for pattern in patterns)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def collect_files(root: Path) -> list[tuple[str, Path]]:
|
|
110
|
+
if not root.is_dir():
|
|
111
|
+
raise BundleError(f"{root} is not a directory.")
|
|
112
|
+
patterns = read_ignore_patterns(root)
|
|
113
|
+
|
|
114
|
+
collected: list[tuple[str, Path]] = []
|
|
115
|
+
for path in root.rglob("*"):
|
|
116
|
+
# Symlinks are dropped rather than followed: the server rejects non-regular
|
|
117
|
+
# members, and following one silently inlines a file from outside the bundle.
|
|
118
|
+
if path.is_symlink() or not path.is_file():
|
|
119
|
+
continue
|
|
120
|
+
relative = path.relative_to(root).as_posix()
|
|
121
|
+
if is_ignored(relative, patterns):
|
|
122
|
+
continue
|
|
123
|
+
collected.append((relative, path))
|
|
124
|
+
|
|
125
|
+
collected.sort(key=lambda item: item[0])
|
|
126
|
+
return collected
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_bundle(root: Path) -> BuiltBundle:
|
|
130
|
+
files = collect_files(root)
|
|
131
|
+
if not files:
|
|
132
|
+
raise BundleError(f"No files to bundle in {root} (everything was ignored).")
|
|
133
|
+
if not any(relative == MANIFEST_FILENAME for relative, _ in files):
|
|
134
|
+
raise BundleError(
|
|
135
|
+
f"{MANIFEST_FILENAME} not found in {root}. Run `sanka code init` first."
|
|
136
|
+
)
|
|
137
|
+
if len(files) > MAX_FILE_COUNT:
|
|
138
|
+
raise BundleError(
|
|
139
|
+
f"Bundle would contain {len(files)} files; the limit is {MAX_FILE_COUNT}."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
total = sum(path.stat().st_size for _, path in files)
|
|
143
|
+
if total > MAX_UNCOMPRESSED_BYTES:
|
|
144
|
+
raise BundleError(
|
|
145
|
+
f"Bundle would be {total} bytes uncompressed; the limit is "
|
|
146
|
+
f"{MAX_UNCOMPRESSED_BYTES}."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
tar_buffer = io.BytesIO()
|
|
150
|
+
with tarfile.open(fileobj=tar_buffer, mode="w", format=tarfile.PAX_FORMAT) as tar:
|
|
151
|
+
for relative, path in files:
|
|
152
|
+
payload = path.read_bytes()
|
|
153
|
+
info = tarfile.TarInfo(relative)
|
|
154
|
+
info.size = len(payload)
|
|
155
|
+
info.mtime = 0
|
|
156
|
+
info.mode = 0o644
|
|
157
|
+
info.uid = 0
|
|
158
|
+
info.gid = 0
|
|
159
|
+
info.uname = ""
|
|
160
|
+
info.gname = ""
|
|
161
|
+
info.type = tarfile.REGTYPE
|
|
162
|
+
tar.addfile(info, io.BytesIO(payload))
|
|
163
|
+
|
|
164
|
+
compressed = io.BytesIO()
|
|
165
|
+
# mtime=0 keeps gzip's own header out of the digest.
|
|
166
|
+
with gzip.GzipFile(fileobj=compressed, mode="wb", mtime=0) as gz:
|
|
167
|
+
gz.write(tar_buffer.getvalue())
|
|
168
|
+
raw = compressed.getvalue()
|
|
169
|
+
|
|
170
|
+
if len(raw) > MAX_COMPRESSED_BYTES:
|
|
171
|
+
raise BundleError(
|
|
172
|
+
f"Bundle is {len(raw)} bytes compressed; the limit is "
|
|
173
|
+
f"{MAX_COMPRESSED_BYTES}."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return BuiltBundle(
|
|
177
|
+
raw=raw,
|
|
178
|
+
sha256=hashlib.sha256(raw).hexdigest(),
|
|
179
|
+
paths=tuple(relative for relative, _ in files),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def extract_bundle(
|
|
184
|
+
raw: bytes, destination: Path, *, overwrite: bool = False
|
|
185
|
+
) -> list[str]:
|
|
186
|
+
"""Write a downloaded bundle to disk.
|
|
187
|
+
|
|
188
|
+
Re-validates every member path even though the server validated on the way in. This
|
|
189
|
+
code runs on a developer's machine against bytes fetched over the network, and a
|
|
190
|
+
client that trusts the server to have been careful is a client that writes to
|
|
191
|
+
``/etc`` the one time it wasn't.
|
|
192
|
+
"""
|
|
193
|
+
written: list[str] = []
|
|
194
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
resolved_root = destination.resolve()
|
|
196
|
+
|
|
197
|
+
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tar:
|
|
198
|
+
for member in tar:
|
|
199
|
+
if member.isdir():
|
|
200
|
+
continue
|
|
201
|
+
if not member.isfile():
|
|
202
|
+
raise BundleError(
|
|
203
|
+
f"Refusing to extract non-regular member {member.name!r}."
|
|
204
|
+
)
|
|
205
|
+
relative = member.name
|
|
206
|
+
if relative.startswith("/") or ".." in relative.split("/"):
|
|
207
|
+
raise BundleError(f"Refusing to extract unsafe path {relative!r}.")
|
|
208
|
+
|
|
209
|
+
target = (destination / relative).resolve()
|
|
210
|
+
# Belt and braces: even a path that looks clean must land inside the root.
|
|
211
|
+
if not target.is_relative_to(resolved_root):
|
|
212
|
+
raise BundleError(
|
|
213
|
+
f"Refusing to extract outside {destination}: {relative!r}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if target.exists() and not overwrite:
|
|
217
|
+
raise BundleError(
|
|
218
|
+
f"{target} already exists. Re-run with --force to overwrite."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
handle = tar.extractfile(member)
|
|
222
|
+
if handle is None:
|
|
223
|
+
raise BundleError(f"Could not read {relative!r} from the bundle.")
|
|
224
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
with handle:
|
|
226
|
+
target.write_bytes(handle.read())
|
|
227
|
+
written.append(relative)
|
|
228
|
+
|
|
229
|
+
return sorted(written)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = [
|
|
233
|
+
"IGNORE_FILENAME",
|
|
234
|
+
"MANIFEST_FILENAME",
|
|
235
|
+
"MAX_COMPRESSED_BYTES",
|
|
236
|
+
"MAX_FILE_COUNT",
|
|
237
|
+
"MAX_UNCOMPRESSED_BYTES",
|
|
238
|
+
"BuiltBundle",
|
|
239
|
+
"BundleError",
|
|
240
|
+
"build_bundle",
|
|
241
|
+
"collect_files",
|
|
242
|
+
"extract_bundle",
|
|
243
|
+
"read_ignore_patterns",
|
|
244
|
+
]
|
sanka_cli/client.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from sanka_cli import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class APIError(Exception):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
status_code: int,
|
|
16
|
+
message: str,
|
|
17
|
+
ctx_id: str | None = None,
|
|
18
|
+
payload: dict[str, Any] | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.message = message
|
|
23
|
+
self.ctx_id = ctx_id
|
|
24
|
+
self.payload = payload or {}
|
|
25
|
+
|
|
26
|
+
def display_message(self) -> str:
|
|
27
|
+
if self.ctx_id:
|
|
28
|
+
return f"{self.message} (ctx_id={self.ctx_id})"
|
|
29
|
+
return self.message
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SankaApiClient:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
base_url: str,
|
|
37
|
+
access_token: str,
|
|
38
|
+
timeout: float = 30.0,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.base_url = base_url.rstrip("/")
|
|
41
|
+
self.access_token = access_token
|
|
42
|
+
self.client = httpx.Client(base_url=self.base_url, timeout=timeout)
|
|
43
|
+
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
self.client.close()
|
|
46
|
+
|
|
47
|
+
def _headers(self, *, include_auth: bool = True) -> dict[str, str]:
|
|
48
|
+
headers = {
|
|
49
|
+
"User-Agent": f"sanka-cli/{__version__}",
|
|
50
|
+
"X-Sanka-CLI-Version": __version__,
|
|
51
|
+
}
|
|
52
|
+
if include_auth and self.access_token:
|
|
53
|
+
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
54
|
+
return headers
|
|
55
|
+
|
|
56
|
+
def _raise_for_response(self, response: httpx.Response) -> None:
|
|
57
|
+
try:
|
|
58
|
+
payload = response.json()
|
|
59
|
+
except Exception:
|
|
60
|
+
payload = {}
|
|
61
|
+
|
|
62
|
+
message = ""
|
|
63
|
+
ctx_id = None
|
|
64
|
+
if isinstance(payload, dict):
|
|
65
|
+
error = payload.get("error")
|
|
66
|
+
error = error if isinstance(error, dict) else {}
|
|
67
|
+
meta = payload.get("meta")
|
|
68
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
69
|
+
message = str(
|
|
70
|
+
payload.get("message")
|
|
71
|
+
or payload.get("detail")
|
|
72
|
+
or error.get("message")
|
|
73
|
+
or ""
|
|
74
|
+
).strip()
|
|
75
|
+
ctx_id = payload.get("ctx_id") or meta.get("ctx_id")
|
|
76
|
+
if not message:
|
|
77
|
+
message = response.text.strip() or f"HTTP {response.status_code}"
|
|
78
|
+
|
|
79
|
+
raise APIError(
|
|
80
|
+
status_code=response.status_code,
|
|
81
|
+
message=message,
|
|
82
|
+
ctx_id=ctx_id,
|
|
83
|
+
payload=payload if isinstance(payload, dict) else {},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def request_bytes(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
path: str,
|
|
90
|
+
*,
|
|
91
|
+
params: dict[str, Any] | None = None,
|
|
92
|
+
) -> tuple[bytes, dict[str, str]]:
|
|
93
|
+
"""Fetch a non-JSON body (currently only the Custom Code bundle download).
|
|
94
|
+
|
|
95
|
+
Returns headers alongside the payload so the caller can verify the digest the
|
|
96
|
+
server reports without a second request.
|
|
97
|
+
"""
|
|
98
|
+
response = self.client.request(
|
|
99
|
+
method.upper(),
|
|
100
|
+
path,
|
|
101
|
+
headers=self._headers(),
|
|
102
|
+
params=params,
|
|
103
|
+
)
|
|
104
|
+
if response.status_code >= 400:
|
|
105
|
+
self._raise_for_response(response)
|
|
106
|
+
return response.content, dict(response.headers)
|
|
107
|
+
|
|
108
|
+
def request_json(
|
|
109
|
+
self,
|
|
110
|
+
method: str,
|
|
111
|
+
path: str,
|
|
112
|
+
*,
|
|
113
|
+
params: dict[str, Any] | None = None,
|
|
114
|
+
json_body: dict[str, Any] | None = None,
|
|
115
|
+
allow_refresh: bool = True,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
response = self.client.request(
|
|
118
|
+
method.upper(),
|
|
119
|
+
path,
|
|
120
|
+
headers=self._headers(),
|
|
121
|
+
params=params,
|
|
122
|
+
json=json_body,
|
|
123
|
+
)
|
|
124
|
+
_ = allow_refresh
|
|
125
|
+
if response.status_code >= 400:
|
|
126
|
+
self._raise_for_response(response)
|
|
127
|
+
|
|
128
|
+
if not response.content:
|
|
129
|
+
return {}
|
|
130
|
+
try:
|
|
131
|
+
return response.json()
|
|
132
|
+
except json.JSONDecodeError as exc:
|
|
133
|
+
raise APIError(
|
|
134
|
+
status_code=response.status_code,
|
|
135
|
+
message="API returned invalid JSON",
|
|
136
|
+
) from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
sanka_cli/commands/ai.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
import sanka_cli.runtime as runtime
|
|
8
|
+
from sanka_cli.state import CLIState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def ai() -> None:
|
|
13
|
+
"""AI commands."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@ai.group("score")
|
|
17
|
+
def ai_score() -> None:
|
|
18
|
+
"""Score records."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _score_command(object_type: str):
|
|
22
|
+
@click.command(name=object_type)
|
|
23
|
+
@click.argument("record_id")
|
|
24
|
+
@click.option("--score-model-id", default=None)
|
|
25
|
+
@click.pass_obj
|
|
26
|
+
def command(
|
|
27
|
+
state: CLIState,
|
|
28
|
+
record_id: str,
|
|
29
|
+
score_model_id: str | None,
|
|
30
|
+
) -> None:
|
|
31
|
+
body: dict[str, Any] = {
|
|
32
|
+
"object_type": object_type,
|
|
33
|
+
"record_id": record_id,
|
|
34
|
+
}
|
|
35
|
+
if score_model_id:
|
|
36
|
+
body["score_model_id"] = score_model_id
|
|
37
|
+
payload = runtime.request_json(state, "POST", "/v2/score", json_body=body)
|
|
38
|
+
runtime.emit_payload(payload, state)
|
|
39
|
+
|
|
40
|
+
return command
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
ai_score.add_command(_score_command("company"))
|
|
44
|
+
ai_score.add_command(_score_command("deal"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@ai.group("enrich")
|
|
48
|
+
def ai_enrich() -> None:
|
|
49
|
+
"""Enrich records."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@ai_enrich.command("company")
|
|
53
|
+
@click.argument("record_id", required=False)
|
|
54
|
+
@click.option("--force-refresh", is_flag=True, default=False)
|
|
55
|
+
@click.option("--dry-run", is_flag=True, default=False)
|
|
56
|
+
@click.option("--custom-field-map", default=None, help="JSON string or @file.json")
|
|
57
|
+
@click.option("--seed-name", default=None)
|
|
58
|
+
@click.option("--seed-url", default=None)
|
|
59
|
+
@click.option("--seed-external-id", default=None)
|
|
60
|
+
@click.pass_obj
|
|
61
|
+
def ai_enrich_company(
|
|
62
|
+
state: CLIState,
|
|
63
|
+
record_id: str | None,
|
|
64
|
+
force_refresh: bool,
|
|
65
|
+
dry_run: bool,
|
|
66
|
+
custom_field_map: str | None,
|
|
67
|
+
seed_name: str | None,
|
|
68
|
+
seed_url: str | None,
|
|
69
|
+
seed_external_id: str | None,
|
|
70
|
+
) -> None:
|
|
71
|
+
has_seed = bool(seed_name or seed_url or seed_external_id)
|
|
72
|
+
if record_id and has_seed:
|
|
73
|
+
raise click.ClickException("Use either record_id or seed fields, not both")
|
|
74
|
+
if not record_id and not has_seed:
|
|
75
|
+
raise click.ClickException("record_id or a seed field is required")
|
|
76
|
+
if has_seed and not dry_run:
|
|
77
|
+
raise click.ClickException(
|
|
78
|
+
"--dry-run is required when seed fields are provided"
|
|
79
|
+
)
|
|
80
|
+
if has_seed and custom_field_map:
|
|
81
|
+
raise click.ClickException(
|
|
82
|
+
"--custom-field-map is only supported with record_id"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
body: dict[str, Any] = {
|
|
86
|
+
"object_type": "company",
|
|
87
|
+
"dry_run": dry_run,
|
|
88
|
+
"force_refresh": force_refresh,
|
|
89
|
+
}
|
|
90
|
+
if record_id:
|
|
91
|
+
body["record_id"] = record_id
|
|
92
|
+
if custom_field_map:
|
|
93
|
+
body["custom_field_map"] = runtime.parse_json_input(custom_field_map)
|
|
94
|
+
else:
|
|
95
|
+
body["seed"] = {
|
|
96
|
+
key: value
|
|
97
|
+
for key, value in {
|
|
98
|
+
"name": seed_name,
|
|
99
|
+
"url": seed_url,
|
|
100
|
+
"external_id": seed_external_id,
|
|
101
|
+
}.items()
|
|
102
|
+
if value
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
payload = runtime.request_json(state, "POST", "/v2/enrich", json_body=body)
|
|
106
|
+
runtime.emit_payload(payload, state)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
import sanka_cli.runtime as runtime
|
|
6
|
+
from sanka_cli.config import DEFAULT_BASE_URL
|
|
7
|
+
from sanka_cli.state import CLIState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def auth() -> None:
|
|
12
|
+
"""Authentication commands."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@auth.command("login")
|
|
16
|
+
@click.option("--access-token", required=True, help="Developer API access token.")
|
|
17
|
+
@click.option(
|
|
18
|
+
"--refresh-token",
|
|
19
|
+
default=None,
|
|
20
|
+
help="Deprecated legacy refresh token. V2 public API uses bearer tokens.",
|
|
21
|
+
)
|
|
22
|
+
@click.option("--profile", "profile_name", default=None, help="Profile name to save.")
|
|
23
|
+
@click.option("--base-url", default=None, help="API base URL to store for the profile.")
|
|
24
|
+
@click.pass_obj
|
|
25
|
+
def auth_login(
|
|
26
|
+
state: CLIState,
|
|
27
|
+
access_token: str,
|
|
28
|
+
refresh_token: str | None,
|
|
29
|
+
profile_name: str | None,
|
|
30
|
+
base_url: str | None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Verify an access token against the API and store it for the profile."""
|
|
33
|
+
resolved_profile_name = profile_name or state.profile or "default"
|
|
34
|
+
resolved_base_url = (base_url or state.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
35
|
+
try:
|
|
36
|
+
identity, verify_warning = runtime.verify_access_token(
|
|
37
|
+
base_url=resolved_base_url,
|
|
38
|
+
access_token=access_token,
|
|
39
|
+
)
|
|
40
|
+
except runtime.TokenVerificationError as exc:
|
|
41
|
+
raise click.ClickException(
|
|
42
|
+
f"{exc} Nothing was saved. Create a token under "
|
|
43
|
+
"Developers → API in Sanka and retry."
|
|
44
|
+
) from exc
|
|
45
|
+
runtime.upsert_profile(resolved_profile_name, base_url=resolved_base_url)
|
|
46
|
+
try:
|
|
47
|
+
runtime.store_tokens(
|
|
48
|
+
resolved_profile_name,
|
|
49
|
+
access_token=access_token,
|
|
50
|
+
refresh_token=refresh_token,
|
|
51
|
+
)
|
|
52
|
+
except runtime.CredentialStoreError as exc:
|
|
53
|
+
raise click.ClickException(str(exc)) from exc
|
|
54
|
+
payload: dict[str, object] = {
|
|
55
|
+
"message": "logged_in" if identity is not None else "saved",
|
|
56
|
+
}
|
|
57
|
+
if identity:
|
|
58
|
+
payload.update(_identity_fields(identity))
|
|
59
|
+
payload["profile"] = resolved_profile_name
|
|
60
|
+
payload["base_url"] = resolved_base_url
|
|
61
|
+
if verify_warning:
|
|
62
|
+
payload["warning"] = verify_warning
|
|
63
|
+
runtime.emit_payload(payload, state)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _identity_fields(identity: dict) -> dict[str, str]:
|
|
67
|
+
workspace_name = str(identity.get("workspace_name") or "").strip()
|
|
68
|
+
workspace_code = str(identity.get("workspace_code") or "").strip()
|
|
69
|
+
if workspace_name and workspace_code:
|
|
70
|
+
workspace = f"{workspace_name} ({workspace_code})"
|
|
71
|
+
else:
|
|
72
|
+
workspace = workspace_name or workspace_code
|
|
73
|
+
fields = {
|
|
74
|
+
"workspace": workspace,
|
|
75
|
+
"user": str(identity.get("email") or identity.get("username") or "").strip(),
|
|
76
|
+
"token": str(identity.get("token_name") or "").strip(),
|
|
77
|
+
"permission_level": str(identity.get("permission_level") or "").strip(),
|
|
78
|
+
}
|
|
79
|
+
return {key: value for key, value in fields.items() if value}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@auth.command("status")
|
|
83
|
+
@click.pass_obj
|
|
84
|
+
def auth_status(state: CLIState) -> None:
|
|
85
|
+
try:
|
|
86
|
+
resolved = runtime.resolve_runtime(
|
|
87
|
+
profile_name=state.profile,
|
|
88
|
+
base_url_override=state.base_url,
|
|
89
|
+
)
|
|
90
|
+
except runtime.CredentialStoreError as exc:
|
|
91
|
+
raise click.ClickException(str(exc)) from exc
|
|
92
|
+
payload = runtime.request_json(state, "GET", "/v2/public/auth/whoami")
|
|
93
|
+
data = payload.get("data", payload)
|
|
94
|
+
data["profile"] = resolved["profile_name"]
|
|
95
|
+
data["base_url"] = resolved["base_url"]
|
|
96
|
+
runtime.emit_payload(data, state)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@auth.command("logout")
|
|
100
|
+
@click.option("--profile", "profile_name", default=None, help="Profile name to clear.")
|
|
101
|
+
@click.pass_obj
|
|
102
|
+
def auth_logout(state: CLIState, profile_name: str | None) -> None:
|
|
103
|
+
resolved_profile_name = profile_name or state.profile or "default"
|
|
104
|
+
runtime.clear_tokens(resolved_profile_name)
|
|
105
|
+
runtime.emit_payload(
|
|
106
|
+
{
|
|
107
|
+
"message": "logged_out",
|
|
108
|
+
"profile": resolved_profile_name,
|
|
109
|
+
},
|
|
110
|
+
state,
|
|
111
|
+
)
|