Coverage for lintro / utils / environment / collectors.py: 21%

179 statements  

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

1"""Environment information collection functions.""" 

2 

3from __future__ import annotations 

4 

5import os 

6import platform 

7import re 

8import shutil 

9import subprocess 

10import sys 

11from pathlib import Path 

12 

13from lintro.utils.environment.ci_environment import CIEnvironment 

14from lintro.utils.environment.environment_report import EnvironmentReport 

15from lintro.utils.environment.go_info import GoInfo 

16from lintro.utils.environment.lintro_info import LintroInfo 

17from lintro.utils.environment.node_info import NodeInfo 

18from lintro.utils.environment.project_info import ProjectInfo 

19from lintro.utils.environment.python_info import PythonInfo 

20from lintro.utils.environment.ruby_info import RubyInfo 

21from lintro.utils.environment.rust_info import RustInfo 

22from lintro.utils.environment.system_info import SystemInfo 

23 

24 

25def _run_command(command: list[str], *, timeout: int = 5) -> str | None: 

26 """Run a command and return its output, or None on failure.""" 

27 try: 

28 result = subprocess.run( 

29 command, 

30 capture_output=True, 

31 text=True, 

32 timeout=timeout, 

33 ) 

34 if result.returncode == 0: 

35 return result.stdout.strip() 

36 return None 

37 except (subprocess.TimeoutExpired, FileNotFoundError, OSError): 

38 return None 

39 

40 

41def _extract_version(output: str | None) -> str | None: 

42 """Extract version number from command output.""" 

43 if not output: 

44 return None 

45 # Handle whitespace-only strings 

46 stripped = output.strip() 

47 if not stripped: 

48 return None 

49 # Common patterns: "X.Y.Z", "vX.Y.Z", "version X.Y.Z" 

50 match = re.search(r"(\d+\.\d+(?:\.\d+)?)", stripped) 

51 if match: 

52 return match.group(1) 

53 # Fallback to first token if no version pattern found 

54 tokens = stripped.split() 

55 return tokens[0] if tokens else None 

56 

57 

58def collect_system_info() -> SystemInfo: 

59 """Collect operating system and shell information.""" 

60 os_name = platform.system() 

61 os_version = platform.release() 

62 

63 # Get friendly platform name 

64 if os_name == "Darwin": 

65 mac_ver = platform.mac_ver()[0] 

66 platform_name = f"macOS {mac_ver}" if mac_ver else "macOS" 

67 elif os_name == "Linux": 

68 # Try to get distro info (optional dependency) 

69 try: 

70 # Optional Linux-only dependency for distro detection. 

71 # ImportError is handled gracefully below. 

72 import distro 

73 

74 platform_name = f"{distro.name()} {distro.version()}" 

75 except ImportError: 

76 platform_name = "Linux" 

77 elif os_name == "Windows": 

78 platform_name = f"Windows {platform.win32_ver()[0]}" 

79 else: 

80 platform_name = os_name 

81 

82 return SystemInfo( 

83 os_name=os_name, 

84 os_version=os_version, 

85 platform_name=platform_name, 

86 architecture=platform.machine(), 

87 shell=os.environ.get("SHELL"), # nosec B604 - not a subprocess call 

88 terminal=os.environ.get("TERM"), 

89 locale=os.environ.get("LANG") or os.environ.get("LC_ALL"), 

90 ) 

91 

92 

93def collect_python_info() -> PythonInfo: 

94 """Collect Python runtime information.""" 

95 # Get pip version 

96 pip_output = _run_command([sys.executable, "-m", "pip", "--version"]) 

97 pip_version = _extract_version(pip_output) 

98 

99 # Get uv version 

100 uv_path = shutil.which("uv") 

101 uv_version = None 

102 if uv_path: 

103 uv_output = _run_command(["uv", "--version"]) 

104 uv_version = _extract_version(uv_output) 

105 

106 return PythonInfo( 

107 version=platform.python_version(), 

108 executable=sys.executable, 

109 virtual_env=os.environ.get("VIRTUAL_ENV"), 

110 pip_version=pip_version, 

111 uv_version=uv_version, 

112 ) 

113 

114 

115def collect_node_info() -> NodeInfo | None: 

116 """Collect Node.js runtime information.""" 

117 node_path = shutil.which("node") 

118 if not node_path: 

119 return None 

120 

121 node_output = _run_command(["node", "--version"]) 

122 npm_output = _run_command(["npm", "--version"]) 

123 bun_output = _run_command(["bun", "--version"]) if shutil.which("bun") else None 

124 pnpm_output = _run_command(["pnpm", "--version"]) if shutil.which("pnpm") else None 

