Skip to content

Commit e722413

Browse files
committed
Update all plugins to 1.1
1 parent 2dc9c02 commit e722413

File tree

19 files changed

+948
-376
lines changed

19 files changed

+948
-376
lines changed

core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "utcp"
7-
version = "1.1.0"
7+
version = "1.1.1"
88
authors = [
99
{ name = "UTCP Contributors" },
1010
]

core/src/utcp/implementations/default_variable_substitutor.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ def substitute(self, obj: dict | list | str, config: UtcpClientConfig, variable_
6767
Non-string types are returned unchanged. String values are scanned
6868
for variable references using ${VAR} and $VAR syntax.
6969
70+
Note:
71+
Strings containing '$ref' are skipped to support OpenAPI specs
72+
stored as string content, where $ref is a JSON reference keyword.
73+
7074
Args:
7175
obj: Object to perform substitution on. Can be any type.
7276
config: UTCP client configuration containing variable sources.
@@ -95,18 +99,22 @@ def substitute(self, obj: dict | list | str, config: UtcpClientConfig, variable_
9599
if variable_namespace and not all(c.isalnum() or c == '_' for c in variable_namespace):
96100
raise ValueError(f"Variable namespace '{variable_namespace}' contains invalid characters. Only alphanumeric characters and underscores are allowed.")
97101

98-
if isinstance(obj, dict):
99-
return {k: self.substitute(v, config, variable_namespace) for k, v in obj.items()}
100-
elif isinstance(obj, list):
101-
return [self.substitute(elem, config, variable_namespace) for elem in obj]
102-
elif isinstance(obj, str):
102+
if isinstance(obj, str):
103+
# Skip substitution for JSON $ref strings
104+
if '$ref' in obj:
105+
return obj
106+
103107
# Use a regular expression to find all variables in the string, supporting ${VAR} and $VAR formats
104108
def replacer(match):
105109
# The first group that is not None is the one that matched
106110
var_name = next((g for g in match.groups() if g is not None), "")
107111
return self._get_variable(var_name, config, variable_namespace)
108112

109-
return re.sub(r'\${(\w+)}|\$(\w+)', replacer, obj)
113+
return re.sub(r'\${([a-zA-Z0-9_]+)}|\$([a-zA-Z0-9_]+)', replacer, obj)
114+
elif isinstance(obj, dict):
115+
return {k: self.substitute(v, config, variable_namespace) for k, v in obj.items()}
116+
elif isinstance(obj, list):
117+
return [self.substitute(elem, config, variable_namespace) for elem in obj]
110118
else:
111119
return obj
112120

@@ -118,6 +126,10 @@ def find_required_variables(self, obj: dict | list | str, variable_namespace: Op
118126
returning fully-qualified variable names with variable namespacing.
119127
Useful for validation and dependency analysis.
120128
129+
Note:
130+
Strings containing '$ref' are skipped to support OpenAPI specs
131+
stored as string content, where $ref is a JSON reference keyword.
132+
121133
Args:
122134
obj: Object to scan for variable references.
123135
variable_namespace: Variable namespace used for variable namespacing.
@@ -127,7 +139,7 @@ def find_required_variables(self, obj: dict | list | str, variable_namespace: Op
127139
ValueError: If variable_namespace contains invalid characters.
128140
129141
Returns:
130-
List of fully-qualified variable names found in the object.
142+
List of unique fully-qualified variable names found in the object.
131143
132144
Example:
133145
```python
@@ -156,19 +168,22 @@ def find_required_variables(self, obj: dict | list | str, variable_namespace: Op
156168
result.extend(vars)
157169
return result
158170
elif isinstance(obj, str):
171+
# Skip substitution for JSON $ref strings
172+
if '$ref' in obj:
173+
return []
159174
# Find all variables in the string, supporting ${VAR} and $VAR formats
160175
variables = []
161-
pattern = r'\${(\w+)}|\$(\w+)'
176+
pattern = r'\${([a-zA-Z0-9_]+)}|\$([a-zA-Z0-9_]+)'
162177

163178
for match in re.finditer(pattern, obj):
164179
# The first group that is not None is the one that matched
165180
var_name = next(g for g in match.groups() if g is not None)
166181
if variable_namespace:
167-
full_var_name = variable_namespace.replace("_", "!").replace("!", "__") + "_" + var_name
182+
full_var_name = variable_namespace.replace("_", "__") + "_" + var_name
168183
else:
169184
full_var_name = var_name
170185
variables.append(full_var_name)
171186

172-
return variables
187+
return list(set(variables))
173188
else:
174189
return []

plugins/communication_protocols/cli/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "utcp-cli"
7-
version = "1.1.0"
7+
version = "1.1.1"
88
authors = [
99
{ name = "UTCP Contributors" },
1010
]
@@ -14,7 +14,7 @@ requires-python = ">=3.10"
1414
dependencies = [
1515
"pydantic>=2.0",
1616
"pyyaml>=6.0",
17-
"utcp>=1.0"
17+
"utcp>=1.1"
1818
]
1919
classifiers = [
2020
"Development Status :: 4 - Beta",
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# UTCP File Plugin
2+
3+
[![PyPI Downloads](https://static.pepy.tech/badge/utcp-file)](https://pepy.tech/projects/utcp-file)
4+
5+
A file-based resource plugin for UTCP. This plugin allows you to define tools that return the content of a specified local file.
6+
7+
## Features
8+
9+
- **Local File Content**: Define tools that read and return the content of local files.
10+
- **UTCP Manual Discovery**: Load tool definitions from local UTCP manual files in JSON or YAML format.
11+
- **OpenAPI Support**: Automatically converts local OpenAPI specs to UTCP tools with optional authentication.
12+
- **Static & Simple**: Ideal for returning mock data, configuration, or any static text content from a file.
13+
- **Version Control**: Tool definitions and their corresponding content files can be versioned with your code.
14+
- **No File Authentication**: Designed for simple, local file access without authentication for file reading.
15+
- **Tool Authentication**: Supports authentication for generated tools from OpenAPI specs via `auth_tools`.
16+
17+
## Installation
18+
19+
```bash
20+
pip install utcp-file
21+
```
22+
23+
## How It Works
24+
25+
The File plugin operates in two main ways:
26+
27+
1. **Tool Discovery (`register_manual`)**: It can read a standard UTCP manual file (e.g., `my-tools.json`) to learn about available tools. This is how the `UtcpClient` discovers what tools can be called.
28+
2. **Tool Execution (`call_tool`)**: When you call a tool, the plugin looks at the `tool_call_template` associated with that tool. It expects a `file` template, and it will read and return the entire content of the `file_path` specified in that template.
29+
30+
**Important**: The `call_tool` function **does not** use the arguments you pass to it. It simply returns the full content of the file defined in the tool's template.
31+
32+
## Quick Start
33+
34+
Here is a complete example demonstrating how to define and use a tool that returns the content of a file.
35+
36+
### 1. Create a Content File
37+
38+
First, create a file with some content that you want your tool to return.
39+
40+
`./mock_data/user.json`:
41+
```json
42+
{
43+
"id": 123,
44+
"name": "John Doe",
45+
"email": "john.doe@example.com"
46+
}
47+
```
48+
49+
### 2. Create a UTCP Manual
50+
51+
Next, define a UTCP manual that describes your tool. The `tool_call_template` must be of type `file` and point to the content file you just created.
52+
53+
`./manuals/local_tools.json`:
54+
```json
55+
{
56+
"manual_version": "1.0.0",
57+
"utcp_version": "1.0.2",
58+
"tools": [
59+
{
60+
"name": "get_mock_user",
61+
"description": "Returns a mock user profile from a local file.",
62+
"tool_call_template": {
63+
"call_template_type": "file",
64+
"file_path": "./mock_data/user.json"
65+
}
66+
}
67+
]
68+
}
69+
```
70+
71+
### 3. Use the Tool in Python
72+
73+
Finally, use the `UtcpClient` to load the manual and call the tool.
74+
75+
```python
76+
import asyncio
77+
from utcp.utcp_client import UtcpClient
78+
79+
async def main():
80+
# Create a client, providing the path to the manual.
81+
# The file plugin is used automatically for the "file" call_template_type.
82+
client = await UtcpClient.create(config={
83+
"manual_call_templates": [{
84+
"name": "local_file_tools",
85+
"call_template_type": "file",
86+
"file_path": "./manuals/local_tools.json"
87+
}]
88+
})
89+
90+
# List the tools to confirm it was loaded
91+
tools = await client.list_tools()
92+
print("Available tools:", [tool.name for tool in tools])
93+
94+
# Call the tool. The result will be the content of './mock_data/user.json'
95+
result = await client.call_tool("local_file_tools.get_mock_user", {})
96+
97+
print("\nTool Result:")
98+
print(result)
99+
100+
if __name__ == "__main__":
101+
asyncio.run(main())
102+
```
103+
104+
### Expected Output:
105+
106+
```
107+
Available tools: ['local_file_tools.get_mock_user']
108+
109+
Tool Result:
110+
{
111+
"id": 123,
112+
"name": "John Doe",
113+
"email": "john.doe@example.com"
114+
}
115+
```
116+
117+
## Use Cases
118+
119+
- **Mocking**: Return mock data for tests or local development without needing a live server.
120+
- **Configuration**: Load static configuration files as tool outputs.
121+
- **Templates**: Retrieve text templates (e.g., for emails or reports).
122+
123+
## Related Documentation
124+
125+
- [Main UTCP Documentation](../../../README.md)
126+
- [Core Package Documentation](../../../core/README.md)
127+
- [HTTP Plugin](../http/README.md) - For calling real web APIs.
128+
- [Text Plugin](../text/README.md) - For direct text content (browser-compatible).
129+
- [CLI Plugin](../cli/README.md) - For executing command-line tools.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
[build-system]
2+
requires = ["setuptools>=61.0"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "utcp-file"
7+
version = "1.1.0"
8+
authors = [
9+
{ name = "UTCP Contributors" },
10+
]
11+
description = "UTCP communication protocol plugin for reading local files."
12+
readme = "README.md"
13+
requires-python = ">=3.10"
14+
dependencies = [
15+
"pydantic>=2.0",
16+
"pyyaml>=6.0",
17+
"utcp>=1.1",
18+
"utcp-http>=1.1",
19+
"aiofiles>=23.2.1"
20+
]
21+
classifiers = [
22+
"Development Status :: 4 - Beta",
23+
"Intended Audience :: Developers",
24+
"Programming Language :: Python :: 3",
25+
"Operating System :: OS Independent",
26+
]
27+
license = "MPL-2.0"
28+
29+
[project.optional-dependencies]
30+
dev = [
31+
"build",
32+
"pytest",
33+
"pytest-asyncio",
34+
"pytest-cov",
35+
"coverage",
36+
"twine",
37+
]
38+
39+
[project.urls]
40+
Homepage = "https://utcp.io"
41+
Source = "https://github.com/universal-tool-calling-protocol/python-utcp"
42+
Issues = "https://github.com/universal-tool-calling-protocol/python-utcp/issues"
43+
44+
[project.entry-points."utcp.plugins"]
45+
file = "utcp_file:register"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""File Communication Protocol plugin for UTCP."""
2+
3+
from utcp.plugins.discovery import register_communication_protocol, register_call_template
4+
from utcp_file.file_communication_protocol import FileCommunicationProtocol
5+
from utcp_file.file_call_template import FileCallTemplate, FileCallTemplateSerializer
6+
7+
8+
def register():
9+
register_communication_protocol("file", FileCommunicationProtocol())
10+
register_call_template("file", FileCallTemplateSerializer())
11+
12+
13+
__all__ = [
14+
"FileCommunicationProtocol",
15+
"FileCallTemplate",
16+
"FileCallTemplateSerializer",
17+
]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from typing import Literal, Optional, Any
2+
from pydantic import Field, field_serializer, field_validator
3+
4+
from utcp.data.call_template import CallTemplate
5+
from utcp.data.auth import Auth, AuthSerializer
6+
from utcp.interfaces.serializer import Serializer
7+
from utcp.exceptions import UtcpSerializerValidationError
8+
import traceback
9+
10+
11+
class FileCallTemplate(CallTemplate):
12+
"""REQUIRED
13+
Call template for file-based manuals and tools.
14+
15+
Reads UTCP manuals or tool definitions from local JSON/YAML files. Useful for
16+
static tool configurations or environments where manuals are distributed as files.
17+
For direct text content, use the text protocol instead.
18+
19+
Attributes:
20+
call_template_type: Always "file" for file call templates.
21+
file_path: Path to the file containing the UTCP manual or tool definitions.
22+
auth: Always None - file call templates don't support authentication for file access.
23+
auth_tools: Optional authentication to apply to generated tools from OpenAPI specs.
24+
"""
25+
26+
call_template_type: Literal["file"] = "file"
27+
file_path: str = Field(..., description="The path to the file containing the UTCP manual or tool definitions.")
28+
auth: None = None
29+
auth_tools: Optional[Auth] = Field(None, description="Authentication to apply to generated tools from OpenAPI specs.")
30+
31+
@field_serializer('auth_tools')
32+
def serialize_auth_tools(self, auth_tools: Optional[Auth]) -> Optional[dict]:
33+
"""Serialize auth_tools to dictionary."""
34+
if auth_tools is None:
35+
return None
36+
return AuthSerializer().to_dict(auth_tools)
37+
38+
@field_validator('auth_tools', mode='before')
39+
@classmethod
40+
def validate_auth_tools(cls, v: Any) -> Optional[Auth]:
41+
"""Validate and deserialize auth_tools from dictionary."""
42+
if v is None:
43+
return None
44+
if isinstance(v, Auth):
45+
return v
46+
if isinstance(v, dict):
47+
return AuthSerializer().validate_dict(v)
48+
raise ValueError(f"auth_tools must be None, Auth instance, or dict, got {type(v)}")
49+
50+
51+
class FileCallTemplateSerializer(Serializer[FileCallTemplate]):
52+
"""REQUIRED
53+
Serializer for FileCallTemplate."""
54+
55+
def to_dict(self, obj: FileCallTemplate) -> dict:
56+
"""REQUIRED
57+
Convert a FileCallTemplate to a dictionary."""
58+
return obj.model_dump()
59+
60+
def validate_dict(self, obj: dict) -> FileCallTemplate:
61+
"""REQUIRED
62+
Validate and convert a dictionary to a FileCallTemplate."""
63+
try:
64+
return FileCallTemplate.model_validate(obj)
65+
except Exception as e:
66+
raise UtcpSerializerValidationError("Invalid FileCallTemplate: " + traceback.format_exc())

0 commit comments

Comments
 (0)