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.
- hugpy_settings-0.1.0/PKG-INFO +56 -0
- hugpy_settings-0.1.0/README.md +49 -0
- hugpy_settings-0.1.0/pyproject.toml +15 -0
- hugpy_settings-0.1.0/setup.cfg +4 -0
- hugpy_settings-0.1.0/src/hugpy_settings/__init__.py +5 -0
- hugpy_settings-0.1.0/src/hugpy_settings/settings_controls.py +328 -0
- hugpy_settings-0.1.0/src/hugpy_settings/settings_schema.py +109 -0
- hugpy_settings-0.1.0/src/hugpy_settings/settings_view.py +385 -0
- hugpy_settings-0.1.0/src/hugpy_settings/settings_widgets.py +241 -0
- hugpy_settings-0.1.0/src/hugpy_settings.egg-info/PKG-INFO +56 -0
- hugpy_settings-0.1.0/src/hugpy_settings.egg-info/SOURCES.txt +11 -0
- hugpy_settings-0.1.0/src/hugpy_settings.egg-info/dependency_links.txt +1 -0
- hugpy_settings-0.1.0/src/hugpy_settings.egg-info/top_level.txt +1 -0
|
@@ -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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hugpy_settings
|