diff --git a/pyproject.toml b/pyproject.toml index 81ca8571..6db113aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "pydantic>=2.10.6", "types-pyyaml>=6.0.12.20241230", "pyyaml>=6.0.2", + "ruamel.yaml>=0.18.0", "jinja2>=3.1.6", "dotenv>=0.9.9", "pathspec>=0.12.1", diff --git a/serena_config.template.yml b/serena_config.template.yml index ca8d0225..bfa00e22 100644 --- a/serena_config.template.yml +++ b/serena_config.template.yml @@ -7,17 +7,20 @@ enable_project_activation: True # Add your list of projects here (which you can switch between using "activate_project"). -# Every list item must be either -# - [Recommended] a path to the project's root directory (e.g. "/path/to/myproject"), with the -# configuration file being located in "/path/to/myproject/.serena/project.yml". -# In this case, the name of the project will be the name of the directory ("myproject"). -# - or a path to a .yml file, absolute or relative to the directory this -# configuration file is in (e.g. "myproject.yml" or "/path/to/wherever/myproject.yml"). -# In this case, the name of the project will be the base filename of the .yml file -# (e.g. "myproject" for "myproject.yml"). +# Every list item must be a path to the project's root directory (e.g. "/path/to/myproject"), with the +# configuration file being located in "/path/to/myproject/.serena/project.yml", or a dict mapping +# project_name to project_path (to avoid name collisions). +# You can activate a project by its name using the "activate_project" command. +# If the name is not explicitly set, the name of the project will be the name of the directory +# ("myproject" in the example above). +# Note: comments in the block below are not preserved when saving the config file, +# so we recommend to not have any comments directly below the "projects" key. projects: - /path/to/myproject + - project_name: /path/to/myproject + +gui_log_window: False # Whether to open a graphical window with Serena's logs (not supported on macOS). # This is useful both for troubleshooting and for monitoring the tool calls, # especially when using the agno playground, since the tool calls are not always shown, @@ -31,7 +34,6 @@ projects: # # Limitations: doesn't seem to work with the community version of Claude Desktop for Linux # Might also cause problems with some MCP clients - if you have any issues, try disabling this -gui_log_window: False -# minimum log level for the GUI log window (10 = debug, 20 = info, 30 = warning, 40 = error) gui_log_level: 20 +# minimum log level for the GUI log window (10 = debug, 20 = info, 30 = warning, 40 = error) diff --git a/src/serena/agent.py b/src/serena/agent.py index 527bfc2c..dd7e00dc 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -11,13 +11,14 @@ import traceback from abc import ABC from collections import defaultdict from collections.abc import Callable, Generator, Iterable, Sequence -from copy import copy -from dataclasses import dataclass, field +from copy import copy, deepcopy +from dataclasses import asdict, dataclass, field from logging import Logger from pathlib import Path from typing import TYPE_CHECKING, Any, Self, TypeVar, Union import yaml +from ruamel.yaml.comments import CommentedMap from sensai.util import logging from sensai.util.logging import FallbackHandler from sensai.util.string import ToStringMixin, dict_string @@ -32,7 +33,8 @@ from serena.prompt_factory import PromptFactory, SerenaPromptFactory from serena.symbol import SymbolLocation, SymbolManager from serena.text_utils import search_files from serena.util.file_system import scan_directory -from serena.util.inspection import iter_subclasses +from serena.util.general import load_yaml, save_yaml +from serena.util.inspection import determine_programming_language_composition, iter_subclasses from serena.util.shell import execute_shell_command if TYPE_CHECKING: @@ -78,10 +80,38 @@ class ProjectConfig(ToStringMixin): excluded_tools: set[str] = field(default_factory=set) read_only: bool = False ignore_all_files_in_gitignore: bool = True + project_info_prompt: str = "" SERENA_MANAGED_DIR = ".serena" SERENA_DEFAULT_PROJECT_FILE = "project.yml" + @classmethod + def autogenerate(cls, project_root: str | Path) -> Self: + """ + Autogenerate a project configuration for a given project root. + """ + project_root = os.path.abspath(project_root) + project_name = Path(project_root).name + language_composition = determine_programming_language_composition(project_root) + # find the language with the highest percentage + dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) + return cls( + project_name=project_name, + language=Language(dominant_language), + project_root=project_root, + ) + + def save(self) -> None: + """ + Save the project configuration to the project root. + """ + config_path = os.path.join(self.project_root, self.rel_path_to_project_yml()) + os.makedirs(os.path.dirname(config_path), exist_ok=True) + serialized_self = asdict(self) + serialized_self["language"] = self.language.value + serialized_self["excluded_tools"] = list(self.excluded_tools) + save_yaml(config_path, serialized_self) + @classmethod def rel_path_to_project_yml(cls) -> str: return os.path.join(cls.SERENA_MANAGED_DIR, cls.SERENA_DEFAULT_PROJECT_FILE) @@ -103,6 +133,7 @@ class ProjectConfig(ToStringMixin): ignored_paths = config_dict.get("ignored_paths", []) excluded_tools = set(config_dict.get("excluded_tools", [])) read_only = config_dict.get("read_only", False) + project_info_prompt = config_dict.get("project_info_prompt", "") if "ignore_all_files_in_gitignore" not in config_dict: raise SerenaConfigError( @@ -127,6 +158,7 @@ class ProjectConfig(ToStringMixin): excluded_tools=excluded_tools, read_only=read_only, ignore_all_files_in_gitignore=ignore_all_files_in_gitignore, + project_info_prompt=project_info_prompt, ) @classmethod @@ -181,49 +213,60 @@ class SerenaConfig: gui_log_window_enabled: bool = False gui_log_window_level: int = logging.INFO enable_project_activation: bool = True + loaded_original_yaml: dict | CommentedMap = field(default_factory=dict) CONFIG_FILE = "serena_config.yml" @classmethod - def from_config_file(cls) -> "SerenaConfig": + def get_config_file_path(cls) -> str: + return os.path.join(serena_root_path(), cls.CONFIG_FILE) + + @classmethod + def from_config_file(cls, preserve_comments: bool = False) -> "SerenaConfig": """ Static constructor to create SerenaConfig from the configuration file """ - config_file = os.path.join(serena_root_path(), cls.CONFIG_FILE) + config_file = cls.get_config_file_path() if not os.path.exists(config_file): raise FileNotFoundError(f"Serena configuration file not found: {config_file}") - with open(config_file, encoding="utf-8") as f: - try: - log.info(f"Loading Serena configuration from {config_file}") - config_yaml = yaml.safe_load(f) - except Exception as e: - raise ValueError(f"Error loading Serena configuration from {config_file}: {e}") from e + log.info(f"Loading Serena configuration from {config_file}") + try: + loaded_original_yaml = load_yaml(config_file, preserve_comments) + except Exception as e: + raise ValueError(f"Error loading Serena configuration from {config_file}: {e}") from e # Create instance instance = cls() # read projects - if "projects" not in config_yaml: + if "projects" not in loaded_original_yaml: raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.") projects = {} - for project_config_path in config_yaml["projects"]: - project_config_path = Path(project_config_path) - if not project_config_path.is_absolute(): - project_config_path = Path(serena_root_path()) / project_config_path - if project_config_path.is_dir(): # assume project file in default location - project_config_path = project_config_path / ProjectConfig.SERENA_MANAGED_DIR / ProjectConfig.SERENA_DEFAULT_PROJECT_FILE - if not project_config_path.is_file(): - raise FileNotFoundError(f"Project file not found: {project_config_path}") - log.info(f"Loading project configuration from {project_config_path}") - project_config = ProjectConfig.from_path(project_config_path) - projects[project_config.project_name] = project_config + for project_entry in loaded_original_yaml["projects"]: + if isinstance(project_entry, str): + project_path = Path(project_entry).resolve() + project_name = project_path.name + elif isinstance(project_entry, dict): + # should be a dict with a single entry mapping project name to project config path + if len(project_entry) != 1: + raise SerenaConfigError( + f"Invalid project configuration: expected a single entry mapping project name to project config path but got: {project_entry}." + ) + project_name, project_path = next(iter(project_entry.items())) + else: + raise SerenaConfigError(f"Invalid project configuration: expected a string or a dict, got {project_entry}.") + project_path = Path(project_path).resolve() + log.info(f"Loading project configuration from {project_path}") + project_config = ProjectConfig.from_path(project_path) + projects[project_name] = project_config instance.projects = projects instance.project_names = list(projects.keys()) - instance.gui_log_window_enabled = config_yaml.get("gui_log_window", False) - instance.gui_log_window_level = config_yaml.get("gui_log_level", logging.INFO) - instance.enable_project_activation = config_yaml.get("enable_project_activation", True) + instance.gui_log_window_enabled = loaded_original_yaml.get("gui_log_window", False) + instance.gui_log_window_level = loaded_original_yaml.get("gui_log_level", logging.INFO) + instance.enable_project_activation = loaded_original_yaml.get("enable_project_activation", True) + instance.loaded_original_yaml = loaded_original_yaml return instance @@ -232,6 +275,80 @@ class SerenaConfig: raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") return self.projects[project_name] + def save(self, preserve_comments: bool = True) -> None: + loaded_original_yaml = deepcopy(self.loaded_original_yaml) + loaded_original_yaml["projects"] = [ + {project_name: str(project_config.project_root)} for project_name, project_config in self.projects.items() + ] + save_yaml(self.get_config_file_path(), loaded_original_yaml, preserve_comments) + + def add_project_from_path( + self, path: Path | str, project_name: str | None = None, update_config_file: bool = True + ) -> tuple[str, ProjectConfig, bool]: + """ + Add a project to the Serena configuration from a given path. + """ + path = Path(path).resolve() + if not path.exists(): + raise FileNotFoundError(f"Error: Path does not exist: {path}") + if not path.is_dir(): + raise FileNotFoundError(f"Error: Path is not a directory: {path}") + + if project_name is None: + project_name = path.name + if project_name in self.projects.keys(): + project_config = self.projects[project_name] + if os.path.abspath(project_config.project_root) == os.path.abspath(path): + raise FileExistsError(f"Project '{project_name}' was already added and points to the correct path, you can activate it.") + raise FileExistsError( + f"Name collision: '{project_name=}' was already added, but points to a different path: {project_config.project_root}." + + "You can either use a different name or remove the existing project first before adding this one again." + + "If you are an LLM assistant, ask the user explicitly how they want to proceed before executing any further actions!" + ) + + try: + project_config = ProjectConfig.from_path(path) + new_config_was_generated = False + except FileNotFoundError: + project_config = ProjectConfig.autogenerate(path) + project_config.save() + new_config_was_generated = True + + self.add_project(project_config, project_name, update_config_file) + return project_name, project_config, new_config_was_generated + + def add_project(self, project_config: ProjectConfig, project_name: str | None = None, update_config_file: bool = True) -> None: + if project_name is None: + project_name = project_config.project_name + existing_project_config = self.projects.get(project_name) + if existing_project_config is not None: + if existing_project_config.project_root == project_config.project_root: + raise FileExistsError(f"Project '{project_name}' was already added and points to the correct path, you can activate it.") + raise FileExistsError( + f"Name collision: '{project_name=}' was already added, but points to a different path: {existing_project_config.project_root}." + + "You can either use a different name or remove the existing project first before adding this one again." + ) + self.projects[project_name] = project_config + self.project_names.append(project_name) + + # Update the config file on disk and preserve comments + if update_config_file: + config_with_comments = SerenaConfig.from_config_file(preserve_comments=True) + config_with_comments.projects[project_name] = project_config + config_with_comments.save(preserve_comments=True) + + def remove_project(self, project_name: str, update_config_file: bool = True) -> None: + if project_name not in self.projects: + raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") + del self.projects[project_name] + self.project_names.remove(project_name) + + # Update the config file on disk and preserve comments + if update_config_file: + config_with_comments = SerenaConfig.from_config_file(preserve_comments=True) + config_with_comments.projects.pop(project_name, None) + config_with_comments.save(preserve_comments=True) + class LinesRead: def __init__(self) -> None: @@ -324,7 +441,7 @@ class SerenaAgent: self._context = context self._modes = modes log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name() for tool in self._all_tools.values()])}") - + self._active_tools: dict[type[Tool], Tool] = {} self._update_active_tools() @@ -416,7 +533,7 @@ class SerenaAgent: log.info(f"Activating {project_config}") self.project_config = project_config self._update_active_tools() - + # start the language server self.reset_language_server() assert self.language_server is not None @@ -1537,12 +1654,77 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): :param project_name: the name of the project to activate """ - try: - project_config = self.agent.serena_config.get_project_configuration(project_name) - except ValueError as e: - return str(e) + project_config = self.agent.serena_config.get_project_configuration(project_name) self.agent.activate_project(project_config) - return SUCCESS_RESULT + return "Activated project " + + +class ListProjectsTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Lists all configured projects in the Serena configuration. + """ + + def apply(self) -> str: + """ + List all configured projects with their details including names, paths, and status. + """ + if not self.agent.serena_config.projects: + return "No projects configured." + + result = [] + result.append("Configured projects:") + result.append("") + + for project_name, project_config in self.agent.serena_config.projects.items(): + status = "✓ Active" if (self.agent.project_config and self.agent.project_config.project_name == project_name) else " Available" + result.append(f"{status} {project_name}") + result.append(f" Path: {project_config.project_root}") + result.append(f" Language: {project_config.language.value}") + result.append(f" Read-only: {project_config.read_only}") + result.append("") + + return "\n".join(result) + + +class AddProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Adds a new project to the Serena configuration. + If the project is not already configured, this tool will autogenerate a project configuration. + """ + + def apply(self, project_path: str, project_name: str | None = None) -> str: + """ + Adds a new project to the Serena configuration. + + :param project_path: Path to the project directory + :param project_name: Name of the project to add. If not provided, the directory name will be used. + """ + project_name, project_config, was_autogenerated = self.agent.serena_config.add_project_from_path( + project_path, project_name, update_config_file=True + ) + result_msg = f"Successfully added project '{project_name}' to configuration." + if was_autogenerated: + result_msg += ( + f"\nNote: A new project configuration was autogenerated because the given path did not contain a {ProjectConfig.SERENA_DEFAULT_PROJECT_FILE} file." + + f"You can now edit the project configuration in the file {project_config.rel_path_to_project_yml()}." + + f"Identified project programming language: {project_config.language.value}" + ) + return result_msg + + +class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Removes a project from the Serena configuration. + """ + + def apply(self, project_name: str) -> str: + """ + Removes a project from the Serena configuration. + + :param project_name: Name of the project to remove + """ + self.agent.serena_config.remove_project(project_name, update_config_file=True) + return f"Successfully removed project '{project_name}' from configuration." class SwitchModesTool(Tool): diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 374cfb00..6be8c739 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -144,8 +144,10 @@ def create_mcp_server_and_agent( @asynccontextmanager async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[None]: """Manage server startup and shutdown lifecycle.""" + nonlocal agent mark_used(mcp_server) yield + del agent mcp_settings = Settings(lifespan=server_lifespan, host=host, port=port) mcp = FastMCP(**mcp_settings.model_dump()) diff --git a/src/serena/util/general.py b/src/serena/util/general.py index e69de29b..8cabcd82 100644 --- a/src/serena/util/general.py +++ b/src/serena/util/general.py @@ -0,0 +1,24 @@ +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + + +def create_YAML(preserve_comments: bool = False) -> YAML: + """ + Creates a YAML that can load/save with comments if preserve_comments is True. + """ + typ = None if preserve_comments else "safe" + result = YAML(typ=typ) + result.preserve_quotes = preserve_comments + return result + + +def load_yaml(path: str, preserve_comments: bool = False) -> dict: + with open(path, encoding="utf-8") as f: + yaml = create_YAML(preserve_comments) + return yaml.load(f) + + +def save_yaml(path: str, data: dict | CommentedMap, preserve_comments: bool = False) -> None: + yaml = create_YAML(preserve_comments) + with open(path, "w", encoding="utf-8") as f: + yaml.dump(data, f) diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py index 36cffbe7..850518e0 100644 --- a/src/serena/util/inspection.py +++ b/src/serena/util/inspection.py @@ -1,6 +1,10 @@ +import os from collections.abc import Generator from typing import TypeVar +from multilspy.multilspy_config import Language +from serena.util.file_system import scan_directory + T = TypeVar("T") @@ -10,3 +14,54 @@ def iter_subclasses(cls: type[T], recursive: bool = True) -> Generator[type[T], yield subclass if recursive: yield from iter_subclasses(subclass, recursive) + + +def determine_programming_language_composition(repo_path: str) -> dict[str, float]: + """ + Determine the programming language composition of a repository. + + :param repo_path: Path to the repository to analyze + :return: Dictionary mapping language names to percentages of files matching each language + """ + if not os.path.exists(repo_path): + raise FileNotFoundError(f"Repository path does not exist: {repo_path}") + + if not os.path.isdir(repo_path): + raise ValueError(f"Repository path is not a directory: {repo_path}") + + # Scan all files in the repository recursively + _, all_files = scan_directory( + path=repo_path, + recursive=True, + relative_to=repo_path, + is_ignored_dir=lambda dirname: os.path.basename(dirname).startswith("."), # Ignore hidden directories + is_ignored_file=lambda filename: os.path.basename(filename).startswith("."), # Ignore hidden files + ) + + if not all_files: + return {} + + # Count files for each language + language_counts: dict[str, int] = {} + total_files = len(all_files) + + for language in Language: + matcher = language.get_source_fn_matcher() + count = 0 + + for file_path in all_files: + # Use just the filename for matching, not the full path + filename = os.path.basename(file_path) + if matcher.is_relevant_filename(filename): + count += 1 + + if count > 0: + language_counts[str(language)] = count + + # Convert counts to percentages + language_percentages: dict[str, float] = {} + for language_name, count in language_counts.items(): + percentage = (count / total_files) * 100 + language_percentages[language_name] = round(percentage, 2) + + return language_percentages diff --git a/uv.lock b/uv.lock index eef6518f..c74774a4 100644 --- a/uv.lock +++ b/uv.lock @@ -890,6 +890,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315, upload-time = "2022-07-20T10:28:34.978Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/46/f44d8be06b85bc7c4d8c95d658be2b68f27711f279bf9dd0612a5e4794f5/ruamel.yaml-0.18.10.tar.gz", hash = "sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58", size = 143447, upload-time = "2025-01-06T14:08:51.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/36/dfc1ebc0081e6d39924a2cc53654497f967a084a436bb64402dfce4254d9/ruamel.yaml-0.18.10-py3-none-any.whl", hash = "sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1", size = 117729, upload-time = "2025-01-06T14:08:47.471Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315, upload-time = "2024-10-20T10:10:56.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224, upload-time = "2024-10-20T10:12:45.162Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480, upload-time = "2024-10-20T10:12:46.758Z" }, + { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068, upload-time = "2024-10-20T10:12:48.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012, upload-time = "2024-10-20T10:12:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352, upload-time = "2024-10-21T11:26:41.438Z" }, + { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344, upload-time = "2024-10-21T11:26:43.62Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498, upload-time = "2024-12-11T19:58:15.592Z" }, + { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205, upload-time = "2024-10-20T10:12:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185, upload-time = "2024-10-20T10:12:54.652Z" }, +] + [[package]] name = "ruff" version = "0.11.5" @@ -946,6 +975,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, + { name = "ruamel-yaml" }, { name = "sensai-utils" }, { name = "types-pyyaml" }, ] @@ -998,6 +1028,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.0,<2" }, { name = "pyyaml", specifier = ">=6.0.2" }, { 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 = "sqlalchemy", marker = "extra == 'agno'", specifier = ">=2.0.40" },