Implemented the next missing parity slice around platform detection and system directories.
This commit is contained in:
+1
-1
@@ -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/`)
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user