artemis_framework 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (29) hide show
  1. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/PKG-INFO +1 -1
  2. artemis_framework-0.4.2/artemis_framework/asphodel/tui/widgets/key_list.py +371 -0
  3. artemis_framework-0.4.2/artemis_framework/asphodel/tui/widgets/operation_log.py +115 -0
  4. artemis_framework-0.4.2/artemis_framework/tartarus/__init__.py +0 -0
  5. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/pyproject.toml +1 -1
  6. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/LICENSE +0 -0
  7. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/README.md +0 -0
  8. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/__init__.py +0 -0
  9. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/__main__.py +0 -0
  10. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/__init__.py +0 -0
  11. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/__main__.py +0 -0
  12. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/core/__init__.py +0 -0
  13. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/core/_gpg_binary.py +0 -0
  14. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/core/armor_binary.py +0 -0
  15. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/core/encrypt_decrypt.py +0 -0
  16. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/core/gpg_context.py +0 -0
  17. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/core/key_management.py +0 -0
  18. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/core/macos_shim.py +0 -0
  19. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/core/sign_verify.py +0 -0
  20. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/tui/__init__.py +0 -0
  21. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/tui/app.py +0 -0
  22. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/tui/modals/__init__.py +0 -0
  23. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/tui/screens/__init__.py +0 -0
  24. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/tui/screens/app_config.py +0 -0
  25. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/tui/settings.py +0 -0
  26. {artemis_framework-0.4.0 → artemis_framework-0.4.2}/artemis_framework/asphodel/tui/widgets/__init__.py +0 -0
  27. /artemis_framework-0.4.0/artemis_framework/elysium/__ini → /artemis_framework-0.4.2/artemis_framework/asphodel/tui/widgets/key_detail.py +0 -0
  28. /artemis_framework-0.4.0/artemis_framework/elysium/__init__.py → /artemis_framework-0.4.2/artemis_framework/elysium/__ini +0 -0
  29. {artemis_framework-0.4.0/artemis_framework/tartarus → artemis_framework-0.4.2/artemis_framework/elysium}/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: artemis_framework
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: A package for doing great things!
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -0,0 +1,371 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import Callable
6
+
7
+ from rich.text import Text
8
+ from textual.app import ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.message import Message
11
+ from textual.widgets import DataTable, Label
12
+
13
+
14
+ @dataclass
15
+ class KeyInfo:
16
+ fingerprint: str
17
+ key_id: str
18
+ uids: list[str]
19
+ name: str
20
+ algorithm: str
21
+ expires: str
22
+ trust: str
23
+ has_secret: bool
24
+
25
+ @classmethod
26
+ def from_gnupg_dict(
27
+ cls,
28
+ pub_dict: dict,
29
+ secret_fingerprints: set[str]
30
+ ) -> "KeyInfo":
31
+ fp = pub_dict.get("fingerprint", "")
32
+ key_id = fp[-16:] if len(fp) >= 16 else fp
33
+ uids = pub_dict.get("uids", []) or ["<no UID>"]
34
+ name = _first_uid_name(uids[0])
35
+ algo = _format_algorithm(pub_dict)
36
+ expires = _format_expiry(pub_dict.get("expires", ""))
37
+ trust = _expand_trust(pub_dict.get("ownertrust", pub_dict.get("trust", "?")))
38
+ has_sec = fp in secret_fingerprints
39
+
40
+ return cls(
41
+ fingerprint=fp,
42
+ key_id=key_id,
43
+ uids=uids,
44
+ name=name,
45
+ algorithm=algo,
46
+ expires=expires,
47
+ trust=trust,
48
+ has_secret=has_sec
49
+ )
50
+
51
+
52
+ _COLUMNS: list[tuple[str, int]] = [
53
+ ("✦", 3),
54
+ ("Name / UID", 28),
55
+ ("Key ID", 18),
56
+ ("Algorithm", 12),
57
+ ("Expires", 12),
58
+ ("Trust", 14),
59
+ ]
60
+
61
+
62
+ class KeyListWidget(DataTable):
63
+
64
+ class CursorMoved(Message):
65
+ def __init__(self, key_info: KeyInfo) -> None:
66
+ super().__init__()
67
+ self.key_info = key_info
68
+
69
+ class KeyConfirmed(Message):
70
+ def __init__(self, key_info: KeyInfo) -> None:
71
+ super().__init__()
72
+ self.key_info = key_info
73
+
74
+ class SelectionChanged(Message):
75
+ def __init__(self, fingerprints: frozenset[str]) -> None:
76
+ super().__init__()
77
+ self.fingerprints = fingerprints
78
+
79
+ BINDINGS = [
80
+ Binding("space", "toggle_select", "Add » selection", show=True),
81
+ Binding("escape", "clear_select", "Clear selection", show=True),
82
+ Binding("enter", "confirm_key", "Confirm", show=True),
83
+ Binding("r", "refresh_table", "Refresh", show=True),
84
+ ]
85
+
86
+ DEFAULT_CSS = """
87
+ KeyListWidget {
88
+ height: 1fr;
89
+ border: double $primary-darken-1;
90
+ scrollbar-color: $primary;
91
+ scrollbar-color-hover: $primary-lighten-1;
92
+ background: $surface;
93
+ }
94
+ KeyListWidget:focus {
95
+ border: double $primary;
96
+ background: $background;
97
+ }
98
+ KeyListWidget > .datatable--header {
99
+ background: $primary-darken-2;
100
+ color: $text-muted;
101
+ text-style: bold;
102
+ }
103
+ KeyListWidget > .datatable--cursor {
104
+ background: $primary-darken-1;
105
+ color: $text;
106
+ }
107
+ KeyListWidget:focus > .datatable--cursor {
108
+ background: $primary;
109
+ color: $background;
110
+ text-style: bold;
111
+ }
112
+ KeyListWidget > .datatable--even-row {
113
+ background: $surface;
114
+ }
115
+ KeyListWidget > .datatable--odd-row {
116
+ background: $background;
117
+ }
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ gpg=None,
123
+ *,
124
+ confirm_callback: Callable[[KeyInfo], None] | None = None,
125
+ **kwargs
126
+ ) -> None:
127
+ super().__init__(
128
+ cursor_type="row",
129
+ zebra_stripes=True,
130
+ **kwargs
131
+ )
132
+ self._gpg = gpg
133
+ self._confirm_callback = confirm_callback
134
+
135
+ self._key_map: dict[str, KeyInfo] = {}
136
+ self._row_key_to_fp: dict[str, str] = {}
137
+ self._selected_fps: set[str] = set()
138
+
139
+ def on_mount(self) -> None:
140
+ for label, width in _COLUMNS:
141
+ self.add_column(label, width=width)
142
+ if self._gpg is not None:
143
+ self.load(self._gpg)
144
+
145
+ def load(self, gpg) -> None:
146
+ self._gpg = gpg
147
+ self.clear(columns=False)
148
+ self._key_map.clear()
149
+ self._row_key_to_fp.clear()
150
+ self._selected_fps.clear()
151
+
152
+ pub_keys = gpg.list_keys(False)
153
+ secret_fps = {k["fingerprint"] for k in gpg.list_keys(True)}
154
+
155
+ for pub_dict in pub_keys:
156
+ info = KeyInfo.from_gnupg_dict(pub_dict, secret_fps)
157
+ self._key_map[info.fingerprint] = info
158
+ row_key = info.fingerprint
159
+ self._row_key_to_fp[row_key] = info.fingerprint
160
+ self.add_row(
161
+ *self._build_row_cells(info, selected=False),
162
+ key=row_key
163
+ )
164
+
165
+ def refresh_keys(self, gpg=None) -> None:
166
+ self.load(gpg or self._gpg)
167
+
168
+ def get_selected(self) -> frozenset[str]:
169
+ return frozenset(self._selected_fps)
170
+
171
+ def get_cursor_key(self) -> KeyInfo | None:
172
+ return self._info_at_cursor()
173
+
174
+ def clear_selection(self) -> None:
175
+ self.action_clear_select()
176
+
177
+ def select_by_fingerprint(self, fingerprint: str) -> None:
178
+ if fingerprint in self._key_map:
179
+ self._selected_fps.add(fingerprint)
180
+ self._redraw_row(fingerprint)
181
+ self.post_message(self.SelectionChanged(self.get_selected()))
182
+
183
+ def on_data_table_row_highlighted(
184
+ self,
185
+ event: DataTable.RowHighlighted
186
+ ) -> None:
187
+ event.stop()
188
+ fp = self._row_key_to_fp.get(str(event.row_key.value), "")
189
+ info = self._key_map.get(fp)
190
+ if info:
191
+ self.post_message(self.CursorMoved(info))
192
+
193
+ def action_toggle_select(self) -> None:
194
+ info = self._info_at_cursor()
195
+ if info is None:
196
+ return
197
+ fp = info.fingerprint
198
+ if fp in self._selected_fps:
199
+ self._selected_fps.discard(fp)
200
+ else:
201
+ self._selected_fps.add(fp)
202
+ self._redraw_row(fp)
203
+ self.post_message(self.SelectionChanged(self.get_selected()))
204
+
205
+ def action_clear_select(self) -> None:
206
+ fps_to_redraw = set(self._selected_fps)
207
+ self._selected_fps.clear()
208
+ for fp in fps_to_redraw:
209
+ self._redraw_row(fp)
210
+ self.post_message(self.SelectionChanged(self.get_selected()))
211
+
212
+ def action_confirm_key(self) -> None:
213
+ info = self._info_at_cursor()
214
+ if info is None:
215
+ return
216
+ self.post_message(self.KeyConfirmed(info))
217
+ if self._confirm_callback:
218
+ self._confirm_callback(info)
219
+
220
+ def action_refresh_table(self) -> None:
221
+ if self._gpg:
222
+ self.load(self._gpg)
223
+
224
+ def _info_at_cursor(self) -> KeyInfo | None:
225
+ if self.row_count == 0:
226
+ return None
227
+ try:
228
+ fp_list = list(self._row_key_to_fp.values())
229
+ if self.cursor_row >= len(fp_list):
230
+ return None
231
+ fp = fp_list[self.cursor_row]
232
+ return self._key_map.get(fp)
233
+ except Exception:
234
+ return None
235
+
236
+ def _build_row_cells(
237
+ self,
238
+ info: KeyInfo,
239
+ *,
240
+ selected: bool
241
+ ) -> tuple[Text | str, ...]:
242
+ sel_marker = "▶" if selected else " "
243
+ sec_marker = "✦" if info.has_secret else "•"
244
+ indicator = Text(f"{sel_marker}{sec_marker}", no_wrap=True)
245
+ if selected:
246
+ indicator.stylize("bold yellow")
247
+ elif info.has_secret:
248
+ indicator.stylize("bold cyan")
249
+ else:
250
+ indicator.stylize("dim")
251
+
252
+ name_cell = Text(info.name[:26], no_wrap=True)
253
+ if info.has_secret:
254
+ name_cell.stylize("cyan")
255
+
256
+ key_id_cell = Text(info.key_id, no_wrap=True, style="dim")
257
+
258
+ algo_cell = Text(info.algorithm, no_wrap=True)
259
+
260
+ expires_cell = Text(info.expires, no_wrap=True)
261
+ if info.expires.lower() not in ("never", ""):
262
+ expires_cell.stylize("yellow")
263
+
264
+ trust_cell = Text(info.trust, no_wrap=True)
265
+ trust_cell.stylize(_trust_style(info.trust))
266
+
267
+ return (
268
+ indicator,
269
+ name_cell,
270
+ key_id_cell,
271
+ algo_cell,
272
+ expires_cell,
273
+ trust_cell,
274
+ )
275
+
276
+ def _redraw_row(self, fingerprint: str) -> None:
277
+ info = self._key_map.get(fingerprint)
278
+ if info is None:
279
+ return
280
+
281
+ selected = fingerprint in self._selected_fps
282
+ cells = self._build_row_cells(info, selected=selected)
283
+ col_keys = [col.key for col in self.columns.values()]
284
+ for col_key, cell_value in zip(col_keys, cells):
285
+ try:
286
+ self.update_cell(
287
+ row_key=fingerprint,
288
+ column_key=col_key,
289
+ value=cell_value,
290
+ update_width=False
291
+ )
292
+ except Exception:
293
+ pass
294
+
295
+
296
+ def _first_uid_name(uid: str) -> str:
297
+ m = re.match(r"^([^<(]+)", uid)
298
+ return m.group(1).strip() if m else uid.strip()
299
+
300
+
301
+ def _format_algorithm(key_dict: dict) -> str:
302
+ algo = key_dict.get("algo", "")
303
+ length = key_dict.get("length", "")
304
+ curve = key_dict.get("curve", "")
305
+
306
+ algo_names = {
307
+ "1": "RSA",
308
+ "2": "RSA",
309
+ "3": "RSA",
310
+ "17": "DSA",
311
+ "18": "ECDH",
312
+ "19": "ECDSA",
313
+ "22": "EdDSA",
314
+ }
315
+ name = algo_names.get(str(algo), str(algo))
316
+
317
+ if curve:
318
+ curve_short = {
319
+ "ed25519": "255",
320
+ "cv25519": "255",
321
+ "nistp256": "P256",
322
+ "nistp384": "P384",
323
+ "nistp521": "P521",
324
+ }.get(curve.lower(), curve)
325
+ return f"{name} {curve_short}"
326
+
327
+ if length:
328
+ return f"{name} {length}"
329
+
330
+ return name
331
+
332
+
333
+ def _format_expiry(expires: str) -> str:
334
+ if not expires:
335
+ return "never"
336
+ try:
337
+ import datetime
338
+ ts = int(expires)
339
+
340
+ return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
341
+ except (ValueError, OSError):
342
+ return expires
343
+
344
+
345
+ def _expand_trust(code: str) -> str:
346
+ return {
347
+ "u": "ultimate",
348
+ "f": "full",
349
+ "m": "marginal",
350
+ "n": "not trusted",
351
+ "e": "expired",
352
+ "r": "revoked",
353
+ "?": "unknown",
354
+ "-": "unknown",
355
+ "q": "undefined",
356
+ }.get(code.lower(), code)
357
+
358
+
359
+ def _trust_style(trust_label: str) -> str:
360
+ return {
361
+ "ultimate": "bold green",
362
+ "full": "green",
363
+ "marginal": "yellow",
364
+ "not trusted": "dim red",
365
+ "expired": "bold red",
366
+ "revoked": "bold red",
367
+ "unknown": "dim",
368
+ "undefined": "dim",
369
+ }.get(trust_label, "")
370
+
371
+
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from rich.text import Text
8
+ from textual.app import ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.widgets import RichLog
11
+
12
+
13
+ Level = Literal["OK", "ERR", "WARN", "INFO"]
14
+
15
+
16
+ _LEVEL_STYLE: dict[str, tuple[str, str]] = {
17
+ "OK": ("[OK] ", "green"),
18
+ "ERR": ("[ERR] ", "bold red"),
19
+ "WARN": ("[WARN] ", "yellow"),
20
+ "INFO": ("[INFO] ", "dim white"),
21
+ }
22
+
23
+ _LEVEL_ALIASES: dict[str, str] = {
24
+ "ok": "OK",
25
+ "err": "ERR",
26
+ "error": "ERR",
27
+ "warn": "WARN",
28
+ "warning": "WARN",
29
+ "info": "INFO",
30
+ }
31
+
32
+
33
+ def _normalize_level(level: str) -> str:
34
+ return _LEVEL_ALIASES.get(level.lower(), _LEVEL_ALIASES.get(level, "INFO"))
35
+
36
+
37
+ class OperationLogWidget(RichLog):
38
+ DEFAULT_CSS = ""
39
+ COMPONENT_CLASSES = {"operation-log"}
40
+
41
+ def __init__(self, **kwargs) -> None:
42
+ super().__init__(
43
+ highlight=False,
44
+ markup=False,
45
+ wrap=True,
46
+ **kwargs
47
+ )
48
+ self._plain_lines: list[str] = []
49
+
50
+ def log(self, message: str, level: str = "INFO") -> None:
51
+ canonical = _normalize_level(level)
52
+ prefix, color = _LEVEL_STYLE[canonical]
53
+ timestamp = _timestamp()
54
+
55
+ line = Text()
56
+ line.append(timestamp, style="dim")
57
+ line.append(" "*3)
58
+ line.append(prefix, style=color)
59
+ line.append(" "*3)
60
+ line.append(message, style=color if canonical == "ERR" else "")
61
+
62
+ plain = f"[{timestamp}] {prefix} {message}"
63
+ self._plain_lines.append(plain)
64
+
65
+ self.write(line)
66
+
67
+ def log_result(
68
+ self,
69
+ ok: bool,
70
+ label: str,
71
+ detail: str | None = None
72
+ ) -> None:
73
+ self.log(label, level="OK" if ok else "ERR")
74
+ if detail:
75
+ self.log(detail, level="INFO")
76
+
77
+ def log_separator(self, label: str = "") -> None:
78
+ width = 60
79
+ if label:
80
+ pad = max(0, (width - len(label) - 2) // 2)
81
+ text = f"{'-' * pad} {label} {'-'* pad}"
82
+ else:
83
+ text = "-" * width
84
+
85
+ line = Text(text, style="dim")
86
+ self._plain_lines.append(text)
87
+ self.write(line)
88
+
89
+ def clear(self) -> None:
90
+ super().clear()
91
+ self._plain_lines.clear()
92
+
93
+ def save(self, path: Path | str | None = None) -> Path | None:
94
+ if not self._plain_lines:
95
+ self.log("Log is empty - nothing to save.", level="WARN")
96
+ return None
97
+
98
+ if path is None:
99
+ stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
100
+ path = Path.cwd() / f"asphodel_pgp_log_{stamp}.log"
101
+
102
+ dest = Path(path)
103
+ try:
104
+ dest.parent.mkdir(parents=True, exist_ok=True)
105
+ dest.write_text("\n".join(self._plain_lines) + "\n", encoding="utf-8")
106
+
107
+ self.log(f"Log saved » {dest}", level="OK")
108
+ return dest
109
+ except OSError as exc:
110
+ self.log(f"Failed to save log: {exc}", level="ERR")
111
+ return None
112
+
113
+
114
+ def _timestamp() -> str:
115
+ return datetime.datetime.now().strftime("%H:%M:%S")
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "artemis_framework"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "A package for doing great things!"
5
5
  authors = ["Dylan Garrett"]
6
6
  license = "MIT"