subactor-shell 0.2.2__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,79 @@
1
+ """Safe terminal presentation for canonical Subactor resource links."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from collections.abc import Mapping
8
+ from urllib.parse import urlencode, urlsplit, urlunsplit
9
+
10
+ from rich.text import Text
11
+
12
+
13
+ _TICKET = re.compile(r"\bPLF-[0-9]{1,20}\b", re.IGNORECASE)
14
+ _FALSE = frozenset({"0", "false", "no", "off", "never"})
15
+ _TRUE = frozenset({"1", "true", "yes", "on", "always"})
16
+
17
+
18
+ def terminal_hyperlinks_enabled(
19
+ *,
20
+ is_terminal: bool,
21
+ env: Mapping[str, str] | None = None,
22
+ ) -> bool:
23
+ values = os.environ if env is None else env
24
+ if not is_terminal:
25
+ return False
26
+ preference = values.get("SUBACTOR_TERMINAL_HYPERLINKS", "auto").strip().lower()
27
+ if preference in _FALSE:
28
+ return False
29
+ if preference in _TRUE:
30
+ return True
31
+ if values.get("TERM", "").lower() == "dumb":
32
+ return False
33
+ return bool(
34
+ values.get("WT_SESSION")
35
+ or values.get("VTE_VERSION")
36
+ or values.get("KONSOLE_VERSION")
37
+ or values.get("KITTY_WINDOW_ID")
38
+ or re.search(r"^(vscode|wezterm|iterm\.app|ghostty)$", values.get("TERM_PROGRAM", ""), re.I)
39
+ or re.search(r"jetbrains|jediterm", values.get("TERMINAL_EMULATOR", ""), re.I)
40
+ or re.search(r"kitty|wezterm|foot|contour", values.get("TERM", ""), re.I)
41
+ )
42
+
43
+
44
+ def canonical_ticket_links(text: str, control_url: str, *, limit: int = 20) -> list[tuple[str, str]]:
45
+ parsed = urlsplit(control_url)
46
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
47
+ return []
48
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
49
+ return []
50
+
51
+ result: list[tuple[str, str]] = []
52
+ seen: set[str] = set()
53
+ for match in _TICKET.finditer(text):
54
+ ticket = match.group(0).upper()
55
+ if ticket in seen:
56
+ continue
57
+ seen.add(ticket)
58
+ query = urlencode({"tab": "delegation", "action": "view", "ticket": ticket, "filter": ticket})
59
+ url = urlunsplit((parsed.scheme, parsed.netloc, "/", query, ""))
60
+ result.append((ticket, url))
61
+ if len(result) >= limit:
62
+ break
63
+ return result
64
+
65
+
66
+ def ticket_link_lines(
67
+ text: str,
68
+ control_url: str,
69
+ *,
70
+ hyperlinks: bool,
71
+ ) -> list[Text]:
72
+ lines: list[Text] = []
73
+ for ticket, url in canonical_ticket_links(text, control_url):
74
+ line = Text(" ")
75
+ line.append(ticket, style="cyan")
76
+ line.append(": ")
77
+ line.append(url, style=f"link {url}" if hyperlinks else None)
78
+ lines.append(line)
79
+ return lines
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class TokenUsage:
10
+ input_tokens: int = 0
11
+ cached_input_tokens: int = 0
12
+ output_tokens: int = 0
13
+ estimated: bool = False
14
+
15
+ def to_dict(self) -> dict[str, Any]:
16
+ return {
17
+ "input_tokens": max(0, int(self.input_tokens)),
18
+ "cached_input_tokens": max(0, int(self.cached_input_tokens)),
19
+ "output_tokens": max(0, int(self.output_tokens)),
20
+ "estimated": bool(self.estimated),
21
+ }
22
+
23
+ def add(self, other: "TokenUsage") -> "TokenUsage":
24
+ return TokenUsage(
25
+ input_tokens=self.input_tokens + other.input_tokens,
26
+ cached_input_tokens=self.cached_input_tokens + other.cached_input_tokens,
27
+ output_tokens=self.output_tokens + other.output_tokens,
28
+ estimated=self.estimated or other.estimated,
29
+ )
30
+
31
+
32
+ def estimate_text_tokens(text: str) -> int:
33
+ """Tokenizer-independent estimate used only when an API returns no usage."""
34
+
35
+ if not text:
36
+ return 0
37
+ return max(1, math.ceil(len(text) / 3.6))
38
+
39
+
40
+ def estimate_messages_tokens(messages: list[dict[str, Any]]) -> int:
41
+ total = 2
42
+ for item in messages:
43
+ total += 4
44
+ total += estimate_text_tokens(str(item.get("role", "")))
45
+ total += estimate_text_tokens(str(item.get("content", "")))
46
+ return total
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable, Any
5
+ from urllib.parse import quote, unquote, urlsplit
6
+
7
+ import httpx
8
+
9
+
10
+ class VaultError(RuntimeError):
11
+ """Vault error that never includes request bodies or secret values."""
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class VaultRef:
16
+ mount: str
17
+ path: str
18
+ field: str
19
+
20
+ @classmethod
21
+ def parse(cls, value: str) -> "VaultRef":
22
+ parsed = urlsplit(value)
23
+ if parsed.scheme != "vault":
24
+ raise ValueError("Referencja Vault musi zaczynać się od vault://")
25
+ if parsed.username or parsed.password or parsed.query:
26
+ raise ValueError("Referencja Vault nie może zawierać danych logowania ani query")
27
+ mount = unquote(parsed.netloc).strip()
28
+ path = unquote(parsed.path).strip("/")
29
+ field = unquote(parsed.fragment).strip()
30
+ if not mount or not path or not field:
31
+ raise ValueError("Użyj formatu vault://MOUNT/SCIEZKA#POLE")
32
+ segments = [mount, *path.split("/"), field]
33
+ if any(not segment or segment in {".", ".."} for segment in segments):
34
+ raise ValueError("Nieprawidłowa ścieżka Vault")
35
+ return cls(mount=mount, path=path, field=field)
36
+
37
+ @property
38
+ def api_path(self) -> str:
39
+ encoded_mount = quote(self.mount, safe="")
40
+ encoded_path = "/".join(quote(part, safe="") for part in self.path.split("/"))
41
+ return f"/v1/{encoded_mount}/data/{encoded_path}"
42
+
43
+
44
+ class VaultClient:
45
+ def __init__(
46
+ self,
47
+ address: str,
48
+ token_loader: Callable[[], str],
49
+ *,
50
+ namespace: str = "",
51
+ verify_tls: bool = True,
52
+ timeout_seconds: float = 10.0,
53
+ transport: httpx.BaseTransport | None = None,
54
+ ):
55
+ self.address = address.rstrip("/")
56
+ if not self.address.startswith(("http://", "https://")):
57
+ raise ValueError("vault.address musi być adresem http:// lub https://")
58
+ self._token_loader = token_loader
59
+ self.namespace = namespace.strip()
60
+ self.verify_tls = verify_tls
61
+ self.timeout_seconds = timeout_seconds
62
+ self.transport = transport
63
+
64
+ def _headers(self, *, wrap_ttl: str = "") -> dict[str, str]:
65
+ token = self._token_loader().strip()
66
+ if not token:
67
+ raise VaultError("Token Vault jest pusty")
68
+ headers = {"X-Vault-Token": token}
69
+ if self.namespace:
70
+ headers["X-Vault-Namespace"] = self.namespace
71
+ if wrap_ttl:
72
+ headers["X-Vault-Wrap-TTL"] = wrap_ttl
73
+ return headers
74
+
75
+ def _client(self) -> httpx.Client:
76
+ return httpx.Client(
77
+ base_url=self.address,
78
+ verify=self.verify_tls,
79
+ timeout=self.timeout_seconds,
80
+ transport=self.transport,
81
+ )
82
+
83
+ @staticmethod
84
+ def _raise_status(response: httpx.Response, operation: str) -> None:
85
+ request_id = response.headers.get("x-vault-request-id", "")
86
+ suffix = f", request_id={request_id}" if request_id else ""
87
+ raise VaultError(f"Vault: {operation} zakończone HTTP {response.status_code}{suffix}")
88
+
89
+ def read_field(self, reference: str | VaultRef) -> str:
90
+ ref = reference if isinstance(reference, VaultRef) else VaultRef.parse(reference)
91
+ try:
92
+ with self._client() as client:
93
+ response = client.get(ref.api_path, headers=self._headers())
94
+ except httpx.HTTPError as exc:
95
+ raise VaultError(f"Vault: błąd połączenia podczas odczytu ({type(exc).__name__})") from exc
96
+ if response.status_code != 200:
97
+ self._raise_status(response, "odczyt")
98
+ try:
99
+ value = response.json()["data"]["data"][ref.field]
100
+ except (KeyError, TypeError, ValueError) as exc:
101
+ raise VaultError(f"Vault: brak pola '{ref.field}' w odpowiedzi KV v2") from exc
102
+ if not isinstance(value, (str, int, float, bool)):
103
+ raise VaultError(f"Vault: pole '{ref.field}' nie jest wartością skalarną")
104
+ return str(value)
105
+
106
+ def write_field(self, reference: str | VaultRef, value: str) -> None:
107
+ ref = reference if isinstance(reference, VaultRef) else VaultRef.parse(reference)
108
+ headers = self._headers()
109
+ headers["Content-Type"] = "application/merge-patch+json"
110
+ try:
111
+ with self._client() as client:
112
+ patch = client.patch(ref.api_path, headers=headers, json={"data": {ref.field: value}})
113
+ if patch.status_code in {200, 204}:
114
+ return
115
+ if patch.status_code not in {400, 403, 404, 405}:
116
+ self._raise_status(patch, "zapis PATCH")
117
+
118
+ # Bezpieczny fallback: odczyt i zapis z CAS. Nie nadpisujemy
119
+ # pozostałych pól na ślepo, gdy PATCH jest wyłączony.
120
+ read = client.get(ref.api_path, headers=self._headers())
121
+ if read.status_code == 404:
122
+ current: dict[str, Any] = {}
123
+ version = 0
124
+ elif read.status_code == 200:
125
+ payload = read.json().get("data", {})
126
+ current = dict(payload.get("data", {}))
127
+ version = int(payload.get("metadata", {}).get("version", 0))
128
+ else:
129
+ self._raise_status(read, "odczyt przed zapisem CAS")
130
+ current[ref.field] = value
131
+ post = client.post(
132
+ ref.api_path,
133
+ headers=self._headers(),
134
+ json={"options": {"cas": version}, "data": current},
135
+ )
136
+ if post.status_code not in {200, 204}:
137
+ self._raise_status(post, "zapis CAS")
138
+ except httpx.HTTPError as exc:
139
+ raise VaultError(f"Vault: błąd połączenia podczas zapisu ({type(exc).__name__})") from exc
140
+
141
+ def wrap_read(self, reference: str | VaultRef, ttl: str = "5m") -> str:
142
+ """Return a one-time Vault wrapping token for the whole KV response."""
143
+ ref = reference if isinstance(reference, VaultRef) else VaultRef.parse(reference)
144
+ if not ttl or len(ttl) > 16:
145
+ raise ValueError("Nieprawidłowy TTL wrappingu")
146
+ try:
147
+ with self._client() as client:
148
+ response = client.get(ref.api_path, headers=self._headers(wrap_ttl=ttl))
149
+ except httpx.HTTPError as exc:
150
+ raise VaultError(f"Vault: błąd połączenia podczas wrappingu ({type(exc).__name__})") from exc
151
+ if response.status_code != 200:
152
+ self._raise_status(response, "response wrapping")
153
+ try:
154
+ token = response.json()["wrap_info"]["token"]
155
+ except (KeyError, TypeError, ValueError) as exc:
156
+ raise VaultError("Vault: odpowiedź nie zawiera wrapping tokenu") from exc
157
+ if not isinstance(token, str) or not token:
158
+ raise VaultError("Vault: pusty wrapping token")
159
+ return token
160
+
161
+ def health(self) -> tuple[bool, str]:
162
+ try:
163
+ with self._client() as client:
164
+ response = client.get("/v1/sys/health")
165
+ except httpx.HTTPError as exc:
166
+ return False, f"błąd połączenia ({type(exc).__name__})"
167
+ # 200 active, 429 standby, 472/473 DR/performance standby są osiągalne.
168
+ if response.status_code in {200, 429, 472, 473}:
169
+ return True, f"HTTP {response.status_code}"
170
+ return False, f"HTTP {response.status_code}"