rootly-mcp-server 0.0.3__tar.gz → 0.0.4__tar.gz
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.
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/PKG-INFO +1 -1
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/pyproject.toml +1 -1
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/src/rootly_mcp_server/server.py +101 -81
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/uv.lock +3 -1
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/.gitignore +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/LICENSE +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/README.md +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/src/rootly_mcp_server/__init__.py +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/src/rootly_mcp_server/__main__.py +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/src/rootly_mcp_server/client.py +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/src/rootly_mcp_server/data/__init__.py +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/src/rootly_mcp_server/data/swagger.json +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/src/rootly_mcp_server/test_client.py +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/swagger.json +0 -0
- {rootly_mcp_server-0.0.3 → rootly_mcp_server-0.0.4}/test_mcp_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rootly-mcp-server
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: A Model Context Protocol server for Rootly APIs using OpenAPI spec
|
|
5
5
|
Project-URL: Homepage, https://github.com/Rootly-AI-Labs/Rootly-MCP-server
|
|
6
6
|
Project-URL: Issues, https://github.com/Rootly-AI-Labs/Rootly-MCP-server/issues
|
|
@@ -30,51 +30,68 @@ SWAGGER_URL = "https://rootly-heroku.s3.amazonaws.com/swagger/v1/swagger.json"
|
|
|
30
30
|
class RootlyMCPServer(FastMCP):
|
|
31
31
|
"""
|
|
32
32
|
A Model Context Protocol server for Rootly API integration.
|
|
33
|
-
|
|
33
|
+
|
|
34
34
|
This server dynamically generates MCP tools based on the Rootly API's
|
|
35
35
|
OpenAPI (Swagger) specification.
|
|
36
36
|
"""
|
|
37
|
-
|
|
38
|
-
def __init__(self,
|
|
37
|
+
|
|
38
|
+
def __init__(self,
|
|
39
|
+
swagger_path: Optional[str] = None,
|
|
40
|
+
name: str = "Rootly",
|
|
41
|
+
default_page_size: int = 10,
|
|
42
|
+
allowed_paths: Optional[List[str]] = None):
|
|
39
43
|
"""
|
|
40
44
|
Initialize the Rootly MCP Server.
|
|
41
|
-
|
|
45
|
+
|
|
42
46
|
Args:
|
|
43
47
|
swagger_path: Path to the Swagger JSON file. If None, will look for
|
|
44
48
|
swagger.json in the current directory and parent directories.
|
|
45
49
|
name: Name of the MCP server.
|
|
46
50
|
default_page_size: Default number of items to return per page for paginated endpoints.
|
|
47
|
-
|
|
51
|
+
allowed_paths: List of API paths to load. If None, all paths will be loaded.
|
|
52
|
+
Paths should be specified without the /v1 prefix.
|
|
53
|
+
Example: ["/incidents", "/incidents/{incident_id}/alerts"]
|
|
48
54
|
"""
|
|
49
|
-
|
|
55
|
+
# Set default allowed paths if none provided
|
|
56
|
+
self.allowed_paths = allowed_paths or [
|
|
57
|
+
"/incidents",
|
|
58
|
+
"/incidents/{incident_id}/alerts"
|
|
59
|
+
]
|
|
60
|
+
# Add /v1 prefix to paths if not present
|
|
61
|
+
self.allowed_paths = [
|
|
62
|
+
f"/v1{path}" if not path.startswith("/v1") else path
|
|
63
|
+
for path in self.allowed_paths
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
logger.info(f"Initializing RootlyMCPServer with allowed paths: {self.allowed_paths}")
|
|
50
67
|
super().__init__(name)
|
|
51
|
-
|
|
68
|
+
|
|
52
69
|
# Initialize the Rootly API client
|
|
53
70
|
self.client = RootlyClient()
|
|
54
|
-
|
|
71
|
+
|
|
55
72
|
# Store default page size
|
|
56
73
|
self.default_page_size = default_page_size
|
|
57
74
|
logger.info(f"Using default page size: {default_page_size}")
|
|
58
|
-
|
|
75
|
+
|
|
59
76
|
# Load the Swagger specification
|
|
60
77
|
logger.info("Loading Swagger specification")
|
|
61
78
|
self.swagger_spec = self._load_swagger_spec(swagger_path)
|
|
62
|
-
logger.info(f"Loaded Swagger spec with {len(self.swagger_spec.get('paths', {}))} paths")
|
|
63
|
-
|
|
79
|
+
logger.info(f"Loaded Swagger spec with {len(self.swagger_spec.get('paths', {}))} total paths")
|
|
80
|
+
|
|
64
81
|
# Register tools based on the Swagger spec
|
|
65
82
|
logger.info("Registering tools based on Swagger spec")
|
|
66
83
|
self._register_tools()
|
|
67
|
-
|
|
84
|
+
|
|
68
85
|
def _fetch_swagger_from_url(self, url: str = SWAGGER_URL) -> Dict[str, Any]:
|
|
69
86
|
"""
|
|
70
87
|
Fetch the Swagger specification from the specified URL.
|
|
71
|
-
|
|
88
|
+
|
|
72
89
|
Args:
|
|
73
90
|
url: URL of the Swagger JSON file.
|
|
74
|
-
|
|
91
|
+
|
|
75
92
|
Returns:
|
|
76
93
|
The Swagger specification as a dictionary.
|
|
77
|
-
|
|
94
|
+
|
|
78
95
|
Raises:
|
|
79
96
|
Exception: If the request fails or the response is not valid JSON.
|
|
80
97
|
"""
|
|
@@ -89,18 +106,18 @@ class RootlyMCPServer(FastMCP):
|
|
|
89
106
|
except json.JSONDecodeError as e:
|
|
90
107
|
logger.error(f"Failed to parse Swagger spec: {e}")
|
|
91
108
|
raise Exception(f"Failed to parse Swagger specification: {e}")
|
|
92
|
-
|
|
109
|
+
|
|
93
110
|
def _load_swagger_spec(self, swagger_path: Optional[str] = None) -> Dict[str, Any]:
|
|
94
111
|
"""
|
|
95
112
|
Load the Swagger specification from a file.
|
|
96
|
-
|
|
113
|
+
|
|
97
114
|
Args:
|
|
98
115
|
swagger_path: Path to the Swagger JSON file. If None, will look for
|
|
99
116
|
swagger.json in the following locations (in order):
|
|
100
117
|
1. package data directory
|
|
101
118
|
2. current directory and parent directories
|
|
102
119
|
3. download from the URL
|
|
103
|
-
|
|
120
|
+
|
|
104
121
|
Returns:
|
|
105
122
|
The Swagger specification as a dictionary.
|
|
106
123
|
"""
|
|
@@ -121,18 +138,18 @@ class RootlyMCPServer(FastMCP):
|
|
|
121
138
|
return json.load(f)
|
|
122
139
|
except Exception as e:
|
|
123
140
|
logger.debug(f"Could not load Swagger file from package data: {e}")
|
|
124
|
-
|
|
141
|
+
|
|
125
142
|
# Then, look for swagger.json in the current directory and parent directories
|
|
126
143
|
logger.info("Looking for swagger.json in current directory and parent directories")
|
|
127
144
|
current_dir = Path.cwd()
|
|
128
|
-
|
|
145
|
+
|
|
129
146
|
# Check current directory first
|
|
130
147
|
swagger_path = current_dir / "swagger.json"
|
|
131
148
|
if swagger_path.is_file():
|
|
132
149
|
logger.info(f"Found Swagger file at {swagger_path}")
|
|
133
150
|
with open(swagger_path, "r") as f:
|
|
134
151
|
return json.load(f)
|
|
135
|
-
|
|
152
|
+
|
|
136
153
|
# Check parent directories
|
|
137
154
|
for parent in current_dir.parents:
|
|
138
155
|
swagger_path = parent / "swagger.json"
|
|
@@ -140,11 +157,11 @@ class RootlyMCPServer(FastMCP):
|
|
|
140
157
|
logger.info(f"Found Swagger file at {swagger_path}")
|
|
141
158
|
with open(swagger_path, "r") as f:
|
|
142
159
|
return json.load(f)
|
|
143
|
-
|
|
160
|
+
|
|
144
161
|
# If the file wasn't found, fetch it from the URL and save it
|
|
145
162
|
logger.info("Swagger file not found locally, fetching from URL")
|
|
146
163
|
swagger_spec = self._fetch_swagger_from_url()
|
|
147
|
-
|
|
164
|
+
|
|
148
165
|
# Save the fetched spec to the current directory
|
|
149
166
|
swagger_path = current_dir / "swagger.json"
|
|
150
167
|
logger.info(f"Saving Swagger file to {swagger_path}")
|
|
@@ -154,32 +171,38 @@ class RootlyMCPServer(FastMCP):
|
|
|
154
171
|
logger.info(f"Saved Swagger file to {swagger_path}")
|
|
155
172
|
except Exception as e:
|
|
156
173
|
logger.warning(f"Failed to save Swagger file: {e}")
|
|
157
|
-
|
|
174
|
+
|
|
158
175
|
return swagger_spec
|
|
159
|
-
|
|
176
|
+
|
|
160
177
|
def _register_tools(self) -> None:
|
|
161
178
|
"""
|
|
162
179
|
Register MCP tools based on the Swagger specification.
|
|
163
|
-
|
|
164
|
-
This method iterates through the paths and operations in the Swagger spec
|
|
165
|
-
and creates corresponding MCP tools.
|
|
180
|
+
Only registers tools for paths specified in allowed_paths.
|
|
166
181
|
"""
|
|
167
182
|
paths = self.swagger_spec.get("paths", {})
|
|
168
|
-
|
|
169
|
-
|
|
183
|
+
|
|
184
|
+
# Filter paths based on allowed_paths
|
|
185
|
+
filtered_paths = {
|
|
186
|
+
path: path_item
|
|
187
|
+
for path, path_item in paths.items()
|
|
188
|
+
if path in self.allowed_paths
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
logger.info(f"Registering {len(filtered_paths)} paths out of {len(paths)} total paths")
|
|
192
|
+
|
|
170
193
|
# Register the list_endpoints tool
|
|
171
194
|
@self.tool()
|
|
172
195
|
def list_endpoints() -> str:
|
|
173
196
|
"""List all available Rootly API endpoints."""
|
|
174
197
|
endpoints = []
|
|
175
|
-
for path, path_item in
|
|
198
|
+
for path, path_item in filtered_paths.items():
|
|
176
199
|
for method, operation in path_item.items():
|
|
177
200
|
if method.lower() not in ["get", "post", "put", "delete", "patch"]:
|
|
178
201
|
continue
|
|
179
|
-
|
|
202
|
+
|
|
180
203
|
summary = operation.get("summary", "")
|
|
181
204
|
description = operation.get("description", "")
|
|
182
|
-
|
|
205
|
+
|
|
183
206
|
endpoints.append({
|
|
184
207
|
"path": path,
|
|
185
208
|
"method": method.upper(),
|
|
@@ -187,92 +210,89 @@ class RootlyMCPServer(FastMCP):
|
|
|
187
210
|
"description": description,
|
|
188
211
|
"tool_name": self._create_tool_name(path, method)
|
|
189
212
|
})
|
|
190
|
-
|
|
213
|
+
|
|
191
214
|
return json.dumps(endpoints, indent=2)
|
|
192
|
-
|
|
215
|
+
|
|
193
216
|
# Register a tool for each endpoint
|
|
194
217
|
tool_count = 0
|
|
195
|
-
|
|
196
|
-
for path, path_item in
|
|
218
|
+
|
|
219
|
+
for path, path_item in filtered_paths.items():
|
|
197
220
|
# Skip path parameters
|
|
198
221
|
if "parameters" in path_item:
|
|
199
222
|
path_item = {k: v for k, v in path_item.items() if k != "parameters"}
|
|
200
|
-
|
|
223
|
+
|
|
201
224
|
for method, operation in path_item.items():
|
|
202
225
|
if method.lower() not in ["get", "post", "put", "delete", "patch"]:
|
|
203
226
|
continue
|
|
204
|
-
|
|
227
|
+
|
|
205
228
|
# Create a tool name based on the path and method
|
|
206
229
|
tool_name = self._create_tool_name(path, method)
|
|
207
|
-
|
|
230
|
+
|
|
208
231
|
# Create a tool description
|
|
209
232
|
description = operation.get("summary", "") or operation.get("description", "")
|
|
210
233
|
if not description:
|
|
211
234
|
description = f"{method.upper()} {path}"
|
|
212
|
-
|
|
213
|
-
# Create the input schema
|
|
214
|
-
input_schema = self._create_input_schema(path, operation)
|
|
215
|
-
|
|
235
|
+
|
|
216
236
|
# Register the tool using the direct method
|
|
217
237
|
try:
|
|
218
238
|
# Define the tool function
|
|
219
239
|
def create_tool_fn(p=path, m=method, op=operation):
|
|
220
240
|
def tool_fn(**kwargs):
|
|
221
241
|
return self._handle_api_request(p, m, op, **kwargs)
|
|
222
|
-
|
|
242
|
+
|
|
223
243
|
# Set the function name and docstring
|
|
224
244
|
tool_fn.__name__ = tool_name
|
|
225
245
|
tool_fn.__doc__ = description
|
|
226
246
|
return tool_fn
|
|
227
|
-
|
|
247
|
+
|
|
228
248
|
# Create the tool function
|
|
229
249
|
tool_fn = create_tool_fn()
|
|
230
|
-
|
|
250
|
+
|
|
231
251
|
# Register the tool with FastMCP
|
|
232
252
|
self.add_tool(
|
|
233
253
|
name=tool_name,
|
|
234
254
|
description=description,
|
|
235
255
|
fn=tool_fn
|
|
236
256
|
)
|
|
237
|
-
|
|
257
|
+
|
|
238
258
|
tool_count += 1
|
|
239
259
|
logger.info(f"Registered tool: {tool_name}")
|
|
240
260
|
except Exception as e:
|
|
241
261
|
logger.error(f"Error registering tool {tool_name}: {e}")
|
|
242
|
-
|
|
243
|
-
logger.info(f"Registered {tool_count} tools in total")
|
|
244
|
-
|
|
262
|
+
|
|
263
|
+
logger.info(f"Registered {tool_count} tools in total. Modify allowed_paths to register more paths from the Rootly API.")
|
|
264
|
+
|
|
245
265
|
def _create_tool_name(self, path: str, method: str) -> str:
|
|
246
266
|
"""
|
|
247
267
|
Create a tool name based on the path and method.
|
|
248
|
-
|
|
268
|
+
|
|
249
269
|
Args:
|
|
250
270
|
path: The API path.
|
|
251
271
|
method: The HTTP method.
|
|
252
|
-
|
|
272
|
+
|
|
253
273
|
Returns:
|
|
254
274
|
A tool name string.
|
|
255
275
|
"""
|
|
256
276
|
# Remove the /v1 prefix if present
|
|
257
277
|
if path.startswith("/v1"):
|
|
258
278
|
path = path[3:]
|
|
259
|
-
|
|
279
|
+
|
|
260
280
|
# Replace path parameters with "by_id"
|
|
261
281
|
path = re.sub(r"\{([^}]+)\}", r"by_\1", path)
|
|
262
|
-
|
|
282
|
+
|
|
263
283
|
# Replace slashes with underscores and remove leading/trailing underscores
|
|
264
284
|
path = path.replace("/", "_").strip("_")
|
|
265
|
-
|
|
285
|
+
|
|
266
286
|
return f"{path}_{method.lower()}"
|
|
267
|
-
|
|
287
|
+
|
|
268
288
|
def _create_input_schema(self, path: str, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
269
289
|
"""
|
|
270
290
|
Create an input schema for the tool.
|
|
271
|
-
|
|
291
|
+
|
|
272
292
|
Args:
|
|
273
293
|
path: The API path.
|
|
274
294
|
operation: The Swagger operation object.
|
|
275
|
-
|
|
295
|
+
|
|
276
296
|
Returns:
|
|
277
297
|
An input schema dictionary.
|
|
278
298
|
"""
|
|
@@ -283,7 +303,7 @@ class RootlyMCPServer(FastMCP):
|
|
|
283
303
|
"required": [],
|
|
284
304
|
"additionalProperties": False
|
|
285
305
|
}
|
|
286
|
-
|
|
306
|
+
|
|
287
307
|
# Extract path parameters
|
|
288
308
|
path_params = re.findall(r"\{([^}]+)\}", path)
|
|
289
309
|
for param in path_params:
|
|
@@ -292,80 +312,80 @@ class RootlyMCPServer(FastMCP):
|
|
|
292
312
|
"description": f"Path parameter: {param}"
|
|
293
313
|
}
|
|
294
314
|
schema["required"].append(param)
|
|
295
|
-
|
|
315
|
+
|
|
296
316
|
# Add operation parameters
|
|
297
317
|
for param in operation.get("parameters", []):
|
|
298
318
|
param_name = param.get("name")
|
|
299
319
|
param_in = param.get("in")
|
|
300
|
-
|
|
320
|
+
|
|
301
321
|
if param_in in ["query", "header"]:
|
|
302
322
|
param_schema = param.get("schema", {})
|
|
303
323
|
param_type = param_schema.get("type", "string")
|
|
304
|
-
|
|
324
|
+
|
|
305
325
|
schema["properties"][param_name] = {
|
|
306
326
|
"type": param_type,
|
|
307
327
|
"description": param.get("description", f"{param_in} parameter: {param_name}")
|
|
308
328
|
}
|
|
309
|
-
|
|
329
|
+
|
|
310
330
|
if param.get("required", False):
|
|
311
331
|
schema["required"].append(param_name)
|
|
312
|
-
|
|
332
|
+
|
|
313
333
|
# Add request body for POST, PUT, PATCH methods
|
|
314
334
|
if "requestBody" in operation:
|
|
315
335
|
content = operation["requestBody"].get("content", {})
|
|
316
336
|
if "application/json" in content:
|
|
317
337
|
body_schema = content["application/json"].get("schema", {})
|
|
318
|
-
|
|
338
|
+
|
|
319
339
|
if "properties" in body_schema:
|
|
320
340
|
for prop_name, prop_schema in body_schema["properties"].items():
|
|
321
341
|
schema["properties"][prop_name] = {
|
|
322
342
|
"type": prop_schema.get("type", "string"),
|
|
323
343
|
"description": prop_schema.get("description", f"Body parameter: {prop_name}")
|
|
324
344
|
}
|
|
325
|
-
|
|
345
|
+
|
|
326
346
|
if "required" in body_schema:
|
|
327
347
|
schema["required"].extend(body_schema["required"])
|
|
328
|
-
|
|
348
|
+
|
|
329
349
|
return schema
|
|
330
|
-
|
|
350
|
+
|
|
331
351
|
def _handle_api_request(self, path: str, method: str, operation: Dict[str, Any], **kwargs) -> str:
|
|
332
352
|
"""
|
|
333
353
|
Handle an API request to the Rootly API.
|
|
334
|
-
|
|
354
|
+
|
|
335
355
|
Args:
|
|
336
356
|
path: The API path.
|
|
337
357
|
method: The HTTP method.
|
|
338
358
|
operation: The Swagger operation object.
|
|
339
359
|
**kwargs: The parameters for the request.
|
|
340
|
-
|
|
360
|
+
|
|
341
361
|
Returns:
|
|
342
362
|
The API response as a JSON string.
|
|
343
363
|
"""
|
|
344
364
|
logger.debug(f"Handling API request: {method} {path}")
|
|
345
365
|
logger.debug(f"Request parameters: {kwargs}")
|
|
346
|
-
|
|
366
|
+
|
|
347
367
|
# Extract path parameters
|
|
348
368
|
path_params = re.findall(r"\{([^}]+)\}", path)
|
|
349
369
|
actual_path = path
|
|
350
|
-
|
|
370
|
+
|
|
351
371
|
# Replace path parameters in the URL
|
|
352
372
|
for param in path_params:
|
|
353
373
|
if param in kwargs:
|
|
354
374
|
actual_path = actual_path.replace(f"{{{param}}}", str(kwargs.pop(param)))
|
|
355
|
-
|
|
375
|
+
|
|
356
376
|
# Separate query parameters and body parameters
|
|
357
377
|
query_params = {}
|
|
358
378
|
body_params = {}
|
|
359
|
-
|
|
379
|
+
|
|
360
380
|
if method.lower() == "get":
|
|
361
381
|
# For GET requests, all remaining parameters are query parameters
|
|
362
382
|
query_params = kwargs
|
|
363
|
-
|
|
383
|
+
|
|
364
384
|
# Add default pagination for incident-related endpoints
|
|
365
385
|
if "incidents" in path and method.lower() == "get":
|
|
366
386
|
# Check if pagination parameters are already specified
|
|
367
387
|
has_pagination = any(param.startswith("page[") for param in query_params.keys())
|
|
368
|
-
|
|
388
|
+
|
|
369
389
|
# If no pagination parameters are specified, add default page size
|
|
370
390
|
if not has_pagination:
|
|
371
391
|
query_params["page[size]"] = self.default_page_size
|
|
@@ -375,13 +395,13 @@ class RootlyMCPServer(FastMCP):
|
|
|
375
395
|
for param in operation.get("parameters", []):
|
|
376
396
|
param_name = param.get("name")
|
|
377
397
|
param_in = param.get("in")
|
|
378
|
-
|
|
398
|
+
|
|
379
399
|
if param_in == "query" and param_name in kwargs:
|
|
380
400
|
query_params[param_name] = kwargs.pop(param_name)
|
|
381
|
-
|
|
401
|
+
|
|
382
402
|
# All remaining parameters go in the request body
|
|
383
403
|
body_params = kwargs
|
|
384
|
-
|
|
404
|
+
|
|
385
405
|
# Make the API request
|
|
386
406
|
try:
|
|
387
407
|
response = self.client.make_request(
|
|
@@ -393,4 +413,4 @@ class RootlyMCPServer(FastMCP):
|
|
|
393
413
|
return response
|
|
394
414
|
except Exception as e:
|
|
395
415
|
logger.error(f"Error calling Rootly API: {e}")
|
|
396
|
-
return json.dumps({"error": str(e)})
|
|
416
|
+
return json.dumps({"error": str(e)})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
version = 1
|
|
2
|
+
revision = 1
|
|
2
3
|
requires-python = ">=3.12"
|
|
3
4
|
|
|
4
5
|
[[package]]
|
|
@@ -379,7 +380,7 @@ wheels = [
|
|
|
379
380
|
|
|
380
381
|
[[package]]
|
|
381
382
|
name = "rootly-mcp-server"
|
|
382
|
-
version = "0.0.
|
|
383
|
+
version = "0.0.4"
|
|
383
384
|
source = { editable = "." }
|
|
384
385
|
dependencies = [
|
|
385
386
|
{ name = "mcp" },
|
|
@@ -408,6 +409,7 @@ requires-dist = [
|
|
|
408
409
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
|
409
410
|
{ name = "requests", specifier = ">=2.28.0" },
|
|
410
411
|
]
|
|
412
|
+
provides-extras = ["dev"]
|
|
411
413
|
|
|
412
414
|
[package.metadata.requires-dev]
|
|
413
415
|
dev = [
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|