From de6f02781b5c872a99c4a15b95b4368bde64c5f4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:37:01 +0200 Subject: [PATCH] Implemented the next missing parity slice around platform detection and system directories. --- PARITY_CHECKLIST.md | 2 +- src/platform_info.py | 232 ++++++++++++++++++++++++++++++++++++ tests/test_platform_info.py | 210 ++++++++++++++++++++++++++++++++ 3 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 src/platform_info.py create mode 100644 tests/test_platform_info.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 9ea0763..bf832a0 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -721,7 +721,7 @@ Missing major utility categories: - [ ] Plugin/Skill utilities (`utils/plugins/`, `utils/skills/`) - [ ] Memory/Context (`utils/memory/`, `utils/claudemd.ts`, `utils/contextAnalysis.ts`) - [ ] IDE integration (`utils/ide.ts`, `utils/jetbrains.ts`) -- [ ] Platform/OS (`utils/platform.ts`, `utils/terminal.ts`, `utils/systemDirectories.ts`) +- [ ] Platform/OS (`utils/platform.ts`, `utils/terminal.ts`, `utils/systemDirectories.ts`) — partial: platform detection (`getPlatform`, `getWslVersion`, `getLinuxDistroInfo`, `detectVcs`) and `getSystemDirectories` ported in `src/platform_info.py` - [ ] Debugging (`utils/debug.ts`, `utils/diagLogs.ts`, `utils/log.ts`, `utils/profilerBase.ts`) - [ ] Telemetry (`utils/telemetry/`) - [ ] Deep link utilities (`utils/deepLink/`) diff --git a/src/platform_info.py b/src/platform_info.py new file mode 100644 index 0000000..1178497 --- /dev/null +++ b/src/platform_info.py @@ -0,0 +1,232 @@ +"""Platform detection and system directories — Python ports of +``utils/platform.ts`` and ``utils/systemDirectories.ts``. + +The npm functions are memoized via lodash; here a module-level cache plus +``_reset_cache`` (test-only) provides equivalent behavior. Detection is +cheap enough that callers can also bypass the cache by passing explicit +overrides to ``get_system_directories``. +""" + +from __future__ import annotations + +import os +import platform as _stdlib_platform +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +Platform = Literal['macos', 'windows', 'wsl', 'linux', 'unknown'] + +SUPPORTED_PLATFORMS: tuple[Platform, ...] = ('macos', 'wsl') + + +_UNSET = object() +_platform_cache: Platform | None = None +_wsl_version_cache: object = _UNSET # sentinel until first computation + + +def _reset_cache() -> None: + """Clear cached platform detection — only used by tests.""" + global _platform_cache, _wsl_version_cache + _platform_cache = None + _wsl_version_cache = _UNSET + + +def _read_proc_version() -> str: + return Path('/proc/version').read_text(encoding='utf-8') + + +def get_platform() -> Platform: + """Return the current platform identifier (memoized).""" + global _platform_cache + if _platform_cache is not None: + return _platform_cache + + if sys.platform == 'darwin': + _platform_cache = 'macos' + elif sys.platform.startswith('win'): + _platform_cache = 'windows' + elif sys.platform.startswith('linux'): + try: + proc_version = _read_proc_version().lower() + if 'microsoft' in proc_version or 'wsl' in proc_version: + _platform_cache = 'wsl' + else: + _platform_cache = 'linux' + except OSError: + _platform_cache = 'linux' + else: + _platform_cache = 'unknown' + return _platform_cache + + +def get_wsl_version() -> str | None: + """Return the WSL major version (`'1'`/`'2'`/...), or None if not WSL.""" + global _wsl_version_cache + if _wsl_version_cache is not _UNSET: + return _wsl_version_cache # type: ignore[return-value] + + result: str | None = None + if sys.platform.startswith('linux'): + try: + proc_version = _read_proc_version() + except OSError: + proc_version = '' + if proc_version: + import re + match = re.search(r'WSL(\d+)', proc_version, re.IGNORECASE) + if match: + result = match.group(1) + elif 'microsoft' in proc_version.lower(): + result = '1' + _wsl_version_cache = result + return result + + +@dataclass(frozen=True) +class LinuxDistroInfo: + linux_distro_id: str | None = None + linux_distro_version: str | None = None + linux_kernel: str | None = None + + def to_dict(self) -> dict[str, str]: + out: dict[str, str] = {} + if self.linux_distro_id is not None: + out['linuxDistroId'] = self.linux_distro_id + if self.linux_distro_version is not None: + out['linuxDistroVersion'] = self.linux_distro_version + if self.linux_kernel is not None: + out['linuxKernel'] = self.linux_kernel + return out + + +def get_linux_distro_info() -> LinuxDistroInfo | None: + """Return distro id/version/kernel on Linux, or None on other platforms.""" + if not sys.platform.startswith('linux'): + return None + + distro_id: str | None = None + distro_version: str | None = None + try: + content = Path('/etc/os-release').read_text(encoding='utf-8') + except OSError: + content = '' + for line in content.splitlines(): + if '=' not in line: + continue + key, _, value = line.partition('=') + value = value.strip().strip('"') + if key == 'ID': + distro_id = value + elif key == 'VERSION_ID': + distro_version = value + + return LinuxDistroInfo( + linux_distro_id=distro_id, + linux_distro_version=distro_version, + linux_kernel=_stdlib_platform.release() or None, + ) + + +_VCS_MARKERS: tuple[tuple[str, str], ...] = ( + ('.git', 'git'), + ('.hg', 'mercurial'), + ('.svn', 'svn'), + ('.p4config', 'perforce'), + ('$tf', 'tfs'), + ('.tfvc', 'tfs'), + ('.jj', 'jujutsu'), + ('.sl', 'sapling'), +) + + +def detect_vcs(directory: str | os.PathLike[str] | None = None) -> list[str]: + """Detect VCS systems by marker files in ``directory`` (defaults to cwd).""" + detected: set[str] = set() + if os.environ.get('P4PORT'): + detected.add('perforce') + + target = Path(directory) if directory is not None else Path.cwd() + try: + entries = {entry.name for entry in target.iterdir()} + except OSError: + entries = set() + + for marker, vcs in _VCS_MARKERS: + if marker in entries: + detected.add(vcs) + + return sorted(detected) + + +# --------------------------------------------------------------------------- +# systemDirectories.ts +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class SystemDirectories: + HOME: str + DESKTOP: str + DOCUMENTS: str + DOWNLOADS: str + + def to_dict(self) -> dict[str, str]: + return { + 'HOME': self.HOME, + 'DESKTOP': self.DESKTOP, + 'DOCUMENTS': self.DOCUMENTS, + 'DOWNLOADS': self.DOWNLOADS, + } + + +def get_system_directories( + *, + env: dict[str, str] | None = None, + home_dir: str | None = None, + platform: Platform | None = None, +) -> SystemDirectories: + """Cross-platform system directories matching ``getSystemDirectories``.""" + chosen_platform: Platform = platform if platform is not None else get_platform() + chosen_home = home_dir if home_dir is not None else str(Path.home()) + chosen_env = env if env is not None else dict(os.environ) + + defaults = SystemDirectories( + HOME=chosen_home, + DESKTOP=str(Path(chosen_home) / 'Desktop'), + DOCUMENTS=str(Path(chosen_home) / 'Documents'), + DOWNLOADS=str(Path(chosen_home) / 'Downloads'), + ) + + if chosen_platform == 'windows': + user_profile = chosen_env.get('USERPROFILE') or chosen_home + return SystemDirectories( + HOME=chosen_home, + DESKTOP=str(Path(user_profile) / 'Desktop'), + DOCUMENTS=str(Path(user_profile) / 'Documents'), + DOWNLOADS=str(Path(user_profile) / 'Downloads'), + ) + + if chosen_platform in ('linux', 'wsl'): + return SystemDirectories( + HOME=chosen_home, + DESKTOP=chosen_env.get('XDG_DESKTOP_DIR') or defaults.DESKTOP, + DOCUMENTS=chosen_env.get('XDG_DOCUMENTS_DIR') or defaults.DOCUMENTS, + DOWNLOADS=chosen_env.get('XDG_DOWNLOAD_DIR') or defaults.DOWNLOADS, + ) + + # macOS and unknown both use the defaults. + return defaults + + +__all__ = [ + 'Platform', + 'SUPPORTED_PLATFORMS', + 'get_platform', + 'get_wsl_version', + 'LinuxDistroInfo', + 'get_linux_distro_info', + 'detect_vcs', + 'SystemDirectories', + 'get_system_directories', +] diff --git a/tests/test_platform_info.py b/tests/test_platform_info.py new file mode 100644 index 0000000..e80540e --- /dev/null +++ b/tests/test_platform_info.py @@ -0,0 +1,210 @@ +"""Tests for ``src/platform_info.py`` — platform detection and system dirs.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from src import platform_info +from src.platform_info import ( + SUPPORTED_PLATFORMS, + LinuxDistroInfo, + SystemDirectories, + detect_vcs, + get_linux_distro_info, + get_platform, + get_system_directories, + get_wsl_version, +) + + +class GetPlatformTest(unittest.TestCase): + def setUp(self) -> None: + platform_info._reset_cache() + + def tearDown(self) -> None: + platform_info._reset_cache() + + def test_macos(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'darwin'): + self.assertEqual(get_platform(), 'macos') + + def test_windows(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'win32'): + self.assertEqual(get_platform(), 'windows') + + def test_linux_no_wsl(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='Linux version 5.10 (gcc)', + ): + self.assertEqual(get_platform(), 'linux') + + def test_linux_wsl_microsoft_marker(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='Linux version 5.10 microsoft-standard-WSL2', + ): + self.assertEqual(get_platform(), 'wsl') + + def test_linux_proc_version_unreadable(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + side_effect=FileNotFoundError(), + ): + self.assertEqual(get_platform(), 'linux') + + def test_unknown_platform(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'sunos5'): + self.assertEqual(get_platform(), 'unknown') + + def test_memoized(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'darwin'): + self.assertEqual(get_platform(), 'macos') + # Second call should hit cache, not re-evaluate sys.platform + with mock.patch('src.platform_info.sys.platform', 'win32'): + self.assertEqual(get_platform(), 'macos') + + def test_supported_platforms_contains_expected(self) -> None: + self.assertIn('macos', SUPPORTED_PLATFORMS) + self.assertIn('wsl', SUPPORTED_PLATFORMS) + + +class GetWslVersionTest(unittest.TestCase): + def setUp(self) -> None: + platform_info._reset_cache() + + def tearDown(self) -> None: + platform_info._reset_cache() + + def test_explicit_wsl2(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='5.15.123-microsoft-standard-WSL2', + ): + self.assertEqual(get_wsl_version(), '2') + + def test_wsl1_fallback(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='4.4.0-19041-Microsoft (Microsoft@Microsoft.com)', + ): + self.assertEqual(get_wsl_version(), '1') + + def test_non_linux(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'darwin'): + self.assertIsNone(get_wsl_version()) + + def test_linux_no_microsoft_marker(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='Linux version 6.5.0 (gcc)', + ): + self.assertIsNone(get_wsl_version()) + + +class GetLinuxDistroInfoTest(unittest.TestCase): + def test_non_linux_returns_none(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'darwin'): + self.assertIsNone(get_linux_distro_info()) + + def test_parses_id_and_version(self) -> None: + os_release = 'NAME="Ubuntu"\nID=ubuntu\nVERSION_ID="22.04"\n' + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info.Path.read_text', return_value=os_release, + ): + info = get_linux_distro_info() + assert info is not None + self.assertEqual(info.linux_distro_id, 'ubuntu') + self.assertEqual(info.linux_distro_version, '22.04') + self.assertIsNotNone(info.linux_kernel) + + def test_to_dict_skips_none(self) -> None: + info = LinuxDistroInfo(linux_distro_id='fedora') + self.assertEqual(info.to_dict(), {'linuxDistroId': 'fedora'}) + + +class DetectVcsTest(unittest.TestCase): + def test_detects_git(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.git').mkdir() + self.assertEqual(detect_vcs(tmp), ['git']) + + def test_detects_multiple(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.git').mkdir() + (Path(tmp) / '.hg').mkdir() + self.assertEqual(detect_vcs(tmp), ['git', 'mercurial']) + + def test_perforce_via_env(self) -> None: + with tempfile.TemporaryDirectory() as tmp, \ + mock.patch.dict(os.environ, {'P4PORT': '1666'}): + self.assertIn('perforce', detect_vcs(tmp)) + + def test_unreadable_directory_returns_empty(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(detect_vcs('/nonexistent/path/abc/xyz'), []) + + +class GetSystemDirectoriesTest(unittest.TestCase): + def test_macos_defaults(self) -> None: + dirs = get_system_directories( + home_dir='/Users/x', platform='macos', env={}, + ) + self.assertEqual(dirs.HOME, '/Users/x') + self.assertEqual(dirs.DESKTOP, '/Users/x/Desktop') + self.assertEqual(dirs.DOCUMENTS, '/Users/x/Documents') + self.assertEqual(dirs.DOWNLOADS, '/Users/x/Downloads') + + def test_windows_uses_userprofile(self) -> None: + dirs = get_system_directories( + home_dir='C:/Users/old', + platform='windows', + env={'USERPROFILE': 'C:/Users/new'}, + ) + # Path normalizes to forward slashes on linux test runs; just check + # USERPROFILE was used as the base, not home_dir. + self.assertIn('Users/new', dirs.DESKTOP.replace('\\', '/')) + self.assertIn('Users/new', dirs.DOWNLOADS.replace('\\', '/')) + # HOME stays as the explicit home_dir + self.assertEqual(dirs.HOME, 'C:/Users/old') + + def test_linux_xdg_overrides(self) -> None: + dirs = get_system_directories( + home_dir='/home/u', + platform='linux', + env={'XDG_DOWNLOAD_DIR': '/data/dl'}, + ) + self.assertEqual(dirs.DOWNLOADS, '/data/dl') + self.assertEqual(dirs.DESKTOP, '/home/u/Desktop') + + def test_wsl_xdg_overrides(self) -> None: + dirs = get_system_directories( + home_dir='/home/u', + platform='wsl', + env={'XDG_DESKTOP_DIR': '/mnt/c/Users/x/Desktop'}, + ) + self.assertEqual(dirs.DESKTOP, '/mnt/c/Users/x/Desktop') + + def test_to_dict_round_trip(self) -> None: + dirs = SystemDirectories( + HOME='/h', DESKTOP='/h/D', DOCUMENTS='/h/Doc', DOWNLOADS='/h/Dn', + ) + self.assertEqual( + dirs.to_dict(), + {'HOME': '/h', 'DESKTOP': '/h/D', 'DOCUMENTS': '/h/Doc', 'DOWNLOADS': '/h/Dn'}, + ) + + +if __name__ == '__main__': + unittest.main()