1#!/usr/bin/env python 2# -*- coding: utf-8 -*- 3 4# 5# Copyright (c) 2025 Northeastern University 6# Licensed under the Apache License, Version 2.0 (the "License"); 7# you may not use this file except in compliance with the License. 8# You may obtain a copy of the License at 9# 10# http://www.apache.org/licenses/LICENSE-2.0 11# 12# Unless required by applicable law or agreed to in writing, software 13# distributed under the License is distributed on an "AS IS" BASIS, 14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15# See the License for the specific language governing permissions and 16# limitations under the License. 17# 18 19import json 20import os 21from datetime import datetime, timezone 22from typing import Any, Dict, List, NamedTuple 23from uuid import uuid4 24 25 26class FieldConfig(NamedTuple): 27 """ 28 A configuration tuple representing a field's metadata. 29 30 Attributes: 31 property: Internal property name used in code 32 json_key: Corresponding JSON key name for serialization 33 required: Whether this field is mandatory 34 default: Default value for the field (supports special tokens) 35 type: Expected data type of the field 36 description: Description of the field 37 example: Example value for documentation purposes 38 """ 39 property: str 40 json_key: str 41 required: bool 42 default: Any 43 type: str 44 description: str 45 example: Any 46 47 48class FieldConfigManager: 49 """ 50 A manager class for handling field configurations with singleton pattern support. 51 52 Features: 53 - Loads configuration from JSON files 54 - Provides bidirectional mapping between property names and JSON keys 55 - Handles special default value tokens (timestamps, UUIDs) 56 - Implements singleton pattern for configuration instances 57 """ 58 59 _instances: Dict[str, 'FieldConfigManager'] = {} 60 61 def __init__(self, config_path: str): 62 """ 63 Initialize a configuration manager from a JSON file. 64 65 Args: 66 config_path: Path to the JSON configuration file 67 68 Raises: 69 FileNotFoundError: If the configuration file doesn't exist 70 """ 71 72 if not os.path.exists(config_path): 73 raise FileNotFoundError(f"Configuration file not found: {config_path}") 74 with open(config_path, 'r', encoding='utf-8') as f: 75 raw_data = json.load(f) 76 77 self._by_property: Dict[str, FieldConfig] = {} 78 self._by_json_key: Dict[str, FieldConfig] = {} 79 80 for item in raw_data.get("fields", []): 81 field = FieldConfig( 82 property=item["property"], 83 json_key=item["json_key"], 84 required=item.get("required", False), 85 default=item.get("default"), 86 type=item.get("type", "string"), 87 description=item.get("description", ""), 88 example=item.get("example") 89 ) 90 self._by_property[field.property] = field 91 self._by_json_key[field.json_key] = field 92 93 @classmethod 94 def get_instance(cls, config_name: str = "default") -> 'FieldConfigManager': 95 """ 96 Get a singleton instance of the configuration manager. 97 98 Args: 99 config_name: Name of the configuration (corresponds to filename without extension) 100 101 Returns: 102 FieldConfigManager: The singleton instance 103 104 Note: 105 Configuration files are expected to be in a 'configs' subdirectory 106 with naming pattern '{config_name}.config.json' 107 """ 108 if config_name not in cls._instances: 109 config_dir = os.path.join(os.path.dirname(__file__), "configs") 110 config_path = os.path.join(config_dir, f"{config_name}.config.json") 111 cls._instances[config_name] = cls(config_path) 112 return cls._instances[config_name] 113 114 def is_required(self, property_name: str) -> bool: 115 """ 116 Check if a field is required. 117 118 Args: 119 property_name: Internal property name to check 120 121 Returns: 122 bool: True if the field is required, False otherwise 123 """ 124 field = self._by_property.get(property_name) 125 return field.required if field else False 126 127 def get_default(self, property_name: str) -> Any: 128 """ 129 Get the default value for a field with special token handling. 130 131 Supported special tokens in default values: 132 - "{now_utc_iso}": Replaced with current UTC time in ISO 8601 format (Z suffix) 133 - "{uuid}": Replaced with a new UUID4 string 134 - Lists/Dictionaries: Returns a deep copy to prevent reference sharing 135 136 Args: 137 property_name: Internal property name to lookup 138 139 Returns: 140 Any: The processed default value or None if not found/specified 141 """ 142 field = self._by_property.get(property_name) 143 if not field: 144 return None 145 146 default = field.default 147 if default is None: 148 return None 149 150 if isinstance(default, str): 151 if default == "{now_utc_iso}": 152 return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") 153 elif "{uuid}" in default: 154 return default.format(uuid=str(uuid4())) 155 elif isinstance(default, list): 156 return [item.copy() if isinstance(item, (dict, list)) else item for item in default] 157 elif isinstance(default, dict): 158 return default.copy() 159 160 return default 161 162 def all_properties(self) -> List[str]: 163 """ 164 Get all available property names. 165 166 Returns: 167 List[str]: List of all internal property names 168 """ 169 return list(self._by_property.keys()) 170