Merge PR #8186: feat(lsp): add Astral ty as built-in Python primary LSP server (@FNDEVVE)

This commit is contained in:
can1357
2026-08-13 02:00:50 +02:00
4 changed files with 95 additions and 0 deletions
+3
View File
@@ -135,6 +135,9 @@
### Fixed
- Fixed Hindsight `per-project` and `per-project-tagged` scoping splitting one repository across two memory scopes when the checkout directory carries capitals: the project label is now lowercased, so a checkout at `~/code/General` writes and recalls under `project:general` like every other client of the same bank instead of opening a private `project:General` scope.
### Added
- Added Astral `ty` as a built-in Python primary LSP server (`ty server`), ordered behind `pyright`/`basedpyright`/`pylsp` so it becomes the primary Python LSP only when the existing servers are unavailable. `ruff` remains the Python linter and coexists alongside `ty` ([#4617](https://github.com/can1357/oh-my-pi/issues/4617)).
## [17.2.12] - 2026-08-08
+1
View File
@@ -229,6 +229,7 @@ export function hasRootMarkerAncestor(filePath: string, markers: string[]): bool
*/
const PYTHON_ROOT_MARKERS = [
"pyproject.toml",
"ty.toml",
"requirements.txt",
"setup.py",
"setup.cfg",
@@ -189,6 +189,12 @@
"fileTypes": [".py"],
"rootMarkers": ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"]
},
"ty": {
"command": "ty",
"args": ["server"],
"fileTypes": [".py", ".pyi"],
"rootMarkers": ["pyproject.toml", "ty.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"]
},
"ruff": {
"command": "ruff",
"args": ["server"],
@@ -3492,3 +3492,88 @@ describe("expert elixir lsp", () => {
expect(names.indexOf("elixirls")).toBeLessThan(names.indexOf("expert"));
});
});
describe("ty python lsp", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("registers ty for .py behind existing Python primaries and before ruff", () => {
const config = { servers: DEFAULTS as unknown as Record<string, ServerConfig> };
const names = getServersForFile(config, "app.py").map(([name]) => name);
expect(names).toContain("ty");
expect(names).toContain("ruff");
// ty is behind all existing Python primaries
expect(names.indexOf("pyright")).toBeLessThan(names.indexOf("ty"));
expect(names.indexOf("basedpyright")).toBeLessThan(names.indexOf("ty"));
expect(names.indexOf("pylsp")).toBeLessThan(names.indexOf("ty"));
// ruff (linter) sorts after all primaries including ty
expect(names.indexOf("ty")).toBeLessThan(names.indexOf("ruff"));
});
it("registers ty for .pyi stub files", () => {
const config = { servers: DEFAULTS as unknown as Record<string, ServerConfig> };
const names = getServersForFile(config, "app.pyi").map(([name]) => name);
expect(names).toContain("ty");
});
it("auto-detects ty when its binary and Python root markers are present", async () => {
const tempDir = TempDir.createSync("@omp-lsp-ty-detect-");
const resolvedTy = path.join(tempDir.path(), "bin", "ty");
const whichSpy = vi
.spyOn(piUtils, "$which")
.mockImplementation(command => (command === "ty" ? resolvedTy : null));
try {
await Bun.write(path.join(tempDir.path(), "pyproject.toml"), '[project]\nname = "demo"\n');
const config = loadConfig(tempDir.path());
expect(config.servers.ty?.resolvedCommand).toBe(resolvedTy);
expect(config.servers.ty?.command).toBe("ty");
expect(config.servers.ty?.args).toEqual(["server"]);
expect(whichSpy).toHaveBeenCalledWith("ty");
} finally {
tempDir.removeSync();
}
});
it("coexists with ruff: ty is primary, ruff is linter, both auto-detected", async () => {
const tempDir = TempDir.createSync("@omp-lsp-ty-ruff-");
const resolvedTy = path.join(tempDir.path(), "bin", "ty");
const resolvedRuff = path.join(tempDir.path(), "bin", "ruff");
vi.spyOn(piUtils, "$which").mockImplementation(command =>
command === "ty" ? resolvedTy : command === "ruff" ? resolvedRuff : null,
);
try {
await Bun.write(path.join(tempDir.path(), "pyproject.toml"), '[project]\nname = "demo"\n');
const config = loadConfig(tempDir.path());
expect(config.servers.ty?.resolvedCommand).toBe(resolvedTy);
expect(config.servers.ruff?.resolvedCommand).toBe(resolvedRuff);
expect(config.servers.ruff?.isLinter).toBe(true);
expect(config.servers.ty?.isLinter).toBeFalsy();
const names = getServersForFile(config, path.join(tempDir.path(), "app.py")).map(([name]) => name);
expect(names.indexOf("ty")).toBeLessThan(names.indexOf("ruff"));
} finally {
tempDir.removeSync();
}
});
it("auto-detects ty in a ty.toml-only project, resolving via project-local venv bin", async () => {
// Astral documents ty.toml as a first-class project config file; a project
// that opts into ty with only that file (no pyproject/setup/requirements)
// must still pass the root-marker gate AND resolve the local venv binary.
const tempDir = TempDir.createSync("@omp-lsp-ty-toml-");
const venvBin = process.platform === "win32" ? ".venv/Scripts" : ".venv/bin";
const resolvedTy = path.join(tempDir.path(), venvBin, "ty");
// $which never succeeds: only LOCAL_BIN_PATHS resolution can find ty.
vi.spyOn(piUtils, "$which").mockImplementation(() => null);
try {
await Bun.write(path.join(tempDir.path(), "ty.toml"), "[configuration]\n");
await Bun.write(resolvedTy, '#!/bin/sh\nexec ty "$@"\n');
const config = loadConfig(tempDir.path());
expect(config.servers.ty?.resolvedCommand).toBe(resolvedTy);
expect(config.servers.ty?.command).toBe("ty");
expect(config.servers.ty?.args).toEqual(["server"]);
} finally {
tempDir.removeSync();
}
});
});