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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Environment information collection functions."""
3from __future__ import annotations
5import os
6import platform
7import re
8import shutil
9import subprocess
10import sys
11from pathlib import Path
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
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
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
58def collect_system_info() -> SystemInfo:
59 """Collect operating system and shell information."""
60 os_name = platform.system()
61 os_version = platform.release()
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
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
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 )
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)
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)
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 )
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
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
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 )
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
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"])
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 )
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
160 version_output = _run_command(["go", "version"])
161 gopath_output = _run_command(["go", "env", "GOPATH"])
162 goroot_output = _run_command(["go", "env", "GOROOT"])
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 )
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
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 )
183 return RubyInfo(
184 version=_extract_version(ruby_output),
185 gem_version=_extract_version(gem_output),
186 bundler_version=_extract_version(bundler_output),
187 )
190def _find_git_root(start_dir: Path | None = None) -> Path | None:
191 """Search upward for .git directory.
193 Args:
194 start_dir: Directory to start searching from. Defaults to cwd.
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
206def collect_project_info() -> ProjectInfo:
207 """Collect project detection information."""
208 cwd = Path.cwd()
209 git_root = _find_git_root(cwd)
211 languages: list[str] = []
212 package_managers: dict[str, str] = {}
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"
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"
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
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
251 if has_typescript:
252 languages.append("TypeScript")
254 # Rust
255 if (cwd / "Cargo.toml").exists():
256 languages.append("Rust")
257 package_managers["cargo"] = "Cargo.toml"
259 # Go
260 if (cwd / "go.mod").exists():
261 languages.append("Go")
262 package_managers["go"] = "go.mod"
264 # Ruby
265 if (cwd / "Gemfile").exists():
266 languages.append("Ruby")
267 package_managers["bundler"] = "Gemfile"
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 )
277def collect_lintro_info() -> LintroInfo:
278 """Collect lintro installation information."""
279 from lintro import __version__
281 # Find config file
282 config_file = None
283 config_valid = False
284 cwd = Path.cwd()
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
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
304 # Get install path
305 import lintro
307 install_path = str(Path(lintro.__file__).parent)
309 return LintroInfo(
310 version=__version__,
311 install_path=install_path,
312 config_file=config_file,
313 config_valid=config_valid,
314 )
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 }
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)
339 # Generic CI detection
340 if os.environ.get("CI"):
341 return CIEnvironment(name="Unknown CI", is_ci=True)
343 return None
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}
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 )