kstlib 1.0.2__py3-none-any.whl → 1.1.1__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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Annotated
5
+ from typing import TYPE_CHECKING, Annotated
6
6
 
7
7
  import typer
8
8
  from rich.table import Table
@@ -10,6 +10,150 @@ from rich.table import Table
10
10
  from kstlib.cli.common import console
11
11
  from kstlib.rapi import load_rapi_config
12
12
 
13
+ if TYPE_CHECKING:
14
+ from kstlib.rapi.config import ApiConfig, EndpointConfig
15
+
16
+
17
+ def _matches_filter(
18
+ filter_terms: list[str],
19
+ ref: str,
20
+ method: str,
21
+ path: str,
22
+ description: str | None,
23
+ ) -> bool:
24
+ """Check if endpoint matches all filter terms (AND logic).
25
+
26
+ Args:
27
+ filter_terms: List of lowercase search terms.
28
+ ref: Endpoint reference (e.g., "github.repos").
29
+ method: HTTP method (e.g., "GET").
30
+ path: Endpoint path.
31
+ description: Optional endpoint description.
32
+
33
+ Returns:
34
+ True if all terms match any field.
35
+ """
36
+ searchable = f"{ref} {method} {path} {description or ''}".lower()
37
+ return all(term in searchable for term in filter_terms)
38
+
39
+
40
+ def _build_query_body_display(
41
+ ep_config: EndpointConfig,
42
+ method: str,
43
+ ) -> tuple[str, str]:
44
+ """Build query and body column displays.
45
+
46
+ Args:
47
+ ep_config: Endpoint configuration.
48
+ method: HTTP method.
49
+
50
+ Returns:
51
+ Tuple of (query_display, body_display).
52
+ """
53
+ query_display = f"[yellow]{len(ep_config.query)}[/]" if ep_config.query else "-"
54
+ body_display = "-"
55
+ if ep_config.body_template and method in ("POST", "PUT", "PATCH"):
56
+ body_display = f"[green]{len(ep_config.body_template)}[/]"
57
+ return query_display, body_display
58
+
59
+
60
+ def _build_compact_indicator(ep_config: EndpointConfig, method: str) -> str:
61
+ """Build compact param indicator for non-verbose mode.
62
+
63
+ Args:
64
+ ep_config: Endpoint configuration.
65
+ method: HTTP method.
66
+
67
+ Returns:
68
+ Formatted indicator string (e.g., " (4) (2)").
69
+ """
70
+ indicator = ""
71
+ if ep_config.query:
72
+ indicator += f" [yellow]({len(ep_config.query)})[/]"
73
+ if ep_config.body_template and method in ("POST", "PUT", "PATCH"):
74
+ indicator += f" [green]({len(ep_config.body_template)})[/]"
75
+ return indicator
76
+
77
+
78
+ def _add_endpoint_row(
79
+ table: Table,
80
+ ep_config: EndpointConfig,
81
+ api_name: str,
82
+ verbose: bool,
83
+ *,
84
+ short_desc: bool = False,
85
+ ) -> None:
86
+ """Add a single endpoint row to the table.
87
+
88
+ Args:
89
+ table: Rich table to add row to.
90
+ ep_config: Endpoint configuration.
91
+ api_name: Parent API name.
92
+ verbose: Whether to show verbose columns.
93
+ short_desc: Whether to truncate description (verbose mode only).
94
+ """
95
+ ref = f"{api_name}.{ep_config.name}"
96
+ method = ep_config.method.upper()
97
+ path_display = f"[dim]{ep_config.path}[/]"
98
+
99
+ if verbose:
100
+ query_display, body_display = _build_query_body_display(ep_config, method)
101
+ description = ep_config.description or ""
102
+ desc_display = (description[:40] + "...") if short_desc and len(description) > 43 else description
103
+ table.add_row(ref, method, path_display, query_display, body_display, desc_display)
104
+ else:
105
+ indicator = _build_compact_indicator(ep_config, method)
106
+ table.add_row(ref, method, path_display + indicator)
107
+
108
+
109
+ def _create_table(verbose: bool) -> Table:
110
+ """Create the endpoints table with appropriate columns.
111
+
112
+ Args:
113
+ verbose: Whether to include verbose columns.
114
+
115
+ Returns:
116
+ Configured Rich table.
117
+ """
118
+ table = Table(title="Available Endpoints", show_lines=False)
119
+ table.add_column("Reference", style="cyan")
120
+ table.add_column("Method", style="green", justify="center")
121
+ table.add_column("Path")
122
+
123
+ if verbose:
124
+ table.add_column("Query", justify="center")
125
+ table.add_column("Body", justify="center")
126
+ table.add_column("Description", style="dim")
127
+
128
+ return table
129
+
130
+
131
+ def _collect_matching_endpoints(
132
+ apis: dict[str, ApiConfig],
133
+ filter_terms: list[str],
134
+ ) -> list[tuple[str, EndpointConfig]]:
135
+ """Collect endpoints matching the filter.
136
+
137
+ Args:
138
+ apis: Dictionary of API configurations.
139
+ filter_terms: List of lowercase filter terms.
140
+
141
+ Returns:
142
+ List of (api_name, endpoint_config) tuples.
143
+ """
144
+ results = []
145
+ for api_name, api_config in sorted(apis.items()):
146
+ for ep_name, ep_config in sorted(api_config.endpoints.items()):
147
+ ref = f"{api_name}.{ep_name}"
148
+ method = ep_config.method.upper()
149
+ description = ep_config.description or ""
150
+
151
+ if filter_terms and not _matches_filter(filter_terms, ref, method, ep_config.path, description):
152
+ continue
153
+
154
+ results.append((api_name, ep_config))
155
+ return results
156
+
13
157
 
