docsif 0.1.0__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.
- docsif-0.1.0.dist-info/METADATA +393 -0
- docsif-0.1.0.dist-info/RECORD +86 -0
- docsif-0.1.0.dist-info/WHEEL +4 -0
- docsif-0.1.0.dist-info/entry_points.txt +3 -0
- docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
- sif/__init__.py +24 -0
- sif/_version.py +3 -0
- sif/cli/__init__.py +11 -0
- sif/cli/commands/__init__.py +18 -0
- sif/cli/commands/bench.py +148 -0
- sif/cli/commands/collection.py +360 -0
- sif/cli/commands/context.py +154 -0
- sif/cli/commands/get.py +200 -0
- sif/cli/commands/index.py +294 -0
- sif/cli/commands/ls.py +67 -0
- sif/cli/commands/mcp.py +93 -0
- sif/cli/commands/pull.py +111 -0
- sif/cli/commands/search.py +574 -0
- sif/cli/config.py +206 -0
- sif/cli/formatters.py +269 -0
- sif/cli/main.py +205 -0
- sif/config/__init__.py +30 -0
- sif/config/constants.py +52 -0
- sif/config/settings.py +217 -0
- sif/core/__init__.py +21 -0
- sif/core/collection.py +161 -0
- sif/core/context.py +167 -0
- sif/core/document.py +125 -0
- sif/core/models.py +298 -0
- sif/database/__init__.py +23 -0
- sif/database/connection.py +139 -0
- sif/database/database.py +136 -0
- sif/database/migrations.py +187 -0
- sif/database/repositories.py +500 -0
- sif/database/repository.py +171 -0
- sif/database/schema.py +362 -0
- sif/embedding/__init__.py +22 -0
- sif/embedding/cache.py +196 -0
- sif/embedding/embedder.py +328 -0
- sif/embedding/factory.py +92 -0
- sif/embedding/manager.py +200 -0
- sif/embedding/model.py +126 -0
- sif/indexing/__init__.py +21 -0
- sif/indexing/chunker.py +226 -0
- sif/indexing/indexer.py +226 -0
- sif/indexing/parser.py +192 -0
- sif/indexing/scanner.py +148 -0
- sif/indexing/watcher.py +156 -0
- sif/mcp/README.md +258 -0
- sif/mcp/__init__.py +9 -0
- sif/mcp/claude_desktop_config.json +16 -0
- sif/mcp/cli.py +92 -0
- sif/mcp/protocol.py +336 -0
- sif/mcp/server.py +433 -0
- sif/mcp/server_http.py +54 -0
- sif/mcp/test_mcp.py +108 -0
- sif/mcp/tools.py +497 -0
- sif/mcp/transport_http.py +424 -0
- sif/mcp/transport_stdio.py +292 -0
- sif/mcp_server/__init__.py +26 -0
- sif/mcp_server/handlers.py +68 -0
- sif/mcp_server/server.py +58 -0
- sif/mcp_server/tools.py +216 -0
- sif/mcp_server/transport.py +221 -0
- sif/models/__init__.py +70 -0
- sif/models/collection.py +51 -0
- sif/models/context.py +45 -0
- sif/models/document.py +76 -0
- sif/models/download.py +208 -0
- sif/models/embedding.py +71 -0
- sif/models/search.py +92 -0
- sif/search/__init__.py +22 -0
- sif/search/benchmark.py +110 -0
- sif/search/bm25.py +216 -0
- sif/search/expansion.py +157 -0
- sif/search/hybrid.py +323 -0
- sif/search/rerank.py +390 -0
- sif/search/rrf.py +186 -0
- sif/search/snippets.py +130 -0
- sif/search/strategy.py +97 -0
- sif/search/vector.py +177 -0
- sif/utils/__init__.py +26 -0
- sif/utils/logging.py +143 -0
- sif/utils/paths.py +82 -0
- sif/utils/progress.py +83 -0
- sif/utils/text.py +128 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP HTTP Transport
|
|
3
|
+
|
|
4
|
+
This module implements the HTTP transport for MCP using FastAPI.
|
|
5
|
+
Supports both JSON-RPC POST endpoints and Server-Sent Events (SSE)
|
|
6
|
+
for real-time communication.
|
|
7
|
+
|
|
8
|
+
Endpoints:
|
|
9
|
+
- POST /mcp/v1/messages - JSON-RPC message endpoint
|
|
10
|
+
- GET /mcp/v1/sse - SSE endpoint for server-to-client streaming
|
|
11
|
+
- GET /health - Health check endpoint
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import AsyncGenerator
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI, Request
|
|
22
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
23
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
|
|
26
|
+
from .protocol import (
|
|
27
|
+
JsonRpcNotification,
|
|
28
|
+
JsonRpcRequest,
|
|
29
|
+
MCPErrorCode,
|
|
30
|
+
create_error_response,
|
|
31
|
+
)
|
|
32
|
+
from .server import MCPServer, ServerConfig, ServerState, create_server
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ============================================================================
|
|
39
|
+
# Pydantic Models
|
|
40
|
+
# ============================================================================
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MessageRequest(BaseModel):
|
|
44
|
+
"""Request body for JSON-RPC messages."""
|
|
45
|
+
|
|
46
|
+
jsonrpc: str = "2.0"
|
|
47
|
+
id: Optional[str | int] = None
|
|
48
|
+
method: Optional[str] = None
|
|
49
|
+
params: Optional[dict[str, Any]] = None
|
|
50
|
+
result: Optional[Any] = None
|
|
51
|
+
error: Optional[dict[str, Any]] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HealthResponse(BaseModel):
|
|
55
|
+
"""Health check response."""
|
|
56
|
+
|
|
57
|
+
status: str
|
|
58
|
+
server: dict[str, str]
|
|
59
|
+
timestamp: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ============================================================================
|
|
63
|
+
# SSE Manager
|
|
64
|
+
# ============================================================================
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SSEManager:
|
|
68
|
+
"""
|
|
69
|
+
Manager for Server-Sent Events connections.
|
|
70
|
+
|
|
71
|
+
Handles multiple client connections and broadcasts messages
|
|
72
|
+
to all connected clients.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self._connections: list[asyncio.Queue] = []
|
|
77
|
+
self._lock = asyncio.Lock()
|
|
78
|
+
|
|
79
|
+
async def connect(self) -> asyncio.Queue:
|
|
80
|
+
"""
|
|
81
|
+
Register a new SSE connection.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Queue for receiving SSE messages
|
|
85
|
+
"""
|
|
86
|
+
queue = asyncio.Queue()
|
|
87
|
+
async with self._lock:
|
|
88
|
+
self._connections.append(queue)
|
|
89
|
+
logger.info(f"SSE client connected. Total connections: {len(self._connections)}")
|
|
90
|
+
return queue
|
|
91
|
+
|
|
92
|
+
async def disconnect(self, queue: asyncio.Queue) -> None:
|
|
93
|
+
"""Unregister an SSE connection."""
|
|
94
|
+
async with self._lock:
|
|
95
|
+
if queue in self._connections:
|
|
96
|
+
self._connections.remove(queue)
|
|
97
|
+
logger.info(f"SSE client disconnected. Total connections: {len(self._connections)}")
|
|
98
|
+
|
|
99
|
+
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Broadcast a message to all connected clients.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
message: The message to broadcast
|
|
105
|
+
"""
|
|
106
|
+
disconnected = []
|
|
107
|
+
async with self._lock:
|
|
108
|
+
for queue in self._connections:
|
|
109
|
+
try:
|
|
110
|
+
await queue.put(message)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.error(f"Error broadcasting to queue: {e}")
|
|
113
|
+
disconnected.append(queue)
|
|
114
|
+
|
|
115
|
+
# Remove disconnected clients
|
|
116
|
+
for queue in disconnected:
|
|
117
|
+
if queue in self._connections:
|
|
118
|
+
self._connections.remove(queue)
|
|
119
|
+
|
|
120
|
+
async def send_to_client(self, queue: asyncio.Queue, message: dict[str, Any]) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Send a message to a specific client.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
queue: The client's queue
|
|
126
|
+
message: The message to send
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
await queue.put(message)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error(f"Error sending to client: {e}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ============================================================================
|
|
135
|
+
# HTTP Transport
|
|
136
|
+
# ============================================================================
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class HTTPTransport:
|
|
140
|
+
"""
|
|
141
|
+
HTTP transport for MCP server using FastAPI.
|
|
142
|
+
|
|
143
|
+
Provides RESTful endpoints for MCP protocol communication.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
server: Optional[MCPServer] = None,
|
|
149
|
+
config: Optional[ServerConfig] = None,
|
|
150
|
+
cors_origins: Optional[list[str]] = None,
|
|
151
|
+
):
|
|
152
|
+
self.server = server or create_server(config=config)
|
|
153
|
+
self.sse_manager = SSEManager()
|
|
154
|
+
self.cors_origins = cors_origins or ["*"]
|
|
155
|
+
self.app = self._create_app()
|
|
156
|
+
|
|
157
|
+
def _create_app(self) -> FastAPI:
|
|
158
|
+
"""Create and configure FastAPI application."""
|
|
159
|
+
app = FastAPI(
|
|
160
|
+
title="SIF MCP Server",
|
|
161
|
+
description="Model Context Protocol server for SIF",
|
|
162
|
+
version="1.0.0",
|
|
163
|
+
docs_url="/docs",
|
|
164
|
+
redoc_url="/redoc",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Add CORS middleware
|
|
168
|
+
app.add_middleware(
|
|
169
|
+
CORSMiddleware,
|
|
170
|
+
allow_origins=self.cors_origins,
|
|
171
|
+
allow_credentials=True,
|
|
172
|
+
allow_methods=["*"],
|
|
173
|
+
allow_headers=["*"],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Register routes
|
|
177
|
+
self._register_routes(app)
|
|
178
|
+
|
|
179
|
+
return app
|
|
180
|
+
|
|
181
|
+
def _register_routes(self, app: FastAPI) -> None: # noqa: C901, PLR0915
|
|
182
|
+
"""Register FastAPI routes."""
|
|
183
|
+
|
|
184
|
+
@app.get("/health", response_model=HealthResponse)
|
|
185
|
+
async def health_check():
|
|
186
|
+
"""Health check endpoint."""
|
|
187
|
+
return HealthResponse(
|
|
188
|
+
status="healthy" if self.server.state != ServerState.SHUTDOWN else "shutdown",
|
|
189
|
+
server=self.server.get_server_info(),
|
|
190
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
@app.get("/mcp/v1/sse")
|
|
194
|
+
async def sse_endpoint(request: Request):
|
|
195
|
+
"""Server-Sent Events endpoint.
|
|
196
|
+
|
|
197
|
+
Streams server-to-client messages in real-time.
|
|
198
|
+
"""
|
|
199
|
+
queue = await self.sse_manager.connect()
|
|
200
|
+
|
|
201
|
+
async def event_generator() -> AsyncGenerator[str, None]:
|
|
202
|
+
try:
|
|
203
|
+
# Send initial connection message
|
|
204
|
+
msg = json.dumps({"message": "Connected to MCP server"})
|
|
205
|
+
yield f"event: connected\ndata: {msg}\n\n"
|
|
206
|
+
|
|
207
|
+
while True:
|
|
208
|
+
# Check if client disconnected
|
|
209
|
+
if await request.is_disconnected():
|
|
210
|
+
break
|
|
211
|
+
|
|
212
|
+
# Wait for message with timeout
|
|
213
|
+
try:
|
|
214
|
+
message = await asyncio.wait_for(queue.get(), timeout=30.0)
|
|
215
|
+
data = json.dumps(message)
|
|
216
|
+
yield f"event: message\ndata: {data}\n\n"
|
|
217
|
+
except asyncio.TimeoutError:
|
|
218
|
+
# Send keepalive
|
|
219
|
+
yield f"event: ping\ndata: {json.dumps({'time': datetime.now(timezone.utc).isoformat()})}\n\n"
|
|
220
|
+
|
|
221
|
+
except asyncio.CancelledError:
|
|
222
|
+
logger.info("SSE connection cancelled")
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"SSE error: {e}")
|
|
225
|
+
finally:
|
|
226
|
+
await self.sse_manager.disconnect(queue)
|
|
227
|
+
|
|
228
|
+
return StreamingResponse(
|
|
229
|
+
event_generator(),
|
|
230
|
+
media_type="text/event-stream",
|
|
231
|
+
headers={
|
|
232
|
+
"Cache-Control": "no-cache",
|
|
233
|
+
"Connection": "keep-alive",
|
|
234
|
+
"X-Accel-Buffering": "no", # Disable nginx buffering
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
@app.post("/mcp/v1/messages")
|
|
239
|
+
async def messages_endpoint(request: MessageRequest):
|
|
240
|
+
"""
|
|
241
|
+
JSON-RPC message endpoint.
|
|
242
|
+
|
|
243
|
+
Accepts JSON-RPC requests and returns responses.
|
|
244
|
+
"""
|
|
245
|
+
# Validate JSON-RPC version
|
|
246
|
+
if request.jsonrpc != "2.0":
|
|
247
|
+
return JSONResponse(
|
|
248
|
+
status_code=400,
|
|
249
|
+
content=create_error_response(
|
|
250
|
+
request.id, MCPErrorCode.INVALID_REQUEST, "Invalid JSON-RPC version"
|
|
251
|
+
).model_dump(),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Check if it's a request or notification
|
|
255
|
+
if request.method is None:
|
|
256
|
+
return JSONResponse(
|
|
257
|
+
status_code=400,
|
|
258
|
+
content=create_error_response(
|
|
259
|
+
request.id, MCPErrorCode.INVALID_REQUEST, "Missing method field"
|
|
260
|
+
).model_dump(),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if request.id is None:
|
|
264
|
+
# Handle notification
|
|
265
|
+
notification = JsonRpcNotification(
|
|
266
|
+
jsonrpc="2.0", method=request.method, params=request.params
|
|
267
|
+
)
|
|
268
|
+
await self.server.handle_notification(notification)
|
|
269
|
+
return JSONResponse(status_code=202, content={})
|
|
270
|
+
|
|
271
|
+
# Handle request
|
|
272
|
+
rpc_request = JsonRpcRequest(
|
|
273
|
+
jsonrpc="2.0", id=request.id, method=request.method, params=request.params
|
|
274
|
+
)
|
|
275
|
+
response = await self.server.handle_request(rpc_request)
|
|
276
|
+
return JSONResponse(content=response.model_dump(exclude_none=True))
|
|
277
|
+
|
|
278
|
+
@app.post("/mcp/v1/batch")
|
|
279
|
+
async def batch_endpoint(requests: list[MessageRequest]):
|
|
280
|
+
"""
|
|
281
|
+
Batch JSON-RPC message endpoint.
|
|
282
|
+
|
|
283
|
+
Processes multiple JSON-RPC requests in a single call.
|
|
284
|
+
"""
|
|
285
|
+
responses = []
|
|
286
|
+
|
|
287
|
+
for request in requests:
|
|
288
|
+
# Skip notifications in batch responses
|
|
289
|
+
if request.id is None:
|
|
290
|
+
notification = JsonRpcNotification(
|
|
291
|
+
jsonrpc="2.0", method=request.method, params=request.params
|
|
292
|
+
)
|
|
293
|
+
await self.server.handle_notification(notification)
|
|
294
|
+
else:
|
|
295
|
+
rpc_request = JsonRpcRequest(
|
|
296
|
+
jsonrpc="2.0", id=request.id, method=request.method, params=request.params
|
|
297
|
+
)
|
|
298
|
+
response = await self.server.handle_request(rpc_request)
|
|
299
|
+
responses.append(response.model_dump(exclude_none=True))
|
|
300
|
+
|
|
301
|
+
return JSONResponse(content=responses)
|
|
302
|
+
|
|
303
|
+
@app.get("/mcp/v1/tools")
|
|
304
|
+
async def list_tools():
|
|
305
|
+
"""List available tools (non-standard endpoint for convenience)."""
|
|
306
|
+
tools = self.server.tool_registry.list_tools()
|
|
307
|
+
return JSONResponse(content={"tools": [tool.model_dump() for tool in tools]})
|
|
308
|
+
|
|
309
|
+
@app.get("/mcp/v1/status")
|
|
310
|
+
async def server_status():
|
|
311
|
+
"""Get server status (non-standard endpoint for convenience)."""
|
|
312
|
+
status = await self.server.tool_registry.call_tool("status", {})
|
|
313
|
+
return JSONResponse(content=status)
|
|
314
|
+
|
|
315
|
+
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
316
|
+
"""Broadcast a message to all SSE clients."""
|
|
317
|
+
await self.sse_manager.broadcast(message)
|
|
318
|
+
|
|
319
|
+
async def shutdown(self) -> None:
|
|
320
|
+
"""Shutdown the transport and server."""
|
|
321
|
+
await self.server.shutdown()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ============================================================================
|
|
325
|
+
# FastAPI App Factory
|
|
326
|
+
# ============================================================================
|
|
327
|
+
|
|
328
|
+
_http_transport: Optional[HTTPTransport] = None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def create_http_app(
|
|
332
|
+
server: Optional[MCPServer] = None,
|
|
333
|
+
config: Optional[ServerConfig] = None,
|
|
334
|
+
cors_origins: Optional[list[str]] = None,
|
|
335
|
+
) -> FastAPI:
|
|
336
|
+
"""
|
|
337
|
+
Create FastAPI application for MCP HTTP transport.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
server: Optional MCPServer instance
|
|
341
|
+
config: Optional server configuration
|
|
342
|
+
cors_origins: Optional CORS allowed origins
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Configured FastAPI application
|
|
346
|
+
"""
|
|
347
|
+
global _http_transport
|
|
348
|
+
_http_transport = HTTPTransport(server=server, config=config, cors_origins=cors_origins)
|
|
349
|
+
return _http_transport.app
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def get_http_transport() -> Optional[HTTPTransport]:
|
|
353
|
+
"""Get the global HTTP transport instance."""
|
|
354
|
+
return _http_transport
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ============================================================================
|
|
358
|
+
# Uvicorn Runner
|
|
359
|
+
# ============================================================================
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def run_http_server( # noqa: PLR0913
|
|
363
|
+
host: str = "0.0.0.0",
|
|
364
|
+
port: int = 8080,
|
|
365
|
+
server: Optional[MCPServer] = None,
|
|
366
|
+
config: Optional[ServerConfig] = None,
|
|
367
|
+
cors_origins: Optional[list[str]] = None,
|
|
368
|
+
log_level: str = "info",
|
|
369
|
+
):
|
|
370
|
+
"""
|
|
371
|
+
Run the HTTP server using Uvicorn.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
host: Host to bind to
|
|
375
|
+
port: Port to bind to
|
|
376
|
+
server: Optional MCPServer instance
|
|
377
|
+
config: Optional server configuration
|
|
378
|
+
cors_origins: Optional CORS allowed origins
|
|
379
|
+
log_level: Uvicorn log level
|
|
380
|
+
"""
|
|
381
|
+
import uvicorn # noqa: PLC0415
|
|
382
|
+
|
|
383
|
+
app = create_http_app(server=server, config=config, cors_origins=cors_origins)
|
|
384
|
+
|
|
385
|
+
config = uvicorn.Config(app, host=host, port=port, log_level=log_level, access_log=True)
|
|
386
|
+
|
|
387
|
+
server_instance = uvicorn.Server(config)
|
|
388
|
+
await server_instance.serve()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ============================================================================
|
|
392
|
+
# Main Entry Point
|
|
393
|
+
# ============================================================================
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def main():
|
|
397
|
+
"""Main entry point for HTTP transport."""
|
|
398
|
+
import argparse # noqa: PLC0415
|
|
399
|
+
|
|
400
|
+
parser = argparse.ArgumentParser(description="SIF MCP HTTP Server")
|
|
401
|
+
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
|
|
402
|
+
parser.add_argument("--port", type=int, default=8080, help="Port to bind to")
|
|
403
|
+
parser.add_argument("--log-level", default="info", help="Log level")
|
|
404
|
+
parser.add_argument("--cors-origins", nargs="*", default=["*"], help="CORS allowed origins")
|
|
405
|
+
|
|
406
|
+
args = parser.parse_args()
|
|
407
|
+
|
|
408
|
+
# Setup logging
|
|
409
|
+
logging.basicConfig(
|
|
410
|
+
level=getattr(logging, args.log_level.upper()),
|
|
411
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
logger.info(f"Starting SIF MCP HTTP Server on {args.host}:{args.port}")
|
|
415
|
+
|
|
416
|
+
asyncio.run(
|
|
417
|
+
run_http_server(
|
|
418
|
+
host=args.host, port=args.port, cors_origins=args.cors_origins, log_level=args.log_level
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
if __name__ == "__main__":
|
|
424
|
+
main()
|