125 

126 return NodeInfo( 

127 version=_extract_version(node_output), 

128 path=node_path, 

129 npm_version=_extract_version(npm_output), 

130 bun_version=_extract_version(bun_output), 

131 pnpm_version=_extract_version(pnpm_output), 

132 ) 

133 

134 

135def collect_rust_info() -> RustInfo | None: 

136 """Collect Rust runtime information.""" 

137 rustc_path = shutil.which("rustc") 

138 if not rustc_path: 

139 return None 

140 

141 rustc_output = _run_command(["rustc", "--version"]) 

142 cargo_output = _run_command(["cargo", "--version"]) 

143 rustfmt_output = _run_command(["rustfmt", "--version"]) 

144 clippy_output = _run_command(["cargo", "clippy", "--version"]) 

145 

146 return RustInfo( 

147 rustc_version=_extract_version(rustc_output), 

148 cargo_version=_extract_version(cargo_output), 

149 rustfmt_version=_extract_version(rustfmt_output), 

150 clippy_version=_extract_version(clippy_output), 

151 ) 

152 

153 

154def collect_go_info() -> GoInfo | None: 

155 """Collect Go runtime information.""" 

156 go_path = shutil.which("go") 

157 if not go_path: 

158 return None 

159 

160 version_output = _run_command(["go", "version"]) 

161 gopath_output = _run_command(["go", "env", "GOPATH"]) 

162 goroot_output = _run_command(["go", "env", "GOROOT"]) 

163 

164 return GoInfo( 

165 version=_extract_version(version_output), 

166 gopath=gopath_output.strip() if gopath_output else None, 

167 goroot=goroot_output.strip() if goroot_output else None, 

168 ) 

169 

170 

171def collect_ruby_info() -> RubyInfo | None: 

172 """Collect Ruby runtime information.""" 

173 ruby_path = shutil.which("ruby") 

174 if not ruby_path: 

175 return None 

176 

177 ruby_output = _run_command(["ruby", "--version"]) 

178 gem_output = _run_command(["gem", "--version"]) if shutil.which("gem") else None 

179 bundler_output = ( 

180 _run_command(["bundler", "--version"]) if shutil.which("bundler") else None 

181 ) 

182 

183 return RubyInfo( 

184 version=_extract_version(ruby_output), 

185 gem_version=_extract_version(gem_output), 

186 bundler_version=_extract_version(bundler_output), 

187 ) 

188 

189 

190def _find_git_root(start_dir: Path | None = None) -> Path | None: 

191 """Search upward for .git directory. 

192 

193 Args: 

194 start_dir: Directory to start searching from. Defaults to cwd. 

195 

196 Returns: 

197 Path to the git root directory, or None if not found. 

198 """ 

199 current = start_dir or Path.cwd() 

200 for parent in [current, *list(current.parents)]: 

201 if (parent / ".git").exists(): 

202 return parent 

203 return None 

204 

205 

206def collect_project_info() -> ProjectInfo: 

207 """Collect project detection information.""" 

208 cwd = Path.cwd() 

209 git_root = _find_git_root(cwd) 

210 

211 languages: list[str] = [] 

212 package_managers: dict[str, str] = {} 

213 

214 # Python 

215 if (cwd / "pyproject.toml").exists(): 

216 languages.append("Python") 

217 package_managers["uv/pip"] = "pyproject.toml" 

218 elif (cwd / "setup.py").exists(): 

219 languages.append("Python") 

220 package_managers["pip"] = "setup.py" 

221 

222 # JavaScript/TypeScript 

223 if (cwd / "package.json").exists(): 

224 languages.append("JavaScript") 

225 if shutil.which("bun"): 

226 package_managers["bun"] = "package.json" 

227 else: 

228 package_managers["npm"] = "package.json" 

229 

230 # Detect TypeScript more accurately using short-circuit evaluation 

231 has_typescript = False 

232 if ( 

233 (cwd / "tsconfig.json").exists() 

234 or any(cwd.glob("**/*.ts")) 

235 or any(cwd.glob("**/*.tsx")) 

236 ): 

237 has_typescript = True 

238 else: 

239 # Check package.json for typescript dependency 

240 try: 

241 import json 

242 

243 pkg_data = json.loads((cwd / "package.json").read_text()) 

244 deps = pkg_data.get("dependencies", {}) 

245 dev_deps = pkg_data.get("devDependencies", {}) 

246 if "typescript" in deps or "typescript" in dev_deps: 

247 has_typescript = True 

248 except (json.JSONDecodeError, OSError): 

249 pass 

250 

251 if has_typescript: 

