implicant 0.2.0__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.
implicant/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Implicant SDK — privacy-preserving ML inference client.
2
+
3
+ Encrypts a single input row locally under BGV, ships the ciphertext to the
4
+ Implicant platform for homomorphic evaluation, then decrypts and argmaxes
5
+ the returned class-score ciphertexts. The secret key never leaves the
6
+ process; only the three public-key blobs (§2) and ciphertexts (§3) cross
7
+ the wire.
8
+
9
+ The FHE primitives are not implemented here — they come from the
10
+ `implicant-fhe` package (`helut.client`). This SDK owns the *client
11
+ workflow*: binarization (§4), the §3 ciphertext envelope, key lifecycle /
12
+ caching, transport, and argmax post-processing.
13
+
14
+ Contract reference: ``platform/docs/FHE_INTERFACE_SPEC.md``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .binarize import BinarizationSpec, encode_row
20
+ from .client import ImplicantClient, PredictResult
21
+ from .config import ImplicantConfig
22
+ from .manifest import CircuitMeta
23
+
24
+ __version__ = "0.2.0"
25
+
26
+ __all__ = [
27
+ "ImplicantClient",
28
+ "PredictResult",
29
+ "ImplicantConfig",
30
+ "CircuitMeta",
31
+ "BinarizationSpec",
32
+ "encode_row",
33
+ "__version__",
34
+ ]
implicant/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m implicant` launches the desktop application."""
2
+ from implicant.app.__main__ import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,8 @@
1
+ """Desktop application for the Implicant FHE inference client.
2
+
3
+ A native PySide6 GUI that embeds the `implicant` SDK so a non-technical user can
4
+ run encrypted inference locally. The secret key stays inside this process.
5
+
6
+ Pure (GUI-free) modules: `preflight`, `credentials`, `catalog`, `messages`.
7
+ PySide6 modules: `worker`, `wizard`, `main_window`, `__main__`.
8
+ """
@@ -0,0 +1,61 @@
1
+ """Desktop app entry point: preflight gate -> onboarding -> main window.
2
+
3
+ The native engine + SDK are imported lazily inside ``main`` (not at module
4
+ import) so this module — and ``choose_startup`` — stay importable and testable
5
+ without the engine present.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ from PySide6.QtWidgets import QApplication
12
+
13
+ from .credentials import has_api_key
14
+ from .main_window import MainWindow
15
+ from .preflight import PreflightResult, run_preflight
16
+ from .wizard import OnboardingWizard, show_unsupported
17
+
18
+ __all__ = ["main", "choose_startup"]
19
+
20
+
21
+ def choose_startup(result: PreflightResult, has_key: bool) -> str:
22
+ """Pure routing decision: 'unsupported' | 'onboard' | 'main'."""
23
+ if not result.supported:
24
+ return "unsupported"
25
+ if not has_key:
26
+ return "onboard"
27
+ return "main"
28
+
29
+
30
+ def _build_client():
31
+ from implicant.client import ImplicantClient
32
+ from implicant.config import ImplicantConfig
33
+ from implicant.transport import HttpxTransport
34
+
35
+ from .credentials import load_api_key
36
+
37
+ config = ImplicantConfig.load()
38
+ transport = HttpxTransport(base_url=config.api_url, api_key=load_api_key() or "")
39
+ return ImplicantClient(transport=transport, key_cache_dir=config.key_cache_dir)
40
+
41
+
42
+ def main() -> int:
43
+ app = QApplication(sys.argv)
44
+ result = run_preflight()
45
+ decision = choose_startup(result, has_api_key())
46
+
47
+ if decision == "unsupported":
48
+ show_unsupported(result)
49
+ return 0
50
+ if decision == "onboard":
51
+ wizard = OnboardingWizard()
52
+ if not wizard.exec(): # 0 == closed/rejected; non-zero == accepted
53
+ return 0
54
+
55
+ window = MainWindow(_build_client(), low_memory=result.low_memory)
56
+ window.show()
57
+ return app.exec()
58
+
59
+
60
+ if __name__ == "__main__":
61
+ raise SystemExit(main())
@@ -0,0 +1,32 @@
1
+ """Bundled catalogue of models the app offers.
2
+
3
+ The wire manifest has no display metadata and the 5-operation transport has no
4
+ list-models call, so this presentation data ships with the app. ``model_id``
5
+ values match the platform's model slugs. ``class_labels`` carry the two
6
+ human-readable outcomes (the wire manifest does not).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ __all__ = ["CatalogEntry", "CATALOG", "by_model_id"]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class CatalogEntry:
17
+ model_id: str
18
+ display_name: str
19
+ class_labels: tuple[str, str]
20
+
21
+
22
+ CATALOG: tuple[CatalogEntry, ...] = (
23
+ CatalogEntry("cancer", "Breast cancer screening", ("benign", "malignant")),
24
+ CatalogEntry("diabetes", "Diabetes risk", ("no diabetes", "diabetes")),
25
+ )
26
+
27
+
28
+ def by_model_id(model_id: str) -> CatalogEntry | None:
29
+ for entry in CATALOG:
30
+ if entry.model_id == model_id:
31
+ return entry
32
+ return None
@@ -0,0 +1,40 @@
1
+ """Access-key storage in the OS keychain — never plaintext on disk.
2
+
3
+ A thin, mockable wrapper over ``keyring`` so the rest of the app (and tests)
4
+ depend on a small surface. The access key is the user's platform login
5
+ credential; it is NOT cryptographic key material.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import keyring
10
+
11
+ __all__ = [
12
+ "SERVICE_NAME",
13
+ "USERNAME",
14
+ "save_api_key",
15
+ "load_api_key",
16
+ "has_api_key",
17
+ "delete_api_key",
18
+ ]
19
+
20
+ SERVICE_NAME = "implicant"
21
+ USERNAME = "api_key"
22
+
23
+
24
+ def save_api_key(key: str) -> None:
25
+ keyring.set_password(SERVICE_NAME, USERNAME, key)
26
+
27
+
28
+ def load_api_key() -> str | None:
29
+ return keyring.get_password(SERVICE_NAME, USERNAME)
30
+
31
+
32
+ def has_api_key() -> bool:
33
+ return bool(load_api_key())
34
+
35
+
36
+ def delete_api_key() -> None:
37
+ try:
38
+ keyring.delete_password(SERVICE_NAME, USERNAME)
39
+ except keyring.errors.PasswordDeleteError:
40
+ pass
@@ -0,0 +1,258 @@
1
+ """Main application window: pick a model, fill the form, get a private result.
2
+
3
+ The window never calls the engine directly: model selection and prediction run
4
+ as worker tasks (Task 6). ``run_task`` is injectable so tests can run tasks
5
+ synchronously. A 'generation' counter ignores results from abandoned tasks.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import TYPE_CHECKING
11
+
12
+ from PySide6.QtCore import QRunnable, QThreadPool
13
+ from PySide6.QtWidgets import (
14
+ QApplication,
15
+ QComboBox,
16
+ QFormLayout,
17
+ QGroupBox,
18
+ QHBoxLayout,
19
+ QLabel,
20
+ QLineEdit,
21
+ QMenu,
22
+ QPushButton,
23
+ QStyle,
24
+ QSystemTrayIcon,
25
+ QVBoxLayout,
26
+ QWidget,
27
+ )
28
+
29
+ from . import catalog, messages
30
+ from .wizard import OnboardingWizard
31
+ from .worker import PredictTask, PrepareTask
32
+
33
+ if TYPE_CHECKING:
34
+ from implicant.client import ImplicantClient
35
+
36
+ __all__ = ["MainWindow"]
37
+
38
+
39
+ class MainWindow(QWidget):
40
+ def __init__(
41
+ self,
42
+ client: "ImplicantClient",
43
+ *,
44
+ run_task: Callable[[QRunnable], None] | None = None,
45
+ low_memory: bool = False,
46
+ ) -> None:
47
+ super().__init__()
48
+ self._client = client
49
+ self._run_task = run_task or QThreadPool.globalInstance().start
50
+ self._prepared = None
51
+ self._generation = 0
52
+ self._task = None
53
+ self.fields: dict[str, QLineEdit] = {}
54
+
55
+ self.setWindowTitle(messages.APP_TITLE)
56
+ self._build_ui(low_memory)
57
+ self._setup_tray()
58
+ if self.model_combo.count():
59
+ self.select_model_index(self.model_combo.currentIndex())
60
+
61
+ # ----- UI construction -------------------------------------------------
62
+
63
+ def _build_ui(self, low_memory: bool) -> None:
64
+ layout = QVBoxLayout(self)
65
+
66
+ if low_memory:
67
+ note = QLabel(messages.LOW_MEMORY_NOTE)
68
+ note.setWordWrap(True)
69
+ layout.addWidget(note)
70
+
71
+ layout.addWidget(QLabel(messages.CHOOSE_MODEL_LABEL))
72
+ self.model_combo = QComboBox()
73
+ for entry in catalog.CATALOG:
74
+ self.model_combo.addItem(entry.display_name, entry.model_id)
75
+ layout.addWidget(self.model_combo)
76
+
77
+ self._form_box = QGroupBox(messages.FORM_GROUP_TITLE)
78
+ self._form_layout = QFormLayout(self._form_box)
79
+ layout.addWidget(self._form_box)
80
+
81
+ self.predict_button = QPushButton(messages.PREDICT_BUTTON)
82
+ self.predict_button.setEnabled(False)
83
+ self.predict_button.clicked.connect(self.on_predict_clicked)
84
+ self.cancel_button = QPushButton(messages.CANCEL_BUTTON)
85
+ self.cancel_button.setEnabled(False)
86
+ self.cancel_button.clicked.connect(self.on_cancel_clicked)
87
+ btn_row = QHBoxLayout()
88
+ btn_row.addWidget(self.predict_button)
89
+ btn_row.addWidget(self.cancel_button)
90
+ layout.addLayout(btn_row)
91
+
92
+ self.status_label = QLabel("")
93
+ self.status_label.setWordWrap(True)
94
+ layout.addWidget(self.status_label)
95
+ self.result_label = QLabel("")
96
+ self.result_label.setWordWrap(True)
97
+ layout.addWidget(self.result_label)
98
+
99
+ trust_box = QGroupBox(messages.PRIVACY_GROUP_TITLE)
100
+ trust_layout = QVBoxLayout(trust_box)
101
+ for line in messages.TRUST_SUMMARY:
102
+ lbl = QLabel("• " + line)
103
+ lbl.setWordWrap(True)
104
+ trust_layout.addWidget(lbl)
105
+ self.details_button = QPushButton(messages.DETAILS_BUTTON)
106
+ self.details_button.setCheckable(True)
107
+ self.details_button.toggled.connect(self.toggle_details)
108
+ trust_layout.addWidget(self.details_button)
109
+ self.details_label = QLabel(messages.TRUST_DETAILS)
110
+ self.details_label.setWordWrap(True)
111
+ self.details_label.setVisible(False)
112
+ trust_layout.addWidget(self.details_label)
113
+ layout.addWidget(trust_box)
114
+
115
+ self.settings_button = QPushButton(messages.SETTINGS_BUTTON)
116
+ self.settings_button.clicked.connect(self.on_settings_clicked)
117
+ layout.addWidget(self.settings_button)
118
+
119
+ # Connect AFTER populating the combo so the initial addItem does not
120
+ # fire into a half-built UI; the initial selection is driven explicitly.
121
+ self.model_combo.currentIndexChanged.connect(self.select_model_index)
122
+
123
+ def _setup_tray(self) -> None:
124
+ self._tray = None
125
+ if not QSystemTrayIcon.isSystemTrayAvailable():
126
+ return
127
+ icon = self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)
128
+ self._tray = QSystemTrayIcon(icon, self)
129
+ menu = QMenu()
130
+ menu.addAction(messages.TRAY_OPEN).triggered.connect(self.showNormal)
131
+ menu.addAction(messages.TRAY_QUIT).triggered.connect(QApplication.quit)
132
+ self._tray.setContextMenu(menu)
133
+ self._tray.show()
134
+
135
+ # ----- behavior --------------------------------------------------------
136
+
137
+ def closeEvent(self, event) -> None:
138
+ if self._tray is not None:
139
+ event.ignore()
140
+ self.hide()
141
+ else:
142
+ event.accept()
143
+
144
+ def toggle_details(self, checked: bool) -> None:
145
+ self.details_label.setVisible(checked)
146
+
147
+ def _next_generation(self) -> int:
148
+ self._generation += 1
149
+ return self._generation
150
+
151
+ def select_model_index(self, index: int) -> None:
152
+ if index < 0 or index >= len(catalog.CATALOG):
153
+ return
154
+ self._prepared = None
155
+ self.predict_button.setEnabled(False)
156
+ self.result_label.setText("")
157
+ self._clear_form()
158
+ entry = catalog.CATALOG[index]
159
+ gen = self._next_generation()
160
+ self.status_label.setText(messages.STAGE_LABELS["fetching"])
161
+ task = PrepareTask(self._client, entry.model_id)
162
+ task.signals.stage.connect(lambda s, g=gen: self._on_stage(s, g))
163
+ task.signals.prepared.connect(lambda p, g=gen: self._on_prepared(p, g))
164
+ task.signals.failed.connect(lambda e, g=gen: self._on_failed(e, g))
165
+ self._task = task
166
+ self.cancel_button.setEnabled(True)
167
+ self._run_task(task)
168
+
169
+ def on_predict_clicked(self) -> None:
170
+ if self._prepared is None:
171
+ return
172
+ try:
173
+ row = self._gather_row()
174
+ except (KeyError, ValueError) as exc:
175
+ self.result_label.setText("")
176
+ self.status_label.setText(messages.friendly_error(exc))
177
+ return
178
+ entry = catalog.CATALOG[self.model_combo.currentIndex()]
179
+ gen = self._next_generation()
180
+ self.predict_button.setEnabled(False)
181
+ self.cancel_button.setEnabled(True)
182
+ self.result_label.setText("")
183
+ task = PredictTask(self._client, self._prepared, row, entry.class_labels)
184
+ task.signals.stage.connect(lambda s, g=gen: self._on_stage(s, g))
185
+ task.signals.finished.connect(lambda r, g=gen: self._on_finished(r, g))
186
+ task.signals.failed.connect(lambda e, g=gen: self._on_failed(e, g))
187
+ self._task = task
188
+ self._run_task(task)
189
+
190
+ def on_cancel_clicked(self) -> None:
191
+ self._next_generation() # abandon any in-flight results
192
+ self.cancel_button.setEnabled(False)
193
+ self.predict_button.setEnabled(self._prepared is not None)
194
+ # If cancelled mid-setup there is no prepared model and Predict stays
195
+ # disabled; guide the user back to the model list instead of leaving a
196
+ # blank, dead-end window.
197
+ if self._prepared is None:
198
+ self.status_label.setText(messages.SELECT_MODEL_HINT)
199
+ else:
200
+ self.status_label.setText("")
201
+
202
+ def on_settings_clicked(self) -> None:
203
+ wizard = OnboardingWizard()
204
+ if wizard.exec(): # accepted: a new access key was saved
205
+ self.status_label.setText(messages.SETTINGS_SAVED_NOTE)
206
+
207
+ def _gather_row(self) -> dict[str, float]:
208
+ row: dict[str, float] = {}
209
+ for name, field in self.fields.items():
210
+ text = field.text().strip()
211
+ if not text:
212
+ raise KeyError(name)
213
+ row[name] = float(text)
214
+ return row
215
+
216
+ def _clear_form(self) -> None:
217
+ while self._form_layout.rowCount():
218
+ self._form_layout.removeRow(0)
219
+ self.fields = {}
220
+
221
+ def _build_form(self, feature_names: tuple[str, ...]) -> None:
222
+ self._clear_form()
223
+ for name in feature_names:
224
+ field = QLineEdit()
225
+ self.fields[name] = field
226
+ self._form_layout.addRow(name, field)
227
+
228
+ # ----- signal slots (guarded by generation) ----------------------------
229
+
230
+ def _on_stage(self, token: str, gen: int) -> None:
231
+ if gen != self._generation:
232
+ return
233
+ self.status_label.setText(messages.STAGE_LABELS.get(token, ""))
234
+
235
+ def _on_prepared(self, prepared, gen: int) -> None:
236
+ if gen != self._generation:
237
+ return
238
+ self._prepared = prepared
239
+ self._build_form(prepared.feature_names)
240
+ self.predict_button.setEnabled(True)
241
+ self.cancel_button.setEnabled(False)
242
+ self.status_label.setText("")
243
+
244
+ def _on_finished(self, result, gen: int) -> None:
245
+ if gen != self._generation:
246
+ return
247
+ self.predict_button.setEnabled(True)
248
+ self.cancel_button.setEnabled(False)
249
+ self.status_label.setText("")
250
+ label = result.prediction.label or str(result.prediction.label_index)
251
+ self.result_label.setText(messages.result_text(label))
252
+
253
+ def _on_failed(self, exc, gen: int) -> None:
254
+ if gen != self._generation:
255
+ return
256
+ self.predict_button.setEnabled(self._prepared is not None)
257
+ self.cancel_button.setEnabled(False)
258
+ self.status_label.setText(messages.friendly_error(exc))
@@ -0,0 +1,145 @@
1
+ """The ONLY place user-facing copy lives.
2
+
3
+ By policy no cryptographic vocabulary appears in any string here (enforced by
4
+ tests/test_app_messages.py). We talk about protecting data on this computer and
5
+ your private protection — never keys, encryption, ciphertext, bits, or argmax.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import httpx
10
+
11
+ from implicant.transport.errors import KeyDepthInsufficientError, TransportError
12
+
13
+ __all__ = [
14
+ "STAGE_LABELS",
15
+ "TRUST_SUMMARY",
16
+ "TRUST_DETAILS",
17
+ "UNSUPPORTED_TEXT",
18
+ "LOW_MEMORY_NOTE",
19
+ "SETTINGS_SAVED_NOTE",
20
+ "SELECT_MODEL_HINT",
21
+ "APP_TITLE",
22
+ "WIZARD_TITLE",
23
+ "WIZARD_WELCOME",
24
+ "ACCESS_KEY_PLACEHOLDER",
25
+ "ACCESS_KEY_REQUIRED",
26
+ "SAVE_AND_CONTINUE",
27
+ "CHOOSE_MODEL_LABEL",
28
+ "FORM_GROUP_TITLE",
29
+ "PREDICT_BUTTON",
30
+ "CANCEL_BUTTON",
31
+ "PRIVACY_GROUP_TITLE",
32
+ "DETAILS_BUTTON",
33
+ "SETTINGS_BUTTON",
34
+ "TRAY_OPEN",
35
+ "TRAY_QUIT",
36
+ "result_text",
37
+ "detected_platform_text",
38
+ "friendly_error",
39
+ ]
40
+
41
+ # Internal stage token -> plain-language status line.
42
+ STAGE_LABELS: dict[str, str] = {
43
+ "fetching": "Getting the model ready…",
44
+ "keygen": "Setting up privacy on this computer (one time)…",
45
+ "encrypting": "Protecting your data on this computer…",
46
+ "computing": "The service is working on your protected data…",
47
+ "reading": "Preparing your result…",
48
+ }
49
+
50
+ TRUST_SUMMARY: tuple[str, ...] = (
51
+ "Your data is protected on this computer.",
52
+ "Nothing readable about your data ever leaves this computer.",
53
+ "Your private protection stays on this computer and is never saved.",
54
+ )
55
+
56
+ TRUST_DETAILS: str = (
57
+ "When you ask for a result, the app first protects your information on "
58
+ "this computer. Only the protected, unreadable form is sent to the "
59
+ "service — your original values never leave. The service works out a "
60
+ "result from the protected data without ever seeing what it contains, and "
61
+ "the result is opened again only here on your computer. The private "
62
+ "protection used to open results stays in this computer's memory while the "
63
+ "app is open and is never written to disk."
64
+ )
65
+
66
+ UNSUPPORTED_TEXT: str = (
67
+ "Implicant can't run on this computer yet. It currently works on "
68
+ "newer Apple-chip Macs and on Linux computers with an Intel or AMD chip."
69
+ )
70
+
71
+ LOW_MEMORY_NOTE: str = (
72
+ "This computer has limited memory, so getting ready may take a little "
73
+ "longer. You can keep using the app while it prepares."
74
+ )
75
+
76
+ SETTINGS_SAVED_NOTE: str = (
77
+ "Your access key was saved. Please reopen the app for it to take effect."
78
+ )
79
+
80
+ SELECT_MODEL_HINT: str = (
81
+ "Setup was stopped. Choose a model from the list to try again."
82
+ )
83
+
84
+ # --- Window chrome and widget labels -----------------------------------------
85
+ # Every string a widget displays lives here too (not just status/trust copy), so
86
+ # the no-jargon policy test in tests/test_app_messages.py covers all of them.
87
+ APP_TITLE: str = "Implicant"
88
+
89
+ WIZARD_TITLE: str = "Welcome to Implicant"
90
+ WIZARD_WELCOME: str = (
91
+ "Welcome. To get started, paste the access key your administrator gave you."
92
+ )
93
+ ACCESS_KEY_PLACEHOLDER: str = "Access key"
94
+ ACCESS_KEY_REQUIRED: str = "Please paste your access key to continue."
95
+ SAVE_AND_CONTINUE: str = "Save and continue"
96
+
97
+ CHOOSE_MODEL_LABEL: str = "Choose a model:"
98
+ FORM_GROUP_TITLE: str = "Your information"
99
+ PREDICT_BUTTON: str = "Predict privately"
100
+ CANCEL_BUTTON: str = "Cancel"
101
+ PRIVACY_GROUP_TITLE: str = "Your privacy"
102
+ DETAILS_BUTTON: str = "Details"
103
+ SETTINGS_BUTTON: str = "Change access key…"
104
+ TRAY_OPEN: str = "Open"
105
+ TRAY_QUIT: str = "Quit"
106
+
107
+
108
+ def detected_platform_text(os_name: str, arch: str) -> str:
109
+ """Informative line shown under the unsupported-computer message."""
110
+ return f"Detected: {os_name} / {arch}."
111
+
112
+
113
+ def result_text(label: str) -> str:
114
+ """The line shown for a finished prediction."""
115
+ return f"Result: {label}"
116
+
117
+
118
+ def friendly_error(exc: Exception) -> str:
119
+ """Map any exception to a plain-language message (no codes, no jargon)."""
120
+ if isinstance(exc, KeyDepthInsufficientError):
121
+ return (
122
+ "This model needs a stronger privacy setup than the one prepared "
123
+ "on this computer. Please contact your administrator."
124
+ )
125
+ if isinstance(exc, TransportError):
126
+ if exc.status_code in (401, 403):
127
+ return "Your access key was not accepted. Please check it in Settings."
128
+ if exc.status_code == 404:
129
+ return "This model isn't available on the service right now."
130
+ if exc.status_code == 429:
131
+ return "You've reached your usage limit. Please try again later."
132
+ return (
133
+ "Couldn't reach the service. Please check your connection and "
134
+ "try again."
135
+ )
136
+ if isinstance(exc, httpx.HTTPError):
137
+ return (
138
+ "Couldn't reach the service. Please check your connection and "
139
+ "try again."
140
+ )
141
+ if isinstance(exc, KeyError):
142
+ return "Please fill in every field before asking for a result."
143
+ if isinstance(exc, ValueError):
144
+ return "Please enter a valid number in every field."
145
+ return "Something went wrong. Please try again."
@@ -0,0 +1,73 @@
1
+ """Hardware/environment preflight for the desktop app.
2
+
3
+ Answers one question: can THIS computer run local private inference? The hard
4
+ gate is whether the native engine (``helut.client``) imports. We also report
5
+ OS/arch (for the friendly 'not supported' message) and total memory (to warn,
6
+ never block, on small machines). The result is UI-agnostic; the GUI turns it
7
+ into plain-language screens.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import platform
13
+ from dataclasses import dataclass
14
+
15
+ __all__ = ["PreflightResult", "evaluate", "run_preflight", "LOW_MEMORY_GB"]
16
+
17
+ # Keygen at the larger rings is memory-hungry; below this we WARN (do not block).
18
+ LOW_MEMORY_GB = 4.0
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class PreflightResult:
23
+ engine_available: bool # could we import the native crypto engine?
24
+ os_name: str # platform.system(): "Darwin" | "Linux" | "Windows"
25
+ arch: str # platform.machine(): "arm64" | "x86_64" | ...
26
+ total_memory_gb: float # best-effort; 0.0 if undetectable
27
+ supported: bool # == engine_available (the hard gate)
28
+ low_memory: bool # supported AND 0 < total_memory_gb < LOW_MEMORY_GB
29
+
30
+
31
+ def evaluate(
32
+ *,
33
+ engine_available: bool,
34
+ os_name: str,
35
+ arch: str,
36
+ total_memory_gb: float,
37
+ ) -> PreflightResult:
38
+ """Pure decision function (unit-tested). No I/O."""
39
+ return PreflightResult(
40
+ engine_available=engine_available,
41
+ os_name=os_name,
42
+ arch=arch,
43
+ total_memory_gb=total_memory_gb,
44
+ supported=engine_available,
45
+ low_memory=engine_available and 0.0 < total_memory_gb < LOW_MEMORY_GB,
46
+ )
47
+
48
+
49
+ def _engine_available() -> bool:
50
+ try:
51
+ import helut.client # noqa: F401
52
+ except Exception:
53
+ return False
54
+ return True
55
+
56
+
57
+ def _total_memory_gb() -> float:
58
+ try:
59
+ pages = os.sysconf("SC_PHYS_PAGES")
60
+ page_size = os.sysconf("SC_PAGE_SIZE")
61
+ return (pages * page_size) / (1024 ** 3)
62
+ except (ValueError, OSError, AttributeError):
63
+ return 0.0
64
+
65
+
66
+ def run_preflight() -> PreflightResult:
67
+ """Probe the real machine and return a :class:`PreflightResult`."""
68
+ return evaluate(
69
+ engine_available=_engine_available(),
70
+ os_name=platform.system(),
71
+ arch=platform.machine(),
72
+ total_memory_gb=_total_memory_gb(),
73
+ )