diff --git a/CHANGELOG.md b/CHANGELOG.md index 41af6783..1ab16662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,16 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ### Added +- Support for system-wide installation using `uv tool` or `pipx` with automatic Python environment detection and virtualenv discovery + +### Changed + +- Server no longer requires installation in project virtualenv, including robust Python dependency resolution using `PATH` and `site-packages` detection + +## [5.1.0a1] + +### Added + - Basic Neovim plugin ## [5.1.0a0] @@ -44,5 +54,6 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ - Josh Thomas (maintainer) -[unreleased]: https://github.com/joshuadavidthomas/django-language-server/compare/v5.1.0a0...HEAD +[unreleased]: https://github.com/joshuadavidthomas/django-language-server/compare/v5.1.0a1...HEAD [5.1.0a0]: https://github.com/joshuadavidthomas/django-language-server/releases/tag/v5.1.0a0 +[5.1.0a1]: https://github.com/joshuadavidthomas/django-language-server/releases/tag/v5.1.0a1 diff --git a/README.md b/README.md index c93625ca..ba91821c 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,19 @@ See the [Versioning](#versioning) section for details on how this project's vers ## Installation -Install the Django Language Server in your project's environment: +The Django Language Server can be installed using your preferred Python package manager. + +For system-wide availability using either `uv` or `pipx`: + +```bash +uv tool install django-language-server + +# or + +pipx install django-language-server +``` + +Or to try it out in your current project: ```bash uv add --dev django-language-server @@ -67,11 +79,9 @@ pip install django-language-server The package provides pre-built wheels with the Rust-based LSP server compiled for common platforms. Installing it adds the `djls` command-line tool to your environment. > [!NOTE] -> The server must currently be installed in each project's environment as it needs to run using the project's Python interpreter to access the correct Django installation and other dependencies. +> The server will automatically detect and use your project's Python environment when you open a Django project. It needs access to your project's Django installation and other dependencies, but should be able to find these regardless of where the server itself is installed. > -> Global installation is not yet supported as it would run against a global Python environment rather than your project's virtualenv. The server uses [PyO3](https://pyo3.rs) to interact with Django, and we aim to support global installation in the future, allowing the server to detect and use project virtualenvs, but this is a tricky problem involving PyO3 and Python interpreter management. -> -> If you have experience with [PyO3](https://pyo3.rs) or [maturin](https://maturin.rs) and ideas on how to achieve this, please check the [Contributing](#contributing) section below. +> It's recommended to use `uv` or `pipx` to install it system-wide for convenience, but installing in your project's environment will work just as well to give it a test drive around the block. ## Editor Setup @@ -144,11 +154,9 @@ The project is written in Rust using PyO3 for Python integration. Here is a high - Template parsing ([`crates/djls-template-ast/`](./crates/djls-template-ast/)) - Tokio-based background task management ([`crates/djls-worker/`](./crates/djls-worker/)) -Code contributions are welcome from developers of all backgrounds. Rust expertise is especially valuable for the LSP server and core components. - -One significant challenge we're trying to solve is supporting global installation of the language server while still allowing it to detect and use project virtualenvs for Django introspection. Currently, the server must be installed in each project's virtualenv to access the project's Django installation. If you have experience with PyO3 and ideas about how to achieve this, we'd love your help! +Code contributions are welcome from developers of all backgrounds. Rust expertise is valuable for the LSP server and core components, but Python and Django developers should not be deterred by the Rust codebase - Django expertise is just as valuable. Understanding Django's internals and common development patterns helps inform what features would be most valuable. -Python and Django developers should not be deterred by the Rust codebase - Django expertise is just as valuable. Understanding Django's internals and common development patterns helps inform what features would be most valuable. The Rust components were built by [a simple country CRUD web developer](https://youtu.be/7ij_1SQqbVo?si=hwwPyBjmaOGnvPPI&t=53) learning Rust along the way. +So far it's all been built by a [a simple country CRUD web developer](https://youtu.be/7ij_1SQqbVo?si=hwwPyBjmaOGnvPPI&t=53) learning Rust along the way - send help! ## License diff --git a/crates/djls-project/Cargo.toml b/crates/djls-project/Cargo.toml index a5247b84..9fd450c0 100644 --- a/crates/djls-project/Cargo.toml +++ b/crates/djls-project/Cargo.toml @@ -6,3 +6,5 @@ edition = "2021" [dependencies] pyo3 = { workspace = true } tower-lsp = { workspace = true } + +which = "7.0.1" diff --git a/crates/djls-project/src/lib.rs b/crates/djls-project/src/lib.rs index b9b9bcd1..e30f1345 100644 --- a/crates/djls-project/src/lib.rs +++ b/crates/djls-project/src/lib.rs @@ -3,12 +3,15 @@ mod templatetags; pub use templatetags::TemplateTags; use pyo3::prelude::*; +use std::fmt; use std::path::{Path, PathBuf}; use tower_lsp::lsp_types::*; +use which::which; #[derive(Debug)] pub struct DjangoProject { path: PathBuf, + env: Option, template_tags: Option, } @@ -16,6 +19,7 @@ impl DjangoProject { pub fn new(path: PathBuf) -> Self { Self { path, + env: None, template_tags: None, } } @@ -36,19 +40,37 @@ impl DjangoProject { } pub fn initialize(&mut self) -> PyResult<()> { + let python_env = PythonEnvironment::new().ok_or_else(|| { + PyErr::new::("Could not find Python in PATH") + })?; + Python::with_gil(|py| { - // Add project to Python path let sys = py.import("sys")?; let py_path = sys.getattr("path")?; - py_path.call_method1("append", (self.path.to_str().unwrap(),))?; - // Setup Django - let django = py.import("django")?; - django.call_method0("setup")?; + if let Some(path_str) = self.path.to_str() { + py_path.call_method1("insert", (0, path_str))?; + } + + for path in &python_env.sys_path { + if let Some(path_str) = path.to_str() { + py_path.call_method1("append", (path_str,))?; + } + } - self.template_tags = Some(TemplateTags::from_python(py)?); + self.env = Some(python_env); - Ok(()) + match py.import("django") { + Ok(django) => { + django.call_method0("setup")?; + self.template_tags = Some(TemplateTags::from_python(py)?); + Ok(()) + } + Err(e) => { + eprintln!("Failed to import Django: {}", e); + Err(e) + } + } }) } @@ -60,3 +82,65 @@ impl DjangoProject { &self.path } } + +impl fmt::Display for DjangoProject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Project path: {}", self.path.display())?; + if let Some(py_env) = &self.env { + write!(f, "{}", py_env)?; + } + Ok(()) + } +} + +#[derive(Debug)] +struct PythonEnvironment { + python_path: PathBuf, + sys_path: Vec, + sys_prefix: PathBuf, +} + +impl PythonEnvironment { + fn new() -> Option { + let python_path = which("python").ok()?; + let prefix = python_path.parent()?.parent()?; + + let mut sys_path = Vec::new(); + sys_path.push(prefix.join("bin")); + + if let Some(site_packages) = Self::find_site_packages(prefix) { + sys_path.push(site_packages); + } + + Some(Self { + python_path: python_path.clone(), + sys_path, + sys_prefix: prefix.to_path_buf(), + }) + } + + #[cfg(windows)] + fn find_site_packages(prefix: &Path) -> Option { + Some(prefix.join("Lib").join("site-packages")) + } + + #[cfg(not(windows))] + fn find_site_packages(prefix: &Path) -> Option { + std::fs::read_dir(prefix.join("lib")) + .ok()? + .filter_map(Result::ok) + .find(|e| e.file_name().to_string_lossy().starts_with("python")) + .map(|e| e.path().join("site-packages")) + } +} + +impl fmt::Display for PythonEnvironment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Sys prefix: {}", self.sys_prefix.display())?; + writeln!(f, "Sys paths:")?; + for path in &self.sys_path { + writeln!(f, " {}", path.display())?; + } + Ok(()) + } +}