55
66import os
77from dataclasses import dataclass , field
8- from typing import Any , Dict , List
8+ from typing import Any , Dict , List , Optional , Tuple
99
1010# Try to import tomli for TOML parsing
1111try :
@@ -48,48 +48,55 @@ class HarmonizerConfig:
4848
4949 # Analysis
5050 complexity_weight : float = 0.2 # For dynamic simulation
51+ custom_vocabulary : Dict [str , str ] = field (default_factory = dict )
52+ source_path : Optional [str ] = None
53+ root_dir : Optional [str ] = None
5154
5255
5356class ConfigLoader :
57+ _YAML_FILENAMES = (
58+ ".harmonizer.yml" ,
59+ ".harmonizer.yaml" ,
60+ "harmonizer.yml" ,
61+ "harmonizer.yaml" ,
62+ )
63+
5464 @staticmethod
55- def load (target_dir : str = "." ) -> HarmonizerConfig :
65+ def load (target_dir : str = "." , search_parents : bool = False ) -> HarmonizerConfig :
5666 """
5767 Load configuration from target directory.
5868 Priority:
59- 1. harmonizer.yaml
69+ 1. harmonizer.yaml / .harmonizer.yaml / .harmonizer.yml
6070 2. pyproject.toml
6171 3. Defaults
6272 """
6373 config = HarmonizerConfig ()
74+ config .root_dir = os .path .abspath (target_dir )
6475
65- # 1. Try harmonizer.yaml
66- yaml_path = os .path .join (target_dir , "harmonizer.yaml" )
67- if os .path .exists (yaml_path ) and yaml :
68- try :
69- with open (yaml_path , "r" , encoding = "utf-8" ) as f :
70- data = yaml .safe_load (f )
71- if data :
72- ConfigLoader ._update_config (config , data )
73- print (f"Loaded config from { yaml_path } " )
74- return config
75- except Exception as e :
76- print (f"Warning: Failed to load { yaml_path } : { e } " )
77-
78- # 2. Try pyproject.toml
79- toml_path = os .path .join (target_dir , "pyproject.toml" )
80- if os .path .exists (toml_path ) and tomli :
81- try :
82- with open (toml_path , "rb" ) as f :
83- data = tomli .load (f )
84- tool_config = data .get ("tool" , {}).get ("harmonizer" , {})
85- if tool_config :
86- ConfigLoader ._update_config (config , tool_config )
87- print (f"Loaded config from { toml_path } " )
88- except Exception as e :
89- print (f"Warning: Failed to load { toml_path } : { e } " )
76+ config_path , config_type = ConfigLoader ._locate_config_path (target_dir , search_parents )
77+ if not config_path :
78+ return config
9079
80+ try :
81+ if config_type == "toml" :
82+ ConfigLoader ._load_from_pyproject (config , config_path )
83+ else :
84+ ConfigLoader ._load_from_yaml (config , config_path )
85+ except Exception as exc : # pragma: no cover - defensive logging
86+ print (f"Warning: Failed to load { config_path } : { exc } " )
87+ return config
88+
89+ config .source_path = config_path
90+ config .root_dir = os .path .dirname (config_path )
9191 return config
9292
93+ @staticmethod
94+ def load_nearest (start_dir : str = "." ) -> HarmonizerConfig :
95+ """
96+ Load configuration searching parent directories for the first config file.
97+ """
98+ return ConfigLoader .load (start_dir , search_parents = True )
99+
93100 @staticmethod
94101 def _update_config (config : HarmonizerConfig , data : Dict [str , Any ]):
95102 """Update config object with dictionary data"""
@@ -102,14 +109,69 @@ def _update_config(config: HarmonizerConfig, data: Dict[str, Any]):
102109 if "min_density" in t :
103110 config .min_density = float (t ["min_density" ])
104111
112+ if "analysis" in data :
113+ a = data ["analysis" ]
114+ if "complexity_weight" in a :
115+ config .complexity_weight = float (a ["complexity_weight" ])
116+
105117 if "paths" in data :
106118 p = data ["paths" ]
107119 if "exclude" in p :
108- config .exclude_patterns = p ["exclude" ]
120+ config .exclude_patterns = list ( p ["exclude" ])
109121 if "report" in p :
110122 config .report_output = p ["report" ]
111123
112- if "analysis" in data :
113- a = data ["analysis" ]
114- if "complexity_weight" in a :
115- config .complexity_weight = float (a ["complexity_weight" ])
124+ if "exclude" in data :
125+ config .exclude_patterns = list (data ["exclude" ])
126+
127+ if "custom_vocabulary" in data :
128+ custom_vocab = data .get ("custom_vocabulary" ) or {}
129+ # Merge so later sources can override defaults
130+ config .custom_vocabulary .update (custom_vocab )
131+
132+ @staticmethod
133+ def _locate_config_path (
134+ start_dir : str , search_parents : bool
135+ ) -> Tuple [Optional [str ], Optional [str ]]:
136+ current_dir = os .path .abspath (start_dir )
137+ while True :
138+ for filename in ConfigLoader ._YAML_FILENAMES :
139+ candidate = os .path .join (current_dir , filename )
140+ if os .path .exists (candidate ) and yaml :
141+ return candidate , "yaml"
142+
143+ toml_path = os .path .join (current_dir , "pyproject.toml" )
144+ if os .path .exists (toml_path ) and tomli :
145+ return toml_path , "toml"
146+
147+ if not search_parents :
148+ break
149+
150+ parent_dir = os .path .dirname (current_dir )
151+ if parent_dir == current_dir :
152+ break
153+ current_dir = parent_dir
154+
155+ return None , None
156+
157+ @staticmethod
158+ def _load_from_yaml (config : HarmonizerConfig , path : str ) -> None :
159+ if not yaml :
160+ raise RuntimeError ("PyYAML is not installed" )
161+
162+ with open (path , "r" , encoding = "utf-8" ) as handle :
163+ data = yaml .safe_load (handle ) or {}
164+ if data :
165+ ConfigLoader ._update_config (config , data )
166+
167+ @staticmethod
168+ def _load_from_pyproject (config : HarmonizerConfig , path : str ) -> None :
169+ if not tomli :
170+ raise RuntimeError ("tomli/tomllib is not available for TOML parsing" )
171+
172+ with open (path , "rb" ) as handle :
173+ data = tomli .load (handle )
174+
175+ tool_config = data .get ("tool" , {}).get ("harmonizer" , {})
176+ if tool_config :
177+ ConfigLoader ._update_config (config , tool_config )
0 commit comments