252 languages.append("TypeScript") 

253 

254 # Rust 

255 if (cwd / "Cargo.toml").exists(): 

256 languages.append("Rust") 

257 package_managers["cargo"] = "Cargo.toml" 

258 

259 # Go 

260 if (cwd / "go.mod").exists(): 

261 languages.append("Go") 

262 package_managers["go"] = "go.mod" 

263 

264 # Ruby 

265 if (cwd / "Gemfile").exists(): 

266 languages.append("Ruby") 

267 package_managers["bundler"] = "Gemfile" 

268 

269 return ProjectInfo( 

270 working_dir=str(cwd), 

271 git_root=str(git_root) if git_root else None, 

272 languages=sorted(set(languages)), 

273 package_managers=package_managers, 

274 ) 

275 

276 

277def collect_lintro_info() -> LintroInfo: 

278 """Collect lintro installation information.""" 

279 from lintro import __version__ 

280 

281 # Find config file 

282 config_file = None 

283 config_valid = False 

284 cwd = Path.cwd() 

285 

286 config_names = [".lintro-config.yaml", ".lintro-config.yml", "lintro.yaml"] 

287 for name in config_names: 

288 path = cwd / name 

289 if path.exists(): 

290 config_file = str(path) 

291 # Basic validation - check if it's readable YAML 

292 try: 

293 import yaml 

294 

295 with open(path, encoding="utf-8") as f: 

296 yaml.safe_load(f) 

297 config_valid = True 

298 except (FileNotFoundError, OSError, UnicodeDecodeError): 

299 config_valid = False 

300 except yaml.YAMLError: 

301 config_valid = False 

302 break 

303 

304 # Get install path 

305 import lintro 

306 

307 install_path = str(Path(lintro.__file__).parent) 

308 

309 return LintroInfo( 

310 version=__version__, 

311 install_path=install_path, 

312 config_file=config_file, 

313 config_valid=config_valid, 

314 ) 

315 

316 

317def detect_ci_environment() -> CIEnvironment | None: 

318 """Detect CI/CD environment.""" 

319 ci_indicators = { 

320 "GITHUB_ACTIONS": ("GitHub Actions", {"run_id": "GITHUB_RUN_ID"}), 

321 "GITLAB_CI": ("GitLab CI", {"job_id": "CI_JOB_ID"}), 

322 "CIRCLECI": ("CircleCI", {"build_num": "CIRCLE_BUILD_NUM"}), 

323 "TRAVIS": ("Travis CI", {"build_id": "TRAVIS_BUILD_ID"}), 

324 "JENKINS_URL": ("Jenkins", {"build_number": "BUILD_NUMBER"}), 

325 "BUILDKITE": ("Buildkite", {"build_id": "BUILDKITE_BUILD_ID"}), 

326 "AZURE_PIPELINES": ("Azure Pipelines", {"build_id": "BUILD_BUILDID"}), 

327 "TEAMCITY_VERSION": ("TeamCity", {"build_number": "BUILD_NUMBER"}), 

328 } 

329 

330 for env_var, (name, detail_vars) in ci_indicators.items(): 

331 if os.environ.get(env_var): 

332 details = { 

333 key: os.environ.get(var, "") 

334 for key, var in detail_vars.items() 

335 if os.environ.get(var) 

336 } 

337 return CIEnvironment(name=name, is_ci=True, details=details) 

338 

339 # Generic CI detection 

340 if os.environ.get("CI"): 

341 return CIEnvironment(name="Unknown CI", is_ci=True) 

342 

343 return None 

344 

345 

346def collect_environment_vars() -> dict[str, str | None]: 

347 """Collect relevant environment variables.""" 

348 vars_to_check = [ 

349 "LINTRO_CONFIG", 

350 "NO_COLOR", 

351 "FORCE_COLOR", 

352 "CI", 

353 "GITHUB_ACTIONS", 

354 "VIRTUAL_ENV", 

355 "PYTHONPATH", 

356 "NODE_PATH", 

357 "PATH", 

358 ] 

359 return {var: os.environ.get(var) for var in vars_to_check} 

360 

361 

362def collect_full_environment() -> EnvironmentReport: 

363 """Collect complete environment report.""" 

364 return EnvironmentReport( 

365 lintro=collect_lintro_info(), 

366 system=collect_system_info(), 

367 python=collect_python_info(), 

368 node=collect_node_info(), 

369 rust=collect_rust_info(), 

370 ci=detect_ci_environment(), 

371 env_vars=collect_environment_vars(), 

372 go=collect_go_info(), 

373 ruby=collect_ruby_info(), 

374 project=collect_project_info(), 

375 )