Coverage for lintro / utils / native_parsers.py: 66%

164 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Native configuration parsers for various tools. 

2 

3Handles loading and parsing of tool-specific configuration files (JSON, YAML, etc.). 

4""" 

5 

6import json 

7from pathlib import Path 

8from typing import Any 

9 

10from loguru import logger 

11 

12from lintro.enums.tool_name import ToolName 

13from lintro.utils.jsonc import ( 

14 strip_jsonc_comments as _strip_jsonc_comments, 

15) 

16from lintro.utils.jsonc import ( 

17 strip_trailing_commas as _strip_trailing_commas, 

18) 

19 

20try: 

21 import yaml 

22except ImportError: 

23 yaml = None # type: ignore[assignment] 

24 

25# Configuration file patterns for different tools 

26YAMLLINT_CONFIG_FILES = [".yamllint", ".yamllint.yaml", ".yamllint.yml"] 

27MARKDOWNLINT_CONFIG_FILES = [ 

28 ".markdownlint.json", 

29 ".markdownlint.yaml", 

30 ".markdownlint.yml", 

31 ".markdownlint.jsonc", 

32] 

33TSC_CONFIG_FILES = ["tsconfig.json"] 

34MYPY_CONFIG_FILES = ["mypy.ini", ".mypy.ini"] 

35OXLINT_CONFIG_FILES = [".oxlintrc.json", "oxlint.json"] 

36OXFMT_CONFIG_FILES = [".oxfmtrc.json", ".oxfmtrc.jsonc"] 

37 

38 

39def _load_json_config(config_path: Path) -> dict[str, Any]: 

40 """Load and parse a JSON configuration file. 

41 

42 Args: 

43 config_path: Path to the JSON configuration file. 

44 

45 Returns: 

46 Parsed configuration as dict, or empty dict on error. 

47 """ 

48 try: 

49 with config_path.open(encoding="utf-8") as f: 

50 loaded = json.load(f) 

51 return loaded if isinstance(loaded, dict) else {} 

52 except json.JSONDecodeError as e: 

53 logger.warning( 

54 f"Failed to parse JSON config {config_path}: {e.msg} " 

55 f"(line {e.lineno}, col {e.colno})", 

56 ) 

57 return {} 

58 except FileNotFoundError: 

59 logger.debug(f"Config file not found: {config_path}") 

60 return {} 

61 except OSError as e: 

62 logger.debug(f"Could not read config file {config_path}: {e}") 

63 return {} 

64 

65 

66def _load_native_tool_config(tool_name: str) -> dict[str, Any]: 

67 """Load native configuration for a specific tool. 

68 

69 Args: 

70 tool_name: Name of the tool 

71 

72 Returns: 

73 Native configuration dictionary 

