structui 0.1.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.
structui/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """StructUI - A format-agnostic, schema-driven, hierarchical configuration UI."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .app import run_app
6
+
7
+ __all__ = ["run_app"]
structui/app.py ADDED
@@ -0,0 +1,15 @@
1
+ from nicegui import ui
2
+ from structui.ui import StructUI
3
+ from structui.state import AppState
4
+ from structui.schema import SchemaManager
5
+
6
+ def run_app(data_dir: str = ".", schema_filepath: str = ".structui_schema.yaml", port: int = 8080):
7
+ schema_manager = SchemaManager(schema_filepath)
8
+ app_state = AppState(data_dir, schema_manager)
9
+
10
+ ui_instance = StructUI(app_state, schema_manager)
11
+
12
+ @ui.page('/')
13
+ def main_page():
14
+ ui_instance.render()
15
+ ui.run(port=port, title="StructUI Editor", show=False, reload=False)
structui/cli.py ADDED
@@ -0,0 +1,20 @@
1
+ import argparse
2
+ import sys
3
+ from structui.app import run_app
4
+
5
+ def main():
6
+ parser = argparse.ArgumentParser(description="StructUI Configuration Editor")
7
+ parser.add_argument("--dir", type=str, default=".", help="Directory containing config files")
8
+ parser.add_argument("--schema", type=str, default=".structui_schema.yaml", help="Path to schema file")
9
+ parser.add_argument("--port", type=int, default=8080, help="Port to run the UI on (default: 8080)")
10
+
11
+ args = parser.parse_args()
12
+
13
+ try:
14
+ run_app(data_dir=args.dir, schema_filepath=args.schema, port=args.port)
15
+ except Exception as e:
16
+ print(f"Error starting StructUI: {e}", file=sys.stderr)
17
+ sys.exit(1)
18
+
19
+ if __name__ in {"__main__", "__mp_main__"}:
20
+ main()
@@ -0,0 +1,95 @@
1
+ import platform
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from nicegui import events, ui
6
+
7
+
8
+ class LocalFilePicker(ui.dialog):
9
+
10
+ def __init__(self, directory: str, *,
11
+ upper_limit: Optional[str] = ..., multiple: bool = False, show_hidden_files: bool = False,
12
+ dirs_only: bool = False) -> None:
13
+ """Local File Picker
14
+
15
+ This is a simple file picker that allows you to select a file from the local filesystem where NiceGUI is running.
16
+
17
+ :param directory: The directory to start in.
18
+ :param upper_limit: The directory to stop navigating up (e.g. the root directory).
19
+ :param multiple: Whether to allow multiple files to be selected.
20
+ :param show_hidden_files: Whether to show hidden files.
21
+ :param dirs_only: Whether to only show directories.
22
+ """
23
+ super().__init__()
24
+
25
+ self.path = Path(directory).expanduser().resolve()
26
+ if upper_limit is None:
27
+ self.upper_limit = None
28
+ else:
29
+ self.upper_limit = Path(directory if upper_limit == ... else upper_limit).expanduser().resolve()
30
+ self.show_hidden_files = show_hidden_files
31
+ self.dirs_only = dirs_only
32
+
33
+ with self, ui.card():
34
+ self.add_drives_toggle()
35
+ self.grid = ui.aggrid({
36
+ 'columnDefs': [{'field': 'name', 'headerName': 'File'}],
37
+ 'rowSelection': 'multiple' if multiple else 'single',
38
+ }, html_columns=[0]).classes('w-96').on('cellDoubleClicked', self.handle_double_click)
39
+ with ui.row().classes('w-full justify-end'):
40
+ ui.button('Cancel', on_click=self.close).props('outline')
41
+ ui.button('Ok', on_click=self._handle_ok)
42
+ self.update_grid()
43
+
44
+ def add_drives_toggle(self):
45
+ if platform.system() == 'Windows':
46
+ import win32api
47
+ drives = win32api.GetLogicalDriveStrings().split('\000')[:-1]
48
+ self.drives_toggle = ui.toggle(drives, value=drives[0], on_change=self.update_drive)
49
+
50
+ def update_drive(self):
51
+ self.path = Path(self.drives_toggle.value).expanduser()
52
+ self.update_grid()
53
+
54
+ def update_grid(self) -> None:
55
+ paths = list(self.path.iterdir())
56
+ if not self.show_hidden_files:
57
+ paths = [p for p in paths if not p.name.startswith('.')]
58
+ if self.dirs_only:
59
+ paths = [p for p in paths if p.is_dir()]
60
+ paths.sort(key=lambda p: p.name.lower())
61
+ paths.sort(key=lambda p: not p.is_dir())
62
+
63
+ self.grid.options['rowData'] = [
64
+ {
65
+ 'name': f'📁 <strong>{p.name}</strong>' if p.is_dir() else p.name,
66
+ 'path': str(p),
67
+ }
68
+ for p in paths
69
+ ]
70
+ if self.upper_limit is None or self.path != self.upper_limit:
71
+ self.grid.options['rowData'].insert(0, {
72
+ 'name': '📁 <strong>..</strong>',
73
+ 'path': str(self.path.parent),
74
+ })
75
+ self.grid.update()
76
+
77
+ def handle_double_click(self, e: events.GenericEventArguments) -> None:
78
+ self.path = Path(e.args['data']['path'])
79
+ if self.path.is_dir():
80
+ self.update_grid()
81
+ else:
82
+ self.submit([str(self.path)])
83
+
84
+ async def _handle_ok(self):
85
+ try:
86
+ rows = await self.grid.get_selected_rows()
87
+ except TimeoutError:
88
+ rows = []
89
+
90
+ if rows:
91
+ self.submit([r['path'] for r in rows])
92
+ elif self.dirs_only:
93
+ self.submit([str(self.path)])
94
+ else:
95
+ ui.notify('No file selected.')
structui/parser.py ADDED
@@ -0,0 +1,51 @@
1
+ import os
2
+ import yaml
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from typing import Dict, Any
6
+
7
+ class DataParser(ABC):
8
+ """Abstract base class for format-agnostic configuration parsing."""
9
+
10
+ @abstractmethod
11
+ def load(self, filepath: str) -> Any:
12
+ pass
13
+
14
+ @abstractmethod
15
+ def save(self, filepath: str, data: Any):
16
+ pass
17
+
18
+ class YamlParser(DataParser):
19
+ def load(self, filepath: str) -> Any:
20
+ try:
21
+ with open(filepath, 'r') as f:
22
+ return yaml.safe_load(f)
23
+ except Exception as e:
24
+ print(f"YAML Load Error ({filepath}): {e}")
25
+ return None
26
+
27
+ def save(self, filepath: str, data: Any):
28
+ with open(filepath, 'w') as f:
29
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
30
+
31
+ class JsonParser(DataParser):
32
+ def load(self, filepath: str) -> Any:
33
+ try:
34
+ with open(filepath, 'r') as f:
35
+ return json.load(f)
36
+ except Exception as e:
37
+ print(f"JSON Load Error ({filepath}): {e}")
38
+ return None
39
+
40
+ def save(self, filepath: str, data: Any):
41
+ with open(filepath, 'w') as f:
42
+ json.dump(data, f, indent=4)
43
+
44
+ def get_parser(filepath: str) -> DataParser:
45
+ """Factory method to resolve the correct parser by file extension."""
46
+ if filepath.endswith(('.yaml', '.yml')):
47
+ return YamlParser()
48
+ elif filepath.endswith('.json'):
49
+ return JsonParser()
50
+ # Easily extensible to XML, CSV, etc.
51
+ return YamlParser()
structui/schema.py ADDED
@@ -0,0 +1,117 @@
1
+ import os
2
+ from typing import Dict, Any
3
+ from .parser import get_parser
4
+
5
+ class SchemaManager:
6
+ """Handles schema metadata parsing, defaults resolution, and validation logic."""
7
+
8
+ def __init__(self, schema_filepath: str):
9
+ self.schema_filepath = schema_filepath
10
+ self.schema_meta: Dict[str, Any] = {}
11
+ self._load_schema()
12
+
13
+ def _load_schema(self):
14
+ if os.path.exists(self.schema_filepath):
15
+ parser = get_parser(self.schema_filepath)
16
+ loaded_schema = parser.load(self.schema_filepath)
17
+ self.schema_meta = loaded_schema if loaded_schema is not None else {}
18
+ else:
19
+ print(f"Schema file {self.schema_filepath} not found. Using empty schema.")
20
+
21
+ def get_meta(self, key: str) -> Dict[str, Any]:
22
+ """Safely fetch metadata for a property key."""
23
+ return self.schema_meta.get(key, {})
24
+
25
+ def get_default_val_for_type(self, type_str: str) -> Any:
26
+ """Returns a sensible default value based on the given schema type."""
27
+ if type_str == 'boolean': return False
28
+ if type_str in ['integer', 'number', 'float']: return 0
29
+ if type_str in ['dict', 'container']: return {}
30
+ if type_str == 'list': return []
31
+ return ""
32
+
33
+ def prefill_required(self, schema_key: str) -> Dict[str, Any]:
34
+ """Scans the schema and creates a dictionary containing all REQUIRED child properties pre-filled."""
35
+ new_val = {}
36
+ allowed = self.get_meta(schema_key).get('allowed_children', [])
37
+ for child_key in allowed:
38
+ child_meta = self.get_meta(child_key)
39
+ if child_meta.get('required', False):
40
+ child_type = child_meta.get('type', 'string')
41
+ if child_type in ['container', 'dict']:
42
+ new_val[child_key] = self.prefill_required(child_key)
43
+ else:
44
+ new_val[child_key] = self.get_default_val_for_type(child_type)
45
+ return new_val
46
+
47
+ def get_schema_key_for_path(self, path: str, root_data: Any) -> str:
48
+ """Resolves the active schema definition key for a data path location."""
49
+ if path == "root":
50
+ return "root"
51
+ parts = path.split('/')[1:]
52
+ current_schema_key = None
53
+ curr_data = root_data
54
+
55
+ for p in parts:
56
+ if curr_data is not None:
57
+ if isinstance(curr_data, list) and p.isdigit():
58
+ try: curr_data = curr_data[int(p)]
59
+ except IndexError: curr_data = None
60
+ elif isinstance(curr_data, dict):
61
+ curr_data = curr_data.get(p)
62
+ else:
63
+ curr_data = None
64
+
65
+ if p.endswith(('.yaml', '.yml', '.json')):
66
+ current_schema_key = os.path.splitext(p)[0]
67
+ elif p.isdigit():
68
+ if current_schema_key and current_schema_key in self.schema_meta:
69
+ meta = self.get_meta(current_schema_key)
70
+ if 'list_item_types' in meta:
71
+ best_match = None
72
+ max_overlap = -1
73
+ if isinstance(curr_data, dict):
74
+ for t in meta['list_item_types']:
75
+ allowed = set(self.get_meta(t).get('allowed_children', []))
76
+ overlap = len(allowed.intersection(curr_data.keys()))
77
+ if overlap > max_overlap:
78
+ max_overlap = overlap
79
+ best_match = t
80
+ current_schema_key = best_match if best_match else meta['list_item_types'][0]
81
+ else:
82
+ current_schema_key = meta.get('list_item_type', current_schema_key)
83
+ else:
84
+ current_schema_key = p
85
+ return current_schema_key
86
+
87
+ def get_label_key_for_schema(self, schema_key: str) -> str:
88
+ """Finds which sub-property should be dynamically used as the naming label for UI containers."""
89
+ if schema_key and schema_key in self.schema_meta:
90
+ meta = self.get_meta(schema_key)
91
+ if 'label_key' in meta:
92
+ return meta['label_key']
93
+ for child in meta.get('allowed_children', []):
94
+ if child in self.schema_meta and self.schema_meta[child].get('is_label', False):
95
+ return child
96
+ return None
97
+
98
+ def get_item_label(self, item_data: Any, item_path: str, root_data: Any, default_label: str) -> str:
99
+ """Agnostically determines the display label for an object via schema rules."""
100
+ if not isinstance(item_data, dict):
101
+ return default_label
102
+
103
+ schema_key = self.get_schema_key_for_path(item_path, root_data)
104
+ label_key = self.get_label_key_for_schema(schema_key)
105
+
106
+ if label_key and label_key in item_data:
107
+ return str(item_data[label_key])
108
+
109
+ for k, v in item_data.items():
110
+ if 'name' in str(k).lower() and isinstance(v, (str, int)):
111
+ return str(v)
112
+
113
+ for k, v in item_data.items():
114
+ if isinstance(v, str):
115
+ return v
116
+
117
+ return default_label
structui/state.py ADDED
@@ -0,0 +1,138 @@
1
+ import os
2
+ import glob
3
+ import copy
4
+ from typing import Dict, Any, List
5
+ from .schema import SchemaManager
6
+ from .parser import get_parser
7
+
8
+ class AppState:
9
+ """Manages raw config data, memory states, transactions, and undo/redo stacks."""
10
+
11
+ def __init__(self, data_dir: str, schema_manager: SchemaManager):
12
+ self.data_dir = data_dir
13
+ self.schema_manager = schema_manager
14
+
15
+ self.config_data: Dict[str, Any] = {}
16
+ self.history: List[Dict[str, Any]] = []
17
+ self.history_index: int = -1
18
+ self.last_saved_index: int = -1
19
+ self.is_dirty: bool = False
20
+
21
+ self.load_files()
22
+
23
+ def load_files(self):
24
+ """Loads all supported formatting files in the source directory."""
25
+ self.config_data = {}
26
+ files = glob.glob(os.path.join(self.data_dir, "*.yaml")) + \
27
+ glob.glob(os.path.join(self.data_dir, "*.yml")) + \
28
+ glob.glob(os.path.join(self.data_dir, "*.json"))
29
+
30
+ for filepath in files:
31
+ filename = os.path.basename(filepath)
32
+ if filename == os.path.basename(self.schema_manager.schema_filepath):
33
+ continue
34
+
35
+ parser = get_parser(filepath)
36
+ data = parser.load(filepath)
37
+
38
+ if data is None:
39
+ # Fill structure based on declared schema
40
+ schema_key = os.path.splitext(filename)[0]
41
+ file_type = self.schema_manager.get_meta(schema_key).get('type', 'dict')
42
+ data = [] if file_type == 'list' else {}
43
+
44
+ self.config_data[filename] = data
45
+
46
+ self.history = [copy.deepcopy(self.config_data)]
47
+ self.history_index = 0
48
+ self.last_saved_index = 0
49
+ self.is_dirty = False
50
+
51
+ def commit(self):
52
+ """Saves a memory snapshot into the history stack."""
53
+ self.history = self.history[:self.history_index + 1]
54
+ self.history.append(copy.deepcopy(self.config_data))
55
+
56
+ if len(self.history) > 100:
57
+ self.history.pop(0)
58
+ if self.last_saved_index > 0:
59
+ self.last_saved_index -= 1
60
+ elif self.last_saved_index == 0:
61
+ self.last_saved_index = -1
62
+ else:
63
+ self.history_index += 1
64
+ self.is_dirty = (self.history_index != self.last_saved_index)
65
+
66
+ def undo(self) -> bool:
67
+ """Reverts local modification to a previous epoch."""
68
+ if self.history_index > 0:
69
+ self.history_index -= 1
70
+ self.config_data = copy.deepcopy(self.history[self.history_index])
71
+ self.is_dirty = (self.history_index != self.last_saved_index)
72
+ return True
73
+ return False
74
+
75
+ def redo(self) -> bool:
76
+ """Restores a previous undo."""
77
+ if self.history_index < len(self.history) - 1:
78
+ self.history_index += 1
79
+ self.config_data = copy.deepcopy(self.history[self.history_index])
80
+ self.is_dirty = (self.history_index != self.last_saved_index)
81
+ return True
82
+ return False
83
+
84
+ def get_data_by_path(self, path: str) -> Any:
85
+ """Returns the local data node associated with the UI tree path string."""
86
+ if path == "root":
87
+ return self.config_data
88
+
89
+ keys = path.split('/')[1:]
90
+ curr = self.config_data
91
+
92
+ try:
93
+ for key in keys:
94
+ if curr is None: return None
95
+ if isinstance(curr, list):
96
+ curr = curr[int(key)]
97
+ elif isinstance(curr, dict):
98
+ curr = curr.get(key)
99
+ else:
100
+ return None
101
+ return curr
102
+ except (IndexError, ValueError, KeyError, AttributeError):
103
+ return None
104
+
105
+ def set_data_by_path(self, path: str, property_key: str, new_value: Any):
106
+ """Mutates a targeted property value on the underlying tree."""
107
+ curr = self.get_data_by_path(path)
108
+ if isinstance(curr, dict):
109
+ curr[property_key] = new_value
110
+ elif isinstance(curr, list):
111
+ curr[int(property_key)] = new_value
112
+ self.is_dirty = (self.history_index != self.last_saved_index)
113
+
114
+ def save_all_to_disk(self):
115
+ """Dispatches save operations to agnostic parsers and handles raw deletion tracking."""
116
+ schema_base = os.path.basename(self.schema_manager.schema_filepath)
117
+ existing_logical_files = [
118
+ f for f in os.listdir(self.data_dir)
119
+ if f.endswith(('.yaml', '.yml', '.json')) and f != schema_base
120
+ ]
121
+
122
+ for filename, data in self.config_data.items():
123
+ filepath = os.path.join(self.data_dir, filename)
124
+ try:
125
+ parser = get_parser(filepath)
126
+ parser.save(filepath, data)
127
+ except Exception as e:
128
+ print(f"Error saving {filename}: {e}")
129
+
130
+ for f in existing_logical_files:
131
+ if f not in self.config_data:
132
+ try:
133
+ os.remove(os.path.join(self.data_dir, f))
134
+ except Exception as e:
135
+ print(f"Error removing {f}: {e}")
136
+
137
+ self.last_saved_index = self.history_index
138
+ self.is_dirty = False
structui/ui.py ADDED
@@ -0,0 +1,458 @@
1
+ import os
2
+ import sys
3
+ from nicegui import app, ui
4
+ from typing import Dict, Any, List
5
+ from .state import AppState
6
+ from .schema import SchemaManager
7
+ from .file_picker import LocalFilePicker
8
+
9
+ class StructUI:
10
+ """The central view abstraction for managing the hierarchical NiceGUI visualization."""
11
+
12
+ def __init__(self, state: AppState, schema_manager: SchemaManager):
13
+ self.state = state
14
+ self.schema_manager = schema_manager
15
+
16
+ self.selected_path = {"value": "root"}
17
+ self.tree = None
18
+ self.editor_scroll_area = None
19
+ self.footer_pane = None
20
+ self.dark_mode = None
21
+ self.save_btn = None
22
+
23
+ def get_allowed_options(self, path: str, data_node: Any) -> List[Dict[str, str]]:
24
+ schema_key = self.schema_manager.get_schema_key_for_path(path, self.state.config_data)
25
+ meta = self.schema_manager.get_meta(schema_key) if schema_key else {}
26
+ allowed_from_schema = meta.get('allowed_children', [])
27
+ allowed_options = []
28
+
29
+ if isinstance(data_node, dict):
30
+ for child in allowed_from_schema:
31
+ if child not in data_node:
32
+ allowed_options.append({'label': f"Add {child.replace('_', ' ').title()}", 'type': 'dict_key', 'key': child})
33
+ else:
34
+ child_meta = self.schema_manager.get_meta(child)
35
+ if isinstance(data_node[child], list) and child_meta.get('type') in ['container', 'list']:
36
+ if 'list_item_types' in child_meta:
37
+ for item_type in child_meta['list_item_types']:
38
+ allowed_options.append({'label': f"Add New {item_type.replace('_', ' ').title()} to {child}", 'type': 'list_item_append', 'key': child, 'item_type': item_type})
39
+ else:
40
+ item_type = child_meta.get('list_item_type', child)
41
+ allowed_options.append({'label': f"Add New {item_type.replace('_', ' ').title()} to {child}", 'type': 'list_item_append', 'key': child, 'item_type': item_type})
42
+
43
+ if not meta.get('restrict_custom_keys', False):
44
+ allowed_options.append({'label': "Add Custom File" if path == "root" else "Add Custom Key", 'type': 'custom_dict'})
45
+
46
+ elif isinstance(data_node, list):
47
+ meta = self.schema_manager.get_meta(schema_key) if schema_key else {}
48
+ if 'list_item_types' in meta:
49
+ for item_type in meta['list_item_types']:
50
+ allowed_options.append({'label': f"Add New {item_type.replace('_', ' ').title()}", 'type': 'list_item_typed', 'key': item_type})
51
+ elif 'list_item_type' in meta:
52
+ item_type = meta['list_item_type']
53
+ allowed_options.append({'label': f"Add New {item_type.replace('_', ' ').upper()}", 'type': 'list_item_typed', 'key': item_type})
54
+ else:
55
+ allowed_options.append({'label': "Add New Item", 'type': 'list_item'})
56
+
57
+ return allowed_options
58
+
59
+ def build_tree_nodes(self, data: Any, path: str = "root", name: str = "Configurations") -> Dict[str, Any]:
60
+ node = {'id': path, 'label': name}
61
+ children = []
62
+ node['allowed'] = self.get_allowed_options(path, data)
63
+ has_prims = False
64
+
65
+ if isinstance(data, dict):
66
+ for k, v in data.items():
67
+ if not isinstance(v, (dict, list)):
68
+ has_prims = True
69
+ elif isinstance(v, list) and all(not isinstance(x, (dict, list)) for x in v):
70
+ if self.schema_manager.get_meta(str(k)).get('type') not in ['container', 'list']:
71
+ has_prims = True
72
+
73
+ if isinstance(v, (dict, list)):
74
+ if isinstance(v, list) and all(not isinstance(x, (dict, list)) for x in v):
75
+ if self.schema_manager.get_meta(str(k)).get('type') not in ['container', 'list']:
76
+ continue
77
+ children.append(self.build_tree_nodes(v, f"{path}/{k}", str(k)))
78
+ elif isinstance(data, list):
79
+ for i, v in enumerate(data):
80
+ if isinstance(v, (dict, list)):
81
+ label = self.schema_manager.get_item_label(v, f"{path}/{i}", self.state.config_data, f"[{i}]")
82
+ children.append(self.build_tree_nodes(v, f"{path}/{i}", label))
83
+
84
+ if has_prims:
85
+ node['icon'], node['color'] = 'settings', 'blue-8'
86
+ else:
87
+ node['icon'], node['color'] = 'folder', 'primary'
88
+
89
+ if not children and not has_prims:
90
+ node['icon'], node['color'] = 'folder_open', 'grey-5'
91
+
92
+ if children:
93
+ node['children'] = children
94
+
95
+ return node
96
+
97
+ def refresh_tree_and_editor(self):
98
+ if self.tree is not None:
99
+ new_nodes = [self.build_tree_nodes(self.state.config_data)]
100
+ curr_expanded = set(self.tree._props.get('expanded', []))
101
+
102
+ if self.selected_path["value"]:
103
+ parts = self.selected_path["value"].split('/')
104
+ for i in range(1, len(parts) + 1):
105
+ curr_expanded.add("/".join(parts[:i]))
106
+
107
+ self.tree._props['nodes'] = new_nodes
108
+ self.tree._props['expanded'] = list(curr_expanded)
109
+
110
+ if self.selected_path["value"]:
111
+ self.tree._props['selected'] = self.selected_path["value"]
112
+
113
+ self.tree.update()
114
+
115
+ if not self.selected_path["value"]:
116
+ self.selected_path["value"] = "root"
117
+
118
+ self.update_save_btn_state()
119
+
120
+ self.draw_editor(self.selected_path["value"])
121
+
122
+ def update_save_btn_state(self):
123
+ if getattr(self, 'save_btn', None):
124
+ if getattr(self.state, 'is_dirty', False):
125
+ self.save_btn._props['color'] = 'warning'
126
+ self.save_btn.tooltip('Unsaved Changes Available!')
127
+ else:
128
+ self.save_btn._props['color'] = 'primary'
129
+ self.save_btn.tooltip('Save Configurations')
130
+ self.save_btn.update()
131
+
132
+ self.draw_editor(self.selected_path["value"])
133
+
134
+ def update_footer(self, prop_key=None):
135
+ self.footer_pane.clear()
136
+ with self.footer_pane:
137
+ if not prop_key:
138
+ ui.label("Help & Metadata").classes('text-lg font-bold text-gray-700 dark:text-gray-300')
139
+ ui.label("Select a property in the editor to see its description and type.").classes('text-gray-500 dark:text-gray-400 italic')
140
+ return
141
+
142
+ meta = self.schema_manager.get_meta(prop_key)
143
+ if not meta:
144
+ meta = {"type": "unknown", "desc": "No description available for this property."}
145
+
146
+ with ui.row().classes('items-center gap-2 mb-2'):
147
+ ui.icon('info', size='sm', color='primary')
148
+ ui.label(prop_key).classes('text-lg font-bold text-blue-800 dark:text-blue-300 font-mono')
149
+ ui.badge(meta.get('type', 'unknown'), color='blue-200').classes('text-blue-900 ml-2')
150
+
151
+ is_req = meta.get('required', False)
152
+ req_color = "red-100 text-red-800" if is_req else "green-100 text-green-800"
153
+ ui.badge("Required" if is_req else "Optional").classes(f'ml-2 {req_color} font-bold border border-current')
154
+ ui.markdown(meta.get('desc', 'No description provided.')).classes('text-gray-700 dark:text-gray-300 text-md ml-8')
155
+
156
+ def handle_add_node(self, path: str, option: Dict[str, str]):
157
+ data_node = self.state.get_data_by_path(path)
158
+ opt_type = option['type']
159
+
160
+ if opt_type == 'dict_key':
161
+ if isinstance(data_node, dict):
162
+ k = option.get('key')
163
+ meta_type = self.schema_manager.get_meta(k).get('type')
164
+ if meta_type == 'list':
165
+ data_node[k] = []
166
+ elif meta_type in ['container', 'dict']:
167
+ data_node[k] = {}
168
+ elif meta_type == 'bool':
169
+ data_node[k] = False
170
+ elif meta_type in ['int', 'number', 'float']:
171
+ data_node[k] = 0
172
+ else:
173
+ data_node[k] = ""
174
+ elif opt_type == 'custom_dict':
175
+ if isinstance(data_node, dict):
176
+ with ui.dialog() as dialog, ui.card().classes('min-w-[300px]'):
177
+ def perform_add(dyn_key, dyn_type):
178
+ if dyn_key:
179
+ if dyn_type == 'dict': data_node[dyn_key] = {}
180
+ elif dyn_type == 'list': data_node[dyn_key] = []
181
+ elif dyn_type == 'string': data_node[dyn_key] = ""
182
+ elif dyn_type == 'integer': data_node[dyn_key] = 0
183
+ elif dyn_type == 'boolean': data_node[dyn_key] = False
184
+ dialog.close()
185
+ self.state.commit()
186
+ self.refresh_tree_and_editor()
187
+ ui.label('Enter new key name:').classes('font-bold')
188
+ inp = ui.input().classes('w-full')
189
+ ui.label('Select Type:').classes('font-bold mt-2')
190
+ type_sel = ui.select(['string', 'integer', 'boolean', 'list', 'dict'], value='string').classes('w-full')
191
+ ui.button('Add', on_click=lambda: perform_add(inp.value, type_sel.value)).classes('mt-4 w-full')
192
+ dialog.open()
193
+ return
194
+ elif opt_type in ['list_item', 'list_item_typed']:
195
+ if isinstance(data_node, list):
196
+ data_node.append({})
197
+ elif opt_type == 'list_item_append':
198
+ if isinstance(data_node, dict):
199
+ list_key = option.get('key')
200
+ if list_key in data_node and isinstance(data_node[list_key], list):
201
+ data_node[list_key].append({})
202
+
203
+ self.state.commit()
204
+ self.refresh_tree_and_editor()
205
+
206
+ def draw_editor(self, path: str):
207
+ if not path:
208
+ path = "root"
209
+ self.selected_path["value"] = path
210
+ self.editor_scroll_area.clear()
211
+ self.update_footer(None)
212
+
213
+ data_node = self.state.get_data_by_path(path)
214
+ if data_node is None:
215
+ with self.editor_scroll_area:
216
+ ui.label("This node no longer exists or was deleted.").classes('text-red-500 mt-10 text-lg font-bold')
217
+ return
218
+
219
+ with self.editor_scroll_area:
220
+ # INTERACTIVE BREADCRUMBS
221
+ parts = path.split('/')
222
+ with ui.row().classes('items-center gap-2 mb-6 w-full flex-wrap'):
223
+ current_path = []
224
+ for i, p in enumerate(parts):
225
+ current_path.append(p)
226
+ full_path = "/".join(current_path)
227
+
228
+ def make_breadcrumb_nav(nav_p=full_path):
229
+ return lambda e: (self.selected_path.update({"value": nav_p}), self.refresh_tree_and_editor())
230
+
231
+ ui.label("Root" if p == "root" else p).classes('text-blue-600 dark:text-blue-400 hover:text-blue-800 cursor-pointer font-bold text-lg hover:underline').on('click', make_breadcrumb_nav())
232
+ if i < len(parts) - 1:
233
+ ui.icon('chevron_right', color='gray').classes('text-gray-400')
234
+
235
+ # EDITOR HEADER
236
+ with ui.row().classes('w-full items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-slate-700'):
237
+ with ui.row().classes('items-center bg-slate-50 dark:bg-slate-900 gap-2'):
238
+ prop_search = ui.input('Search properties...').classes('w-64').props('dense rounded outlined')
239
+
240
+ def locate_in_tree(p=path):
241
+ if self.tree:
242
+ parts = p.split('/')
243
+ paths_to_expand = ['/'.join(parts[:i+1]) for i in range(len(parts))]
244
+ self.tree.expand(paths_to_expand)
245
+ ui.notify('Located in tree', color='blue')
246
+
247
+ ui.button(icon='my_location', on_click=locate_in_tree).props('flat round').classes('text-slate-600 dark:text-slate-400').tooltip('Locate in Tree')
248
+
249
+ with ui.button(icon='add').props('flat round').classes('text-blue-500').tooltip('Add Property / Node'):
250
+ with ui.menu() as add_menu:
251
+ opts = self.get_allowed_options(path, data_node)
252
+ if not opts:
253
+ ui.menu_item('No actions available').classes('text-gray-400')
254
+ for opt in opts:
255
+ def make_add_callback(o=opt):
256
+ return lambda: self.handle_add_node(path, o)
257
+ ui.menu_item(opt['label'], auto_close=True, on_click=make_add_callback()).classes('flex items-center gap-2')
258
+
259
+ if path != "root":
260
+ def delete_current_container():
261
+ parent_path = "/".join(path.split('/')[:-1])
262
+ node_key = path.split('/')[-1]
263
+ parent_node = self.state.get_data_by_path(parent_path)
264
+ if isinstance(parent_node, dict): parent_node.pop(node_key, None)
265
+ elif isinstance(parent_node, list): parent_node.pop(int(node_key))
266
+ self.state.commit()
267
+ self.selected_path["value"] = parent_path
268
+ self.refresh_tree_and_editor()
269
+
270
+ ui.button(icon='delete', on_click=delete_current_container).props('flat round').classes('text-red-500').tooltip('Delete this entire container')
271
+
272
+ # PRIMITIVES EDITOR
273
+ props_container = ui.column().classes('w-full gap-4')
274
+
275
+ def render_primitive_input(k, v, parent_node):
276
+ def make_on_change(prop_key=k):
277
+ def handler(e):
278
+ self.state.set_data_by_path(self.selected_path["value"], str(prop_key), e.value)
279
+ self.state.commit()
280
+ self.update_save_btn_state()
281
+ return handler
282
+
283
+ meta = self.schema_manager.get_meta(str(k))
284
+ is_required = meta.get('required', False)
285
+ label_text = f"{k} *" if is_required else str(k)
286
+ options = meta.get('options')
287
+
288
+ with ui.row().classes('items-center flex-grow flex-nowrap gap-2 w-full'):
289
+ if options:
290
+ safe_options = list(options)
291
+ if v not in safe_options: safe_options.append(v)
292
+ inp = ui.select(safe_options, value=v, label=label_text).classes('flex-grow').on_value_change(make_on_change())
293
+ elif isinstance(v, bool):
294
+ inp = ui.switch(text=label_text, value=v).on_value_change(make_on_change())
295
+ elif isinstance(v, (int, float)):
296
+ inp = ui.number(label=label_text, value=v).classes('flex-grow').on_value_change(make_on_change())
297
+ else:
298
+ inp = ui.input(label=label_text, value=str(v)).classes('flex-grow').on_value_change(make_on_change())
299
+
300
+ inp.on('focus', lambda _: self.update_footer(str(k)))
301
+
302
+ if not is_required:
303
+ def delete_prop(pk=k, pd=parent_node):
304
+ if isinstance(pd, dict) and pk in pd: pd.pop(pk, None)
305
+ elif isinstance(pd, list) and int(pk) < len(pd): pd.pop(int(pk))
306
+ self.state.commit()
307
+ self.refresh_tree_and_editor()
308
+ ui.button(icon='delete_outline', color='red-400', on_click=delete_prop).props('flat round size=sm').tooltip('Remove Property').classes('mt-2')
309
+
310
+ with props_container:
311
+ has_primitives = False
312
+ if isinstance(data_node, dict):
313
+ for k, v in data_node.items():
314
+ if not isinstance(v, (dict, list)):
315
+ has_primitives = True
316
+ with ui.row().classes('w-full items-center gap-2'):
317
+ is_req = self.schema_manager.get_meta(str(k)).get('required', False)
318
+ ui.icon('lock', size='sm').classes('text-gray-400 w-8') if is_req else ui.icon('edit', size='sm').classes('text-blue-400 w-8')
319
+ render_primitive_input(k, v, data_node)
320
+ elif isinstance(data_node, list):
321
+ for i, v in enumerate(data_node):
322
+ if not isinstance(v, (dict, list)):
323
+ has_primitives = True
324
+ with ui.row().classes('w-full items-center gap-2'):
325
+ ui.icon('edit', size='sm').classes('text-blue-400 w-8')
326
+ render_primitive_input(i, v, data_node)
327
+
328
+ if not has_primitives:
329
+ ui.label("This node contains no primitive properties directly.").classes('text-gray-400 italic mt-2')
330
+
331
+ # SUB-CONTAINERS FOLDERS
332
+ sub_containers = []
333
+ if isinstance(data_node, dict):
334
+ for k, v in data_node.items():
335
+ if isinstance(v, dict) or (isinstance(v, list) and not (isinstance(v, list) and all(not isinstance(x, (dict, list)) for x in v))):
336
+ sub_containers.append((k, f"{path}/{k}"))
337
+ elif isinstance(data_node, list):
338
+ for i, v in enumerate(data_node):
339
+ if isinstance(v, (dict, list)):
340
+ item_label = self.schema_manager.get_item_label(v, f"{path}/{i}", self.state.config_data, f"Item [{i}]")
341
+ sub_containers.append((item_label, f"{path}/{i}"))
342
+
343
+ if sub_containers:
344
+ ui.separator().classes('my-6')
345
+ ui.label("Sub-Containers").classes('text-xl font-bold text-slate-800 dark:text-slate-200 mb-4')
346
+ with ui.row().classes('w-full gap-4 flex-wrap'):
347
+ for label, child_path in sub_containers:
348
+ with ui.card().tight().classes('cursor-pointer hover:bg-blue-50 border border-gray-200 dark:bg-slate-800 shadow-sm w-48').on('click', lambda _, cp=child_path: (self.selected_path.update({"value": cp}), self.refresh_tree_and_editor())):
349
+ with ui.row().classes('items-center p-3 gap-3 w-full'):
350
+ ui.icon('folder', color='primary', size='sm')
351
+ ui.label(str(label)).classes('font-bold text-slate-700 dark:text-slate-300 truncate w-32')
352
+
353
+ def render(self):
354
+ self.dark_mode = ui.dark_mode()
355
+ ui.add_head_html('''
356
+ <style>
357
+ body.body--light { background-color: #f8fafc; }
358
+ body.body--dark { background-color: #0f172a; }
359
+ .q-tree__node-header { width: 100%; }
360
+
361
+ body.body--dark .q-tree { color: #e2e8f0 !important; }
362
+ body.body--dark .q-field__native, body.body--dark .q-field__label { color: #e2e8f0 !important; }
363
+ body.body--dark .q-card { background-color: #1e293b !important; border-color: #334155 !important; color: #e2e8f0 !important; }
364
+ body.body--dark .bg-slate-50 { background-color: #0f172a !important; border-color: #334155 !important; }
365
+ </style>
366
+ ''')
367
+
368
+ with ui.header().classes('bg-slate-800 text-white shadow-md p-4 flex justify-between items-center'):
369
+ with ui.row().classes('items-center gap-3 w-1/2 overflow-hidden'):
370
+ ui.icon('settings_input_component', size='md')
371
+ ui.label('StructUI Editor').classes('text-xl font-bold tracking-wide')
372
+ ui.badge().classes('text-xs ml-4 py-1 px-2 font-mono truncate max-w-sm').bind_text_from(self.state, 'data_dir', backward=lambda d: f"Workspace: {d}" if d else "No Workspace Loaded")
373
+
374
+ with ui.row().classes('gap-2 items-center'):
375
+ ui.button(icon='dark_mode', on_click=lambda: self.dark_mode.set_value(not self.dark_mode.value)).props('flat round color=white')
376
+ ui.separator().props('vertical color=gray-500').classes('mx-2')
377
+
378
+ # Load Configuration / Schema Buttons
379
+ async def pick_config_dir():
380
+ result = await LocalFilePicker(directory=self.state.data_dir, dirs_only=True, upper_limit=None, show_hidden_files=True)
381
+ if result:
382
+ self.state.data_dir = result[0]
383
+ self.state.load_files()
384
+ self.selected_path["value"] = "root"
385
+ self.refresh_tree_and_editor()
386
+ ui.notify(f'Loaded Configs from {self.state.data_dir}', color='blue')
387
+
388
+ async def pick_schema_file():
389
+ result = await LocalFilePicker(directory=os.path.dirname(os.path.abspath(self.schema_manager.schema_filepath)), multiple=False, upper_limit=None, show_hidden_files=True)
390
+ if result:
391
+ self.schema_manager.schema_filepath = result[0]
392
+ self.schema_manager._load_schema()
393
+ self.refresh_tree_and_editor()
394
+ ui.notify(f'Loaded Schema from {os.path.basename(self.schema_manager.schema_filepath)}', color='blue')
395
+
396
+ ui.button('Load Configs', icon='folder_open', color='slate-600', on_click=pick_config_dir).props('outline').tooltip("Select configuration directory to load")
397
+ ui.button('Load Schema', icon='schema', color='slate-600', on_click=pick_schema_file).props('outline').tooltip("Select schema file to load")
398
+
399
+ ui.separator().props('vertical color=gray-500').classes('mx-2')
400
+
401
+ # Exit Dialog
402
+ with ui.dialog() as exit_dialog, ui.card():
403
+ ui.label('Are you sure you want to exit?').classes('text-lg font-bold mb-4')
404
+ with ui.row().classes('w-full justify-between mt-2'):
405
+ ui.button('Cancel', color='gray', on_click=exit_dialog.close).props('outline')
406
+ with ui.row().classes('gap-2'):
407
+ ui.button('Exit without Saving', color='red', on_click=lambda: (ui.notify('Exiting...', type='warning'), ui.run_javascript('window.close()'), app.shutdown())).props('outline')
408
+ ui.button('Save and Exit', color='green', on_click=lambda: (self.state.save_all_to_disk(), ui.notify('Saved! Exiting...', color='green'), ui.run_javascript('window.close()'), app.shutdown()))
409
+
410
+ ui.button(icon='undo', color='slate-600', on_click=lambda: self.refresh_tree_and_editor() if self.state.undo() else ui.notify('Nothing to undo', type='warning')).props('flat round')
411
+ ui.button(icon='redo', color='slate-600', on_click=lambda: self.refresh_tree_and_editor() if self.state.redo() else ui.notify('Nothing to redo', type='warning')).props('flat round')
412
+ ui.separator().props('vertical')
413
+ self.save_btn = ui.button(icon='save', color='primary', on_click=lambda: (self.state.save_all_to_disk(), ui.notify('Saved Configurations', color='green'), self.refresh_tree_and_editor())).props('round')
414
+ self.save_btn.tooltip('Save Configurations')
415
+ ui.button(icon='power_settings_new', color='red-500', on_click=exit_dialog.open).props('flat round').tooltip('Exit Application')
416
+
417
+ with ui.row().classes('w-full h-[calc(100vh-80px)] overflow-hidden p-4 gap-4 flex-nowrap'):
418
+ with ui.column().classes('w-1/3 min-w-[300px] h-full gap-4'):
419
+ with ui.card().classes('w-full h-full p-0 shadow-md border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col'):
420
+ with ui.row().classes('w-full p-4 border-b border-gray-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 justify-between items-center'):
421
+ ui.label("Configuration Tree").classes('font-bold text-slate-700 dark:text-slate-300 tracking-wide uppercase text-sm')
422
+ with ui.row().classes('gap-1 items-center'):
423
+ tree_search = ui.input('Search...').classes('w-48').props('dense rounded outlined clearable')
424
+ ui.button(icon='unfold_less', on_click=lambda: self.tree.collapse() if self.tree else None).props('flat round size=sm').classes('text-slate-500').tooltip('Collapse All')
425
+ ui.button(icon='unfold_more', on_click=lambda: self.tree.expand() if self.tree else None).props('flat round size=sm').classes('text-slate-500').tooltip('Expand All')
426
+
427
+ with ui.scroll_area().classes('w-full bg-slate-50 dark:bg-slate-900 flex-grow p-4 pt-2'):
428
+ self.tree = ui.tree([self.build_tree_nodes(self.state.config_data)], label_key='label', on_select=lambda e: (self.selected_path.update({"value": e.value}), self.refresh_tree_and_editor()) if e.value else None).classes('w-full custom-tree font-medium text-slate-700 dark:text-slate-300')
429
+ if self.tree:
430
+ self.tree._props['selected'] = self.selected_path["value"]
431
+ self.tree.props('control-color=primary node-key=id')
432
+ tree_search.on_value_change(lambda e: (self.tree._props.update({'filter': e.value}), self.tree.update()) if self.tree else None)
433
+ self.tree.expand()
434
+
435
+ def handle_expanded(e):
436
+ if getattr(e, 'args', None) is not None:
437
+ old_expanded = set(self.tree._props.get('expanded', []))
438
+ new_expanded = set(e.args)
439
+ added = new_expanded - old_expanded
440
+ if added:
441
+ target = list(added)[0]
442
+ self.selected_path["value"] = target
443
+ self.refresh_tree_and_editor()
444
+ else:
445
+ self.tree._props['expanded'] = list(new_expanded)
446
+ self.tree.update()
447
+
448
+ self.tree.on('update:expanded', handle_expanded)
449
+
450
+ with ui.column().classes('w-2/3 flex-grow bg-slate-50 dark:bg-slate-900 h-full gap-4'):
451
+ with ui.card().classes('w-full h-3/4 shadow-md border border-gray-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 flex flex-col p-6 pt-4'):
452
+ ui.label("Properties Editor").classes('font-bold text-slate-700 dark:text-slate-300 tracking-wide uppercase text-sm mb-2')
453
+ self.editor_scroll_area = ui.scroll_area().classes('w-full flex-grow')
454
+
455
+ with ui.card().classes('w-full h-1/4 shadow-md border border-gray-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 p-4'):
456
+ self.footer_pane = ui.column().classes('w-full')
457
+
458
+ self.refresh_tree_and_editor()
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: structui
3
+ Version: 0.1.0
4
+ Summary: A format-agnostic, schema-driven, hierarchical configuration UI.
5
+ Author: structui contributors
6
+ License: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Web Environment
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: nicegui>=1.4.0
19
+ Requires-Dist: pyyaml>=6.0.1
20
+ Requires-Dist: pywin32>=300; sys_platform == "win32"
21
+
22
+ # StructUI
23
+
24
+ StructUI is a format-agnostic, schema-driven, hierarchical configuration UI engine built in Python. Designed as a flexible architectural backbone, it parses standard configuration files (YAML, JSON, CSV, XML) and dynamically generates a live web-based property editor based on constraints and metadata defined in a schema file.
25
+
26
+ The architecture is explicitly decoupled, making it readily extensible to strict domain-specific specifications (e.g., AUTOSAR configurators) and agent-driven programmatic workflows.
27
+
28
+ ## Features
29
+
30
+ - **Pillar A: Format-Agnostic Parsers:** cleanly separate UI generation from underlying data formats. Out-of-the-box support for YAML and JSON, with abstract base classes easily extensible to XML or CSV.
31
+ - **Pillar B: Hierarchical UI:** Dynamic tree-based rendering with full support for multidimensional containers, dynamic polymorphic list additions, and node mapping. Powered natively by NiceGUI.
32
+ - **Pillar C: Data Validity:** Enforces schema metadata strictly at the UI layer. Missing fields gracefully populate via defaults, required flags trigger locking, and nested typings are continuously evaluated.
33
+ - **Pillar D: Extensibility & Programmatic Control:** Decomposed core logic (App, Parser, State, Schema, UI) allowing external tools and wrappers (e.g. CLI, Agent Workflows) to invoke the editor or inject properties safely.
34
+
35
+ ## Installation & Setup
36
+
37
+ StructUI leverages standard Python packaging mechanisms.
38
+
39
+ ```bash
40
+ # Clone the repository
41
+ git clone https://github.com/your-username/structui.git
42
+ cd structui
43
+
44
+ # Install the application locally
45
+ pip install -e .
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ Launch the editor in the current directory against your local configuration files by simply typing:
51
+
52
+ ```bash
53
+ structui --dir . --schema .structui_schema.yaml --port 8080
54
+ ```
@@ -0,0 +1,13 @@
1
+ structui/__init__.py,sha256=X9solSS5AR_nnc7a5b6WzTEH2-YV1AID4uH_mI6FeIw,161
2
+ structui/app.py,sha256=u8tkJFG2QHhBoFK2YdV2fBX9SlkhOF6h9qdYLOG2Ocw,558
3
+ structui/cli.py,sha256=IQT6-i1Pd-d7-M-FFR36C3KEa9z9v8yVxpQUZbQCk4k,781
4
+ structui/file_picker.py,sha256=fN5csaPKgMqf-bmrejQEPaH9awZVKDjDfHOPUj53AFM,3719
5
+ structui/parser.py,sha256=APGoUBUM2gczwZnF7fMiHI1LbxTeNndIS7ya50idGjE,1607
6
+ structui/schema.py,sha256=sAjJnYqblIzy8BhVjG-aRo52p2hXWELUxdz0ZjIxTg0,5403
7
+ structui/state.py,sha256=cz1AUNodTXnQvqJnJbxXPGrrzoIvqSk1C-Hjcht9AKY,5474
8
+ structui/ui.py,sha256=htO0YZmba0S2Q-nJ7hpRHd8g4H4lFvA9P6pFo0EWrxc,28751
9
+ structui-0.1.0.dist-info/METADATA,sha256=dhya0ciM-54qux3UnJXWaZpqy58h8aSYLRnnUza9ZXg,2633
10
+ structui-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
11
+ structui-0.1.0.dist-info/entry_points.txt,sha256=UdaDGP9j50oP075Ak_LoXYPCGqsv5LZhuuh0ot-1qLk,47
12
+ structui-0.1.0.dist-info/top_level.txt,sha256=1EXWIhwYOrJkPjryNqLSFl1_beNghV48nZzEqQ1YEGg,9
13
+ structui-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ structui = structui.cli:main
@@ -0,0 +1 @@
1
+ structui