diff --git a/lang/python/lib.go b/lang/python/lib.go index 34415562..53324375 100644 --- a/lang/python/lib.go +++ b/lang/python/lib.go @@ -16,7 +16,9 @@ package python import ( "fmt" + "os" "os/exec" + "path/filepath" "regexp" "strconv" "strings" @@ -33,6 +35,86 @@ const lspUrl = "https://github.com/Hoblovski/python-lsp-server.git" const lspBranch = "abc" const lspPath = "pylsp" +func pylspInstallPath() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("find user cache directory: %w", err) + } + return filepath.Join(cacheDir, "abcoder", lspPath), nil +} + +func validatePylspSource(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("inspect cached pylsp path %q: %w", path, err) + } + if !info.IsDir() { + return fmt.Errorf("cached pylsp path %q is not a directory", path) + } + gitInfo, gitErr := os.Stat(filepath.Join(path, ".git")) + if gitErr != nil || !gitInfo.IsDir() { + return fmt.Errorf("cached pylsp path %q is not a git checkout", path) + } + projectInfo, projectErr := os.Stat(filepath.Join(path, "pyproject.toml")) + if projectErr != nil || projectInfo.IsDir() { + return fmt.Errorf("cached pylsp checkout %q has no pyproject.toml", path) + } + return nil +} + +func clonePylspSource(path string) error { + log.Error("Installing pylsp... Now running git clone -b %s %s %s", lspBranch, lspUrl, path) + if output, err := exec.Command("git", "clone", "-b", lspBranch, lspUrl, path).CombinedOutput(); err != nil { + return fmt.Errorf("clone pylsp into %q: %w: %s", path, err, strings.TrimSpace(string(output))) + } + return nil +} + +func ensurePylspSourceWithClone(path string, clone func(string) error) error { + if _, err := os.Stat(path); err == nil { + if err := validatePylspSource(path); err != nil { + return err + } + log.Info("Reusing cached pylsp checkout at %s", path) + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect cached pylsp path %q: %w", path, err) + } + + parent := filepath.Dir(path) + if err := os.MkdirAll(parent, 0o755); err != nil { + return fmt.Errorf("create pylsp cache directory: %w", err) + } + tempPath, err := os.MkdirTemp(parent, ".pylsp-clone-") + if err != nil { + return fmt.Errorf("create temporary pylsp checkout: %w", err) + } + defer func() { + if err := os.RemoveAll(tempPath); err != nil { + log.Error("failed to clean up temporary pylsp checkout %s: %v", tempPath, err) + } + }() + + if err := clone(tempPath); err != nil { + return err + } + if err := validatePylspSource(tempPath); err != nil { + return fmt.Errorf("validate cloned pylsp checkout: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + if validationErr := validatePylspSource(path); validationErr == nil { + log.Info("Reusing pylsp checkout installed concurrently at %s", path) + return nil + } + return fmt.Errorf("publish pylsp checkout at %q: %w", path, err) + } + return nil +} + +func ensurePylspSource(path string) error { + return ensurePylspSourceWithClone(path, clonePylspSource) +} + func CheckPythonVersion() error { // Check python3 command availability and get version. output, err := exec.Command("python3", "--version").CombinedOutput() @@ -66,15 +148,18 @@ func InstallLanguageServer() (string, error) { log.Error("python version check failed: %v", err) return "", err } - // git clone - log.Error("Installing pylsp... Now running git clone -b %s %s %s", lspBranch, lspUrl, lspPath) - if err := exec.Command("git", "clone", "-b", lspBranch, lspUrl, lspPath).Run(); err != nil { - log.Error("git clone failed: %v", err) + path, err := pylspInstallPath() + if err != nil { + log.Error("failed to determine pylsp install path: %v", err) + return "", err + } + if err := ensurePylspSource(path); err != nil { + log.Error("failed to prepare pylsp source: %v", err) return "", err } - // python -m pip install -e projectRoot/pylsp + // Install the cached checkout in editable mode. log.Error("Installing pylsp via pip. This might take some time, make sure the network connection is ok.") - if err := exec.Command("python3", "-m", "pip", "install", "--break-system-packages", "-e", lspPath).Run(); err != nil { + if err := exec.Command("python3", "-m", "pip", "install", "--break-system-packages", "-e", path).Run(); err != nil { log.Error("python3 -m pip install failed: %v", err) return "", err } diff --git a/lang/python/lib_test.go b/lang/python/lib_test.go new file mode 100644 index 00000000..235d4c70 --- /dev/null +++ b/lang/python/lib_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 CloudWeGo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package python + +import ( + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPylspInstallPathUsesUserCache(t *testing.T) { + cacheDir, err := os.UserCacheDir() + require.NoError(t, err) + + got, err := pylspInstallPath() + require.NoError(t, err) + require.Equal(t, filepath.Join(cacheDir, "abcoder", "pylsp"), got) + require.True(t, filepath.IsAbs(got)) +} + +func TestEnsurePylspSourceReusesCachedCheckout(t *testing.T) { + path := filepath.Join(t.TempDir(), "abcoder", "pylsp") + require.NoError(t, os.MkdirAll(filepath.Join(path, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(path, "pyproject.toml"), nil, 0o600)) + + require.NoError(t, ensurePylspSource(path)) +} + +func TestEnsurePylspSourceRejectsInvalidCacheDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "abcoder", "pylsp") + require.NoError(t, os.MkdirAll(path, 0o755)) + + err := ensurePylspSource(path) + require.ErrorContains(t, err, "not a git checkout") +} + +func TestEnsurePylspSourcePublishesConcurrentCloneAtomically(t *testing.T) { + parent := filepath.Join(t.TempDir(), "abcoder") + path := filepath.Join(parent, "pylsp") + ready := make(chan struct{}, 2) + release := make(chan struct{}) + clone := func(tempPath string) error { + if err := os.MkdirAll(filepath.Join(tempPath, ".git"), 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(tempPath, "pyproject.toml"), nil, 0o600); err != nil { + return err + } + ready <- struct{}{} + <-release + return nil + } + + errs := make(chan error, 2) + var wg sync.WaitGroup + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + errs <- ensurePylspSourceWithClone(path, clone) + }() + } + <-ready + <-ready + close(release) + wg.Wait() + close(errs) + + for err := range errs { + require.NoError(t, err) + } + require.NoError(t, validatePylspSource(path)) + entries, err := os.ReadDir(parent) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, "pylsp", entries[0].Name()) +} + +func TestEnsurePylspSourceCleansUpInterruptedClone(t *testing.T) { + parent := filepath.Join(t.TempDir(), "abcoder") + path := filepath.Join(parent, "pylsp") + cloneErr := errors.New("clone interrupted") + + err := ensurePylspSourceWithClone(path, func(tempPath string) error { + require.NoError(t, os.MkdirAll(filepath.Join(tempPath, ".git"), 0o755)) + return cloneErr + }) + require.ErrorIs(t, err, cloneErr) + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + entries, err := os.ReadDir(parent) + require.NoError(t, err) + require.Empty(t, entries) +} diff --git a/lang/python/spec.go b/lang/python/spec.go index 85c4aebd..10f6eff9 100644 --- a/lang/python/spec.go +++ b/lang/python/spec.go @@ -63,21 +63,7 @@ func (c *PythonSpec) WorkSpace(root string) (map[string]string, error) { return nil, err } - num_projfiles := 0 - scanner := func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - base := filepath.Base(path) - if base == "pyproject.toml" { - num_projfiles++ - if num_projfiles > 1 { - panic("multiple pyproject.toml files found") - } - } - return nil - } - if err := filepath.Walk(root, scanner); err != nil { + if _, err := os.Stat(absPath); err != nil { return nil, err } diff --git a/lang/python/spec_test.go b/lang/python/spec_test.go new file mode 100644 index 00000000..293bf1ae --- /dev/null +++ b/lang/python/spec_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 CloudWeGo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package python + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPythonSpecWorkSpaceAllowsNestedPyprojectFiles(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "pyproject.toml"), nil, 0o600)) + nested := filepath.Join(root, "vendor", "pylsp") + require.NoError(t, os.MkdirAll(nested, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(nested, "pyproject.toml"), nil, 0o600)) + + modules, err := (&PythonSpec{}).WorkSpace(root) + require.NoError(t, err) + absRoot, err := filepath.Abs(root) + require.NoError(t, err) + require.Equal(t, map[string]string{"current": absRoot}, modules) +} + +func TestPythonSpecWorkSpaceRejectsMissingRoot(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + + modules, err := (&PythonSpec{}).WorkSpace(missing) + require.Error(t, err) + require.Nil(t, modules) +}