tooluniverse 1.0.7__py3-none-any.whl → 1.0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of tooluniverse might be problematic. Click here for more details.
- tooluniverse/__init__.py +37 -14
- tooluniverse/admetai_tool.py +16 -5
- tooluniverse/base_tool.py +36 -0
- tooluniverse/biogrid_tool.py +118 -0
- tooluniverse/build_optimizer.py +87 -0
- tooluniverse/cache/__init__.py +3 -0
- tooluniverse/cache/memory_cache.py +99 -0
- tooluniverse/cache/result_cache_manager.py +235 -0
- tooluniverse/cache/sqlite_backend.py +257 -0
- tooluniverse/clinvar_tool.py +90 -0
- tooluniverse/compose_scripts/output_summarizer.py +87 -33
- tooluniverse/compose_tool.py +2 -2
- tooluniverse/custom_tool.py +28 -0
- tooluniverse/data/adverse_event_tools.json +97 -98
- tooluniverse/data/agentic_tools.json +81 -162
- tooluniverse/data/arxiv_tools.json +1 -4
- tooluniverse/data/compose_tools.json +0 -54
- tooluniverse/data/core_tools.json +1 -4
- tooluniverse/data/dataset_tools.json +7 -7
- tooluniverse/data/doaj_tools.json +1 -3
- tooluniverse/data/drug_discovery_agents.json +282 -0
- tooluniverse/data/europe_pmc_tools.json +1 -2
- tooluniverse/data/genomics_tools.json +174 -0
- tooluniverse/data/geo_tools.json +86 -0
- tooluniverse/data/literature_search_tools.json +15 -35
- tooluniverse/data/markitdown_tools.json +51 -0
- tooluniverse/data/monarch_tools.json +1 -2
- tooluniverse/data/openalex_tools.json +1 -5
- tooluniverse/data/opentarget_tools.json +8 -16
- tooluniverse/data/output_summarization_tools.json +23 -20
- tooluniverse/data/packages/bioinformatics_core_tools.json +2 -2
- tooluniverse/data/packages/cheminformatics_tools.json +1 -1
- tooluniverse/data/packages/genomics_tools.json +1 -1
- tooluniverse/data/packages/single_cell_tools.json +1 -1
- tooluniverse/data/packages/structural_biology_tools.json +1 -1
- tooluniverse/data/pmc_tools.json +1 -4
- tooluniverse/data/ppi_tools.json +139 -0
- tooluniverse/data/pubmed_tools.json +1 -3
- tooluniverse/data/semantic_scholar_tools.json +1 -2
- tooluniverse/data/tool_composition_tools.json +2 -4
- tooluniverse/data/unified_guideline_tools.json +206 -4
- tooluniverse/data/xml_tools.json +15 -15
- tooluniverse/data/zenodo_tools.json +1 -2
- tooluniverse/dbsnp_tool.py +71 -0
- tooluniverse/default_config.py +6 -0
- tooluniverse/ensembl_tool.py +61 -0
- tooluniverse/execute_function.py +235 -76
- tooluniverse/generate_tools.py +303 -20
- tooluniverse/genomics_gene_search_tool.py +56 -0
- tooluniverse/geo_tool.py +116 -0
- tooluniverse/gnomad_tool.py +63 -0
- tooluniverse/logging_config.py +64 -2
- tooluniverse/markitdown_tool.py +159 -0
- tooluniverse/mcp_client_tool.py +10 -5
- tooluniverse/molecule_2d_tool.py +9 -3
- tooluniverse/molecule_3d_tool.py +9 -3
- tooluniverse/output_hook.py +217 -150
- tooluniverse/smcp.py +18 -10
- tooluniverse/smcp_server.py +89 -199
- tooluniverse/string_tool.py +112 -0
- tooluniverse/tools/{MultiAgentLiteratureSearch.py → ADMETAnalyzerAgent.py} +18 -18
- tooluniverse/tools/ArXiv_search_papers.py +3 -3
- tooluniverse/tools/CMA_Guidelines_Search.py +52 -0
- tooluniverse/tools/CORE_search_papers.py +3 -3
- tooluniverse/tools/ClinVar_search_variants.py +52 -0
- tooluniverse/tools/ClinicalTrialDesignAgent.py +63 -0
- tooluniverse/tools/CompoundDiscoveryAgent.py +59 -0
- tooluniverse/tools/DOAJ_search_articles.py +2 -2
- tooluniverse/tools/DiseaseAnalyzerAgent.py +52 -0
- tooluniverse/tools/DrugInteractionAnalyzerAgent.py +52 -0
- tooluniverse/tools/DrugOptimizationAgent.py +63 -0
- tooluniverse/tools/Ensembl_lookup_gene_by_symbol.py +52 -0
- tooluniverse/tools/EuropePMC_search_articles.py +1 -1
- tooluniverse/tools/GIN_Guidelines_Search.py +52 -0
- tooluniverse/tools/GWAS_search_associations_by_gene.py +52 -0
- tooluniverse/tools/LiteratureSynthesisAgent.py +59 -0
- tooluniverse/tools/PMC_search_papers.py +3 -3
- tooluniverse/tools/PubMed_search_articles.py +2 -2
- tooluniverse/tools/SemanticScholar_search_papers.py +1 -1
- tooluniverse/tools/UCSC_get_genes_by_region.py +67 -0
- tooluniverse/tools/Zenodo_search_records.py +1 -1
- tooluniverse/tools/__init__.py +33 -3
- tooluniverse/tools/convert_to_markdown.py +59 -0
- tooluniverse/tools/dbSNP_get_variant_by_rsid.py +46 -0
- tooluniverse/tools/gnomAD_query_variant.py +52 -0
- tooluniverse/tools/openalex_literature_search.py +4 -4
- tooluniverse/ucsc_tool.py +60 -0
- tooluniverse/unified_guideline_tools.py +1175 -57
- tooluniverse/utils.py +51 -4
- tooluniverse/zenodo_tool.py +2 -1
- {tooluniverse-1.0.7.dist-info → tooluniverse-1.0.9.dist-info}/METADATA +10 -3
- {tooluniverse-1.0.7.dist-info → tooluniverse-1.0.9.dist-info}/RECORD +96 -61
- {tooluniverse-1.0.7.dist-info → tooluniverse-1.0.9.dist-info}/entry_points.txt +0 -3
- {tooluniverse-1.0.7.dist-info → tooluniverse-1.0.9.dist-info}/WHEEL +0 -0
- {tooluniverse-1.0.7.dist-info → tooluniverse-1.0.9.dist-info}/licenses/LICENSE +0 -0
- {tooluniverse-1.0.7.dist-info → tooluniverse-1.0.9.dist-info}/top_level.txt +0 -0
tooluniverse/__init__.py
CHANGED
|
@@ -1,39 +1,54 @@
|
|
|
1
1
|
from importlib.metadata import version
|
|
2
|
+
import os
|
|
2
3
|
import warnings
|
|
3
4
|
from typing import Any, Optional, List
|
|
5
|
+
|
|
4
6
|
from .execute_function import ToolUniverse
|
|
5
7
|
from .base_tool import BaseTool
|
|
6
8
|
from .default_config import default_tool_files
|
|
9
|
+
from .tool_registry import register_tool, get_tool_registry
|
|
10
|
+
|
|
11
|
+
_LIGHT_IMPORT = os.getenv("TOOLUNIVERSE_LIGHT_IMPORT", "false").lower() in (
|
|
12
|
+
"true",
|
|
13
|
+
"1",
|
|
14
|
+
"yes",
|
|
15
|
+
)
|
|
7
16
|
|
|
8
17
|
# Version information - read from package metadata or pyproject.toml
|
|
9
18
|
__version__ = version("tooluniverse")
|
|
10
|
-
from .tool_registry import register_tool, get_tool_registry
|
|
11
19
|
|
|
12
20
|
# Import tools with graceful fallback
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
if not _LIGHT_IMPORT:
|
|
22
|
+
try:
|
|
23
|
+
from . import tools
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
except ImportError:
|
|
25
|
+
_TOOLS_AVAILABLE = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
_TOOLS_AVAILABLE = False
|
|
28
|
+
tools = None # type: ignore
|
|
29
|
+
else:
|
|
18
30
|
_TOOLS_AVAILABLE = False
|
|
19
31
|
tools = None # type: ignore
|
|
20
32
|
|
|
21
33
|
# Check if lazy loading is enabled
|
|
22
|
-
# LAZY_LOADING_ENABLED = os.getenv('TOOLUNIVERSE_LAZY_LOADING', 'true').lower() in (
|
|
34
|
+
# LAZY_LOADING_ENABLED = os.getenv('TOOLUNIVERSE_LAZY_LOADING', 'true').lower() in (
|
|
35
|
+
# 'true', '1', 'yes'
|
|
36
|
+
# )
|
|
23
37
|
LAZY_LOADING_ENABLED = (
|
|
24
38
|
False # LAZY LOADING DISABLED BECAUSE IT'S STILL UNDER DEVELOPMENT
|
|
25
39
|
)
|
|
26
40
|
|
|
27
41
|
# Import MCP functionality
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
if not _LIGHT_IMPORT:
|
|
43
|
+
try:
|
|
44
|
+
from .mcp_integration import _patch_tooluniverse
|
|
30
45
|
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
# Automatically patch ToolUniverse with MCP methods
|
|
47
|
+
_patch_tooluniverse()
|
|
33
48
|
|
|
34
|
-
except ImportError:
|
|
35
|
-
|
|
36
|
-
|
|
49
|
+
except ImportError:
|
|
50
|
+
# MCP functionality not available
|
|
51
|
+
pass
|
|
37
52
|
|
|
38
53
|
# Import SMCP with graceful fallback and consistent signatures for type checking
|
|
39
54
|
try:
|
|
@@ -162,13 +177,21 @@ ComposeTool: Any
|
|
|
162
177
|
CellosaurusSearchTool: Any
|
|
163
178
|
CellosaurusQueryConverterTool: Any
|
|
164
179
|
CellosaurusGetCellLineInfoTool: Any
|
|
165
|
-
if not LAZY_LOADING_ENABLED:
|
|
180
|
+
if not _LIGHT_IMPORT and not LAZY_LOADING_ENABLED:
|
|
166
181
|
# Import all tool classes immediately (old behavior) with warning suppression # noqa: E501
|
|
167
182
|
with warnings.catch_warnings():
|
|
168
183
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
169
184
|
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
|
170
185
|
warnings.filterwarnings("ignore", category=UserWarning)
|
|
171
186
|
warnings.filterwarnings("ignore", category=FutureWarning)
|
|
187
|
+
# Suppress specific third-party warnings
|
|
188
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="hyperopt")
|
|
189
|
+
warnings.filterwarnings(
|
|
190
|
+
"ignore", category=DeprecationWarning, module="pkg_resources"
|
|
191
|
+
)
|
|
192
|
+
warnings.filterwarnings(
|
|
193
|
+
"ignore", category=RuntimeWarning, module="importlib._bootstrap"
|
|
194
|
+
)
|
|
172
195
|
|
|
173
196
|
from .restful_tool import MonarchTool, MonarchDiseasesForMultiplePhenoTool
|
|
174
197
|
from .ctg_tool import ClinicalTrialsSearchTool, ClinicalTrialsDetailsTool
|
tooluniverse/admetai_tool.py
CHANGED
|
@@ -2,6 +2,7 @@ import numpy
|
|
|
2
2
|
from .base_tool import BaseTool
|
|
3
3
|
from .tool_registry import register_tool
|
|
4
4
|
import torch
|
|
5
|
+
import warnings
|
|
5
6
|
|
|
6
7
|
# Patch for numpy.VisibleDeprecationWarning for newer numpy versions
|
|
7
8
|
if not hasattr(numpy, "VisibleDeprecationWarning"):
|
|
@@ -21,7 +22,13 @@ def _patched_torch_load(*args, **kwargs):
|
|
|
21
22
|
|
|
22
23
|
torch.load = _patched_torch_load
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
# Suppress admet-ai specific warnings during import
|
|
26
|
+
with warnings.catch_warnings():
|
|
27
|
+
warnings.filterwarnings(
|
|
28
|
+
"ignore", category=DeprecationWarning, module="pkg_resources"
|
|
29
|
+
)
|
|
30
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning, module="admet_ai")
|
|
31
|
+
from admet_ai import ADMETModel # noqa: E402
|
|
25
32
|
|
|
26
33
|
|
|
27
34
|
@register_tool("ADMETAITool")
|
|
@@ -30,13 +37,15 @@ class ADMETAITool(BaseTool):
|
|
|
30
37
|
|
|
31
38
|
def __init__(self, **kwargs):
|
|
32
39
|
super().__init__(**kwargs)
|
|
40
|
+
# Initialize the model once during tool initialization
|
|
41
|
+
self.model = ADMETModel()
|
|
33
42
|
|
|
34
43
|
def _predict(self, smiles: str) -> dict:
|
|
35
44
|
"""
|
|
36
45
|
Gets ADMET predictions for the given smiles
|
|
37
46
|
"""
|
|
38
|
-
model
|
|
39
|
-
preds = model.predict(smiles=smiles)
|
|
47
|
+
# Reuse the pre-loaded model instead of creating a new one
|
|
48
|
+
preds = self.model.predict(smiles=smiles)
|
|
40
49
|
return preds
|
|
41
50
|
|
|
42
51
|
def run(self, arguments: dict) -> dict:
|
|
@@ -47,7 +56,8 @@ class ADMETAITool(BaseTool):
|
|
|
47
56
|
smiles: The SMILES string(s) of the molecule(s).
|
|
48
57
|
|
|
49
58
|
Returns:
|
|
50
|
-
A dictionary mapping each SMILES string to a subdictionary of
|
|
59
|
+
A dictionary mapping each SMILES string to a subdictionary of
|
|
60
|
+
selected ADMET properties and their predicted values.
|
|
51
61
|
"""
|
|
52
62
|
smiles = arguments.get("smiles", [])
|
|
53
63
|
if not smiles:
|
|
@@ -65,7 +75,8 @@ class ADMETAITool(BaseTool):
|
|
|
65
75
|
):
|
|
66
76
|
return {"error": "No predictions could be extracted."}
|
|
67
77
|
|
|
68
|
-
# Expand columns to include _drugbank_approved_percentile columns
|
|
78
|
+
# Expand columns to include _drugbank_approved_percentile columns
|
|
79
|
+
# if present
|
|
69
80
|
if columns is not None:
|
|
70
81
|
expanded_columns = []
|
|
71
82
|
for col in columns:
|
tooluniverse/base_tool.py
CHANGED
|
@@ -13,11 +13,15 @@ import json
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import no_type_check, Optional, Dict, Any
|
|
15
15
|
import hashlib
|
|
16
|
+
import inspect
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class BaseTool:
|
|
20
|
+
STATIC_CACHE_VERSION = "1"
|
|
21
|
+
|
|
19
22
|
def __init__(self, tool_config):
|
|
20
23
|
self.tool_config = self._apply_defaults(tool_config)
|
|
24
|
+
self._cached_version_hash: Optional[str] = None
|
|
21
25
|
|
|
22
26
|
@classmethod
|
|
23
27
|
def get_default_config_file(cls):
|
|
@@ -286,6 +290,38 @@ class BaseTool:
|
|
|
286
290
|
"""
|
|
287
291
|
return self.tool_config.get("cacheable", True)
|
|
288
292
|
|
|
293
|
+
def get_cache_namespace(self) -> str:
|
|
294
|
+
"""Return cache namespace identifier for this tool."""
|
|
295
|
+
return self.tool_config.get("name", self.__class__.__name__)
|
|
296
|
+
|
|
297
|
+
def get_cache_version(self) -> str:
|
|
298
|
+
"""Return a stable cache version fingerprint for this tool."""
|
|
299
|
+
if self._cached_version_hash:
|
|
300
|
+
return self._cached_version_hash
|
|
301
|
+
|
|
302
|
+
hasher = hashlib.sha256()
|
|
303
|
+
hasher.update(self.STATIC_CACHE_VERSION.encode("utf-8"))
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
source = inspect.getsource(self.__class__)
|
|
307
|
+
hasher.update(source.encode("utf-8"))
|
|
308
|
+
except (OSError, TypeError):
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
schema = json.dumps(self.tool_config.get("parameter", {}), sort_keys=True)
|
|
313
|
+
hasher.update(schema.encode("utf-8"))
|
|
314
|
+
except (TypeError, ValueError):
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
self._cached_version_hash = hasher.hexdigest()[:16]
|
|
318
|
+
return self._cached_version_hash
|
|
319
|
+
|
|
320
|
+
def get_cache_ttl(self, result: Any = None) -> Optional[int]:
|
|
321
|
+
"""Return TTL (seconds) for cached results; None means no expiration."""
|
|
322
|
+
ttl = self.tool_config.get("cache_ttl")
|
|
323
|
+
return int(ttl) if ttl is not None else None
|
|
324
|
+
|
|
289
325
|
def get_tool_info(self) -> Dict[str, Any]:
|
|
290
326
|
"""
|
|
291
327
|
Get comprehensive information about this tool.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BioGRID Database REST API Tool
|
|
3
|
+
|
|
4
|
+
This tool provides access to protein and genetic interaction data from the BioGRID database.
|
|
5
|
+
BioGRID is a comprehensive database of physical and genetic interactions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from typing import Dict, Any, List
|
|
10
|
+
from .base_tool import BaseTool
|
|
11
|
+
from .tool_registry import register_tool
|
|
12
|
+
|
|
13
|
+
BIOGRID_BASE_URL = "https://webservice.thebiogrid.org"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@register_tool("BioGRIDRESTTool")
|
|
17
|
+
class BioGRIDRESTTool(BaseTool):
|
|
18
|
+
"""
|
|
19
|
+
BioGRID Database REST API tool.
|
|
20
|
+
Generic wrapper for BioGRID API endpoints defined in ppi_tools.json.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, tool_config):
|
|
24
|
+
super().__init__(tool_config)
|
|
25
|
+
fields = tool_config.get("fields", {})
|
|
26
|
+
parameter = tool_config.get("parameter", {})
|
|
27
|
+
|
|
28
|
+
self.endpoint_template: str = fields.get("endpoint", "/interactions/")
|
|
29
|
+
self.required: List[str] = parameter.get("required", [])
|
|
30
|
+
self.output_format: str = fields.get("return_format", "JSON")
|
|
31
|
+
|
|
32
|
+
def _build_url(self, arguments: Dict[str, Any]) -> str | Dict[str, Any]:
|
|
33
|
+
"""Build URL for BioGRID API request."""
|
|
34
|
+
url_path = self.endpoint_template
|
|
35
|
+
return BIOGRID_BASE_URL + url_path
|
|
36
|
+
|
|
37
|
+
def _build_params(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
38
|
+
"""Build parameters for BioGRID API request."""
|
|
39
|
+
params = {"format": "json", "interSpeciesExcluded": "false"}
|
|
40
|
+
|
|
41
|
+
# Check for API key
|
|
42
|
+
api_key = arguments.get("api_key") or arguments.get("accesskey")
|
|
43
|
+
if not api_key:
|
|
44
|
+
# Try to get from environment variable
|
|
45
|
+
import os
|
|
46
|
+
|
|
47
|
+
api_key = os.getenv("BIOGRID_API_KEY")
|
|
48
|
+
|
|
49
|
+
if not api_key:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"BioGRID API key is required. Please provide 'api_key' parameter "
|
|
52
|
+
"or set BIOGRID_API_KEY environment variable. "
|
|
53
|
+
"Register at: https://webservice.thebiogrid.org/"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
params["accesskey"] = api_key
|
|
57
|
+
|
|
58
|
+
# Map gene names to BioGRID format
|
|
59
|
+
if "gene_names" in arguments:
|
|
60
|
+
gene_names = arguments["gene_names"]
|
|
61
|
+
if isinstance(gene_names, list):
|
|
62
|
+
params["geneList"] = "|".join(gene_names)
|
|
63
|
+
else:
|
|
64
|
+
params["geneList"] = str(gene_names)
|
|
65
|
+
|
|
66
|
+
# Add other parameters
|
|
67
|
+
if "organism" in arguments:
|
|
68
|
+
# Convert organism name to taxonomy ID
|
|
69
|
+
organism = arguments["organism"]
|
|
70
|
+
if organism.lower() == "homo sapiens":
|
|
71
|
+
params["organism"] = 9606
|
|
72
|
+
elif organism.lower() == "mus musculus":
|
|
73
|
+
params["organism"] = 10090
|
|
74
|
+
else:
|
|
75
|
+
params["organism"] = organism
|
|
76
|
+
|
|
77
|
+
if "interaction_type" in arguments:
|
|
78
|
+
interaction_type = arguments["interaction_type"]
|
|
79
|
+
if interaction_type == "physical":
|
|
80
|
+
params["evidenceList"] = "physical"
|
|
81
|
+
elif interaction_type == "genetic":
|
|
82
|
+
params["evidenceList"] = "genetic"
|
|
83
|
+
# "both" means no evidence filter
|
|
84
|
+
|
|
85
|
+
if "limit" in arguments:
|
|
86
|
+
params["max"] = arguments["limit"]
|
|
87
|
+
|
|
88
|
+
return params
|
|
89
|
+
|
|
90
|
+
def _make_request(self, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
91
|
+
"""Perform a GET request and handle common errors."""
|
|
92
|
+
try:
|
|
93
|
+
response = requests.get(url, params=params, timeout=30)
|
|
94
|
+
response.raise_for_status()
|
|
95
|
+
|
|
96
|
+
if self.output_format == "JSON":
|
|
97
|
+
return response.json()
|
|
98
|
+
else:
|
|
99
|
+
return {"data": response.text}
|
|
100
|
+
|
|
101
|
+
except requests.exceptions.RequestException as e:
|
|
102
|
+
return {"error": f"Request failed: {str(e)}"}
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return {"error": f"Unexpected error: {str(e)}"}
|
|
105
|
+
|
|
106
|
+
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
107
|
+
"""Execute the tool with given arguments."""
|
|
108
|
+
# Validate required parameters
|
|
109
|
+
for param in self.required:
|
|
110
|
+
if param not in arguments:
|
|
111
|
+
return {"error": f"Missing required parameter: {param}"}
|
|
112
|
+
|
|
113
|
+
url = self._build_url(arguments)
|
|
114
|
+
if isinstance(url, dict) and "error" in url:
|
|
115
|
+
return url
|
|
116
|
+
|
|
117
|
+
params = self._build_params(arguments)
|
|
118
|
+
return self._make_request(url, params)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Build optimization utilities for ToolUniverse tools."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Any, Set, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def calculate_tool_hash(tool_config: Dict[str, Any]) -> str:
|
|
10
|
+
"""Calculate a hash for tool configuration to detect changes."""
|
|
11
|
+
# Create a normalized version of the config for hashing
|
|
12
|
+
normalized_config = {}
|
|
13
|
+
for key, value in sorted(tool_config.items()):
|
|
14
|
+
if key not in ["timestamp", "last_updated", "created_at"]:
|
|
15
|
+
normalized_config[key] = value
|
|
16
|
+
|
|
17
|
+
config_str = json.dumps(normalized_config, sort_keys=True, separators=(",", ":"))
|
|
18
|
+
return hashlib.md5(config_str.encode("utf-8")).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_metadata(metadata_file: Path) -> Dict[str, str]:
|
|
22
|
+
"""Load tool metadata from file."""
|
|
23
|
+
if not metadata_file.exists():
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
|
28
|
+
return json.load(f)
|
|
29
|
+
except (json.JSONDecodeError, IOError):
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save_metadata(metadata: Dict[str, str], metadata_file: Path) -> None:
|
|
34
|
+
"""Save tool metadata to file."""
|
|
35
|
+
metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
with open(metadata_file, "w", encoding="utf-8") as f:
|
|
37
|
+
json.dump(metadata, f, indent=2, sort_keys=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cleanup_orphaned_files(tools_dir: Path, current_tool_names: Set[str]) -> int:
|
|
41
|
+
"""Remove files for tools that no longer exist."""
|
|
42
|
+
if not tools_dir.exists():
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
cleaned_count = 0
|
|
46
|
+
keep_files = {"__init__", "_shared_client", "__pycache__"}
|
|
47
|
+
|
|
48
|
+
for file_path in tools_dir.iterdir():
|
|
49
|
+
if (
|
|
50
|
+
file_path.is_file()
|
|
51
|
+
and file_path.suffix == ".py"
|
|
52
|
+
and file_path.stem not in keep_files
|
|
53
|
+
and file_path.stem not in current_tool_names
|
|
54
|
+
):
|
|
55
|
+
print(f"🗑️ Removing orphaned tool file: {file_path.name}")
|
|
56
|
+
file_path.unlink()
|
|
57
|
+
cleaned_count += 1
|
|
58
|
+
|
|
59
|
+
return cleaned_count
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_changed_tools(
|
|
63
|
+
current_tools: Dict[str, Any], metadata_file: Path
|
|
64
|
+
) -> Tuple[list, list, list]:
|
|
65
|
+
"""Get lists of new, changed, and unchanged tools."""
|
|
66
|
+
old_metadata = load_metadata(metadata_file)
|
|
67
|
+
new_metadata = {}
|
|
68
|
+
new_tools = []
|
|
69
|
+
changed_tools = []
|
|
70
|
+
unchanged_tools = []
|
|
71
|
+
|
|
72
|
+
for tool_name, tool_config in current_tools.items():
|
|
73
|
+
current_hash = calculate_tool_hash(tool_config)
|
|
74
|
+
new_metadata[tool_name] = current_hash
|
|
75
|
+
|
|
76
|
+
old_hash = old_metadata.get(tool_name)
|
|
77
|
+
if old_hash is None:
|
|
78
|
+
new_tools.append(tool_name)
|
|
79
|
+
elif old_hash != current_hash:
|
|
80
|
+
changed_tools.append(tool_name)
|
|
81
|
+
else:
|
|
82
|
+
unchanged_tools.append(tool_name)
|
|
83
|
+
|
|
84
|
+
# Save updated metadata
|
|
85
|
+
save_metadata(new_metadata, metadata_file)
|
|
86
|
+
|
|
87
|
+
return new_tools, changed_tools, unchanged_tools
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory cache utilities for ToolUniverse.
|
|
3
|
+
|
|
4
|
+
Provides a lightweight, thread-safe LRU cache with optional singleflight
|
|
5
|
+
deduplication for expensive misses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import Any, Dict, Iterator, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LRUCache:
|
|
18
|
+
"""Thread-safe LRU cache with basic telemetry."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, max_size: int = 128):
|
|
21
|
+
self.max_size = max(1, int(max_size))
|
|
22
|
+
self._data: "OrderedDict[str, Tuple[Any, float]]" = OrderedDict()
|
|
23
|
+
self._lock = threading.RLock()
|
|
24
|
+
self.hits = 0
|
|
25
|
+
self.misses = 0
|
|
26
|
+
|
|
27
|
+
def get(self, key: str) -> Optional[Any]:
|
|
28
|
+
with self._lock:
|
|
29
|
+
if key not in self._data:
|
|
30
|
+
self.misses += 1
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
value, timestamp = self._data.pop(key)
|
|
34
|
+
self._data[key] = (value, timestamp)
|
|
35
|
+
self.hits += 1
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
def set(self, key: str, value: Any):
|
|
39
|
+
with self._lock:
|
|
40
|
+
if key in self._data:
|
|
41
|
+
self._data.pop(key)
|
|
42
|
+
self._data[key] = (value, time.time())
|
|
43
|
+
self._evict_if_needed()
|
|
44
|
+
|
|
45
|
+
def delete(self, key: str):
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._data.pop(key, None)
|
|
48
|
+
|
|
49
|
+
def clear(self):
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._data.clear()
|
|
52
|
+
self.hits = 0
|
|
53
|
+
self.misses = 0
|
|
54
|
+
|
|
55
|
+
def _evict_if_needed(self):
|
|
56
|
+
while len(self._data) > self.max_size:
|
|
57
|
+
self._data.popitem(last=False)
|
|
58
|
+
|
|
59
|
+
def stats(self) -> Dict[str, Any]:
|
|
60
|
+
with self._lock:
|
|
61
|
+
return {
|
|
62
|
+
"max_size": self.max_size,
|
|
63
|
+
"current_size": len(self._data),
|
|
64
|
+
"hits": self.hits,
|
|
65
|
+
"misses": self.misses,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
with self._lock:
|
|
70
|
+
return len(self._data)
|
|
71
|
+
|
|
72
|
+
def items(self) -> Iterator[Tuple[str, Any]]:
|
|
73
|
+
with self._lock:
|
|
74
|
+
for key, (value, _) in list(self._data.items()):
|
|
75
|
+
yield key, value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SingleFlight:
|
|
79
|
+
"""Per-key lock manager to collapse duplicate cache misses."""
|
|
80
|
+
|
|
81
|
+
def __init__(self):
|
|
82
|
+
self._locks: Dict[str, threading.Lock] = {}
|
|
83
|
+
self._global = threading.Lock()
|
|
84
|
+
|
|
85
|
+
@contextmanager
|
|
86
|
+
def acquire(self, key: str):
|
|
87
|
+
with self._global:
|
|
88
|
+
lock = self._locks.get(key)
|
|
89
|
+
if lock is None:
|
|
90
|
+
lock = threading.Lock()
|
|
91
|
+
self._locks[key] = lock
|
|
92
|
+
lock.acquire()
|
|
93
|
+
try:
|
|
94
|
+
yield
|
|
95
|
+
finally:
|
|
96
|
+
lock.release()
|
|
97
|
+
with self._global:
|
|
98
|
+
if not lock.locked():
|
|
99
|
+
self._locks.pop(key, None)
|