artemis_framework 0.2.3__tar.gz → 0.3.1__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 (25) hide show
  1. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/PKG-INFO +1 -1
  2. artemis_framework-0.3.1/artemis_framework/asphodel/tui/app.py +303 -0
  3. artemis_framework-0.3.1/artemis_framework/asphodel/tui/screens/__init__.py +0 -0
  4. artemis_framework-0.3.1/artemis_framework/asphodel/tui/settings.py +129 -0
  5. artemis_framework-0.3.1/artemis_framework/asphodel/tui/widgets/__init__.py +0 -0
  6. artemis_framework-0.3.1/artemis_framework/elysium/__ini +0 -0
  7. artemis_framework-0.3.1/artemis_framework/elysium/__init__.py +0 -0
  8. artemis_framework-0.3.1/artemis_framework/tartarus/__init__.py +0 -0
  9. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/pyproject.toml +1 -1
  10. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/LICENSE +0 -0
  11. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/README.md +0 -0
  12. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/__init__.py +0 -0
  13. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/__main__.py +0 -0
  14. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/asphodel/__init__.py +0 -0
  15. /artemis_framework-0.2.3/artemis_framework/elysium/__ini → /artemis_framework-0.3.1/artemis_framework/asphodel/__main__.py +0 -0
  16. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/asphodel/core/__init__.py +0 -0
  17. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/asphodel/core/_gpg_binary.py +0 -0
  18. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/asphodel/core/armor_binary.py +0 -0
  19. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/asphodel/core/encrypt_decrypt.py +0 -0
  20. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/asphodel/core/gpg_context.py +0 -0
  21. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/asphodel/core/key_management.py +0 -0
  22. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/asphodel/core/macos_shim.py +0 -0
  23. {artemis_framework-0.2.3 → artemis_framework-0.3.1}/artemis_framework/asphodel/core/sign_verify.py +0 -0
  24. {artemis_framework-0.2.3/artemis_framework/elysium → artemis_framework-0.3.1/artemis_framework/asphodel/tui}/__init__.py +0 -0
  25. {artemis_framework-0.2.3/artemis_framework/tartarus → artemis_framework-0.3.1/artemis_framework/asphodel/tui/modals}/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: artemis_framework
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: A package for doing great things!
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.screen import Screen
10
+ from textual.widgets import Footer, Header, Label
11
+
12
+ try:
13
+ from artemis_framework.asphodel.core import build_gpg
14
+ except ImportError as exc:
15
+ print(
16
+ f"[FATAL] Cannot import '.asphodel.core': {exc}\n"
17
+ "Ensure the artemis_framework.asphodel package is installed and available on your PATH."
18
+ )
19
+ sys.exit(1)
20
+
21
+ from artemis_framework.asphodel.tui.settings import AppSettings, load_settings
22
+
23
+
24
+ def _import_key_management_screen():
25
+ from artemis_framework.asphodel.tui.screens.key_management import KeyManagementScreen
26
+ return KeyManagementScreen
27
+
28
+
29
+ def _import_encrypt_decrypt_screen():
30
+ from artemis_framework.asphodel.tui.screens.encrypt_decrypt import EncryptDecryptScreen
31
+ return EncryptDecryptScreen
32
+
33
+
34
+ def _import_config_screen():
35
+ from artemis_framework.asphodel.tui.screens.app_config import AppConfigScreen
36
+ return AppConfigScreen
37
+
38
+
39
+ class PlaceholderScreen(Screen):
40
+
41
+ BINDINGS = [Binding("escape,q", "app.pop_screen", "Back", priority=True)]
42
+
43
+ def __init__(self, title: str = "Coming soon", **kwargs) -> None:
44
+ super().__init__(**kwargs)
45
+ self._title = title
46
+
47
+ def compose(self) -> ComposeResult:
48
+ yield Header()
49
+ yield Label(
50
+ f"\n [ {self._title} ]\n\n This screen is not yet implemented.\n Press [b]Q[/b] or [b]Escape[/b] to [i][u]go back[/u][/i].",
51
+ markup=True,
52
+ id="placeholder=label"
53
+ )
54
+ yield Footer()
55
+
56
+
57
+ class AsphodelApp(App):
58
+ TITLE = "Asphodel PGP Terminal Interface"
59
+ SUB_TITLE = "GnuPG key management & crypto operations"
60
+
61
+ CSS = """
62
+ Screen {
63
+ background: $background;
64
+ }
65
+ Header {
66
+ background: $primary;
67
+ color: $text;
68
+ height: 1;
69
+ dock: top;
70
+ }
71
+ Footer {
72
+ background: $primary-darken-2;
73
+ color: $text-muted;
74
+ height: 1;
75
+ dock: bottom;
76
+ }
77
+ #welcome-label {
78
+ padding: 1 4;
79
+ color: $text;
80
+ content-align: left top;
81
+ }
82
+ #placeholder-label {
83
+ padding: 2 4;
84
+ color: $text-muted;
85
+ content-align: left top;
86
+ }
87
+ """
88
+
89
+ BINDINGS = [
90
+ Binding("k", "switch_screen('keys')", "Keys"),
91
+ Binding("e", "switch_screen('encrypt')", "Encrypt/Decrypt"),
92
+ Binding("c", "switch_screen('config')", "Configuration", priority=True),
93
+ Binding("escape,q", "quit", "Quit"),
94
+ ]
95
+
96
+ def __init__(self, gpg_instance, settings: AppSettings, **kwargs) -> None:
97
+ super().__init__(**kwargs)
98
+ self.gpg = gpg_instance
99
+ self.settings = settings
100
+ self._passphrase_cache: dict[str, str] = {}
101
+
102
+ def cache_passphrase(self, fingerprint: str, passphrase: str | None) -> None:
103
+ if not self.settings.passphrase_cache_enabled:
104
+ return
105
+ if passphrase:
106
+ self._passphrase_cache[fingerprint] = passphrase
107
+
108
+ def get_cached_passphrase(self, fingerprint: str) -> str | None:
109
+ if not self.settings.passphrase_cache_enabled:
110
+ return
111
+ return self._passphrase_cache.get(fingerprint)
112
+
113
+ def evict_passphrase(self, fingerprint: str) -> None:
114
+ self._passphrase_cache.pop(fingerprint, None)
115
+
116
+ def clear_passphrase_cache(self) -> None:
117
+ self._passphrase_cache.clear()
118
+
119
+ @property
120
+ def passphrase_cache_size(self) -> int:
121
+ return len(self._passphrase_cache)
122
+
123
+ def on_mount(self) -> None:
124
+ self.install_screen(self._make_key_screen, name="keys")
125
+ self.install_screen(self._make_encrypt_screen, name="encrypt")
126
+ self.install_screen(self._make_config_screen, name="config")
127
+
128
+ def _make_key_screen(self):
129
+ try:
130
+ KeyManagementScreen = _import_key_management_screen()
131
+ return KeyManagementScreen(self.gpg)
132
+ except (ImportError, ModuleNotFoundError):
133
+ return PlaceholderScreen("Key Management (not yet implemented)")
134
+
135
+ def _make_encrypt_screen(self):
136
+ try:
137
+ EncryptDecryptScreen = _import_encrypt_decrypt_screen()
138
+ return EncryptDecryptScreen(self.gpg)
139
+ except (ImportError, ModuleNotFoundError):
140
+ return PlaceholderScreen("Encrypt / Decrypt (not yet implemented)")
141
+
142
+ def _make_config_screen(self):
143
+ try:
144
+ AppConfigScreen = _import_config_screen()
145
+ return AppConfigScreen(self.settings)
146
+ except (ImportError, ModuleNotFoundError):
147
+ return PlaceholderScreen("App Configuration (not yet implemented)")
148
+
149
+ def compose(self) -> ComposeResult:
150
+ yield Header()
151
+ yield Label(_WELCOME_TEXT, id="welcome-label", markup=True)
152
+ yield Footer()
153
+
154
+ _TOP_LEVEL_SCREENS = frozenset({
155
+ "KeyManagementScreen",
156
+ "EncryptDecryptScreen",
157
+ "AppConfigScreen",
158
+ })
159
+
160
+ def action_switch_screen(self, screen_name: str) -> None:
161
+ if self.screen.__class__.__name__ in self._TOP_LEVEL_SCREENS:
162
+ return
163
+ self.push_screen(screen_name)
164
+
165
+ def on_config_screen_saved(
166
+ self,
167
+ message: "AppConfigScreen.Saved" # type: ignore[name-defined]
168
+ ) -> None:
169
+ new_cfg = message.settings
170
+ cache_was_on = self.settings.passphrase_cache_enabled
171
+ self.settings = new_cfg
172
+
173
+ if cache_was_on and not new_cfg.passphrase_cache_enabled:
174
+ self.clear_passphrase_cache()
175
+ self.notify(
176
+ "Passphrase cache cleared.",
177
+ title="Cache disabled",
178
+ severity="information",
179
+ timeout=3
180
+ )
181
+
182
+ try:
183
+ new_gpg = new_cfg.build_gpg()
184
+ except RuntimeError as exc:
185
+ self.notify(
186
+ f"Settings saved, but GPG could not be reloaded:\n{exc}\n\n"
187
+ "The previuos keyring is still active.",
188
+ title="GPG reload failed",
189
+ severity="warning",
190
+ timeout=10
191
+ )
192
+ return
193
+
194
+ self.gpg = new_gpg
195
+ self.SUB_TITLE = f"keyring: {new_gpg.gnupghome}"
196
+
197
+ self._propagate_gpg_to_screens(new_gpg)
198
+
199
+ self.notify(
200
+ f"Keyring: {new_gpg.gnupghome}",
201
+ title="Settings saved",
202
+ severity="information",
203
+ timeout=4
204
+ )
205
+
206
+ def _propagate_gpg_to_screens(self, gpg) -> None:
207
+ for name in ("keys", "encrypt"):
208
+ try:
209
+ screen = self._installed_screens.get(name)
210
+ if screen is None or callable(screen):
211
+ continue
212
+
213
+ if hasattr(screen, "gpg"):
214
+ screen.gpg = gpg
215
+
216
+ try:
217
+ from artemis_framework.asphodel.tui.widgets.key_list import KeyListWidget
218
+ for widget in screen.query(KeyListWidget):
219
+ widget.refresh_keys(gpg)
220
+ except Exception:
221
+ pass
222
+ except Exception:
223
+ pass
224
+
225
+
226
+
227
+ WELCOME_TEXT = """\
228
+
229
+ ██████╗ ██████╗ ██████╗
230
+ ██╔════╝ ██╔══██╗██╔════╝
231
+ ██║ ███╗██████╔╝██║ ███╗
232
+ ██║ ██║██╔═══╝ ██║ ██║
233
+ ╚██████╔╝██║ ╚██████╔╝
234
+ ╚═════╝ ╚═╝ ╚═════╝ TUI
235
+
236
+ [i]GnuPG key management & cryptographic operations.[/i]
237
+
238
+ [b][u]Navigation[/u][/b]
239
+
240
+ [b]K[/b] → Key Management [i](generate, import, export, delete keys)[/i]
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
244
+
245
+ The keyring path is shown in the header subtitle.
246
+ [i]Use [b]`--gnupghome`[/b] on the command line to point at a different keyring.[/i]
247
+ """
248
+
249
+
250
+ def parse_args() -> argparse.Namespace:
251
+ parser = argparse.ArgumentParser(
252
+ description="GnuPG key management & crypto operations",
253
+ formatter_class=argparse.RawDescriptionHelpFormatter,
254
+ epilog=__doc__
255
+ )
256
+ parser.add_argument(
257
+ "--gnupghome",
258
+ type=Path,
259
+ default=None,
260
+ metavar="PATH",
261
+ help=(
262
+ "Path to the GNUPGHOME keyring directory. "
263
+ "Overrides the stored setting for this session. "
264
+ "Defaults to the value saved in settings, or the built-in test keyring."
265
+ )
266
+ )
267
+ parser.add_argument(
268
+ "--gpg-binary",
269
+ type=str,
270
+ default=None,
271
+ metavar="BINARY",
272
+ help=(
273
+ "Name or full path of the GPG binary. "
274
+ "Overrides the stored setting for this session."
275
+ )
276
+ )
277
+ return parser.parse_args()
278
+
279
+
280
+ def asphodel_launch() -> None:
281
+ args = parse_args()
282
+
283
+ cfg = load_settings()
284
+
285
+ if args.gnupghome is not None:
286
+ cfg.gnupghom = str(args.gnupghome)
287
+ if args.gpg_binary is not None:
288
+ cfg.gpg_binary = str(args.gpg_binary)
289
+
290
+ try:
291
+ gpg = cfg.build_gpg()
292
+ except RuntimeError as exc:
293
+ print(f"[FATAL] Failed to initialize GPG: {exc}")
294
+ sys.exit(1)
295
+
296
+ app = AsphodelApp(gpg_instance=gpg, settings=cfg)
297
+ app.SUB_TITLE = f"keyring: {gpg.gnupghome}"
298
+
299
+ app.run()
300
+
301
+
302
+ if __name__ == "__main__":
303
+ asphodel_launch()
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ def _config_dir() -> Path:
11
+ if os.name == "nt":
12
+ base = Path(os.environ.get("APPDATA", Path.home()))
13
+ else:
14
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.hom() / ".config"))
15
+ return base / "artemis-framework" / "asphodel"
16
+
17
+
18
+ CONFIG_FILE = _config_dir() / "settings.json"
19
+
20
+
21
+ _DEFAULT_GNUPGHOME: str = ""
22
+ _DEFAULT_GPG_BINARY: str = "gpg"
23
+ _DEFAULT_ALGORITHM: str = "rsa"
24
+ _DEFAULT_EXPIRE: str = "2y"
25
+ _DEFAULT_KEYSERVER_URL: str = "https://keys.openpgp.org"
26
+ _DEFAULT_PASSPHRASE_CACHE: bool = False
27
+
28
+
29
+ @dataclass
30
+ class AppSettings:
31
+ gnupghome: str = field(default=_DEFAULT_GNUPGHOME)
32
+ gpg_binary: str = field(default=_DEFAULT_GPG_BINARY)
33
+ algorithm: str = field(default=_DEFAULT_ALGORITHM)
34
+ expire: str = field(default=_DEFAULT_EXPIRE)
35
+ keyserver_url: str = field(default=_DEFAULT_KEYSERVER_URL)
36
+ passphrase_cache_enabled: bool = field(default=_DEFAULT_PASSPHRASE_CACHE)
37
+
38
+ def gnupghome_path(self) -> Path | None:
39
+ if not self.gnupghome.strip():
40
+ return None
41
+ return Path(self.gnupghome).expanduser().resolve()
42
+
43
+ def gpg_binary_resolved(self) -> str:
44
+ return self.gpg_binary.strip() or _DEFAULT_GPG_BINARY
45
+
46
+ def keyserver_url_resolved(self) -> str:
47
+ url = self.keyserver_url.strip()
48
+ return url if url else _DEFAULT_KEYSERVER_URL
49
+
50
+ def build_gpg(self):
51
+ from artemis_framework.asphodel.core import build_gpg as _build_gpg
52
+ return _build_gpg(
53
+ gnupghome=self.gnupghome_path(),
54
+ binary=self.gpg_binary_resolved()
55
+ )
56
+
57
+ def validate_gnupghome(self) -> tuple[bool, str]:
58
+ if not self.gnupghome.strip():
59
+ return True, "Use asphodel-pgp default (test keyring)"
60
+ p = Path(self.gnupghome).expanduser()
61
+ if p.exists():
62
+ if p.is_dir():
63
+ return True, str(p.resolve())
64
+ return False, f"Path exists but is not a directory: {p}"
65
+ return True, f"Will be created: {p.resolve()}"
66
+
67
+ def validate_gpg_binary(self) -> tuple[bool, str]:
68
+ import shutil
69
+ binary = self.gpg_binary_resolved()
70
+ found = shutil.which(binary)
71
+ if found:
72
+ return True, found
73
+ p = Path(binary)
74
+ if p.exists() and os.access(p, os.X_OK):
75
+ return True, str(p.resolve())
76
+
77
+ return False, f"'{binary}' not found on PATH"
78
+
79
+ def validate_keyserver_url(self) -> tuple[bool, str]:
80
+ url = self.keyserver_url_resolved()
81
+ if url.startswith("https://") or url.startswith("http://"):
82
+ return True, url
83
+ return False, f"URL must start with https:// -or- http:// (got: '{url}')"
84
+
85
+
86
+ def load_settings() -> AppSettings:
87
+ if not CONFIG_FILE.exists():
88
+ return AppSettings()
89
+
90
+ try:
91
+ raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
92
+ except (json.JSONDecodeError, OSError):
93
+ return AppSettings()
94
+
95
+ return AppSettings(
96
+ gnupghome=raw.get("gnupghome", _DEFAULT_GNUPGHOME),
97
+ gpg_binary=raw.get("gpg_binary", _DEFAULT_GPG_BINARY),
98
+ algorithm=raw.get("algorithm", _DEFAULT_ALGORITHM),
99
+ expire=raw.get("expire", _DEFAULT_EXPIRE),
100
+ keyserver_url=raw.get("keyserver_url", _DEFAULT_KEYSERVER_URL),
101
+ passphrase_cache_enabled=bool(
102
+ raw.get("passphrase_cache_enabled", _DEFAULT_PASSPHRASE_CACHE)
103
+ )
104
+ )
105
+
106
+
107
+ def save_settings(cfg: AppSettings) -> bool:
108
+ try:
109
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
110
+ payload = json.dumps(asdict(cfg), indent=2, ensure_ascii=False)
111
+
112
+ fd, tmp_path = tempfile.mkstemp(
113
+ dir=CONFIG_FILE.parent,
114
+ prefix=".settings_tmp_",
115
+ suffix=".json"
116
+ )
117
+ try:
118
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
119
+ file.write(payload)
120
+ Path(tmp_path).replace(CONFIG_FILE)
121
+ except Exception:
122
+ try:
123
+ os.unlink(tmp_path)
124
+ except OSError:
125
+ pass
126
+ raise
127
+ return True
128
+ except OSError:
129
+ return False
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "artemis_framework"
3
- version = "0.2.3"
3
+ version = "0.3.1"
4
4
  description = "A package for doing great things!"
5
5
  authors = ["Dylan Garrett"]
6
6
  license = "MIT"