14
158
  def list_endpoints(
15
159
  api: Annotated[
@@ -21,7 +165,22 @@ def list_endpoints(
21
165
  typer.Option(
22
166
  "--verbose",
23
167
  "-v",
24
- help="Show additional details (method, auth, headers).",
168
+ help="Show additional details (method, description, query params).",
169
+ ),
170
+ ] = False,
171
+ filter_str: Annotated[
172
+ str | None,
173
+ typer.Option(
174
+ "--filter",
175
+ "-f",
176
+ help="Filter endpoints by keyword(s). Searches in ref, method, path, description.",
177
+ ),
178
+ ] = None,
179
+ short_desc: Annotated[
180
+ bool,
181
+ typer.Option(
182
+ "--short-desc",
183
+ help="Truncate descriptions to 40 chars (verbose mode only).",
25
184
  ),
26
185
  ] = False,
27
186
  ) -> None:
@@ -34,8 +193,20 @@ def list_endpoints(
34
193
  # List endpoints for specific API
35
194
  kstlib rapi list github
36
195
 
37
- # Verbose output with methods and auth
196
+ # Filter by keyword (searches everywhere)
197
+ kstlib rapi list --filter "delete"
198
+
199
+ # Multiple keywords (AND logic)
200
+ kstlib rapi list --filter "annotation GET"
201
+
202
+ # Combine API filter with keyword filter
203
+ kstlib rapi list annotations --filter "member"
204
+
205
+ # Verbose output with method, description, query params
38
206
  kstlib rapi list -v
207
+
208
+ # Show details for specific endpoint (use 'rapi show')
209
+ kstlib rapi show annotations.create
39
210
  """
40
211
  try:
41
212
  config_manager = load_rapi_config()
@@ -58,42 +229,33 @@ def list_endpoints(
58
229
  raise typer.Exit(code=1)
59
230
  apis = {api: apis[api]}
60
231
 
61
- # Build table
62
- if verbose:
63
- table = Table(title="Available Endpoints", show_lines=True)
64
- table.add_column("Reference", style="cyan")
65
- table.add_column("Method", style="green")
66
- table.add_column("Path")
67
- table.add_column("Query", style="yellow")
68
- else:
69
- table = Table(title="Available Endpoints")
70
- table.add_column("Reference", style="cyan")
71
- table.add_column("Path")
232
+ # Parse filter terms
233
+ filter_terms = filter_str.lower().split() if filter_str else []
72
234
 
73
- for api_name, api_config in sorted(apis.items()):
74
- for ep_name, ep_config in sorted(api_config.endpoints.items()):
75
- ref = f"{api_name}.{ep_name}"
235
+ # Collect matching endpoints
236
+ matches = _collect_matching_endpoints(apis, filter_terms)
76
237
 
77
- # Build path display with query param indicator
78
- path_display = f"[dim]{ep_config.path}[/]"
79
- if ep_config.query:
80
- path_display += f" [yellow]({len(ep_config.query)})[/]"
81
-
82
- if verbose:
83
- method = ep_config.method.upper()
84
- # Show query param keys or "-"
85
- query_info = ", ".join(ep_config.query.keys()) if ep_config.query else "-"
238
+ if not matches:
239
+ if filter_terms:
240
+ console.print(f"[yellow]No endpoints matching '{filter_str}'[/]")
241
+ else:
242
+ console.print("[yellow]No endpoints found.[/]")
243
+ raise typer.Exit(code=0)
86
244
 
87
- table.add_row(ref, method, path_display, query_info)
88
- else:
89
- table.add_row(ref, path_display)
245
+ # Build and populate table
246
+ table = _create_table(verbose)
247
+ for api_name, ep_config in matches:
248
+ _add_endpoint_row(table, ep_config, api_name, verbose, short_desc=short_desc)
90
249
 
91
250
  console.print(table)
92
251
 
93
252
  # Summary
94
253
  total_apis = len(apis)
95
- total_endpoints = sum(len(api.endpoints) for api in apis.values())
96
- console.print(f"\n[dim]{total_endpoints} endpoints across {total_apis} API(s)[/]")
254
+ total_endpoints = sum(len(a.endpoints) for a in apis.values())
255
+ if filter_terms:
256
+ console.print(f"\n[dim]{len(matches)} matching / {total_endpoints} total endpoints[/]")
257
+ else:
258
+ console.print(f"\n[dim]{total_endpoints} endpoints across {total_apis} API(s)[/]")
97
259
 
98
260
 
99
261
  __all__ = ["list_endpoints"]
@@ -222,6 +222,48 @@ def run_sops_command(binary: str, arguments: list[str]) -> CompletedProcess[str]
222
222
  )
223
223
 
224
224
 
225
+ def find_sops_config(start_path: Path | None = None) -> Path | None:
226
+ """Find .sops.yaml by searching from start_path up to root, then home.
227
+
228
+ This mimics the native sops behavior which searches for configuration
229
+ files starting from the current directory and walking up to the root,
230
+ then falling back to the user home directory.
231
+
232
+ Args:
233
+ start_path: Directory to start searching from. If None, uses cwd.
234
+
235
+ Returns:
236
+ Path to .sops.yaml if found, None otherwise.
237
+ """
238
+ config_name = ".sops.yaml"
239
+
240
+ # Start from provided path or current working directory
241
+ if start_path is not None:
242
+ current = start_path.resolve()
243
+ if current.is_file():
244
+ current = current.parent
245
+ else:
246
+ current = Path.cwd()
247
+
248
+ # Walk up the directory tree
249
+ while True:
250
+ candidate = current / config_name
251
+ if candidate.is_file():
252
+ return candidate
253
+ parent = current.parent
254
+ if parent == current:
255
+ # Reached root
256
+ break
257
+ current = parent
258
+
259
+ # Fallback to home directory
260
+ home_config = Path.home() / config_name
261
+ if home_config.is_file():
262
+ return home_config
263
+
264
+ return None
265
+
266
+
225
267
  def resolve_sops_binary() -> str:
226
268
  """Return the configured sops binary name if set, otherwise the default."""
227
269
  default_binary = "sops"
@@ -418,6 +460,7 @@ __all__ = [
418
460
  "Path",
419
461
  "SecureDeleteCLIOptions",
420
462
  "ShredCommandOptions",
463
+ "find_sops_config",
421
464
  "format_arguments",
422
465
  "resolve_sops_binary",
423
466
  "run_sops_command",
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pathlib import Path
6
5
  from typing import TYPE_CHECKING
7
6
 
8
7
  import typer
@@ -31,7 +30,9 @@ from .common import (
31
30
  SHRED_PASSES_OPTION,
32
31
  SHRED_ZERO_LAST_OPTION,
33
32
  EncryptCommandOptions,
33
+ Path,
34
34
  SecureDeleteCLIOptions,
35
+ find_sops_config,
35
36
  format_arguments,
36
37
  resolve_sops_binary,
37
38
  run_sops_command,
@@ -213,9 +214,8 @@ def encrypt(
213
214
  binary = resolve_sops_binary()
214
215
  effective_config = config
215
216
  if effective_config is None:
216
- default_config = Path.home() / ".sops.yaml"
217
- if default_config.exists():
218
- effective_config = default_config
217
+ # Search for .sops.yaml from source file directory up to root, then home
218
+ effective_config = find_sops_config(source)
219
219
 
220
220
  options = EncryptCommandOptions(
221
221
  out=out,
kstlib/kstlib.conf.yml CHANGED
@@ -623,6 +623,17 @@ rapi:
623
623
  retry_delay: 1.0 # Initial delay between retries (seconds)
624
624
  retry_backoff: 2.0 # Exponential backoff multiplier
625
625
 
626
+ # Safeguard configuration for dangerous HTTP methods
627
+ # Endpoints using these methods MUST define a safeguard string
628
+ # to prevent accidental destructive operations
629
+ safeguard:
630
+ # HTTP methods that require a safeguard to be defined on endpoints
631
+ # Default: DELETE and PUT (most destructive operations)
632
+ # Set to empty list [] to disable safeguard requirements
633
+ required_methods:
634
+ - DELETE
635
+ - PUT
636
+
626
637
  # Pretty-print settings for CLI output
627
638
  # Controls formatting of JSON and XML responses in terminal
628
639
  pretty_render:
kstlib/meta.py CHANGED
@@ -39,7 +39,7 @@ __logo__ = (
39
39
  )
40
40
 
41
41
  __app_name__ = "kstlib"
42
- __version__ = "1.0.2"
42
+ __version__ = "1.1.1"
43
43
  __description__ = (
44
44
  "Config-driven helpers for Python projects (dynamic config, secure secrets, preset logging, and more…)"
45
45
  )
kstlib/rapi/__init__.py CHANGED
@@ -85,26 +85,32 @@ from kstlib.rapi.config import (
85
85
  EndpointConfig,
86
86
  HmacConfig,
87
87
  RapiConfigManager,
88
+ SafeguardConfig,
88
89
  load_rapi_config,
89
90
  )
90
91
  from kstlib.rapi.credentials import CredentialRecord, CredentialResolver
91
92
  from kstlib.rapi.exceptions import (
93
+ ConfirmationRequiredError,
92
94
  CredentialError,
93
95
  EndpointAmbiguousError,
94
96
  EndpointNotFoundError,
97
+ EnvVarError,
95
98
  RapiError,
96
99
  RequestError,
97
100
  ResponseTooLargeError,
101
+ SafeguardMissingError,
98
102
  )
99
103
 
100
104
  __all__ = [
101
105
  "ApiConfig",
106
+ "ConfirmationRequiredError",
102
107
  "CredentialError",
103
108
  "CredentialRecord",
104
109
  "CredentialResolver",
105
110
  "EndpointAmbiguousError",
106
111
  "EndpointConfig",
107
112
  "EndpointNotFoundError",
113
+ "EnvVarError",
108
114
  "HmacConfig",
109
115
  "RapiClient",
110
116
  "RapiConfigManager",
@@ -112,6 +118,8 @@ __all__ = [
112
118
  "RapiResponse",
113
119
  "RequestError",
114
120
  "ResponseTooLargeError",
121
+ "SafeguardConfig",
122
+ "SafeguardMissingError",
115
123
  "call",
116
124
  "call_async",
117
125
  "load_rapi_config",
kstlib/rapi/client.py CHANGED
@@ -27,6 +27,7 @@ from kstlib.rapi.config import (
27
27
  )
28
28
  from kstlib.rapi.credentials import CredentialRecord, CredentialResolver
29
29
  from kstlib.rapi.exceptions import (
30
+ ConfirmationRequiredError,
30
31
  RequestError,
31
32
  ResponseTooLargeError,
32
33
  )
@@ -45,6 +46,37 @@ def _log_trace(msg: str, *args: Any) -> None:
45
46
  log.log(TRACE_LEVEL, msg, *args)
46
47
 
47
48
 
49
+ def _validate_safeguard(
50
+ endpoint_config: EndpointConfig,
51
+ args: tuple[Any, ...],
52
+ kwargs: dict[str, Any],
53
+ confirm: str | None,
54
+ ) -> None:
55
+ """Validate safeguard confirmation for dangerous endpoints.
56
+
57
+ Args:
58
+ endpoint_config: Endpoint configuration.
59
+ args: Positional arguments for path/safeguard substitution.
60
+ kwargs: Keyword arguments for path/safeguard substitution.
61
+ confirm: Confirmation string provided by caller.
62
+
63
+ Raises:
64
+ ConfirmationRequiredError: If safeguard is required but confirm is missing or wrong.
65
+ """
66
+ if endpoint_config.safeguard is None:
67
+ return
68
+
69
+ expected = endpoint_config.build_safeguard(*args, **kwargs)
70
+ if expected is None:
71
+ return
72
+
73
+ if confirm is None:
74
+ raise ConfirmationRequiredError(endpoint_config.full_ref, expected=expected)
75
+
76
+ if confirm != expected:
77
+ raise ConfirmationRequiredError(endpoint_config.full_ref, expected=expected, actual=confirm)
78
+
79
+
48
80
  @dataclass
49
81
  class RapiResponse:
50
82
  """Response from an API call.
@@ -238,6 +270,7 @@ class RapiClient:
238
270
  body: Any = None,
239
271
  headers: Mapping[str, str] | None = None,
240
272
  timeout: float | None = None,
273
+ confirm: str | None = None,
241
274
  **kwargs: Any,
242
275
  ) -> RapiResponse:
243
276
  """Make a synchronous API call.
@@ -248,12 +281,14 @@ class RapiClient:
248
281
  body: Request body (dict for JSON, str for raw).
249
282
  headers: Runtime headers (override service/endpoint headers).
250
283
  timeout: Request timeout (uses config default if None).
284
+ confirm: Confirmation string for dangerous endpoints with safeguard.
251
285
  **kwargs: Keyword arguments for path parameters and query params.
252
286
 
253
287
  Returns:
254
288
  RapiResponse with parsed data.
255
289
 
256
290
  Raises:
291
+ ConfirmationRequiredError: If safeguard requires confirmation.
257
292
  RequestError: If request fails after retries.
258
293
  ResponseTooLargeError: If response exceeds max size.
259
294
 
@@ -262,6 +297,7 @@ class RapiClient:
262
297
  >>> client.call("httpbin.get_ip") # doctest: +SKIP
263
298
  >>> client.call("httpbin.delayed", 5) # doctest: +SKIP
264
299
  >>> client.call("httpbin.post_data", body={"key": "value"}) # doctest: +SKIP
300
+ >>> client.call("admin.delete_user", userId="123", confirm="DELETE USER 123") # doctest: +SKIP
265
301
  """
266
302
  log.debug("Calling endpoint: %s", endpoint_ref)
267
303
 
@@ -269,6 +305,9 @@ class RapiClient:
269
305
  api_config, endpoint_config = self._config_manager.resolve(endpoint_ref)
270
306
  _log_trace("Resolved to: %s", endpoint_config.full_ref)
271
307
 
308
+ # Validate safeguard before proceeding
309
+ _validate_safeguard(endpoint_config, args, kwargs, confirm)
310
+
272
311
  # Build request
273
312
  request = self._build_request(
274
313
  api_config,
@@ -290,6 +329,7 @@ class RapiClient:
290
329
  body: Any = None,
291
330
  headers: Mapping[str, str] | None = None,
292
331
  timeout: float | None = None,
332
+ confirm: str | None = None,
293
333
  **kwargs: Any,
294
334
  ) -> RapiResponse:
295
335
  """Make an asynchronous API call.
@@ -300,12 +340,14 @@ class RapiClient:
300
340
  body: Request body (dict for JSON, str for raw).
301
341
  headers: Runtime headers (override service/endpoint headers).
302
342
  timeout: Request timeout (uses config default if None).
343
+ confirm: Confirmation string for dangerous endpoints with safeguard.
303
344
  **kwargs: Keyword arguments for path parameters and query params.
304
345
 
305
346
  Returns:
306
347
  RapiResponse with parsed data.
307
348
 
308
349
  Raises:
350
+ ConfirmationRequiredError: If safeguard requires confirmation.
309
351
  RequestError: If request fails after retries.
310
352
  ResponseTooLargeError: If response exceeds max size.
311
353
  """
@@ -315,6 +357,9 @@ class RapiClient:
315
357
  api_config, endpoint_config = self._config_manager.resolve(endpoint_ref)
316
358
  _log_trace("Resolved to: %s", endpoint_config.full_ref)
317
359
 
360
+ # Validate safeguard before proceeding
361
+ _validate_safeguard(endpoint_config, args, kwargs, confirm)
362
+
318
363
  # Build request
319
364
  request = self._build_request(
320
365
  api_config,
@@ -814,6 +859,7 @@ def call(
814
859
  *args: Any,
815
860
  body: Any = None,
816
861
  headers: Mapping[str, str] | None = None,
862
+ confirm: str | None = None,
817
863
  **kwargs: Any,
818
864
  ) -> RapiResponse:
819
865
  """Convenience function for quick API calls.
@@ -825,6 +871,7 @@ def call(
825
871
  *args: Positional path parameters.
826
872
  body: Request body.
827
873
  headers: Runtime headers.
874
+ confirm: Confirmation string for dangerous endpoints with safeguard.
828
875
  **kwargs: Keyword parameters.
829
876
 
830
877
  Returns:
@@ -835,7 +882,7 @@ def call(
835
882
  >>> response = call("httpbin.get_ip") # doctest: +SKIP
836
883
  """
837
884
  client = RapiClient()
838
- return client.call(endpoint_ref, *args, body=body, headers=headers, **kwargs)
885
+ return client.call(endpoint_ref, *args, body=body, headers=headers, confirm=confirm, **kwargs)
839
886
 
840
887
 
841
888
  async def call_async(
@@ -843,6 +890,7 @@ async def call_async(
843
890
  *args: Any,
844
891
  body: Any = None,
845
892
  headers: Mapping[str, str] | None = None,
893
+ confirm: str | None = None,
846
894
  **kwargs: Any,
847
895
  ) -> RapiResponse:
848
896
  """Convenience function for async API calls.
@@ -854,6 +902,7 @@ async def call_async(
854
902
  *args: Positional path parameters.
855
903
  body: Request body.
856
904
  headers: Runtime headers.
905
+ confirm: Confirmation string for dangerous endpoints with safeguard.
857
906
  **kwargs: Keyword parameters.
858
907
 
859
908
  Returns:
@@ -864,7 +913,7 @@ async def call_async(
864
913
  >>> response = await call_async("httpbin.get_ip") # doctest: +SKIP
865
914
  """
866
915
  client = RapiClient()
867
- return await client.call_async(endpoint_ref, *args, body=body, headers=headers, **kwargs)
916
+ return await client.call_async(endpoint_ref, *args, body=body, headers=headers, confirm=confirm, **kwargs)
868
917
 
869
918
 
870
919
  __all__ = [