universal-mcp 0.1.15rc5__py3-none-any.whl → 0.1.16__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.
- universal_mcp/analytics.py +7 -1
- universal_mcp/applications/README.md +122 -0
- universal_mcp/applications/__init__.py +51 -56
- universal_mcp/applications/application.py +255 -82
- universal_mcp/cli.py +27 -43
- universal_mcp/config.py +16 -48
- universal_mcp/exceptions.py +8 -0
- universal_mcp/integrations/__init__.py +1 -3
- universal_mcp/integrations/integration.py +18 -2
- universal_mcp/logger.py +31 -29
- universal_mcp/servers/server.py +6 -18
- universal_mcp/stores/store.py +2 -12
- universal_mcp/tools/__init__.py +12 -1
- universal_mcp/tools/adapters.py +11 -0
- universal_mcp/tools/func_metadata.py +11 -15
- universal_mcp/tools/manager.py +163 -117
- universal_mcp/tools/tools.py +6 -13
- universal_mcp/utils/agentr.py +2 -6
- universal_mcp/utils/common.py +33 -0
- universal_mcp/utils/docstring_parser.py +4 -13
- universal_mcp/utils/installation.py +67 -184
- universal_mcp/utils/openapi/__inti__.py +0 -0
- universal_mcp/utils/{api_generator.py → openapi/api_generator.py} +2 -4
- universal_mcp/utils/{docgen.py → openapi/docgen.py} +17 -54
- universal_mcp/utils/openapi/openapi.py +882 -0
- universal_mcp/utils/openapi/preprocessor.py +1093 -0
- universal_mcp/utils/{readme.py → openapi/readme.py} +21 -37
- universal_mcp-0.1.16.dist-info/METADATA +282 -0
- universal_mcp-0.1.16.dist-info/RECORD +44 -0
- universal_mcp-0.1.16.dist-info/licenses/LICENSE +21 -0
- universal_mcp/utils/openapi.py +0 -646
- universal_mcp-0.1.15rc5.dist-info/METADATA +0 -245
- universal_mcp-0.1.15rc5.dist-info/RECORD +0 -39
- /universal_mcp/{templates → utils/templates}/README.md.j2 +0 -0
- /universal_mcp/{templates → utils/templates}/api_client.py.j2 +0 -0
- {universal_mcp-0.1.15rc5.dist-info → universal_mcp-0.1.16.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.15rc5.dist-info → universal_mcp-0.1.16.dist-info}/entry_points.txt +0 -0
universal_mcp/tools/manager.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
from collections.abc import Callable
|
2
|
-
from typing import Any
|
2
|
+
from typing import Any
|
3
3
|
|
4
4
|
from loguru import logger
|
5
5
|
|
@@ -7,98 +7,159 @@ from universal_mcp.analytics import analytics
|
|
7
7
|
from universal_mcp.applications.application import BaseApplication
|
8
8
|
from universal_mcp.exceptions import ToolError
|
9
9
|
from universal_mcp.tools.adapters import (
|
10
|
+
ToolFormat,
|
10
11
|
convert_tool_to_langchain_tool,
|
11
12
|
convert_tool_to_mcp_tool,
|
12
13
|
convert_tool_to_openai_tool,
|
13
14
|
)
|
14
15
|
from universal_mcp.tools.tools import Tool
|
15
16
|
|
17
|
+
# Constants
|
18
|
+
DEFAULT_IMPORTANT_TAG = "important"
|
19
|
+
TOOL_NAME_SEPARATOR = "_"
|
20
|
+
|
21
|
+
|
22
|
+
def _filter_by_name(tools: list[Tool], tool_names: list[str]) -> list[Tool]:
|
23
|
+
if not tool_names:
|
24
|
+
return tools
|
25
|
+
return [tool for tool in tools if tool.name in tool_names]
|
26
|
+
|
27
|
+
|
28
|
+
def _filter_by_tags(tools: list[Tool], tags: list[str] | None) -> list[Tool]:
|
29
|
+
tags = tags or [DEFAULT_IMPORTANT_TAG]
|
30
|
+
return [tool for tool in tools if any(tag in tool.tags for tag in tags)]
|
31
|
+
|
16
32
|
|
17
33
|
class ToolManager:
|
18
|
-
"""Manages FastMCP tools.
|
34
|
+
"""Manages FastMCP tools.
|
35
|
+
|
36
|
+
This class provides functionality for registering, managing, and executing tools.
|
37
|
+
It supports multiple tool formats and provides filtering capabilities based on names and tags.
|
38
|
+
"""
|
19
39
|
|
20
40
|
def __init__(self, warn_on_duplicate_tools: bool = True):
|
41
|
+
"""Initialize the ToolManager.
|
42
|
+
|
43
|
+
Args:
|
44
|
+
warn_on_duplicate_tools: Whether to warn when duplicate tool names are detected.
|
45
|
+
"""
|
21
46
|
self._tools: dict[str, Tool] = {}
|
22
47
|
self.warn_on_duplicate_tools = warn_on_duplicate_tools
|
23
48
|
|
24
49
|
def get_tool(self, name: str) -> Tool | None:
|
25
|
-
"""Get tool by name.
|
50
|
+
"""Get tool by name.
|
51
|
+
|
52
|
+
Args:
|
53
|
+
name: The name of the tool to retrieve.
|
54
|
+
|
55
|
+
Returns:
|
56
|
+
The Tool instance if found, None otherwise.
|
57
|
+
"""
|
26
58
|
return self._tools.get(name)
|
27
59
|
|
28
60
|
def list_tools(
|
29
|
-
self,
|
61
|
+
self,
|
62
|
+
format: ToolFormat = ToolFormat.MCP,
|
63
|
+
tags: list[str] | None = None,
|
30
64
|
) -> list[Tool]:
|
31
|
-
"""List all registered tools.
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
65
|
+
"""List all registered tools in the specified format.
|
66
|
+
|
67
|
+
Args:
|
68
|
+
format: The format to convert tools to.
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
List of tools in the specified format.
|
72
|
+
|
73
|
+
Raises:
|
74
|
+
ValueError: If an invalid format is provided.
|
75
|
+
"""
|
76
|
+
|
77
|
+
tools = list(self._tools.values())
|
78
|
+
if tags:
|
79
|
+
tools = _filter_by_tags(tools, tags)
|
80
|
+
|
81
|
+
if format == ToolFormat.MCP:
|
82
|
+
tools = [convert_tool_to_mcp_tool(tool) for tool in tools]
|
83
|
+
elif format == ToolFormat.LANGCHAIN:
|
84
|
+
tools = [convert_tool_to_langchain_tool(tool) for tool in tools]
|
85
|
+
elif format == ToolFormat.OPENAI:
|
86
|
+
tools = [convert_tool_to_openai_tool(tool) for tool in tools]
|
40
87
|
else:
|
41
88
|
raise ValueError(f"Invalid format: {format}")
|
42
89
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
90
|
+
return tools
|
91
|
+
|
92
|
+
def add_tool(self, fn: Callable[..., Any] | Tool, name: str | None = None) -> Tool:
|
93
|
+
"""Add a tool to the manager.
|
94
|
+
|
95
|
+
Args:
|
96
|
+
fn: The tool function or Tool instance to add.
|
97
|
+
name: Optional name override for the tool.
|
98
|
+
|
99
|
+
Returns:
|
100
|
+
The registered Tool instance.
|
101
|
+
|
102
|
+
Raises:
|
103
|
+
ValueError: If the tool name is invalid.
|
104
|
+
"""
|
49
105
|
tool = fn if isinstance(fn, Tool) else Tool.from_function(fn, name=name)
|
106
|
+
|
107
|
+
if not tool.name or not isinstance(tool.name, str):
|
108
|
+
raise ValueError("Tool name must be a non-empty string")
|
109
|
+
|
50
110
|
existing = self._tools.get(tool.name)
|
51
111
|
if existing:
|
52
112
|
if self.warn_on_duplicate_tools:
|
53
|
-
# Check if it's the *exact* same function object being added again
|
54
113
|
if existing.fn is not tool.fn:
|
55
114
|
logger.warning(
|
56
115
|
f"Tool name '{tool.name}' conflicts with an existing tool. Skipping addition of new function."
|
57
116
|
)
|
58
117
|
else:
|
59
|
-
logger.debug(
|
60
|
-
|
61
|
-
)
|
62
|
-
return existing # Return the existing tool if name conflicts
|
118
|
+
logger.debug(f"Tool '{tool.name}' with the same function already exists.")
|
119
|
+
return existing
|
63
120
|
|
64
121
|
logger.debug(f"Adding tool: {tool.name}")
|
65
122
|
self._tools[tool.name] = tool
|
66
123
|
return tool
|
67
124
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
context=None,
|
73
|
-
) -> Any:
|
74
|
-
"""Call a tool by name with arguments."""
|
75
|
-
tool = self.get_tool(name)
|
76
|
-
if not tool:
|
77
|
-
raise ToolError(f"Unknown tool: {name}")
|
78
|
-
try:
|
79
|
-
result = await tool.run(arguments)
|
80
|
-
analytics.track_tool_called(name, "success")
|
81
|
-
return result
|
82
|
-
except Exception as e:
|
83
|
-
analytics.track_tool_called(name, "error", str(e))
|
84
|
-
raise
|
125
|
+
def register_tools(self, tools: list[Tool]) -> None:
|
126
|
+
"""Register a list of tools."""
|
127
|
+
for tool in tools:
|
128
|
+
self.add_tool(tool)
|
85
129
|
|
86
|
-
def
|
87
|
-
"""
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
130
|
+
def remove_tool(self, name: str) -> bool:
|
131
|
+
"""Remove a tool by name.
|
132
|
+
|
133
|
+
Args:
|
134
|
+
name: The name of the tool to remove.
|
135
|
+
|
136
|
+
Returns:
|
137
|
+
True if the tool was removed, False if it didn't exist.
|
138
|
+
"""
|
139
|
+
if name in self._tools:
|
140
|
+
del self._tools[name]
|
141
|
+
return True
|
142
|
+
return False
|
143
|
+
|
144
|
+
def clear_tools(self) -> None:
|
145
|
+
"""Remove all registered tools."""
|
146
|
+
self._tools.clear()
|
93
147
|
|
94
148
|
def register_tools_from_app(
|
95
149
|
self,
|
96
150
|
app: BaseApplication,
|
97
|
-
|
98
|
-
tags: list[str]
|
151
|
+
tool_names: list[str] = None,
|
152
|
+
tags: list[str] = None,
|
99
153
|
) -> None:
|
154
|
+
"""Register tools from an application.
|
155
|
+
|
156
|
+
Args:
|
157
|
+
app: The application to register tools from.
|
158
|
+
tools: Optional list of specific tool names to register.
|
159
|
+
tags: Optional list of tags to filter tools by.
|
160
|
+
"""
|
100
161
|
try:
|
101
|
-
|
162
|
+
functions = app.list_tools()
|
102
163
|
except TypeError as e:
|
103
164
|
logger.error(f"Error calling list_tools for app '{app.name}'. Error: {e}")
|
104
165
|
return
|
@@ -106,75 +167,60 @@ class ToolManager:
|
|
106
167
|
logger.error(f"Failed to get tool list from app '{app.name}': {e}")
|
107
168
|
return
|
108
169
|
|
109
|
-
if not isinstance(
|
110
|
-
logger.error(
|
111
|
-
f"App '{app.name}' list_tools() did not return a list. Skipping registration."
|
112
|
-
)
|
170
|
+
if not isinstance(functions, list):
|
171
|
+
logger.error(f"App '{app.name}' list_tools() did not return a list. Skipping registration.")
|
113
172
|
return
|
114
173
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
# For tags, determine the filter list based on priority: passed 'tags' or default 'important'
|
120
|
-
# This list is only used if tools_name_filter is empty.
|
121
|
-
active_tags_filter = tags if tags else ["important"] # Default filter
|
122
|
-
|
123
|
-
logger.debug(
|
124
|
-
f"Registering tools for '{app.name}'. Name filter: {tools_name_filter or 'None'}. Tag filter (if name filter empty): {active_tags_filter}"
|
125
|
-
)
|
126
|
-
|
127
|
-
for tool_func in available_tool_functions:
|
128
|
-
if not callable(tool_func):
|
129
|
-
logger.warning(
|
130
|
-
f"Item returned by {app.name}.list_tools() is not callable: {tool_func}. Skipping."
|
131
|
-
)
|
174
|
+
tools = []
|
175
|
+
for function in functions:
|
176
|
+
if not callable(function):
|
177
|
+
logger.warning(f"Non-callable tool from {app.name}: {function}")
|
132
178
|
continue
|
133
179
|
|
134
180
|
try:
|
135
|
-
|
136
|
-
|
137
|
-
tool_instance
|
181
|
+
tool_instance = Tool.from_function(function)
|
182
|
+
tool_instance.name = f"{app.name}{TOOL_NAME_SEPARATOR}{tool_instance.name}"
|
183
|
+
tool_instance.tags.append(app.name) if app.name not in tool_instance.tags else None
|
184
|
+
tools.append(tool_instance)
|
138
185
|
except Exception as e:
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
# logger.trace(f"Tool '{tool_instance.name}' skipped due to filters.") # Use trace level
|
186
|
+
tool_name = getattr(function, "__name__", "unknown")
|
187
|
+
logger.error(f"Failed to create Tool from '{tool_name}' in {app.name}: {e}")
|
188
|
+
|
189
|
+
tools = _filter_by_name(tools, tool_names)
|
190
|
+
tools = _filter_by_tags(tools, tags)
|
191
|
+
self.register_tools(tools)
|
192
|
+
return
|
193
|
+
|
194
|
+
async def call_tool(
|
195
|
+
self,
|
196
|
+
name: str,
|
197
|
+
arguments: dict[str, Any],
|
198
|
+
context: dict[str, Any] | None = None,
|
199
|
+
) -> Any:
|
200
|
+
"""Call a tool by name with arguments.
|
201
|
+
|
202
|
+
Args:
|
203
|
+
name: The name of the tool to call.
|
204
|
+
arguments: The arguments to pass to the tool.
|
205
|
+
context: Optional context information for the tool execution.
|
206
|
+
|
207
|
+
Returns:
|
208
|
+
The result of the tool execution.
|
209
|
+
|
210
|
+
Raises:
|
211
|
+
ToolError: If the tool is not found or execution fails.
|
212
|
+
"""
|
213
|
+
logger.debug(f"Calling tool: {name} with arguments: {arguments}")
|
214
|
+
tool = self.get_tool(name)
|
215
|
+
if not tool:
|
216
|
+
raise ToolError(f"Unknown tool: {name}")
|
217
|
+
|
218
|
+
try:
|
219
|
+
result = await tool.run(arguments, context)
|
220
|
+
app_name = tool.name.split(TOOL_NAME_SEPARATOR)[0]
|
221
|
+
analytics.track_tool_called(name, app_name, "success")
|
222
|
+
return result
|
223
|
+
except Exception as e:
|
224
|
+
app_name = tool.name.split(TOOL_NAME_SEPARATOR)[0]
|
225
|
+
analytics.track_tool_called(name, app_name, "error", str(e))
|
226
|
+
raise ToolError(f"Tool execution failed: {str(e)}") from e
|
universal_mcp/tools/tools.py
CHANGED
@@ -20,20 +20,15 @@ class Tool(BaseModel):
|
|
20
20
|
args_description: dict[str, str] = Field(
|
21
21
|
default_factory=dict, description="Descriptions of arguments from the docstring"
|
22
22
|
)
|
23
|
-
returns_description: str = Field(
|
24
|
-
default="", description="Description of the return value from the docstring"
|
25
|
-
)
|
23
|
+
returns_description: str = Field(default="", description="Description of the return value from the docstring")
|
26
24
|
raises_description: dict[str, str] = Field(
|
27
25
|
default_factory=dict,
|
28
26
|
description="Descriptions of exceptions raised from the docstring",
|
29
27
|
)
|
30
|
-
tags: list[str] = Field(
|
31
|
-
default_factory=list, description="Tags for categorizing the tool"
|
32
|
-
)
|
28
|
+
tags: list[str] = Field(default_factory=list, description="Tags for categorizing the tool")
|
33
29
|
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
|
34
30
|
fn_metadata: FuncMetadata = Field(
|
35
|
-
description="Metadata about the function including a pydantic model for tool"
|
36
|
-
" arguments"
|
31
|
+
description="Metadata about the function including a pydantic model for tool arguments"
|
37
32
|
)
|
38
33
|
is_async: bool = Field(description="Whether the tool is async")
|
39
34
|
|
@@ -55,9 +50,7 @@ class Tool(BaseModel):
|
|
55
50
|
|
56
51
|
is_async = inspect.iscoroutinefunction(fn)
|
57
52
|
|
58
|
-
func_arg_metadata = FuncMetadata.func_metadata(
|
59
|
-
fn,
|
60
|
-
)
|
53
|
+
func_arg_metadata = FuncMetadata.func_metadata(fn, arg_description=parsed_doc["args"])
|
61
54
|
parameters = func_arg_metadata.arg_model.model_json_schema()
|
62
55
|
|
63
56
|
return cls(
|
@@ -76,12 +69,12 @@ class Tool(BaseModel):
|
|
76
69
|
async def run(
|
77
70
|
self,
|
78
71
|
arguments: dict[str, Any],
|
79
|
-
context=None,
|
72
|
+
context: dict[str, Any] | None = None,
|
80
73
|
) -> Any:
|
81
74
|
"""Run the tool with arguments."""
|
82
75
|
try:
|
83
76
|
return await self.fn_metadata.call_fn_with_arg_validation(
|
84
|
-
self.fn, self.is_async, arguments, None
|
77
|
+
self.fn, self.is_async, arguments, None, context=context
|
85
78
|
)
|
86
79
|
except NotAuthorizedError as e:
|
87
80
|
message = f"Not authorized to call tool {self.name}: {e.message}"
|
universal_mcp/utils/agentr.py
CHANGED
@@ -26,9 +26,7 @@ class AgentrClient(metaclass=Singleton):
|
|
26
26
|
"API key for AgentR is missing. Please visit https://agentr.dev to create an API key, then set it as AGENTR_API_KEY environment variable."
|
27
27
|
)
|
28
28
|
raise ValueError("AgentR API key required - get one at https://agentr.dev")
|
29
|
-
self.base_url = (
|
30
|
-
base_url or os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")
|
31
|
-
).rstrip("/")
|
29
|
+
self.base_url = (base_url or os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")).rstrip("/")
|
32
30
|
|
33
31
|
def get_credentials(self, integration_name: str) -> dict:
|
34
32
|
"""Get credentials for an integration from the AgentR API.
|
@@ -48,9 +46,7 @@ class AgentrClient(metaclass=Singleton):
|
|
48
46
|
headers={"accept": "application/json", "X-API-KEY": self.api_key},
|
49
47
|
)
|
50
48
|
if response.status_code == 404:
|
51
|
-
logger.warning(
|
52
|
-
f"No credentials found for {integration_name}. Requesting authorization..."
|
53
|
-
)
|
49
|
+
logger.warning(f"No credentials found for {integration_name}. Requesting authorization...")
|
54
50
|
action = self.get_authorization_url(integration_name)
|
55
51
|
raise NotAuthorizedError(action)
|
56
52
|
response.raise_for_status()
|
@@ -0,0 +1,33 @@
|
|
1
|
+
def get_default_repository_path(slug: str) -> str:
|
2
|
+
"""
|
3
|
+
Convert a repository slug to a repository URL.
|
4
|
+
"""
|
5
|
+
slug = slug.strip().lower()
|
6
|
+
return f"universal-mcp-{slug}"
|
7
|
+
|
8
|
+
|
9
|
+
def get_default_package_name(slug: str) -> str:
|
10
|
+
"""
|
11
|
+
Convert a repository slug to a package name.
|
12
|
+
"""
|
13
|
+
slug = slug.strip().lower()
|
14
|
+
package_name = f"universal_mcp_{slug.replace('-', '_')}"
|
15
|
+
return package_name
|
16
|
+
|
17
|
+
|
18
|
+
def get_default_module_path(slug: str) -> str:
|
19
|
+
"""
|
20
|
+
Convert a repository slug to a module path.
|
21
|
+
"""
|
22
|
+
package_name = get_default_package_name(slug)
|
23
|
+
module_path = f"{package_name}.app"
|
24
|
+
return module_path
|
25
|
+
|
26
|
+
|
27
|
+
def get_default_class_name(slug: str) -> str:
|
28
|
+
"""
|
29
|
+
Convert a repository slug to a class name.
|
30
|
+
"""
|
31
|
+
slug = slug.strip().lower()
|
32
|
+
class_name = "".join(part.capitalize() for part in slug.split("-")) + "App"
|
33
|
+
return class_name
|
@@ -81,17 +81,13 @@ def parse_docstring(docstring: str | None) -> dict[str, Any]:
|
|
81
81
|
elif stripped_lower.startswith(("raises ", "errors ", "exceptions ")):
|
82
82
|
section_type = "raises"
|
83
83
|
# Capture content after header word and potential colon/space
|
84
|
-
parts = re.split(
|
85
|
-
r"[:\s]+", line.strip(), maxsplit=1
|
86
|
-
) # B034: Use keyword maxsplit
|
84
|
+
parts = re.split(r"[:\s]+", line.strip(), maxsplit=1) # B034: Use keyword maxsplit
|
87
85
|
if len(parts) > 1:
|
88
86
|
header_content = parts[1].strip()
|
89
87
|
elif stripped_lower.startswith(("tags",)):
|
90
88
|
section_type = "tags"
|
91
89
|
# Capture content after header word and potential colon/space
|
92
|
-
parts = re.split(
|
93
|
-
r"[:\s]+", line.strip(), maxsplit=1
|
94
|
-
) # B034: Use keyword maxsplit
|
90
|
+
parts = re.split(r"[:\s]+", line.strip(), maxsplit=1) # B034: Use keyword maxsplit
|
95
91
|
if len(parts) > 1:
|
96
92
|
header_content = parts[1].strip()
|
97
93
|
|
@@ -117,9 +113,7 @@ def parse_docstring(docstring: str | None) -> dict[str, Any]:
|
|
117
113
|
stripped_line = line.strip()
|
118
114
|
original_indentation = len(line) - len(line.lstrip(" "))
|
119
115
|
|
120
|
-
is_new_section_header, new_section_type_this_line, header_content_this_line = (
|
121
|
-
check_for_section_header(line)
|
122
|
-
)
|
116
|
+
is_new_section_header, new_section_type_this_line, header_content_this_line = check_for_section_header(line)
|
123
117
|
|
124
118
|
should_finalize_previous = False
|
125
119
|
|
@@ -156,10 +150,7 @@ def parse_docstring(docstring: str | None) -> dict[str, Any]:
|
|
156
150
|
or (
|
157
151
|
current_section in ["args", "raises"]
|
158
152
|
and current_key is not None
|
159
|
-
and (
|
160
|
-
key_pattern.match(line)
|
161
|
-
or (original_indentation == 0 and stripped_line)
|
162
|
-
)
|
153
|
+
and (key_pattern.match(line) or (original_indentation == 0 and stripped_line))
|
163
154
|
)
|
164
155
|
or (
|
165
156
|
current_section in ["returns", "tags", "other"]
|