Towards 0 config setup: tools for adding/removing projects

Possibility to autogenerate project-config
This commit is contained in:
Michael Panchenko committed 2025-05-26 00:49:19 +02:00
1 parent 10432473cf
commit a22b3c2486
7 files changed
+340 -43

No files matched your search

+1
View File
@@ -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",
+12 -10
View File
@@ -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)
+215 -33
View File
@@ -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):
+2
View File
@@ -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())
+24
View File
@@ -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)
+55
View File
@@ -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
Generated
+31
View File
@@ -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" },