diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index cf3b93a4..3eeac6be 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -28,6 +28,12 @@ jobs: - uses: actions/setup-go@v5 with: go-version: '>=1.17.0' + - name: Ensure cached directory exist before calling cache-related actions + shell: bash + run: | + mkdir -p $HOME/.serena/language_servers/static + mkdir -p $HOME/.cache/go-build + mkdir -p $HOME/go/bin # Add Go bin directory to PATH for this workflow # GITHUB_PATH is a special file that GitHub Actions uses to modify PATH # Writing to this file adds the directory to the PATH for subsequent steps @@ -49,7 +55,6 @@ jobs: with: elixir-version: '1.18.4' otp-version: '26.1' - - name: Prepare java uses: actions/setup-java@v3 with: @@ -77,7 +82,7 @@ jobs: id: cache-language-servers uses: actions/cache@v3 with: - path: src/solidlsp/language_servers/static + path: ~/.serena/language_servers/static key: language-servers-${{ runner.os }}-v1 restore-keys: | language-servers-${{ runner.os }}- diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c1b190..dfbf52c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,15 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* Language servers: + * Reliably detect language server termination and propagate the respective error all the way + back to the tool application, where an unexpected termination is handled by restarting the language server + and subsequently retrying the tool application. + Fixes: * Ignore `.git` in check for ignored paths and improve performance of `find_all_non_ignored_files` +* Fix language server startup issues on Windows when using Claude Code (which was due to + default shell reconfiguration imposed by Claude Code) # 0.1.3 diff --git a/README.md b/README.md index a349aef1..1b6777cb 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,16 @@ Note that no matter how you run the MCP server, Serena will, by default, start a MCP server (since many clients fail to clean up processes correctly). This and other settings can be adjusted in the [configuration](#configuration) and/or by providing [command-line arguments](#command-line-arguments). +##### Using uvx + +`uvx` can be used to run the latest version of Serena directly from the repository, without an explicit local installation. + +```shell +uvx --from git+https://github.com/oraios/serena serena start-mcp-server +``` + +Explore the CLI to see some of the customization options that serena provides (more info on them below). + ###### Local Installation 1. Clone the repository and change into it. @@ -202,38 +212,19 @@ This and other settings can be adjusted in the [configuration](#configuration) a git clone https://github.com/oraios/serena cd serena ``` -2. Optionally create the configuration file in your home directory, i.e. - - * `~/.serena/serena_config.yml` on Linux and macOS, or - * `%USERPROFILE%\.serena\serena_config.yml` on Windows. - - by copying the template and then adjusting it according to your needs: +2. Optionally edit the configuration file in your home directory with ```shell - mkdir ~/.serena - cp src/serena/resources/serena_config.template.yml ~/.serena/serena_config.yml + uv run serena config edit ``` If you just want the default config, you can skip this part, and a config file will be created when you first run Serena. 3. Run the server with `uv`: ```shell - uv run serena-mcp-server + uv run serena start-mcp-server ``` - When running from outside the serena installation directory, be sure to pass it, i.e. use + When running from outside the serena installation directory, be sure to pass it, i.e., use ```shell - uv run --directory /abs/path/to/serena serena-mcp-server - ``` - -##### Using uvx - -`uvx` can be used to run the latest version of Serena directly from the repository, without an explicit local installation. - -* Windows: - ```shell - uvx --from git+https://github.com/oraios/serena serena-mcp-server.exe - ``` -* Other operating systems: - ```shell - uvx --from git+https://github.com/oraios/serena serena-mcp-server - ``` + uv run --directory /abs/path/to/serena serena start-mcp-server + ``` ##### Using Docker (Experimental) @@ -243,7 +234,7 @@ You can run the Serena MCP server directly via docker as follows, assuming that the projects you want to work on are all located in `/path/to/your/projects`: ```shell -docker run --rm -i --network host -v /path/to/your/projects:/workspaces/projects ghcr.io/oraios/serena:latest serena-mcp-server --transport stdio +docker run --rm -i --network host -v /path/to/your/projects:/workspaces/projects ghcr.io/oraios/serena:latest serena start-mcp-server --transport stdio ``` Replace `/path/to/your/projects` with the absolute path to your projects directory. The Docker approach provides: @@ -263,15 +254,15 @@ therefore needs to be configured with a launch command. When using instead the SSE mode, which uses HTTP-based communication, you control the server lifecycle yourself, i.e. you start the server and provide the client with the URL to connect to it. -Simply provide `serena-mcp-server` with the `--transport sse` option and optionally provide the port. +Simply provide `start-mcp-server` with the `--transport sse` option and optionally provide the port. For example, to run the Serena MCP server in SSE mode on port 9121 using a local installation, you would run this command from the Serena directory, ```shell -uv run serena-mcp-server --transport sse --port 9121 +uv run serena start-mcp-server --transport sse --port 9121 ``` -and then configure your client to connect to `http://localhost/sse:9121`. +and then configure your client to connect to `http://localhost:9121/sse`. #### Command-Line Arguments @@ -289,12 +280,22 @@ Serena's behavior (active tools and prompts as well as logging configuration, et 1. The `serena_config.yml` for general settings that apply to all clients and projects. It is located in your user directory under `.serena/serena_config.yml`. If you do not explicitly create the file, it will be auto-generated when you first run Serena. -2. In the arguments passed to the `serena-mcp-server` in your client's config (see below), + You can edit it directly or use + ```shell + uvx --from git+https://github.com/oraios/serena serena config edit + ``` + (or use the `--directory` command version). +2. In the arguments passed to the `start-mcp-server` in your client's config (see below), which will apply to all sessions started by the respective client. In particular, the [context](#contexts) parameter should be set appropriately for Serena to be best adjusted to existing tools and capabilities of your client. See for a detailed explanation. You can override all entries from the `serena_config.yml` through command line arguments. 3. In the `.serena/project.yml` file within your project. This will hold project-level configuration that is used whenever - that project is activated. + that project is activated. This file will be autogenerated when you first use Serena on that project, but you can also + generate it explicitly with + ```shell + uvx --from git+https://github.com/oraios/serena serena project generate-yml + ``` + (or use the `--directory` command version). 4. Through the currently active set of [modes](#modes). @@ -323,20 +324,17 @@ project, the file `.serena/project.yml` will be generated. You can adjust the la same name. If you are mostly working with the same project, you can also configure to always activate a project at startup -by passing `--project ` to the `serena-mcp-server` command in your client's MCP config. +by passing `--project ` to the `start-mcp-server` command in your client's MCP config. ℹ️ For larger projects, we recommend that you index your project to accelerate Serena's tools; otherwise the first tool application may be very slow. -To do so, run one of these commands the project directory or pass the path to the project as an argument: +To do so, run this from the project directory (or pass the path to the project as an argument): -* When using a local installation: - ```shell - uv run --directory /abs/path/to/serena index-project - ``` -* When using uvx: - ```shell - uvx --from git+https://github.com/oraios/serena index-project - ``` +```shell +uvx --from git+https://github.com/oraios/serena serena project index +``` + +(or use the `--directory` command version). ### Claude Code @@ -351,15 +349,16 @@ claude mcp add serena -- --context ide-assistant --project $ where `` is your way of [running the Serena MCP server](#running-the-serena-mcp-server). For example, when using `uvx`, you would run ```shell -claude mcp add serena -- uvx --from git+https://github.com/oraios/serena serena-mcp-server --context ide-assistant --project $(pwd) +claude mcp add serena -- uvx --from git+https://github.com/oraios/serena serena start-mcp-server --context ide-assistant --project $(pwd) ``` -ℹ️ Serena comes with an instruction text, and Claude needs to read it to properly use Serena's tools. - Once in Claude Code, you can ask to "read Serena's initial instructions" or run `/mcp__serena__initial_instructions` to load the instruction text. - Do this whenever you start a new conversation and after any compacting operation to ensure Claude remains properly configured to use Serena's tools. - -ℹ️ **NEW**: an alternative to the above is adding the instructions as part of the system prompt, then you will not need to run the command above or to remember re-running it after compacting. - This can be achieved through starting claude code with `claude --append-system-prompt $(uvx --from git+https://github.com/oraios/serena serena print-system-prompt)`. Note that this is **experimental**, Claude may not understand the instructions correctly in this way, and we haven't thoroughly tested the resulting behavior. Please report any issues you encounter. +ℹ️ Serena comes with an instruction text, and Claude needs to read it to properly use Serena's tools. + As of version `v1.0.52`, claude code reads the instructions of the MCP server, so this **is handled automatically**. + If you are using an older version, or if Claude fails to read the instructions, you can ask it explicitly + to "read Serena's initial instructions" or run `/mcp__serena__initial_instructions` to load the instruction text. + If you want to make use of that, you will have to enable the corresponding tool explicitly by adding `initial_instructions` to the `included_optional_tools` + in your config. + Note that you may have to make Claude read the instructions you start a new conversation and after any compacting operation to ensure Claude remains properly configured to use Serena's tools. ### Claude Desktop @@ -374,7 +373,7 @@ Add the `serena` MCP server configuration, using a [run command](#running-the-se "mcpServers": { "serena": { "command": "/abs/path/to/uv", - "args": ["run", "--directory", "/abs/path/to/serena", "serena-mcp-server"] + "args": ["run", "--directory", "/abs/path/to/serena", "serena", "start-mcp-server"] } } } @@ -385,7 +384,7 @@ Add the `serena` MCP server configuration, using a [run command](#running-the-se "mcpServers": { "serena": { "command": "/abs/path/to/uvx", - "args": ["--from", "git+https://github.com/oraios/serena", "serena-mcp-server"] + "args": ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"] } } } @@ -396,7 +395,7 @@ Add the `serena` MCP server configuration, using a [run command](#running-the-se "mcpServers": { "serena": { "command": "docker", - "args": ["run", "--rm", "-i", "--network", "host", "-v", "/path/to/your/projects:/workspaces/projects", "ghcr.io/oraios/serena:latest", "serena-mcp-server", "--transport", "stdio"] + "args": ["run", "--rm", "-i", "--network", "host", "-v", "/path/to/your/projects:/workspaces/projects", "ghcr.io/oraios/serena:latest", "serena", "start-mcp-server", "--transport", "stdio"] } } } @@ -578,14 +577,20 @@ When launching Serena, specify modes using `--mode `; multiple modes #### Customization You can create your own contexts and modes to precisely tailor Serena to your needs in two ways: -* **Adding to Serena's configuration directory**: Create new `.yml` files in the `config/contexts/` or `config/modes/` directories within your local Serena repository. These custom contexts/modes will be automatically registered and available for use by their name (filename without the `.yml` extension). They will also appear in listings of available contexts/modes. -* **Using external YAML files**: When starting Serena, you can provide an absolute path to a custom `.yml` file for a context or mode. +* You can use Serena's CLI to manage modes and contexts. Check out + ```shell + uvx --from git+https://github.com/oraios/serena serena mode --help + uvx --from git+https://github.com/oraios/serena serena context --help + ``` + *NOTE*: Custom contexts/modes are simply YAML files in `/.serena`, they are automatically registered and available for use by their name (filename without the `.yml` extension). If you don't want to use Serena's CLI, you can create and manage them in any way you see fit. +* **Using external YAML files**: When starting Serena, you can also provide an absolute path to a custom `.yml` file for a context or mode. A context or mode YAML file typically defines: * `name`: (Optional if filename is used) The name of the context/mode. * `prompt`: A string that will be incorporated into Serena's system prompt. -* `description`: (Optional) A brief description. +* `description`: (Optional) A brief description, not passed to the LLM. * `excluded_tools`: A list of tool names (strings) to disable when this context/mode is active. +* `included_optional_tools`: A list of tool names that are disabled by default and have to be explicitly enabled by the user. This customization allows for deep integration and adaptation of Serena to specific project requirements or personal preferences. @@ -738,11 +743,10 @@ subscriptions. ### Subscription-Based Coding Agents -The most prominent subscription-based coding agents are parts of IDEs like +Many prominent subscription-based coding agents are parts of IDEs like Windsurf, Cursor and VSCode. Serena's functionality is similar to Cursor's Agent, Windsurf's Cascade or -VSCode's -upcoming [agent mode](https://code.visualstudio.com/blogs/2025/02/24/introducing-copilot-agent-mode). +VSCode's agent mode. Serena has the advantage of not requiring a subscription. A potential disadvantage is that it @@ -750,7 +754,7 @@ is not directly integrated into an IDE, so the inspection of newly written code is not as seamless. More technical differences are: -* Serena is not bound to a specific IDE. +* Serena is not bound to a specific IDE or CLI. Serena's MCP server can be used with any MCP client (including some IDEs), and the Agno-based agent provides additional ways of applying its functionality. * Serena is not bound to a specific large language model or API. @@ -826,7 +830,7 @@ For details on contributing, see [here](/CONTRIBUTING.md). ## Full List of Tools -Here is the full list of Serena's tools with a short description (output of `uv run serena-list-tools`): +Here is the full list of Serena's tools with a short description (output of `uv run serena tools list`): * `activate_project`: Activates a project by name. * `check_onboarding_performed`: Checks whether project onboarding was already performed. @@ -840,9 +844,7 @@ Here is the full list of Serena's tools with a short description (output of `uv * `get_active_project`: Gets the name of the currently active project (if any) and lists existing projects * `get_current_config`: Prints the current configuration of the agent, including the active modes, tools, and context. * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file or directory. - * `initial_instructions`: Gets the initial instructions for the current project. - Should only be used in settings where the system prompt cannot be set, - e.g. in clients you have no control over, like Claude Desktop. + * `initial_instructions`: Gets the initial instructions for the current project. Disabled by default, has to be enabled explicitly by using `included_optional_tools` * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. * `insert_at_line`: Inserts content at a given line in a file. * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. diff --git a/docs/serena_on_chatgpt.md b/docs/serena_on_chatgpt.md new file mode 100644 index 00000000..ec872696 --- /dev/null +++ b/docs/serena_on_chatgpt.md @@ -0,0 +1,102 @@ + +# Connecting Serena MCP Server to ChatGPT via MCPO & Cloudflare Tunnel + +This guide explains how to expose a **locally running Serena MCP server** (powered by MCPO) to the internet using **Cloudflare Tunnel**, and how to connect it to **ChatGPT as a Custom GPT with tool access**. + +Once configured, ChatGPT becomes a powerful **coding agent** with direct access to your codebase, shell, and file system — so **read the security notes carefully**. + +--- +## Prerequisites + +Make sure you have [uv](https://docs.astral.sh/uv/getting-started/installation/) +and [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed. + +## 1. Start the Serena MCP Server via MCPO + +Run the following command to launch Serena as http server (assuming port 8000): + +```bash +uvx mcpo --port 8000 --api-key -- \ + uvx --from git+https://github.com/oraios/serena \ + serena start-mcp-server --context chatgpt --project $(pwd) +``` + +- `--api-key` is required to secure the server. +- `--project` should point to the root of your codebase. + +You can also use other options, and you don't have to pass `--project` if you want to work on multiple projects +or want to activate it later. See + +```shell +uvx --from git+https://github.com/oraios/serena serena start-mcp-server --help +``` + +--- + +## 2. Expose the Server Using Cloudflare Tunnel + +Run: + +```bash +cloudflared tunnel --url http://localhost:8000 +``` + +This will give you a **public HTTPS URL** like: + +``` +https://serena-agent-tunnel.trycloudflare.com +``` + +Your server is now securely exposed to the internet. + +--- + +## 3. Connect It to ChatGPT (Custom GPT) + +### Steps: + +1. Go to [ChatGPT → Explore GPTs → Create](https://chat.openai.com/gpts/editor) +2. During setup, click **“Add APIs”** +3. Set up **API Key authentication** with the auth type as **Bearer** and enter the api key you used to start the MCPO server. +4. In the **Schema** section, click on **import from URL** and paste `/openapi.json` with the URL you got from the previous step. +5. Add the following line to the top of the imported JSON schema: + ``` + "servers": ["url": ""], + ``` + **Important**: don't include a trailing slash at the end of the URL! + +ChatGPT will read the schema and create functions automatically. + +--- + +## Security Warning — Read Carefully + +Depending on your configuration and enabled tools, Serena's MCP server may: +- Execute **arbitrary shell commands** +- Read, write, and modify **files in your codebase** + +This gives ChatGPT the same powers as a remote developer on your machine. + +### ⚠️ Key Rules: +- **NEVER expose your API key** +- **Only expose this server when needed**, and monitor its use. + +In your project’s `.serena/project.yml` or global config, you can disable tools like: + +```yaml +excluded_tools: + - execute_shell_command + - ... +read_only: true +``` + +This is strongly recommended if you want a read-only or safer agent. + + +--- + +## Final Thoughts + +With this setup, ChatGPT becomes a coding assistant **running on your local code** — able to index, search, edit, and even run shell commands depending on your configuration. + +Use responsibly, and keep security in mind. diff --git a/pyproject.toml b/pyproject.toml index e79567c2..8872384c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,9 @@ dependencies = [ "pyright>=1.1.396,<2", "overrides>=7.7.0,<8", "python-dotenv>=1.0.0, <2", - "mcp>=1.5.0", + "mcp==1.12.3", "flask>=3.0.0", - "sensai-utils>=1.4.0", + "sensai-utils>=1.5.0", "pydantic>=2.10.6", "types-pyyaml>=6.0.12.20241230", "pyyaml>=6.0.2", diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index 394fe553..d52cfb00 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -9,7 +9,7 @@ from pprint import pprint from serena.agent import SerenaAgent from serena.config.serena_config import SerenaConfig from serena.constants import REPO_ROOT -from serena.tools import FindFileTool, FindReferencingSymbolsTool, SearchForPatternTool +from serena.tools import FindFileTool, FindReferencingSymbolsTool, GetSymbolsOverviewTool, SearchForPatternTool if __name__ == "__main__": agent = SerenaAgent(project=REPO_ROOT, serena_config=SerenaConfig(gui_log_window_enabled=False, web_dashboard=False)) @@ -18,13 +18,9 @@ if __name__ == "__main__": find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) find_file_tool = agent.get_tool(FindFileTool) search_pattern_tool = agent.get_tool(SearchForPatternTool) + overview_tool = agent.get_tool(GetSymbolsOverviewTool) result = agent.execute_task( - lambda: search_pattern_tool.apply( - r"def request_full_.*?\).*?\)", - restrict_search_to_code_files=False, - relative_path="src/solidlsp", - paths_include_glob="**/ls.py", - ) + lambda: overview_tool.apply("src/solidlsp/ls.py"), ) pprint(json.loads(result)) diff --git a/scripts/gen_prompt_factory.py b/scripts/gen_prompt_factory.py index 4b1354fd..4877d4d1 100644 --- a/scripts/gen_prompt_factory.py +++ b/scripts/gen_prompt_factory.py @@ -7,14 +7,14 @@ from pathlib import Path from sensai.util import logging from interprompt import autogenerate_prompt_factory_module -from serena.constants import PROMPT_TEMPLATES_DIR, REPO_ROOT +from serena.constants import PROMPT_TEMPLATES_DIR_INTERNAL, REPO_ROOT log = logging.getLogger(__name__) def main(): autogenerate_prompt_factory_module( - prompts_dir=PROMPT_TEMPLATES_DIR, + prompts_dir=PROMPT_TEMPLATES_DIR_INTERNAL, target_module_path=str(Path(REPO_ROOT) / "src" / "serena" / "generated" / "generated_prompt_factory.py"), ) diff --git a/src/interprompt/multilang_prompt.py b/src/interprompt/multilang_prompt.py index 55777344..e7322efb 100644 --- a/src/interprompt/multilang_prompt.py +++ b/src/interprompt/multilang_prompt.py @@ -1,7 +1,7 @@ import logging import os from enum import Enum -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Literal, TypeVar import yaml from sensai.util.string import ToStringMixin @@ -92,6 +92,9 @@ class _MultiLangContainer(Generic[T], ToStringMixin): raise KeyError(f"Item for language '{lang_code}' already registered for name '{self.name}'") self._lang2item[lang_code] = item + def has_item(self, lang_code: str = DEFAULT_LANG_CODE) -> bool: + return lang_code in self._lang2item + def get_item(self, lang: str = DEFAULT_LANG_CODE, fallback_mode: LanguageFallbackMode = LanguageFallbackMode.EXCEPTION) -> T: """ Gets the item for the given language. @@ -183,6 +186,9 @@ class MultiLangPromptTemplate(ParameterizedTemplateInterface): prompt_template = self.get_prompt_template(lang_code, fallback_mode) return prompt_template.render(**params) + def has_item(self, lang_code: str = DEFAULT_LANG_CODE) -> bool: + return self._prompts_container.has_item(lang_code) + class MultiLangPromptList(_MultiLangContainer[PromptList]): pass @@ -212,44 +218,96 @@ class MultiLangPromptCollection: The prompt names must be unique (for the same language) within the collection. """ - def __init__(self, prompts_dir: str, fallback_mode: LanguageFallbackMode = LanguageFallbackMode.EXCEPTION) -> None: + def __init__(self, prompts_dir: str | list[str], fallback_mode: LanguageFallbackMode = LanguageFallbackMode.EXCEPTION) -> None: """ - :param prompts_dir: the directory containing the prompt templates and prompt lists + :param prompts_dir: the directory containing the prompt templates and prompt lists. + If a list is provided, will look for prompt templates in the dirs from left to right + (first one containing the desired template wins). :param fallback_mode: the fallback mode to use when a prompt template or prompt list is not found for the requested language. May be reset after initialization. """ self._multi_lang_prompt_templates: dict[str, MultiLangPromptTemplate] = {} self._multi_lang_prompt_lists: dict[str, MultiLangPromptList] = {} - self._load_from_disc(prompts_dir) + if isinstance(prompts_dir, str): + prompts_dir = [prompts_dir] + + # Add prompts from multiple directories, prioritizing names from the left. + # If name collisions appear in the first directory, an error is raised (so the first directory should have no + # internal collisions, this helps in avoiding errors) + # For all following directories, on a collision the new value will be ignored. + # This also means that for the following directories, there is no error check on collisions internal to them. + # We assume that they are correct (i.e., they have no internal collisions). + first_prompts_dir, fallback_prompt_dirs = prompts_dir[0], prompts_dir[1:] + self._load_from_disc(first_prompts_dir, on_name_collision="raise") + for fallback_prompt_dir in fallback_prompt_dirs: + # already loaded prompts have priority + self._load_from_disc(fallback_prompt_dir, on_name_collision="skip") + self.fallback_mode = fallback_mode - def _add_prompt_template(self, name: str, template_str: str, lang_code: str = DEFAULT_LANG_CODE) -> None: + def _add_prompt_template( + self, + name: str, + template_str: str, + lang_code: str = DEFAULT_LANG_CODE, + on_name_collision: Literal["skip", "overwrite", "raise"] = "raise", + ) -> None: """ :param name: name of the prompt template :param template_str: the Jinja template string :param lang_code: the language code for which to add the prompt template. + :param on_name_collision: how to deal with name/lang_code collisions """ + allow_overwrite = False prompt_template = PromptTemplate(name, template_str) mlpt = self._multi_lang_prompt_templates.get(name) if mlpt is None: mlpt = MultiLangPromptTemplate(name) self._multi_lang_prompt_templates[name] = mlpt - mlpt.add_prompt_template(prompt_template, lang_code=lang_code) + if mlpt.has_item(lang_code): + if on_name_collision == "raise": + raise KeyError(f"Prompt '{name}' for {lang_code} already exists!") + if on_name_collision == "skip": + log.debug(f"Skipping prompt '{name}' since it already exists.") + return + elif on_name_collision == "overwrite": + allow_overwrite = True + mlpt.add_prompt_template(prompt_template, lang_code=lang_code, allow_overwrite=allow_overwrite) - def _add_prompt_list(self, name: str, prompt_list: list[str], lang_code: str = DEFAULT_LANG_CODE) -> None: + def _add_prompt_list( + self, + name: str, + prompt_list: list[str], + lang_code: str = DEFAULT_LANG_CODE, + on_name_collision: Literal["skip", "overwrite", "raise"] = "raise", + ) -> None: """ :param name: name of the prompt list :param prompt_list: a list of prompts :param lang_code: the language code for which to add the prompt list. + :param on_name_collision: how to deal with name/lang_code collisions """ + allow_overwrite = False multilang_prompt_list = self._multi_lang_prompt_lists.get(name) if multilang_prompt_list is None: multilang_prompt_list = MultiLangPromptList(name) self._multi_lang_prompt_lists[name] = multilang_prompt_list - multilang_prompt_list.add_item(PromptList(prompt_list), lang_code=lang_code) + if multilang_prompt_list.has_item(lang_code): + if on_name_collision == "raise": + raise KeyError(f"Prompt '{name}' for {lang_code} already exists!") + if on_name_collision == "skip": + log.debug(f"Skipping prompt '{name}' since it already exists.") + return + elif on_name_collision == "overwrite": + allow_overwrite = True + multilang_prompt_list.add_item(PromptList(prompt_list), lang_code=lang_code, allow_overwrite=allow_overwrite) - def _load_from_disc(self, prompts_dir: str) -> None: - """Loads all prompt templates and prompt lists from yaml files in the given directory.""" + def _load_from_disc(self, prompts_dir: str, on_name_collision: Literal["skip", "overwrite", "raise"] = "raise") -> None: + """Loads all prompt templates and prompt lists from yaml files in the given directory. + + :param prompts_dir: + :param on_name_collision: how to deal with name/lang_code collisions + """ for fn in os.listdir(prompts_dir): if not fn.endswith((".yml", ".yaml")): log.debug(f"Skipping non-YAML file: {fn}") @@ -266,9 +324,11 @@ class MultiLangPromptCollection: # add the data to the collection for prompt_name, prompt_template_or_list in prompts_data.items(): if isinstance(prompt_template_or_list, list): - self._add_prompt_list(prompt_name, prompt_template_or_list, lang_code=lang_code) + self._add_prompt_list(prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision) elif isinstance(prompt_template_or_list, str): - self._add_prompt_template(prompt_name, prompt_template_or_list, lang_code=lang_code) + self._add_prompt_template( + prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision + ) else: raise ValueError( f"Invalid prompt type for {prompt_name} in file {path} (should be str or list): {prompt_template_or_list}" @@ -306,6 +366,18 @@ class MultiLangPromptCollection: """The PromptList object for the given prompt name and language code.""" return self.get_multilang_prompt_list(prompt_name).get_item(lang_code) + def _has_prompt_list(self, prompt_name: str, lang_code: str = DEFAULT_LANG_CODE) -> bool: + multi_lang_prompt_list = self._multi_lang_prompt_lists.get(prompt_name) + if multi_lang_prompt_list is None: + return False + return multi_lang_prompt_list.has_item(lang_code) + + def _has_prompt_template(self, prompt_name: str, lang_code: str = DEFAULT_LANG_CODE) -> bool: + multi_lang_prompt_template = self._multi_lang_prompt_templates.get(prompt_name) + if multi_lang_prompt_template is None: + return False + return multi_lang_prompt_template.has_item(lang_code) + def render_prompt_template( self, prompt_name: str, diff --git a/src/interprompt/prompt_factory.py b/src/interprompt/prompt_factory.py index 3aa9da08..1769e7f2 100644 --- a/src/interprompt/prompt_factory.py +++ b/src/interprompt/prompt_factory.py @@ -10,9 +10,11 @@ log = logging.getLogger(__name__) class PromptFactoryBase: """Base class for auto-generated prompt factory classes.""" - def __init__(self, prompts_dir: str, lang_code: str = DEFAULT_LANG_CODE, fallback_mode=LanguageFallbackMode.EXCEPTION): + def __init__(self, prompts_dir: str | list[str], lang_code: str = DEFAULT_LANG_CODE, fallback_mode=LanguageFallbackMode.EXCEPTION): """ - :param prompts_dir: the directory containing the prompt templates and prompt lists + :param prompts_dir: the directory containing the prompt templates and prompt lists. + If a list is provided, will look for prompt templates in the dirs from left to right + (first one containing the desired template wins). :param lang_code: the language code to use for retrieving the prompt templates and prompt lists. Leave as `default` for single-language use cases. :param fallback_mode: the fallback mode to use when a prompt template or prompt list is not found for the requested language. diff --git a/src/serena/agent.py b/src/serena/agent.py index fe6a9091..53fead1d 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Any, Optional, TypeVar from sensai.util import logging from sensai.util.logging import LogTime +from interprompt.jinja_template import JinjaTemplate from serena import serena_version from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import RegisteredContext, SerenaAgentContext, SerenaAgentMode @@ -25,7 +26,8 @@ from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition, T from serena.dashboard import SerenaDashboardAPI from serena.project import Project from serena.prompt_factory import SerenaPromptFactory -from serena.tools import ActivateProjectTool, Tool, ToolRegistry +from serena.tools import ActivateProjectTool, Tool, ToolMarker, ToolRegistry +from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler from solidlsp import SolidLanguageServer @@ -91,6 +93,23 @@ class MemoriesManager: return f"Memory {name} deleted." +class AvailableTools: + def __init__(self, tools: list[Tool]): + """ + :param tools: the list of available tools + """ + self.tools = tools + self.tool_names = [tool.get_name_from_cls() for tool in tools] + self.tool_marker_names = set() + for marker_class in iter_subclasses(ToolMarker): + for tool in tools: + if isinstance(tool, marker_class): + self.tool_marker_names.add(marker_class.__name__) + + def __len__(self) -> int: + return len(self.tools) + + class SerenaAgent: def __init__( self, @@ -181,6 +200,8 @@ class SerenaAgent: log.info("Available projects: {}".format(", ".join(self.serena_config.project_names))) log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}") + self._check_shell_settings() + # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), # limited by the Serena config, the context (which is fixed for the session) and JetBrains mode tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context] @@ -190,7 +211,7 @@ class SerenaAgent: tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains")) self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions) - self._exposed_tools = {tc: t for tc, t in self._all_tools.items() if self._base_tool_set.includes_name(t.get_name())} + self._exposed_tools = AvailableTools([t for t in self._all_tools.values() if self._base_tool_set.includes_name(t.get_name())]) log.info(f"Number of exposed tools: {len(self._exposed_tools)}") # create executor for starting the language server and running tools in another thread @@ -225,6 +246,22 @@ class SerenaAgent: except Exception as e: log.error(f"Error activating project '{project}' at startup: {e}", exc_info=e) + def get_context(self) -> SerenaAgentContext: + return self._context + + def get_tool_description_override(self, tool_name: str) -> str | None: + return self._context.tool_description_overrides.get(tool_name, None) + + def _check_shell_settings(self) -> None: + # On Windows, Claude Code sets COMSPEC to Git-Bash (often even with a path containing spaces), + # which causes all sorts of trouble, preventing language servers from being launched correctly. + # So we make sure that COMSPEC is unset if it has been set to bash specifically. + if platform.system() == "Windows": + comspec = os.environ.get("COMSPEC", "") + if "bash" in comspec: + os.environ["COMSPEC"] = "" # force use of default shell + log.info("Adjusting COMSPEC environment variable to use the default shell instead of '%s'", comspec) + def _ide_context_tool_inclusion_definitions(self, project_root_or_name: str | None) -> list[ToolInclusionDefinition]: """ In the IDE assistant context, the agent is assumed to work on a single project, and we thus @@ -289,7 +326,7 @@ class SerenaAgent: If a client should attempt to use a tool that is dynamically disabled (e.g. because a project is activated that disables it), it will receive an error. """ - return list(self._exposed_tools.values()) + return list(self._exposed_tools.tools) def get_active_project(self) -> Project | None: """ @@ -323,11 +360,21 @@ class SerenaAgent: """ return list(self._modes) + def _format_prompt(self, prompt_template: str) -> str: + template = JinjaTemplate(prompt_template) + return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names) + def create_system_prompt(self) -> str: - return self.prompt_factory.create_system_prompt( - context_system_prompt=self._context.prompt, - mode_system_prompts=[mode.prompt for mode in self._modes], + available_markers = self._exposed_tools.tool_marker_names + log.info("Generating system prompt with available_tools=(see exposed tools), available_markers=%s", available_markers) + system_prompt = self.prompt_factory.create_system_prompt( + context_system_prompt=self._format_prompt(self._context.prompt), + mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self._modes], + available_tools=self._exposed_tools.tool_names, + available_markers=available_markers, ) + log.info("System prompt:\n%s", system_prompt) + return system_prompt def _update_active_tools(self) -> None: """ @@ -571,3 +618,7 @@ class SerenaAgent: if self._gui_log_viewer: log.info("Stopping the GUI log window ...") self._gui_log_viewer.stop() + + def get_tool_by_name(self, tool_name: str) -> Tool: + tool_class = ToolRegistry().get_tool_class_by_name(tool_name) + return self.get_tool(tool_class) diff --git a/src/serena/cli.py b/src/serena/cli.py index 7591e0ca..457b3acb 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -1,3 +1,4 @@ +import glob import os import shutil import subprocess @@ -16,6 +17,8 @@ from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths from serena.constants import ( DEFAULT_CONTEXT, DEFAULT_MODES, + PROMPT_TEMPLATES_DIR_IN_USER_HOME, + PROMPT_TEMPLATES_DIR_INTERNAL, SERENA_LOG_FORMAT, SERENA_MANAGED_DIR_IN_HOME, SERENAS_OWN_CONTEXT_YAMLS_DIR, @@ -23,7 +26,7 @@ from serena.constants import ( USER_CONTEXT_YAMLS_DIR, USER_MODE_YAMLS_DIR, ) -from serena.mcp import SerenaMCPFactorySingleProcess +from serena.mcp import SerenaMCPFactory, SerenaMCPFactorySingleProcess from serena.project import Project from serena.tools import ToolRegistry from serena.util.logging import MemoryLogHandler @@ -435,7 +438,7 @@ class ProjectCommands(AutoRegisteringGroup): @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) @click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING") def index_deprecated(project: str, log_level: str = "WARNING") -> None: - click.echo("Deprecated! Use `project index` instead.") + click.echo("Deprecated! Use `serena project index` instead.") ProjectCommands._index_project(project, log_level) @staticmethod @@ -475,6 +478,113 @@ class ToolCommands(AutoRegisteringGroup): else: ToolRegistry().print_tool_overview() + @staticmethod + @click.command( + "description", + help="Print the description of a tool, optionally with a specific context (the latter may modify the default description).", + ) + @click.argument("tool_name", type=str) + @click.option("--context", type=str, default=None, help="Context name or path to context file.") + def description(tool_name: str, context: str | None = None) -> None: + # Load the context + serena_context = None + if context: + serena_context = SerenaAgentContext.load(context) + + agent = SerenaAgent( + project=None, + serena_config=SerenaConfig(web_dashboard=False, log_level=logging.INFO), + context=serena_context, + ) + tool = agent.get_tool_by_name(tool_name) + mcp_tool = SerenaMCPFactory.make_mcp_tool(tool) + click.echo(mcp_tool.description) + + +class PromptCommands(AutoRegisteringGroup): + def __init__(self) -> None: + super().__init__(name="prompts", help="Commands related to Serena's prompts that are outside of contexts and modes.") + + @staticmethod + def _get_user_prompt_yaml_path(prompt_yaml_name: str) -> str: + os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) + return os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, prompt_yaml_name) + + @staticmethod + @click.command("list", help="Lists yamls that are used for defining prompts.") + def list() -> None: + serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] + for prompt_yaml_name in serena_prompt_yaml_names: + user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) + if os.path.exists(user_prompt_yaml_path): + click.echo(f"{user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}") + else: + click.echo(prompt_yaml_name) + + @staticmethod + @click.command("create-override", help="Create an override of an internal prompts yaml for customizing Serena's prompts") + @click.argument("prompt_yaml_name") + def create_override(prompt_yaml_name: str) -> None: + """ + :param prompt_yaml_name: The yaml name of the prompt you want to override. Call the `list` command for discovering valid prompt yaml names. + :return: + """ + # for convenience, we can pass names without .yml + if not prompt_yaml_name.endswith(".yml"): + prompt_yaml_name = prompt_yaml_name + ".yml" + user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) + if os.path.exists(user_prompt_yaml_path): + raise FileExistsError(f"{user_prompt_yaml_path} already exists.") + serena_prompt_yaml_path = os.path.join(PROMPT_TEMPLATES_DIR_INTERNAL, prompt_yaml_name) + shutil.copyfile(serena_prompt_yaml_path, user_prompt_yaml_path) + _open_in_editor(user_prompt_yaml_path) + + @staticmethod + @click.command("edit-override", help="Edit an existing prompt override file") + @click.argument("prompt_yaml_name") + def edit_override(prompt_yaml_name: str) -> None: + """ + :param prompt_yaml_name: The yaml name of the prompt override to edit. + :return: + """ + # for convenience, we can pass names without .yml + if not prompt_yaml_name.endswith(".yml"): + prompt_yaml_name = prompt_yaml_name + ".yml" + user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) + if not os.path.exists(user_prompt_yaml_path): + click.echo(f"Override file '{prompt_yaml_name}' not found. Create it with: prompts create-override {prompt_yaml_name}") + return + _open_in_editor(user_prompt_yaml_path) + + @staticmethod + @click.command("list-overrides", help="List existing prompt override files") + def list_overrides() -> None: + os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) + serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] + override_files = glob.glob(os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, "*.yml")) + for file_path in override_files: + if os.path.basename(file_path) in serena_prompt_yaml_names: + click.echo(file_path) + + @staticmethod + @click.command("delete-override", help="Delete a prompt override file") + @click.argument("prompt_yaml_name") + def delete_override(prompt_yaml_name: str) -> None: + """ + + :param prompt_yaml_name: The yaml name of the prompt override to delete." + :return: + """ + # for convenience, we can pass names without .yml + if not prompt_yaml_name.endswith(".yml"): + prompt_yaml_name = prompt_yaml_name + ".yml" + user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) + if not os.path.exists(user_prompt_yaml_path): + click.echo(f"Override file '{prompt_yaml_name}' not found.") + return + os.remove(user_prompt_yaml_path) + click.echo(f"Deleted override file '{prompt_yaml_name}'.") + # Expose groups so we can reference them in pyproject.toml mode = ModeCommands() @@ -482,6 +592,7 @@ context = ContextCommands() project = ProjectCommands() config = SerenaConfigCommands() tools = ToolCommands() +prompts = PromptCommands() # Expose toplevel commands for the same reason top_level = TopLevelCommands() @@ -489,7 +600,7 @@ start_mcp_server = top_level.start_mcp_server index_project = project.index_deprecated # needed for the help script to work - register all subcommands to the top-level group -for subgroup in (mode, context, project, config, tools): +for subgroup in (mode, context, project, config, tools, prompts): top_level.add_command(subgroup) diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index de84f6a7..44cd42fb 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -3,8 +3,7 @@ Context and Mode configuration loader """ import os -from copy import copy -from dataclasses import asdict, dataclass +from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Self @@ -39,22 +38,15 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): name: str prompt: str + """ + a Jinja2 template for the generation of the system prompt. + It is formatted by the agent (see SerenaAgent._format_prompt()). + """ description: str = "" def _tostring_includes(self) -> list[str]: return ["name"] - def to_json_dict(self) -> dict[str, str | list[str]]: - result = asdict(self) - result["excluded_tools"] = list(result["excluded_tools"]) - return result - - @classmethod - def from_json_dict(cls, data: dict) -> Self: - data = copy(data) - data["excluded_tools"] = set(data["excluded_tools"]) - return cls(**data) - def print_overview(self) -> None: """Print an overview of the mode.""" print(f"{self.name}:\n {self.description}") @@ -133,28 +125,26 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): name: str prompt: str + """ + a Jinja2 template for the generation of the system prompt. + It is formatted by the agent (see SerenaAgent._format_prompt()). + """ description: str = "" + tool_description_overrides: dict[str, str] = field(default_factory=dict) + """Maps tool names to custom descriptions, default descriptions are extracted from the tool docstrings.""" def _tostring_includes(self) -> list[str]: return ["name"] - def to_json_dict(self) -> dict[str, str | list[str]]: - result = asdict(self) - result["excluded_tools"] = list(result["excluded_tools"]) - return result - - @classmethod - def from_json_dict(cls, data: dict) -> Self: - data = copy(data) - data["excluded_tools"] = set(data["excluded_tools"]) - return cls(**data) - @classmethod def from_yaml(cls, yaml_path: str | Path) -> Self: """Load a context from a YAML file.""" with open(yaml_path, encoding="utf-8") as f: data = yaml.safe_load(f) name = data.pop("name", Path(yaml_path).stem) + # Ensure backwards compatibility for tool_description_overrides + if "tool_description_overrides" not in data: + data["tool_description_overrides"] = {} return cls(name=name, **data) @classmethod diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 839f2f40..41ef6433 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -47,13 +47,13 @@ class SerenaPaths: Provides paths to various Serena-related directories and files. """ - def __init__(self): + def __init__(self) -> None: self.user_config_dir: str = SERENA_MANAGED_DIR_IN_HOME """ the path to the user's Serena configuration directory, which is typically ~/.serena """ - def get_next_log_file_path(self, prefix: str): + def get_next_log_file_path(self, prefix: str) -> str: """ :param prefix: the filename prefix indicating the type of the log file :return: the full path to the log file to use diff --git a/src/serena/constants.py b/src/serena/constants.py index 9b0dd43f..d25f5c45 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -10,7 +10,8 @@ SERENA_MANAGED_DIR_IN_HOME = str(_serena_in_home_managed_dir) # TODO: Path-related constants should be moved to SerenaPaths; don't add further constants here. REPO_ROOT = str(_repo_root_path) -PROMPT_TEMPLATES_DIR = str(_serena_pkg_path / "resources" / "config" / "prompt_templates") +PROMPT_TEMPLATES_DIR_INTERNAL = str(_serena_pkg_path / "resources" / "config" / "prompt_templates") +PROMPT_TEMPLATES_DIR_IN_USER_HOME = str(_serena_in_home_managed_dir / "prompt_templates") SERENAS_OWN_CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts") """The contexts that are shipped with the Serena package, i.e. the default contexts.""" USER_CONTEXT_YAMLS_DIR = str(_serena_in_home_managed_dir / "contexts") diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 6bb7a76b..3a5e1442 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -32,5 +32,7 @@ class PromptFactory(PromptFactoryBase): def create_prepare_for_new_conversation(self) -> str: return self._render_prompt("prepare_for_new_conversation", locals()) - def create_system_prompt(self, *, context_system_prompt: Any, mode_system_prompts: Any) -> str: + def create_system_prompt( + self, *, available_markers: Any, available_tools: Any, context_system_prompt: Any, mode_system_prompts: Any + ) -> str: return self._render_prompt("system_prompt", locals()) diff --git a/src/serena/mcp.py b/src/serena/mcp.py index ff8a11ba..fb42cbe9 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -68,10 +68,17 @@ class SerenaMCPFactory: # Mount the tool description as a combination of the docstring description and # the return value description, if it exists. - if docstring.description: - func_doc = f"{docstring.description.strip().strip('.')}." + overridden_description = tool.agent.get_context().tool_description_overrides.get(func_name, None) + + if overridden_description is not None: + func_doc = overridden_description + elif docstring.description: + func_doc = docstring.description else: func_doc = "" + func_doc = func_doc.strip().strip(".") + if func_doc: + func_doc += "." if docstring.returns and (docstring_returns_descr := docstring.returns.description): # Only add a space before "Returns" if func_doc is not empty prefix = " " if func_doc else "" @@ -113,6 +120,7 @@ class SerenaMCPFactory: for tool in self._iter_tools(): mcp_tool = self.make_mcp_tool(tool) mcp._tool_manager._tools[tool.get_name()] = mcp_tool + log.info(f"Starting MCP server with {len(mcp._tool_manager._tools)} tools: {list(mcp._tool_manager._tools.keys())}") @abstractmethod def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None: @@ -170,9 +178,8 @@ class SerenaMCPFactory: # `.env` files (e.g. containing LOG_LEVEL) that are not supposed to override the MCP settings; # retain only FASTMCP_ prefix for already set environment variables. Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") - - mcp_settings: Settings = Settings(lifespan=self.server_lifespan, host=host, port=port) - mcp = FastMCP(**mcp_settings.model_dump()) + instructions = self._get_initial_instructions() + mcp = FastMCP(lifespan=self.server_lifespan, host=host, port=port, instructions=instructions) return mcp @asynccontextmanager @@ -181,6 +188,10 @@ class SerenaMCPFactory: """Manage server startup and shutdown lifecycle.""" yield None # ensures MyPy understands we yield None + @abstractmethod + def _get_initial_instructions(self) -> str: + pass + class SerenaMCPFactorySingleProcess(SerenaMCPFactory): """ @@ -207,6 +218,11 @@ class SerenaMCPFactorySingleProcess(SerenaMCPFactory): assert self.agent is not None yield from self.agent.get_exposed_tool_instances() + def _get_initial_instructions(self) -> str: + assert self.agent is not None + # we don't use the tool (which at the time of writing calls this method), since the tool may be disabled by the config + return self.agent.create_system_prompt() + @asynccontextmanager async def server_lifespan(self, mcp_server: FastMCP) -> AsyncIterator[None]: self._set_mcp_tools(mcp_server) diff --git a/src/serena/project.py b/src/serena/project.py index b73a252d..d6a7ba79 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -5,11 +5,13 @@ from pathlib import Path import pathspec from serena.config.serena_config import DEFAULT_TOOL_TIMEOUT, ProjectConfig +from serena.constants import SERENA_MANAGED_DIR_IN_HOME from serena.text_utils import MatchedConsecutiveLines, search_files from serena.util.file_system import GitignoreParser, match_path from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -271,4 +273,5 @@ class Project: ls_logger, self.project_root, timeout=ls_timeout, + solidlsp_settings=SolidLSPSettings(solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME), ) diff --git a/src/serena/prompt_factory.py b/src/serena/prompt_factory.py index f4f02ca7..40f51e7a 100644 --- a/src/serena/prompt_factory.py +++ b/src/serena/prompt_factory.py @@ -1,4 +1,6 @@ -from serena.constants import PROMPT_TEMPLATES_DIR +import os + +from serena.constants import PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL from serena.generated.generated_prompt_factory import PromptFactory @@ -8,4 +10,5 @@ class SerenaPromptFactory(PromptFactory): """ def __init__(self) -> None: - super().__init__(PROMPT_TEMPLATES_DIR) + os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) + super().__init__(prompts_dir=[PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL]) diff --git a/src/serena/resources/config/contexts/agent.yml b/src/serena/resources/config/contexts/agent.yml index d3da9da9..e2984283 100644 --- a/src/serena/resources/config/contexts/agent.yml +++ b/src/serena/resources/config/contexts/agent.yml @@ -3,4 +3,6 @@ prompt: | You are running in agent context where the system prompt is provided externally. You should use symbolic tools when possible for code understanding and modification. excluded_tools: - - initial_instructions \ No newline at end of file + - initial_instructions + +tool_description_overrides: {} \ No newline at end of file diff --git a/src/serena/resources/config/contexts/chatgpt.yml b/src/serena/resources/config/contexts/chatgpt.yml new file mode 100644 index 00000000..c758b2c3 --- /dev/null +++ b/src/serena/resources/config/contexts/chatgpt.yml @@ -0,0 +1,28 @@ +description: A configuration specific for chatgpt, which has a limit of 30 tools and requires short descriptions. +prompt: | + You are running in desktop app context where the tools give you access to the code base as well as some + access to the file system, if configured. You interact with the user through a chat interface that is separated + from the code base. As a consequence, if you are in interactive mode, your communication with the user should + involve high-level thinking and planning as well as some summarization of any code edits that you make. + For viewing the code edits the user will view them in a separate code editor window, and the back-and-forth + between the chat and the code editor should be minimized as well as facilitated by you. + If complex changes have been made, advise the user on how to review them in the code editor. + If complex relationships that the user asked for should be visualized or explained, consider creating + a diagram in addition to your text-based communication. Note that in the chat interface you have various rendering + options for text, html, and mermaid diagrams, as has been explained to you in your initial instructions. +excluded_tools: [] +included_optional_tools: + - switch_modes + +tool_description_overrides: + find_symbol: | + Retrieves symbols matching `name_path` in a file. + Use `depth > 0` to include children. `name_path` can be: "foo": any symbol named "foo"; "foo/bar": "bar" within "foo"; "/foo/bar": only top-level "foo/bar" + replace_regex: | + Replaces text using regular expressions. Preferred for smaller edits where symbol-level tools aren't appropriate. + Use wildcards (.*?) to match large sections efficiently: "beginning.*?end" instead of specifying exact content. + Essential for multi-line replacements. + search_for_pattern: | + Flexible pattern search across codebase. Prefer symbolic operations when possible. + Uses DOTALL matching. Use non-greedy quantifiers (.*?) to avoid over-matching. + Supports file filtering via globs and code-only restriction. \ No newline at end of file diff --git a/src/serena/resources/config/contexts/context.template.yml b/src/serena/resources/config/contexts/context.template.yml index 52e94535..c01a1177 100644 --- a/src/serena/resources/config/contexts/context.template.yml +++ b/src/serena/resources/config/contexts/context.template.yml @@ -1,4 +1,11 @@ # See Serena's documentation for more details on concept of contexts. description: Description of the context, not used in the code. prompt: Prompt that will form part of the system prompt/initial instructions for agents started in this context. -excluded_tools: [] \ No newline at end of file +excluded_tools: [] + +# several tools are excluded by default and have to be explicitly included by the user +included_optional_tools: [] + +# mapping of tool names to an override of their descriptions (the default description is the docstring of the Tool's apply method). +# Sometimes, tool descriptions are too long (e.g., for ChatGPT), or users may want to override them for another reason. +tool_description_overrides: {} \ No newline at end of file diff --git a/src/serena/resources/config/contexts/desktop-app.yml b/src/serena/resources/config/contexts/desktop-app.yml index ea1567fb..c3adcfb4 100644 --- a/src/serena/resources/config/contexts/desktop-app.yml +++ b/src/serena/resources/config/contexts/desktop-app.yml @@ -10,4 +10,8 @@ prompt: | If complex relationships that the user asked for should be visualized or explained, consider creating a diagram in addition to your text-based communication. Note that in the chat interface you have various rendering options for text, html, and mermaid diagrams, as has been explained to you in your initial instructions. -excluded_tools: [] \ No newline at end of file +excluded_tools: [] +included_optional_tools: + - switch_modes + +tool_description_overrides: {} diff --git a/src/serena/resources/config/contexts/ide-assistant.yml b/src/serena/resources/config/contexts/ide-assistant.yml index d40cb860..27a13673 100644 --- a/src/serena/resources/config/contexts/ide-assistant.yml +++ b/src/serena/resources/config/contexts/ide-assistant.yml @@ -19,7 +19,8 @@ prompt: | excluded_tools: - create_text_file - read_file - - delete_lines - - replace_lines - - insert_at_line - - execute_shell_command \ No newline at end of file + - execute_shell_command + - prepare_for_new_conversation + +tool_description_overrides: {} + diff --git a/src/serena/resources/config/modes/editing.yml b/src/serena/resources/config/modes/editing.yml index d53750fd..464f4537 100644 --- a/src/serena/resources/config/modes/editing.yml +++ b/src/serena/resources/config/modes/editing.yml @@ -14,7 +14,7 @@ prompt: | use the regex-based approach that is described below. Let us first discuss the symbol-based approach. - Symbols are identified by their name path and relative file path, see the description of the `find_symbols` tool for more details + Symbols are identified by their name path and relative file path, see the description of the `find_symbol` tool for more details on how the `name_path` matches symbols. You can get information about available symbols by using the `get_symbols_overview` tool for finding top-level symbols in a file or directory, or by using `find_symbol` if you already know the symbol's name path. You generally try to read as little code as possible @@ -34,7 +34,8 @@ prompt: | You will generally be able to use the info from the snippets and the regex-based approach to adjust the references as well. You can assume that all symbol editing tools are reliable, so you don't need to verify the results if the tool returns without error. - Now let us discuss the regex-based approach. + {% if 'replace_regex' in available_tools %} + Let us discuss the regex-based approach. The regex-based approach is your primary tool for editing code whenever replacing or deleting a whole symbol would be a more expensive operation. This is the case if you need to adjust just a few lines of code within a method, or a chunk that is much smaller than a whole symbol. You use other tools to find the relevant content and @@ -104,7 +105,8 @@ prompt: | Generally, I remind you that you rely on the regex tool with providing you the correct feedback, no need for more verification! IMPORTANT: REMEMBER TO USE WILDCARDS WHEN APPROPRIATE! I WILL BE VERY UNHAPPY IF YOU WRITE LONG REGEXES WITHOUT USING WILDCARDS INSTEAD! -excluded_tools: + {% endif %} +excluded_tools: - replace_lines - insert_at_line - delete_lines diff --git a/src/serena/resources/config/modes/mode.template.yml b/src/serena/resources/config/modes/mode.template.yml index 431afdeb..6d836ca3 100644 --- a/src/serena/resources/config/modes/mode.template.yml +++ b/src/serena/resources/config/modes/mode.template.yml @@ -2,3 +2,6 @@ description: Description of the mode, not used in the code. prompt: Prompt that will form part of the message sent to the model when activating this mode. excluded_tools: [] + +# several tools are excluded by default and have to be explicitly included by the user +included_optional_tools: [] \ No newline at end of file diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index 8f3dd504..e912f7ca 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -5,32 +5,38 @@ prompts: system_prompt: | You are a professional coding agent concerned with one particular codebase. You have access to semantic coding tools on which you rely heavily for all your work, as well as collection of memory - files containing general information about the codebase. You operate in a frugal and intelligent manner, always + files containing general information about the codebase. You operate in a resource-efficient and intelligent manner, always keeping in mind to not read or generate content that is not needed for the task at hand. When reading code in order to answer a user question or task, you should try reading only the necessary code. Some tasks may require you to understand the architecture of large parts of the codebase, while for others, it may be enough to read a small set of symbols or a single file. Generally, you should avoid reading entire files unless it is absolutely necessary, instead relying on - intelligent step-by-step acquisition of information. However, if you already read a file, it does not make + intelligent step-by-step acquisition of information. {% if 'ToolMarkerSymbolicRead' in available_markers %}However, if you already read a file, it does not make sense to further analyse it with the symbolic tools (except for the `find_referencing_symbols` tool), - as you already have the information. + as you already have the information.{% endif %} - I WILL BE SERIOUSLY UPSET IF YOU READ ENTIRE FILES WITHOUT NEED! CONSIDER INSTEAD USING THE OVERVIEW TOOL AND - SYMBOLIC TOOLS TO READ ONLY THE NECESSARY CODE FIRST! + I WILL BE SERIOUSLY UPSET IF YOU READ ENTIRE FILES WITHOUT NEED! + {% if 'ToolMarkerSymbolicRead' in available_markers %} + CONSIDER INSTEAD USING THE OVERVIEW TOOL AND SYMBOLIC TOOLS TO READ ONLY THE NECESSARY CODE FIRST! I WILL BE EVEN MORE UPSET IF AFTER HAVING READ AN ENTIRE FILE YOU KEEP READING THE SAME CONTENT WITH THE SYMBOLIC TOOLS! THE PURPOSE OF THE SYMBOLIC TOOLS IS TO HAVE TO READ LESS CODE, NOT READ THE SAME CONTENT MULTIPLE TIMES! + {% endif %} You can achieve the intelligent reading of code by using the symbolic tools for getting an overview of symbols and the relations between them, and then only reading the bodies of symbols that are necessary to answer the question - or complete the task. You can also use the standard tools like list_dir, find_file and search_for_pattern if you need to. + or complete the task. + You can use the standard tools like list_dir, find_file and search_for_pattern if you need to. When tools allow it, you pass the `relative_path` parameter to restrict the search to a specific file or directory. For some tools, `relative_path` can only be a file path, so make sure to properly read the tool descriptions. - If you are unsure about a symbol's name or location (to the extent that substring_matching for the symbol name is not enough), you can use the `search_for_pattern` tool, which allows fast - and flexible search for patterns in the codebase. This way you can first find candidates for symbols or files, - and then proceed with the symbolic tools. + {% if 'search_for_pattern' in available_tools %} + If you are unsure about a symbol's name or location{% if 'find_symbol' in available_tools %} (to the extent that substring_matching for the symbol name is not enough){% endif %}, you can use the `search_for_pattern` tool, which allows fast + and flexible search for patterns in the codebase.{% if 'ToolMarkerSymbolicRead' in available_markers %}This way you can first find candidates for symbols or files, + and then proceed with the symbolic tools.{% endif %} + {% endif %} - Symbols are identified by their `name_path and `relative_path`, see the description of the `find_symbols` tool for more details + {% if 'ToolMarkerSymbolicRead' in available_markers %} + Symbols are identified by their `name_path and `relative_path`, see the description of the `find_symbol` tool for more details on how the `name_path` matches symbols. You can get information about available symbols by using the `get_symbols_overview` tool for finding top-level symbols in a file or directory, or by using `find_symbol` if you already know the symbol's name path. You generally try to read as little code as possible @@ -40,10 +46,13 @@ prompts: you can use `find_symbol` with the name path `Foo`, `include_body=False` and `depth=1` to get all (top-level) methods of `Foo` before proceeding to read the desired methods with `include_body=True` You can understand relationships between symbols by using the `find_referencing_symbols` tool. + {% endif %} + {% if 'read_memory' in available_tools %} You generally have access to memories and it may be useful for you to read them, but also only if they help you to answer the question or complete the task. You can infer which memories are relevant to the current task by reading the memory names and descriptions. + {% endif %} The context and modes of operation are described below. From them you can infer how to interact with your user and which tasks and kinds of interactions are expected of you. diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index b57226aa..318d0c16 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -1,5 +1,6 @@ class LogMessage { constructor(message, toolNames) { + message = this.escapeHtml(message); const logLevel = this.determineLogLevel(message); const highlightedMessage = this.highlightToolNames(message, toolNames); this.$elem = $('
').addClass('log-' + logLevel).html(highlightedMessage + '\n'); @@ -27,6 +28,21 @@ class LogMessage { }); return highlightedMessage; } + + escapeHtml (convertString) { + if (typeof convertString !== 'string') return convertString; + + const patterns = { + '<' : '<', + '>' : '>', + '&' : '&', + '"' : '"', + '\'' : ''', + '`' : '`' + }; + + return convertString.replace(/[<>&"'`]/g, match => patterns[match]); + }; } class Dashboard { diff --git a/src/serena/tools/cmd_tools.py b/src/serena/tools/cmd_tools.py index 0db0da08..214d54e2 100644 --- a/src/serena/tools/cmd_tools.py +++ b/src/serena/tools/cmd_tools.py @@ -19,15 +19,8 @@ class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit): max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH, ) -> str: """ - Execute a shell command and return its output. - - IMPORTANT: you should always consider the memory about suggested shell commands before using this tool. - If this memory was not loaded in the current conversation, you should load it using the `read_memory` tool - before using this tool. - - You should have at least once looked at the suggested shell commands from the corresponding memory - created during the onboarding process before using this tool. - Never execute unsafe shell commands like `rm -rf /` or similar! Generally be very careful with deletions. + Execute a shell command and return its output. If there is a memory about suggested commands, read that first. + Never execute unsafe shell commands like `rm -rf /` or similar! :param command: the shell command to execute :param cwd: the working directory to execute the command in. If None, the project root will be used. diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py index 51b782ad..8a3ddeb7 100644 --- a/src/serena/tools/config_tools.py +++ b/src/serena/tools/config_tools.py @@ -1,7 +1,7 @@ import json from serena.config.context_mode import SerenaAgentMode -from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject +from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): @@ -35,7 +35,7 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): return result_str -class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): +class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): """ Removes a project from the Serena configuration. """ @@ -50,7 +50,7 @@ class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): return f"Successfully removed project '{project_name}' from configuration." -class SwitchModesTool(Tool): +class SwitchModesTool(Tool, ToolMarkerOptional): """ Activates modes by providing a list of their names """ @@ -71,7 +71,7 @@ class SwitchModesTool(Tool): return result_str -class GetCurrentConfigTool(Tool): +class GetCurrentConfigTool(Tool, ToolMarkerOptional): """ Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. """ diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index f79ffe06..b5770ab5 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -14,7 +14,7 @@ from fnmatch import fnmatch from pathlib import Path from serena.text_utils import search_files -from serena.tools import SUCCESS_RESULT, TOOL_DEFAULT_MAX_ANSWER_LENGTH, EditedFileContext, Tool, ToolMarkerCanEdit +from serena.tools import SUCCESS_RESULT, TOOL_DEFAULT_MAX_ANSWER_LENGTH, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional from serena.util.file_system import scan_directory @@ -29,7 +29,6 @@ class ReadFileTool(Tool): """ Reads the given file or a chunk of it. Generally, symbolic operations like find_symbol or find_referencing_symbols should be preferred if you know which symbols you are looking for. - Reading the entire file is only recommended if there is no other way to get the content required for the task. :param relative_path: the relative path to the file to read :param start_line: the 0-based index of the first line to be retrieved. @@ -60,14 +59,7 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit): def apply(self, relative_path: str, content: str) -> str: """ - Write a new file (or overwrite an existing file). For existing files, it is strongly recommended - to use symbolic operations like replace_symbol_body or insert_after_symbol/insert_before_symbol, if possible. - You can also use insert_at_line to insert content at a specific line for existing files if the symbolic operations - are not the right choice for what you want to do. - - If ever used on an existing file, the content has to be the complete content of that file (so it - may never end with something like "The remaining content of the file is left unchanged."). - For operations that just replace a part of a file, use the replace_lines or the symbolic editing tools instead. + Write a new file or overwrite an existing file. :param relative_path: the relative path to the file to create :param content: the (utf-8-encoded) content to write to the file @@ -200,7 +192,7 @@ class ReplaceRegexTool(Tool, ToolMarkerCanEdit): return SUCCESS_RESULT -class DeleteLinesTool(Tool, ToolMarkerCanEdit): +class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): """ Deletes a range of lines within a file. """ @@ -228,7 +220,7 @@ class DeleteLinesTool(Tool, ToolMarkerCanEdit): return SUCCESS_RESULT -class ReplaceLinesTool(Tool, ToolMarkerCanEdit): +class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): """ Replaces a range of lines within a file with new content. """ @@ -259,7 +251,7 @@ class ReplaceLinesTool(Tool, ToolMarkerCanEdit): return SUCCESS_RESULT -class InsertAtLineTool(Tool, ToolMarkerCanEdit): +class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): """ Inserts content at a given line in a file. """ @@ -351,7 +343,7 @@ class SearchForPatternTool(Tool): For example, for finding classes or methods from a name pattern. Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files, which is why it is the default. - :return: A JSON object mapping file paths to lists of matched consecutive lines (with context, if requested). + :return: A mapping of file paths to lists of matched consecutive lines. """ abs_path = os.path.join(self.get_project_root(), relative_path) if not os.path.exists(abs_path): diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 319aa590..8b9f2d1a 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -1,10 +1,10 @@ import json -from serena.tools import TOOL_DEFAULT_MAX_ANSWER_LENGTH, Tool, ToolMarkerOptional +from serena.tools import TOOL_DEFAULT_MAX_ANSWER_LENGTH, Tool, ToolMarkerOptional, ToolMarkerSymbolicRead from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient -class JetBrainsFindSymbolTool(Tool, ToolMarkerOptional): +class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). """ @@ -70,7 +70,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerOptional): return self._limit_length(result, max_answer_chars) -class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerOptional): +class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ Finds symbols that reference the given symbol """ @@ -100,7 +100,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerOptional): return self._limit_length(result, max_answer_chars) -class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerOptional): +class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): """ Retrieves an overview of the top-level symbols within a specified file """ diff --git a/src/serena/tools/memory_tools.py b/src/serena/tools/memory_tools.py index 63bba4fe..3cf611f0 100644 --- a/src/serena/tools/memory_tools.py +++ b/src/serena/tools/memory_tools.py @@ -10,15 +10,8 @@ class WriteMemoryTool(Tool): def apply(self, memory_name: str, content: str, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str: """ - Write some information about this project that can be useful for future tasks to a memory. - Use markdown formatting for the content. - The information should be short and to the point. - The memory name should be meaningful, such that from the name you can infer what the information is about. - It is better to have multiple small memories than to have a single large one because - memories will be read one by one and we only ever want to read relevant memories. - - This tool is either called during the onboarding process or when you have identified - something worth remembering about the project from the past conversation. + Write some information about this project that can be useful for future tasks to a memory in md format. + The memory name should be meaningful. """ if len(content) > max_answer_chars: raise ValueError( diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 4d2a5ffc..0c9b7868 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -4,11 +4,18 @@ Language server-related tools import dataclasses import json +import os from collections.abc import Sequence from copy import copy from typing import Any -from serena.tools import SUCCESS_RESULT, TOOL_DEFAULT_MAX_ANSWER_LENGTH, Tool, ToolMarkerCanEdit +from serena.tools import ( + SUCCESS_RESULT, + TOOL_DEFAULT_MAX_ANSWER_LENGTH, + Tool, + ToolMarkerSymbolicEdit, + ToolMarkerSymbolicRead, +) from solidlsp.ls_types import SymbolKind @@ -33,44 +40,45 @@ class RestartLanguageServerTool(Tool): def apply(self) -> str: """Use this tool only on explicit user request or after confirmation. - It may be necessary to restart the language server if the user performs edits - not through Serena, so the language server state becomes outdated and further editing attempts lead to errors. - - If such editing errors happen, you should suggest using this tool. + It may be necessary to restart the language server if it hangs. """ self.agent.reset_language_server() return SUCCESS_RESULT -class GetSymbolsOverviewTool(Tool): +class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): """ Gets an overview of the top-level symbols defined in a given file or directory. """ def apply(self, relative_path: str, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str: """ - Gets an overview of the given file or directory. - For each analyzed file, we list the top-level symbols in the file (name_path, kind). - Use this tool to get a high-level understanding of the code symbols. - Calling this is often a good idea before more targeted reading, searching or editing operations on the code symbols. - Before requesting a symbol overview, it is usually a good idea to narrow down the scope of the overview - by first understanding the basic directory structure of the repository that you can get from memories - or by using the `list_dir` and `find_file` tools (or similar). + Use this tool to get a high-level understanding of the code symbols in a file. + This should be the first tool to call when you want to understand a new file, unless you already know + what you are looking for. :param relative_path: the relative path to the file or directory to get the overview of :param max_answer_chars: if the overview is longer than this number of characters, no content will be returned. Don't adjust unless there is really no other way to get the content required for the task. If the overview is too long, you should use a smaller directory instead, (e.g. a subdirectory). - :return: a JSON object mapping relative paths of all contained files to info about top-level symbols in the file (name_path, kind). + :return: a JSON object containing info about top-level symbols in the file """ symbol_retriever = self.create_language_server_symbol_retriever() - result = symbol_retriever.get_symbol_overview(relative_path) - result_json_str = json.dumps({k: [dataclasses.asdict(i) for i in l] for k, l in result.items()}) + file_path = os.path.join(self.project.project_root, relative_path) + + # The symbol overview is capable of working with both files and directories, + # but we want to ensure that the user provides a file path. + if not os.path.exists(file_path): + raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.") + if os.path.isdir(file_path): + raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ") + result = symbol_retriever.get_symbol_overview(relative_path)[relative_path] + result_json_str = json.dumps([dataclasses.asdict(i) for i in result]) return self._limit_length(result_json_str, max_answer_chars) -class FindSymbolTool(Tool): +class FindSymbolTool(Tool, ToolMarkerSymbolicRead): """ Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). """ @@ -132,7 +140,7 @@ class FindSymbolTool(Tool): :param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`. :param substring_matching: If True, use substring matching for the last segment of `name`. :param max_answer_chars: Max characters for the JSON result. If exceeded, no content is returned. - :return: JSON string: a list of symbols (with locations) matching the name. + :return: a list of symbols (with locations) matching the name. """ parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None @@ -150,7 +158,7 @@ class FindSymbolTool(Tool): return self._limit_length(result, max_answer_chars) -class FindReferencingSymbolsTool(Tool): +class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): """ Finds symbols that reference the symbol at the given location (optionally filtered by type). """ @@ -164,10 +172,8 @@ class FindReferencingSymbolsTool(Tool): max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH, ) -> str: """ - Finds symbols that reference the symbol at the given `name_path`. The result will contain metadata about the referencing symbols - as well as a short code snippet around the reference (unless `include_body` is True, then the short snippet will be omitted). - Note that among other kinds of references, this function can be used to find (direct) subclasses of a class, - as subclasses are referencing symbols that have the kind class. + Finds references to the symbol at the given `name_path`. The result will contain metadata about the referencing symbols + as well as a short code snippet around the reference. :param name_path: for finding the symbol to find references for, same logic as in the `find_symbol` tool. :param relative_path: the relative path to the file containing the symbol for which to find references. @@ -204,7 +210,7 @@ class FindReferencingSymbolsTool(Tool): return self._limit_length(result, max_answer_chars) -class ReplaceSymbolBodyTool(Tool, ToolMarkerCanEdit): +class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): """ Replaces the full definition of a symbol. """ @@ -232,7 +238,7 @@ class ReplaceSymbolBodyTool(Tool, ToolMarkerCanEdit): return SUCCESS_RESULT -class InsertAfterSymbolTool(Tool, ToolMarkerCanEdit): +class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): """ Inserts content after the end of the definition of a given symbol. """ @@ -257,7 +263,7 @@ class InsertAfterSymbolTool(Tool, ToolMarkerCanEdit): return SUCCESS_RESULT -class InsertBeforeSymbolTool(Tool, ToolMarkerCanEdit): +class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): """ Inserts content before the beginning of the definition of a given symbol. """ @@ -269,9 +275,9 @@ class InsertBeforeSymbolTool(Tool, ToolMarkerCanEdit): body: str, ) -> str: """ - Inserts the given body/content before the beginning of the definition of the given symbol (via the symbol's location). - A typical use case is to insert a new class, function, method, field or variable assignment. - It also can be used to insert a new import statement before the first symbol in the file. + Inserts the given content before the beginning of the definition of the given symbol (via the symbol's location). + A typical use case is to insert a new class, function, method, field or variable assignment; or + a new import statement before the first symbol in the file. :param name_path: name path of the symbol before which to insert content (definitions in the `find_symbol` tool apply) :param relative_path: the relative path to the file containing the symbol diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 173726fc..1f55e604 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -1,6 +1,5 @@ import inspect import os -import traceback from abc import ABC from collections.abc import Iterable from dataclasses import dataclass @@ -16,6 +15,7 @@ from serena.prompt_factory import PromptFactory from serena.symbol import LanguageServerSymbolRetriever from serena.util.class_decorators import singleton from serena.util.inspection import iter_subclasses +from solidlsp.ls_exceptions import SolidLSPException if TYPE_CHECKING: from serena.agent import LinesRead, MemoriesManager, SerenaAgent @@ -73,22 +73,40 @@ class Component(ABC): TOOL_DEFAULT_MAX_ANSWER_LENGTH = int(2e5) -class ToolMarkerCanEdit: +class ToolMarker: + """ + Base class for tool markers. + """ + + +class ToolMarkerCanEdit(ToolMarker): """ Marker class for all tools that can perform editing operations on files. """ -class ToolMarkerDoesNotRequireActiveProject: +class ToolMarkerDoesNotRequireActiveProject(ToolMarker): pass -class ToolMarkerOptional: +class ToolMarkerOptional(ToolMarker): """ Marker class for optional tools that are disabled by default. """ +class ToolMarkerSymbolicRead(ToolMarker): + """ + Marker class for tools that perform symbol read operations. + """ + + +class ToolMarkerSymbolicEdit(ToolMarkerCanEdit): + """ + Marker class for tools that perform symbolic edit operations. + """ + + class ApplyMethodProtocol(Protocol): """Callable protocol for the apply method of a tool.""" @@ -237,18 +255,24 @@ class Tool(Component): self.agent.reset_language_server() # apply the actual tool - result = apply_fn(**kwargs) + try: + result = apply_fn(**kwargs) + except SolidLSPException as e: + if e.is_language_server_terminated(): + log.error(f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ...") + self.agent.reset_language_server() + result = apply_fn(**kwargs) + else: + raise + + # record tool usage self.agent.record_tool_usage_if_enabled(kwargs, result, self) except Exception as e: if not catch_exceptions: raise - msg = f"Error executing tool: {e}\n{traceback.format_exc()}" - log.error( - f"Error executing tool: {e}. " - f"Consider restarting the language server to solve this (especially, if it's a timeout of a symbolic operation)", - exc_info=e, - ) + msg = f"Error executing tool: {e}" + log.error(f"Error executing tool: {e}", exc_info=e) result = msg if log_call: diff --git a/src/serena/tools/workflow_tools.py b/src/serena/tools/workflow_tools.py index 6172cc33..be78d03d 100644 --- a/src/serena/tools/workflow_tools.py +++ b/src/serena/tools/workflow_tools.py @@ -5,7 +5,7 @@ Tools supporting the general workflow of the agent import json import platform -from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject +from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional class CheckOnboardingPerformedTool(Tool): @@ -96,7 +96,7 @@ class ThinkAboutWhetherYouAreDoneTool(Tool): return self.prompt_factory.create_think_about_whether_you_are_done() -class SummarizeChangesTool(Tool): +class SummarizeChangesTool(Tool, ToolMarkerOptional): """ Provides instructions for summarizing the changes made to the codebase. """ @@ -122,7 +122,7 @@ class PrepareForNewConversationTool(Tool): return self.prompt_factory.create_prepare_for_new_conversation() -class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject): +class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): """ Gets the initial instructions for the current project. Should only be used in settings where the system prompt cannot be set, diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index b8845d84..3879fae7 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -5,7 +5,6 @@ Provides C/C++ specific instantiation of the LanguageServer class. Contains vari import logging import os import pathlib -import stat import threading from solidlsp.ls import SolidLanguageServer @@ -13,6 +12,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -24,25 +24,30 @@ class ClangdLanguageServer(SolidLanguageServer): Also make sure compile_commands.json is created at root of the source directory. Check clangd test case for example. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - clangd_executable_path = self._setup_runtime_dependencies(logger, config) + clangd_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) super().__init__( config, logger, repository_root_path, ProcessLaunchInfo(cmd=clangd_executable_path, cwd=repository_root_path), "cpp", + solidlsp_settings, ) self.server_ready = threading.Event() self.service_ready_event = threading.Event() self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() - @staticmethod - def _setup_runtime_dependencies(logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + @classmethod + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> str: """ Setup runtime dependencies for ClangdLanguageServer and return the command to start the server. """ @@ -75,7 +80,7 @@ class ClangdLanguageServer(SolidLanguageServer): ] ) - clangd_ls_dir = os.path.join(os.path.dirname(__file__), "static", "clangd") + clangd_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "clangd") dep = deps.single_for_current_platform() clangd_executable_path = deps.binary_path(clangd_ls_dir) if not os.path.exists(clangd_executable_path): @@ -89,7 +94,7 @@ class ClangdLanguageServer(SolidLanguageServer): f"Clangd executable not found at {clangd_executable_path}.\n" "Make sure you have installed clangd. See https://clangd.llvm.org/installation" ) - os.chmod(clangd_executable_path, stat.S_IEXEC) + os.chmod(clangd_executable_path, 0o755) return clangd_executable_path diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index cae8abc2..d81fa236 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -6,7 +6,6 @@ import logging import os import pathlib import shutil -import stat import subprocess import threading @@ -15,6 +14,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -78,34 +78,38 @@ class ClojureLSP(SolidLanguageServer): ] ) - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a ClojureLSP instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - clojure_lsp_executable_path = self._setup_runtime_dependencies(logger, config) + clojure_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) super().__init__( config, logger, repository_root_path, ProcessLaunchInfo(cmd=clojure_lsp_executable_path, cwd=repository_root_path), "clojure", + solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() self.service_ready_event = threading.Event() - @staticmethod - def _setup_runtime_dependencies(logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + @classmethod + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> str: """Setup runtime dependencies for clojure-lsp and return the command to start the server.""" verify_clojure_cli() deps = ClojureLSP.runtime_dependencies dependency = deps.single_for_current_platform() - clojurelsp_ls_dir = os.path.join(os.path.dirname(__file__), "static", "clojure-lsp") + clojurelsp_ls_dir = cls.ls_resources_dir(solidlsp_settings) clojurelsp_executable_path = deps.binary_path(clojurelsp_ls_dir) if not os.path.exists(clojurelsp_executable_path): - os.makedirs(clojurelsp_ls_dir, exist_ok=True) logger.log( f"Downloading and extracting clojure-lsp from {dependency.url} to {clojurelsp_ls_dir}", logging.INFO, @@ -113,7 +117,7 @@ class ClojureLSP(SolidLanguageServer): deps.install(logger, clojurelsp_ls_dir) if not os.path.exists(clojurelsp_executable_path): raise FileNotFoundError(f"Download failed? Could not find clojure-lsp executable at {clojurelsp_executable_path}") - os.chmod(clojurelsp_executable_path, stat.S_IEXEC) + os.chmod(clojurelsp_executable_path, 0o755) return clojurelsp_executable_path @staticmethod diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index 58a1f1b9..1587d0a9 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import os +import platform import subprocess from collections.abc import Sequence from dataclasses import dataclass @@ -21,7 +22,7 @@ class RuntimeDependency: url: str | None = None archive_type: str | None = None binary_name: str | None = None - command: str | None = None + command: str | list[str] | None = None package_name: str | None = None package_version: str | None = None extract_path: str | None = None @@ -71,7 +72,7 @@ class RuntimeDependencyCollection: return results @staticmethod - def _run_command(command: str, cwd: str) -> None: + def _run_command(command: str | list[str], cwd: str) -> None: kwargs = {} if PlatformUtils.get_platform_id().is_windows(): kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW # type: ignore @@ -79,8 +80,16 @@ class RuntimeDependencyCollection: import pwd kwargs["user"] = pwd.getpwuid(os.getuid()).pw_name - log.info("Running command '%s' in '%s'", command, cwd) - completed_proces = subprocess.run( + + is_windows = platform.system() == "Windows" + if not isinstance(command, str) and not is_windows: + # Since we are using the shell, we need to convert the command list to a single string + # on Linux/macOS + command = " ".join(command) + + log.info("Running command %s in '%s'", f"'{command}'" if isinstance(command, str) else command, cwd) + + completed_process = subprocess.run( command, shell=True, check=True, @@ -89,11 +98,13 @@ class RuntimeDependencyCollection: stderr=subprocess.STDOUT, **kwargs, ) - log.log( - logging.WARNING if completed_proces.returncode else logging.INFO, - "Command completed with return code %d", - completed_proces.returncode, - ) + if completed_process.returncode != 0: + log.warning("Command '%s' failed with return code %d", command, completed_process.returncode) + log.warning("Command output:\n%s", completed_process.stdout) + else: + log.info( + "Command completed successfully", + ) @staticmethod def _install_from_url(dep: RuntimeDependency, logger: LanguageServerLogger, target_dir: str) -> None: diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 7d11a1df..f01d5b04 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -7,7 +7,6 @@ import logging import os import platform import shutil -import stat import subprocess import tarfile import threading @@ -20,11 +19,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_exceptions import LanguageServerException +from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency @@ -175,18 +175,20 @@ class CSharpLanguageServer(SolidLanguageServer): This is the official Roslyn-based language server from Microsoft. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - dotnet_path, language_server_path = self._ensure_server_installed(logger, config) + dotnet_path, language_server_path = self._ensure_server_installed(logger, config, solidlsp_settings) # Find solution or project file solution_or_project = find_solution_or_project_file(repository_root_path) # Create log directory - log_dir = Path(self.ls_resources_dir()) / "logs" + log_dir = Path(self.ls_resources_dir(solidlsp_settings)) / "logs" log_dir.mkdir(parents=True, exist_ok=True) # Build command using dotnet directly @@ -206,6 +208,7 @@ class CSharpLanguageServer(SolidLanguageServer): repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp", + solidlsp_settings, ) self.initialization_complete = threading.Event() @@ -215,15 +218,17 @@ class CSharpLanguageServer(SolidLanguageServer): return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"] @classmethod - def _ensure_server_installed(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> tuple[str, str]: + def _ensure_server_installed( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> tuple[str, str]: """ Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available. Returns a tuple of (dotnet_path, language_server_dll_path). """ runtime_id = CSharpLanguageServer._get_runtime_id() lang_server_dep, dotnet_runtime_dep = CSharpLanguageServer._get_runtime_dependencies(runtime_id) - dotnet_path = CSharpLanguageServer._ensure_dotnet_runtime(logger, dotnet_runtime_dep) - server_dll_path = CSharpLanguageServer._ensure_language_server(logger, lang_server_dep) + dotnet_path = CSharpLanguageServer._ensure_dotnet_runtime(logger, dotnet_runtime_dep, solidlsp_settings) + server_dll_path = CSharpLanguageServer._ensure_language_server(logger, lang_server_dep, solidlsp_settings) return dotnet_path, server_dll_path @@ -240,7 +245,7 @@ class CSharpLanguageServer(SolidLanguageServer): elif system == "linux": return "linux-x64" if machine in ["x86_64", "amd64"] else "linux-arm64" else: - raise LanguageServerException(f"Unsupported platform: {system} {machine}") + raise SolidLSPException(f"Unsupported platform: {system} {machine}") @staticmethod def _get_runtime_dependencies(runtime_id: str) -> tuple[RuntimeDependency, RuntimeDependency]: @@ -255,14 +260,16 @@ class CSharpLanguageServer(SolidLanguageServer): dotnet_runtime_dep = dep if not lang_server_dep: - raise LanguageServerException(f"No C# language server dependency found for platform {runtime_id}") + raise SolidLSPException(f"No C# language server dependency found for platform {runtime_id}") if not dotnet_runtime_dep: - raise LanguageServerException(f"No .NET runtime dependency found for platform {runtime_id}") + raise SolidLSPException(f"No .NET runtime dependency found for platform {runtime_id}") return lang_server_dep, dotnet_runtime_dep - @staticmethod - def _ensure_dotnet_runtime(logger: LanguageServerLogger, runtime_dep: RuntimeDependency) -> str: + @classmethod + def _ensure_dotnet_runtime( + cls, logger: LanguageServerLogger, runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings + ) -> str: """Ensure .NET runtime is available and return the dotnet executable path.""" # Check if dotnet is already available on the system system_dotnet = shutil.which("dotnet") @@ -277,15 +284,17 @@ class CSharpLanguageServer(SolidLanguageServer): pass # Download .NET 9 runtime using config - return CSharpLanguageServer._ensure_dotnet_runtime_from_config(logger, runtime_dep) + return cls._ensure_dotnet_runtime_from_config(logger, runtime_dep, solidlsp_settings) @classmethod - def _ensure_language_server(cls, logger: LanguageServerLogger, lang_server_dep: RuntimeDependency) -> str: + def _ensure_language_server( + cls, logger: LanguageServerLogger, lang_server_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings + ) -> str: """Ensure language server is available and return the DLL path.""" package_name = lang_server_dep.package_name package_version = lang_server_dep.package_version - server_dir = Path(cls.ls_resources_dir()) / f"{package_name}.{package_version}" + server_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / f"{package_name}.{package_version}" server_dll = server_dir / lang_server_dep.binary_name if server_dll.exists(): @@ -294,17 +303,17 @@ class CSharpLanguageServer(SolidLanguageServer): # Download and install the language server logger.log(f"Downloading {package_name} version {package_version}...", logging.INFO) - package_path = CSharpLanguageServer._download_nuget_package_direct(logger, package_name, package_version) + package_path = cls._download_nuget_package_direct(logger, package_name, package_version, solidlsp_settings) # Extract and install - CSharpLanguageServer._extract_language_server(lang_server_dep, package_path, server_dir) + cls._extract_language_server(lang_server_dep, package_path, server_dir) if not server_dll.exists(): - raise LanguageServerException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction") + raise SolidLSPException("Microsoft.CodeAnalysis.LanguageServer.dll not found after extraction") # Make executable on Unix systems if platform.system().lower() != "windows": - server_dll.chmod(server_dll.stat().st_mode | stat.S_IEXEC) + server_dll.chmod(0o755) logger.log(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}", logging.INFO) return str(server_dll) @@ -326,14 +335,16 @@ class CSharpLanguageServer(SolidLanguageServer): source_dir = possible_dir break else: - raise LanguageServerException(f"Could not find language server files in package. Searched in {package_path}") + raise SolidLSPException(f"Could not find language server files in package. Searched in {package_path}") # Copy files to cache directory server_dir.mkdir(parents=True, exist_ok=True) shutil.copytree(source_dir, server_dir, dirs_exist_ok=True) @classmethod - def _download_nuget_package_direct(cls, logger: LanguageServerLogger, package_name: str, package_version: str) -> Path: + def _download_nuget_package_direct( + cls, logger: LanguageServerLogger, package_name: str, package_version: str, solidlsp_settings: SolidLSPSettings + ) -> Path: """ Download a NuGet package directly from the Azure NuGet feed. Returns the path to the extracted package directory. @@ -341,7 +352,7 @@ class CSharpLanguageServer(SolidLanguageServer): azure_feed_url = "https://pkgs.dev.azure.com/azure-public/vside/_packaging/vs-impl/nuget/v3/index.json" # Create temporary directory for package download - temp_dir = Path(cls.ls_resources_dir()) / "temp_downloads" + temp_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "temp_downloads" temp_dir.mkdir(parents=True, exist_ok=True) try: @@ -358,7 +369,7 @@ class CSharpLanguageServer(SolidLanguageServer): break if not package_base_address: - raise LanguageServerException("Could not find package base address in Azure NuGet feed") + raise SolidLSPException("Could not find package base address in Azure NuGet feed") # Construct the download URL for the specific package package_id_lower = package_name.lower() @@ -385,12 +396,14 @@ class CSharpLanguageServer(SolidLanguageServer): return package_extract_dir except Exception as e: - raise LanguageServerException( + raise SolidLSPException( f"Failed to download package {package_name} version {package_version} from Azure NuGet feed: {e}" ) from e @classmethod - def _ensure_dotnet_runtime_from_config(cls, logger: LanguageServerLogger, runtime_dep: RuntimeDependency) -> str: + def _ensure_dotnet_runtime_from_config( + cls, logger: LanguageServerLogger, runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings + ) -> str: """ Ensure .NET 9 runtime is available using runtime dependency configuration. Returns the path to the dotnet executable. @@ -408,7 +421,7 @@ class CSharpLanguageServer(SolidLanguageServer): pass # Download .NET 9 runtime using config - dotnet_dir = Path(cls.ls_resources_dir()) / "dotnet-runtime-9.0" + dotnet_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "dotnet-runtime-9.0" dotnet_exe = dotnet_dir / runtime_dep.binary_name if dotnet_exe.exists(): @@ -442,13 +455,13 @@ class CSharpLanguageServer(SolidLanguageServer): # Make dotnet executable on Unix if platform.system().lower() != "windows": - dotnet_exe.chmod(dotnet_exe.stat().st_mode | stat.S_IEXEC) + dotnet_exe.chmod(0o755) logger.log(f"Successfully installed .NET 9 runtime to {dotnet_exe}", logging.INFO) return str(dotnet_exe) except Exception as e: - raise LanguageServerException(f"Failed to download .NET 9 runtime from {url}: {e}") from e + raise SolidLSPException(f"Failed to download .NET 9 runtime from {url}: {e}") from e def _get_initialize_params(self) -> InitializeParams: """ @@ -623,7 +636,7 @@ class CSharpLanguageServer(SolidLanguageServer): self.server.start() except Exception as e: self.logger.log(f"Failed to start language server process: {e}", logging.ERROR) - raise LanguageServerException(f"Failed to start C# language server: {e}") + raise SolidLSPException(f"Failed to start C# language server: {e}") # Send initialization initialize_params = self._get_initialize_params() @@ -633,7 +646,7 @@ class CSharpLanguageServer(SolidLanguageServer): init_response = self.server.send.initialize(initialize_params) self.logger.log(f"Received initialize response: {init_response}", logging.DEBUG) except Exception as e: - raise LanguageServerException(f"Failed to initialize C# language server for {self.repository_root_path}: {e}") from e + raise SolidLSPException(f"Failed to initialize C# language server for {self.repository_root_path}: {e}") from e # Apply diagnostic capabilities self._force_pull_diagnostics(init_response) diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py index 28696ad6..297294a4 100644 --- a/src/solidlsp/language_servers/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -1,11 +1,11 @@ import logging import os import pathlib -import stat from solidlsp.ls import SolidLanguageServer from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -15,21 +15,22 @@ class DartLanguageServer(SolidLanguageServer): Provides Dart specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Dart. """ - def __init__(self, config, logger, repository_root_path): + def __init__(self, config, logger, repository_root_path, solidlsp_settings: SolidLSPSettings): """ Creates a DartServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - executable_path = self._setup_runtime_dependencies(logger) + executable_path = self._setup_runtime_dependencies(logger, solidlsp_settings) super().__init__( config, logger, repository_root_path, ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), "dart", + solidlsp_settings, ) @classmethod - def _setup_runtime_dependencies(cls, logger: "LanguageServerLogger") -> str: + def _setup_runtime_dependencies(cls, logger: "LanguageServerLogger", solidlsp_settings: SolidLSPSettings) -> str: deps = RuntimeDependencyCollection( [ RuntimeDependency( @@ -75,14 +76,14 @@ class DartLanguageServer(SolidLanguageServer): ] ) - dart_ls_dir = cls.ls_resources_dir() + dart_ls_dir = cls.ls_resources_dir(solidlsp_settings) dart_executable_path = deps.binary_path(dart_ls_dir) if not os.path.exists(dart_executable_path): deps.install(logger, dart_ls_dir) assert os.path.exists(dart_executable_path) - os.chmod(dart_executable_path, stat.S_IEXEC) + os.chmod(dart_executable_path, 0o755) return f"{dart_executable_path} language-server --client-id multilspy.dart --client-version 1.2" diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 5ebb378c..d664fb7a 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -7,7 +7,6 @@ import logging import os import pathlib import shutil -import stat import threading import uuid from pathlib import PurePath @@ -44,18 +43,20 @@ class EclipseJDTLS(SolidLanguageServer): The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a new EclipseJDTLS instance initializing the language server settings appropriately. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - runtime_dependency_paths = self._setupRuntimeDependencies(logger, config) + runtime_dependency_paths = self._setupRuntimeDependencies(logger, config, solidlsp_settings) self.runtime_dependency_paths = runtime_dependency_paths # ws_dir is the workspace directory for the EclipseJDTLS server ws_dir = str( PurePath( - SolidLSPSettings.get_language_server_directory(), + solidlsp_settings.ls_resources_dir, "EclipseJDTLS", "workspaces", uuid.uuid4().hex, @@ -63,15 +64,15 @@ class EclipseJDTLS(SolidLanguageServer): ) # shared_cache_location is the global cache used by Eclipse JDTLS across all workspaces - shared_cache_location = str(PurePath(SolidLSPSettings.get_global_cache_directory(), "lsp", "EclipseJDTLS", "sharedIndex")) + shared_cache_location = str(PurePath(solidlsp_settings.ls_resources_dir, "lsp", "EclipseJDTLS", "sharedIndex")) + os.makedirs(shared_cache_location, exist_ok=True) + os.makedirs(ws_dir, exist_ok=True) jre_path = self.runtime_dependency_paths.jre_path lombok_jar_path = self.runtime_dependency_paths.lombok_jar_path jdtls_launcher_jar = self.runtime_dependency_paths.jdtls_launcher_jar_path - os.makedirs(ws_dir, exist_ok=True) - data_dir = str(PurePath(ws_dir, "data_dir")) jdtls_config_path = str(PurePath(ws_dir, "config_path")) @@ -132,7 +133,9 @@ class EclipseJDTLS(SolidLanguageServer): self.intellicode_enable_command_available = threading.Event() self.initialize_searcher_command_available = threading.Event() - super().__init__(config, logger, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java") + super().__init__( + config, logger, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java", solidlsp_settings=solidlsp_settings + ) @override def is_ignored_dirname(self, dirname: str) -> bool: @@ -153,7 +156,9 @@ class EclipseJDTLS(SolidLanguageServer): ] @classmethod - def _setupRuntimeDependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> RuntimeDependencyPaths: + def _setupRuntimeDependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> RuntimeDependencyPaths: """ Setup runtime dependencies for EclipseJDTLS and return the paths. """ @@ -238,7 +243,7 @@ class EclipseJDTLS(SolidLanguageServer): gradle_path = str( PurePath( - cls.ls_resources_dir(), + cls.ls_resources_dir(solidlsp_settings), "gradle-7.3.3", ) ) @@ -254,7 +259,7 @@ class EclipseJDTLS(SolidLanguageServer): assert os.path.exists(gradle_path) dependency = runtime_dependencies["vscode-java"][platformId.value] - vscode_java_path = str(PurePath(cls.ls_resources_dir(), dependency["relative_extraction_path"])) + vscode_java_path = str(PurePath(cls.ls_resources_dir(solidlsp_settings), dependency["relative_extraction_path"])) os.makedirs(vscode_java_path, exist_ok=True) jre_home_path = str(PurePath(vscode_java_path, dependency["jre_home_path"])) jre_path = str(PurePath(vscode_java_path, dependency["jre_path"])) @@ -273,7 +278,7 @@ class EclipseJDTLS(SolidLanguageServer): ): FileUtils.download_and_extract_archive(logger, dependency["url"], vscode_java_path, dependency["archiveType"]) - os.chmod(jre_path, stat.S_IEXEC) + os.chmod(jre_path, 0o755) assert os.path.exists(vscode_java_path) assert os.path.exists(jre_home_path) @@ -283,7 +288,7 @@ class EclipseJDTLS(SolidLanguageServer): assert os.path.exists(jdtls_readonly_config_path) dependency = runtime_dependencies["intellicode"]["platform-agnostic"] - intellicode_directory_path = str(PurePath(cls.ls_resources_dir(), dependency["relative_extraction_path"])) + intellicode_directory_path = str(PurePath(cls.ls_resources_dir(solidlsp_settings), dependency["relative_extraction_path"])) os.makedirs(intellicode_directory_path, exist_ok=True) intellicode_jar_path = str(PurePath(intellicode_directory_path, dependency["intellicode_jar_path"])) intellisense_members_path = str(PurePath(intellicode_directory_path, dependency["intellisense_members_path"])) diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 96674722..5f6a9794 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -1,4 +1,3 @@ -import json import logging import os import pathlib @@ -6,16 +5,18 @@ import stat import subprocess import threading import time -from pathlib import PurePath from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger -from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from ..common import RuntimeDependency class ElixirTools(SolidLanguageServer): @@ -68,8 +69,8 @@ class ElixirTools(SolidLanguageServer): return filtered_response - @staticmethod - def _get_elixir_version(): + @classmethod + def _get_elixir_version(cls): """Get the installed Elixir version or None if not found.""" try: result = subprocess.run(["elixir", "--version"], capture_output=True, text=True, check=False) @@ -79,13 +80,16 @@ class ElixirTools(SolidLanguageServer): return None return None - def setupRuntimeDependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + @classmethod + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> str: """ Setup runtime dependencies for Next LS. Downloads the Next LS binary for the current platform and returns the path to the executable. """ # Check if Elixir is available first - elixir_version = self._get_elixir_version() + elixir_version = cls._get_elixir_version() if not elixir_version: raise RuntimeError( "Elixir is not installed. Please install Elixir from https://elixir-lang.org/install.html and make sure it is added to your PATH." @@ -93,70 +97,78 @@ class ElixirTools(SolidLanguageServer): logger.log(f"Found Elixir: {elixir_version}", logging.INFO) - platformId = PlatformUtils.get_platform_id() - - with open(str(PurePath(os.path.dirname(__file__), "runtime_dependencies.json")), encoding="utf-8") as f: - runtimeDependencies = json.load(f) - del runtimeDependencies["_description"] - - os.makedirs(str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static")), exist_ok=True) - - # Map platform IDs to runtime dependency keys - platform_mapping = { - "linux-x64": "linux-x64", - "osx-x64": "darwin-x64", - "osx-arm64": "darwin-arm64", - "darwin-x64": "darwin-x64", - "darwin-arm64": "darwin-arm64", - "win-x64": "win-x64", - } - - platform_key = platform_mapping.get(platformId.value) - if not platform_key: - raise RuntimeError(f"Unsupported platform for Next LS: {platformId.value}") + platform_id = PlatformUtils.get_platform_id() # Check for Windows and provide a helpful error message - if platformId.value.startswith("win"): + if platform_id.value.startswith("win"): raise RuntimeError( "Windows is not supported by Next LS. The Next LS project does not provide Windows binaries. " "Consider using Windows Subsystem for Linux (WSL) or a virtual machine with Linux/macOS." ) - dependency = runtimeDependencies["next_ls"][platform_key] - next_ls_dir = str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static", dependency["relative_extraction_path"])) - os.makedirs(next_ls_dir, exist_ok=True) + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + ] + assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for Next LS at the moment" - executable_path = str(PurePath(next_ls_dir, dependency["executable_name"])) - binary_path = str(PurePath(next_ls_dir, dependency["binary_name"])) + next_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "next-ls") + + # Define runtime dependencies inline + runtime_deps = { + PlatformId.LINUX_x64: RuntimeDependency( + id="next_ls_linux_amd64", + platform_id="linux-x64", + url="https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_linux_amd64", + archive_type="binary", + binary_name="next_ls_linux_amd64", + extract_path="next_ls", + ), + PlatformId.OSX_x64: RuntimeDependency( + id="next_ls_darwin_amd64", + platform_id="osx-x64", + url="https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_amd64", + archive_type="binary", + binary_name="next_ls_darwin_amd64", + extract_path="next_ls", + ), + PlatformId.OSX_arm64: RuntimeDependency( + id="next_ls_darwin_arm64", + platform_id="osx-arm64", + url="https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_arm64", + archive_type="binary", + binary_name="next_ls_darwin_arm64", + extract_path="next_ls", + ), + } + + dependency = runtime_deps[platform_id] + executable_path = os.path.join(next_ls_dir, "nextls") + binary_path = os.path.join(next_ls_dir, dependency.binary_name) if not os.path.exists(executable_path): - logger.log(f"Downloading Next LS binary from {dependency['url']}", logging.INFO) - FileUtils.download_file(logger, dependency["url"], binary_path) + logger.log(f"Downloading Next LS binary from {dependency.url}", logging.INFO) + FileUtils.download_file(logger, dependency.url, binary_path) # Make the binary executable on Unix-like systems - if not platformId.value.startswith("win"): - os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) - # Create a symlink or copy with the expected name + # Create a symlink with the expected name if binary_path != executable_path: if os.path.exists(executable_path): os.remove(executable_path) - if platformId.value.startswith("win"): - # On Windows, copy the file - import shutil - - shutil.copy2(binary_path, executable_path) - else: - # On Unix-like systems, create a symlink - os.symlink(os.path.basename(binary_path), executable_path) + os.symlink(os.path.basename(binary_path), executable_path) assert os.path.exists(executable_path), f"Next LS executable not found at {executable_path}" logger.log(f"Next LS binary ready at: {executable_path}", logging.INFO) return executable_path - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): - nextls_executable_path = self.setupRuntimeDependencies(logger, config) + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): + nextls_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) super().__init__( config, @@ -164,6 +176,7 @@ class ElixirTools(SolidLanguageServer): repository_root_path, ProcessLaunchInfo(cmd=f'"{nextls_executable_path}" --stdio', cwd=repository_root_path), "elixir", + solidlsp_settings, ) self.server_ready = threading.Event() self.request_id = 0 @@ -171,29 +184,66 @@ class ElixirTools(SolidLanguageServer): # Set generous timeout for Next LS which can be slow to initialize and respond self.set_request_timeout(180.0) # 60 seconds for all environments - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Next LS Language Server. """ - with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: - d = json.load(f) + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "locale": "en", + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "initializationOptions": { + "mix_env": "dev", + "mix_target": "host", + "experimental": {"completions": {"enable": False}}, + "extensions": {"credo": {"enable": True, "cli_options": []}}, + }, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": { + "dynamicRegistration": True, + "completionItem": {"snippetSupport": True, "documentationFormat": ["markdown", "plaintext"]}, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "formatting": {"dynamicRegistration": True}, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "executeCommand": {"dynamicRegistration": True}, + }, + }, + "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], + } - del d["_description"] - - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) - - return d + return initialize_params def _start_server(self): """Start Next LS server process""" diff --git a/src/solidlsp/language_servers/elixir_tools/initialize_params.json b/src/solidlsp/language_servers/elixir_tools/initialize_params.json deleted file mode 100644 index 51ce266f..00000000 --- a/src/solidlsp/language_servers/elixir_tools/initialize_params.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", - "processId": "os.getpid()", - "locale": "en", - "rootPath": "$rootPath", - "rootUri": "$rootUri", - "initializationOptions": { - "mix_env": "dev", - "mix_target": "host", - "experimental": { - "completions": { - "enable": false - } - }, - "extensions": { - "credo": { - "enable": true, - "cli_options": [] - } - } - }, - "capabilities": { - "textDocument": { - "synchronization": { - "didSave": true, - "dynamicRegistration": true - }, - "completion": { - "dynamicRegistration": true, - "completionItem": { - "snippetSupport": true, - "documentationFormat": [ - "markdown", - "plaintext" - ] - } - }, - "definition": { - "dynamicRegistration": true - }, - "references": { - "dynamicRegistration": true - }, - "documentSymbol": { - "dynamicRegistration": true, - "hierarchicalDocumentSymbolSupport": true, - "symbolKind": { - "valueSet": [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, - 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 - ] - } - }, - "hover": { - "dynamicRegistration": true, - "contentFormat": [ - "markdown", - "plaintext" - ] - }, - "formatting": { - "dynamicRegistration": true - }, - "codeAction": { - "dynamicRegistration": true, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": [ - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports" - ] - } - } - } - }, - "workspace": { - "workspaceFolders": true, - "didChangeConfiguration": { - "dynamicRegistration": true - }, - "executeCommand": { - "dynamicRegistration": true - } - } - }, - "workspaceFolders": [ - { - "uri": "$uri", - "name": "$name" - } - ] -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/elixir_tools/runtime_dependencies.json b/src/solidlsp/language_servers/elixir_tools/runtime_dependencies.json deleted file mode 100644 index 32641a0d..00000000 --- a/src/solidlsp/language_servers/elixir_tools/runtime_dependencies.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "_description": "This file lists the runtime dependencies for the Elixir Language Server (Next LS)", - "next_ls": { - "linux-x64": { - "url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_linux_amd64", - "archiveType": "binary", - "relative_extraction_path": "next_ls", - "binary_name": "next_ls_linux_amd64", - "executable_name": "nextls" - }, - "darwin-x64": { - "url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_amd64", - "archiveType": "binary", - "relative_extraction_path": "next_ls", - "binary_name": "next_ls_darwin_amd64", - "executable_name": "nextls" - }, - "darwin-arm64": { - "url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_arm64", - "archiveType": "binary", - "relative_extraction_path": "next_ls", - "binary_name": "next_ls_darwin_arm64", - "executable_name": "nextls" - }, - "win-x64": { - "url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_windows_amd64.exe", - "archiveType": "binary", - "relative_extraction_path": "next_ls", - "binary_name": "next_ls_windows_amd64.exe", - "executable_name": "nextls.exe" - } - } -} \ No newline at end of file diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index 70eb15fa..c21663b7 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -11,6 +11,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings class Gopls(SolidLanguageServer): @@ -70,7 +71,9 @@ class Gopls(SolidLanguageServer): return True - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): self._setup_runtime_dependency() super().__init__( @@ -79,6 +82,7 @@ class Gopls(SolidLanguageServer): repository_root_path, ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), "go", + solidlsp_settings, ) self.server_ready = threading.Event() self.request_id = 0 diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py index f9c83976..2d1d48e2 100644 --- a/src/solidlsp/language_servers/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -16,6 +16,7 @@ from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DefinitionParams, InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -34,7 +35,9 @@ class Intelephense(SolidLanguageServer): return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "vendor", "cache"] @classmethod - def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> str: """ Setup runtime dependencies for Intelephense and return the command to start the server. """ @@ -58,7 +61,7 @@ class Intelephense(SolidLanguageServer): assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." # Install intelephense if not already installed - intelephense_ls_dir = os.path.join(cls.ls_resources_dir(), "php-lsp") + intelephense_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "php-lsp") os.makedirs(intelephense_ls_dir, exist_ok=True) intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense") if not os.path.exists(intelephense_executable_path): @@ -79,11 +82,20 @@ class Intelephense(SolidLanguageServer): return f"{intelephense_executable_path} --stdio" - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): # Setup runtime dependencies before initializing - intelephense_cmd = self._setup_runtime_dependencies(logger, config) + intelephense_cmd = self._setup_runtime_dependencies(logger, config, solidlsp_settings) - super().__init__(config, logger, repository_root_path, ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path), "php") + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path), + "php", + solidlsp_settings, + ) self.request_id = 0 @staticmethod diff --git a/src/solidlsp/language_servers/jedi_server.py b/src/solidlsp/language_servers/jedi_server.py index 161ce754..956e00b9 100644 --- a/src/solidlsp/language_servers/jedi_server.py +++ b/src/solidlsp/language_servers/jedi_server.py @@ -13,6 +13,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings class JediServer(SolidLanguageServer): @@ -20,7 +21,9 @@ class JediServer(SolidLanguageServer): Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a JediServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ @@ -30,6 +33,7 @@ class JediServer(SolidLanguageServer): repository_root_path, ProcessLaunchInfo(cmd="jedi-language-server", cwd=repository_root_path), "python", + solidlsp_settings, ) @override diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py index ef2a77ca..5d0d2083 100644 --- a/src/solidlsp/language_servers/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -14,6 +14,7 @@ from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import FileUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings @dataclasses.dataclass @@ -32,11 +33,13 @@ class KotlinLanguageServer(SolidLanguageServer): Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a Kotlin Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - runtime_dependency_paths = self._setup_runtime_dependencies(logger, config) + runtime_dependency_paths = self._setup_runtime_dependencies(logger, config, solidlsp_settings) self.runtime_dependency_paths = runtime_dependency_paths # Create command to execute the Kotlin Language Server script @@ -51,10 +54,13 @@ class KotlinLanguageServer(SolidLanguageServer): repository_root_path, ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), "kotlin", + solidlsp_settings, ) @classmethod - def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> KotlinRuntimeDependencyPaths: + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> KotlinRuntimeDependencyPaths: """ Setup runtime dependencies for Kotlin Language Server and return the paths. """ @@ -111,7 +117,7 @@ class KotlinLanguageServer(SolidLanguageServer): java_dependency = runtime_dependencies["java"][platform_id.value] # Setup paths for dependencies - static_dir = os.path.join(cls.ls_resources_dir(), "kotlin_language_server") + static_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "kotlin_language_server") os.makedirs(static_dir, exist_ok=True) # Setup Java paths diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py index eafaa310..e43287c5 100644 --- a/src/solidlsp/language_servers/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp.py @@ -6,7 +6,6 @@ import json import logging import os import pathlib -import stat import threading from collections.abc import Iterable @@ -14,11 +13,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_exceptions import LanguageServerException +from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import DotnetVersion, FileUtils, PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings def breadth_first_file_scan(root) -> Iterable[str]: @@ -59,16 +59,18 @@ class OmniSharp(SolidLanguageServer): Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates an OmniSharp instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - omnisharp_executable_path, dll_path = self._setup_runtime_dependencies(logger, config) + omnisharp_executable_path, dll_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) slnfilename = find_least_depth_sln_file(repository_root_path) if slnfilename is None: logger.log("No *.sln file found in repository", logging.ERROR) - raise LanguageServerException("No SLN file found in repository") + raise SolidLSPException("No SLN file found in repository") cmd = " ".join( [ @@ -102,7 +104,9 @@ class OmniSharp(SolidLanguageServer): "formattingOptions:indentationSize=4", ] ) - super().__init__(config, logger, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp") + super().__init__( + config, logger, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp", solidlsp_settings + ) self.server_ready = threading.Event() self.definition_available = threading.Event() @@ -138,7 +142,9 @@ class OmniSharp(SolidLanguageServer): return d @classmethod - def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> tuple[str, str]: + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> tuple[str, str]: """ Setup runtime dependencies for OmniSharp. """ @@ -181,15 +187,15 @@ class OmniSharp(SolidLanguageServer): assert "OmniSharp" in runtime_dependencies assert "RazorOmnisharp" in runtime_dependencies - omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(), "OmniSharp") + omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "OmniSharp") if not os.path.exists(omnisharp_ls_dir): os.makedirs(omnisharp_ls_dir) FileUtils.download_and_extract_archive(logger, runtime_dependencies["OmniSharp"]["url"], omnisharp_ls_dir, "zip") omnisharp_executable_path = os.path.join(omnisharp_ls_dir, runtime_dependencies["OmniSharp"]["binaryName"]) assert os.path.exists(omnisharp_executable_path) - os.chmod(omnisharp_executable_path, stat.S_IEXEC) + os.chmod(omnisharp_executable_path, 0o755) - razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(), "RazorOmnisharp") + razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "RazorOmnisharp") if not os.path.exists(razor_omnisharp_ls_dir): os.makedirs(razor_omnisharp_ls_dir) FileUtils.download_and_extract_archive(logger, runtime_dependencies["RazorOmnisharp"]["url"], razor_omnisharp_ls_dir, "zip") diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index 905bb3d6..c760ebc1 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -15,6 +15,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings class PyrightServer(SolidLanguageServer): @@ -23,7 +24,9 @@ class PyrightServer(SolidLanguageServer): Contains various configurations and settings specific to Python. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a PyrightServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. @@ -36,6 +39,7 @@ class PyrightServer(SolidLanguageServer): # Note 2: we can also use `bpyright-langserver --stdio` if we ever are unhappy with pyright ProcessLaunchInfo(cmd="python -m pyright.langserver --stdio", cwd=repository_root_path), "python", + solidlsp_settings, ) # Event to signal when initial workspace analysis is complete diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index 5a181fc3..de73f045 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -5,7 +5,6 @@ Provides Rust specific instantiation of the LanguageServer class. Contains vario import logging import os import pathlib -import stat import threading from overrides import override @@ -15,6 +14,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -24,17 +24,20 @@ class RustAnalyzer(SolidLanguageServer): Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - rustanalyzer_executable_path = self._setup_runtime_dependencies(logger, config) + rustanalyzer_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) super().__init__( config, logger, repository_root_path, ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path), "rust", + solidlsp_settings, ) self.server_ready = threading.Event() self.service_ready_event = threading.Event() @@ -46,7 +49,9 @@ class RustAnalyzer(SolidLanguageServer): return super().is_ignored_dirname(dirname) or dirname in ["target"] @classmethod - def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> str: """ Setup runtime dependencies for rust_analyzer and return the command to start the server. """ @@ -100,13 +105,13 @@ class RustAnalyzer(SolidLanguageServer): # "win-x64", # ], "Only linux-x64 and win-x64 platform is supported for in multilspy at the moment" - rustanalyzer_ls_dir = os.path.join(cls.ls_resources_dir(), "RustAnalyzer") + rustanalyzer_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "RustAnalyzer") rustanalyzer_executable_path = deps.binary_path(rustanalyzer_ls_dir) if not os.path.exists(rustanalyzer_ls_dir): os.makedirs(rustanalyzer_ls_dir) deps.install(logger, rustanalyzer_ls_dir) assert os.path.exists(rustanalyzer_executable_path) - os.chmod(rustanalyzer_executable_path, stat.S_IEXEC) + os.chmod(rustanalyzer_executable_path, 0o755) return rustanalyzer_executable_path diff --git a/src/solidlsp/language_servers/solargraph.py b/src/solidlsp/language_servers/solargraph.py index 8034ad80..a4f7e694 100644 --- a/src/solidlsp/language_servers/solargraph.py +++ b/src/solidlsp/language_servers/solargraph.py @@ -7,7 +7,6 @@ import json import logging import os import pathlib -import stat import subprocess import threading @@ -18,6 +17,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings class Solargraph(SolidLanguageServer): @@ -26,7 +26,9 @@ class Solargraph(SolidLanguageServer): Contains various configurations and settings specific to Ruby. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a Solargraph instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. @@ -38,6 +40,7 @@ class Solargraph(SolidLanguageServer): repository_root_path, ProcessLaunchInfo(cmd=f"{solargraph_executable_path} stdio", cwd=repository_root_path), "ruby", + solidlsp_settings, ) self.server_ready = threading.Event() self.service_ready_event = threading.Event() @@ -83,19 +86,7 @@ class Solargraph(SolidLanguageServer): logger.log("Installing Solargraph...", logging.INFO) subprocess.run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path) - # Get the gem executable path directly - result = subprocess.run(["gem", "which", "solargraph"], check=True, capture_output=True, text=True, cwd=repository_root_path) - gem_path = result.stdout.strip() - bin_dir = os.path.join(os.path.dirname(os.path.dirname(gem_path)), "bin") - executable_path = os.path.join(bin_dir, "solargraph") - - if not os.path.exists(executable_path): - raise RuntimeError(f"Solargraph executable not found at {executable_path}") - - # Ensure the executable has the right permissions - os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IEXEC) - - return executable_path + return "gem exec solargraph" except subprocess.CalledProcessError as e: raise RuntimeError(f"Failed to check or install Solargraph. {e.stderr}") from e diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py index 5a230a23..6a374df6 100644 --- a/src/solidlsp/language_servers/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -1,7 +1,6 @@ import logging import os import shutil -import stat import threading from overrides import override @@ -12,6 +11,7 @@ from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PathUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -57,7 +57,7 @@ class TerraformLS(SolidLanguageServer): ) @classmethod - def _setup_runtime_dependencies(cls, logger: LanguageServerLogger) -> str: + def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for terraform-ls. Downloads and installs terraform-ls if not already present. @@ -102,24 +102,26 @@ class TerraformLS(SolidLanguageServer): ) dependency = deps.single_for_current_platform() - terraform_ls_executable_path = deps.binary_path(cls.ls_resources_dir()) + terraform_ls_executable_path = deps.binary_path(cls.ls_resources_dir(solidlsp_settings)) if not os.path.exists(terraform_ls_executable_path): logger.log(f"Downloading terraform-ls from {dependency.url}", logging.INFO) - deps.install(logger, cls.ls_resources_dir()) + deps.install(logger, cls.ls_resources_dir(solidlsp_settings)) assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" # Make the executable file executable on Unix-like systems if platform_id.value != "win-x64": - os.chmod(terraform_ls_executable_path, stat.S_IEXEC | stat.S_IREAD) + os.chmod(terraform_ls_executable_path, 0o755) return terraform_ls_executable_path - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - terraform_ls_executable_path = self._setup_runtime_dependencies(logger) + terraform_ls_executable_path = self._setup_runtime_dependencies(logger, solidlsp_settings) super().__init__( config, @@ -127,6 +129,7 @@ class TerraformLS(SolidLanguageServer): repository_root_path, ProcessLaunchInfo(cmd=f"{terraform_ls_executable_path} serve", cwd=repository_root_path), "terraform", + solidlsp_settings, ) self.server_ready = threading.Event() self.request_id = 0 diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 48bcf444..ccb5e8c4 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -18,6 +18,7 @@ from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -42,17 +43,20 @@ class TypeScriptLanguageServer(SolidLanguageServer): Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a TypeScriptLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - ts_lsp_executable_path = self._setup_runtime_dependencies(logger, config) + ts_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) super().__init__( config, logger, repository_root_path, ProcessLaunchInfo(cmd=ts_lsp_executable_path, cwd=repository_root_path), "typescript", + solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() @@ -67,7 +71,9 @@ class TypeScriptLanguageServer(SolidLanguageServer): ] @classmethod - def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> list[str]: """ Setup runtime dependencies for TypeScript Language Server and return the command to start the server. """ @@ -89,13 +95,13 @@ class TypeScriptLanguageServer(SolidLanguageServer): RuntimeDependency( id="typescript", description="typescript package", - command="npm install --prefix ./ typescript@5.5.4", + command=["npm", "install", "--prefix", "./", "typescript@5.5.4"], platform_id="any", ), RuntimeDependency( id="typescript-language-server", description="typescript-language-server package", - command="npm install --prefix ./ typescript-language-server@4.3.3", + command=["npm", "install", "--prefix", "./", "typescript-language-server@4.3.3"], platform_id="any", ), ] @@ -114,7 +120,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." # Install typescript and typescript-language-server if not already installed - tsserver_ls_dir = os.path.join(cls.ls_resources_dir(), "ts-lsp") + tsserver_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "ts-lsp") tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") if not os.path.exists(tsserver_executable_path): logger.log(f"Typescript Language Server executable not found at {tsserver_executable_path}. Installing...", logging.INFO) @@ -125,7 +131,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): raise FileNotFoundError( f"typescript-language-server executable not found at {tsserver_executable_path}, something went wrong with the installation." ) - return f"{tsserver_executable_path} --stdio" + return [tsserver_executable_path, "--stdio"] @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index 40de7b55..c8596b89 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -19,6 +19,7 @@ from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -29,17 +30,20 @@ class VtsLanguageServer(SolidLanguageServer): Contains various configurations and settings specific to TypeScript via vtsls wrapper. """ - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): """ Creates a VtsLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - vts_lsp_executable_path = self._setup_runtime_dependencies(logger, config) + vts_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) super().__init__( config, logger, repository_root_path, ProcessLaunchInfo(cmd=vts_lsp_executable_path, cwd=repository_root_path), "typescript", + solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() @@ -54,7 +58,9 @@ class VtsLanguageServer(SolidLanguageServer): ] @classmethod - def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> str: """ Setup runtime dependencies for VTS Language Server and return the command to start the server. """ @@ -81,7 +87,7 @@ class VtsLanguageServer(SolidLanguageServer): ), ] ) - vts_ls_dir = os.path.join(cls.ls_resources_dir(), "vts-lsp") + vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vts-lsp") vts_executable_path = os.path.join(vts_ls_dir, "vtsls") # Verify both node and npm are installed diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 7cb82d72..ded2645a 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -5,6 +5,7 @@ import logging import os import pathlib import pickle +import shutil import subprocess import threading from abc import ABC, abstractmethod @@ -21,7 +22,7 @@ from serena.text_utils import MatchedConsecutiveLines from serena.util.file_system import match_path from solidlsp import ls_types from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.ls_exceptions import LanguageServerException +from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_handler import SolidLanguageServerHandler from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils @@ -30,10 +31,11 @@ from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes from solidlsp.lsp_protocol_handler.lsp_constants import LSPConstants from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink, SymbolKind from solidlsp.lsp_protocol_handler.server import ( - Error, + LSPError, ProcessLaunchInfo, StringDict, ) +from solidlsp.settings import SolidLSPSettings GenericDocumentSymbol = Union[LSPTypes.DocumentSymbol, LSPTypes.SymbolInformation, ls_types.UnifiedSymbolInformation] @@ -89,19 +91,34 @@ class SolidLanguageServer(ABC): return dirname.startswith(".") @classmethod - def ls_resources_dir(cls, mkdir: bool = True) -> str: + def ls_resources_dir(cls, solidlsp_settings: SolidLSPSettings, mkdir: bool = True) -> str: """ Returns the directory where the language server resources are downloaded. This is used to store language server binaries, configuration files, etc. """ - result = os.path.join(os.path.dirname(__file__), "language_servers", "static", cls.__name__) + result = os.path.join(solidlsp_settings.ls_resources_dir, cls.__name__) + + # Migration of previously downloaded LS resources that were downloaded to a subdir of solidlsp instead of to the user's home + pre_migration_ls_resources_dir = os.path.join(os.path.dirname(__file__), "language_servers", "static", cls.__name__) + if os.path.exists(pre_migration_ls_resources_dir): + if os.path.exists(result): + # if the directory already exists, we just remove the old resources + shutil.rmtree(result, ignore_errors=True) + else: + # move old resources to the new location + shutil.move(pre_migration_ls_resources_dir, result) if mkdir: os.makedirs(result, exist_ok=True) return result @classmethod def create( - cls, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, timeout: float | None = None + cls, + config: LanguageServerConfig, + logger: LanguageServerLogger, + repository_root_path: str, + timeout: float | None = None, + solidlsp_settings: SolidLSPSettings | None = None, ) -> "SolidLanguageServer": """ Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. @@ -116,100 +133,102 @@ class SolidLanguageServer(ABC): :return LanguageServer: A language specific LanguageServer instance. """ ls: SolidLanguageServer + if solidlsp_settings is None: + solidlsp_settings = SolidLSPSettings() if config.code_language == Language.PYTHON: from solidlsp.language_servers.pyright_server import ( PyrightServer, ) - ls = PyrightServer(config, logger, repository_root_path) + ls = PyrightServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.PYTHON_JEDI: from solidlsp.language_servers.jedi_server import JediServer - ls = JediServer(config, logger, repository_root_path) + ls = JediServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.JAVA: from solidlsp.language_servers.eclipse_jdtls import ( EclipseJDTLS, ) - ls = EclipseJDTLS(config, logger, repository_root_path) + ls = EclipseJDTLS(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.KOTLIN: from solidlsp.language_servers.kotlin_language_server import ( KotlinLanguageServer, ) - ls = KotlinLanguageServer(config, logger, repository_root_path) + ls = KotlinLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.RUST: from solidlsp.language_servers.rust_analyzer import ( RustAnalyzer, ) - ls = RustAnalyzer(config, logger, repository_root_path) + ls = RustAnalyzer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.CSHARP: from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer - ls = CSharpLanguageServer(config, logger, repository_root_path) + ls = CSharpLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.CSHARP_OMNISHARP: from solidlsp.language_servers.omnisharp import OmniSharp - ls = OmniSharp(config, logger, repository_root_path) + ls = OmniSharp(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.TYPESCRIPT: from solidlsp.language_servers.typescript_language_server import ( TypeScriptLanguageServer, ) - ls = TypeScriptLanguageServer(config, logger, repository_root_path) + ls = TypeScriptLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.TYPESCRIPT_VTS: # VTS based Language Server implementation, need to experiment to see if it improves performance from solidlsp.language_servers.vts_language_server import VtsLanguageServer - ls = VtsLanguageServer(config, logger, repository_root_path) + ls = VtsLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.GO: from solidlsp.language_servers.gopls import Gopls - ls = Gopls(config, logger, repository_root_path) + ls = Gopls(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.RUBY: from solidlsp.language_servers.solargraph import Solargraph - ls = Solargraph(config, logger, repository_root_path) + ls = Solargraph(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.DART: from solidlsp.language_servers.dart_language_server import DartLanguageServer - ls = DartLanguageServer(config, logger, repository_root_path) + ls = DartLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.CPP: from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer - ls = ClangdLanguageServer(config, logger, repository_root_path) + ls = ClangdLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.PHP: from solidlsp.language_servers.intelephense import Intelephense - ls = Intelephense(config, logger, repository_root_path) + ls = Intelephense(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.CLOJURE: from solidlsp.language_servers.clojure_lsp import ClojureLSP - ls = ClojureLSP(config, logger, repository_root_path) + ls = ClojureLSP(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.ELIXIR: from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools - ls = ElixirTools(config, logger, repository_root_path) + ls = ElixirTools(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) elif config.code_language == Language.TERRAFORM: from solidlsp.language_servers.terraform_ls import TerraformLS - ls = TerraformLS(config, logger, repository_root_path) + ls = TerraformLS(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings) else: logger.log(f"Language {config.code_language} is not supported", logging.ERROR) - raise LanguageServerException(f"Language {config.code_language} is not supported") + raise SolidLSPException(f"Language {config.code_language} is not supported") ls.set_request_timeout(timeout) return ls @@ -221,6 +240,7 @@ class SolidLanguageServer(ABC): repository_root_path: str, process_launch_info: ProcessLaunchInfo, language_id: str, + solidlsp_settings: SolidLSPSettings, ): """ Initializes a LanguageServer instance. @@ -235,6 +255,7 @@ class SolidLanguageServer(ABC): The command must pass appropriate flags to the binary, so that it runs in the stdio mode, as opposed to HTTP, TCP modes supported by some language servers. """ + self._solidlsp_settings = solidlsp_settings self.logger = logger self.repository_root_path: str = repository_root_path self.logger.log( @@ -260,7 +281,7 @@ class SolidLanguageServer(ABC): if config.trace_lsp_communication: def logging_fn(source: str, target: str, msg: StringDict | str): - self.logger.log(f"LSP: {source} -> {target}: {str(msg)[:90]}...", self.logger.logger.level) + self.logger.log(f"LSP: {source} -> {target}: {msg!s}", self.logger.logger.level) else: logging_fn = None @@ -427,7 +448,7 @@ class SolidLanguageServer(ABC): "open_file called before Language Server started", logging.ERROR, ) - raise LanguageServerException("Language Server not started") + raise SolidLSPException("Language Server not started") absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) uri = pathlib.Path(absolute_file_path).as_uri() @@ -483,7 +504,7 @@ class SolidLanguageServer(ABC): "insert_text_at_position called before Language Server started", logging.ERROR, ) - raise LanguageServerException("Language Server not started") + raise SolidLSPException("Language Server not started") absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) uri = pathlib.Path(absolute_file_path).as_uri() @@ -529,7 +550,7 @@ class SolidLanguageServer(ABC): "insert_text_at_position called before Language Server started", logging.ERROR, ) - raise LanguageServerException("Language Server not started") + raise SolidLSPException("Language Server not started") absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) uri = pathlib.Path(absolute_file_path).as_uri() @@ -573,7 +594,7 @@ class SolidLanguageServer(ABC): "request_definition called before Language Server started", logging.ERROR, ) - raise LanguageServerException("Language Server not started") + raise SolidLSPException("Language Server not started") with self.open_file(relative_file_path): # sending request to the language server and waiting for response @@ -665,14 +686,14 @@ class SolidLanguageServer(ABC): "request_references called before Language Server started", logging.ERROR, ) - raise LanguageServerException("Language Server not started") + raise SolidLSPException("Language Server not started") with self.open_file(relative_file_path): try: response = self._send_references_request(relative_file_path, line=line, column=column) except Exception as e: # Catch LSP internal error (-32603) and raise a more informative exception - if isinstance(e, Error) and getattr(e, "code", None) == -32603: + if isinstance(e, LSPError) and getattr(e, "code", None) == -32603: raise RuntimeError( f"LSP internal error (-32603) when requesting references for {relative_file_path}:{line}:{column}. " "This often occurs when requesting references for a symbol not referenced in the expected way. " @@ -724,7 +745,7 @@ class SolidLanguageServer(ABC): "request_text_document_diagnostics called before Language Server started", logging.ERROR, ) - raise LanguageServerException("Language Server not started") + raise SolidLSPException("Language Server not started") with self.open_file(relative_file_path): response = self.server.send.text_document_diagnostic( @@ -1286,7 +1307,7 @@ class SolidLanguageServer(ABC): "request_referencing_symbols called before Language Server started", logging.ERROR, ) - raise LanguageServerException("Language Server not started") + raise SolidLSPException("Language Server not started") # First, get all references to the symbol references = self.request_references(relative_file_path, line, column) @@ -1558,7 +1579,7 @@ class SolidLanguageServer(ABC): "request_defining_symbol called before Language Server started", logging.ERROR, ) - raise LanguageServerException("Language Server not started") + raise SolidLSPException("Language Server not started") # Get the definition location(s) definitions = self.request_definition(relative_file_path, line, column) diff --git a/src/solidlsp/ls_exceptions.py b/src/solidlsp/ls_exceptions.py index 0484da6b..7c03cfe5 100644 --- a/src/solidlsp/ls_exceptions.py +++ b/src/solidlsp/ls_exceptions.py @@ -1,15 +1,40 @@ """ -This module contains the exceptions raised by the Multilspy framework. +This module contains the exceptions raised by the framework. """ -class LanguageServerException(Exception): - """ - Exceptions raised by the Multilspy framework. - """ - - def __init__(self, message: str): +class SolidLSPException(Exception): + def __init__(self, message: str, cause: Exception | None = None): """ Initializes the exception with the given message. + + :param message: the message describing the exception + :param cause: the original exception that caused this exception, if any. + For exceptions raised during request handling, this is typically + * an LSPError for errors returned by the LSP server + * LanguageServerTerminatedException for errors due to the language server having terminated. """ + self.cause = cause super().__init__(message) + + def is_language_server_terminated(self): + """ + :return: True if the exception is caused by the language server having terminated as indicated + by the causing exception being an instance of LanguageServerTerminatedException. + """ + from .ls_handler import LanguageServerTerminatedException + + return isinstance(self.cause, LanguageServerTerminatedException) + + def __str__(self) -> str: + """ + Returns a string representation of the exception. + """ + s = super().__str__() + if self.cause: + if "\n" in s: + s += "\n" + else: + s += " " + s += f"(caused by {self.cause})" + return s diff --git a/src/solidlsp/ls_handler.py b/src/solidlsp/ls_handler.py index 731952f3..6a994ed3 100644 --- a/src/solidlsp/ls_handler.py +++ b/src/solidlsp/ls_handler.py @@ -12,14 +12,15 @@ from queue import Queue from typing import Any import psutil +from sensai.util.string import ToStringMixin -from solidlsp.ls_exceptions import LanguageServerException +from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_request import LanguageServerRequest from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes from solidlsp.lsp_protocol_handler.server import ( ENCODING, - Error, + LSPError, MessageType, PayloadLike, ProcessLaunchInfo, @@ -35,23 +36,50 @@ from solidlsp.lsp_protocol_handler.server import ( log = logging.getLogger(__name__) -class Request: +class LanguageServerTerminatedException(Exception): + """ + Exception raised when the language server process has terminated unexpectedly. + """ + + def __init__(self, message: str, cause: Exception | None = None) -> None: + super().__init__(message) + self.message = message + self.cause = cause + + def __str__(self) -> str: + return f"LanguageServerTerminatedException: {self.message}" + (f"; Cause: {self.cause}" if self.cause else "") + + +class Request(ToStringMixin): @dataclass class Result: payload: PayloadLike | None = None - error: Error | None = None + error: Exception | None = None def is_error(self) -> bool: return self.error is not None - def __init__(self) -> None: + def __init__(self, request_id: int, method: str) -> None: + self._request_id = request_id + self._method = method + self._status = "pending" self._result_queue = Queue() + def _tostring_includes(self) -> list[str]: + return ["_request_id", "_status", "_method"] + def on_result(self, params: PayloadLike) -> None: + self._status = "completed" self._result_queue.put(Request.Result(payload=params)) - def on_error(self, err: Error) -> None: + def on_error(self, err: Exception) -> None: + """ + :param err: the error that occurred while processing the request (typically an LSPError + for errors returned by the LS or LanguageServerTerminatedException if the error + is due to the language server process terminating unexpectedly). + """ + self._status = "error" self._result_queue.put(Request.Result(error=err)) def get_result(self, timeout: float | None = None) -> Result: @@ -75,10 +103,8 @@ class SolidLanguageServerHandler: notify: A LspNotification object that can be used to send notifications to the server. cmd: A string that represents the command to launch the language server process. process: A subprocess.Popen object that represents the language server process. - _received_shutdown: A boolean flag that indicates whether the client has received - a shutdown request from the server. request_id: An integer that represents the next available request id for the client. - _response_handlers: A dictionary that maps request ids to Request objects that + _pending_requests: A dictionary that maps request ids to Request objects that store the results or errors of the requests. on_request_handlers: A dictionary that maps method names to callback functions that handle requests from the server. @@ -104,15 +130,15 @@ class SolidLanguageServerHandler: start_independent_lsp_process=True, request_timeout: float | None = None, ) -> None: - self.send = LanguageServerRequest(self.send_request) + self.send = LanguageServerRequest(self) self.notify = LspNotification(self.send_notification) self.process_launch_info = process_launch_info - self.process = None - self._received_shutdown = False + self.process: subprocess.Popen | None = None + self._is_shutting_down = False self.request_id = 1 - self._response_handlers: dict[Any, Request] = {} + self._pending_requests: dict[Any, Request] = {} self.on_request_handlers = {} self.on_notification_handlers = {} self.logger = logger @@ -176,12 +202,12 @@ class SolidLanguageServerHandler: # start threads to read stdout and stderr of the process threading.Thread( - target=self.run_forever, + target=self._read_ls_process_stdout, name="LSP-stdout-reader", daemon=True, ).start() threading.Thread( - target=self.run_forever_stderr, + target=self._read_ls_process_stderr, name="LSP-stderr-reader", daemon=True, ).start() @@ -225,21 +251,6 @@ class SolidLanguageServerHandler: # First try to terminate the process tree gracefully self._signal_process_tree(process, terminate=True) - # TODO - """ - # Wait for the process to exit (with timeout) - try: - asyncio.wait_for(process.wait(), timeout=10) - except (asyncio.TimeoutError, Exception): - # If termination failed, forcefully kill the process tree - self._signal_process_tree(process, terminate=False) - try: - # Give it one more chance to exit - await asyncio.wait_for(process.wait(), timeout=2) - except Exception: - pass - """ - def _signal_process_tree(self, process, terminate=True): """Send signal (terminate or kill) to the process and all its children.""" signal_method = "terminate" if terminate else "kill" @@ -276,21 +287,13 @@ class SolidLanguageServerHandler: """ Perform the shutdown sequence for the client, including sending the shutdown request to the server and notifying it of exit """ + self._is_shutting_down = True self._log("Sending shutdown request to server") self.send.shutdown() self._log("Received shutdown response from server") - self._received_shutdown = True self._log("Sending exit notification to server") self.notify.exit() self._log("Sent exit notification to server") - # TODO - """ - if self.process and self.process.stdout: - self.process.stdout.set_exception(StopLoopException()) - # This yields the control to the event loop to allow the exception to be handled - # in the run_forever and run_forever_stderr methods - await asyncio.sleep(0) - """ def _log(self, message: str | StringDict) -> None: """ @@ -302,29 +305,30 @@ class SolidLanguageServerHandler: @staticmethod def _read_bytes_from_process(process, stream, num_bytes): """Read exactly num_bytes from process stdout""" - if process.poll() is not None: - # Process has terminated, check if we can still read - pass - data = b"" while len(data) < num_bytes: chunk = stream.read(num_bytes - len(data)) if not chunk: if process.poll() is not None: - raise EOFError(f"Process terminated. Expected {num_bytes} bytes, got {len(data)}") - # Process still running but no data available yet - time.sleep(0.01) # Small delay + raise LanguageServerTerminatedException( + f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)" + ) + # Process still running but no data available yet, retry after a short delay + time.sleep(0.01) continue data += chunk return data - def run_forever(self) -> bool: + def _read_ls_process_stdout(self) -> None: """ Continuously read from the language server process stdout and handle the messages invoking the registered response and notification handlers """ + exception: Exception | None = None try: - while self.process and self.process.stdout and self.process.stdout.readable(): + while self.process and self.process.stdout: + if self.process.poll() is not None: # process has terminated + break line = self.process.stdout.readline() if not line: continue @@ -341,22 +345,38 @@ class SolidLanguageServerHandler: body = self._read_bytes_from_process(self.process, self.process.stdout, num_bytes) self._handle_body(body) - except (BrokenPipeError, ConnectionResetError): - pass - return self._received_shutdown + except LanguageServerTerminatedException as e: + exception = e + except (BrokenPipeError, ConnectionResetError) as e: + exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", cause=e) + except Exception as e: + exception = LanguageServerTerminatedException("Unexpected error while reading stdout from language server process", cause=e) + log.info("Language server stdout reader thread has terminated") + if not self._is_shutting_down: + if exception is None: + exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly") + log.error(str(exception)) + self._cancel_pending_requests(exception) - def run_forever_stderr(self) -> None: + def _read_ls_process_stderr(self) -> None: """ Continuously read from the language server process stderr and log the messages """ try: - while self.process and self.process.stderr and self.process.stderr.readable(): + while self.process and self.process.stderr: + if self.process.poll() is not None: + # process has terminated + break line = self.process.stderr.readline() if not line: continue - self._log("LSP stderr: " + line.decode(ENCODING, errors="replace")) - except (BrokenPipeError, ConnectionResetError): - pass + log.error(line.decode(ENCODING, errors="replace")) + except Exception as e: + log.error("Error while reading stderr from language server process: %s", e, exc_info=e) + if not self._is_shutting_down: + log.error("Language server stderr reader thread terminated unexpectedly") + else: + log.info("Language server stderr reader thread has terminated") def _handle_body(self, body: bytes) -> None: """ @@ -402,37 +422,47 @@ class SolidLanguageServerHandler: """ self._send_payload(make_response(request_id, params)) - def send_error_response(self, request_id: Any, err: Error) -> None: + def send_error_response(self, request_id: Any, err: LSPError) -> None: """ Send error response to the given request id to the server with the given error """ # Use lock to prevent race conditions on tasks and task_counter self._send_payload(make_error_response(request_id, err)) + def _cancel_pending_requests(self, exception: Exception) -> None: + """ + Cancel all pending requests by setting their results to an error + """ + with self._response_handlers_lock: + log.info("Cancelling %d pending language server requests", len(self._pending_requests)) + for request in self._pending_requests.values(): + log.info("Cancelling %s", request) + request.on_error(exception) + self._pending_requests.clear() + def send_request(self, method: str, params: dict | None = None) -> PayloadLike: """ Send request to the server, register the request id, and wait for the response """ - request = Request() - - # Use lock to prevent race conditions on request_id and _response_handlers with self._request_id_lock: request_id = self.request_id self.request_id += 1 + request = Request(request_id=request_id, method=method) + log.debug("Starting: %s", request) + with self._response_handlers_lock: - self._response_handlers[request_id] = request + self._pending_requests[request_id] = request self._send_payload(make_request(method, request_id, params)) self._log(f"Waiting for response to request {method} with params:\n{params}") result = request.get_result(timeout=self._request_timeout) + log.debug("Completed: %s", request) self._log("Processing result") if result.is_error(): - raise LanguageServerException( - f"Could not process request {method} with params:\n{params}.\n Language server error: {result.error}" - ) from result.error + raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error self._log(f"Returning non-error result, which is:\n{result.payload}") return result.payload @@ -474,14 +504,14 @@ class SolidLanguageServerHandler: Handle the response received from the server for a request, using the id to determine the request """ with self._response_handlers_lock: - request = self._response_handlers.pop(response["id"]) + request = self._pending_requests.pop(response["id"]) if "result" in response and "error" not in response: request.on_result(response["result"]) elif "result" not in response and "error" in response: - request.on_error(Error.from_lsp(response["error"])) + request.on_error(LSPError.from_lsp(response["error"])) else: - request.on_error(Error(ErrorCodes.InvalidRequest, "")) + request.on_error(LSPError(ErrorCodes.InvalidRequest, "")) def _request_handler(self, response: StringDict) -> None: """ @@ -494,7 +524,7 @@ class SolidLanguageServerHandler: if not handler: self.send_error_response( request_id, - Error( + LSPError( ErrorCodes.MethodNotFound, f"method '{method}' not handled on client.", ), @@ -502,10 +532,10 @@ class SolidLanguageServerHandler: return try: self.send_response(request_id, handler(params)) - except Error as ex: + except LSPError as ex: self.send_error_response(request_id, ex) except Exception as ex: - self.send_error_response(request_id, Error(ErrorCodes.InternalError, str(ex))) + self.send_error_response(request_id, LSPError(ErrorCodes.InternalError, str(ex))) def _notification_handler(self, response: StringDict) -> None: """ @@ -522,7 +552,7 @@ class SolidLanguageServerHandler: except asyncio.CancelledError: return except Exception as ex: - if (not self._received_shutdown) and self.logger: + if (not self._is_shutting_down) and self.logger: self.logger( "client", "logger", diff --git a/src/solidlsp/ls_request.py b/src/solidlsp/ls_request.py index 209a3f04..cacd2872 100644 --- a/src/solidlsp/ls_request.py +++ b/src/solidlsp/ls_request.py @@ -1,11 +1,17 @@ -from typing import Union +from typing import TYPE_CHECKING, Any, Union from solidlsp.lsp_protocol_handler import lsp_types +if TYPE_CHECKING: + from .ls_handler import SolidLanguageServerHandler + class LanguageServerRequest: - def __init__(self, send_request): - self.send_request = send_request + def __init__(self, handler: "SolidLanguageServerHandler"): + self.handler = handler + + def _send_request(self, method: str, params: Any | None = None) -> Any: + return self.handler.send_request(method, params) def implementation(self, params: lsp_types.ImplementationParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: """A request to resolve the implementation locations of a symbol at a given text @@ -13,7 +19,7 @@ class LanguageServerRequest: (#TextDocumentPositionParams) the response is of type {@link Definition} or a Thenable that resolves to such. """ - return self.send_request("textDocument/implementation", params) + return self._send_request("textDocument/implementation", params) def type_definition( self, params: lsp_types.TypeDefinitionParams @@ -23,7 +29,7 @@ class LanguageServerRequest: (#TextDocumentPositionParams) the response is of type {@link Definition} or a Thenable that resolves to such. """ - return self.send_request("textDocument/typeDefinition", params) + return self._send_request("textDocument/typeDefinition", params) def document_color(self, params: lsp_types.DocumentColorParams) -> list["lsp_types.ColorInformation"]: """A request to list all color symbols found in a given text document. The request's @@ -31,7 +37,7 @@ class LanguageServerRequest: response is of type {@link ColorInformation ColorInformation[]} or a Thenable that resolves to such. """ - return self.send_request("textDocument/documentColor", params) + return self._send_request("textDocument/documentColor", params) def color_presentation(self, params: lsp_types.ColorPresentationParams) -> list["lsp_types.ColorPresentation"]: """A request to list all presentation for a color. The request's @@ -39,7 +45,7 @@ class LanguageServerRequest: response is of type {@link ColorInformation ColorInformation[]} or a Thenable that resolves to such. """ - return self.send_request("textDocument/colorPresentation", params) + return self._send_request("textDocument/colorPresentation", params) def folding_range(self, params: lsp_types.FoldingRangeParams) -> list["lsp_types.FoldingRange"] | None: """A request to provide folding ranges in a document. The request's @@ -47,7 +53,7 @@ class LanguageServerRequest: response is of type {@link FoldingRangeList} or a Thenable that resolves to such. """ - return self.send_request("textDocument/foldingRange", params) + return self._send_request("textDocument/foldingRange", params) def declaration(self, params: lsp_types.DeclarationParams) -> Union["lsp_types.Declaration", list["lsp_types.LocationLink"], None]: """A request to resolve the type definition locations of a symbol at a given text @@ -56,7 +62,7 @@ class LanguageServerRequest: or a typed array of {@link DeclarationLink} or a Thenable that resolves to such. """ - return self.send_request("textDocument/declaration", params) + return self._send_request("textDocument/declaration", params) def selection_range(self, params: lsp_types.SelectionRangeParams) -> list["lsp_types.SelectionRange"] | None: """A request to provide selection ranges in a document. The request's @@ -64,7 +70,7 @@ class LanguageServerRequest: response is of type {@link SelectionRange SelectionRange[]} or a Thenable that resolves to such. """ - return self.send_request("textDocument/selectionRange", params) + return self._send_request("textDocument/selectionRange", params) def prepare_call_hierarchy(self, params: lsp_types.CallHierarchyPrepareParams) -> list["lsp_types.CallHierarchyItem"] | None: """A request to result a `CallHierarchyItem` in a document at a given position. @@ -72,42 +78,42 @@ class LanguageServerRequest: @since 3.16.0 """ - return self.send_request("textDocument/prepareCallHierarchy", params) + return self._send_request("textDocument/prepareCallHierarchy", params) def incoming_calls(self, params: lsp_types.CallHierarchyIncomingCallsParams) -> list["lsp_types.CallHierarchyIncomingCall"] | None: """A request to resolve the incoming calls for a given `CallHierarchyItem`. @since 3.16.0 """ - return self.send_request("callHierarchy/incomingCalls", params) + return self._send_request("callHierarchy/incomingCalls", params) def outgoing_calls(self, params: lsp_types.CallHierarchyOutgoingCallsParams) -> list["lsp_types.CallHierarchyOutgoingCall"] | None: """A request to resolve the outgoing calls for a given `CallHierarchyItem`. @since 3.16.0 """ - return self.send_request("callHierarchy/outgoingCalls", params) + return self._send_request("callHierarchy/outgoingCalls", params) def semantic_tokens_full(self, params: lsp_types.SemanticTokensParams) -> Union["lsp_types.SemanticTokens", None]: """@since 3.16.0""" - return self.send_request("textDocument/semanticTokens/full", params) + return self._send_request("textDocument/semanticTokens/full", params) def semantic_tokens_delta( self, params: lsp_types.SemanticTokensDeltaParams ) -> Union["lsp_types.SemanticTokens", "lsp_types.SemanticTokensDelta", None]: """@since 3.16.0""" - return self.send_request("textDocument/semanticTokens/full/delta", params) + return self._send_request("textDocument/semanticTokens/full/delta", params) def semantic_tokens_range(self, params: lsp_types.SemanticTokensRangeParams) -> Union["lsp_types.SemanticTokens", None]: """@since 3.16.0""" - return self.send_request("textDocument/semanticTokens/range", params) + return self._send_request("textDocument/semanticTokens/range", params) def linked_editing_range(self, params: lsp_types.LinkedEditingRangeParams) -> Union["lsp_types.LinkedEditingRanges", None]: """A request to provide ranges that can be edited together. @since 3.16.0 """ - return self.send_request("textDocument/linkedEditingRange", params) + return self._send_request("textDocument/linkedEditingRange", params) def will_create_files(self, params: lsp_types.CreateFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: """The will create files request is sent from the client to the server before files are actually @@ -115,7 +121,7 @@ class LanguageServerRequest: @since 3.16.0 """ - return self.send_request("workspace/willCreateFiles", params) + return self._send_request("workspace/willCreateFiles", params) def will_rename_files(self, params: lsp_types.RenameFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: """The will rename files request is sent from the client to the server before files are actually @@ -123,7 +129,7 @@ class LanguageServerRequest: @since 3.16.0 """ - return self.send_request("workspace/willRenameFiles", params) + return self._send_request("workspace/willRenameFiles", params) def will_delete_files(self, params: lsp_types.DeleteFilesParams) -> Union["lsp_types.WorkspaceEdit", None]: """The did delete files notification is sent from the client to the server when @@ -131,14 +137,14 @@ class LanguageServerRequest: @since 3.16.0 """ - return self.send_request("workspace/willDeleteFiles", params) + return self._send_request("workspace/willDeleteFiles", params) def moniker(self, params: lsp_types.MonikerParams) -> list["lsp_types.Moniker"] | None: """A request to get the moniker of a symbol at a given text document position. The request parameter is of type {@link TextDocumentPositionParams}. The response is of type {@link Moniker Moniker[]} or `null`. """ - return self.send_request("textDocument/moniker", params) + return self._send_request("textDocument/moniker", params) def prepare_type_hierarchy(self, params: lsp_types.TypeHierarchyPrepareParams) -> list["lsp_types.TypeHierarchyItem"] | None: """A request to result a `TypeHierarchyItem` in a document at a given position. @@ -146,21 +152,21 @@ class LanguageServerRequest: @since 3.17.0 """ - return self.send_request("textDocument/prepareTypeHierarchy", params) + return self._send_request("textDocument/prepareTypeHierarchy", params) def type_hierarchy_supertypes(self, params: lsp_types.TypeHierarchySupertypesParams) -> list["lsp_types.TypeHierarchyItem"] | None: """A request to resolve the supertypes for a given `TypeHierarchyItem`. @since 3.17.0 """ - return self.send_request("typeHierarchy/supertypes", params) + return self._send_request("typeHierarchy/supertypes", params) def type_hierarchy_subtypes(self, params: lsp_types.TypeHierarchySubtypesParams) -> list["lsp_types.TypeHierarchyItem"] | None: """A request to resolve the subtypes for a given `TypeHierarchyItem`. @since 3.17.0 """ - return self.send_request("typeHierarchy/subtypes", params) + return self._send_request("typeHierarchy/subtypes", params) def inline_value(self, params: lsp_types.InlineValueParams) -> list["lsp_types.InlineValue"] | None: """A request to provide inline values in a document. The request's parameter is of @@ -169,7 +175,7 @@ class LanguageServerRequest: @since 3.17.0 """ - return self.send_request("textDocument/inlineValue", params) + return self._send_request("textDocument/inlineValue", params) def inlay_hint(self, params: lsp_types.InlayHintParams) -> list["lsp_types.InlayHint"] | None: """A request to provide inlay hints in a document. The request's parameter is of @@ -178,7 +184,7 @@ class LanguageServerRequest: @since 3.17.0 """ - return self.send_request("textDocument/inlayHint", params) + return self._send_request("textDocument/inlayHint", params) def resolve_inlay_hint(self, params: lsp_types.InlayHint) -> "lsp_types.InlayHint": """A request to resolve additional properties for an inlay hint. @@ -187,21 +193,21 @@ class LanguageServerRequest: @since 3.17.0 """ - return self.send_request("inlayHint/resolve", params) + return self._send_request("inlayHint/resolve", params) def text_document_diagnostic(self, params: lsp_types.DocumentDiagnosticParams) -> "lsp_types.DocumentDiagnosticReport": """The document diagnostic request definition. @since 3.17.0 """ - return self.send_request("textDocument/diagnostic", params) + return self._send_request("textDocument/diagnostic", params) def workspace_diagnostic(self, params: lsp_types.WorkspaceDiagnosticParams) -> "lsp_types.WorkspaceDiagnosticReport": """The workspace diagnostic request definition. @since 3.17.0 """ - return self.send_request("workspace/diagnostic", params) + return self._send_request("workspace/diagnostic", params) def initialize(self, params: lsp_types.InitializeParams) -> "lsp_types.InitializeResult": """The initialize request is sent from the client to the server. @@ -210,7 +216,7 @@ class LanguageServerRequest: the response if of type {@link InitializeResult} of a Thenable that resolves to such. """ - return self.send_request("initialize", params) + return self._send_request("initialize", params) def shutdown(self) -> None: """A shutdown request is sent from the client to the server. @@ -218,7 +224,7 @@ class LanguageServerRequest: server. The only notification that is sent after a shutdown request is the exit event. """ - return self.send_request("shutdown") + return self._send_request("shutdown") def will_save_wait_until(self, params: lsp_types.WillSaveTextDocumentParams) -> list["lsp_types.TextEdit"] | None: """A document will save request is sent from the client to the server before @@ -228,7 +234,7 @@ class LanguageServerRequest: server constantly fails on this request. This is done to keep the save fast and reliable. """ - return self.send_request("textDocument/willSaveWaitUntil", params) + return self._send_request("textDocument/willSaveWaitUntil", params) def completion(self, params: lsp_types.CompletionParams) -> Union[list["lsp_types.CompletionItem"], "lsp_types.CompletionList", None]: """Request to request completion at a given text document position. The request's @@ -241,24 +247,24 @@ class LanguageServerRequest: request. However, properties that are needed for the initial sorting and filtering, like `sortText`, `filterText`, `insertText`, and `textEdit`, must not be changed during resolve. """ - return self.send_request("textDocument/completion", params) + return self._send_request("textDocument/completion", params) def resolve_completion_item(self, params: lsp_types.CompletionItem) -> "lsp_types.CompletionItem": """Request to resolve additional information for a given completion item.The request's parameter is of type {@link CompletionItem} the response is of type {@link CompletionItem} or a Thenable that resolves to such. """ - return self.send_request("completionItem/resolve", params) + return self._send_request("completionItem/resolve", params) def hover(self, params: lsp_types.HoverParams) -> Union["lsp_types.Hover", None]: """Request to request hover information at a given text document position. The request's parameter is of type {@link TextDocumentPosition} the response is of type {@link Hover} or a Thenable that resolves to such. """ - return self.send_request("textDocument/hover", params) + return self._send_request("textDocument/hover", params) def signature_help(self, params: lsp_types.SignatureHelpParams) -> Union["lsp_types.SignatureHelp", None]: - return self.send_request("textDocument/signatureHelp", params) + return self._send_request("textDocument/signatureHelp", params) def definition(self, params: lsp_types.DefinitionParams) -> Union["lsp_types.Definition", list["lsp_types.LocationLink"], None]: """A request to resolve the definition location of a symbol at a given text @@ -267,7 +273,7 @@ class LanguageServerRequest: or a typed array of {@link DefinitionLink} or a Thenable that resolves to such. """ - return self.send_request("textDocument/definition", params) + return self._send_request("textDocument/definition", params) def references(self, params: lsp_types.ReferenceParams) -> list["lsp_types.Location"] | None: """A request to resolve project-wide references for the symbol denoted @@ -275,7 +281,7 @@ class LanguageServerRequest: type {@link ReferenceParams} the response is of type {@link Location Location[]} or a Thenable that resolves to such. """ - return self.send_request("textDocument/references", params) + return self._send_request("textDocument/references", params) def document_highlight(self, params: lsp_types.DocumentHighlightParams) -> list["lsp_types.DocumentHighlight"] | None: """Request to resolve a {@link DocumentHighlight} for a given @@ -283,7 +289,7 @@ class LanguageServerRequest: (#TextDocumentPosition) the request response is of type [DocumentHighlight[]] (#DocumentHighlight) or a Thenable that resolves to such. """ - return self.send_request("textDocument/documentHighlight", params) + return self._send_request("textDocument/documentHighlight", params) def document_symbol( self, params: lsp_types.DocumentSymbolParams @@ -293,18 +299,18 @@ class LanguageServerRequest: response is of type {@link SymbolInformation SymbolInformation[]} or a Thenable that resolves to such. """ - return self.send_request("textDocument/documentSymbol", params) + return self._send_request("textDocument/documentSymbol", params) def code_action(self, params: lsp_types.CodeActionParams) -> list[Union["lsp_types.Command", "lsp_types.CodeAction"]] | None: """A request to provide commands for the given text document and range.""" - return self.send_request("textDocument/codeAction", params) + return self._send_request("textDocument/codeAction", params) def resolve_code_action(self, params: lsp_types.CodeAction) -> "lsp_types.CodeAction": """Request to resolve additional information for a given code action.The request's parameter is of type {@link CodeAction} the response is of type {@link CodeAction} or a Thenable that resolves to such. """ - return self.send_request("codeAction/resolve", params) + return self._send_request("codeAction/resolve", params) def workspace_symbol( self, params: lsp_types.WorkspaceSymbolParams @@ -318,7 +324,7 @@ class LanguageServerRequest: need to advertise support for WorkspaceSymbols via the client capability `workspace.symbol.resolveSupport`. """ - return self.send_request("workspace/symbol", params) + return self._send_request("workspace/symbol", params) def resolve_workspace_symbol(self, params: lsp_types.WorkspaceSymbol) -> "lsp_types.WorkspaceSymbol": """A request to resolve the range inside the workspace @@ -326,52 +332,52 @@ class LanguageServerRequest: @since 3.17.0 """ - return self.send_request("workspaceSymbol/resolve", params) + return self._send_request("workspaceSymbol/resolve", params) def code_lens(self, params: lsp_types.CodeLensParams) -> list["lsp_types.CodeLens"] | None: """A request to provide code lens for the given text document.""" - return self.send_request("textDocument/codeLens", params) + return self._send_request("textDocument/codeLens", params) def resolve_code_lens(self, params: lsp_types.CodeLens) -> "lsp_types.CodeLens": """A request to resolve a command for a given code lens.""" - return self.send_request("codeLens/resolve", params) + return self._send_request("codeLens/resolve", params) def document_link(self, params: lsp_types.DocumentLinkParams) -> list["lsp_types.DocumentLink"] | None: """A request to provide document links""" - return self.send_request("textDocument/documentLink", params) + return self._send_request("textDocument/documentLink", params) def resolve_document_link(self, params: lsp_types.DocumentLink) -> "lsp_types.DocumentLink": """Request to resolve additional information for a given document link. The request's parameter is of type {@link DocumentLink} the response is of type {@link DocumentLink} or a Thenable that resolves to such. """ - return self.send_request("documentLink/resolve", params) + return self._send_request("documentLink/resolve", params) def formatting(self, params: lsp_types.DocumentFormattingParams) -> list["lsp_types.TextEdit"] | None: """A request to to format a whole document.""" - return self.send_request("textDocument/formatting", params) + return self._send_request("textDocument/formatting", params) def range_formatting(self, params: lsp_types.DocumentRangeFormattingParams) -> list["lsp_types.TextEdit"] | None: """A request to to format a range in a document.""" - return self.send_request("textDocument/rangeFormatting", params) + return self._send_request("textDocument/rangeFormatting", params) def on_type_formatting(self, params: lsp_types.DocumentOnTypeFormattingParams) -> list["lsp_types.TextEdit"] | None: """A request to format a document on type.""" - return self.send_request("textDocument/onTypeFormatting", params) + return self._send_request("textDocument/onTypeFormatting", params) def rename(self, params: lsp_types.RenameParams) -> Union["lsp_types.WorkspaceEdit", None]: """A request to rename a symbol.""" - return self.send_request("textDocument/rename", params) + return self._send_request("textDocument/rename", params) def prepare_rename(self, params: lsp_types.PrepareRenameParams) -> Union["lsp_types.PrepareRenameResult", None]: """A request to test and perform the setup necessary for a rename. @since 3.16 - support for default behavior """ - return self.send_request("textDocument/prepareRename", params) + return self._send_request("textDocument/prepareRename", params) def execute_command(self, params: lsp_types.ExecuteCommandParams) -> Union["lsp_types.LSPAny", None]: """A request send from the client to the server to execute a command. The request might return a workspace edit which the client will apply to the workspace. """ - return self.send_request("workspace/executeCommand", params) + return self._send_request("workspace/executeCommand", params) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index d1161880..db7e67fa 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -14,7 +14,7 @@ from pathlib import Path, PurePath import requests -from solidlsp.ls_exceptions import LanguageServerException +from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_types import UnifiedSymbolInformation @@ -170,29 +170,30 @@ class FileUtils: """ if not os.path.exists(file_path): logger.log(f"File read '{file_path}' failed: File does not exist.", logging.ERROR) - raise LanguageServerException(f"File read '{file_path}' failed: File does not exist.") + raise SolidLSPException(f"File read '{file_path}' failed: File does not exist.") try: with open(file_path, encoding="utf-8") as inp_file: return inp_file.read() except Exception as exc: logger.log(f"File read '{file_path}' failed to read with encoding 'utf-8': {exc}", logging.ERROR) - raise LanguageServerException("File read failed.") from None + raise SolidLSPException("File read failed.") from None @staticmethod def download_file(logger: LanguageServerLogger, url: str, target_path: str) -> None: """ Downloads the file from the given URL to the given {target_path} """ + os.makedirs(os.path.dirname(target_path), exist_ok=True) try: response = requests.get(url, stream=True, timeout=60) if response.status_code != 200: logger.log(f"Error downloading file '{url}': {response.status_code} {response.text}", logging.ERROR) - raise LanguageServerException("Error downloading file.") + raise SolidLSPException("Error downloading file.") with open(target_path, "wb") as f: shutil.copyfileobj(response.raw, f) except Exception as exc: logger.log(f"Error downloading file '{url}': {exc}", logging.ERROR) - raise LanguageServerException("Error downloading file.") from None + raise SolidLSPException("Error downloading file.") from None @staticmethod def download_and_extract_archive(logger: LanguageServerLogger, url: str, target_path: str, archive_type: str) -> None: @@ -220,10 +221,10 @@ class FileUtils: shutil.copyfileobj(f_in, f_out) else: logger.log(f"Unknown archive type '{archive_type}' for extraction", logging.ERROR) - raise LanguageServerException(f"Unknown archive type '{archive_type}'") + raise SolidLSPException(f"Unknown archive type '{archive_type}'") except Exception as exc: logger.log(f"Error extracting archive '{tmp_file_name}' obtained from '{url}': {exc}", logging.ERROR) - raise LanguageServerException("Error extracting archive.") from exc + raise SolidLSPException("Error extracting archive.") from exc finally: for tmp_file_name in tmp_files: if os.path.exists(tmp_file_name): @@ -289,7 +290,7 @@ class PlatformUtils: platform_id += "-" + libc return PlatformId(platform_id) else: - raise LanguageServerException(f"Unknown platform: {system=}, {machine=}, {bitness=}") + raise SolidLSPException(f"Unknown platform: {system=}, {machine=}, {bitness=}") @staticmethod def _determine_windows_machine_type(): @@ -345,7 +346,7 @@ class PlatformUtils: available_version_cmd_output.append(version_cmd_output) if not available_version_cmd_output: - raise LanguageServerException("dotnet not found on the system") + raise SolidLSPException("dotnet not found on the system") # Check for supported versions in order of preference (latest first) for version_cmd_output in available_version_cmd_output: @@ -361,7 +362,7 @@ class PlatformUtils: return DotnetVersion.V4 # If no supported version found, raise exception with all available versions - raise LanguageServerException( + raise SolidLSPException( f"No supported dotnet version found. Available versions: {', '.join(available_version_cmd_output)}. Supported versions: 4, 6, 7, 8" ) except (FileNotFoundError, subprocess.CalledProcessError): @@ -369,7 +370,7 @@ class PlatformUtils: result = subprocess.run(["mono", "--version"], capture_output=True, check=True) return DotnetVersion.VMONO except (FileNotFoundError, subprocess.CalledProcessError): - raise LanguageServerException("dotnet or mono not found on the system") + raise SolidLSPException("dotnet or mono not found on the system") class SymbolUtils: diff --git a/src/solidlsp/lsp_protocol_handler/server.py b/src/solidlsp/lsp_protocol_handler/server.py index 0cb59490..62d50e7f 100644 --- a/src/solidlsp/lsp_protocol_handler/server.py +++ b/src/solidlsp/lsp_protocol_handler/server.py @@ -59,7 +59,7 @@ class ProcessLaunchInfo: cwd: str = os.getcwd() -class Error(Exception): +class LSPError(Exception): def __init__(self, code: ErrorCodes, message: str) -> None: super().__init__(message) self.code = code @@ -68,8 +68,8 @@ class Error(Exception): return {"code": self.code, "message": super().__str__()} @classmethod - def from_lsp(cls, d: StringDict) -> "Error": - return Error(d["code"], d["message"]) + def from_lsp(cls, d: StringDict) -> "LSPError": + return LSPError(d["code"], d["message"]) def __str__(self) -> str: return f"{super().__str__()} ({self.code})" @@ -79,7 +79,7 @@ def make_response(request_id: Any, params: PayloadLike) -> StringDict: return {"jsonrpc": "2.0", "id": request_id, "result": params} -def make_error_response(request_id: Any, err: Error) -> StringDict: +def make_error_response(request_id: Any, err: LSPError) -> StringDict: return {"jsonrpc": "2.0", "id": request_id, "error": err.to_lsp()} diff --git a/src/solidlsp/settings.py b/src/solidlsp/settings.py index 38b30102..0b956bff 100644 --- a/src/solidlsp/settings.py +++ b/src/solidlsp/settings.py @@ -4,21 +4,17 @@ Defines settings for Solid-LSP import os import pathlib +from dataclasses import dataclass +@dataclass class SolidLSPSettings: - @staticmethod - def get_language_server_directory() -> str: - """Returns the directory for language servers""" - user_home = pathlib.Path.home() - multilspy_dir = str(pathlib.PurePath(user_home, ".multilspy")) - lsp_dir = str(pathlib.PurePath(multilspy_dir, "lsp")) - os.makedirs(lsp_dir, exist_ok=True) - return lsp_dir + solidlsp_dir: str = str(pathlib.Path.home() / ".solidlsp") - @staticmethod - def get_global_cache_directory() -> str: - """Returns the cache directory""" - global_cache_dir = os.path.join(str(pathlib.Path.home()), ".multilspy", "global_cache") - os.makedirs(global_cache_dir, exist_ok=True) - return global_cache_dir + def __post_init__(self): + os.makedirs(str(self.solidlsp_dir), exist_ok=True) + os.makedirs(str(self.ls_resources_dir), exist_ok=True) + + @property + def ls_resources_dir(self): + return os.path.join(str(self.solidlsp_dir), "language_servers", "static") diff --git a/test/conftest.py b/test/conftest.py index 90e522d0..849af543 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -4,11 +4,13 @@ from pathlib import Path import pytest from sensai.util.logging import configure +from serena.constants import SERENA_MANAGED_DIR_IN_HOME from serena.project import Project from serena.util.file_system import GitignoreParser from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.settings import SolidLSPSettings configure(level=logging.ERROR) @@ -43,7 +45,9 @@ def create_ls( ignored_paths.extend(spec.patterns) config = LanguageServerConfig(code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication) logger = LanguageServerLogger(log_level=log_level) - return SolidLanguageServer.create(config, logger, repo_path) + return SolidLanguageServer.create( + config, logger, repo_path, solidlsp_settings=SolidLSPSettings(solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME) + ) def create_default_ls(language: Language) -> SolidLanguageServer: diff --git a/test/serena/test_mcp.py b/test/serena/test_mcp.py index 37b6eca8..731a1cc6 100644 --- a/test/serena/test_mcp.py +++ b/test/serena/test_mcp.py @@ -4,16 +4,28 @@ import pytest from mcp.server.fastmcp.tools.base import Tool as MCPTool from serena.agent import Tool, ToolRegistry +from serena.config.context_mode import SerenaAgentContext from serena.mcp import SerenaMCPFactory make_tool = SerenaMCPFactory.make_mcp_tool +# Create a mock agent for tool initialization +class MockAgent: + def __init__(self): + self.project_config = None + self.serena_config = None + + @staticmethod + def get_context() -> SerenaAgentContext: + return SerenaAgentContext.load_default() + + class BaseMockTool(Tool): """A mock Tool class for testing.""" - def __init__(self) -> None: - pass + def __init__(self): + super().__init__(MockAgent()) class BasicTool(BaseMockTool): @@ -274,13 +286,6 @@ def is_test_mock_class(tool_class: type) -> bool: @pytest.mark.parametrize("tool_class", ToolRegistry().get_all_tool_classes()) def test_make_tool_all_tools(tool_class) -> None: """Test that make_tool works for all tools in the codebase.""" - - # Create a mock agent for tool initialization - class MockAgent: - def __init__(self): - self.project_config = None - self.serena_config = None - # Create an instance of the tool tool_instance = tool_class(MockAgent()) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 59f393ac..89d3caea 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -13,6 +13,7 @@ from solidlsp.language_servers.csharp_language_server import ( ) from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import SymbolUtils +from solidlsp.settings import SolidLSPSettings @pytest.mark.csharp @@ -214,7 +215,9 @@ class TestCSharpSolutionProjectOpening: mock_config.ignored_paths = [] # Create CSharpLanguageServer instance - CSharpLanguageServer(mock_config, mock_logger, str(temp_path)) + mock_settings = Mock(spec=SolidLSPSettings) + mock_settings.ls_resources_dir = "/tmp/test_ls_resources" + CSharpLanguageServer(mock_config, mock_logger, str(temp_path), mock_settings) # Verify that logger was called with solution file discovery mock_logger.log.assert_any_call(f"Found solution/project file: {solution_file}", 20) # logging.INFO @@ -236,7 +239,9 @@ class TestCSharpSolutionProjectOpening: mock_config.ignored_paths = [] # Create CSharpLanguageServer instance - CSharpLanguageServer(mock_config, mock_logger, str(temp_path)) + mock_settings = Mock(spec=SolidLSPSettings) + mock_settings.ls_resources_dir = "/tmp/test_ls_resources" + CSharpLanguageServer(mock_config, mock_logger, str(temp_path), mock_settings) # Verify that logger was called with warning about no solution/project files mock_logger.log.assert_any_call( diff --git a/uv.lock b/uv.lock index 52fe9264..a1169097 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = "==3.11.*" [[package]] name = "agno" -version = "1.7.2" +version = "1.7.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -20,9 +20,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/64/6a82d2375145fd733009db0ada531b595fc2f3c1712ac91c91b2e5fcf02f/agno-1.7.2.tar.gz", hash = "sha256:f4f97e5ea1f9c0781d9bb8396862c82671381386c1569cdb456d70f6c8d3a108", size = 661788, upload-time = "2025-07-10T15:50:06.217Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/84/d2390249d1b0150971a749df79cf653a631227b748fe10be1ef4f426ed2d/agno-1.7.6.tar.gz", hash = "sha256:8df575f86eac6511996045fb90d281ca61fd19774dccbb82f9dc34bda46effdf", size = 724233, upload-time = "2025-07-24T14:36:27.356Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/24/884cbd1133e3b9985d53733250251098caead365bb8706af8da105813abe/agno-1.7.2-py3-none-any.whl", hash = "sha256:37a1985e67762f0d8016318ba5d8b8697c855827d7c86b21a41cdff088294cc2", size = 888229, upload-time = "2025-07-10T15:50:04.262Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7e/5f85c92a34f41f8ffb1467768bae50506f734320c478ed24c4237dc342ea/agno-1.7.6-py3-none-any.whl", hash = "sha256:c6350e44747b37048d4ac080ffa5fb0e0ce2b3d5bc55d37120711ae8d31f27c6", size = 962755, upload-time = "2025-07-24T14:36:25.648Z" }, ] [[package]] @@ -36,7 +36,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.57.1" +version = "0.59.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -47,9 +47,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/75/6261a1a8d92aed47e27d2fcfb3a411af73b1435e6ae1186da02b760565d0/anthropic-0.57.1.tar.gz", hash = "sha256:7815dd92245a70d21f65f356f33fc80c5072eada87fb49437767ea2918b2c4b0", size = 423775, upload-time = "2025-07-03T16:57:35.932Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/52daff015f5a1f24eec891b3041f5f816712fea8b5113dc76638bcbc23d8/anthropic-0.59.0.tar.gz", hash = "sha256:d710d1ef0547ebbb64b03f219e44ba078e83fc83752b96a9b22e9726b523fd8f", size = 425679, upload-time = "2025-07-23T16:23:16.901Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/cf/ca0ba77805aec6171629a8b665c7dc224dab374539c3d27005b5d8c100a0/anthropic-0.57.1-py3-none-any.whl", hash = "sha256:33afc1f395af207d07ff1bffc0a3d1caac53c371793792569c5d2f09283ea306", size = 292779, upload-time = "2025-07-03T16:57:34.636Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b1/03f680393eac04afd8f2be44ee0e39e033c40faf43dbc1c11764b07a2687/anthropic-0.59.0-py3-none-any.whl", hash = "sha256:cbc8b3dccef66ad6435c4fa1d317e5ebb092399a4b88b33a09dc4bf3944c3183", size = 293057, upload-time = "2025-07-23T16:23:14.934Z" }, ] [[package]] @@ -130,11 +130,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.7.9" +version = "2025.7.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" }, + { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" }, ] [[package]] @@ -200,11 +200,11 @@ wheels = [ [[package]] name = "docstring-parser" -version = "0.16" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/12/9c22a58c0b1e29271051222d8906257616da84135af9ed167c9e28f85cb3/docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e", size = 26565, upload-time = "2024-03-15T10:39:44.419Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533, upload-time = "2024-03-15T10:39:41.527Z" }, + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] [[package]] @@ -267,14 +267,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.44" +version = "3.1.45" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196, upload-time = "2025-01-02T07:32:43.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599, upload-time = "2025-01-02T07:32:40.731Z" }, + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, ] [[package]] @@ -293,7 +293,7 @@ wheels = [ [[package]] name = "google-genai" -version = "1.25.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -305,9 +305,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/59/c9b9148c8702b60253f5a251f16ae436534c5d4362da193c9db05ac9858c/google_genai-1.25.0.tar.gz", hash = "sha256:a08a79c819a5d949d9948cd372e36e512bf85cd28158994daaa36d0ec4cb2b02", size = 228141, upload-time = "2025-07-09T20:53:47.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/37/6c0ececc3a7a629029b5beed2ceb9f28f73292236eb96272355636769b0d/google_genai-1.27.0.tar.gz", hash = "sha256:15a13ffe7b3938da50b9ab77204664d82122617256f55b5ce403d593848ef635", size = 220099, upload-time = "2025-07-23T22:00:46.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/ec/149f3d49b56cf848142071772aabb1c290b535bd9b5327a5dfccf1d00332/google_genai-1.25.0-py3-none-any.whl", hash = "sha256:fb5cee79b9a0a1b2afd5cfdf279099ecebd186551eefcaa6ec0c6016244e6138", size = 226847, upload-time = "2025-07-09T20:53:46.532Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/279afe7357af73f9737a3412b6f0bc1482075b896340eb46a2f9cb0fd791/google_genai-1.27.0-py3-none-any.whl", hash = "sha256:afd6b4efaf8ec1d20a6e6657d768b68d998d60007c6e220e9024e23c913c1833", size = 218489, upload-time = "2025-07-23T22:00:44.879Z" }, ] [[package]] @@ -489,7 +489,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.24.0" +version = "4.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -497,9 +497,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/d3/1cf5326b923a53515d8f3a2cd442e6d7e94fcc444716e879ea70a0ce3177/jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196", size = 353480, upload-time = "2025-05-26T18:48:10.459Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/00/a297a868e9d0784450faa7365c2172a7d6110c763e30ba861867c32ae6a9/jsonschema-4.25.0.tar.gz", hash = "sha256:e63acf5c11762c0e6672ffb61482bdf57f0876684d8d249c0fe2d730d48bc55f", size = 356830, upload-time = "2025-07-18T15:39:45.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/3d/023389198f69c722d039351050738d6755376c8fd343e91dc493ea485905/jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d", size = 88709, upload-time = "2025-05-26T18:48:08.417Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/c86cd8e011fe98803d7e382fd67c0df5ceab8d2b7ad8c5a81524f791551c/jsonschema-4.25.0-py3-none-any.whl", hash = "sha256:24c2e8da302de79c8b9382fee3e76b355e44d2a4364bb207159ce10b517bd716", size = 89184, upload-time = "2025-07-18T15:39:42.956Z" }, ] [[package]] @@ -558,7 +558,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.11.0" +version = "1.12.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -573,9 +573,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/f5/9506eb5578d5bbe9819ee8ba3198d0ad0e2fbe3bab8b257e4131ceb7dfb6/mcp-1.11.0.tar.gz", hash = "sha256:49a213df56bb9472ff83b3132a4825f5c8f5b120a90246f08b0dac6bedac44c8", size = 406907, upload-time = "2025-07-10T16:41:09.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/19/9955e2df5384ff5dd25d38f8e88aaf89d2d3d9d39f27e7383eaf0b293836/mcp-1.12.3.tar.gz", hash = "sha256:ab2e05f5e5c13e1dc90a4a9ef23ac500a6121362a564447855ef0ab643a99fed", size = 427203, upload-time = "2025-07-31T18:36:36.795Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/9c/c9ca79f9c512e4113a5d07043013110bb3369fc7770040c61378c7fbcf70/mcp-1.11.0-py3-none-any.whl", hash = "sha256:58deac37f7483e4b338524b98bc949b7c2b7c33d978f5fafab5bde041c5e2595", size = 155880, upload-time = "2025-07-10T16:41:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8b/0be74e3308a486f1d127f3f6767de5f9f76454c9b4183210c61cc50999b6/mcp-1.12.3-py3-none-any.whl", hash = "sha256:5483345bf39033b858920a5b6348a303acacf45b23936972160ff152107b850e", size = 158810, upload-time = "2025-07-31T18:36:34.915Z" }, ] [[package]] @@ -589,22 +589,22 @@ wheels = [ [[package]] name = "mypy" -version = "1.16.1" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114, upload-time = "2025-07-14T20:34:30.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, - { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, - { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, - { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, - { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/82efb502b0b0f661c49aa21cfe3e1999ddf64bf5500fc03b5a1536a39d39/mypy-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d4fe5c72fd262d9c2c91c1117d16aac555e05f5beb2bae6a755274c6eec42be", size = 10914150, upload-time = "2025-07-14T20:31:51.985Z" }, + { url = "https://files.pythonhosted.org/packages/03/96/8ef9a6ff8cedadff4400e2254689ca1dc4b420b92c55255b44573de10c54/mypy-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96b196e5c16f41b4f7736840e8455958e832871990c7ba26bf58175e357ed61", size = 10039845, upload-time = "2025-07-14T20:32:30.527Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/7ce359a56be779d38021d07941cfbb099b41411d72d827230a36203dbb81/mypy-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73a0ff2dd10337ceb521c080d4147755ee302dcde6e1a913babd59473904615f", size = 11837246, upload-time = "2025-07-14T20:32:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/b775047054de4d8dbd668df9137707e54b07fe18c7923839cd1e524bf756/mypy-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cfcc1179c4447854e9e406d3af0f77736d631ec87d31c6281ecd5025df625d", size = 12571106, upload-time = "2025-07-14T20:34:26.942Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/fa33eaf29a606102c8d9ffa45a386a04c2203d9ad18bf4eef3e20c43ebc8/mypy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56f180ff6430e6373db7a1d569317675b0a451caf5fef6ce4ab365f5f2f6c3", size = 12759960, upload-time = "2025-07-14T20:33:42.882Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/3f5a29209f27e739ca57e6350bc6b783a38c7621bdf9cac3ab8a08665801/mypy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:eafaf8b9252734400f9b77df98b4eee3d2eecab16104680d51341c75702cad70", size = 9503888, upload-time = "2025-07-14T20:32:34.392Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fc/ee058cc4316f219078464555873e99d170bde1d9569abd833300dbeb484a/mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496", size = 2283195, upload-time = "2025-07-14T20:31:54.753Z" }, ] [[package]] @@ -913,12 +913,12 @@ wheels = [ [[package]] name = "pywin32" -version = "310" +version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/b1/68aa2986129fb1011dabbe95f0136f44509afaf072b12b8f815905a39f33/pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd", size = 8784284, upload-time = "2025-03-17T00:55:53.124Z" }, - { url = "https://files.pythonhosted.org/packages/b3/bd/d1592635992dd8db5bb8ace0551bc3a769de1ac8850200cfa517e72739fb/pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c", size = 9520748, upload-time = "2025-03-17T00:55:55.203Z" }, - { url = "https://files.pythonhosted.org/packages/90/b1/ac8b1ffce6603849eb45a91cf126c0fa5431f186c2e768bf56889c46f51c/pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582", size = 8455941, upload-time = "2025-03-17T00:55:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, ] [[package]] @@ -992,15 +992,15 @@ wheels = [ [[package]] name = "rich" -version = "14.0.0" +version = "14.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, ] [[package]] @@ -1079,39 +1079,39 @@ wheels = [ [[package]] name = "ruff" -version = "0.12.3" +version = "0.12.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/2a/43955b530c49684d3c38fcda18c43caf91e99204c2a065552528e0552d4f/ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77", size = 4459341, upload-time = "2025-07-11T13:21:16.086Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/cd/01015eb5034605fd98d829c5839ec2c6b4582b479707f7c1c2af861e8258/ruff-0.12.5.tar.gz", hash = "sha256:b209db6102b66f13625940b7f8c7d0f18e20039bb7f6101fbdac935c9612057e", size = 5170722, upload-time = "2025-07-24T13:26:37.456Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fd/b44c5115539de0d598d75232a1cc7201430b6891808df111b8b0506aae43/ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2", size = 10430499, upload-time = "2025-07-11T13:20:26.321Z" }, - { url = "https://files.pythonhosted.org/packages/43/c5/9eba4f337970d7f639a37077be067e4ec80a2ad359e4cc6c5b56805cbc66/ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041", size = 11213413, upload-time = "2025-07-11T13:20:30.017Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2c/fac3016236cf1fe0bdc8e5de4f24c76ce53c6dd9b5f350d902549b7719b2/ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882", size = 10586941, upload-time = "2025-07-11T13:20:33.046Z" }, - { url = "https://files.pythonhosted.org/packages/c5/0f/41fec224e9dfa49a139f0b402ad6f5d53696ba1800e0f77b279d55210ca9/ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901", size = 10783001, upload-time = "2025-07-11T13:20:35.534Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/dd64a9ce56d9ed6cad109606ac014860b1c217c883e93bf61536400ba107/ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0", size = 10269641, upload-time = "2025-07-11T13:20:38.459Z" }, - { url = "https://files.pythonhosted.org/packages/63/5c/2be545034c6bd5ce5bb740ced3e7014d7916f4c445974be11d2a406d5088/ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6", size = 11875059, upload-time = "2025-07-11T13:20:41.517Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d4/a74ef1e801ceb5855e9527dae105eaff136afcb9cc4d2056d44feb0e4792/ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc", size = 12658890, upload-time = "2025-07-11T13:20:44.442Z" }, - { url = "https://files.pythonhosted.org/packages/13/c8/1057916416de02e6d7c9bcd550868a49b72df94e3cca0aeb77457dcd9644/ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687", size = 12232008, upload-time = "2025-07-11T13:20:47.374Z" }, - { url = "https://files.pythonhosted.org/packages/f5/59/4f7c130cc25220392051fadfe15f63ed70001487eca21d1796db46cbcc04/ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e", size = 11499096, upload-time = "2025-07-11T13:20:50.348Z" }, - { url = "https://files.pythonhosted.org/packages/d4/01/a0ad24a5d2ed6be03a312e30d32d4e3904bfdbc1cdbe63c47be9d0e82c79/ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311", size = 11688307, upload-time = "2025-07-11T13:20:52.945Z" }, - { url = "https://files.pythonhosted.org/packages/93/72/08f9e826085b1f57c9a0226e48acb27643ff19b61516a34c6cab9d6ff3fa/ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07", size = 10661020, upload-time = "2025-07-11T13:20:55.799Z" }, - { url = "https://files.pythonhosted.org/packages/80/a0/68da1250d12893466c78e54b4a0ff381370a33d848804bb51279367fc688/ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12", size = 10246300, upload-time = "2025-07-11T13:20:58.222Z" }, - { url = "https://files.pythonhosted.org/packages/6a/22/5f0093d556403e04b6fd0984fc0fb32fbb6f6ce116828fd54306a946f444/ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b", size = 11263119, upload-time = "2025-07-11T13:21:01.503Z" }, - { url = "https://files.pythonhosted.org/packages/92/c9/f4c0b69bdaffb9968ba40dd5fa7df354ae0c73d01f988601d8fac0c639b1/ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f", size = 11746990, upload-time = "2025-07-11T13:21:04.524Z" }, - { url = "https://files.pythonhosted.org/packages/fe/84/7cc7bd73924ee6be4724be0db5414a4a2ed82d06b30827342315a1be9e9c/ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d", size = 10589263, upload-time = "2025-07-11T13:21:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/07/87/c070f5f027bd81f3efee7d14cb4d84067ecf67a3a8efb43aadfc72aa79a6/ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7", size = 11695072, upload-time = "2025-07-11T13:21:11.004Z" }, - { url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855, upload-time = "2025-07-11T13:21:13.547Z" }, + { url = "https://files.pythonhosted.org/packages/d4/de/ad2f68f0798ff15dd8c0bcc2889558970d9a685b3249565a937cd820ad34/ruff-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:1de2c887e9dec6cb31fcb9948299de5b2db38144e66403b9660c9548a67abd92", size = 11819133, upload-time = "2025-07-24T13:25:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/c6b65cd0e7fbe60f17e7ad619dca796aa49fbca34bb9bea5f8faf1ec2643/ruff-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1ab65e7d8152f519e7dea4de892317c9da7a108da1c56b6a3c1d5e7cf4c5e9a", size = 12501114, upload-time = "2025-07-24T13:25:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/c6bec1dce5ead9f9e6a946ea15e8d698c35f19edc508289d70a577921b30/ruff-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:962775ed5b27c7aa3fdc0d8f4d4433deae7659ef99ea20f783d666e77338b8cf", size = 11716873, upload-time = "2025-07-24T13:26:01.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/16/cf372d2ebe91e4eb5b82a2275c3acfa879e0566a7ac94d331ea37b765ac8/ruff-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b4cae449597e7195a49eb1cdca89fd9fbb16140c7579899e87f4c85bf82f73", size = 11958829, upload-time = "2025-07-24T13:26:03.721Z" }, + { url = "https://files.pythonhosted.org/packages/25/bf/cd07e8f6a3a6ec746c62556b4c4b79eeb9b0328b362bb8431b7b8afd3856/ruff-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b13489c3dc50de5e2d40110c0cce371e00186b880842e245186ca862bf9a1ac", size = 11626619, upload-time = "2025-07-24T13:26:06.118Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c9/c2ccb3b8cbb5661ffda6925f81a13edbb786e623876141b04919d1128370/ruff-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1504fea81461cf4841778b3ef0a078757602a3b3ea4b008feb1308cb3f23e08", size = 13221894, upload-time = "2025-07-24T13:26:08.292Z" }, + { url = "https://files.pythonhosted.org/packages/6b/58/68a5be2c8e5590ecdad922b2bcd5583af19ba648f7648f95c51c3c1eca81/ruff-0.12.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c7da4129016ae26c32dfcbd5b671fe652b5ab7fc40095d80dcff78175e7eddd4", size = 14163909, upload-time = "2025-07-24T13:26:10.474Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d1/ef6b19622009ba8386fdb792c0743f709cf917b0b2f1400589cbe4739a33/ruff-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca972c80f7ebcfd8af75a0f18b17c42d9f1ef203d163669150453f50ca98ab7b", size = 13583652, upload-time = "2025-07-24T13:26:13.381Z" }, + { url = "https://files.pythonhosted.org/packages/62/e3/1c98c566fe6809a0c83751d825a03727f242cdbe0d142c9e292725585521/ruff-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8dbbf9f25dfb501f4237ae7501d6364b76a01341c6f1b2cd6764fe449124bb2a", size = 12700451, upload-time = "2025-07-24T13:26:15.488Z" }, + { url = "https://files.pythonhosted.org/packages/24/ff/96058f6506aac0fbc0d0fc0d60b0d0bd746240a0594657a2d94ad28033ba/ruff-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c47dea6ae39421851685141ba9734767f960113d51e83fd7bb9958d5be8763a", size = 12937465, upload-time = "2025-07-24T13:26:17.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d3/68bc5e7ab96c94b3589d1789f2dd6dd4b27b263310019529ac9be1e8f31b/ruff-0.12.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5076aa0e61e30f848846f0265c873c249d4b558105b221be1828f9f79903dc5", size = 11771136, upload-time = "2025-07-24T13:26:20.422Z" }, + { url = "https://files.pythonhosted.org/packages/52/75/7356af30a14584981cabfefcf6106dea98cec9a7af4acb5daaf4b114845f/ruff-0.12.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5a4c7830dadd3d8c39b1cc85386e2c1e62344f20766be6f173c22fb5f72f293", size = 11601644, upload-time = "2025-07-24T13:26:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/91c71d27205871737cae11025ee2b098f512104e26ffd8656fd93d0ada0a/ruff-0.12.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:46699f73c2b5b137b9dc0fc1a190b43e35b008b398c6066ea1350cce6326adcb", size = 12478068, upload-time = "2025-07-24T13:26:26.134Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/b6b00383cf2f48e8e78e14eb258942fdf2a9bf0287fbf5cdd398b749193a/ruff-0.12.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a655a0a0d396f0f072faafc18ebd59adde8ca85fb848dc1b0d9f024b9c4d3bb", size = 12991537, upload-time = "2025-07-24T13:26:28.533Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/053d6445dc7544fb6594785056d8ece61daae7214859ada4a152ad56b6e0/ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9", size = 11751575, upload-time = "2025-07-24T13:26:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" }, ] [[package]] name = "sensai-utils" -version = "1.4.0" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/b1/9dba08e2d8ea739b58d1ac393c066169f1ca6506c86f0322d6e56e600ae2/sensai_utils-1.4.0.tar.gz", hash = "sha256:2d32bdcc91fd1428c5cae0181e98623142d2d5f7e115e23d585a842dd9dc59ba", size = 56317, upload-time = "2025-01-27T22:47:51.121Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/dd/faa2e2de71a03af3def212c70777e794dd54ad5ab87927bb5c29f85f24fc/sensai_utils-1.5.0.tar.gz", hash = "sha256:2ca709a0d5807caf1632d665a455c173987b25276ce61693021672e875f0f17b", size = 61677, upload-time = "2025-07-28T22:46:10.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/df/49cc942b653427df0bbd3b9a2fb1e85652df3d4c1d9f6dcbfce1b6155716/sensai_utils-1.4.0-py3-none-any.whl", hash = "sha256:ed6fc57552620e43b33cf364ea0bc0fd7df39391069dd7b621b113ef55547507", size = 63214, upload-time = "2025-01-27T22:47:49.606Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d5/62a0e693230bace8e9a767d6d187a4d9421a7c6ee4b48551f8ff7bd1629a/sensai_utils-1.5.0-py3-none-any.whl", hash = "sha256:c23c51d23d353e7a9b77c70aa8ba7f2d0a8fe4e67ee5bc1ef41c69dba5e4befb", size = 68836, upload-time = "2025-07-28T22:46:08.929Z" }, ] [[package]] @@ -1174,7 +1174,7 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", marker = "extra == 'dev'" }, { name = "joblib", specifier = ">=1.5.1" }, - { name = "mcp", specifier = ">=1.5.0" }, + { name = "mcp", specifier = "==1.12.3" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.16.1" }, { name = "overrides", specifier = ">=7.7.0,<8" }, { name = "pathspec", specifier = ">=0.12.1" }, @@ -1189,7 +1189,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.3,<3" }, { name = "ruamel-yaml", specifier = ">=0.18.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.285" }, - { name = "sensai-utils", specifier = ">=1.4.0" }, + { name = "sensai-utils", specifier = ">=1.5.0" }, { name = "sqlalchemy", marker = "extra == 'agno'", specifier = ">=2.0.40" }, { name = "syrupy", marker = "extra == 'dev'", specifier = ">=4.9.1" }, { name = "tiktoken", specifier = ">=0.9.0" }, @@ -1250,14 +1250,14 @@ wheels = [ [[package]] name = "sse-starlette" -version = "2.4.1" +version = "3.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/3e/eae74d8d33e3262bae0a7e023bb43d8bdd27980aa3557333f4632611151f/sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926", size = 18635, upload-time = "2025-07-06T09:41:33.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/f1/6c7eaa8187ba789a6dd6d74430307478d2a91c23a5452ab339b6fbe15a08/sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a", size = 10824, upload-time = "2025-07-06T09:41:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, ] [[package]] @@ -1276,15 +1276,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.47.1" +version = "0.47.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/69/662169fdb92fb96ec3eaee218cf540a629d629c86d7993d9651226a6789b/starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b", size = 2583072, upload-time = "2025-06-21T04:03:17.337Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747, upload-time = "2025-06-21T04:03:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, ] [[package]]