feat(rust): PR 8 — interpreter probe heuristic → sessions_native::interpreter_probe
PYTHON_THINNING_PLAN §5 PR 8. Wave 1.5 amend §F의 ``interpreter_probe`` 슬롯.
scope 정직화:
- plan v1.1 §5 PR 8은 "캐시·랭킹 ~100 LOC 이관"이라고 명시했으나 실측:
- _VERSION_CACHE는 dict + threading.Lock instance state. ABI 라운드트립
비용 > LOC 절감 ROI → Python 잔존 (rust-max 양보 영역과 정합).
- "랭킹"은 사실 부재. detect_venv_interpreters는 python/python3 두 binary
순서 probe + dedupe만 함.
- 진짜 휴리스틱은 ``derive_venv_name`` (~40 LOC, 3-case priority).
- 따라서 PR 8 scope를 ``derive_venv_name`` 단독 이관으로 확정.
- ``_parse_probe_stdout`` 정규식과 ``parse_version_output`` regex는 Python
잔존 (rust-max 양보 영역, boundary doc Wave 1.5 amend §F notes).
Rust 측:
- rust/crates/sessions_native/src/interpreter_probe.rs 신설 (8 단위 테스트).
- ABI: sessions_interpreter_derive_venv_name(remote_path) → str.
Python 측:
- python_interpreter_registry.py: derive_venv_name 본체 ~40 LOC 삭제 →
_rust_ffi.derive_venv_name 호출 + None 정규화 (Rust는 empty string,
legacy contract는 Optional[str]).
- _rust_ffi/_tool_runtime.py: derive_venv_name thin wrapper.
- _rust_ffi/__init__.py: re-export.
추가 위생:
- python_interpreter_registry.py:374,382 ``is_python_view`` 의 ``object``
타입 가드 강화 (isinstance str 체크) — pyright reportOperatorIssue 청산.
테스트: cargo 8 신규 + pytest 1268 그린 (sublime/tests 전체).
pyright (PR 8 scope CLI 직접 실행): 0 errors.
boundary lint: 위반 0건.
boundary-claim:
removes:
- sublime/sessions/python_interpreter_registry.py:235-275 # derive_venv_name 본체
delete-count: 40
rust-additions: ~120 LOC (interpreter_probe + 8 unit tests + ABI)
ban-list: '#1/#4/#6 통과; rust-max probe regex 양보 영역 보존'
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
rust/crates/sessions_native/src/interpreter_probe.rs
Normal file
115
rust/crates/sessions_native/src/interpreter_probe.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
//! Python interpreter probe heuristics (Wave 1.5 amend §F — `interpreter_probe`).
|
||||
//!
|
||||
//! Python `sublime/sessions/python_interpreter_registry.py`의 ``derive_venv_name``
|
||||
//! 휴리스틱을 흡수. 본 모듈은 입출력이 string인 pure function — Sublime API
|
||||
//! 의존 0건, 캐시/락은 Python에 정당히 잔존(instance state + threading.Lock는
|
||||
//! ABI 라운드트립 비용 > LOC 절감 ROI).
|
||||
//!
|
||||
//! 책임 경계:
|
||||
//! - heuristic = Rust (이 모듈).
|
||||
//! - 캐시·랭킹·SSH probe = Python (`python_interpreter_registry`).
|
||||
//! - probe regex (parse_version_output) = Python 잔존 (rust-max 양보 영역,
|
||||
//! Wave 1.5 amend §F notes).
|
||||
|
||||
/// Return a human-friendly venv label for ``remote_path``.
|
||||
///
|
||||
/// Heuristics, in priority order:
|
||||
/// - ``<name>/.venv/bin/python(3)`` → ``<name>``
|
||||
/// - ``.../envs/<name>/bin/python(3)`` (conda layout) → ``<name>``
|
||||
/// - fallback: parent of ``bin/`` directory.
|
||||
/// - fallback²: immediate parent (no ``bin`` separator at all).
|
||||
///
|
||||
/// Returns empty string for an empty input or a path with fewer than two
|
||||
/// components — caller treats that as "no useful name".
|
||||
pub fn derive_venv_name(remote_path: &str) -> String {
|
||||
if remote_path.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let parts: Vec<&str> = remote_path.split('/').filter(|p| !p.is_empty()).collect();
|
||||
if parts.len() < 2 {
|
||||
return String::new();
|
||||
}
|
||||
let last = parts[parts.len() - 1];
|
||||
// Case 1: <name>/.venv/bin/python(3)
|
||||
if parts.len() >= 4
|
||||
&& last.starts_with("python")
|
||||
&& parts[parts.len() - 2] == "bin"
|
||||
&& parts[parts.len() - 3] == ".venv"
|
||||
{
|
||||
return parts[parts.len() - 4].to_string();
|
||||
}
|
||||
// Case 2: .../envs/<name>/bin/python(3)
|
||||
if parts.len() >= 4
|
||||
&& last.starts_with("python")
|
||||
&& parts[parts.len() - 2] == "bin"
|
||||
&& parts[parts.len() - 4] == "envs"
|
||||
{
|
||||
return parts[parts.len() - 3].to_string();
|
||||
}
|
||||
// Case 3: fallback — parent of ``bin``.
|
||||
if parts.len() >= 3 && parts[parts.len() - 2] == "bin" {
|
||||
return parts[parts.len() - 3].to_string();
|
||||
}
|
||||
// No ``bin/`` separator at all: punt to the immediate parent directory.
|
||||
parts[parts.len() - 2].to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_returns_empty() {
|
||||
assert_eq!(derive_venv_name(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_component_returns_empty() {
|
||||
assert_eq!(derive_venv_name("python"), "");
|
||||
assert_eq!(derive_venv_name("/python"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_venv_layout_returns_project_name() {
|
||||
assert_eq!(derive_venv_name("/path/to/MIN-T/.venv/bin/python"), "MIN-T",);
|
||||
assert_eq!(derive_venv_name("/srv/app/.venv/bin/python3"), "app",);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conda_envs_layout_returns_env_name() {
|
||||
assert_eq!(
|
||||
derive_venv_name("/home/u/.local/share/conda/envs/foo/bin/python"),
|
||||
"foo",
|
||||
);
|
||||
assert_eq!(
|
||||
derive_venv_name("/opt/conda/envs/myenv/bin/python3"),
|
||||
"myenv",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_parent_of_bin() {
|
||||
assert_eq!(derive_venv_name("/opt/python311/bin/python3"), "python311");
|
||||
assert_eq!(derive_venv_name("/usr/local/bin/python"), "local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_no_bin_uses_immediate_parent() {
|
||||
assert_eq!(derive_venv_name("/opt/python311/python"), "python311");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slashes_tolerated() {
|
||||
assert_eq!(
|
||||
derive_venv_name("/path/to/proj/.venv/bin/python///"),
|
||||
"proj",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python3_with_minor_suffix() {
|
||||
// _PYTHON_NAME_RE in the Python module accepts "python3.11" too;
|
||||
// the venv-name heuristic is "starts_with python", so this matches.
|
||||
assert_eq!(derive_venv_name("/srv/app/.venv/bin/python3.11"), "app",);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod abi_error;
|
||||
pub mod broker;
|
||||
mod broker_ffi;
|
||||
mod interpreter_probe;
|
||||
mod settings_normalize;
|
||||
|
||||
pub use abi_error::AbiError;
|
||||
@@ -1285,6 +1286,32 @@ pub unsafe extern "C" fn sessions_settings_normalize_extensions(
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Python interpreter probe heuristics (Wave 1.5 amend §F)
|
||||
// ===========================================================================
|
||||
|
||||
/// Derive a human-friendly venv label from a remote interpreter path.
|
||||
///
|
||||
/// # Safety
|
||||
/// `remote_path` must be a valid UTF-8 C string. `out_buf` must be writable
|
||||
/// for `out_cap` bytes when non-null. Output is empty string when input has
|
||||
/// no useful name to extract (single-component paths).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_interpreter_derive_venv_name(
|
||||
remote_path: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if remote_path.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(remote_path_s) = (unsafe { CStr::from_ptr(remote_path) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let derived = interpreter_probe::derive_venv_name(remote_path_s);
|
||||
write_output(out_buf, out_cap, &derived)
|
||||
}
|
||||
|
||||
/// Merge user remote extension specs over a Python-supplied builtin catalog.
|
||||
///
|
||||
/// # Safety
|
||||
|
||||
@@ -83,6 +83,7 @@ from ._loader import (
|
||||
call_string_abi,
|
||||
)
|
||||
from ._tool_runtime import (
|
||||
derive_venv_name,
|
||||
merge_remote_extension_catalog_json,
|
||||
normalize_code_server_specs_json,
|
||||
normalize_python_tool_pipeline,
|
||||
@@ -119,6 +120,7 @@ __all__ = (
|
||||
"reload_recommendation_code",
|
||||
"save_decision_code",
|
||||
# _tool_runtime
|
||||
"derive_venv_name",
|
||||
"merge_remote_extension_catalog_json",
|
||||
"normalize_code_server_specs_json",
|
||||
"normalize_python_tool_pipeline",
|
||||
|
||||
@@ -107,6 +107,19 @@ def normalize_remote_extension_specs_json(
|
||||
return tuple(item for item in out if isinstance(item, dict))
|
||||
|
||||
|
||||
def derive_venv_name(remote_path: str) -> str:
|
||||
"""Return a human-friendly venv label for ``remote_path`` (Wave 1.5 amend §F)."""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_interpreter_derive_venv_name",
|
||||
[ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
return call_string_abi(
|
||||
func,
|
||||
(ctypes.c_char_p(remote_path.encode("utf-8")),),
|
||||
failure_prefix="sessions_interpreter_derive_venv_name",
|
||||
)
|
||||
|
||||
|
||||
def merge_remote_extension_catalog_json(
|
||||
builtin_specs: Sequence[Dict[str, Any]], user_raw: Any
|
||||
) -> Tuple[Dict[str, Any], ...]:
|
||||
|
||||
@@ -17,6 +17,8 @@ import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from . import _rust_ffi
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
@@ -235,44 +237,13 @@ def clear_active_interpreter(window: object) -> None:
|
||||
def derive_venv_name(remote_path: str) -> Optional[str]:
|
||||
"""Return a human-friendly venv label for ``remote_path``.
|
||||
|
||||
Heuristics, in priority order, with examples:
|
||||
|
||||
* ``/path/to/MIN-T/.venv/bin/python`` → ``MIN-T``
|
||||
(parent of the ``.venv/bin/python(3)`` tail)
|
||||
* ``$HOME/.local/share/conda/envs/foo/bin/python`` → ``foo``
|
||||
(a conda-style ``envs/<name>/bin/python`` layout)
|
||||
* ``/opt/python311/bin/python3`` → ``python311``
|
||||
(anything else: parent of ``bin``)
|
||||
|
||||
Returns ``None`` only when ``remote_path`` is empty or has fewer than two
|
||||
components — there's no useful name we can pull out in that case.
|
||||
Heuristics live in ``sessions_native::interpreter_probe`` (Wave 1.5
|
||||
amend §F). Returns ``None`` when input has no useful name (empty or
|
||||
single-component path) — Rust returns empty string in that case, this
|
||||
wrapper normalizes back to ``None`` to preserve the legacy contract.
|
||||
"""
|
||||
if not remote_path:
|
||||
return None
|
||||
parts = [p for p in remote_path.split("/") if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
# Case 1: <name>/.venv/bin/python(3)
|
||||
if (
|
||||
len(parts) >= 4
|
||||
and parts[-1].startswith("python")
|
||||
and parts[-2] == "bin"
|
||||
and parts[-3] == ".venv"
|
||||
):
|
||||
return parts[-4]
|
||||
# Case 2: .../envs/<name>/bin/python(3)
|
||||
if (
|
||||
len(parts) >= 4
|
||||
and parts[-1].startswith("python")
|
||||
and parts[-2] == "bin"
|
||||
and parts[-4] == "envs"
|
||||
):
|
||||
return parts[-3]
|
||||
# Case 3: fallback — parent of ``bin``.
|
||||
if len(parts) >= 3 and parts[-2] == "bin":
|
||||
return parts[-3]
|
||||
# No ``bin/`` separator at all: punt to the immediate parent directory.
|
||||
return parts[-2]
|
||||
derived = _rust_ffi.derive_venv_name(remote_path)
|
||||
return derived if derived else None
|
||||
|
||||
|
||||
def parse_version_output(output: str) -> Optional[str]:
|
||||
@@ -397,19 +368,20 @@ def is_python_view(view: object) -> bool:
|
||||
scope_name = getattr(view, "scope_name", None)
|
||||
if callable(scope_name):
|
||||
try:
|
||||
scope = scope_name(0) or ""
|
||||
scope_raw = scope_name(0)
|
||||
except Exception: # noqa: BLE001
|
||||
scope = ""
|
||||
scope_raw = None
|
||||
scope = scope_raw if isinstance(scope_raw, str) else ""
|
||||
if "source.python" in scope or "source.cython" in scope:
|
||||
return True
|
||||
file_name = getattr(view, "file_name", None)
|
||||
if callable(file_name):
|
||||
try:
|
||||
name = file_name() or ""
|
||||
name_raw = file_name()
|
||||
except Exception: # noqa: BLE001
|
||||
name = ""
|
||||
lower = name.lower()
|
||||
if lower.endswith((".py", ".pyi", ".pyx", ".pxd")):
|
||||
name_raw = None
|
||||
name = name_raw if isinstance(name_raw, str) else ""
|
||||
if name.lower().endswith((".py", ".pyi", ".pyx", ".pxd")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user