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:
2026-05-01 19:48:54 +09:00
parent c29e3f5995
commit 32fc8efb84
5 changed files with 172 additions and 43 deletions

View 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",);
}
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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], ...]:

View File

@@ -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