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 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}"
@@ -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()