74 """ 

75 from lintro.utils.config import load_pyproject 

76 

77 # Convert string to ToolName enum for consistent comparisons 

78 try: 

79 tool_enum = ToolName(tool_name) 

80 except ValueError: 

81 # Unknown tool, return empty config 

82 return {} 

83 

84 pyproject = load_pyproject() 

85 tool_section_raw = pyproject.get("tool", {}) 

86 tool_section = tool_section_raw if isinstance(tool_section_raw, dict) else {} 

87 

88 # Tools with pyproject.toml config 

89 if tool_enum in (ToolName.RUFF, ToolName.BLACK, ToolName.BANDIT): 

90 config_value = tool_section.get(tool_name, {}) 

91 return config_value if isinstance(config_value, dict) else {} 

92 

93 # Yamllint: check native config files (not pyproject.toml) 

94 if tool_enum == ToolName.YAMLLINT: 

95 for config_file in YAMLLINT_CONFIG_FILES: 

96 config_path = Path(config_file) 

97 if config_path.exists(): 

98 if yaml is None: 

99 logger.debug( 

100 f"[UnifiedConfig] Found {config_file} but yaml not installed", 

101 ) 

102 return {} 

103 try: 

104 with config_path.open(encoding="utf-8") as f: 

105 content = yaml.safe_load(f) 

106 return content if isinstance(content, dict) else {} 

107 except yaml.YAMLError as e: 

108 logger.warning( 

109 f"Failed to parse yamllint config {config_file}: {e}", 

110 ) 

111 except OSError as e: 

112 logger.debug(f"Could not read yamllint config {config_file}: {e}") 

113 return {} 

114 

115 # Markdownlint: check config files 

116 if tool_enum == ToolName.MARKDOWNLINT: 

117 for config_file in MARKDOWNLINT_CONFIG_FILES: 

118 config_path = Path(config_file) 

119 if not config_path.exists(): 

120 continue 

121 

122 # Handle JSON/JSONC files 

123 if config_file.endswith((".json", ".jsonc")): 

124 try: 

125 with config_path.open(encoding="utf-8") as f: 

126 content = f.read() 

127 # Strip JSONC comments safely (preserves strings) 

128 content = _strip_jsonc_comments(content) 

129 loaded = json.loads(content) 

130 return loaded if isinstance(loaded, dict) else {} 

131 except json.JSONDecodeError as e: 

132 logger.warning( 

133 f"Failed to parse markdownlint config {config_file}: {e.msg} " 

134 f"(line {e.lineno}, col {e.colno})", 

135 ) 

136 except FileNotFoundError: 

137 logger.debug(f"Markdownlint config not found: {config_file}") 

138 except OSError as e: 

139 logger.debug(f"Could not read markdownlint config: {e}") 

140 

141 # Handle YAML files 

142 elif config_file.endswith((".yaml", ".yml")): 

143 if yaml is None: 

144 logger.warning( 

145 "PyYAML not available; cannot parse .markdownlint.yaml", 

146 ) 

147 continue 

148 try: 

149 with config_path.open(encoding="utf-8") as f: 

150 content = yaml.safe_load(f) 

151 # Handle multi-document YAML (coerce to dict) 

152 if isinstance(content, list) and len(content) > 0: 

153 logger.debug( 

154 "[UnifiedConfig] Markdownlint YAML config " 

155 "contains multiple documents, using first " 

156 "document", 

157 ) 

158 content = content[0] 

159 if isinstance(content, dict): 

160 return content 

161 except yaml.YAMLError as e: 

162 logger.warning( 

163 f"Failed to parse markdownlint config {config_path}: {e}", 

164 ) 

165 except (OSError, UnicodeDecodeError) as e: 

166 logger.debug( 

167 f"Could not read markdownlint config {config_path}: " 

168 f"{type(e).__name__}: {e}", 

169 ) 

170 return {} 

171 

172 # TSC (TypeScript Compiler): check tsconfig.json 

173 if tool_enum == ToolName.TSC: 

174 for config_file in TSC_CONFIG_FILES: 

175 config_path = Path(config_file) 

176 if config_path.exists(): 

177 try: 

178 content = config_path.read_text(encoding="utf-8") 

179 # tsconfig.json may have comments and trailing commas (JSONC format) 

180 content = _strip_jsonc_comments(content) 

181 content = _strip_trailing_commas(content) 

182 loaded = json.loads(content) 

183 if isinstance(loaded, dict): 

184 # Return a summary of the most relevant options 

185 result: dict[str, Any] = {} 

186 if "extends" in loaded: 

187 result["extends"] = loaded["extends"] 

188 if "compilerOptions" in loaded: 

189 result["compilerOptions"] = loaded["compilerOptions"] 

190 if "include" in loaded: 

191 result["include"] = loaded["include"] 

192 if "exclude" in loaded: 

193 result["exclude"] = loaded["exclude"] 

194 return result if result else loaded 

195 except json.JSONDecodeError as e: 

196 logger.warning( 

197 f"Failed to parse tsconfig.json: {e.msg} " 

198 f"(line {e.lineno}, col {e.colno})", 

199 ) 

200 except OSError as e: 

201 logger.debug(f"Could not read tsconfig.json: {e}") 

202 return {} 

203 

204 # Mypy: check mypy.ini or pyproject.toml [tool.mypy] 

205 if tool_enum == ToolName.MYPY: 

206 # First check pyproject.toml [tool.mypy] 

207 mypy_config = tool_section.get("mypy", {}) 

208 if isinstance(mypy_config, dict) and mypy_config: 

209 return mypy_config 

210 

211 # Then check mypy.ini / .mypy.ini 

212 for config_file in MYPY_CONFIG_FILES: 

213 config_path = Path(config_file) 

214 if config_path.exists(): 

215 try: 

216 import configparser 

217 

218 parser = configparser.ConfigParser(interpolation=None) 

219 parser.read(config_path, encoding="utf-8") 

220 # Convert to dict, focusing on [mypy] section 

221 result = {} 

222 if "mypy" in parser: 

223 result = dict(parser["mypy"]) 

224 return result 

225 except (configparser.Error, OSError, UnicodeDecodeError) as e: 

226 logger.debug(f"Could not read mypy config {config_file}: {e}") 

227 return {} 

228 

229 # Oxlint: check native config files 

230 if tool_enum == ToolName.OXLINT: 

231 for config_file in OXLINT_CONFIG_FILES: 

232 config_path = Path(config_file) 

233 if config_path.exists(): 

234 return _load_json_config(config_path) 

235 return {} 

236 

237 # Oxfmt: check native config files (supports JSONC comments) 

238 if tool_enum == ToolName.OXFMT: 

239 for config_file in OXFMT_CONFIG_FILES: 

240 config_path = Path(config_file) 

241 if not config_path.exists(): 

242 continue 

243 try: 

244 with config_path.open(encoding="utf-8") as f: 

245 content = f.read() 

246 # Strip JSONC comments and trailing commas safely 

247 if config_file.endswith(".jsonc"): 

248 content = _strip_jsonc_comments(content) 

249 content = _strip_trailing_commas(content) 

250 loaded = json.loads(content) 

251 return loaded if isinstance(loaded, dict) else {} 

252 except json.JSONDecodeError as e: 

253 logger.warning( 

254 f"Failed to parse oxfmt config {config_file}: {e.msg} " 

255 f"(line {e.lineno}, col {e.colno})", 

256 ) 

257 except FileNotFoundError: 

258 logger.debug(f"Oxfmt config not found: {config_file}") 

259 except OSError as e: 

260 logger.debug(f"Could not read oxfmt config {config_file}: {e}") 

261 return {} 

262 

263 return {}