artemis_framework 0.3.2__tar.gz → 0.4.0__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 (26) hide show
  1. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/PKG-INFO +1 -1
  2. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/tui/app.py +2 -2
  3. artemis_framework-0.4.0/artemis_framework/asphodel/tui/screens/app_config.py +434 -0
  4. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/pyproject.toml +1 -1
  5. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/LICENSE +0 -0
  6. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/README.md +0 -0
  7. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/__init__.py +0 -0
  8. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/__main__.py +0 -0
  9. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/__init__.py +0 -0
  10. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/__main__.py +0 -0
  11. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/core/__init__.py +0 -0
  12. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/core/_gpg_binary.py +0 -0
  13. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/core/armor_binary.py +0 -0
  14. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/core/encrypt_decrypt.py +0 -0
  15. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/core/gpg_context.py +0 -0
  16. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/core/key_management.py +0 -0
  17. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/core/macos_shim.py +0 -0
  18. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/core/sign_verify.py +0 -0
  19. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/tui/__init__.py +0 -0
  20. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/tui/modals/__init__.py +0 -0
  21. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/tui/screens/__init__.py +0 -0
  22. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/tui/settings.py +0 -0
  23. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/asphodel/tui/widgets/__init__.py +0 -0
  24. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/elysium/__ini +0 -0
  25. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/elysium/__init__.py +0 -0
  26. {artemis_framework-0.3.2 → artemis_framework-0.4.0}/artemis_framework/tartarus/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: artemis_framework
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A package for doing great things!
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -239,8 +239,8 @@ _WELCOME_TEXT = """\
239
239
 
240
240
  [b]K[/b] → Key Management [i](generate, import, export, delete keys)[/i]
241
241
  [b]E[/b] → Encrypt / Decrypt [i](encrypt, decrypt, sign, verify)[/i]
242
- [b]S[/b] → Settings [i](keyring path, GPG binary, key defaults)[/i]
243
- [b]Esc[/b] → Quit
242
+ [b]C[/b] → Configuration [i](keyring path, GPG binary, key defaults)[/i]
243
+ [b]Esc[/b] → Quit
244
244
 
245
245
  The keyring path is shown in the header subtitle.
246
246
  [i]Use [b]`--gnupghome`[/b] on the command line to point at a different keyring.[/i]
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
8
+ from textual.message import Message
9
+ from textual.screen import Screen
10
+ from textual.widgets import (
11
+ Button,
12
+ Footer,
13
+ Header,
14
+ Input,
15
+ Label,
16
+ Select,
17
+ Static,
18
+ Switch,
19
+ )
20
+
21
+ from artemis_framework.asphodel.tui.settings import AppSettings, CONFIG_FILE, save_settings
22
+
23
+
24
+ _ALGO_OPTIONS: list[tuple[str, str]] = [
25
+ ("RSA 4096", "rsa"),
26
+ ("ECC (Ed25519 / Cv25519)", "ecc"),
27
+ ]
28
+
29
+ _EXPIRE_OPTIONS: list[tuple[str, str]] = [
30
+ ("Never", "0"),
31
+ ("1 year", "1y"),
32
+ ("2 years", "2y"),
33
+ ("5 years", "5y"),
34
+ ]
35
+
36
+ _CACHE_WARNING = (
37
+ "⚠ SECURITY RISK ⚠\n"
38
+ "Passphrases will be held in application memory for the entire "
39
+ "session and reused automatically. They are never written to disk, "
40
+ "but any process that can read this application's memory - such as "
41
+ "a debugger, crash dump, or another process running as the same "
42
+ "user - could potentially recover them.\n"
43
+ "Enable only on a trusted, single-user system."
44
+ )
45
+
46
+
47
+ class AppConfigScreen(Screen):
48
+ TITLE = "App Configuration"
49
+
50
+ DEFAULT_CSS = """
51
+ #cfg-scroll {
52
+ height: 1fr;
53
+ width: 100%;
54
+ padding: 1 3;
55
+ }
56
+ .cfg-section-label {
57
+ color: $primary;
58
+ text-style: bold;
59
+ margin-top: 1;
60
+ margin-bottom: 0;
61
+ padding-left: 0;
62
+ }
63
+ .cfg-divider {
64
+ color: $primary-darken-2;
65
+ margin-bottom: 1;
66
+ margin-top: 0;
67
+ }
68
+ .cfg-row {
69
+ height: auto;
70
+ width: 100%;
71
+ margin-bottom: 1;
72
+ align: left top;
73
+ }
74
+ .cfg-label {
75
+ width: 22;
76
+ color: $text-muted;
77
+ padding-top: 1;
78
+ }
79
+ .cfg-input-col {
80
+ width: 1fr;
81
+ height: auto;
82
+ }
83
+ .cfg-row Input {
84
+ width: 100%;
85
+ border: double $primary-darken-1;
86
+ }
87
+ .cfg-row Input:focus {
88
+ border: double $primary;
89
+ }
90
+ .cfg-row Select {
91
+ width: 1fr;
92
+ border: double $primary-darken-1;
93
+ }
94
+ .cfg-row Select:focus {
95
+ border: double $primary;
96
+ }
97
+ .cfg-status {
98
+ color: $text-muted;
99
+ text-style: italic;
100
+ height: auto;
101
+ padding-left: 0;
102
+ margin-top: 0;
103
+ }
104
+ .cfg-status-ok {
105
+ color: $success;
106
+ }
107
+ .cfg-status-err {
108
+ color: $error;
109
+ }
110
+ .cfg-about-value {
111
+ color: $text-muted;
112
+ padding-top: 0;
113
+ width: 1fr;
114
+ }
115
+ .cfg-spacer {
116
+ height: 1;
117
+ }
118
+ #cfg-buttons {
119
+ height: auto;
120
+ width: 100%;
121
+ align: left middle;
122
+ margin-bottom: 1;
123
+ }
124
+ #cfg-buttons Button {
125
+ margin-right: 1;
126
+ min-width: 12;
127
+ }
128
+ .cfg-switch-row {
129
+ align: left middle;
130
+ }
131
+ .cfg-switch-row .cfg-label {
132
+ padding-top: 0;
133
+ }
134
+ .cfg-cache-warning {
135
+ margin-top: 1;
136
+ margin-bottom: 1;
137
+ padding: 1 2;
138
+ border: double $warning;
139
+ color: $warning;
140
+ text-style: bold;
141
+ background: $surface;
142
+ width: 100%;
143
+ height: auto;
144
+ }
145
+ """
146
+
147
+ BINDINGS = [
148
+ Binding("s", "save", "Save", show=True, priority=True),
149
+ Binding("q,d", "discard", "Discard", show=True, priority=True),
150
+ ]
151
+
152
+ class Saved(Message):
153
+ def __init__(self, settings: AppSettings) -> None:
154
+ super().__init__()
155
+ self.settings = settings
156
+
157
+ def __init__(self, current_settings: AppSettings, **kwargs) -> None:
158
+ super().__init__(**kwargs)
159
+ self._cfg = current_settings
160
+
161
+ def compose(self) -> ComposeResult:
162
+ yield Header()
163
+
164
+ with ScrollableContainer(id="cfg-scroll"):
165
+
166
+ yield Static("GPG ENVIRONMENT", classes="cfg-section-label")
167
+ yield Static("-" * 58, classes="cfg-divider")
168
+
169
+ with Horizontal(classes="cfg-row"):
170
+ yield Label("GNUPGHOME path", classes="cfg-label")
171
+ with Vertical(classes="cfg-input-col"):
172
+ yield Input(
173
+ value=self._cfg.gnupghome,
174
+ placeholder="(leave blank to use the built-in test keyring)",
175
+ id="cfg-gnupghome"
176
+ )
177
+ yield Static("", id="cfg-gnupghome-status", classes="cfg-status")
178
+
179
+ yield Static("GPG BINARY", classes="cfg-section-label")
180
+ yield Static("-" * 58, classes="cfg-divider")
181
+
182
+ with Horizontal(classes="cfg-row"):
183
+ yield Label("Binary name / path", classes="cfg-label")
184
+ with Vertical(classes="cfg-input-col"):
185
+ yield Input(
186
+ value=self._cfg.gpg_binary,
187
+ placeholder="gpg",
188
+ id="cfg-binary"
189
+ )
190
+ yield Static("", id="cfg-binary-status", classes="cfg-status")
191
+
192
+ yield Static("KEY GENERATION DEFAULTS", classes="cfg-section-label")
193
+ yield Static("-" * 58, classes="cfg-divider")
194
+
195
+ with Horizontal(classes="cfg-row"):
196
+ yield Label("Default algorithm", classes="cfg-label")
197
+ yield Select(
198
+ _ALGO_OPTIONS,
199
+ value=self._cfg.algorithm,
200
+ id="cfg-algo",
201
+ allow_blank=False
202
+ )
203
+
204
+ with Horizontal(classes="cfg-row"):
205
+ yield Label("Default expiry", classes="cfg-label")
206
+ yield Select(
207
+ _EXPIRE_OPTIONS,
208
+ value=self._cfg.expire,
209
+ id="cfg-expire",
210
+ allow_blank=False
211
+ )
212
+
213
+ yield Static("KEYSERVER", classes="cfg-section-label")
214
+ yield Static("-" * 58, classes="cfg-divider")
215
+
216
+ with Horizontal(classes="cfg-row"):
217
+ yield Label("Keyserver URL", classes="cfg-label")
218
+ with Vertical(classes="cfg-input-col"):
219
+ yield Input(
220
+ value=self._cfg.keyserver_url,
221
+ placeholder="https://keys.openpgp.org",
222
+ id="cfg-keyserver",
223
+
224
+ )
225
+ yield Static("", id="cfg-keyserver-status", classes="cfg-status")
226
+
227
+ yield Static("SECURITY", classes="cfg-section-label")
228
+ yield Static("-" * 58, classes="cfg-divider")
229
+
230
+ with Horizontal(classes="cfg-row cfg-switch-row"):
231
+ yield Label("Passphrase caching", classes="cfg-label")
232
+ with Vertical(classes="cfg-input-col"):
233
+ yield Switch(
234
+ value=self._cfg.passphrase_cache_enabled,
235
+ id="cfg-cache-switch"
236
+ )
237
+ yield Static(
238
+ "Disabled by default. Read the warning below before enabling.",
239
+ id="cfg-cache-switch-hint",
240
+ classes="cfg-status"
241
+ )
242
+
243
+ yield Static(
244
+ _CACHE_WARNING,
245
+ id="cfg-cache-warning",
246
+ classes="cfg-cache-warning"
247
+ )
248
+
249
+ yield Static("ABOUT", classes="cfg-section-label")
250
+ yield Static("-" * 58, classes="cfg-divider")
251
+
252
+ with Horizontal(classes="cfg-row"):
253
+ yield Label("Config file", classes="cfg-label")
254
+ yield Static(str(CONFIG_FILE), classes="cfg-about-value")
255
+
256
+ yield Static("", classes="cfg-spacer")
257
+ with Horizontal(id="cfg-buttons"):
258
+ yield Button("Save", variant="primary", id="cfg-btn-save")
259
+ yield Button("Discard", variant="default", id="cfg-btn-discard")
260
+
261
+ yield Footer()
262
+
263
+ def on_mount(self) -> None:
264
+ self.query_one("#cfg-gnupghome", Input).focus()
265
+ self._validate_gnupghome()
266
+ self._validate_binary()
267
+ self._validate_keyserver()
268
+ self._update_cache_warning()
269
+
270
+ def on_input_changed(self, event: Input.Changed) -> None:
271
+ if event.input.id == "cfg-gnupghome":
272
+ self._validate_gnupghome()
273
+ elif event.input.id == "cfg-binary":
274
+ self._validate_binary()
275
+ elif event.input.id == "cfg-keyserver":
276
+ self._validate_keyserver()
277
+
278
+ def on_switch_changed(self, event: Switch.Changed) -> None:
279
+ if event.switch.id == "cfg-cache-switch":
280
+ self._update_cache_warning()
281
+
282
+ def _update_cache_warning(self) -> None:
283
+ enabled = self.query_one("#cfg-cache-switch", Switch).value
284
+ warning = self.query_one("#cfg-cache-warning", Static)
285
+ hint = self.query_one("#cfg-cache-switch-hint", Static)
286
+
287
+ warning.display = enabled
288
+
289
+ if enabled:
290
+ hint.update("⚠ Enabled - passphrases cached in memory this session.")
291
+ hint.remove_class("cfg-status-ok")
292
+ hint.add_class("cfg-status-warn")
293
+
294
+ else:
295
+ hint.update("Disabled by default. Read the warning below before enabling.")
296
+ hint.remove_class("cfg-status-warn")
297
+ hint.add_class("cfg-status-ok")
298
+
299
+ def _validate_gnupghome(self) -> None:
300
+ raw = self.query_one("#cfg-gnupghome", Input).value.strip()
301
+ status = self.query_one("#cfg-gnupghome-status", Static)
302
+
303
+ if not raw:
304
+ self._ok(status, " » using built-in test keyring (asphodel-pgp library default)")
305
+ return
306
+
307
+ p = Path(raw).expanduser()
308
+ if p.exists():
309
+ if p.is_dir():
310
+ self._ok(status, f" ✔ exists: {p.resolve()}")
311
+ else:
312
+ self._err(status, f" ✗ path exists but is not a directory")
313
+ else:
314
+ self._ok(status, f" » will be created: {p.resolve()}")
315
+
316
+ def _validate_binary(self) -> None:
317
+ import shutil
318
+ raw = self.query_one("#cfg-binary", Input).value.strip() or "gpg"
319
+ status = self.query_one("#cfg-binary-status", Static)
320
+
321
+ found = shutil.which(raw)
322
+ if found:
323
+ version = self._read_gpg_version(found)
324
+ self._ok(status, f" ✔ {found}" + (f" ({version})" if version else ""))
325
+ return
326
+
327
+ p = Path(raw)
328
+ if p.exists():
329
+ import os
330
+ if os.access(p, os.X_OK):
331
+ self._ok(status, f" ✔ {p.resolve()}")
332
+ return
333
+
334
+ self._err(status, " ✗ '{raw}' not found on PATH")
335
+
336
+ def _validate_keyserver(self) -> None:
337
+ raw = self.query_one("#cfg-keyserver", Input).value.strip()
338
+ status = self.query_one("#cfg-keyserver-status", Static)
339
+
340
+ if not raw:
341
+ self._ok(status, f" » using default: https://keys.openpgp.org")
342
+ return
343
+
344
+ if raw.startswith("https://") or raw.startswith("http://"):
345
+ self._ok(status, f" ✔ {raw}")
346
+ else:
347
+ self._err(status, " ✗ URL must start with https:// -or- http://")
348
+
349
+ @staticmethod
350
+ def _ok(widget: Static, text: str) -> None:
351
+ widget.update(text)
352
+ widget.remove_class("cfg-status-err")
353
+ widget.remove_class("cfg-status-warn")
354
+ widget.add_class("cfg-status-ok")
355
+
356
+ @staticmethod
357
+ def _err(widget: Static, text: str) -> None:
358
+ widget.update(text)
359
+ widget.remove_class("cfg-status-ok")
360
+ widget.remove_class("cfg-status-warn")
361
+ widget.add_class("cfg-status-err")
362
+
363
+ @staticmethod
364
+ def _read_gpg_version(binary: str) -> str:
365
+ import subprocess
366
+ try:
367
+ result = subprocess.run(
368
+ [binary, "--version"],
369
+ capture_output=True,
370
+ timeout=2,
371
+ text=True
372
+ )
373
+ first = result.stdout.splitlines()[0] if result.stdout else ""
374
+ parts = first.split()
375
+ if len(parts) >= 3:
376
+ return f"{parts[1].strip('()')} {parts[2]}"
377
+ return first.strip()
378
+
379
+ except Exception:
380
+ return ""
381
+
382
+ def on_button_pressed(self, event: Button.Pressed) -> None:
383
+ if event.button.id == "cfg-btn-save":
384
+ self.action_save()
385
+ elif event.button.id == "cfg-btn-discard":
386
+ self.action_discard()
387
+
388
+ def action_save(self) -> None:
389
+ gnupghome = self.query_one("#cfg-gnupghome", Input).value.strip()
390
+ gpg_binary = self.query_one("#cfg-binary", Input).value.strip() or "gpg"
391
+ algorithm = str(self.query_one("#cfg-algo", Select).value)
392
+ expire = str(self.query_one("#cfg-expire", Select).value)
393
+ keyserver = self.query_one("#cfg-keyserver", Input).value.strip()
394
+ cache_on = self.query_one("#cfg-cache-switch", Switch).value
395
+
396
+ if keyserver and not (
397
+ keyserver.startswith("https://") or keyserver.startswith("http://")
398
+ ):
399
+ self.notify(
400
+ "Keyserver URL must start with [b][u]https://[/u][/b] [i]or[/i] [b][u]http://[/u][/b]",
401
+ title="Invalid URL",
402
+ severity="warning",
403
+ markup=True
404
+ )
405
+ self.query_one("#cfg-keyserver", Input).focus()
406
+
407
+ return
408
+
409
+ new_cfg = AppSettings(
410
+ gnupghome=gnupghome,
411
+ gpg_binary=gpg_binary,
412
+ algorithm=algorithm,
413
+ expire=expire,
414
+ keyserver_url=keyserver or "https://keys.openpgp.org",
415
+ passphrase_cache_enabled=cache_on
416
+ )
417
+
418
+ if not save_settings(new_cfg):
419
+ self.notify(
420
+ f"Could not write settings to:\n{CONFIG_FILE}\n"
421
+ "Check file permissions.",
422
+ title="Save failed",
423
+ severity="error",
424
+ timeout=8
425
+ )
426
+ return
427
+
428
+ self.app.post_message(self.Saved(new_cfg))
429
+ self.app.pop_screen()
430
+
431
+ def action_discard(self) -> None:
432
+ self.app.pop_screen()
433
+
434
+
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "artemis_framework"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "A package for doing great things!"
5
5
  authors = ["Dylan Garrett"]
6
6
  license = "MIT"