paramify 0.1.4__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.
- paramify/__init__.py +0 -0
- paramify/paramify.py +258 -0
- paramify/paramify_web.py +106 -0
- paramify/static/assets/index-C3Xrcbp_.js +6794 -0
- paramify/static/assets/index-DwXyNpx-.css +1 -0
- paramify/static/index.html +17 -0
- paramify-0.1.4.dist-info/LICENSE +21 -0
- paramify-0.1.4.dist-info/METADATA +281 -0
- paramify-0.1.4.dist-info/RECORD +20 -0
- paramify-0.1.4.dist-info/WHEEL +5 -0
- paramify-0.1.4.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_paramify_callback.py +60 -0
- tests/test_paramify_cli.py +47 -0
- tests/test_paramify_dynamic.py +64 -0
- tests/test_paramify_init.py +88 -0
- tests/test_paramify_misc.py +61 -0
- tests/test_paramify_performance.py +95 -0
- tests/test_paramify_persist.py +80 -0
- tests/test_paramify_validation.py +61 -0
paramify/__init__.py
ADDED
|
File without changes
|
paramify/paramify.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from ruamel.yaml import YAML
|
|
3
|
+
import argparse
|
|
4
|
+
from pydantic import BaseModel, create_model, ValidationError
|
|
5
|
+
from typing import Any, Dict, Type, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Paramify:
|
|
9
|
+
def __init__(self, config: Union[Dict[str, Any], str], enable_cli: bool = True):
|
|
10
|
+
"""
|
|
11
|
+
A class for dynamic parameter management, validation, and runtime configuration.
|
|
12
|
+
|
|
13
|
+
The Paramify class allows developers to define and manage parameters dynamically
|
|
14
|
+
using a configuration provided as a dictionary, JSON file, or YAML file. It also
|
|
15
|
+
provides optional command-line argument parsing and supports runtime updates with
|
|
16
|
+
automatic persistence for file-based configurations.
|
|
17
|
+
|
|
18
|
+
Features:
|
|
19
|
+
- Dynamic parameter validation using Pydantic.
|
|
20
|
+
- Support for JSON and YAML configuration formats.
|
|
21
|
+
- Optional CLI integration for overriding parameters at runtime.
|
|
22
|
+
- Automatic persistence of parameter changes when initialized with a JSON or YAML file.
|
|
23
|
+
- Custom callback methods triggered on parameter updates.
|
|
24
|
+
|
|
25
|
+
Parameters:
|
|
26
|
+
----------
|
|
27
|
+
config : Union[Dict[str, Any], str]
|
|
28
|
+
A dictionary or the path to a JSON/YAML file containing the parameter configuration.
|
|
29
|
+
The configuration should have a "parameters" key, which is a list of parameter definitions.
|
|
30
|
+
|
|
31
|
+
Example Configuration:
|
|
32
|
+
```yaml
|
|
33
|
+
parameters:
|
|
34
|
+
- name: "param1"
|
|
35
|
+
type: "bool"
|
|
36
|
+
default: true
|
|
37
|
+
label: "Enable Feature"
|
|
38
|
+
description: "A boolean parameter to enable or disable a feature."
|
|
39
|
+
- name: "param2"
|
|
40
|
+
type: "int"
|
|
41
|
+
default: 42
|
|
42
|
+
label: "Max Value"
|
|
43
|
+
description: "An integer parameter for setting the maximum value."
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
enable_cli : bool, optional
|
|
47
|
+
If True, enables command-line argument parsing to override parameters. Default is True.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
------
|
|
51
|
+
ValueError:
|
|
52
|
+
If the configuration format is invalid or unsupported.
|
|
53
|
+
ValidationError:
|
|
54
|
+
If parameter validation fails during initialization.
|
|
55
|
+
|
|
56
|
+
Notes:
|
|
57
|
+
-----
|
|
58
|
+
- When initialized with a dictionary, changes to parameters are not persisted.
|
|
59
|
+
- For JSON or YAML configurations, changes are automatically saved to the file.
|
|
60
|
+
- CLI changes are transient and do not persist to the file.
|
|
61
|
+
"""
|
|
62
|
+
# Track the file path for persistence
|
|
63
|
+
self._file_path = config if isinstance(config, str) else None
|
|
64
|
+
self._yaml_loader = YAML()
|
|
65
|
+
self._yaml_loader.preserve_quotes = True # Retain quotes from the original file
|
|
66
|
+
|
|
67
|
+
# Load configuration from file or dictionary
|
|
68
|
+
if isinstance(config, str):
|
|
69
|
+
if config.endswith('.json'):
|
|
70
|
+
with open(config, 'r') as f:
|
|
71
|
+
config = json.load(f)
|
|
72
|
+
elif config.endswith(('.yaml', '.yml')):
|
|
73
|
+
with open(config, 'r') as f:
|
|
74
|
+
config = self._yaml_loader.load(f)
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError("Unsupported file format. Use a JSON or YAML file.")
|
|
77
|
+
elif not isinstance(config, dict):
|
|
78
|
+
raise ValueError("Config must be a dictionary or a valid JSON/YAML file path.")
|
|
79
|
+
|
|
80
|
+
self._config = config
|
|
81
|
+
|
|
82
|
+
if not isinstance(config, dict) or 'parameters' not in config:
|
|
83
|
+
raise ValueError("Invalid configuration format. Expected a 'parameters' key.")
|
|
84
|
+
|
|
85
|
+
self._config_params: list = config['parameters']
|
|
86
|
+
|
|
87
|
+
# Dynamically create a Pydantic model
|
|
88
|
+
self.ParameterModel = self._create_model(self._config_params)
|
|
89
|
+
try:
|
|
90
|
+
self.parameters = self.ParameterModel(**{p['name']: p.get('default', None) for p in self._config_params})
|
|
91
|
+
except ValidationError as e:
|
|
92
|
+
print("Validation Error in Configuration:", e)
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
# Parse CLI arguments if enabled
|
|
96
|
+
if enable_cli:
|
|
97
|
+
self._parse_cli_args()
|
|
98
|
+
|
|
99
|
+
# Dynamically create setters for each parameter
|
|
100
|
+
for param in self._config_params:
|
|
101
|
+
self._add_parameter(param['name'])
|
|
102
|
+
|
|
103
|
+
def _create_model(self, config_data: list) -> Type[BaseModel]:
|
|
104
|
+
"""
|
|
105
|
+
Dynamically create a Pydantic BaseModel based on the configuration data.
|
|
106
|
+
Fields without a default value or explicitly set to `None` are marked as optional.
|
|
107
|
+
"""
|
|
108
|
+
from typing import Optional
|
|
109
|
+
|
|
110
|
+
fields = {}
|
|
111
|
+
for param in config_data:
|
|
112
|
+
field_type = eval(param['type']) # Determine the type
|
|
113
|
+
default = param.get('default', None) # Get the default value, or None if not provided
|
|
114
|
+
|
|
115
|
+
if default is None:
|
|
116
|
+
# If no default value is provided, make the field optional
|
|
117
|
+
fields[param['name']] = (Optional[field_type], default)
|
|
118
|
+
else:
|
|
119
|
+
# Otherwise, set the field type and default value
|
|
120
|
+
fields[param['name']] = (field_type, default)
|
|
121
|
+
|
|
122
|
+
return create_model('ParameterModel', **fields)
|
|
123
|
+
|
|
124
|
+
def _add_parameter(self, name: str):
|
|
125
|
+
"""
|
|
126
|
+
Dynamically create a setter method with validation and a callback for each parameter.
|
|
127
|
+
"""
|
|
128
|
+
def setter(self, value: Any):
|
|
129
|
+
# Validate the updated value by creating a new validated model
|
|
130
|
+
try:
|
|
131
|
+
updated_params = self.parameters.dict() # Get current parameters as a dictionary
|
|
132
|
+
updated_params[name] = value # Update the parameter
|
|
133
|
+
self.parameters = self.ParameterModel(**updated_params) # Revalidate
|
|
134
|
+
except ValidationError as e:
|
|
135
|
+
raise TypeError(f"Invalid value for {name}: {e}")
|
|
136
|
+
|
|
137
|
+
# Invoke the callback for the parameter if defined
|
|
138
|
+
callback_name = f"on_{name}_set"
|
|
139
|
+
if hasattr(self, callback_name) and callable(getattr(self, callback_name)):
|
|
140
|
+
getattr(self, callback_name)(value)
|
|
141
|
+
|
|
142
|
+
# Save changes if a file path is provided
|
|
143
|
+
if self._file_path:
|
|
144
|
+
self._save_config()
|
|
145
|
+
|
|
146
|
+
# Attach the setter method to the class
|
|
147
|
+
setattr(self, f"set_{name}", setter.__get__(self))
|
|
148
|
+
|
|
149
|
+
def _parse_cli_args(self):
|
|
150
|
+
"""
|
|
151
|
+
Parse CLI arguments and update the parameters accordingly.
|
|
152
|
+
"""
|
|
153
|
+
self.parser = argparse.ArgumentParser(description=self._config.get("description", ""))
|
|
154
|
+
|
|
155
|
+
for param in self._config_params:
|
|
156
|
+
scope = param.get("scope", "all")
|
|
157
|
+
if scope not in ["all", "cli"]:
|
|
158
|
+
continue # Only include parameters with scope "all" or "cli" in the CLI
|
|
159
|
+
|
|
160
|
+
arg_name = f"--{param['name'].replace('_', '-')}"
|
|
161
|
+
param_type = param["type"]
|
|
162
|
+
|
|
163
|
+
if param_type == "bool":
|
|
164
|
+
# Use `store_true` or `store_false` for boolean arguments
|
|
165
|
+
self.parser.add_argument(
|
|
166
|
+
arg_name,
|
|
167
|
+
help=param.get("description", ""),
|
|
168
|
+
default=param.get("default", False),
|
|
169
|
+
action="store_true" # if not param.get("default", False) else "store_false"
|
|
170
|
+
)
|
|
171
|
+
elif param_type == "list":
|
|
172
|
+
# Handle list arguments with nargs="+"
|
|
173
|
+
self.parser.add_argument(
|
|
174
|
+
arg_name,
|
|
175
|
+
help=param.get("description", ""),
|
|
176
|
+
nargs="+",
|
|
177
|
+
default=param.get("default", []),
|
|
178
|
+
type=str # Assume lists are of type str; adjust as needed
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
# Add other parameter types
|
|
182
|
+
self.parser.add_argument(
|
|
183
|
+
arg_name,
|
|
184
|
+
help=param.get("description", ""),
|
|
185
|
+
default=param.get("default"),
|
|
186
|
+
type=eval(param_type) if param_type in ["int", "float", "str"] else str
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Parse arguments and update parameters
|
|
190
|
+
args = self.parser.parse_args()
|
|
191
|
+
cli_args = vars(args)
|
|
192
|
+
|
|
193
|
+
for name, value in cli_args.items():
|
|
194
|
+
if name in self.parameters.dict():
|
|
195
|
+
# Directly update the parameter model to avoid triggering persistence
|
|
196
|
+
self.parameters.__dict__[name] = value
|
|
197
|
+
|
|
198
|
+
def _save_config(self):
|
|
199
|
+
"""
|
|
200
|
+
Save the current configuration back to the file with preserved formatting using ruamel.yaml.
|
|
201
|
+
"""
|
|
202
|
+
if not self._file_path:
|
|
203
|
+
return # No file to save to
|
|
204
|
+
|
|
205
|
+
if self._file_path.endswith(('.yaml', '.yml')):
|
|
206
|
+
# Load the original YAML content to retain formatting
|
|
207
|
+
with open(self._file_path, 'r') as f:
|
|
208
|
+
original_data = self._yaml_loader.load(f)
|
|
209
|
+
|
|
210
|
+
# Synchronize self.parameters with the original data
|
|
211
|
+
for param in self._config_params:
|
|
212
|
+
name = param['name']
|
|
213
|
+
if name in self.parameters.dict():
|
|
214
|
+
for p in original_data.get('parameters', []):
|
|
215
|
+
if p['name'] == name:
|
|
216
|
+
p['default'] = self.parameters.dict()[name]
|
|
217
|
+
|
|
218
|
+
# Save the updated configuration back to the file
|
|
219
|
+
with open(self._file_path, 'w') as f:
|
|
220
|
+
self._yaml_loader.dump(original_data, f)
|
|
221
|
+
|
|
222
|
+
elif self._file_path.endswith('.json'):
|
|
223
|
+
# Synchronize self.parameters with the configuration
|
|
224
|
+
for param in self._config['parameters']:
|
|
225
|
+
name = param['name']
|
|
226
|
+
if name in self.parameters.dict():
|
|
227
|
+
param['default'] = self.parameters.dict()[name]
|
|
228
|
+
|
|
229
|
+
# Overwrite the JSON file with updated configuration
|
|
230
|
+
with open(self._file_path, 'w') as f:
|
|
231
|
+
json.dump(self._config, f, indent=4)
|
|
232
|
+
|
|
233
|
+
def get_parameters(self) -> Dict[str, Any]:
|
|
234
|
+
"""
|
|
235
|
+
Return the current parameters and their values.
|
|
236
|
+
"""
|
|
237
|
+
return self.parameters.dict()
|
|
238
|
+
|
|
239
|
+
def __str__(self):
|
|
240
|
+
"""
|
|
241
|
+
Return a formatted string representation of the parameters and their values.
|
|
242
|
+
Includes parameter labels if available.
|
|
243
|
+
"""
|
|
244
|
+
params = self.get_parameters()
|
|
245
|
+
|
|
246
|
+
max_length = max(
|
|
247
|
+
len(f"{p['name']} ({p.get('label', '')})") for p in self._config_params
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
formatted_params = "\n".join(
|
|
251
|
+
f" {name} ({param.get('label', '')}){(max_length - len(name) - len(param.get('label', '')) - 2) * ' '}: {value}"
|
|
252
|
+
for name, value, param in (
|
|
253
|
+
(name, value, next((p for p in self._config_params if p['name'] == name), {}))
|
|
254
|
+
for name, value in params.items()
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
app_name = self._config.get('name', self.__class__.__name__)
|
|
258
|
+
return f"{app_name} initialized with:\n{formatted_params}"
|
paramify/paramify_web.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
from typing import Any, Dict, Union
|
|
4
|
+
from flask import Flask, jsonify, request, send_from_directory
|
|
5
|
+
# from flask_cors import CORS
|
|
6
|
+
|
|
7
|
+
from paramify.paramify import Paramify
|
|
8
|
+
|
|
9
|
+
class ParamifyWeb(Paramify):
|
|
10
|
+
def __init__(self,
|
|
11
|
+
config: Union[Dict[str, Any], str],
|
|
12
|
+
enable_cli: bool = True,
|
|
13
|
+
host: str = '0.0.0.0',
|
|
14
|
+
port: int = 5000):
|
|
15
|
+
"""
|
|
16
|
+
Initialize the ParamifyWeb class, set up the Flask app, and start the server in a separate thread.
|
|
17
|
+
"""
|
|
18
|
+
super().__init__(config, enable_cli) # Initialize the parent class
|
|
19
|
+
self.host = host
|
|
20
|
+
self.port = port
|
|
21
|
+
|
|
22
|
+
# Initialize Flask app with static and template folders
|
|
23
|
+
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
24
|
+
static_folder = os.path.join(base_dir, 'static/assets')
|
|
25
|
+
template_folder = os.path.join(base_dir, 'static')
|
|
26
|
+
|
|
27
|
+
# Initialize Flask app with static and template folders
|
|
28
|
+
self.app = Flask(__name__, static_folder=static_folder, template_folder=template_folder)
|
|
29
|
+
# CORS(self.app) # Enable CORS for development convenience
|
|
30
|
+
|
|
31
|
+
# Set up Flask routes
|
|
32
|
+
self._setup_routes()
|
|
33
|
+
|
|
34
|
+
# Start the Flask app in a separate thread
|
|
35
|
+
self._start_server()
|
|
36
|
+
|
|
37
|
+
def _setup_routes(self):
|
|
38
|
+
"""
|
|
39
|
+
Define Flask routes for serving the Vue app and handling parameter updates.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Serve the Vue app's index.html
|
|
43
|
+
@self.app.route('/')
|
|
44
|
+
@self.app.route('/<path:path>')
|
|
45
|
+
def serve_frontend(path=None):
|
|
46
|
+
"""
|
|
47
|
+
Serve static files or the Vue index.html.
|
|
48
|
+
"""
|
|
49
|
+
if path and os.path.exists(os.path.join(self.app.template_folder, path)):
|
|
50
|
+
return send_from_directory(self.app.template_folder, path)
|
|
51
|
+
return send_from_directory(self.app.template_folder, 'index.html')
|
|
52
|
+
|
|
53
|
+
# API route: Get configuration details
|
|
54
|
+
@self.app.route('/api/config', methods=['GET'])
|
|
55
|
+
def get_config():
|
|
56
|
+
"""
|
|
57
|
+
Return the configuration details (metadata and current parameter values).
|
|
58
|
+
Exclude parameters with scope "cli".
|
|
59
|
+
"""
|
|
60
|
+
# Start with the original configuration metadata
|
|
61
|
+
config_with_values = self._config.copy()
|
|
62
|
+
|
|
63
|
+
# Filter out parameters with scope "cli"
|
|
64
|
+
config_with_values['parameters'] = [
|
|
65
|
+
{
|
|
66
|
+
**param,
|
|
67
|
+
"default": self.get_parameters().get(param["name"], param.get("default"))
|
|
68
|
+
}
|
|
69
|
+
for param in config_with_values['parameters']
|
|
70
|
+
if param.get("scope", "all") != "cli"
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
return jsonify(config_with_values)
|
|
74
|
+
|
|
75
|
+
# API route: Update a parameter value
|
|
76
|
+
@self.app.route('/api/update', methods=['POST'])
|
|
77
|
+
def update_parameter():
|
|
78
|
+
"""
|
|
79
|
+
Update parameters dynamically based on the received data.
|
|
80
|
+
"""
|
|
81
|
+
data = request.json # Example: {'param1': False, 'param2': 42}
|
|
82
|
+
if not data or not isinstance(data, dict):
|
|
83
|
+
return jsonify({"status": "error", "message": "Invalid request format. Expected a dictionary."}), 400
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
# Iterate through the received parameters and update them
|
|
87
|
+
for name, value in data.items():
|
|
88
|
+
setter = getattr(self, f"set_{name}", None) # Dynamically find the setter
|
|
89
|
+
if not setter:
|
|
90
|
+
return jsonify({"status": "error", "message": f"Parameter '{name}' does not exist"}), 404
|
|
91
|
+
setter(value) # Call the setter to update the parameter
|
|
92
|
+
|
|
93
|
+
return jsonify({"status": "success", "message": "Parameters updated successfully"})
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return jsonify({"status": "error", "message": str(e)}), 400
|
|
96
|
+
|
|
97
|
+
def _start_server(self):
|
|
98
|
+
"""
|
|
99
|
+
Start the Flask app in a separate thread to avoid blocking the main thread.
|
|
100
|
+
"""
|
|
101
|
+
server_thread = threading.Thread(
|
|
102
|
+
target=self.app.run,
|
|
103
|
+
kwargs={"host": self.host, "port": self.port, "debug": False, "use_reloader": False},
|
|
104
|
+
daemon=True # Mark the thread as a daemon thread
|
|
105
|
+
)
|
|
106
|
+
server_thread.start()
|