configui 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
configui/__about__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
configui/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from configui.__about__ import __version__
2
+
3
+ __all__ = ["__version__"]
configui/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from configui.tui.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,7 @@
1
+ from configui.config._protocol import Config
2
+ from configui.config.formats import SupportedConfigFormat
3
+ from configui.config.json_config import JsonConfig
4
+ from configui.config.toml_config import TomlConfig
5
+ from configui.config.yaml_config import YamlConfig
6
+
7
+ __all__ = ["Config", "JsonConfig", "SupportedConfigFormat", "TomlConfig", "YamlConfig"]
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ from contextlib import suppress
6
+ from pathlib import Path
7
+ from typing import IO, TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Callable, MutableMapping
11
+
12
+ _ALLOW_NEW_KEYS: bool = os.environ.get("CONFIGUI_UPDATE_ALLOW_NEW_KEYS", "0") == "1"
13
+ _ALLOW_MISSING_KEYS: bool = os.environ.get("CONFIGUI_UPDATE_ALLOW_MISSING_KEYS", "1") == "1"
14
+
15
+
16
+ def atomic_update(
17
+ data: MutableMapping,
18
+ updates: dict[str, Any],
19
+ *,
20
+ allow_new_keys: bool,
21
+ allow_missing_keys: bool,
22
+ ) -> None:
23
+ if not allow_new_keys:
24
+ extra = updates.keys() - data.keys()
25
+ if extra:
26
+ msg = f"Keys not found in config: {sorted(extra)}"
27
+ raise KeyError(msg)
28
+ if not allow_missing_keys:
29
+ missing = data.keys() - updates.keys()
30
+ if missing:
31
+ msg = f"Missing keys in update: {sorted(missing)}"
32
+ raise KeyError(msg)
33
+ for k, v in updates.items():
34
+ data[k] = v
35
+
36
+
37
+ def atomic_write(path: Path, writer: Callable[[IO[str]], None]) -> None:
38
+ path = Path(path)
39
+ fd, tmp_path = tempfile.mkstemp(suffix=".tmp", dir=path.parent)
40
+ try:
41
+ with os.fdopen(fd, "w") as f:
42
+ writer(f)
43
+ os.replace(tmp_path, path)
44
+ except BaseException as e:
45
+ msg = f"Failed to save config to '{path}'"
46
+ raise RuntimeError(msg) from e
47
+ finally:
48
+ with suppress(BaseException):
49
+ os.unlink(tmp_path)
@@ -0,0 +1,18 @@
1
+ from collections.abc import Mapping
2
+ from pathlib import Path
3
+ from typing import Any, Protocol
4
+
5
+
6
+ class Config(Protocol):
7
+ _data: Mapping
8
+ _path: Path
9
+
10
+ def load(self, **kwargs: Any) -> None: ...
11
+
12
+ def save(self, **kwargs: Any) -> None: ...
13
+
14
+ def save_as(self, new_path: Path, **kwargs: Any) -> None: ...
15
+
16
+ def update(
17
+ self, data: dict[str, Any], *, allow_new_keys: bool | None = None, allow_missing_keys: bool | None = None
18
+ ) -> None: ...
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum, auto
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from configui.config._protocol import Config
9
+
10
+ from configui.config.json_config import JsonConfig
11
+ from configui.config.toml_config import TomlConfig
12
+ from configui.config.yaml_config import YamlConfig
13
+
14
+ _YAML_ALIASES: set[str] = {"yml", "yaml"}
15
+
16
+
17
+ class SupportedConfigFormat(StrEnum):
18
+ JSON = auto()
19
+ YAML = auto()
20
+ TOML = auto()
21
+
22
+ @classmethod
23
+ def from_filename(cls, filename: str) -> SupportedConfigFormat:
24
+ ext = Path(filename).suffix.lstrip(".").lower()
25
+ if ext in _YAML_ALIASES:
26
+ ext = "yaml"
27
+ return cls(ext)
28
+
29
+ def get_config_cls(self) -> type[Config]:
30
+ return _CONFIG_CLASSES[self]
31
+
32
+
33
+ _CONFIG_CLASSES: dict[SupportedConfigFormat, type[Config]] = {
34
+ SupportedConfigFormat.JSON: JsonConfig,
35
+ SupportedConfigFormat.YAML: YamlConfig,
36
+ SupportedConfigFormat.TOML: TomlConfig,
37
+ }
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from configui.config._atomic import _ALLOW_MISSING_KEYS, _ALLOW_NEW_KEYS, atomic_update, atomic_write
8
+ from configui.config._protocol import Config
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import MutableMapping
12
+
13
+
14
+ class JsonConfig(Config):
15
+ def __init__(self, path: str | Path) -> None:
16
+ self._path = Path(path)
17
+ self._data: MutableMapping[str, Any] = {}
18
+
19
+ def load(self, **kwargs: Any) -> None:
20
+ try:
21
+ self._data = json.loads(self._path.read_text(**kwargs))
22
+ except json.JSONDecodeError as e:
23
+ msg = f"Failed to parse JSON file '{self._path}': {e}"
24
+ raise ValueError(msg) from e
25
+
26
+ def save(self, **kwargs: Any) -> None:
27
+ atomic_write(self._path, lambda f: json.dump(self._data, f, indent=2, ensure_ascii=False, **kwargs))
28
+
29
+ def save_as(self, new_path: Path, **kwargs: Any) -> None:
30
+ atomic_write(new_path, lambda f: json.dump(self._data, f, indent=2, ensure_ascii=False, **kwargs))
31
+
32
+ def update(
33
+ self, data: dict[str, Any], *, allow_new_keys: bool | None = None, allow_missing_keys: bool | None = None
34
+ ) -> None:
35
+ _allow_new = allow_new_keys if allow_new_keys is not None else _ALLOW_NEW_KEYS
36
+ _allow_missing = allow_missing_keys if allow_missing_keys is not None else _ALLOW_MISSING_KEYS
37
+ atomic_update(self._data, data, allow_new_keys=_allow_new, allow_missing_keys=_allow_missing)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import tomlkit
7
+ from tomlkit.exceptions import TOMLKitError
8
+
9
+ from configui.config._atomic import _ALLOW_MISSING_KEYS, _ALLOW_NEW_KEYS, atomic_update, atomic_write
10
+ from configui.config._protocol import Config
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import MutableMapping
14
+
15
+
16
+ class TomlConfig(Config):
17
+ def __init__(self, path: str | Path) -> None:
18
+ self._path = Path(path)
19
+ self._data: MutableMapping[str, Any] = {}
20
+
21
+ def load(self, **kwargs: Any) -> None:
22
+ try:
23
+ self._data = tomlkit.parse(self._path.read_text(**kwargs))
24
+ except TOMLKitError as e:
25
+ msg = f"Failed to parse TOML file '{self._path}': {e}"
26
+ raise ValueError(msg) from e
27
+
28
+ def save(self, **_kwargs: Any) -> None:
29
+ atomic_write(self._path, lambda f: tomlkit.dump(self._data, f))
30
+
31
+ def save_as(self, new_path: Path, **_kwargs: Any) -> None:
32
+ atomic_write(new_path, lambda f: tomlkit.dump(self._data, f))
33
+
34
+ def update(
35
+ self, data: dict[str, Any], *, allow_new_keys: bool | None = None, allow_missing_keys: bool | None = None
36
+ ) -> None:
37
+ _allow_new = allow_new_keys if allow_new_keys is not None else _ALLOW_NEW_KEYS
38
+ _allow_missing = allow_missing_keys if allow_missing_keys is not None else _ALLOW_MISSING_KEYS
39
+ atomic_update(self._data, data, allow_new_keys=_allow_new, allow_missing_keys=_allow_missing)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from ruamel.yaml import YAML
7
+ from ruamel.yaml.error import YAMLError
8
+
9
+ from configui.config._atomic import _ALLOW_MISSING_KEYS, _ALLOW_NEW_KEYS, atomic_update, atomic_write
10
+ from configui.config._protocol import Config
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import MutableMapping
14
+
15
+
16
+ class YamlConfig(Config):
17
+ def __init__(self, path: str | Path, yaml_type: str = "rt") -> None:
18
+ """Initialize YamlConfig.
19
+
20
+ Args:
21
+ path: Path to the YAML file.
22
+ yaml_type: ruamel.yaml round-trip mode — ``"rt"`` preserves
23
+ comments, anchors, aliases, and key ordering on load/edit/save.
24
+ """
25
+ self._path = Path(path)
26
+ self._data: MutableMapping[str, Any] = {}
27
+ self._yaml = YAML(typ=yaml_type)
28
+
29
+ def load(self, **kwargs: Any) -> None:
30
+ try:
31
+ self._data = self._yaml.load(self._path.read_text(**kwargs))
32
+ except YAMLError as e:
33
+ msg = f"Failed to parse YAML file '{self._path}': {e}"
34
+ raise ValueError(msg) from e
35
+
36
+ def save(self, **_kwargs: Any) -> None:
37
+ atomic_write(self._path, lambda f: self._yaml.dump(self._data, f))
38
+
39
+ def save_as(self, new_path: Path, **_kwargs: Any) -> None:
40
+ atomic_write(new_path, lambda f: self._yaml.dump(self._data, f))
41
+
42
+ def update(
43
+ self, data: dict[str, Any], *, allow_new_keys: bool | None = None, allow_missing_keys: bool | None = None
44
+ ) -> None:
45
+ _allow_new = allow_new_keys if allow_new_keys is not None else _ALLOW_NEW_KEYS
46
+ _allow_missing = allow_missing_keys if allow_missing_keys is not None else _ALLOW_MISSING_KEYS
47
+ atomic_update(self._data, data, allow_new_keys=_allow_new, allow_missing_keys=_allow_missing)
File without changes
configui/tui/app.py ADDED
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
7
+
8
+ from textual.app import App, ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Horizontal, Vertical
11
+ from textual.widgets import Button, Collapsible, Footer, Header, Input, Switch, Tree
12
+
13
+ from configui.config import SupportedConfigFormat
14
+ from configui.tui.collector import map_widgets_to_config
15
+ from configui.tui.screens import ConfirmExitScreen, ResetConfirmScreen, SaveAsScreen
16
+ from configui.tui.widgets import map_config_to_widgets
17
+
18
+ if TYPE_CHECKING:
19
+ from textual.widgets.tree import TreeNode
20
+
21
+ _MOUNT_SETTLE_YIELDS: int = 100
22
+
23
+
24
+ class ConfigUIApp(App[None]):
25
+ TITLE = "ConfigUI"
26
+
27
+ CSS = """
28
+ Screen {
29
+ layout: grid;
30
+ grid-size: 1 3;
31
+ grid-rows: auto 1fr auto;
32
+ }
33
+
34
+ #main-container {
35
+ layout: horizontal;
36
+ }
37
+
38
+ #sidebar-tree {
39
+ dock: left;
40
+ width: 30%;
41
+ min-width: 30;
42
+ max-width: 50;
43
+ border: round $primary;
44
+ overflow-y: auto;
45
+ margin: 0 0 0 1;
46
+ }
47
+
48
+ #sidebar-tree:focus-within {
49
+ border: round $primary;
50
+ }
51
+
52
+ #main-content {
53
+ width: 1fr;
54
+ height: 1fr;
55
+ border: round $primary;
56
+ overflow-y: auto;
57
+ padding: 1 2;
58
+ margin: 0 1;
59
+ }
60
+
61
+ #main-content:focus-within {
62
+ border: round $primary;
63
+ }
64
+
65
+ .scalar-row {
66
+ height: auto;
67
+ margin: 0 0 1 0;
68
+ }
69
+
70
+ .scalar-label {
71
+ width: auto;
72
+ height: 3;
73
+ padding: 0 1 0 0;
74
+ color: $text;
75
+ text-style: bold;
76
+ content-align: left middle;
77
+ }
78
+
79
+ .scalar-input {
80
+ width: 1fr;
81
+ height: 3;
82
+ }
83
+
84
+ Input.scalar-input {
85
+ background: $surface;
86
+ border: round $primary;
87
+ }
88
+
89
+ Input.scalar-input:focus {
90
+ background: $surface;
91
+ border: round $primary;
92
+ }
93
+
94
+ Switch.scalar-input {
95
+ width: auto;
96
+ height: 3;
97
+ border: round $primary;
98
+ }
99
+
100
+ Switch.scalar-input:focus {
101
+ border: round $primary;
102
+ }
103
+
104
+ .reset-btn {
105
+ width: auto;
106
+ height: 3;
107
+ min-width: 3;
108
+ margin: 0 0 0 1;
109
+ background: transparent;
110
+ border: none;
111
+ color: $text;
112
+ }
113
+
114
+ .reset-btn:hover {
115
+ color: $primary;
116
+ }
117
+
118
+ .content-wrapper {
119
+ height: auto;
120
+ }
121
+
122
+ Collapsible > .container-dict,
123
+ Collapsible > .container-list {
124
+ margin: 0 0 0 1;
125
+ }
126
+
127
+ Tree {
128
+ padding: 0 1;
129
+ }
130
+ """
131
+
132
+ BINDINGS: ClassVar[list[Binding]] = [ # type: ignore[assignment]
133
+ Binding("ctrl+s", "save", "Save", priority=True),
134
+ Binding("ctrl+shift+s", "save_as", "Save As", priority=True),
135
+ Binding("ctrl+r", "reset_all", "Reset All", priority=True),
136
+ Binding("ctrl+q", "quit", "Quit", priority=True),
137
+ ]
138
+
139
+ def __init__(self, path: str, *, read_only: bool = False) -> None:
140
+ super().__init__()
141
+ self._filepath = Path(path)
142
+ self._read_only = read_only
143
+ self._dirty = False
144
+ self._config = None
145
+ self._original_data: dict[str, Any] = {}
146
+ self._tree_nodes: dict[str, TreeNode] = {}
147
+ self._header: Header | None = None
148
+ self._mount_phase = True
149
+
150
+ def _load_config(self):
151
+ fmt = SupportedConfigFormat.from_filename(self._filepath.name)
152
+ config = fmt.get_config_cls()(self._filepath)
153
+ config.load()
154
+ return config
155
+
156
+ def compose(self) -> ComposeResult:
157
+ yield Header(show_clock=False)
158
+ with Horizontal(id="main-container"):
159
+ yield Tree("config", id="sidebar-tree")
160
+ yield Vertical(id="main-content")
161
+ yield Footer()
162
+
163
+ async def on_mount(self) -> None:
164
+ self._config = self._load_config()
165
+ data: dict[str, Any] = cast("dict[str, Any]", self._config._data) # type: ignore[attr-defined,union-attr] # noqa: SLF001
166
+ self._original_data = deepcopy(data)
167
+
168
+ self._header = self.query_one(Header)
169
+ content = self.query_one("#main-content", Vertical)
170
+ content.remove_children()
171
+ content.mount(map_config_to_widgets(data))
172
+
173
+ tree = self.query_one("#sidebar-tree", Tree)
174
+ tree.clear()
175
+ self._tree_nodes.clear()
176
+ self._populate_tree(tree.root, data)
177
+
178
+ if self._read_only:
179
+ self._header.sub_title = "[READ ONLY]" # type: ignore[union-attr,attr-defined]
180
+ else:
181
+ self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
182
+
183
+ for _ in range(_MOUNT_SETTLE_YIELDS):
184
+ await asyncio.sleep(0)
185
+
186
+ self.call_later(self._end_mount_phase)
187
+
188
+ def _end_mount_phase(self) -> None:
189
+ self._mount_phase = False
190
+ self._dirty = False
191
+ if self._read_only:
192
+ self._header.sub_title = "[READ ONLY]" # type: ignore[union-attr,attr-defined]
193
+ else:
194
+ self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
195
+
196
+ def _get_nested(self, data: dict[str, Any], dotted_path: str) -> Any:
197
+ parts = dotted_path.split(".")
198
+ current: Any = data
199
+ for part in parts:
200
+ current = current[int(part)] if isinstance(current, list) and part.isdigit() else current[part] # type: ignore[index]
201
+ return current
202
+
203
+ def _populate_tree(self, node: TreeNode, data: dict[str, Any], prefix: str = "") -> None:
204
+ for key, value in data.items():
205
+ path = f"{prefix}.{key}" if prefix else key
206
+ if isinstance(value, dict):
207
+ child = node.add(key, expand=True)
208
+ child.data = {"path": path}
209
+ self._tree_nodes[path] = child
210
+ self._populate_tree(child, value, prefix=path)
211
+ else:
212
+ child = node.add_leaf(key)
213
+ child.data = {"path": path}
214
+ self._tree_nodes[path] = child
215
+
216
+ def _expand_tree_ancestors(self, key_path: str) -> None:
217
+ parts = key_path.split(".")
218
+ for i in range(1, len(parts)):
219
+ ancestor_path = ".".join(parts[:i])
220
+ ancestor = self._tree_nodes.get(ancestor_path)
221
+ if ancestor is not None:
222
+ ancestor.expand()
223
+
224
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
225
+ if event.node.tree.id != "sidebar-tree":
226
+ return
227
+ data = event.node.data
228
+ if data is None or "path" not in data:
229
+ return
230
+ dotted_path = data["path"]
231
+ wid = dotted_path.replace(".", "_")
232
+ try:
233
+ widget = self.query_one(f"#{wid}")
234
+ if isinstance(widget, Collapsible) and widget.collapsed:
235
+ widget.collapsed = False
236
+ widget.scroll_visible()
237
+ except Exception: # noqa: BLE001, S110
238
+ pass
239
+
240
+ def _sync_tree_from_collapsible(self, collapsible_id: str, *, expanded: bool) -> None:
241
+ key_path = collapsible_id.replace("_", ".")
242
+ node = self._tree_nodes.get(key_path)
243
+ if node is not None:
244
+ if expanded:
245
+ node.expand()
246
+ else:
247
+ node.collapse()
248
+ self._expand_tree_ancestors(key_path)
249
+
250
+ def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None:
251
+ wid = event.collapsible.id
252
+ if wid:
253
+ self._sync_tree_from_collapsible(wid, expanded=True)
254
+
255
+ def on_collapsible_collapsed(self, event: Collapsible.Collapsed) -> None:
256
+ wid = event.collapsible.id
257
+ if wid:
258
+ self._sync_tree_from_collapsible(wid, expanded=False)
259
+
260
+ def _mark_dirty(self) -> None:
261
+ if self._mount_phase or self._read_only:
262
+ return
263
+ if not self._dirty:
264
+ self._dirty = True
265
+ self._header.sub_title = f"{self._filepath.name} *" # type: ignore[union-attr,attr-defined]
266
+
267
+ def on_input_changed(self, _event: Input.Changed) -> None:
268
+ self._mark_dirty()
269
+
270
+ def on_switch_changed(self, _event: Switch.Changed) -> None:
271
+ self._mark_dirty()
272
+
273
+ def on_button_pressed(self, event: Button.Pressed) -> None:
274
+ button_id = event.button.id
275
+ if button_id is None:
276
+ return
277
+ if button_id.startswith("reset-") and "reset-btn" in event.button.classes:
278
+ widget_id = button_id[6:]
279
+ widget = self.query_one(f"#{widget_id}")
280
+ row = widget.parent if isinstance(widget.parent, Horizontal) else None
281
+ dotted_path = getattr(row, "reset_path", None) or widget_id.replace("_", ".")
282
+ original_value = self._get_nested(self._original_data, dotted_path)
283
+ if isinstance(widget, Switch):
284
+ widget.value = bool(original_value)
285
+ elif isinstance(widget, Input):
286
+ widget.value = str(original_value)
287
+ self._mark_dirty()
288
+
289
+ def action_save(self) -> None:
290
+ if self._read_only or not self._dirty:
291
+ return
292
+ try:
293
+ content = self.query_one("#main-content", Vertical)
294
+ data = map_widgets_to_config(content)
295
+ self._config.update(data) # type: ignore[attr-defined]
296
+ self._config.save() # type: ignore[attr-defined]
297
+ self._dirty = False
298
+ self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
299
+ except Exception as exc: # noqa: BLE001
300
+ self.notify(str(exc), severity="error", title="Save failed")
301
+
302
+ def action_save_as(self) -> None:
303
+ if self._read_only:
304
+ return
305
+ self.push_screen(SaveAsScreen(self._filepath), self._on_save_as_result)
306
+
307
+ def _on_save_as_result(self, new_path: Path | None) -> None:
308
+ if new_path is None:
309
+ return
310
+ try:
311
+ content = self.query_one("#main-content", Vertical)
312
+ data = map_widgets_to_config(content)
313
+ self._config.update(data) # type: ignore[attr-defined]
314
+ self._config.save_as(new_path) # type: ignore[attr-defined]
315
+ self._filepath = new_path
316
+ self._dirty = False
317
+ self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
318
+ except Exception as exc: # noqa: BLE001
319
+ self.notify(str(exc), severity="error", title="Save As failed")
320
+
321
+ def action_reset_all(self) -> None:
322
+ if self._read_only or not self._dirty:
323
+ return
324
+ self.push_screen(ResetConfirmScreen(), self._on_reset_all_result) # type: ignore[arg-type]
325
+
326
+ def _on_reset_all_result(self, confirmed: bool) -> None: # noqa: FBT001
327
+ if not confirmed:
328
+ return
329
+ content = self.query_one("#main-content", Vertical)
330
+ content.remove_children()
331
+ content.mount(map_config_to_widgets(self._original_data))
332
+ tree = self.query_one("#sidebar-tree", Tree)
333
+ tree.clear()
334
+ self._tree_nodes.clear()
335
+ self._populate_tree(tree.root, self._original_data)
336
+ self._dirty = False
337
+ self._header.sub_title = self._filepath.name # type: ignore[union-attr,attr-defined]
338
+
339
+ def action_quit(self) -> None: # type: ignore[override]
340
+ if self._dirty:
341
+ self.push_screen(ConfirmExitScreen(), self._on_quit_result) # type: ignore[arg-type]
342
+ else:
343
+ self.exit()
344
+
345
+ def _on_quit_result(self, confirmed: bool) -> None: # noqa: FBT001
346
+ if confirmed:
347
+ self.exit()
configui/tui/cli.py ADDED
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from configui.tui.app import ConfigUIApp
6
+
7
+
8
+ @click.command()
9
+ @click.argument("path", type=click.Path(exists=True))
10
+ @click.option("-r", "--read-only", is_flag=True, help="Open in read-only mode")
11
+ def main(path: str, read_only: bool) -> None: # noqa: FBT001
12
+ """Open a configuration file in the ConfigUI TUI editor."""
13
+ app = ConfigUIApp(path, read_only=read_only)
14
+ app.run()
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ from textual.containers import Horizontal, Vertical
6
+ from textual.widgets import Collapsible, Input, Static, Switch
7
+
8
+
9
+ def _extract_value(widget: Switch | Input) -> Any:
10
+ if isinstance(widget, Switch):
11
+ return widget.value
12
+ if widget.type == "integer":
13
+ return int(widget.value) if widget.value else 0
14
+ if widget.type == "number":
15
+ return float(widget.value) if widget.value else 0.0
16
+ return widget.value
17
+
18
+
19
+ def _content_children(coll: Collapsible):
20
+ """Yield actual user-provided children of a Collapsible, skipping internal wrappers."""
21
+ contents = list(coll.children)[1]
22
+ yield from contents.children
23
+
24
+
25
+ def _collect(widget: Horizontal | Collapsible, container: dict[str, Any] | list[Any]) -> None:
26
+ if isinstance(widget, Horizontal):
27
+ label = cast("Static", widget.children[0])
28
+ value_widget = cast("Switch | Input", widget.children[1])
29
+ key = str(label.content)
30
+ value = _extract_value(value_widget)
31
+ if isinstance(container, dict):
32
+ container[key] = value
33
+ else:
34
+ container.append(value)
35
+ elif isinstance(widget, Collapsible):
36
+ sub: list[Any] | dict[str, Any]
37
+ if "container-dict" in widget.classes:
38
+ sub = {}
39
+ for child in _content_children(widget):
40
+ _collect(cast("Horizontal | Collapsible", child), sub)
41
+ elif "container-list" in widget.classes:
42
+ sub = []
43
+ for child in _content_children(widget):
44
+ _collect(cast("Horizontal | Collapsible", child), sub)
45
+ else:
46
+ return
47
+
48
+ if isinstance(container, dict):
49
+ key = str(widget.title)
50
+ if "container-list" in widget.classes:
51
+ key = key.rsplit(" [", 1)[0]
52
+ container[key] = sub
53
+ else:
54
+ container.append(sub)
55
+
56
+
57
+ def _unwrap(root: Vertical) -> Vertical:
58
+ """Unwrap a single wrapper Vertical, if present."""
59
+ children = list(root.children)
60
+ if len(children) == 1 and isinstance(children[0], Vertical):
61
+ return cast("Vertical", children[0])
62
+ return root
63
+
64
+
65
+ def map_widgets_to_config(root: Vertical) -> dict[str, Any]:
66
+ result: dict[str, Any] = {}
67
+ target = _unwrap(root)
68
+ for child in target.children:
69
+ _collect(cast("Horizontal | Collapsible", child), result)
70
+ return result
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from textual.containers import Vertical
7
+ from textual.screen import Screen
8
+ from textual.widgets import Button, Input, Label, Static
9
+
10
+ from configui.tui.widgets import NavigableDirectoryTree
11
+
12
+ if TYPE_CHECKING:
13
+ from textual.app import ComposeResult
14
+
15
+
16
+ class ConfirmExitScreen(Screen[bool]):
17
+ CSS = """
18
+ ConfirmExitScreen {
19
+ align: center middle;
20
+ }
21
+
22
+ #confirm-exit-box {
23
+ width: 50;
24
+ height: auto;
25
+ padding: 2;
26
+ border: round $primary;
27
+ }
28
+
29
+ #confirm-exit-box > Label {
30
+ text-align: center;
31
+ margin: 1 0 2 0;
32
+ }
33
+ """
34
+
35
+ def compose(self) -> ComposeResult:
36
+ with Vertical(id="confirm-exit-box"):
37
+ yield Label("You have unsaved changes. Exit anyway?")
38
+ yield Button("Cancel", variant="primary", id="cancel")
39
+ yield Button("Exit", variant="error", id="exit")
40
+
41
+ def on_button_pressed(self, event: Button.Pressed) -> None:
42
+ if event.button.id == "exit":
43
+ self.dismiss(True)
44
+ else:
45
+ self.dismiss(False)
46
+
47
+
48
+ class ResetConfirmScreen(Screen[bool]):
49
+ CSS = """
50
+ ResetConfirmScreen {
51
+ align: center middle;
52
+ }
53
+
54
+ #reset-confirm-box {
55
+ width: 50;
56
+ height: auto;
57
+ padding: 2;
58
+ border: round $primary;
59
+ }
60
+
61
+ #reset-confirm-box > Label {
62
+ text-align: center;
63
+ margin: 1 0 2 0;
64
+ }
65
+ """
66
+
67
+ def compose(self) -> ComposeResult:
68
+ with Vertical(id="reset-confirm-box"):
69
+ yield Label("Reset all fields to their original values?")
70
+ yield Button("Cancel", variant="primary", id="cancel")
71
+ yield Button("Reset All", variant="error", id="reset")
72
+
73
+ def on_button_pressed(self, event: Button.Pressed) -> None:
74
+ if event.button.id == "reset":
75
+ self.dismiss(True)
76
+ else:
77
+ self.dismiss(False)
78
+
79
+
80
+ class SaveAsScreen(Screen[Path | None]):
81
+ CSS = """
82
+ SaveAsScreen {
83
+ align: center middle;
84
+ }
85
+
86
+ #save-as-box {
87
+ width: 80;
88
+ height: auto;
89
+ padding: 2;
90
+ border: round $primary;
91
+ }
92
+
93
+ #save-as-box > Label {
94
+ text-align: center;
95
+ text-style: bold;
96
+ margin: 0 0 1 0;
97
+ }
98
+
99
+ #save-directory-tree {
100
+ height: 20;
101
+ width: 1fr;
102
+ border: round $surface;
103
+ }
104
+
105
+ #save-filename {
106
+ margin: 1 0;
107
+ border: round $primary;
108
+ }
109
+
110
+ #save-filename:focus {
111
+ border: round $primary;
112
+ }
113
+
114
+ #save-path-preview {
115
+ margin: 1 0;
116
+ }
117
+
118
+ #save-as-box > Button {
119
+ margin: 1 0 0 0;
120
+ }
121
+ """
122
+
123
+ def __init__(self, current_path: Path) -> None:
124
+ super().__init__()
125
+ self._current_path = current_path
126
+ self._selected_dir: Path = current_path.parent
127
+
128
+ def compose(self) -> ComposeResult:
129
+ with Vertical(id="save-as-box"):
130
+ yield Label("Save As")
131
+ yield NavigableDirectoryTree(str(self._current_path.parent), id="save-directory-tree")
132
+ yield Input(value=self._current_path.name, id="save-filename", placeholder="Filename")
133
+ yield Static(id="save-path-preview")
134
+ yield Button("Browse", id="browse")
135
+ yield Button("Save", variant="primary", id="save")
136
+ yield Button("Cancel", id="cancel")
137
+
138
+ def on_mount(self) -> None:
139
+ self._update_preview()
140
+
141
+ def _get_save_path(self) -> Path | None:
142
+ filename = self.query_one("#save-filename", Input).value.strip()
143
+ if not filename:
144
+ return None
145
+ try:
146
+ candidate = Path(filename).expanduser()
147
+ except RuntimeError:
148
+ return None
149
+ if candidate.is_absolute():
150
+ return candidate
151
+ return self._selected_dir / candidate
152
+
153
+ def _update_preview(self) -> None:
154
+ save_path = self._get_save_path()
155
+ preview = self.query_one("#save-path-preview", Static)
156
+ if save_path:
157
+ preview.update(f"Save path: {save_path}")
158
+ else:
159
+ preview.update("")
160
+
161
+ def on_input_changed(self, _event: Input.Changed) -> None:
162
+ self._update_preview()
163
+
164
+ def on_directory_tree_directory_selected(self, event: NavigableDirectoryTree.DirectorySelected) -> None:
165
+ self._selected_dir = event.path
166
+ tree = self.query_one("#save-directory-tree", NavigableDirectoryTree)
167
+ tree.path = event.path
168
+ self._update_preview()
169
+
170
+ def on_button_pressed(self, event: Button.Pressed) -> None:
171
+ if event.button.id == "save":
172
+ save_path = self._get_save_path()
173
+ if save_path:
174
+ self.dismiss(save_path)
175
+ elif event.button.id == "cancel":
176
+ self.dismiss(None)
177
+ elif event.button.id == "browse":
178
+ self.query_one("#save-directory-tree", NavigableDirectoryTree).focus()
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from textual.containers import Horizontal, Vertical
6
+ from textual.widgets import Button, Collapsible, DirectoryTree, Input, Static, Switch
7
+ from textual.widgets._directory_tree import DirEntry
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable
11
+ from pathlib import Path
12
+
13
+ from textual.widgets._tree import TreeNode
14
+
15
+ DEFAULT_INT_RESTRICT: str = r"^-?\d*$"
16
+ DEFAULT_FLOAT_RESTRICT: str = r"^-?\d*(\.\d*)?([eE][+-]?\d+)?$"
17
+
18
+
19
+ def _safe_id(prefix: str, key: str) -> str:
20
+ raw = f"{prefix}_{key}" if prefix else key
21
+ return raw.replace(".", "_")
22
+
23
+
24
+ def _make_scalar_row(
25
+ label: str,
26
+ value: Any,
27
+ *,
28
+ widget_id: str | None = None,
29
+ restrict: str | None = None,
30
+ dotted_path: str | None = None,
31
+ ) -> Horizontal:
32
+ widget: Switch | Input
33
+ if isinstance(value, bool):
34
+ widget = Switch(value=value, id=widget_id)
35
+ elif isinstance(value, int):
36
+ widget = Input(
37
+ type="integer",
38
+ value=str(value),
39
+ restrict=restrict or DEFAULT_INT_RESTRICT,
40
+ id=widget_id,
41
+ )
42
+ elif isinstance(value, float):
43
+ widget = Input(
44
+ type="number",
45
+ value=str(value),
46
+ restrict=restrict or DEFAULT_FLOAT_RESTRICT,
47
+ id=widget_id,
48
+ )
49
+ else:
50
+ widget = Input(type="text", value=str(value), id=widget_id, restrict=restrict)
51
+
52
+ widget.add_class("scalar-input")
53
+ reset_btn = Button("↺", id=f"reset-{widget_id}" if widget_id else None, classes="reset-btn")
54
+ row = Horizontal(Static(label, classes="scalar-label"), widget, reset_btn, classes="scalar-row")
55
+ row.reset_path = dotted_path # type: ignore[attr-defined]
56
+ return row
57
+
58
+
59
+ def _map_value(
60
+ key: str,
61
+ value: Any,
62
+ *,
63
+ prefix: str = "",
64
+ regex_overrides: dict[str, str] | None = None,
65
+ ) -> Horizontal | Collapsible:
66
+ dotted_path = f"{prefix}.{key}" if prefix else key
67
+ wid = _safe_id(prefix, key)
68
+ overrides = regex_overrides or {}
69
+
70
+ if isinstance(value, dict):
71
+ children = [_map_value(k, v, prefix=dotted_path, regex_overrides=overrides) for k, v in value.items()]
72
+ return Collapsible(*children, title=key, id=wid, classes="container-dict", collapsed=False)
73
+
74
+ if isinstance(value, list):
75
+ children = [
76
+ _map_value(str(i), item, prefix=dotted_path, regex_overrides=overrides) for i, item in enumerate(value)
77
+ ]
78
+ title = f"{key} [{len(value)}]"
79
+ return Collapsible(*children, title=title, id=wid, classes="container-list", collapsed=False)
80
+
81
+ restrict = overrides.get(dotted_path)
82
+ return _make_scalar_row(key, value, widget_id=wid, restrict=restrict, dotted_path=dotted_path)
83
+
84
+
85
+ class NavigableDirectoryTree(DirectoryTree):
86
+ def _populate_node(self, node: TreeNode[DirEntry], content: Iterable[Path]) -> None:
87
+ node.remove_children()
88
+ if node.data is not None:
89
+ parent_path = node.data.path.parent
90
+ if parent_path != node.data.path:
91
+ node.add("..", data=DirEntry(parent_path), allow_expand=True)
92
+ for path in content:
93
+ node.add(
94
+ path.name,
95
+ data=DirEntry(path),
96
+ allow_expand=self._safe_is_dir(path),
97
+ )
98
+ node.expand()
99
+
100
+
101
+ def map_config_to_widgets(
102
+ data: dict[str, Any],
103
+ *,
104
+ regex_overrides: dict[str, str] | None = None,
105
+ ) -> Vertical:
106
+ overrides = regex_overrides or {}
107
+ children = [_map_value(key, value, regex_overrides=overrides) for key, value in data.items()]
108
+ return Vertical(*children, classes="content-wrapper")
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: configui
3
+ Version: 1.0.0
4
+ Dynamic: License
5
+ Dynamic: License-Expression
6
+ Summary: A tool that turns any yaml or json configs into tui
7
+ Author-email: Chenchao Zhao <chenchao.zhao.dev+github@gmail.com>
8
+ License-File: LICENSE
9
+ Requires-Python: <3.14,>=3.12
10
+ Requires-Dist: click>=8.1.0
11
+ Requires-Dist: ruamel-yaml>=0.18.0
12
+ Requires-Dist: textual>=1.0.0
13
+ Requires-Dist: tomlkit>=0.13.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ConfigUI
17
+
18
+ [![CI](https://github.com/chenchaozhao/configui/actions/workflows/tests.yml/badge.svg)](https://github.com/chenchaozhao/configui/actions/workflows/tests.yml)
19
+ [![PyPI version](https://img.shields.io/pypi/v/configui)](https://pypi.org/project/configui/)
20
+ [![Python](https://img.shields.io/badge/python-3.12%20|%203.13-blue)](https://www.python.org/)
21
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
22
+
23
+ Turn YAML, JSON, and TOML configuration files into a terminal user interface (TUI) — no IDE required.
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ # Install from PyPI
31
+ uv tool install configui
32
+
33
+ # Or with pip
34
+ pip install configui
35
+ ```
36
+
37
+ ## Quickstart
38
+
39
+ ```bash
40
+ configui path/to/config.yaml
41
+ ```
42
+
43
+ Open a configuration file in the interactive TUI editor. Edit values, toggle booleans, navigate nested structures — everything stays type-aware. Try it with the sample configs in `samples/`:
44
+
45
+ ```bash
46
+ configui samples/training_config.yaml
47
+ configui samples/training_config.json
48
+ configui samples/training_config.toml
49
+ ```
50
+
51
+ ```bash
52
+ configui path/to/config.yaml -r # Read-only mode
53
+ ```
54
+
55
+ ![ConfigUI screenshot](_assets/ConfigUI_screenshot.svg)
56
+
57
+ ## Features
58
+
59
+ - **Type-aware editing** — booleans become checkboxes, numbers get numeric inputs, nested objects become collapsible sections.
60
+ - **Multi-format** — YAML (with comment preservation via `ruamel.yaml`), JSON, and TOML (with `tomlkit`).
61
+ - **Read-only mode** — inspect configs without accidental edits (`-r` flag).
62
+ - **Save or Save As** — overwrite the original or write to a new path.
63
+ - **Comment preservation** — your YAML and TOML comments stay intact.
64
+
65
+ ## Supported Formats
66
+
67
+ | Format | Library | Comments preserved |
68
+ |--------|---------|-------------------|
69
+ | YAML | ruamel.yaml | ✅ |
70
+ | JSON | stdlib json | ❌ (not supported by format) |
71
+ | TOML | tomlkit | ✅ |
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ uv tool install hatch
77
+ hatch run release # fmt → typing → test with coverage
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,21 @@
1
+ configui/__about__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
2
+ configui/__init__.py,sha256=Vf7-MY0K_6mZs3IxeAsj4wY0DyW_gQP6iuNhPFE5Iow,70
3
+ configui/__main__.py,sha256=5zmNw8RQWWDg3Mb2Ihd1EyGWkdxhz1LU4m1yGraPlHc,73
4
+ configui/config/__init__.py,sha256=eV9OesaYHtJsSpB3na1cOl9Kg4B5yvU71suddbyZT2A,345
5
+ configui/config/_atomic.py,sha256=sfmCr1vESOaMm6YH1SozH__E8sSRaKfxxO4dagY5qe8,1470
6
+ configui/config/_protocol.py,sha256=UgiLkZhh_-6U8rKpB2tUQrkQhZkmM3B4F6bX0yOlwro,468
7
+ configui/config/formats.py,sha256=51iSfNOf6sEsMXErL6TL4omB2rwGbDJzi830ewVHtMQ,991
8
+ configui/config/json_config.py,sha256=Lyua4WyLpXSG9q9nYM1F7-Uch6jruA6h-JejXAZh1VU,1521
9
+ configui/config/toml_config.py,sha256=aNciaAYNZdTDfk6Zlzx4myYF2Po-aJKEpWGV1Gh5wgQ,1492
10
+ configui/config/yaml_config.py,sha256=np36liRCuCk6b52bToOHANW_Vg7a1HoHE-hM2RpNjnw,1827
11
+ configui/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ configui/tui/app.py,sha256=YB3Z4gGjGHkQut8SjwXLW4KtmTLGNcFKeMGeOVTKnQ0,11463
13
+ configui/tui/cli.py,sha256=vdyd3TlO64_FogTpnLks7yor6O3FbhzckYgabN8YCHo,433
14
+ configui/tui/collector.py,sha256=oGs3310RuHrkBSMcDRBB-Q1l4qEbTui2OAgCj5P5hH4,2418
15
+ configui/tui/screens.py,sha256=f6ETw9nJQVkI9FAwFs9gKpQirv_6j6Jad7mSQr86Uzg,4903
16
+ configui/tui/widgets.py,sha256=n-Tzw3Jo5k5WS-2-icpb-Q2uNB0bgUSJRxk7kQsBRR0,3646
17
+ configui-1.0.0.dist-info/METADATA,sha256=pI5K9OdJXoOJF-30upWBZw5zCcxGsot45Ga1HJfhNLM,2416
18
+ configui-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
19
+ configui-1.0.0.dist-info/entry_points.txt,sha256=7uS8KCdGBpKbHXJIQXzH_Da9IOjEoYMplByZHpCKZcw,51
20
+ configui-1.0.0.dist-info/licenses/LICENSE,sha256=-T4MqdM5NeuybxUKObsf95pcYmKFa5QpvezUvkJc0fo,1070
21
+ configui-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ configui = configui.tui.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chenchao Zhao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.