onetool-mcp 1.0.0b1__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.
- bench/__init__.py +5 -0
- bench/cli.py +69 -0
- bench/harness/__init__.py +66 -0
- bench/harness/client.py +692 -0
- bench/harness/config.py +397 -0
- bench/harness/csv_writer.py +109 -0
- bench/harness/evaluate.py +512 -0
- bench/harness/metrics.py +283 -0
- bench/harness/runner.py +899 -0
- bench/py.typed +0 -0
- bench/reporter.py +629 -0
- bench/run.py +487 -0
- bench/secrets.py +101 -0
- bench/utils.py +16 -0
- onetool/__init__.py +4 -0
- onetool/cli.py +391 -0
- onetool/py.typed +0 -0
- onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
- onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
- onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
- onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
- ot/__init__.py +37 -0
- ot/__main__.py +6 -0
- ot/_cli.py +107 -0
- ot/_tui.py +53 -0
- ot/config/__init__.py +46 -0
- ot/config/defaults/bench.yaml +4 -0
- ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
- ot/config/defaults/diagram-templates/c4-context.puml +30 -0
- ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
- ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
- ot/config/defaults/diagram-templates/microservices.d2 +81 -0
- ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
- ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
- ot/config/defaults/onetool.yaml +25 -0
- ot/config/defaults/prompts.yaml +97 -0
- ot/config/defaults/servers.yaml +7 -0
- ot/config/defaults/snippets.yaml +4 -0
- ot/config/defaults/tool_templates/__init__.py +7 -0
- ot/config/defaults/tool_templates/extension.py +52 -0
- ot/config/defaults/tool_templates/isolated.py +61 -0
- ot/config/dynamic.py +121 -0
- ot/config/global_templates/__init__.py +2 -0
- ot/config/global_templates/bench-secrets-template.yaml +6 -0
- ot/config/global_templates/bench.yaml +9 -0
- ot/config/global_templates/onetool.yaml +27 -0
- ot/config/global_templates/secrets-template.yaml +44 -0
- ot/config/global_templates/servers.yaml +18 -0
- ot/config/global_templates/snippets.yaml +235 -0
- ot/config/loader.py +1087 -0
- ot/config/mcp.py +145 -0
- ot/config/secrets.py +190 -0
- ot/config/tool_config.py +125 -0
- ot/decorators.py +116 -0
- ot/executor/__init__.py +35 -0
- ot/executor/base.py +16 -0
- ot/executor/fence_processor.py +83 -0
- ot/executor/linter.py +142 -0
- ot/executor/pack_proxy.py +260 -0
- ot/executor/param_resolver.py +140 -0
- ot/executor/pep723.py +288 -0
- ot/executor/result_store.py +369 -0
- ot/executor/runner.py +496 -0
- ot/executor/simple.py +163 -0
- ot/executor/tool_loader.py +396 -0
- ot/executor/validator.py +398 -0
- ot/executor/worker_pool.py +388 -0
- ot/executor/worker_proxy.py +189 -0
- ot/http_client.py +145 -0
- ot/logging/__init__.py +37 -0
- ot/logging/config.py +315 -0
- ot/logging/entry.py +213 -0
- ot/logging/format.py +188 -0
- ot/logging/span.py +349 -0
- ot/meta.py +1555 -0
- ot/paths.py +453 -0
- ot/prompts.py +218 -0
- ot/proxy/__init__.py +21 -0
- ot/proxy/manager.py +396 -0
- ot/py.typed +0 -0
- ot/registry/__init__.py +189 -0
- ot/registry/models.py +57 -0
- ot/registry/parser.py +269 -0
- ot/registry/registry.py +413 -0
- ot/server.py +315 -0
- ot/shortcuts/__init__.py +15 -0
- ot/shortcuts/aliases.py +87 -0
- ot/shortcuts/snippets.py +258 -0
- ot/stats/__init__.py +35 -0
- ot/stats/html.py +250 -0
- ot/stats/jsonl_writer.py +283 -0
- ot/stats/reader.py +354 -0
- ot/stats/timing.py +57 -0
- ot/support.py +63 -0
- ot/tools.py +114 -0
- ot/utils/__init__.py +81 -0
- ot/utils/batch.py +161 -0
- ot/utils/cache.py +120 -0
- ot/utils/deps.py +403 -0
- ot/utils/exceptions.py +23 -0
- ot/utils/factory.py +179 -0
- ot/utils/format.py +65 -0
- ot/utils/http.py +202 -0
- ot/utils/platform.py +45 -0
- ot/utils/sanitize.py +130 -0
- ot/utils/truncate.py +69 -0
- ot_tools/__init__.py +4 -0
- ot_tools/_convert/__init__.py +12 -0
- ot_tools/_convert/excel.py +279 -0
- ot_tools/_convert/pdf.py +254 -0
- ot_tools/_convert/powerpoint.py +268 -0
- ot_tools/_convert/utils.py +358 -0
- ot_tools/_convert/word.py +283 -0
- ot_tools/brave_search.py +604 -0
- ot_tools/code_search.py +736 -0
- ot_tools/context7.py +495 -0
- ot_tools/convert.py +614 -0
- ot_tools/db.py +415 -0
- ot_tools/diagram.py +1604 -0
- ot_tools/diagram.yaml +167 -0
- ot_tools/excel.py +1372 -0
- ot_tools/file.py +1348 -0
- ot_tools/firecrawl.py +732 -0
- ot_tools/grounding_search.py +646 -0
- ot_tools/package.py +604 -0
- ot_tools/py.typed +0 -0
- ot_tools/ripgrep.py +544 -0
- ot_tools/scaffold.py +471 -0
- ot_tools/transform.py +213 -0
- ot_tools/web_fetch.py +384 -0
ot/logging/format.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Output formatting for log entries.
|
|
2
|
+
|
|
3
|
+
Provides truncation and credential sanitisation at OUTPUT time.
|
|
4
|
+
Full values are preserved in LogEntry for programmatic access.
|
|
5
|
+
|
|
6
|
+
Field-based truncation limits:
|
|
7
|
+
| Pattern | Limit |
|
|
8
|
+
|------------------------------------------------|-------|
|
|
9
|
+
| path, filepath, source, dest, directory | 200 |
|
|
10
|
+
| url | 120 |
|
|
11
|
+
| query, topic | 100 |
|
|
12
|
+
| pattern | 100 |
|
|
13
|
+
| error | 300 |
|
|
14
|
+
| default | 120 |
|
|
15
|
+
|
|
16
|
+
Credential sanitisation:
|
|
17
|
+
- URLs with credentials: postgres://user:pass@host -> postgres://***:***@host
|
|
18
|
+
- Applied to fields containing 'url' or values starting with http(s)://
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlparse, urlunparse
|
|
26
|
+
|
|
27
|
+
# Field name patterns mapped to truncation limits
|
|
28
|
+
FIELD_LIMITS: dict[str, int] = {
|
|
29
|
+
"path": 200,
|
|
30
|
+
"filepath": 200,
|
|
31
|
+
"source": 200,
|
|
32
|
+
"dest": 200,
|
|
33
|
+
"directory": 200,
|
|
34
|
+
"url": 120,
|
|
35
|
+
"query": 100,
|
|
36
|
+
"topic": 100,
|
|
37
|
+
"pattern": 100,
|
|
38
|
+
"error": 300,
|
|
39
|
+
}
|
|
40
|
+
DEFAULT_LIMIT = 120
|
|
41
|
+
|
|
42
|
+
# URL credential pattern: scheme://user:pass@host
|
|
43
|
+
URL_WITH_CREDS = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)([^:]+):([^@]+)@(.+)$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_field_limit(field_name: str) -> int:
|
|
47
|
+
"""Get truncation limit for a field based on name patterns.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
field_name: Name of the field (case-insensitive matching)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Truncation limit in characters
|
|
54
|
+
"""
|
|
55
|
+
lower_name = field_name.lower()
|
|
56
|
+
|
|
57
|
+
# Check for pattern matches in field name
|
|
58
|
+
for pattern, limit in FIELD_LIMITS.items():
|
|
59
|
+
if pattern in lower_name:
|
|
60
|
+
return limit
|
|
61
|
+
|
|
62
|
+
return DEFAULT_LIMIT
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sanitize_url(url: str) -> str:
|
|
66
|
+
"""Mask credentials in a URL.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
url: URL string, potentially with embedded credentials
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
URL with username:password masked as ***:***
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
postgres://user:password@host/db -> postgres://***:***@host/db
|
|
76
|
+
"""
|
|
77
|
+
match = URL_WITH_CREDS.match(url)
|
|
78
|
+
if match:
|
|
79
|
+
scheme, _user, _password, rest = match.groups()
|
|
80
|
+
return f"{scheme}***:***@{rest}"
|
|
81
|
+
|
|
82
|
+
# Also handle parsed URLs without regex (for edge cases)
|
|
83
|
+
try:
|
|
84
|
+
parsed = urlparse(url)
|
|
85
|
+
if parsed.username or parsed.password:
|
|
86
|
+
# Reconstruct with masked credentials
|
|
87
|
+
netloc = "***:***@" if parsed.password else "***@"
|
|
88
|
+
netloc += parsed.hostname or ""
|
|
89
|
+
if parsed.port:
|
|
90
|
+
netloc += f":{parsed.port}"
|
|
91
|
+
return urlunparse(
|
|
92
|
+
(
|
|
93
|
+
parsed.scheme,
|
|
94
|
+
netloc,
|
|
95
|
+
parsed.path,
|
|
96
|
+
parsed.params,
|
|
97
|
+
parsed.query,
|
|
98
|
+
parsed.fragment,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass # Return original if parsing fails
|
|
103
|
+
|
|
104
|
+
return url
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def format_value(value: Any, field_name: str = "", max_length: int | None = None) -> Any:
|
|
108
|
+
"""Format a single value for output with truncation.
|
|
109
|
+
|
|
110
|
+
Only string values are truncated. Other types pass through unchanged.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
value: Value to format
|
|
114
|
+
field_name: Field name for determining truncation limit
|
|
115
|
+
max_length: Override truncation limit (None = use field-based limit)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Formatted value (truncated string or original value)
|
|
119
|
+
"""
|
|
120
|
+
if not isinstance(value, str):
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
if max_length is None:
|
|
124
|
+
max_length = _get_field_limit(field_name)
|
|
125
|
+
|
|
126
|
+
if len(value) <= max_length:
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
# Truncate with ellipsis
|
|
130
|
+
return value[: max_length - 3] + "..."
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def sanitize_for_output(value: Any, field_name: str = "") -> Any:
|
|
134
|
+
"""Sanitize a value by masking credentials.
|
|
135
|
+
|
|
136
|
+
Applies to:
|
|
137
|
+
- Fields containing 'url' in name
|
|
138
|
+
- String values starting with http:// or https://
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
value: Value to sanitize
|
|
142
|
+
field_name: Field name for context
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Sanitized value
|
|
146
|
+
"""
|
|
147
|
+
if not isinstance(value, str):
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
lower_name = field_name.lower()
|
|
151
|
+
lower_value = value.lower()
|
|
152
|
+
|
|
153
|
+
# Apply URL sanitisation if field name contains 'url' or value is a URL
|
|
154
|
+
if "url" in lower_name or lower_value.startswith(("http://", "https://")):
|
|
155
|
+
return sanitize_url(value)
|
|
156
|
+
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def format_log_entry(
|
|
161
|
+
entry_dict: dict[str, Any],
|
|
162
|
+
verbose: bool = False,
|
|
163
|
+
) -> dict[str, Any]:
|
|
164
|
+
"""Format a log entry dict for output.
|
|
165
|
+
|
|
166
|
+
Applies truncation and credential sanitisation to all fields.
|
|
167
|
+
Full values are preserved in the original entry.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
entry_dict: Log entry as dict (from LogEntry.to_dict())
|
|
171
|
+
verbose: If True, skip truncation (still sanitizes credentials)
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
New dict with formatted values
|
|
175
|
+
"""
|
|
176
|
+
formatted: dict[str, Any] = {}
|
|
177
|
+
|
|
178
|
+
for key, value in entry_dict.items():
|
|
179
|
+
# Always sanitize credentials
|
|
180
|
+
sanitized = sanitize_for_output(value, key)
|
|
181
|
+
|
|
182
|
+
# Apply truncation unless verbose mode
|
|
183
|
+
if verbose:
|
|
184
|
+
formatted[key] = sanitized
|
|
185
|
+
else:
|
|
186
|
+
formatted[key] = format_value(sanitized, key)
|
|
187
|
+
|
|
188
|
+
return formatted
|
ot/logging/span.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""LogSpan context manager for auto-logging operations.
|
|
2
|
+
|
|
3
|
+
Wraps LogEntry and auto-logs on context exit with duration and status.
|
|
4
|
+
Supports FastMCP Context for async MCP tool execution.
|
|
5
|
+
|
|
6
|
+
Example (sync):
|
|
7
|
+
with LogSpan(span="tool.execute", tool="search") as s:
|
|
8
|
+
result = execute_tool()
|
|
9
|
+
s.add("resultCount", len(result))
|
|
10
|
+
# Auto-logs with duration, status=SUCCESS at INFO level
|
|
11
|
+
|
|
12
|
+
Example (async with FastMCP Context):
|
|
13
|
+
async with LogSpan.async_span(ctx, span="tool.execute", tool="search") as s:
|
|
14
|
+
result = await execute_tool()
|
|
15
|
+
await s.log_info("Tool completed", resultCount=len(result))
|
|
16
|
+
# Logs via FastMCP Context if available, falls back to loguru
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from contextlib import asynccontextmanager
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from loguru import logger
|
|
26
|
+
|
|
27
|
+
from ot.logging.entry import LogEntry
|
|
28
|
+
from ot.logging.format import format_log_entry
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import AsyncIterator
|
|
32
|
+
from types import TracebackType
|
|
33
|
+
|
|
34
|
+
# FastMCP Context is Any since it's an optional dependency with dynamic methods
|
|
35
|
+
Context = Any # FastMCP context with log_info, log_error, etc.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _format_for_output(entry: LogEntry) -> str:
|
|
39
|
+
"""Format a LogEntry for log output with truncation and sanitisation.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
entry: LogEntry to format
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
JSON string with formatted values
|
|
46
|
+
"""
|
|
47
|
+
# Import here to avoid circular dependency
|
|
48
|
+
from ot.config import is_log_verbose
|
|
49
|
+
|
|
50
|
+
formatted = format_log_entry(entry.to_dict(), verbose=is_log_verbose())
|
|
51
|
+
return json.dumps(formatted, separators=(",", ":"), default=str)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LogSpan:
|
|
55
|
+
"""Context manager that wraps LogEntry and auto-logs on exit.
|
|
56
|
+
|
|
57
|
+
On successful exit, logs at INFO level with status=SUCCESS.
|
|
58
|
+
On exception, logs at ERROR level with status=FAILED, errorType, errorMessage.
|
|
59
|
+
|
|
60
|
+
Supports optional FastMCP Context for async logging in MCP tool execution.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
level: str = "INFO",
|
|
66
|
+
ctx: Context | None = None,
|
|
67
|
+
**initial_fields: Any,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Initialize a log span.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
level: Default log level for successful completion (default: INFO)
|
|
73
|
+
ctx: Optional FastMCP Context for async logging
|
|
74
|
+
**initial_fields: Initial fields for the underlying LogEntry
|
|
75
|
+
"""
|
|
76
|
+
self._level = level.upper()
|
|
77
|
+
self._entry = LogEntry(**initial_fields)
|
|
78
|
+
self._ctx = ctx
|
|
79
|
+
|
|
80
|
+
def add(self, key: str | None = None, value: Any = None, **kwargs: Any) -> LogSpan:
|
|
81
|
+
"""Add one or more fields to the span.
|
|
82
|
+
|
|
83
|
+
Delegates to the underlying LogEntry.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
key: Field name (optional if using kwargs)
|
|
87
|
+
value: Field value (required if key is provided)
|
|
88
|
+
**kwargs: Bulk field additions
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Self for method chaining
|
|
92
|
+
"""
|
|
93
|
+
self._entry.add(key, value, **kwargs)
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
97
|
+
"""Set a field using dict-style access.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
key: Field name
|
|
101
|
+
value: Field value
|
|
102
|
+
"""
|
|
103
|
+
self._entry[key] = value
|
|
104
|
+
|
|
105
|
+
def __getitem__(self, key: str) -> Any:
|
|
106
|
+
"""Get a field using dict-style access.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
key: Field name
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Field value
|
|
113
|
+
"""
|
|
114
|
+
return self._entry[key]
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def entry(self) -> LogEntry:
|
|
118
|
+
"""Return the underlying LogEntry for direct access.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The wrapped LogEntry instance
|
|
122
|
+
"""
|
|
123
|
+
return self._entry
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def duration(self) -> float:
|
|
127
|
+
"""Return current duration since span creation.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Duration in seconds
|
|
131
|
+
"""
|
|
132
|
+
return self._entry.duration
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def context(self) -> Any:
|
|
136
|
+
"""Return the FastMCP Context if available.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
FastMCP Context or None
|
|
140
|
+
"""
|
|
141
|
+
return self._ctx
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict[str, Any]:
|
|
144
|
+
"""Return all fields with duration for output.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Dict with all fields, duration, and status info
|
|
148
|
+
"""
|
|
149
|
+
return self._entry.to_dict()
|
|
150
|
+
|
|
151
|
+
# -------------------------------------------------------------------------
|
|
152
|
+
# Sync context manager
|
|
153
|
+
# -------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def __enter__(self) -> LogSpan:
|
|
156
|
+
"""Enter the span context.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Self for use in with statement
|
|
160
|
+
"""
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def __exit__(
|
|
164
|
+
self,
|
|
165
|
+
exc_type: type[BaseException] | None,
|
|
166
|
+
exc_val: BaseException | None,
|
|
167
|
+
exc_tb: TracebackType | None,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Exit the span context and auto-log.
|
|
170
|
+
|
|
171
|
+
On success (no exception), logs at the configured level with status=SUCCESS.
|
|
172
|
+
On exception, logs at ERROR level with status=FAILED and error details.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
exc_type: Exception type if an exception was raised
|
|
176
|
+
exc_val: Exception value if an exception was raised
|
|
177
|
+
exc_tb: Exception traceback if an exception was raised
|
|
178
|
+
"""
|
|
179
|
+
if exc_val is not None:
|
|
180
|
+
# Exception occurred - log as FAILED at ERROR level
|
|
181
|
+
if isinstance(exc_val, Exception):
|
|
182
|
+
self._entry.failure(error=exc_val)
|
|
183
|
+
else:
|
|
184
|
+
self._entry.failure(
|
|
185
|
+
error_type=type(exc_val).__name__, error_message=str(exc_val)
|
|
186
|
+
)
|
|
187
|
+
# depth=1 makes loguru report the caller's location, not span.py
|
|
188
|
+
logger.opt(depth=1).error(_format_for_output(self._entry))
|
|
189
|
+
else:
|
|
190
|
+
# Success - log at configured level
|
|
191
|
+
self._entry.success()
|
|
192
|
+
# depth=1 makes loguru report the caller's location, not span.py
|
|
193
|
+
logger.opt(depth=1).log(self._level, _format_for_output(self._entry))
|
|
194
|
+
|
|
195
|
+
# -------------------------------------------------------------------------
|
|
196
|
+
# Async logging methods
|
|
197
|
+
# -------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
async def log_debug(self, message: str, **kwargs: Any) -> None:
|
|
200
|
+
"""Log a debug message.
|
|
201
|
+
|
|
202
|
+
Dispatches to FastMCP Context if available, otherwise uses loguru.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
message: Log message
|
|
206
|
+
**kwargs: Additional fields to include
|
|
207
|
+
"""
|
|
208
|
+
await self._log_async("DEBUG", message, **kwargs)
|
|
209
|
+
|
|
210
|
+
async def log_info(self, message: str, **kwargs: Any) -> None:
|
|
211
|
+
"""Log an info message.
|
|
212
|
+
|
|
213
|
+
Dispatches to FastMCP Context if available, otherwise uses loguru.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
message: Log message
|
|
217
|
+
**kwargs: Additional fields to include
|
|
218
|
+
"""
|
|
219
|
+
await self._log_async("INFO", message, **kwargs)
|
|
220
|
+
|
|
221
|
+
async def log_warning(self, message: str, **kwargs: Any) -> None:
|
|
222
|
+
"""Log a warning message.
|
|
223
|
+
|
|
224
|
+
Dispatches to FastMCP Context if available, otherwise uses loguru.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
message: Log message
|
|
228
|
+
**kwargs: Additional fields to include
|
|
229
|
+
"""
|
|
230
|
+
await self._log_async("WARNING", message, **kwargs)
|
|
231
|
+
|
|
232
|
+
async def log_error(self, message: str, **kwargs: Any) -> None:
|
|
233
|
+
"""Log an error message.
|
|
234
|
+
|
|
235
|
+
Dispatches to FastMCP Context if available, otherwise uses loguru.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
message: Log message
|
|
239
|
+
**kwargs: Additional fields to include
|
|
240
|
+
"""
|
|
241
|
+
await self._log_async("ERROR", message, **kwargs)
|
|
242
|
+
|
|
243
|
+
async def _log_async(self, level: str, message: str, **kwargs: Any) -> None:
|
|
244
|
+
"""Internal async logging dispatcher.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR)
|
|
248
|
+
message: Log message
|
|
249
|
+
**kwargs: Additional fields
|
|
250
|
+
"""
|
|
251
|
+
# Add fields to entry
|
|
252
|
+
for key, value in kwargs.items():
|
|
253
|
+
self._entry.add(key, value)
|
|
254
|
+
|
|
255
|
+
# Try to use FastMCP Context if available
|
|
256
|
+
if self._ctx is not None:
|
|
257
|
+
try:
|
|
258
|
+
# FastMCP Context has log_* methods
|
|
259
|
+
log_method = getattr(self._ctx, f"log_{level.lower()}", None)
|
|
260
|
+
if log_method is not None and callable(log_method):
|
|
261
|
+
await log_method(message)
|
|
262
|
+
return
|
|
263
|
+
except Exception:
|
|
264
|
+
pass # Fall through to loguru
|
|
265
|
+
|
|
266
|
+
# Fallback to loguru
|
|
267
|
+
log_message = f"{message} - {self._entry}"
|
|
268
|
+
logger.opt(depth=2).log(level.upper(), log_message)
|
|
269
|
+
|
|
270
|
+
# -------------------------------------------------------------------------
|
|
271
|
+
# Async context manager
|
|
272
|
+
# -------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
@asynccontextmanager
|
|
276
|
+
async def async_span(
|
|
277
|
+
cls,
|
|
278
|
+
ctx: Context | None = None,
|
|
279
|
+
level: str = "INFO",
|
|
280
|
+
**initial_fields: Any,
|
|
281
|
+
) -> AsyncIterator[LogSpan]:
|
|
282
|
+
"""Create an async context manager span.
|
|
283
|
+
|
|
284
|
+
Use this for async code that needs to log via FastMCP Context.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
ctx: Optional FastMCP Context for async logging
|
|
288
|
+
level: Default log level for successful completion
|
|
289
|
+
**initial_fields: Initial fields for the span
|
|
290
|
+
|
|
291
|
+
Yields:
|
|
292
|
+
LogSpan instance
|
|
293
|
+
|
|
294
|
+
Example:
|
|
295
|
+
async with LogSpan.async_span(ctx, span="tool.run") as span:
|
|
296
|
+
result = await run_tool()
|
|
297
|
+
span.add("result", result)
|
|
298
|
+
"""
|
|
299
|
+
span = cls(level=level, ctx=ctx, **initial_fields)
|
|
300
|
+
exc_info: tuple[type[BaseException] | None, BaseException | None, Any] = (
|
|
301
|
+
None,
|
|
302
|
+
None,
|
|
303
|
+
None,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
yield span
|
|
308
|
+
except BaseException as e:
|
|
309
|
+
exc_info = (type(e), e, e.__traceback__)
|
|
310
|
+
raise
|
|
311
|
+
finally:
|
|
312
|
+
_exc_type, exc_val, _exc_tb = exc_info
|
|
313
|
+
|
|
314
|
+
if exc_val is not None:
|
|
315
|
+
# Exception occurred
|
|
316
|
+
if isinstance(exc_val, Exception):
|
|
317
|
+
span._entry.failure(error=exc_val)
|
|
318
|
+
else:
|
|
319
|
+
span._entry.failure(
|
|
320
|
+
error_type=type(exc_val).__name__,
|
|
321
|
+
error_message=str(exc_val),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Log via Context or loguru
|
|
325
|
+
formatted = _format_for_output(span._entry)
|
|
326
|
+
if ctx is not None:
|
|
327
|
+
try:
|
|
328
|
+
await ctx.log_error(formatted)
|
|
329
|
+
except Exception:
|
|
330
|
+
logger.error(formatted)
|
|
331
|
+
else:
|
|
332
|
+
logger.error(formatted)
|
|
333
|
+
else:
|
|
334
|
+
# Success
|
|
335
|
+
span._entry.success()
|
|
336
|
+
|
|
337
|
+
# Log via Context or loguru
|
|
338
|
+
formatted = _format_for_output(span._entry)
|
|
339
|
+
if ctx is not None:
|
|
340
|
+
try:
|
|
341
|
+
log_method = getattr(ctx, f"log_{span._level.lower()}", None)
|
|
342
|
+
if log_method is not None and callable(log_method):
|
|
343
|
+
await log_method(formatted)
|
|
344
|
+
else:
|
|
345
|
+
await ctx.log_info(formatted)
|
|
346
|
+
except Exception:
|
|
347
|
+
logger.log(span._level, formatted)
|
|
348
|
+
else:
|
|
349
|
+
logger.log(span._level, formatted)
|