libentry 1.22.3__py3-none-any.whl → 1.22.4__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.
libentry/json.py CHANGED
@@ -13,6 +13,7 @@ from base64 import b64decode, b64encode
13
13
  from functools import partial
14
14
 
15
15
  import numpy as np
16
+ from pydantic import BaseModel
16
17
 
17
18
  _BINDINGS = []
18
19
 
@@ -26,6 +27,9 @@ def bind(name, type_):
26
27
 
27
28
 
28
29
  def custom_encode(o) -> dict:
30
+ if isinstance(o, BaseModel):
31
+ return o.model_dump()
32
+
29
33
  for name, type_, support in _BINDINGS:
30
34
  if isinstance(o, type_):
31
35
  doc = support.encode(o)
libentry/service/flask.py CHANGED
@@ -371,6 +371,7 @@ def run_service(
371
371
  worker_class: str = "gthread",
372
372
  timeout: int = 60,
373
373
  keyfile: Optional[str] = None,
374
+ keyfile_password: Optional[str] = None,
374
375
  certfile: Optional[str] = None
375
376
  ):
376
377
  logger.info("Starting gunicorn server.")
@@ -378,6 +379,18 @@ def run_service(
378
379
  num_connections = num_threads * 2
379
380
  if backlog is None or backlog < num_threads * 2:
380
381
  backlog = num_threads * 2
382
+
383
+ def ssl_context(config, default_ssl_context_factory):
384
+ import ssl
385
+ context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
386
+ context.load_cert_chain(
387
+ certfile=config.certfile,
388
+ keyfile=config.keyfile,
389
+ password=keyfile_password
390
+ )
391
+ context.minimum_version = ssl.TLSVersion.TLSv1_3
392
+ return context
393
+
381
394
  options = {
382
395
  "bind": f"{host}:{port}",
383
396
  "workers": num_workers,
@@ -387,9 +400,9 @@ def run_service(
387
400
  "backlog": backlog,
388
401
  "keyfile": keyfile,
389
402
  "certfile": certfile,
390
- "worker_class": worker_class
403
+ "worker_class": worker_class,
404
+ "ssl_context": ssl_context
391
405
  }
392
406
  for name, value in options.items():
393
407
  logger.info(f"Option {name}: {value}")
394
408
  GunicornApplication(service_type, service_config, options).run()
395
-
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env python3
2
+
3
+ __author__ = "xi"
4
+ __all__ = [
5
+ "MCPMethod",
6
+ ]
7
+
8
+ import asyncio
9
+ import traceback
10
+ from inspect import signature
11
+ from types import GeneratorType
12
+ from typing import Callable, Dict, Iterable, Optional, Type, Union
13
+
14
+ from flask import Flask, request as flask_request
15
+ from pydantic import BaseModel
16
+
17
+ from libentry import api, json, logger
18
+ from libentry.api import list_api_info
19
+ from libentry.schema import query_api
20
+
21
+ try:
22
+ from gunicorn.app.base import BaseApplication
23
+ except ImportError:
24
+ class BaseApplication:
25
+
26
+ def load(self) -> Flask:
27
+ pass
28
+
29
+ def run(self):
30
+ flask_server = self.load()
31
+ assert hasattr(self, "options")
32
+ bind = getattr(self, "options")["bind"]
33
+ pos = bind.rfind(":")
34
+ host = bind[:pos]
35
+ port = int(bind[pos + 1:])
36
+ logger.warn("Your system doesn't support gunicorn.")
37
+ logger.warn("Use Flask directly.")
38
+ logger.warn("Options like \"num_threads\", \"num_workers\" are ignored.")
39
+ return flask_server.run(host=host, port=port)
40
+
41
+
42
+ class MCPMethod:
43
+
44
+ def __init__(self, fn: Callable, method: str = None):
45
+ self.fn = fn
46
+ assert hasattr(fn, "__name__")
47
+ self.__name__ = fn.__name__
48
+ self.method = self.__name__ if method is None else method
49
+
50
+ self.input_schema = None
51
+ params = signature(fn).parameters
52
+ if len(params) == 1:
53
+ for name, value in params.items():
54
+ annotation = value.annotation
55
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
56
+ self.input_schema = annotation
57
+
58
+ def __call__(self, request: dict) -> Union[dict, Iterable[dict]]:
59
+ try:
60
+ jsonrpc_version = request["jsonrpc"]
61
+ request_id = request["id"]
62
+ method = request["method"]
63
+ except KeyError:
64
+ raise RuntimeError("Invalid JSON-RPC specification.")
65
+
66
+ if not isinstance(request_id, (str, int)):
67
+ raise RuntimeError(
68
+ f"Request ID should be an integer or string. "
69
+ f"Got {type(request_id)}."
70
+ )
71
+
72
+ if method != self.method:
73
+ raise RuntimeError(
74
+ f"Method missmatch."
75
+ f"Expect {self.method}, got {method}."
76
+ )
77
+
78
+ params = request.get("params", {})
79
+
80
+ try:
81
+ if self.input_schema is not None:
82
+ # Note that "input_schema is not None" means:
83
+ # (1) The function has only one argument;
84
+ # (2) The arguments is a BaseModel.
85
+ # In this case, the request data can be directly validated as a "BaseModel" and
86
+ # subsequently passed to the function as a single object.
87
+ pydantic_params = self.input_schema.model_validate(params)
88
+ result = self.fn(pydantic_params)
89
+ else:
90
+ # The function has multiple arguments, and the request data bundle them as a single object.
91
+ # So, they should be unpacked before pass to the function.
92
+ result = self.fn(**params)
93
+ except Exception as e:
94
+ if isinstance(e, (SystemExit, KeyboardInterrupt)):
95
+ raise e
96
+ return {
97
+ "jsonrpc": jsonrpc_version,
98
+ "id": request_id,
99
+ "error": self._make_error(e)
100
+ }
101
+
102
+ if not isinstance(result, (GeneratorType, range)):
103
+ return {
104
+ "jsonrpc": jsonrpc_version,
105
+ "id": request_id,
106
+ "result": result
107
+ }
108
+
109
+ return ({
110
+ "jsonrpc": jsonrpc_version,
111
+ "id": request_id,
112
+ "result": item
113
+ } for item in result)
114
+
115
+ @staticmethod
116
+ def _make_error(e):
117
+ err_cls = e.__class__
118
+ err_name = err_cls.__name__
119
+ module = err_cls.__module__
120
+ if module != "builtins":
121
+ err_name = f"{module}.{err_name}"
122
+ return {
123
+ "code": 1,
124
+ "message": f"{err_name}: {str(e)}",
125
+ "data": traceback.format_exc()
126
+ }
127
+
128
+
129
+ class FlaskMethod:
130
+
131
+ def __init__(self, method, api_info, app):
132
+ self.method = MCPMethod(method)
133
+ self.api_info = api_info
134
+ self.app = app
135
+ assert hasattr(method, "__name__")
136
+ self.__name__ = method.__name__
137
+
138
+ CONTENT_TYPE_JSON = "application/json"
139
+ CONTENT_TYPE_SSE = "text/event-stream"
140
+
141
+ def __call__(self):
142
+ args = flask_request.args
143
+ data = flask_request.data
144
+ content_type = flask_request.content_type
145
+ accepts = flask_request.accept_mimetypes
146
+
147
+ json_from_url = {**args}
148
+ if data:
149
+ if (not content_type) or content_type == self.CONTENT_TYPE_JSON:
150
+ json_from_data = json.loads(data)
151
+ else:
152
+ return self.app.error(f"Unsupported Content-Type: \"{content_type}\".")
153
+ else:
154
+ json_from_data = {}
155
+
156
+ conflicts = json_from_url.keys() & json_from_data.keys()
157
+ if len(conflicts) > 0:
158
+ return self.app.error(f"Duplicated fields: \"{conflicts}\".")
159
+
160
+ input_json = {**json_from_url, **json_from_data}
161
+ print(input_json)
162
+
163
+ try:
164
+ output_json = self.method(input_json)
165
+ except Exception as e:
166
+ return self.app.error(str(e))
167
+
168
+ if isinstance(output_json, Dict):
169
+ if self.CONTENT_TYPE_JSON in accepts:
170
+ return self.app.ok(json.dumps(output_json), mimetype=self.CONTENT_TYPE_JSON)
171
+ else:
172
+ return self.app.error(f"Unsupported Accept: \"{[*accepts]}\".")
173
+ elif isinstance(output_json, (GeneratorType, range)):
174
+ if self.CONTENT_TYPE_SSE in accepts:
175
+ # todo
176
+ return self.app.ok(json.dumps(output_json), mimetype=self.CONTENT_TYPE_SSE)
177
+ else:
178
+ return self.app.error(f"Unsupported Accept: \"{[*accepts]}\".")
179
+
180
+
181
+ class FlaskServer(Flask):
182
+
183
+ def __init__(self, service):
184
+ super().__init__(__name__)
185
+ self.service = service
186
+
187
+ logger.info("Initializing Flask application.")
188
+ self.api_info_list = list_api_info(service)
189
+ if len(self.api_info_list) == 0:
190
+ logger.error("No API found, nothing to serve.")
191
+ return
192
+
193
+ for fn, api_info in self.api_info_list:
194
+ method = api_info.method
195
+ path = api_info.path
196
+ if asyncio.iscoroutinefunction(fn):
197
+ logger.error(f"Async function \"{fn.__name__}\" is not supported.")
198
+ continue
199
+ logger.info(f"Serving {method}-API for {path}")
200
+
201
+ wrapped_fn = FlaskMethod(fn, api_info, self)
202
+ if method == "GET":
203
+ self.get(path)(wrapped_fn)
204
+ elif method == "POST":
205
+ self.post(path)(wrapped_fn)
206
+ else:
207
+ raise RuntimeError(f"Unsupported method \"{method}\" for ")
208
+
209
+ for fn, api_info in list_api_info(self):
210
+ method = api_info.method
211
+ path = api_info.path
212
+
213
+ if any(api_info.path == a.path for _, a in self.api_info_list):
214
+ logger.info(f"Use custom implementation of {path}.")
215
+ continue
216
+
217
+ if asyncio.iscoroutinefunction(fn):
218
+ logger.error(f"Async function \"{fn.__name__}\" is not supported.")
219
+ continue
220
+ logger.info(f"Serving {method}-API for {path}")
221
+
222
+ wrapped_fn = FlaskMethod(fn, api_info, self)
223
+ if method == "GET":
224
+ self.get(path)(wrapped_fn)
225
+ elif method == "POST":
226
+ self.post(path)(wrapped_fn)
227
+ else:
228
+ raise RuntimeError(f"Unsupported method \"{method}\" for ")
229
+
230
+ logger.info("Flask application initialized.")
231
+
232
+ @api.get("/")
233
+ def index(self, name: str = None):
234
+ if name is None:
235
+ all_api = []
236
+ for _, api_info in self.api_info_list:
237
+ all_api.append({"path": api_info.path})
238
+ return all_api
239
+
240
+ for fn, api_info in self.api_info_list:
241
+ if api_info.path == "/" + name:
242
+ return query_api(fn).model_dump()
243
+
244
+ return f"No API named \"{name}\""
245
+
246
+ @api.get()
247
+ def live(self):
248
+ return "OK"
249
+
250
+ def ok(self, body: Union[str, Iterable[str]], mimetype: str):
251
+ return self.response_class(body, status=200, mimetype=mimetype)
252
+
253
+ def error(self, body: str, mimetype="text"):
254
+ return self.response_class(body, status=500, mimetype=mimetype)
255
+
256
+
257
+ class GunicornApplication(BaseApplication):
258
+
259
+ def __init__(self, service_type, service_config=None, options=None):
260
+ self.service_type = service_type
261
+ self.service_config = service_config
262
+ self.options = options or {}
263
+ super().__init__()
264
+
265
+ def load_config(self):
266
+ config = {
267
+ key: value
268
+ for key, value in self.options.items()
269
+ if key in self.cfg.settings and value is not None
270
+ }
271
+ for key, value in config.items():
272
+ self.cfg.set(key.lower(), value)
273
+
274
+ def load(self):
275
+ logger.info("Initializing the service.")
276
+ if isinstance(self.service_type, type) or callable(self.service_type):
277
+ service = self.service_type(self.service_config) if self.service_config else self.service_type()
278
+ elif self.service_config is None:
279
+ logger.warning(
280
+ "Be careful! It is not recommended to start the server from a service instance. "
281
+ "Use service_type and service_config instead."
282
+ )
283
+ service = self.service_type
284
+ else:
285
+ raise TypeError(f"Invalid service type \"{type(self.service_type)}\".")
286
+ logger.info("Service initialized.")
287
+
288
+ return FlaskServer(service)
289
+
290
+
291
+ def run_service(
292
+ service_type: Union[Type, Callable],
293
+ service_config=None,
294
+ host: str = "0.0.0.0",
295
+ port: int = 8888,
296
+ num_workers: int = 1,
297
+ num_threads: int = 20,
298
+ num_connections: Optional[int] = 1000,
299
+ backlog: Optional[int] = 1000,
300
+ worker_class: str = "gthread",
301
+ timeout: int = 60,
302
+ keyfile: Optional[str] = None,
303
+ keyfile_password: Optional[str] = None,
304
+ certfile: Optional[str] = None
305
+ ):
306
+ logger.info("Starting gunicorn server.")
307
+ if num_connections is None or num_connections < num_threads * 2:
308
+ num_connections = num_threads * 2
309
+ if backlog is None or backlog < num_threads * 2:
310
+ backlog = num_threads * 2
311
+
312
+ def ssl_context(config, default_ssl_context_factory):
313
+ import ssl
314
+ context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
315
+ context.load_cert_chain(
316
+ certfile=config.certfile,
317
+ keyfile=config.keyfile,
318
+ password=keyfile_password
319
+ )
320
+ context.minimum_version = ssl.TLSVersion.TLSv1_3
321
+ return context
322
+
323
+ options = {
324
+ "bind": f"{host}:{port}",
325
+ "workers": num_workers,
326
+ "threads": num_threads,
327
+ "timeout": timeout,
328
+ "worker_connections": num_connections,
329
+ "backlog": backlog,
330
+ "keyfile": keyfile,
331
+ "certfile": certfile,
332
+ "worker_class": worker_class,
333
+ "ssl_context": ssl_context
334
+ }
335
+ for name, value in options.items():
336
+ logger.info(f"Option {name}: {value}")
337
+ GunicornApplication(service_type, service_config, options).run()
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: libentry
3
- Version: 1.22.3
3
+ Version: 1.22.4
4
4
  Summary: Entries for experimental utilities.
5
5
  Home-page: https://github.com/XoriieInpottn/libentry
6
6
  Author: xi
@@ -9,13 +9,24 @@ License: Apache-2.0 license
9
9
  Platform: any
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Description-Content-Type: text/markdown
12
- Requires-Dist: Flask
12
+ License-File: LICENSE
13
+ Requires-Dist: pydantic
13
14
  Requires-Dist: PyYAML
14
- Requires-Dist: gunicorn
15
- Requires-Dist: httpx
16
15
  Requires-Dist: numpy
17
- Requires-Dist: pydantic
18
16
  Requires-Dist: urllib3
17
+ Requires-Dist: httpx
18
+ Requires-Dist: Flask
19
+ Requires-Dist: gunicorn
20
+ Dynamic: author
21
+ Dynamic: author-email
22
+ Dynamic: classifier
23
+ Dynamic: description
24
+ Dynamic: description-content-type
25
+ Dynamic: home-page
26
+ Dynamic: license
27
+ Dynamic: platform
28
+ Dynamic: requires-dist
29
+ Dynamic: summary
19
30
 
20
31
  # libentry
21
32
 
@@ -29,5 +40,3 @@ Requires-Dist: urllib3
29
40
  2. Use its post() or get() method to send the request.
30
41
 
31
42
 
32
-
33
-
@@ -4,22 +4,23 @@ libentry/argparse.py,sha256=NxzXV-jBN51ReZsNs5aeyOfzwYQ5A5nJ95rWoa-FYCs,10415
4
4
  libentry/dataclasses.py,sha256=AQV2PuxplJCwGZ5HKX72U-z-POUhTdy3XtpEK9KNIGQ,4541
5
5
  libentry/executor.py,sha256=cTV0WxJi0nU1TP-cOwmeodN8DD6L1691M2HIQsJtGrU,6582
6
6
  libentry/experiment.py,sha256=ejgAHDXWIe9x4haUzIFuz1WasLY0_aD1z_vyEVGjTu8,4922
7
- libentry/json.py,sha256=Rigg8H3oWooI9bNfIpx9mSOcoc3aTigy_-BHiPIv8kY,1821
7
+ libentry/json.py,sha256=CubUUu29h7idLaC4d66vKhjBgVHKN1rZOv-Tw2qM17k,1916
8
8
  libentry/logging.py,sha256=IiYoCUzm8XTK1fduA-NA0FI2Qz_m81NEPV3d3tEfgdI,1349
9
9
  libentry/schema.py,sha256=o6JcdR00Yj4_Qjmlo100OlQpMVnl0PgvvwVVrL9limw,8268
10
10
  libentry/test_api.py,sha256=Xw7B7sH6g1iCTV5sFzyBF3JAJzeOr9xg0AyezTNsnIk,4452
11
11
  libentry/utils.py,sha256=O7P6GadtUIjq0N2IZH7PhHZDUM3NebzcqyDqytet7CM,683
12
12
  libentry/service/__init__.py,sha256=1oLL20yLB1GL9IbFiZD8OReDqiCpFr-yetIR6x1cNkI,23
13
13
  libentry/service/common.py,sha256=OVaW2afgKA6YqstJmtnprBCqQEUZEWotZ6tHavmJJeU,42
14
- libentry/service/flask.py,sha256=Alpsix01ROqI28k-dabD8wJlWAWs-ObNc1nJ-LxOXn4,13349
14
+ libentry/service/flask.py,sha256=SDaZnhkS3Zk6y8CytVO_awwQ3RUiY7qSuMkYAgTu_SU,13816
15
+ libentry/service/flask_mcp.py,sha256=guzDVVT4gfjhFhnLbMSTWYARyxqbEv1gDaI6SLKurdU,11540
15
16
  libentry/service/list.py,sha256=ElHWhTgShGOhaxMUEwVbMXos0NQKjHsODboiQ-3AMwE,1397
16
17
  libentry/service/running.py,sha256=FrPJoJX6wYxcHIysoatAxhW3LajCCm0Gx6l7__6sULQ,5105
17
18
  libentry/service/start.py,sha256=mZT7b9rVULvzy9GTZwxWnciCHgv9dbGN2JbxM60OMn4,1270
18
19
  libentry/service/stop.py,sha256=wOpwZgrEJ7QirntfvibGq-XsTC6b3ELhzRW2zezh-0s,1187
19
- libentry-1.22.3.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
20
- libentry-1.22.3.dist-info/METADATA,sha256=xpYfLdij0X7LmPtj1jHxb7RmeL3p2zrYA5fPJiwLnEY,813
21
- libentry-1.22.3.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
22
- libentry-1.22.3.dist-info/entry_points.txt,sha256=vgHmJZhM-kqM7U9S179UwDD3pM232tpzJ5NntncXi_8,62
23
- libentry-1.22.3.dist-info/top_level.txt,sha256=u2uF6-X5fn2Erf9PYXOg_6tntPqTpyT-yzUZrltEd6I,9
24
- libentry-1.22.3.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
25
- libentry-1.22.3.dist-info/RECORD,,
20
+ libentry-1.22.4.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
21
+ libentry-1.22.4.dist-info/METADATA,sha256=B9WWlSfqYclWBLafid59zTg2GMh78j5cp7uOGTKznUo,1040
22
+ libentry-1.22.4.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
23
+ libentry-1.22.4.dist-info/entry_points.txt,sha256=1v_nLVDsjvVJp9SWhl4ef2zZrsLTBtFWgrYFgqvQBgc,61
24
+ libentry-1.22.4.dist-info/top_level.txt,sha256=u2uF6-X5fn2Erf9PYXOg_6tntPqTpyT-yzUZrltEd6I,9
25
+ libentry-1.22.4.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
26
+ libentry-1.22.4.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.45.1)
2
+ Generator: setuptools (75.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,3 +1,2 @@
1
1
  [console_scripts]
2
2
  libentry_test_api = libentry.test_api:main
3
-