hugpy-settings 0.1.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.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: hugpy-settings
3
+ Version: 0.1.0
4
+ Summary: Shared HTTP-backed settings editor and reports for Hugpy Wrapper and Harness
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Hugpy settings
9
+
10
+ The shared settings layer extracted from Hugpy Wrapper's terminal harness.
11
+ Wrapper and Hugpy Harness use this package directly instead of maintaining
12
+ duplicate implementations. It uses only the Python standard library and has
13
+ no dependency on either application, model engines, or a database driver.
14
+
15
+ The layer owns settings snapshots and redaction, field schemas, inheritance
16
+ reports, editing controls, terminal navigation, and the remote folder picker.
17
+ The running Wrapper remains responsible for validation, persistence, resource
18
+ admission, and applying changes through its existing HTTP API.
19
+
20
+ ```python
21
+ from hugpy_settings.settings_view import show_settings, run_settings, snapshot
22
+
23
+ # gateway exposes api_key and models(); the host supplies endpoint discovery.
24
+ show_settings(gateway, base, model=model_key, as_json=True)
25
+ # A curses host can embed the same editor:
26
+ run_settings(screen, gateway, base, model=model_key, drain=drain, wait=wait)
27
+ ```
28
+
29
+ `drain` is an optional event-draining callback. `wait(title, operation)` is an
30
+ optional host callback for displaying progress around blocking work. Hosts own
31
+ the terminal lifecycle. JSON and text reports remain available without curses.
32
+
33
+ Implementation modules:
34
+
35
+ - `settings_view`: snapshots, reports, and terminal entry points.
36
+ - `settings_schema`: model and box field descriptions.
37
+ - `settings_controls`: parsing, HTTP updates, and editing workflows.
38
+ - `settings_widgets`: dropdowns, mouse handling, and remote directory browsing.
39
+
40
+ Wrapper's `fitevict.cli.settings_*` and Harness's
41
+ `hugpy_harness.tui.views.settings_*` paths alias these same modules, preserving
42
+ existing callers and shared module state. New callers should import
43
+ `hugpy_settings` modules directly. Wrapper's schema was used as the source of
44
+ record; Harness's stale `video_allow_cpu` UI entry was not carried forward.
45
+
46
+ For source development, install this package into each host's development
47
+ environment before installing that host:
48
+
49
+ ```sh
50
+ python3 -m pip install -e /path/to/staging/hugpy-settings/package
51
+ ```
52
+
53
+ Both Debian builders build and bundle the shared wheel alongside their own
54
+ wheel. `HUGPY_SETTINGS_SOURCE` can override the sibling source directory.
55
+ The package must be published to the chosen Python index before distributing
56
+ Wrapper or Harness wheels that depend on `hugpy-settings==0.1.0` through it.
@@ -0,0 +1,49 @@
1
+ # Hugpy settings
2
+
3
+ The shared settings layer extracted from Hugpy Wrapper's terminal harness.
4
+ Wrapper and Hugpy Harness use this package directly instead of maintaining
5
+ duplicate implementations. It uses only the Python standard library and has
6
+ no dependency on either application, model engines, or a database driver.
7
+
8
+ The layer owns settings snapshots and redaction, field schemas, inheritance
9
+ reports, editing controls, terminal navigation, and the remote folder picker.
10
+ The running Wrapper remains responsible for validation, persistence, resource
11
+ admission, and applying changes through its existing HTTP API.
12
+
13
+ ```python
14
+ from hugpy_settings.settings_view import show_settings, run_settings, snapshot
15
+
16
+ # gateway exposes api_key and models(); the host supplies endpoint discovery.
17
+ show_settings(gateway, base, model=model_key, as_json=True)
18
+ # A curses host can embed the same editor:
19
+ run_settings(screen, gateway, base, model=model_key, drain=drain, wait=wait)
20
+ ```
21
+
22
+ `drain` is an optional event-draining callback. `wait(title, operation)` is an
23
+ optional host callback for displaying progress around blocking work. Hosts own
24
+ the terminal lifecycle. JSON and text reports remain available without curses.
25
+
26
+ Implementation modules:
27
+
28
+ - `settings_view`: snapshots, reports, and terminal entry points.
29
+ - `settings_schema`: model and box field descriptions.
30
+ - `settings_controls`: parsing, HTTP updates, and editing workflows.
31
+ - `settings_widgets`: dropdowns, mouse handling, and remote directory browsing.
32
+
33
+ Wrapper's `fitevict.cli.settings_*` and Harness's
34
+ `hugpy_harness.tui.views.settings_*` paths alias these same modules, preserving
35
+ existing callers and shared module state. New callers should import
36
+ `hugpy_settings` modules directly. Wrapper's schema was used as the source of
37
+ record; Harness's stale `video_allow_cpu` UI entry was not carried forward.
38
+
39
+ For source development, install this package into each host's development
40
+ environment before installing that host:
41
+
42
+ ```sh
43
+ python3 -m pip install -e /path/to/staging/hugpy-settings/package
44
+ ```
45
+
46
+ Both Debian builders build and bundle the shared wheel alongside their own
47
+ wheel. `HUGPY_SETTINGS_SOURCE` can override the sibling source directory.
48
+ The package must be published to the chosen Python index before distributing
49
+ Wrapper or Harness wheels that depend on `hugpy-settings==0.1.0` through it.
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hugpy-settings"
7
+ version = "0.1.0"
8
+ description = "Shared HTTP-backed settings editor and reports for Hugpy Wrapper and Harness"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = []
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["src"]
15
+ include = ["hugpy_settings*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Shared HTTP-backed settings UI for Hugpy Wrapper and Harness.
2
+
3
+ Import terminal/report entry points from hugpy_settings.settings_view.
4
+ The package has no dependency on either host application.
5
+ """
@@ -0,0 +1,328 @@
1
+ """Stdlib editing controls for the shared Wrapper and Harness settings UI."""
2
+ from __future__ import annotations
3
+
4
+ try:
5
+ import curses
6
+ except ImportError: # JSON/text reporting remains available without a terminal backend.
7
+ curses = None
8
+ import json
9
+ import math
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+
14
+ from .settings_schema import KNOB_SCHEMA, BOX_SCHEMA
15
+ from .settings_widgets import CANCEL, SettingsWidgets, mouse_event
16
+
17
+ ROLES = [('Central library', 'central'), ('Worker storage', 'worker'), ('Read-only source', 'immutable'),
18
+ ('Inherit designation (remove this override)', None)]
19
+
20
+
21
+ def parse_value(spec, text):
22
+ if text == '':
23
+ return None
24
+ kind = spec.get('type')
25
+ if kind in ('number', 'int'):
26
+ try:
27
+ value = int(text) if kind == 'int' else float(text)
28
+ except (ValueError, TypeError):
29
+ raise ValueError('Enter a whole number.' if kind == 'int' else 'Enter a number.') from None
30
+ if not math.isfinite(value) or ('min' in spec and value < spec['min']) or ('max' in spec and value > spec['max']):
31
+ raise ValueError('Value is outside the allowed range.')
32
+ return value
33
+ if kind == 'list':
34
+ values = [s.strip() for s in text.split(',') if s.strip()]
35
+ if spec.get('options') and any(v not in spec['options'] for v in values):
36
+ raise ValueError('Choose values from the offered options.')
37
+ return values
38
+ return text
39
+
40
+
41
+ def write_endpoint(gateway, base, path, body, timeout):
42
+ """One merge update; never retry a write or follow it to another origin."""
43
+ headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
44
+ if getattr(gateway, 'api_key', ''):
45
+ headers['Authorization'] = 'Bearer ' + gateway.api_key
46
+ request = urllib.request.Request(base + path, data=json.dumps(body).encode(), headers=headers, method='POST')
47
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
48
+ def redirect_request(self, *args, **kwargs): return None
49
+ try:
50
+ with urllib.request.build_opener(NoRedirect).open(request, timeout=min(timeout, 10)) as response:
51
+ data = json.load(response)
52
+ except urllib.error.HTTPError as exc:
53
+ try:
54
+ error = json.loads(exc.read()).get('error', {})
55
+ message = error.get('message') if isinstance(error, dict) else error
56
+ except (OSError, ValueError):
57
+ message = None
58
+ raise ValueError('HTTP %s: %s' % (exc.code, message or exc.reason)) from None
59
+ if not isinstance(data, dict) or data.get('error') or data.get('ok') is False:
60
+ raise ValueError('Wrapper rejected the setting: ' + str(data.get('error') if isinstance(data, dict) else 'invalid response'))
61
+ return data
62
+
63
+
64
+ class SettingsEditor(SettingsWidgets):
65
+ def __init__(self, screen, gateway, base, model=None, timeout=30, drain=None, wait=None):
66
+ self.screen, self.gateway, self.base = screen, gateway, base
67
+ self.model, self.timeout, self.drain, self.wait = model, timeout, drain, wait
68
+
69
+ def put(self, y, text, attr=0):
70
+ h, w = self.screen.getmaxyx()
71
+ if 0 <= y < h and w > 1:
72
+ text = ''.join(c if c.isprintable() else ' ' for c in str(text))
73
+ try:
74
+ self.screen.addnstr(y, 0, text, w - 1, attr)
75
+ except curses.error:
76
+ pass
77
+
78
+ def key(self):
79
+ key = self.screen.getch()
80
+ if self.drain:
81
+ self.drain()
82
+ return key
83
+
84
+ def choose(self, title, labels, selected=0):
85
+ if not labels:
86
+ return None
87
+ selected = max(0, min(selected, len(labels) - 1))
88
+ while True:
89
+ self.screen.erase()
90
+ h, w = self.screen.getmaxyx()
91
+ count = max(1, h - 5)
92
+ self.put(0, title, curses.A_BOLD)
93
+ start = selected // count * count
94
+ for y, label in enumerate(labels[start:start + count], 2):
95
+ i = start + y - 2
96
+ self.put(y, ('> ' if i == selected else ' ') + label,
97
+ curses.A_REVERSE if i == selected else 0)
98
+ self.put(h - 1, 'Up/Down choose · Enter select · Esc cancel')
99
+ self.screen.refresh()
100
+ key = self.key()
101
+ if key == curses.KEY_MOUSE:
102
+ event = mouse_event(self.screen)
103
+ if event and event['wheel']:
104
+ key = curses.KEY_UP if event['wheel'] < 0 else curses.KEY_DOWN
105
+ elif event and event['click'] and 2 <= event['y'] < 2 + min(count, len(labels) - start):
106
+ if h >= 6 and w >= 20:
107
+ return start + event['y'] - 2
108
+ else:
109
+ continue
110
+ if key in (27, curses.KEY_F10, 3, 4): return None
111
+ if key in (10, 13, curses.KEY_ENTER) and h >= 6 and w >= 20: return selected
112
+ if key == curses.KEY_UP: selected = max(0, selected - 1)
113
+ elif key == curses.KEY_DOWN: selected = min(len(labels) - 1, selected + 1)
114
+ elif key == curses.KEY_PPAGE: selected = max(0, selected - count)
115
+ elif key == curses.KEY_NPAGE: selected = min(len(labels) - 1, selected + count)
116
+ elif key == curses.KEY_HOME: selected = 0
117
+ elif key == curses.KEY_END: selected = len(labels) - 1
118
+
119
+ def text(self, title, current='', help='Empty = inherit; Ctrl-U clears'):
120
+ text = str(current)
121
+ while True:
122
+ self.screen.erase()
123
+ h, w = self.screen.getmaxyx()
124
+ self.put(0, title, curses.A_BOLD)
125
+ # Keep the cursor end visible for long remote paths.
126
+ self.put(2, (text + '_')[-max(1, w - 1):])
127
+ self.put(4, help)
128
+ self.put(h - 1, 'Enter continue · Backspace edit · Esc cancel')
129
+ self.screen.refresh()
130
+ key = self.key()
131
+ if key in (27, curses.KEY_F10, 3, 4): return CANCEL
132
+ if key in (10, 13, curses.KEY_ENTER) and h >= 6 and w >= 20: return text.strip()
133
+ if key in (curses.KEY_BACKSPACE, 127, 8): text = text[:-1]
134
+ elif key == 21: text = ''
135
+ elif 32 <= key <= 126: text += chr(key)
136
+
137
+ def message(self, title, message):
138
+ self.choose(title, [str(message), 'Back'])
139
+
140
+ def fetch(self, title, fn):
141
+ return self.wait(title, fn) if self.wait else fn()
142
+
143
+ def save(self, path, settings, model=None):
144
+ body = {'settings': settings}
145
+ if model is not None:
146
+ body['model_key'] = model
147
+ return self.save_body(path, body)
148
+
149
+ def save_body(self, path, body):
150
+ changes = body.get('settings', body)
151
+ names = {s['key']: s['label'] for s in KNOB_SCHEMA + BOX_SCHEMA}
152
+ descriptions = []
153
+ for key, value in changes.items():
154
+ if key in ('drive_roles', 'drive_budgets'):
155
+ role_names = {role: name for name, role in ROLES}
156
+ for drive_path, setting in value.items():
157
+ shown = role_names[setting] if key == 'drive_roles' else ('inherit' if setting is None else str(setting) + ' GiB')
158
+ descriptions.append(drive_path + ': ' + shown)
159
+ else:
160
+ shown = 'inherit' if value is None else ('on' if value else 'off') if isinstance(value, bool) else str(value)
161
+ descriptions.append(names.get(key, key.replace('_', ' ').title()) + ': ' + shown)
162
+ label = '; '.join(descriptions)
163
+ scope = (' · model ' + str(body['model_key'])) if body.get('model_key') else ''
164
+ if self.choose('Save to Wrapper ' + self.base + scope + '?', ['Back', 'Save ' + label]) != 1:
165
+ return False
166
+ try:
167
+ if self.screen is not None:
168
+ self.put(0, 'Saving Wrapper settings…', curses.A_BOLD)
169
+ self.screen.refresh()
170
+ write_endpoint(self.gateway, self.base, path, body, self.timeout)
171
+ except (OSError, ValueError) as exc:
172
+ self.message('Save failed — refresh before retrying', str(exc))
173
+ return False
174
+ return True
175
+
176
+ def select_value(self, spec, current, data):
177
+ kind = spec.get('type')
178
+ if kind in ('bool', 'select'):
179
+ values = [None] + ([True, False] if kind == 'bool' else list(spec.get('options') or []))
180
+ labels = ['Inherit (remove override)'] + [('On' if v else 'Off') if isinstance(v, bool) else str(v) for v in values[1:]]
181
+ selected = values.index(current) if current in values else 0
182
+ pick = self.dropdown(spec['label'], labels, selected)
183
+ return CANCEL if pick is None else values[pick]
184
+ if kind == 'list' and spec.get('options'):
185
+ values = list(spec['options'])
186
+ chosen = set(current or [])
187
+ while True:
188
+ labels = ['Apply selection', 'Inherit (remove override)'] + [
189
+ ('[x] ' if v in chosen else '[ ] ') + v for v in values]
190
+ pick = self.choose(spec['label'] + ' · toggle choices, then apply', labels)
191
+ if pick is None: return CANCEL
192
+ if pick == 0: return [v for v in values if v in chosen]
193
+ if pick == 1: return None
194
+ value = values[pick - 2]
195
+ chosen.symmetric_difference_update([value])
196
+ if kind in ('model', 'quant'):
197
+ try:
198
+ records = self.fetch('Reading model options', self.gateway.models)
199
+ except (OSError, ValueError) as exc:
200
+ self.message('Model catalog unavailable', str(exc))
201
+ return CANCEL
202
+ values = sorted({r.get('file') if kind == 'quant' else r.get('id') for r in records
203
+ if r.get('id') and (kind != 'quant' or r['id'] == self.model)} - {None, ''})
204
+ pick = self.choose(spec['label'], ['Inherit (remove override)'] + values + ['Enter a value'])
205
+ if pick is None: return CANCEL
206
+ if pick == 0: return None
207
+ if pick <= len(values): return values[pick - 1]
208
+ default = ', '.join(str(v) for v in current) if isinstance(current, list) else str(current) if current is not None else ''
209
+ while True:
210
+ text = self.text(spec['label'], default, spec.get('help') or 'Empty = inherit; Ctrl-U clears')
211
+ if text is CANCEL: return CANCEL
212
+ try:
213
+ return parse_value(spec, text)
214
+ except ValueError as exc:
215
+ self.message('Invalid value', str(exc))
216
+ default = text
217
+
218
+ def drive_path(self, data, budget=False):
219
+ storage = data.get('storage') or {}
220
+ drives = storage.get('drives', []) + storage.get('central_drives', [])
221
+ drives = [d for d in drives if d.get('path') and (not budget or d.get('role') in ('worker', 'central'))]
222
+ labels = [d['path'] + ' · ' + dict((v, k) for k, v in ROLES).get(d.get('role'), d.get('role') or '') for d in drives]
223
+ pick = self.choose('Choose a drive on the Wrapper host', labels + ['Browse disks and folders on the Wrapper host…', 'Enter a directory path on the Wrapper host'])
224
+ if pick is None: return CANCEL
225
+ if pick < len(drives): return drives[pick]['path']
226
+ if pick == len(drives): return self.browse_directory(data)
227
+ path = self.text('Directory path on the Wrapper host', help='Absolute directory path; Wrapper checks it exists')
228
+ if path is CANCEL: return CANCEL
229
+ if not path.startswith('/'):
230
+ self.message('Invalid path', 'Enter an absolute directory path on the Wrapper host.')
231
+ return CANCEL
232
+ return path
233
+
234
+ def drive(self, key, data):
235
+ if 'storage' in data.get('errors', {}):
236
+ self.message('Drive settings unavailable', data['errors']['storage'])
237
+ return
238
+ path = self.drive_path(data, budget=key == 'drive_budgets')
239
+ if path is CANCEL: return
240
+ box = (data.get('box') or {}).get('box_only') or {}
241
+ if key == 'drive_roles':
242
+ current = (box.get(key) or {}).get(path)
243
+ pick = self.dropdown('Designation for ' + path, [r[0] for r in ROLES],
244
+ next((i for i, r in enumerate(ROLES) if r[1] == current), 3))
245
+ if pick is None: return
246
+ value = ROLES[pick][1]
247
+ else:
248
+ value = self.select_value({'type': 'number', 'label': 'Budget in GiB for ' + path, 'min': 0,
249
+ 'help': 'Empty = inherit. Must fit within the drive designation.'},
250
+ (box.get(key) or {}).get(path), data)
251
+ if value is CANCEL: return
252
+ # Send just the selected path. The backend merges into its latest map.
253
+ return self.save('/v1/settings/box', {key: {path: value}})
254
+
255
+ def fields(self, section, data):
256
+ """Editable rows, in the same order for display and Enter dispatch."""
257
+ if section == 'Connections':
258
+ return []
259
+ if section == 'Worker':
260
+ if 'limits' in data.get('errors', {}):
261
+ return []
262
+ limits = data.get('limits') or {}
263
+ rows = []
264
+ for key, label, kind in [('gpu_limit', 'VRAM limit (GiB)', 'number'),
265
+ ('ram_limit', 'RAM limit (GiB)', 'number'),
266
+ ('storage_limit', 'Storage limit (GiB)', 'number'),
267
+ ('cpu_limit', 'CPU limit (physical cores)', 'int')]:
268
+ current = limits.get(key)
269
+ if current is not None and kind == 'number':
270
+ current /= 2**30
271
+ rows.append(({'key': key, 'label': label, 'type': kind, 'min': 0,
272
+ 'help': 'Live limit; Core may replace it on registration. Empty = no local limit.'}, current))
273
+ return rows
274
+ pair = section == 'Model'
275
+ if (pair and not self.model) or ('pair' if pair else 'box') in data.get('errors', {}):
276
+ return []
277
+ box = data.get('box') or {}
278
+ accepted = set(box.get('accepts') or []) | set(box.get('box_only_accepts') or [])
279
+ schema = KNOB_SCHEMA if pair else [s for s in KNOB_SCHEMA + BOX_SCHEMA if s['key'] in accepted]
280
+ if section in ('Drives', 'Central'):
281
+ schema = [s for s in schema if s.get('group') == 'drives']
282
+ elif section == 'Auto':
283
+ schema = [s for s in schema if s.get('group') != 'drives']
284
+ own = ((data.get('pair') or {}).get('settings') or {}) if pair else {
285
+ **(box.get('settings') or {}), **(box.get('box_only') or {})}
286
+ return [(spec, own.get(spec['key'])) for spec in schema]
287
+
288
+ def edit(self, section, data, selected=None):
289
+ if section == 'Connections':
290
+ self.message('Connection settings', 'Server addresses and access settings come from the Wrapper installation configuration.')
291
+ return
292
+ if section == 'Worker':
293
+ return self.worker_limits(data, selected=selected)
294
+ pair = section == 'Model'
295
+ if pair and not self.model:
296
+ self.message('Choose a model first', 'Open settings from the highlighted model in the model picker.')
297
+ return
298
+ source = 'pair' if pair else 'box'
299
+ if source in data.get('errors', {}):
300
+ self.message('Settings unavailable', data['errors'][source])
301
+ return
302
+ rows = self.fields(section, data)
303
+ labels = [spec['label'] + ': ' + (json.dumps(value, ensure_ascii=False) if value is not None else 'inherit')
304
+ for spec, value in rows]
305
+ pick = self.choose(section + ' · choose a setting', labels) if selected is None else selected
306
+ if pick is None or not 0 <= pick < len(rows): return
307
+ spec, current = rows[pick]
308
+ key = spec['key']
309
+ if key in ('drive_roles', 'drive_budgets'):
310
+ return self.drive(key, data)
311
+ value = self.select_value(spec, current, data)
312
+ if value is CANCEL: return
313
+ return self.save('/v1/settings/pair' if pair else '/v1/settings/box', {key: value}, self.model if pair else None)
314
+
315
+ def worker_limits(self, data, selected=None):
316
+ if 'limits' in data.get('errors', {}):
317
+ self.message('Resource limits unavailable', data['errors']['limits'])
318
+ return
319
+ rows = self.fields('Worker', data)
320
+ pick = self.choose('Live worker limits · Core may replace these on registration',
321
+ [spec['label'] for spec, _ in rows]) if selected is None else selected
322
+ if pick is None or not 0 <= pick < len(rows): return
323
+ spec, current = rows[pick]
324
+ value = self.select_value(spec, current, data)
325
+ if value is CANCEL: return
326
+ if value is not None and spec['type'] == 'number':
327
+ value = int(value * 2**30)
328
+ return self.save_body('/limits', {spec['key']: value})
@@ -0,0 +1,109 @@
1
+ """Wrapper terminal setting descriptions, adapted from Core config/knob_schema.
2
+
3
+ The running Wrapper accepts list gates box controls. No Core dependency.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ # The per-(worker, model) knobs the wrapper honours (fitevict pair_settings).
8
+ # The console builds its editor from this list and Core validates against it.
9
+ KNOB_SCHEMA = [
10
+ {'key':'min_decode_tok_s','label':'Decode target (tokens/s)','type':'number','min':0.01,'group':'cpu',
11
+ 'help':'Minimum predicted decode throughput. Derives a CPU minimum when a curve is known; an unmeasured trial is explicitly unknown, never a throughput guarantee.'},
12
+ {'key':'disk_mmap','label':'Disk-backed mmap','type':'bool','group':'placement',
13
+ 'help':'Allow native GGUF CPU inference backed by disk as a final Auto fallback. Default off. Slowest or low-quality tolerance permits it.'},
14
+ {'key':'disk_mmap_cache_gib','label':'Mapped working set (GiB)','type':'number','min':0.25,'group':'placement',
15
+ 'help':'RAM reservation for mapped weight pages, plus all anonymous memory. Default 4 GiB. This is not a kernel RSS limit.'},
16
+ {'key': 'auto_goal', 'label': 'Auto goal', 'type': 'select', 'options': ['quality', 'fast'],
17
+ 'group': 'placement', 'help': 'How Auto ranks the admissible placements: best quality or fastest. None = inherit the box blanket; with no preference anywhere every model lists its load options.'},
18
+ {'key': 'tolerance', 'label': 'Tolerance', 'type': 'select', 'options': ['fast', 'slow', 'slowest', 'low-quality'],
19
+ 'group': 'placement', 'help': 'The floor on the placement ladder: fast = GPU only; slow = + MoE split; slowest = + RAM placements; low-quality = + 4-bit and lower quants. Tolerance determines eligibility; Auto goal ranks the permitted placements.'},
20
+ {'key': 'alloc_mode', 'label': 'Placement', 'type': 'select', 'options': ['gpu-only', 'ram-only', 'max-gpu', 'max-ram', 'explicit'],
21
+ 'group': 'placement', 'help': 'Pin a placement mode. explicit = the MoE expert split / explicit budgets. Empty = Auto (GPU only, then MoE split, then 4-bit, then RAM).'},
22
+ {'key': 'gguf_file', 'label': 'Quant file', 'type': 'quant', 'group': 'placement',
23
+ 'help': 'Serve this quant file instead of the elected one. Empty = the elected quant with Auto stepping.'},
24
+ {'key': 'moe', 'label': 'MoE expert split', 'type': 'bool', 'group': 'placement',
25
+ 'help': 'Keep the MoE experts in host RAM and the attention on the GPU.'},
26
+ {'key': 'n_gpu_layers', 'label': 'GPU layers', 'type': 'int', 'min': -1, 'max': 1000, 'group': 'placement',
27
+ 'help': '-1 = every layer on the GPU; 0 = none; N = an explicit split.'},
28
+ {'key': 'n_cpu_moe', 'label': 'MoE layers on CPU', 'type': 'int', 'min': 0, 'max': 1000, 'group': 'placement'},
29
+ {'key': 'gpu_mem_gib', 'label': 'VRAM budget (GiB)', 'type': 'number', 'min': 0, 'group': 'placement'},
30
+ {'key': 'cpu_mem_gib', 'label': 'RAM budget (GiB)', 'type': 'number', 'min': 0, 'group': 'placement'},
31
+ {'key': 'cpu_cores', 'label': 'CPU cores', 'type': 'int', 'min': 1, 'max': 1024, 'group': 'cpu'},
32
+ {'key': 'cpu_floor_cores', 'label': 'CPU floor (cores)', 'type': 'int', 'min': 0, 'max': 1024, 'group': 'cpu',
33
+ 'help': 'A host-resident placement is admissible only if it would get at least this many physical cores.'},
34
+ {'key': 'kv_cache_type', 'label': 'KV cache type', 'type': 'select', 'options': ['f16', 'q8_0', 'q4_0'], 'group': 'context'},
35
+ {'key': 'flash_attn', 'label': 'Flash attention', 'type': 'bool', 'group': 'context'},
36
+ {'key': 'ctx_preset', 'label': 'Context preset', 'type': 'select', 'options': ['low', 'medium', 'high', 'xhigh'], 'group': 'context'},
37
+ {'key': 'ctx_pct', 'label': 'Max context (% of trained)', 'type': 'int', 'min': 1, 'max': 100, 'group': 'context'},
38
+ {'key': 'ctx_min_pct', 'label': 'Min context (% of trained)', 'type': 'int', 'min': 1, 'max': 100, 'group': 'context'},
39
+ {'key': 'ctx_range_enabled', 'label': 'Context range', 'type': 'bool', 'group': 'context',
40
+ 'help': 'Allow a polite-eviction window between min and max context.'},
41
+ {'key': 'ctx_yield', 'label': 'Shrink instead of evict', 'type': 'bool', 'group': 'context'},
42
+ {'key': 'ctx_restore', 'label': 'Grow back when idle', 'type': 'bool', 'group': 'context'},
43
+ {'key': 'max_wait_s', 'label': 'Max wait (seconds)', 'type': 'int', 'min': 1, 'max': 86400, 'group': 'admission'},
44
+ {'key': 'residency', 'label': 'Residency', 'type': 'select', 'options': ['static'], 'group': 'admission',
45
+ 'help': 'static = never evicted once loaded.'},
46
+ {'key': 'quality_alternatives', 'label': 'Quality alternatives', 'type': 'select', 'options': ['auto', 'none'], 'group': 'admission',
47
+ 'help': 'Whether Auto may step down to a smaller approved quant of this model.'},
48
+ {'key': 'blocked', 'label': 'Blocked', 'type': 'bool', 'group': 'admission', 'help': 'Refuse calls for this model on this worker.'},
49
+ {'key': 'fit_order', 'label': 'Fit order', 'type': 'list', 'group': 'admission',
50
+ 'help': 'Comma-separated steps for @fit: gpu-only, moe, max-gpu, 4bit-gpu-only, 4bit-max-gpu, ram-only.'},
51
+ # The original console's own knobs (quant preference list, 4-bit, explicit
52
+ # placement bands and priorities): stored on the pair like every other knob.
53
+ # Blanket preferences (directions/auto-multiplexing): what Auto may change.
54
+ {'key': 'allowed_placements', 'label': 'Allowed placements', 'type': 'list', 'group': 'admission',
55
+ 'options': ['gpu-only', 'moe', '4bit', 'ram-only', 'max-ram', 'max-gpu'],
56
+ 'help': 'The placements Auto may use. Empty = all (GPU preferred). Remove ram-only to disable CPU execution.'},
57
+ {'key': 'wait_before_quality', 'label': 'Wait before a quality change', 'type': 'bool', 'group': 'admission',
58
+ 'help': 'Wait (within max wait) for expected room — a call in flight finishing — before stepping to an approved alternative.'},
59
+ {'key': 'resident_priority', 'label': 'Resident priority', 'type': 'int', 'min': 0, 'max': 1000, 'group': 'admission',
60
+ 'help': 'Eviction band: a higher priority resident is evicted after every lower one.'},
61
+ {'key': 'latency_target_s', 'label': 'Latency target (s)', 'type': 'number', 'min': 0, 'group': 'admission',
62
+ 'help': 'An admission preference: a measured response estimate above it ranks after every plan within it.'},
63
+ {'key': 'quants', 'label': 'Quant preference', 'type': 'list', 'group': 'placement',
64
+ 'help': 'Ordered quant files; the allocated quant is the first that fits.'},
65
+ {'key': 'bnb_4bit', 'label': '4-bit (bitsandbytes)', 'type': 'bool', 'group': 'placement'},
66
+ {'key': 'attention_gpu_layers', 'label': 'Attention layers on GPU', 'type': 'int', 'min': 0, 'max': 10000, 'group': 'placement'},
67
+ {'key': 'experts_cpu_layers', 'label': 'Expert layers on CPU', 'type': 'int', 'min': 0, 'max': 10000, 'group': 'placement'},
68
+ {'key': 'explicit_spill', 'label': 'Explicit spill', 'type': 'select', 'options': ['prefer-ram', 'prefer-gpu'], 'group': 'placement'},
69
+ {'key': 'gpu_model_pct', 'label': 'Model % on GPU', 'type': 'number', 'min': 0, 'group': 'placement'},
70
+ {'key': 'leniency_pct', 'label': 'Leniency %', 'type': 'number', 'min': 0, 'group': 'placement'},
71
+ {'key': 'priority_device', 'label': 'Priority device', 'type': 'select', 'options': ['gpu', 'ram'], 'group': 'placement'},
72
+ {'key': 'gpu_mem_gib_deviation_pct', 'label': 'VRAM band %', 'type': 'number', 'min': 0, 'group': 'placement'},
73
+ {'key': 'cpu_mem_gib_deviation_pct', 'label': 'RAM band %', 'type': 'number', 'min': 0, 'group': 'placement'},
74
+ {'key': 'ctx_deviation_pct', 'label': 'Context band %', 'type': 'number', 'min': 0, 'group': 'context'},
75
+ {'key': 'threads', 'label': 'Threads', 'type': 'int', 'min': 1, 'max': 4096, 'group': 'cpu'},
76
+ {'key': 'priority', 'label': 'Priority', 'type': 'number', 'min': 0, 'group': 'admission'},
77
+ ]
78
+ KNOBS_BY_KEY = {k['key']: k for k in KNOB_SCHEMA}
79
+
80
+
81
+ # The BOX's own settings (wrapper /v1/settings/box, 0.1.111+): not per model, never inherited by
82
+ # one — the help model that reads plain language, and the drives (roles, budgets, the movers).
83
+ BOX_SCHEMA = [
84
+ {'key': 'assist_model', 'label': 'Assistant model', 'type': 'model', 'group': 'box',
85
+ 'help': 'The catalog model that turns plain language said to HugpyHelper into one of its commands.'},
86
+ {'key': 'drive_roles', 'label': 'Drive roles', 'type': 'drive_roles', 'options': ['central', 'worker', 'immutable'],
87
+ 'group': 'drives', 'help': 'Per path: central = the source of record (loaded from, uploaded from, copied to; never '
88
+ 'reaped); worker = the fast drive the box loads from, within its budget, reaped to fit; immutable = read in place, '
89
+ 'never written. Over the env designation (HUGPY_CENTRAL_DRIVES / HUGPY_WORKER_DRIVES); MODELS_HOME is a worker '
90
+ 'drive by default.'},
91
+ {'key': 'drive_budgets', 'label': 'Drive budgets (GiB)', 'type': 'drive_budgets', 'group': 'drives',
92
+ 'help': 'Per worker or central drive: the GiB of models it may hold. Never above the drive (or its env designation).'},
93
+ {'key': 'upload_to_worker', 'label': 'Upload to worker drive', 'type': 'bool', 'group': 'drives',
94
+ 'help': 'A called model that lives only on a central drive is copied whole to the worker drive first, then loaded '
95
+ 'from there. On by default under a core (a central is every worker’s central: the second load is never a time trap), off wrapper-only (env HUGPY_UPLOAD_TO_WORKER).'},
96
+ {'key': 'reap_to_fit', 'label': 'Reap to fit', 'type': 'bool', 'group': 'drives',
97
+ 'help': 'An upload that does not fit removes worker copies, least recently called first, never resident or '
98
+ 'protected, only when a central drive holds the exact duplicate. On by default under a core, off wrapper-only (env '
99
+ 'HUGPY_REAP_TO_FIT); set on, it turns the other two on unless they are set either way.'},
100
+ {'key': 'copy_to_central', 'label': 'Copy to central', 'type': 'bool', 'group': 'drives',
101
+ 'help': 'A model whose home is a worker drive is copied to a central drive with room (event-driven, in the '
102
+ 'background). Off by default (env HUGPY_COPY_TO_CENTRAL) — under a core central’s own copier owns writes to central.'},
103
+ {'key': 'central_serves', 'label': 'Central serves', 'type': 'bool', 'group': 'drives',
104
+ 'help': 'May a load read a model straight from a central drive. Off by default when the box is under a core '
105
+ '(central is storage only: the model is staged to a worker drive first, and a call that cannot be staged is refused '
106
+ 'with the reason); on for a wrapper-only build. Env HUGPY_CENTRAL_SERVES (wrapper 0.1.119).'},
107
+ ]
108
+
109
+
@@ -0,0 +1,385 @@
1
+ """Settings snapshots and an editable stdlib terminal viewer."""
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ import datetime
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+ import textwrap
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+
13
+ from .settings_controls import SettingsEditor
14
+ from .settings_widgets import settings_input, mouse_event
15
+
16
+ AUTO_KEYS = ("auto_goal", "tolerance", "quality_alternatives", "allowed_placements", "max_wait_s",
17
+ "wait_before_quality", "min_decode_tok_s", "cpu_floor_cores", "resident_priority",
18
+ "latency_target_s", "disk_mmap", "disk_mmap_cache_gib")
19
+ MODEL_KEYS = AUTO_KEYS + ("alloc_mode", "gguf_file", "quants", "moe", "bnb_4bit", "n_gpu_layers",
20
+ "n_cpu_moe", "gpu_mem_gib", "cpu_mem_gib", "cpu_cores", "kv_cache_type",
21
+ "flash_attn", "ctx_preset", "ctx_pct", "ctx_min_pct", "ctx_range_enabled",
22
+ "ctx_yield", "ctx_restore", "residency", "blocked", "fit_order")
23
+ ROLE_LABELS = {"central": "Central library", "worker": "Worker storage", "immutable": "Read-only source"}
24
+
25
+
26
+ def redact(value):
27
+ if isinstance(value, dict):
28
+ out = {}
29
+ for key, item in value.items():
30
+ name = str(key).lower()
31
+ secret = name in ("api_key", "token", "secret", "password", "authorization", "secret_hash") or name.endswith(
32
+ ("_api_key", "_token", "_password", "_secret", "_secret_hash"))
33
+ summary = isinstance(item, dict) and set(item) <= {"configured", "display", "source"}
34
+ out[key] = ("****" if item else "not set") if secret and not summary else redact(item)
35
+ return out
36
+ if isinstance(value, list):
37
+ return [redact(item) for item in value]
38
+ return value
39
+
40
+
41
+ def local_record(base):
42
+ config = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
43
+ for path in (config / "hugpy/wrapper.endpoint.json", Path("/etc/hugpy/wrapper.endpoint.json")):
44
+ try:
45
+ record = json.loads(path.read_text())
46
+ if str(record.get("url", "")).rstrip("/") == base.rstrip("/"):
47
+ return record
48
+ except (OSError, ValueError):
49
+ pass
50
+ return {}
51
+
52
+
53
+ def read_endpoint(gateway, base, path, timeout):
54
+ headers = {"Accept": "application/json"}
55
+ if getattr(gateway, "api_key", ""):
56
+ headers["Authorization"] = "Bearer " + gateway.api_key
57
+ request = urllib.request.Request(base + path, headers=headers, method="GET")
58
+ with urllib.request.urlopen(request, timeout=min(timeout, 10)) as response:
59
+ data = json.load(response)
60
+ if not isinstance(data, dict):
61
+ raise ValueError("invalid settings response")
62
+ return data
63
+
64
+
65
+ def snapshot(gateway, base, model=None, timeout=30):
66
+ base = base.rstrip('/')
67
+ if base.endswith('/v1'):
68
+ base = base[:-3]
69
+ paths = {"health": "/health", "limits": "/limits", "box": "/v1/settings/box",
70
+ "storage": "/v1/drives", "runtime": "/v1/settings/runtime", "keys": "/v1/keys?all=1"}
71
+ if model:
72
+ paths["pair"] = "/v1/settings/pair?" + urllib.parse.urlencode({"model_key": model})
73
+ data = {"read_only": True, "endpoint": base, "model": model, "errors": {},
74
+ "captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
75
+ "client_api_key": {"configured": bool(getattr(gateway, "api_key", "")),
76
+ "display": "****" if getattr(gateway, "api_key", "") else "not set"}}
77
+ with ThreadPoolExecutor(max_workers=len(paths)) as pool:
78
+ pending = {name: pool.submit(read_endpoint, gateway, base, path, timeout) for name, path in paths.items()}
79
+ for name, future in pending.items():
80
+ try:
81
+ data[name] = future.result()
82
+ except (OSError, ValueError) as exc:
83
+ data[name] = {}
84
+ data["errors"][name] = ("Admin API key required" if getattr(exc, "code", None) in (401, 403)
85
+ else str(exc))
86
+ if not data.get("runtime"):
87
+ record = local_record(base)
88
+ if record.get("runtime_settings"):
89
+ data["runtime"] = record["runtime_settings"]
90
+ data["runtime_source"] = record.get("runtime_settings_source", "local install record")
91
+ data["errors"].pop("runtime", None)
92
+ else:
93
+ data["runtime_source"] = "running Wrapper API"
94
+ return redact(data)
95
+
96
+
97
+ def knob_rows(data, keys):
98
+ pair, box = data.get("pair") or {}, data.get("box") or {}
99
+ own = pair.get("settings") or {}
100
+ blanket = pair.get("box") if pair.get("box") is not None else box.get("settings") or {}
101
+ effective = pair.get("effective") or {}
102
+ defaults = {"auto_goal": "quality", "max_wait_s": 120,
103
+ "quality_alternatives": "auto"}
104
+ rows = []
105
+ for key in keys:
106
+ if own.get(key) is not None:
107
+ value, origin = effective.get(key, own[key]), "model override"
108
+ elif blanket.get(key) is not None:
109
+ value, origin = effective.get(key, blanket[key]), "box default"
110
+ elif effective.get(key) is not None:
111
+ value, origin = effective[key], "reported effective"
112
+ elif key in defaults:
113
+ value, origin = defaults[key], "installed-version default"
114
+ else:
115
+ value, origin = "Auto / not explicitly set", "runtime rule"
116
+ rows.append(f"{key}: {format_value(value)} [{origin}]")
117
+ return rows
118
+
119
+
120
+ def format_value(value):
121
+ return json.dumps(value, ensure_ascii=False, sort_keys=True) if not isinstance(value, str) else value
122
+
123
+
124
+ def size(value):
125
+ import math
126
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or value < 0:
127
+ return 'unknown'
128
+ return f'{value / 2**30:.1f} GiB' if value >= 2**30 else f'{value / 2**20:.0f} MiB'
129
+
130
+
131
+ def sections(data):
132
+ health, runtime = data.get("health") or {}, data.get("runtime") or {}
133
+ pair, box, storage = data.get("pair") or {}, data.get("box") or {}, data.get("storage") or {}
134
+ access, database = runtime.get("access") or {}, runtime.get("database") or {}
135
+ keys = data.get("keys", {}).get("keys") or []
136
+ revoked = [key for key in keys if key.get("revoked_at")]
137
+ connections = [
138
+ "Wrapper endpoint: " + data["endpoint"],
139
+ "Core endpoint: " + str(runtime.get("core_endpoint") or health.get("fleet_url") or "not configured / not reported"),
140
+ "Central endpoint: " + str(runtime.get("central_endpoint") or "not configured / not reported"),
141
+ "Fleet endpoint: " + str(runtime.get("fleet_endpoint") or "not configured / not reported"),
142
+ "Database backend: " + str(database.get("engine") or "not reported"),
143
+ "Database file: " + str(database.get("file") or "not reported / not applicable"),
144
+ "Database network endpoint: " + str(database.get("network_endpoint") or "none reported"),
145
+ "Connection report source: " + data.get("runtime_source", "not available"),
146
+ "Connection report captured: " + str(runtime.get("captured_at") or "not reported"),
147
+ "", "ACCESS",
148
+ "Client api_key: " + data["client_api_key"]["display"],
149
+ "Server api_key: " + str((access.get("server_api_key") or {}).get("display") or "not reported"),
150
+ "API-key requirement: " + str(access.get("require_key") or "not reported"),
151
+ "Trusted networks: " + format_value(access.get("trusted_networks", "not reported")),
152
+ "Trusted proxies: " + format_value(access.get("trusted_proxies", "not reported")),
153
+ "Caller blacklist: " + str((access.get("caller_blacklist") or {}).get("note") or "not reported"),
154
+ "Revoked API keys: " + (str(len(revoked)) if "keys" not in data["errors"] else "not available"),
155
+ ]
156
+ for key in keys:
157
+ state = "revoked" if key.get("revoked_at") else "not revoked"
158
+ connections.append(f" {key.get('name') or key.get('id')}: scope {key.get('scope')} | {state} | expires {key.get('expires_at')}")
159
+ if data["errors"]:
160
+ connections += ["", "UNAVAILABLE FIELDS"] + [f"{name}: {error}" for name, error in data["errors"].items()]
161
+
162
+ auto = ["Auto goal ranks permitted placements; tolerance controls their eligibility.",
163
+ "Defaults below come from this installed Wrapper version when not reported by the API.", ""]
164
+ if "box" in data["errors"] or "pair" in data["errors"]:
165
+ auto.append("Settings could not be fully read; effective values are unavailable.")
166
+ else:
167
+ auto += knob_rows(data, AUTO_KEYS)
168
+ auto += ["", "BOX-ONLY SETTINGS"] + [f"{key}: {format_value(value)}" for key, value in sorted((box.get("box_only") or {}).items())]
169
+
170
+ model = ["Selected model: " + str(data.get("model") or "none")]
171
+ if data.get("model") and "pair" not in data["errors"]:
172
+ all_keys = set(MODEL_KEYS) | set(pair.get("settings") or {}) | set(pair.get("box") or {}) | set(pair.get("effective") or {})
173
+ model += knob_rows(data, sorted(all_keys))
174
+ model += ["", "Environment profile: " + format_value(pair.get("env_profile")),
175
+ "Quant fallback ceiling: " + format_value(pair.get("quant_fallback"))]
176
+ else:
177
+ model.append(data["errors"].get("pair", "Select a model to inspect its own and inherited knobs."))
178
+
179
+ worker = ["Worker: " + str(health.get("worker") or "not reported"),
180
+ "Status: " + str(health.get("status") or "not reported"),
181
+ "Version: " + str(health.get("version") or "not reported"),
182
+ "Listen host: " + str(runtime.get("listen_host") or "not reported"),
183
+ "Listen port: " + str(runtime.get("listen_port") or "not reported"),
184
+ "", "RESOURCE LIMITS AND TIERS"] + json.dumps(data.get("limits") or {}, indent=2).splitlines()
185
+
186
+ central = ["Core connection: " + str(runtime.get("core_endpoint") or health.get("fleet_url") or "not configured / not reported"),
187
+ "Central connection: " + str(runtime.get("central_endpoint") or "not configured / not reported"),
188
+ "Reported by this Wrapper; remote Core database settings are separate.", "", "CENTRAL LIBRARY DRIVES"]
189
+ central += [drive.get("path", "not reported") for drive in storage.get("central_drives", [])] or ["None reported"]
190
+
191
+ drives = ["Read-only source = immutable to Wrapper; its source files are not changed.",
192
+ "Central library holds shared originals. Worker storage holds local working copies.", ""]
193
+ all_drives = storage.get("drives", []) + storage.get("central_drives", [])
194
+ if not all_drives:
195
+ drives.append(data["errors"].get("storage", "No drives reported"))
196
+ for drive in all_drives:
197
+ tier = drive.get("tier") or {}
198
+ drives += [ROLE_LABELS.get(drive.get("role"), drive.get("role") or "Unclassified") + ": " + drive.get("path", "unknown"),
199
+ f" role source: {drive.get('source')} | readable: {drive.get('readable')} | writable by Wrapper: {drive.get('writable')}",
200
+ f" filesystem: {size(drive.get('total'))} | free: {size(drive.get('free'))} | held: {size(drive.get('held'))}",
201
+ f" effective budget: {size(tier.get('effective'))} | room: {size(drive.get('room'))}",
202
+ " mount: " + str(drive.get("mount") or "not reported"), ""]
203
+ drives.append("TRANSFER SETTINGS")
204
+ for key, value in (storage.get("switches") or {}).items():
205
+ drives.append(f"{key}: {format_value(value)}")
206
+ return [("Connections", connections), ("Auto", auto), ("Model", model),
207
+ ("Worker", worker), ("Central", central), ("Drives", drives)]
208
+
209
+
210
+ def _screen(screen, data, editor=None, drain=None):
211
+ with settings_input(screen):
212
+ return _settings_screen(screen, data, editor, drain)
213
+
214
+
215
+ def _settings_screen(screen, data, editor=None, drain=None):
216
+ import curses
217
+ screen.keypad(True)
218
+ if editor:
219
+ editor.screen = screen
220
+ try:
221
+ curses.curs_set(0)
222
+ except curses.error:
223
+ pass
224
+ tab, offset = (getattr(editor, 'tab', 0), getattr(editor, 'offset', 0)) if editor else (0, 0)
225
+ report = getattr(editor, 'report', False)
226
+ cursors = dict(getattr(editor, 'cursors', {}))
227
+ pages = sections(data)
228
+
229
+ def remember():
230
+ if editor:
231
+ editor.tab, editor.offset, editor.report = tab, offset, report
232
+ editor.cursors = dict(cursors)
233
+
234
+ while True:
235
+ height, width = screen.getmaxyx()
236
+ screen.erase()
237
+
238
+ def put(y, text, attr=0):
239
+ if 0 <= y < height and width > 1:
240
+ clean = ''.join(c if c.isprintable() else ' ' for c in str(text))
241
+ try:
242
+ screen.addnstr(y, 0, clean, width - 1, attr)
243
+ except curses.error:
244
+ pass
245
+
246
+ section = pages[tab][0]
247
+ fields = editor.fields(section, data) if editor else []
248
+ selecting = bool(fields) and not report
249
+ put(0, 'WRAPPER SETTINGS' + (' — Up/Down select, Enter edit' if selecting else ' — details'), curses.A_BOLD)
250
+ put(1, ' | '.join(('[' + title + ']') if i == tab else title for i, (title, _) in enumerate(pages)))
251
+ if selecting:
252
+ cursor = max(0, min(cursors.get(tab, 0), len(fields) - 1))
253
+ cursors[tab] = cursor
254
+ count = max(1, height - 9)
255
+ start = cursor // count * count
256
+ for y, (spec, value) in enumerate(fields[start:start + count], 3):
257
+ active = start + y - 3 == cursor
258
+ label = spec['label'] + ': ' + (format_value(value) if value is not None else 'inherit')
259
+ if spec.get('type') in ('bool', 'select', 'model', 'quant'):
260
+ label += ' [v]'
261
+ put(y, ('> ' if active else ' ') + label, curses.A_REVERSE if active else 0)
262
+ spec, value = fields[cursor]
263
+ put(height - 5, str(cursor + 1) + '/' + str(len(fields)) + ' · ' + spec['label'], curses.A_BOLD)
264
+ help_text = spec.get('help') or 'Enter edits this setting; choose inherit to remove an override.'
265
+ for y, line in enumerate(textwrap.wrap(help_text, max(1, width - 1))[:3], height - 4):
266
+ put(y, line)
267
+ footer = 'Click/Up/Down | Enter edit | Tab section | I details | F5 refresh | Esc back'
268
+ else:
269
+ lines = []
270
+ for line in pages[tab][1]:
271
+ lines += textwrap.wrap(line, max(1, width - 2), replace_whitespace=True) or ['']
272
+ count = max(1, height - 5)
273
+ offset = max(0, min(offset, len(lines) - count))
274
+ for y, line in enumerate(lines[offset:offset + count], 3):
275
+ put(y, line)
276
+ footer = 'Up/Down scroll | Tab section | I settings | F5 refresh | Esc back' if fields else 'Up/Down scroll | Tab section | F5 refresh | Esc back'
277
+ put(height - 1, footer)
278
+ screen.refresh()
279
+ key = screen.getch()
280
+ if drain:
281
+ drain()
282
+ if key == curses.KEY_MOUSE:
283
+ event = mouse_event(screen)
284
+ if not event:
285
+ continue
286
+ if event['wheel']:
287
+ key = curses.KEY_UP if event['wheel'] < 0 else curses.KEY_DOWN
288
+ elif event['click'] and event['y'] == 1:
289
+ x = 0
290
+ for i, (name, _) in enumerate(pages):
291
+ length = len(name) + (2 if i == tab else 0)
292
+ if x <= event['x'] < x + length:
293
+ tab, offset, report = i, 0, False
294
+ break
295
+ x += length + 3
296
+ continue
297
+ elif event['click'] and selecting and 3 <= event['y'] < 3 + min(count, len(fields) - start):
298
+ cursors[tab] = start + event['y'] - 3
299
+ key = 10
300
+ else:
301
+ continue
302
+ if key in (27, curses.KEY_F10, 3, 4):
303
+ remember()
304
+ return 'close'
305
+ if key in (curses.KEY_F5, 18):
306
+ remember()
307
+ return 'refresh'
308
+ if editor and key in (10, 13, curses.KEY_ENTER, ord('e'), ord('E')) and height >= 10 and width >= 30:
309
+ remember()
310
+ editor.anchor = 3 + cursors[tab] - start if selecting else 3
311
+ if editor.edit(section, data, selected=cursors[tab] if selecting else None):
312
+ return 'refresh'
313
+ # Cancelled editors have no server changes to reload. Redraw in place.
314
+ continue
315
+ if fields and key in (ord('i'), ord('I')):
316
+ report, offset = not report, 0
317
+ elif key in (9, curses.KEY_RIGHT, curses.KEY_LEFT, curses.KEY_BTAB):
318
+ tab = (tab + (-1 if key in (curses.KEY_LEFT, curses.KEY_BTAB) else 1)) % len(pages)
319
+ offset, report = 0, False
320
+ elif selecting:
321
+ if key == curses.KEY_UP:
322
+ cursors[tab] = max(0, cursor - 1)
323
+ elif key == curses.KEY_DOWN:
324
+ cursors[tab] = min(len(fields) - 1, cursor + 1)
325
+ elif key == curses.KEY_PPAGE:
326
+ cursors[tab] = max(0, cursor - count)
327
+ elif key == curses.KEY_NPAGE:
328
+ cursors[tab] = min(len(fields) - 1, cursor + count)
329
+ elif key == curses.KEY_HOME:
330
+ cursors[tab] = 0
331
+ elif key == curses.KEY_END:
332
+ cursors[tab] = len(fields) - 1
333
+ elif key == curses.KEY_UP:
334
+ offset -= 1
335
+ elif key == curses.KEY_DOWN:
336
+ offset += 1
337
+ elif key == curses.KEY_PPAGE:
338
+ offset -= count
339
+ elif key == curses.KEY_NPAGE:
340
+ offset += count
341
+ elif key == curses.KEY_HOME:
342
+ offset = 0
343
+ elif key == curses.KEY_END:
344
+ offset = len(lines)
345
+
346
+
347
+ def run_settings(screen, gateway, base, model=None, timeout=30, drain=None, wait=None):
348
+ base = base.rstrip('/')
349
+ if base.endswith('/v1'):
350
+ base = base[:-3]
351
+ editor = SettingsEditor(screen, gateway, base, model, timeout, drain=drain, wait=wait)
352
+ while True:
353
+ read = lambda: snapshot(gateway, base, model, timeout)
354
+ data = wait('Reading Wrapper settings', read) if wait else read()
355
+ if _screen(screen, data, editor=editor, drain=drain) != 'refresh':
356
+ return 0
357
+
358
+
359
+ def show_settings(gateway, base, model=None, timeout=30, as_json=False):
360
+ base = base.rstrip('/')
361
+ if base.endswith('/v1'):
362
+ base = base[:-3]
363
+ editor = SettingsEditor(None, gateway, base, model, timeout)
364
+ while True:
365
+ data = snapshot(gateway, base, model, timeout)
366
+ if as_json:
367
+ print(json.dumps(data, indent=2, ensure_ascii=False))
368
+ return 1 if data["errors"] else 0
369
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
370
+ for title, lines in sections(data):
371
+ print("\n" + title.upper() + " — READ ONLY")
372
+ print("\n".join(lines))
373
+ return 1 if data["errors"] else 0
374
+ try:
375
+ import curses
376
+ except ImportError as exc:
377
+ print("Settings viewer unavailable: " + str(exc), file=sys.stderr)
378
+ return 1
379
+ try:
380
+ action = curses.wrapper(_screen, data, editor)
381
+ except curses.error as exc:
382
+ print("Settings viewer unavailable: " + str(exc), file=sys.stderr)
383
+ return 1
384
+ if action != "refresh":
385
+ return 0
@@ -0,0 +1,241 @@
1
+ """Small terminal dropdowns, mouse input and a Wrapper-host folder picker."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+
9
+ try:
10
+ import curses
11
+ except ImportError:
12
+ curses = None
13
+
14
+ CANCEL = object()
15
+
16
+
17
+ def mouse_event(screen):
18
+ forwarded = getattr(screen, 'settings_mouse_event', None)
19
+ if forwarded is not None:
20
+ screen.settings_mouse_event = None
21
+ return forwarded
22
+ try:
23
+ _, x, y, _, state = curses.getmouse()
24
+ wheel = -1 if state & getattr(curses, 'BUTTON4_PRESSED', 0) else 1 if state & getattr(curses, 'BUTTON5_PRESSED', 0) else 0
25
+ click = bool(state & (curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED | curses.BUTTON1_DOUBLE_CLICKED))
26
+ return {'x': x, 'y': y, 'wheel': wheel, 'click': click}
27
+ except curses.error:
28
+ return None
29
+
30
+
31
+ def read_directory(gateway, base, path, timeout, offset=0, query='', hidden=False):
32
+ params = urllib.parse.urlencode({'path': path, 'offset': offset, 'q': query, 'hidden': int(hidden)})
33
+ headers = {'Accept': 'application/json'}
34
+ if getattr(gateway, 'api_key', ''):
35
+ headers['Authorization'] = 'Bearer ' + gateway.api_key
36
+ request = urllib.request.Request(base + '/v1/drives/browse?' + params, headers=headers)
37
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
38
+ def redirect_request(self, *a, **kw): return None
39
+ try:
40
+ with urllib.request.build_opener(NoRedirect).open(request, timeout=min(timeout, 10)) as response:
41
+ doc = json.load(response)
42
+ except urllib.error.HTTPError as exc:
43
+ try:
44
+ error = json.loads(exc.read()).get('error', {})
45
+ detail = error.get('message') if isinstance(error, dict) else str(error)
46
+ except (ValueError, OSError):
47
+ detail = ''
48
+ if exc.code == 404 and detail in ('', 'not found', 'no such route'):
49
+ detail = 'This running Wrapper does not support folder browsing yet. Update/restart it, or enter a path.'
50
+ raise ValueError('HTTP %s: %s' % (exc.code, detail or exc.reason)) from None
51
+ if not isinstance(doc, dict) or not isinstance(doc.get('path'), str) or not isinstance(doc.get('directories'), list):
52
+ raise ValueError('The running Wrapper does not provide a directory browser. Enter a path or update it.')
53
+ return doc
54
+
55
+
56
+ class SettingsWidgets:
57
+ def dropdown(self, title, labels, selected=0):
58
+ """Overlay a choice list on the current settings page."""
59
+ if self.screen is None:
60
+ return self.choose(title, labels, selected)
61
+ h, w = self.screen.getmaxyx()
62
+ if h < 10 or w < 30:
63
+ return None
64
+ selected = max(0, min(selected, len(labels) - 1))
65
+ size_h = min(h - 2, max(6, len(labels) + 4))
66
+ size_w = min(w - 2, max(36, len(title) + 6, max(map(len, labels), default=0) + 6))
67
+ top = max(0, min(getattr(self, 'anchor', 3) + 1, h - size_h - 1))
68
+ left = max(0, min(2, w - size_w))
69
+ popup = curses.newwin(size_h, size_w, top, left)
70
+ count = size_h - 4
71
+ try:
72
+ while True:
73
+ popup.erase()
74
+ popup.box()
75
+ popup.addnstr(0, 2, ' ' + title + ' ', size_w - 4, curses.A_BOLD)
76
+ start = selected // count * count
77
+ for line, label in enumerate(labels[start:start + count], 2):
78
+ i = start + line - 2
79
+ popup.addnstr(line, 2, ('> ' if i == selected else ' ') + label, size_w - 4,
80
+ curses.A_REVERSE if i == selected else 0)
81
+ popup.addnstr(size_h - 2, 2, 'Enter choose | Esc cancel', size_w - 4)
82
+ popup.refresh()
83
+ key = self.key()
84
+ if key in (27, curses.KEY_F10, 3, 4, curses.KEY_RESIZE): return None
85
+ if key in (10, 13, curses.KEY_ENTER): return selected
86
+ if key == curses.KEY_UP: selected = max(0, selected - 1)
87
+ elif key == curses.KEY_DOWN: selected = min(len(labels) - 1, selected + 1)
88
+ elif key == curses.KEY_HOME: selected = 0
89
+ elif key == curses.KEY_END: selected = len(labels) - 1
90
+ elif key == curses.KEY_PPAGE: selected = max(0, selected - count)
91
+ elif key == curses.KEY_NPAGE: selected = min(len(labels) - 1, selected + count)
92
+ elif key == curses.KEY_MOUSE:
93
+ event = mouse_event(self.screen)
94
+ if not event: continue
95
+ if event['wheel']:
96
+ selected = max(0, min(len(labels) - 1, selected + event['wheel']))
97
+ elif event['click']:
98
+ row = event['y'] - top - 2
99
+ if left <= event['x'] < left + size_w and 0 <= row < min(count, len(labels) - start):
100
+ return start + row
101
+ return None
102
+ finally:
103
+ del popup
104
+ self.screen.touchwin()
105
+ self.screen.noutrefresh()
106
+
107
+ def browse_directory(self, data):
108
+ """Browse only the server's filesystem. Never infer a remote path locally."""
109
+ path, offset, query, hidden = '/', 0, '', False
110
+ doc = None
111
+ disks = True
112
+ selected = 0
113
+ while True:
114
+ try:
115
+ doc = self.fetch('Reading directories on the Wrapper host',
116
+ lambda: read_directory(self.gateway, self.base, path, self.timeout, offset, query, hidden))
117
+ except (OSError, ValueError) as exc:
118
+ self.message('Cannot browse that directory', str(exc))
119
+ return CANCEL
120
+ path = doc['path']
121
+ if disks:
122
+ places = {row['path']: row for row in doc.get('locations', []) if row.get('path')}
123
+ storage = data.get('storage') or {}
124
+ for row in storage.get('drives', []) + storage.get('central_drives', []):
125
+ if row.get('path'):
126
+ places[row['path']] = {**places.get(row['path'], {}), **row}
127
+ places = sorted(places.values(), key=lambda row: row['path'].casefold())
128
+ labels = [row['path'] + (' · ' + str(row.get('filesystem') or row.get('role')) if row.get('filesystem') or row.get('role') else '') for row in places]
129
+ pick = self.choose('Disks and locations on the Wrapper host', labels + ['Enter a directory path'])
130
+ if pick is None: return CANCEL
131
+ if pick == len(places):
132
+ value = self.text('Directory on the Wrapper host', path, 'Absolute path; Esc cancels')
133
+ if value is CANCEL: return CANCEL
134
+ path = value
135
+ else:
136
+ path = places[pick]['path']
137
+ offset, query, disks, selected = 0, '', False, 0
138
+ continue
139
+ actions = [('Use this directory', 'select', path), ('.. Parent directory', 'parent', doc['parent']),
140
+ ('Disks and locations…', 'disks', None)]
141
+ if offset:
142
+ actions.append(('Previous page', 'page', max(0, offset - doc.get('limit', 200))))
143
+ if doc.get('more'):
144
+ actions.append(('Next page', 'page', offset + doc.get('limit', 200)))
145
+ rows = actions + [('[link] ' + row['name'] + '/' if row.get('symlink') else '[dir] ' + row['name'] + '/', 'folder', row['path'])
146
+ for row in doc['directories']]
147
+ selected = min(selected, len(rows) - 1)
148
+ reload = False
149
+ while not reload:
150
+ self.screen.erase()
151
+ h, w = self.screen.getmaxyx()
152
+ self.put(0, 'DIRECTORY PICKER · Wrapper host', curses.A_BOLD)
153
+ self.put(1, self.base)
154
+ self.put(2, path)
155
+ free = doc.get('free_bytes')
156
+ self.put(3, ('Free: %.1f GiB · ' % (free / 2**30) if isinstance(free, (int, float)) else '') +
157
+ 'Search: ' + (query or 'all folders') + (' · hidden shown' if hidden else '') +
158
+ (' · listing capped; narrow search' if doc.get('partial') else ''))
159
+ count = max(1, h - 8)
160
+ start = selected // count * count
161
+ for y, row in enumerate(rows[start:start + count], 5):
162
+ active = start + y - 5 == selected
163
+ self.put(y, ('> ' if active else ' ') + row[0], curses.A_REVERSE if active else 0)
164
+ self.put(h - 2, 'Enter open/choose | Space use directory | Left parent | D disks')
165
+ self.put(h - 1, '/ search | G path | H hidden | F5 refresh | Esc cancel')
166
+ self.screen.refresh()
167
+ key = self.key()
168
+ activate = key in (10, 13, curses.KEY_ENTER, curses.KEY_RIGHT)
169
+ if key in (27, curses.KEY_F10, 3, 4): return CANCEL
170
+ if key == curses.KEY_MOUSE:
171
+ event = mouse_event(self.screen)
172
+ if not event: continue
173
+ if event['wheel']:
174
+ selected = max(0, min(len(rows) - 1, selected + event['wheel']))
175
+ elif event['click'] and 5 <= event['y'] < 5 + min(count, len(rows) - start):
176
+ selected, activate = start + event['y'] - 5, True
177
+ if h < 10 or w < 30:
178
+ continue
179
+ if key == ord(' '): return path
180
+ if key in (curses.KEY_LEFT, curses.KEY_BACKSPACE, 127, 8):
181
+ path, offset, query, selected, reload = doc['parent'], 0, '', 0, True
182
+ elif key in (ord('d'), ord('D')):
183
+ disks, reload = True, True
184
+ elif key in (ord('g'), ord('G'), ord('/')):
185
+ value = self.text('Search folders' if key == ord('/') else 'Directory on the Wrapper host',
186
+ query if key == ord('/') else path,
187
+ 'Empty clears search; Ctrl-U clears; Esc cancels' if key == ord('/') else
188
+ 'Absolute folder path; Ctrl-U clears; Esc cancels')
189
+ if value is not CANCEL:
190
+ if key == ord('/'): query = value
191
+ else: path = value
192
+ offset, selected, reload = 0, 0, True
193
+ elif key in (ord('h'), ord('H')):
194
+ hidden, offset, reload = not hidden, 0, True
195
+ elif key == curses.KEY_F5:
196
+ reload = True
197
+ elif activate:
198
+ _, action, value = rows[selected]
199
+ if action == 'select': return path
200
+ if action == 'disks': disks = True
201
+ elif action == 'page': offset = value
202
+ else: path, offset, query = value, 0, ''
203
+ selected, reload = 0, True
204
+ elif key == curses.KEY_UP: selected = max(0, selected - 1)
205
+ elif key == curses.KEY_DOWN: selected = min(len(rows) - 1, selected + 1)
206
+ elif key == curses.KEY_PPAGE: selected = max(0, selected - count)
207
+ elif key == curses.KEY_NPAGE: selected = min(len(rows) - 1, selected + count)
208
+ elif key == curses.KEY_HOME: selected = 0
209
+ elif key == curses.KEY_END: selected = len(rows) - 1
210
+
211
+
212
+ from contextlib import contextmanager
213
+
214
+
215
+ @contextmanager
216
+ def settings_input(screen):
217
+ """Keep Harness's mouse adapter and curses event mask scoped to settings."""
218
+ previous = getattr(screen, 'settings_mouse', False)
219
+ forwarding = False
220
+ try:
221
+ screen.settings_mouse = True
222
+ forwarding = True
223
+ except AttributeError: # Native curses windows do not allow custom attributes.
224
+ pass
225
+ oldmask = oldinterval = None
226
+ try:
227
+ try:
228
+ _, oldmask = curses.mousemask(curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED |
229
+ curses.BUTTON1_DOUBLE_CLICKED | curses.BUTTON4_PRESSED |
230
+ getattr(curses, 'BUTTON5_PRESSED', 0))
231
+ oldinterval = curses.mouseinterval(0)
232
+ except curses.error:
233
+ pass
234
+ yield
235
+ finally:
236
+ if forwarding:
237
+ screen.settings_mouse = previous
238
+ if oldmask is not None:
239
+ curses.mousemask(oldmask)
240
+ if oldinterval is not None:
241
+ curses.mouseinterval(oldinterval)
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: hugpy-settings
3
+ Version: 0.1.0
4
+ Summary: Shared HTTP-backed settings editor and reports for Hugpy Wrapper and Harness
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Hugpy settings
9
+
10
+ The shared settings layer extracted from Hugpy Wrapper's terminal harness.
11
+ Wrapper and Hugpy Harness use this package directly instead of maintaining
12
+ duplicate implementations. It uses only the Python standard library and has
13
+ no dependency on either application, model engines, or a database driver.
14
+
15
+ The layer owns settings snapshots and redaction, field schemas, inheritance
16
+ reports, editing controls, terminal navigation, and the remote folder picker.
17
+ The running Wrapper remains responsible for validation, persistence, resource
18
+ admission, and applying changes through its existing HTTP API.
19
+
20
+ ```python
21
+ from hugpy_settings.settings_view import show_settings, run_settings, snapshot
22
+
23
+ # gateway exposes api_key and models(); the host supplies endpoint discovery.
24
+ show_settings(gateway, base, model=model_key, as_json=True)
25
+ # A curses host can embed the same editor:
26
+ run_settings(screen, gateway, base, model=model_key, drain=drain, wait=wait)
27
+ ```
28
+
29
+ `drain` is an optional event-draining callback. `wait(title, operation)` is an
30
+ optional host callback for displaying progress around blocking work. Hosts own
31
+ the terminal lifecycle. JSON and text reports remain available without curses.
32
+
33
+ Implementation modules:
34
+
35
+ - `settings_view`: snapshots, reports, and terminal entry points.
36
+ - `settings_schema`: model and box field descriptions.
37
+ - `settings_controls`: parsing, HTTP updates, and editing workflows.
38
+ - `settings_widgets`: dropdowns, mouse handling, and remote directory browsing.
39
+
40
+ Wrapper's `fitevict.cli.settings_*` and Harness's
41
+ `hugpy_harness.tui.views.settings_*` paths alias these same modules, preserving
42
+ existing callers and shared module state. New callers should import
43
+ `hugpy_settings` modules directly. Wrapper's schema was used as the source of
44
+ record; Harness's stale `video_allow_cpu` UI entry was not carried forward.
45
+
46
+ For source development, install this package into each host's development
47
+ environment before installing that host:
48
+
49
+ ```sh
50
+ python3 -m pip install -e /path/to/staging/hugpy-settings/package
51
+ ```
52
+
53
+ Both Debian builders build and bundle the shared wheel alongside their own
54
+ wheel. `HUGPY_SETTINGS_SOURCE` can override the sibling source directory.
55
+ The package must be published to the chosen Python index before distributing
56
+ Wrapper or Harness wheels that depend on `hugpy-settings==0.1.0` through it.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/hugpy_settings/__init__.py
4
+ src/hugpy_settings/settings_controls.py
5
+ src/hugpy_settings/settings_schema.py
6
+ src/hugpy_settings/settings_view.py
7
+ src/hugpy_settings/settings_widgets.py
8
+ src/hugpy_settings.egg-info/PKG-INFO
9
+ src/hugpy_settings.egg-info/SOURCES.txt
10
+ src/hugpy_settings.egg-info/dependency_links.txt
11
+ src/hugpy_settings.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ hugpy_settings