ktr-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.
@@ -0,0 +1 @@
1
+ """Core runtime helpers for ktr-cli."""
@@ -0,0 +1,230 @@
1
+ import time as _time
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+ from urllib.parse import urlparse
5
+
6
+ import requests
7
+
8
+ from kantree_cli.core.errors import (
9
+ ApiError,
10
+ AuthError,
11
+ NetworkError,
12
+ NotFoundError,
13
+ PreconditionError,
14
+ ValidationError,
15
+ )
16
+
17
+ DEFAULT_TIMEOUT_SECONDS = 30.0
18
+ DEFAULT_MAX_RETRIES = 3
19
+ _RETRYABLE_STATUSES = frozenset({502, 503, 504})
20
+ _IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class KantreeResponse:
25
+ status_code: int
26
+ data: Any
27
+ headers: dict[str, str]
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class KantreeRawResponse:
32
+ status_code: int
33
+ content: bytes
34
+ headers: dict[str, str]
35
+
36
+
37
+ class KantreeClient:
38
+ def __init__(
39
+ self,
40
+ *,
41
+ base_url: str,
42
+ api_key: str | None,
43
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
44
+ max_retries: int = DEFAULT_MAX_RETRIES,
45
+ session: requests.Session | None = None,
46
+ ) -> None:
47
+ self._base_url = base_url.rstrip("/")
48
+ self._api_key = api_key
49
+ self._timeout_seconds = timeout_seconds
50
+ self._max_retries = max_retries
51
+ self._session = session or requests.Session()
52
+
53
+ def request(
54
+ self,
55
+ method: str,
56
+ path: str,
57
+ *,
58
+ params: dict[str, str] | None = None,
59
+ json_body: Any = None,
60
+ form_data: dict[str, str] | None = None,
61
+ files: dict[str, Any] | None = None,
62
+ headers: dict[str, str] | None = None,
63
+ ) -> KantreeResponse:
64
+ if json_body is not None and (form_data is not None or files is not None):
65
+ raise ValidationError("Use either a JSON payload or form-data/files payload, not both.")
66
+
67
+ target_url = self._build_url(path)
68
+ request_headers = {"Accept": "application/json"}
69
+ if self._api_key:
70
+ request_headers["X-Api-Key"] = self._api_key
71
+ if headers:
72
+ request_headers.update(headers)
73
+
74
+ upper_method = method.upper()
75
+ retryable = upper_method in _IDEMPOTENT_METHODS and self._max_retries > 0
76
+
77
+ for attempt in range(self._max_retries + 1):
78
+ if attempt > 0:
79
+ backoff = 2 ** (attempt - 1)
80
+ _time.sleep(backoff)
81
+
82
+ try:
83
+ response = self._session.request(
84
+ method=upper_method,
85
+ url=target_url,
86
+ params=params,
87
+ json=json_body,
88
+ data=form_data,
89
+ files=files,
90
+ headers=request_headers,
91
+ timeout=self._timeout_seconds,
92
+ )
93
+ except (requests.Timeout, requests.ConnectionError) as exc:
94
+ if retryable and attempt < self._max_retries:
95
+ continue
96
+ raise NetworkError(
97
+ f"Network request failed: {exc}"
98
+ if not isinstance(exc, requests.Timeout)
99
+ else f"Request timed out after {self._timeout_seconds:.0f}s"
100
+ ) from exc
101
+ except requests.RequestException as exc:
102
+ raise NetworkError(f"Network request failed: {exc}") from exc
103
+
104
+ if (
105
+ retryable
106
+ and response.status_code in _RETRYABLE_STATUSES
107
+ and attempt < self._max_retries
108
+ ):
109
+ continue
110
+
111
+ break
112
+
113
+ if response.status_code in (401, 403):
114
+ raise AuthError(f"Authentication failed ({response.status_code})")
115
+ if response.status_code == 404:
116
+ details = _extract_api_error_details(response)
117
+ raise NotFoundError(f"Resource not found for `{path}`: {details}")
118
+ if response.status_code == 412:
119
+ details = _extract_api_error_details(response)
120
+ raise PreconditionError(f"Precondition failed for `{path}`: {details}")
121
+ if response.status_code >= 400:
122
+ details = _extract_api_error_details(response)
123
+ raise ApiError(f"API request failed ({response.status_code}): {details}")
124
+
125
+ return KantreeResponse(
126
+ status_code=response.status_code,
127
+ data=_parse_response_data(response),
128
+ headers=dict(response.headers),
129
+ )
130
+
131
+ def get_json(self, path: str) -> Any:
132
+ response = self.request("GET", path)
133
+ return response.data
134
+
135
+ def download_same_origin_url(self, url: str) -> KantreeRawResponse:
136
+ target = urlparse(url)
137
+ base = urlparse(self._base_url)
138
+ if target.scheme not in {"http", "https"} or not target.netloc:
139
+ raise ValidationError("Artifact URL must be absolute.")
140
+ if (target.scheme, target.netloc) != (base.scheme, base.netloc):
141
+ raise ValidationError("Artifact URL must use the configured Kantree origin.")
142
+
143
+ request_headers = {"Accept": "*/*"}
144
+ if self._api_key:
145
+ request_headers["X-Api-Key"] = self._api_key
146
+
147
+ retryable = self._max_retries > 0
148
+ for attempt in range(self._max_retries + 1):
149
+ if attempt > 0:
150
+ backoff = 2 ** (attempt - 1)
151
+ _time.sleep(backoff)
152
+
153
+ try:
154
+ response = self._session.request(
155
+ method="GET",
156
+ url=url,
157
+ headers=request_headers,
158
+ timeout=self._timeout_seconds,
159
+ )
160
+ except (requests.Timeout, requests.ConnectionError) as exc:
161
+ if retryable and attempt < self._max_retries:
162
+ continue
163
+ raise NetworkError(
164
+ f"Network request failed: {exc}"
165
+ if not isinstance(exc, requests.Timeout)
166
+ else f"Request timed out after {self._timeout_seconds:.0f}s"
167
+ ) from exc
168
+ except requests.RequestException as exc:
169
+ raise NetworkError(f"Network request failed: {exc}") from exc
170
+
171
+ if (
172
+ retryable
173
+ and response.status_code in _RETRYABLE_STATUSES
174
+ and attempt < self._max_retries
175
+ ):
176
+ continue
177
+
178
+ break
179
+
180
+ if response.status_code in (401, 403):
181
+ raise AuthError(f"Authentication failed ({response.status_code})")
182
+ if response.status_code == 404:
183
+ details = _extract_api_error_details(response)
184
+ raise NotFoundError(f"Resource not found for artifact URL: {details}")
185
+ if response.status_code == 412:
186
+ details = _extract_api_error_details(response)
187
+ raise PreconditionError(f"Precondition failed for artifact URL: {details}")
188
+ if response.status_code >= 400:
189
+ details = _extract_api_error_details(response)
190
+ raise ApiError(f"API request failed ({response.status_code}): {details}")
191
+
192
+ return KantreeRawResponse(
193
+ status_code=response.status_code,
194
+ content=response.content,
195
+ headers=dict(response.headers),
196
+ )
197
+
198
+ def _build_url(self, path: str) -> str:
199
+ if path.lower().startswith(("http://", "https://")):
200
+ raise ValidationError(
201
+ "Path must be relative to the configured base URL; absolute URLs are not allowed."
202
+ )
203
+ if not path.startswith("/"):
204
+ path = f"/{path}"
205
+ return f"{self._base_url}{path}"
206
+
207
+
208
+ def _extract_api_error_details(response: requests.Response) -> str:
209
+ payload = _parse_response_data(response)
210
+ if isinstance(payload, dict):
211
+ for key in ("error", "message", "detail", "description"):
212
+ value = payload.get(key)
213
+ if isinstance(value, str) and value.strip():
214
+ return value
215
+ errors_value = payload.get("errors")
216
+ if isinstance(errors_value, list):
217
+ parts = [str(item) for item in errors_value if item]
218
+ if parts:
219
+ return "; ".join(parts)
220
+ if isinstance(errors_value, str) and errors_value.strip():
221
+ return errors_value
222
+ text = response.text.strip()
223
+ return text if text else "unknown API error"
224
+
225
+
226
+ def _parse_response_data(response: requests.Response) -> Any:
227
+ try:
228
+ return response.json()
229
+ except ValueError:
230
+ return response.text
@@ -0,0 +1,226 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from kantree_cli.core.errors import ValidationError
8
+
9
+ DEFAULT_PROFILE = "default"
10
+ DEFAULT_BASE_URL = "https://kantree.io/api/1.0"
11
+ DEFAULT_API_KEY_ENV = "KANTREE_API_KEY"
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class ProfileConfig:
16
+ base_url: str | None = None
17
+ api_key_env: str | None = None
18
+ default_workspace: str | None = None
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class SearchPresetConfig:
23
+ kql: str
24
+ scope: str | None = None
25
+ default_format: str | None = None
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class AppConfig:
30
+ active_profile: str | None = None
31
+ profiles: dict[str, ProfileConfig] = field(default_factory=dict)
32
+ search_presets: dict[str, SearchPresetConfig] = field(default_factory=dict)
33
+
34
+
35
+ def config_path() -> Path:
36
+ return config_dir() / "config.toml"
37
+
38
+
39
+ def config_dir() -> Path:
40
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
41
+ if xdg_config_home:
42
+ return Path(xdg_config_home).expanduser() / "kantree"
43
+ return Path.home() / ".config" / "kantree"
44
+
45
+
46
+ def default_profile_config() -> ProfileConfig:
47
+ return ProfileConfig(base_url=DEFAULT_BASE_URL, api_key_env=DEFAULT_API_KEY_ENV)
48
+
49
+
50
+ def default_app_config() -> AppConfig:
51
+ return AppConfig(
52
+ active_profile=DEFAULT_PROFILE,
53
+ profiles={DEFAULT_PROFILE: default_profile_config()},
54
+ )
55
+
56
+
57
+ def load_config(path: Path | None = None) -> AppConfig:
58
+ path = path or config_path()
59
+ if not path.exists():
60
+ return AppConfig()
61
+
62
+ try:
63
+ import tomllib
64
+
65
+ parsed = tomllib.loads(path.read_text(encoding="utf-8"))
66
+ except OSError as exc:
67
+ raise ValidationError(f"Failed to read config file: {path}") from exc
68
+ except Exception as exc:
69
+ raise ValidationError(f"Invalid TOML in config file: {path}") from exc
70
+
71
+ return _parse_app_config(parsed)
72
+
73
+
74
+ def save_config(config: AppConfig, path: Path | None = None) -> Path:
75
+ path = path or config_path()
76
+ path.parent.mkdir(parents=True, exist_ok=True)
77
+ data = _dump_app_config(config)
78
+
79
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
80
+ try:
81
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
82
+ stream.write(data)
83
+ finally:
84
+ os.chmod(path, 0o600)
85
+
86
+ return path
87
+
88
+
89
+ def ensure_default_config(path: Path | None = None) -> tuple[AppConfig, Path, bool]:
90
+ path = path or config_path()
91
+ if path.exists():
92
+ return load_config(path), path, False
93
+
94
+ config = default_app_config()
95
+ save_config(config, path)
96
+ return config, path, True
97
+
98
+
99
+ def _parse_app_config(raw: dict[str, Any]) -> AppConfig:
100
+ active_profile = raw.get("active_profile")
101
+ if active_profile is not None and not isinstance(active_profile, str):
102
+ raise ValidationError("`active_profile` must be a string in config.toml")
103
+
104
+ raw_profiles = raw.get("profiles", {})
105
+ if raw_profiles is None:
106
+ raw_profiles = {}
107
+ if not isinstance(raw_profiles, dict):
108
+ raise ValidationError("`profiles` must be a table in config.toml")
109
+
110
+ profiles: dict[str, ProfileConfig] = {}
111
+ for profile_name, profile_data in raw_profiles.items():
112
+ if not isinstance(profile_name, str):
113
+ raise ValidationError("Profile names must be strings in config.toml")
114
+ if not isinstance(profile_data, dict):
115
+ raise ValidationError(f"Profile `{profile_name}` must be a table in config.toml")
116
+ profiles[profile_name] = ProfileConfig(
117
+ base_url=_as_optional_string(profile_data.get("base_url"), "base_url", profile_name),
118
+ api_key_env=_as_optional_string(
119
+ profile_data.get("api_key_env"),
120
+ "api_key_env",
121
+ profile_name,
122
+ ),
123
+ default_workspace=_as_optional_string(
124
+ profile_data.get("default_workspace"),
125
+ "default_workspace",
126
+ profile_name,
127
+ ),
128
+ )
129
+
130
+ raw_search_presets = raw.get("search_presets", {})
131
+ if raw_search_presets is None:
132
+ raw_search_presets = {}
133
+ if not isinstance(raw_search_presets, dict):
134
+ raise ValidationError("`search_presets` must be a table in config.toml")
135
+
136
+ search_presets: dict[str, SearchPresetConfig] = {}
137
+ for preset_name, preset_data in raw_search_presets.items():
138
+ if not isinstance(preset_name, str):
139
+ raise ValidationError("Search preset names must be strings in config.toml")
140
+ if not isinstance(preset_data, dict):
141
+ raise ValidationError(f"Search preset `{preset_name}` must be a table in config.toml")
142
+ kql = _as_required_string(preset_data.get("kql"), "kql", preset_name)
143
+ search_presets[preset_name] = SearchPresetConfig(
144
+ kql=kql,
145
+ scope=_as_optional_string(
146
+ preset_data.get("scope"),
147
+ "scope",
148
+ preset_name,
149
+ key_prefix="search_presets",
150
+ ),
151
+ default_format=_as_optional_string(
152
+ preset_data.get("default_format"),
153
+ "default_format",
154
+ preset_name,
155
+ key_prefix="search_presets",
156
+ ),
157
+ )
158
+
159
+ return AppConfig(
160
+ active_profile=active_profile,
161
+ profiles=profiles,
162
+ search_presets=search_presets,
163
+ )
164
+
165
+
166
+ def _dump_app_config(config: AppConfig) -> str:
167
+ lines: list[str] = []
168
+
169
+ if config.active_profile is not None:
170
+ lines.append(f"active_profile = {_toml_quote(config.active_profile)}")
171
+
172
+ for profile_name in sorted(config.profiles):
173
+ profile = config.profiles[profile_name]
174
+ lines.append("")
175
+ lines.append(f"[profiles.{_toml_quote(profile_name)}]")
176
+ if profile.base_url is not None:
177
+ lines.append(f"base_url = {_toml_quote(profile.base_url)}")
178
+ if profile.api_key_env is not None:
179
+ lines.append(f"api_key_env = {_toml_quote(profile.api_key_env)}")
180
+ if profile.default_workspace is not None:
181
+ lines.append(f"default_workspace = {_toml_quote(profile.default_workspace)}")
182
+
183
+ for preset_name in sorted(config.search_presets):
184
+ preset = config.search_presets[preset_name]
185
+ lines.append("")
186
+ lines.append(f"[search_presets.{_toml_quote(preset_name)}]")
187
+ lines.append(f"kql = {_toml_quote(preset.kql)}")
188
+ if preset.scope is not None:
189
+ lines.append(f"scope = {_toml_quote(preset.scope)}")
190
+ if preset.default_format is not None:
191
+ lines.append(f"default_format = {_toml_quote(preset.default_format)}")
192
+
193
+ return "\n".join(lines).rstrip() + "\n"
194
+
195
+
196
+ def _as_optional_string(
197
+ value: Any,
198
+ key: str,
199
+ entry_name: str,
200
+ *,
201
+ key_prefix: str = "profiles",
202
+ ) -> str | None:
203
+ if value is None:
204
+ return None
205
+ if not isinstance(value, str):
206
+ raise ValidationError(f"`{key_prefix}.{entry_name}.{key}` must be a string in config.toml")
207
+ return value
208
+
209
+
210
+ def _as_required_string(value: Any, key: str, entry_name: str) -> str:
211
+ if value is None:
212
+ raise ValidationError(f"`search_presets.{entry_name}.{key}` is required in config.toml")
213
+ if not isinstance(value, str):
214
+ raise ValidationError(
215
+ f"`search_presets.{entry_name}.{key}` must be a string in config.toml"
216
+ )
217
+ normalized = value.strip()
218
+ if not normalized:
219
+ raise ValidationError(
220
+ f"`search_presets.{entry_name}.{key}` must be a non-empty string in config.toml"
221
+ )
222
+ return normalized
223
+
224
+
225
+ def _toml_quote(value: str) -> str:
226
+ return json.dumps(value)
@@ -0,0 +1,141 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from kantree_cli.core.config import (
6
+ DEFAULT_API_KEY_ENV,
7
+ DEFAULT_BASE_URL,
8
+ DEFAULT_PROFILE,
9
+ AppConfig,
10
+ config_path,
11
+ load_config,
12
+ )
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ResolvedSettings:
17
+ profile_name: str
18
+ base_url: str
19
+ api_key_env: str
20
+ api_key_value: str | None
21
+ api_key_source: str
22
+ default_organization: str | None
23
+ default_organization_source: str | None
24
+ default_workspace: str | None
25
+ default_workspace_source: str | None
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class AppRuntime:
30
+ config_path: Path
31
+ config: AppConfig
32
+ profile_override: str | None
33
+ base_url_override: str | None
34
+ organization_override: str | None
35
+ workspace_id_override: int | None
36
+ workspace_name_override: str | None
37
+ output_format: str
38
+ output_fields: list[str] | None
39
+ verbose: bool
40
+ assume_yes: bool
41
+ debug: bool
42
+
43
+ def resolve(self) -> ResolvedSettings:
44
+ profile_name = (
45
+ self.profile_override
46
+ or os.getenv("KANTREE_PROFILE")
47
+ or self.config.active_profile
48
+ or DEFAULT_PROFILE
49
+ )
50
+
51
+ profile_config = self.config.profiles.get(profile_name)
52
+
53
+ base_url = (
54
+ self.base_url_override
55
+ or os.getenv("KANTREE_BASE_URL")
56
+ or (profile_config.base_url if profile_config else None)
57
+ or DEFAULT_BASE_URL
58
+ )
59
+ base_url = base_url.rstrip("/")
60
+
61
+ api_key_env = (
62
+ os.getenv("KANTREE_API_KEY_ENV")
63
+ or (profile_config.api_key_env if profile_config else None)
64
+ or DEFAULT_API_KEY_ENV
65
+ )
66
+
67
+ direct_api_key = os.getenv("KANTREE_API_KEY")
68
+ if direct_api_key:
69
+ api_key_value = direct_api_key
70
+ api_key_source = "env:KANTREE_API_KEY"
71
+ else:
72
+ api_key_value = os.getenv(api_key_env)
73
+ api_key_source = f"env:{api_key_env}"
74
+
75
+ default_organization = self.organization_override
76
+ default_organization_source = (
77
+ "flag:--org" if self.organization_override is not None else None
78
+ )
79
+
80
+ default_workspace: str | None = None
81
+ default_workspace_source: str | None = None
82
+ env_workspace_id = (os.getenv("KANTREE_WORKSPACE_ID") or "").strip()
83
+ env_workspace_name = (os.getenv("KANTREE_WORKSPACE") or "").strip()
84
+ if env_workspace_id:
85
+ default_workspace = env_workspace_id
86
+ default_workspace_source = "env:KANTREE_WORKSPACE_ID"
87
+ elif env_workspace_name:
88
+ default_workspace = env_workspace_name
89
+ default_workspace_source = "env:KANTREE_WORKSPACE"
90
+ elif profile_config and profile_config.default_workspace:
91
+ default_workspace = profile_config.default_workspace.strip() or None
92
+ if default_workspace:
93
+ default_workspace_source = f"profile:{profile_name}.default_workspace"
94
+
95
+ if self.workspace_id_override is not None:
96
+ default_workspace = str(self.workspace_id_override)
97
+ default_workspace_source = "flag:--workspace-id"
98
+ elif self.workspace_name_override is not None:
99
+ default_workspace = self.workspace_name_override
100
+ default_workspace_source = "flag:--workspace"
101
+
102
+ return ResolvedSettings(
103
+ profile_name=profile_name,
104
+ base_url=base_url,
105
+ api_key_env=api_key_env,
106
+ api_key_value=api_key_value,
107
+ api_key_source=api_key_source,
108
+ default_organization=default_organization,
109
+ default_organization_source=default_organization_source,
110
+ default_workspace=default_workspace,
111
+ default_workspace_source=default_workspace_source,
112
+ )
113
+
114
+
115
+ def build_runtime(
116
+ profile_override: str | None,
117
+ base_url_override: str | None,
118
+ organization_override: str | None,
119
+ workspace_id_override: int | None,
120
+ workspace_name_override: str | None,
121
+ output_format: str,
122
+ output_fields: list[str] | None,
123
+ verbose: bool,
124
+ assume_yes: bool,
125
+ debug: bool,
126
+ ) -> AppRuntime:
127
+ path = config_path()
128
+ return AppRuntime(
129
+ config_path=path,
130
+ config=load_config(path),
131
+ profile_override=profile_override,
132
+ base_url_override=base_url_override,
133
+ organization_override=organization_override,
134
+ workspace_id_override=workspace_id_override,
135
+ workspace_name_override=workspace_name_override,
136
+ output_format=output_format.lower(),
137
+ output_fields=output_fields,
138
+ verbose=verbose,
139
+ assume_yes=assume_yes,
140
+ debug=debug,
141
+ )
@@ -0,0 +1,32 @@
1
+ class KantreeError(Exception):
2
+ """Base class for normalized CLI errors with explicit exit codes."""
3
+
4
+ exit_code = 1
5
+
6
+
7
+ class AuthError(KantreeError):
8
+ exit_code = 3
9
+
10
+
11
+ class NotFoundError(KantreeError):
12
+ exit_code = 4
13
+
14
+
15
+ class AmbiguityError(KantreeError):
16
+ exit_code = 5
17
+
18
+
19
+ class ValidationError(KantreeError):
20
+ exit_code = 6
21
+
22
+
23
+ class ApiError(KantreeError):
24
+ exit_code = 7
25
+
26
+
27
+ class NetworkError(KantreeError):
28
+ exit_code = 8
29
+
30
+
31
+ class PreconditionError(KantreeError):
32
+ exit_code = 12
@@ -0,0 +1,64 @@
1
+ import json
2
+ from typing import Any, Iterable
3
+
4
+ import click
5
+
6
+
7
+ def emit_json(payload: Any) -> None:
8
+ click.echo(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True))
9
+
10
+
11
+ def emit_ids(values: Iterable[object]) -> None:
12
+ for value in values:
13
+ click.echo(str(value))
14
+
15
+
16
+ def emit_ndjson(values: Iterable[Any]) -> None:
17
+ for value in values:
18
+ click.echo(json.dumps(value, ensure_ascii=False, sort_keys=True))
19
+
20
+
21
+ def value_for_field(item: dict[str, Any], field: str) -> Any:
22
+ current: Any = item
23
+ for token in field.split("."):
24
+ if not isinstance(current, dict):
25
+ return None
26
+ current = current.get(token)
27
+ return current
28
+
29
+
30
+ def emit_tsv(items: list[dict[str, Any]], fields: list[str]) -> None:
31
+ click.echo("\t".join(fields))
32
+ for item in items:
33
+ row = [_stringify(value_for_field(item, field)) for field in fields]
34
+ click.echo("\t".join(row))
35
+
36
+
37
+ def emit_table(items: list[dict[str, Any]], fields: list[str]) -> None:
38
+ rows = [[_stringify(value_for_field(item, field)) for field in fields] for item in items]
39
+ widths = [len(field) for field in fields]
40
+ for row in rows:
41
+ for index, cell in enumerate(row):
42
+ widths[index] = max(widths[index], len(cell))
43
+
44
+ header = " | ".join(field.ljust(widths[index]) for index, field in enumerate(fields))
45
+ separator = "-+-".join("-" * width for width in widths)
46
+ click.echo(header)
47
+ click.echo(separator)
48
+ for row in rows:
49
+ click.echo(" | ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)))
50
+
51
+
52
+ def _stringify(value: Any) -> str:
53
+ if value is None:
54
+ return ""
55
+ if isinstance(value, bool):
56
+ return "true" if value else "false"
57
+ if isinstance(value, (int, float, str)):
58
+ return str(value)
59
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
60
+
61
+
62
+ def emit_error(message: str, *, exit_code: int) -> None:
63
+ click.echo(f"Error: {message}", err=True)
64
+ raise click.exceptions.Exit(exit_code)