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 ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.5"
sanka_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from sanka_cli.main import main
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ main()
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__ = []
@@ -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
+ )