From d531a8c00b8f8d8f1b84f4cacf42064524656a6f Mon Sep 17 00:00:00 2001 From: nikto_b Date: Tue, 22 Jul 2025 00:35:30 +0300 Subject: [PATCH] Add content_type field for SerializedResponses, add conversion pipeline basic docs generation --- pyproject.toml | 5 +- src/turbosloth/__main__.py | 2 +- src/turbosloth/app.py | 85 +++--- src/turbosloth/didoc/__init__.py | 50 ++++ src/turbosloth/didoc/page.jinja2 | 244 ++++++++++++++++++ src/turbosloth/interfaces/serialized/base.py | 1 + src/turbosloth/interfaces/serialized/html.py | 17 ++ src/turbosloth/interfaces/serialized/json.py | 4 +- .../interfaces/serialized/msgpack.py | 4 +- src/turbosloth/interfaces/serialized/text.py | 4 +- src/turbosloth/interfaces/serialized/xml.py | 4 +- uv.lock | 10 +- 12 files changed, 385 insertions(+), 45 deletions(-) create mode 100644 src/turbosloth/didoc/__init__.py create mode 100644 src/turbosloth/didoc/page.jinja2 create mode 100644 src/turbosloth/interfaces/serialized/html.py diff --git a/pyproject.toml b/pyproject.toml index baa118d..d2d5517 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,9 +9,10 @@ license = "LGPL-3.0-or-later" requires-python = ">=3.13" dependencies = [ "megasniff>=0.2.0", - "breakshaft>=0.1.1", + "breakshaft>=0.1.3", "case-insensitive-dictionary>=0.2.1", "mypy>=1.17.0", + "jinja2>=3.1.6", ] [tool.uv.sources] @@ -46,4 +47,4 @@ all_containers = [ { "include-group" = "xml" }, { "include-group" = "msgpack" }, { "include-group" = "json" }, -] \ No newline at end of file +] diff --git a/src/turbosloth/__main__.py b/src/turbosloth/__main__.py index 12f7833..6c0c88c 100644 --- a/src/turbosloth/__main__.py +++ b/src/turbosloth/__main__.py @@ -10,7 +10,7 @@ from turbosloth.interfaces.serialized import SerializedResponse, SerializedReque from turbosloth.internal_types import QTYPE, BTYPE, PTYPE, HTYPE from turbosloth.req_schema import UnwrappedRequest -app = SlothApp() +app = SlothApp(di_autodoc_prefix='/didoc') @app.get("/") diff --git a/src/turbosloth/app.py b/src/turbosloth/app.py index 8573a16..2175ddf 100644 --- a/src/turbosloth/app.py +++ b/src/turbosloth/app.py @@ -1,11 +1,13 @@ +import html import typing from typing import Optional, Callable, Awaitable, Protocol, get_type_hints, get_origin, get_args, Any, Annotated import breakshaft.util_mermaid import megasniff.exceptions -from breakshaft.models import ConversionPoint +from breakshaft.models import ConversionPoint, Callgraph from megasniff import SchemaInflatorGenerator +from .didoc import create_di_autodoc_handler from .exceptions import HTTPException from .interfaces.base import BasicRequest, BasicResponse from .interfaces.serialize_selector import SerializeSelector @@ -179,16 +181,24 @@ class MethodRoutersApp(ASGIApp): class SlothApp(HTTPApp, WSApp, LifespanApp, MethodRoutersApp): + di_autodoc_prefix: Optional[str] = None def __init__(self, on_startup: Optional[Callable[[], Awaitable[None]]] = None, - on_shutdown: Optional[Callable[[], Awaitable[None]]] = None): + on_shutdown: Optional[Callable[[], Awaitable[None]]] = None, + di_autodoc_prefix: Optional[str] = None, + ): self.router = Router() self._on_startup = on_startup self._on_shutdown = on_shutdown self.serialize_selector = SerializeSelector() self.infl_generator = SchemaInflatorGenerator(strict_mode=True) - self.inj_repo = ConvRepo() + self.di_autodoc_prefix = di_autodoc_prefix + + if di_autodoc_prefix is not None: + self.inj_repo = ConvRepo(store_sources=True, store_callseq=True) + else: + self.inj_repo = ConvRepo() @self.inj_repo.mark_injector() def extract_query(req: BasicRequest | SerializedRequest) -> QTYPE: @@ -231,42 +241,43 @@ class SlothApp(HTTPApp, WSApp, LifespanApp, MethodRoutersApp): if get_origin(tp) == UnwrappedRequest: req_schema = tp - if req_schema is None: - raise ValueError(f'Unable to find request schema in handler {fn}') + if req_schema is not None: - unwrap_types = get_args(req_schema) - defaults = (QTYPE, BTYPE, PTYPE, HTYPE) + unwrap_types = get_args(req_schema) + defaults = (QTYPE, BTYPE, PTYPE, HTYPE) - def none_generator(*args) -> None: - return None + def none_generator(*args) -> None: + return None - def create_convertor(t, def_type): - infl = None - if t not in [def_type, None, Any]: - infl = self.infl_generator.schema_to_inflator( - t, - strict_mode_override=False, - from_type_override=def_type + def create_convertor(t, def_type): + infl = None + if t not in [def_type, None, Any]: + infl = self.infl_generator.schema_to_inflator( + t, + strict_mode_override=False, + from_type_override=def_type + ) + return ConversionPoint(infl, t, (def_type,), (), ) + else: + return ConversionPoint(none_generator, t, (def_type,), (), ) + + fork_with = set(map(lambda x: create_convertor(*x), zip(unwrap_types, defaults))) + + def construct_unwrap(q: QTYPE, b: BTYPE, p: PTYPE, h: HTYPE) -> UnwrappedRequest: + return UnwrappedRequest(q, b, p, h) + + fork_with |= { + ConversionPoint( + construct_unwrap, + req_schema, + unwrap_types, + (), ) - return ConversionPoint(infl, t, (def_type,), (), ) - else: - return ConversionPoint(none_generator, t, (def_type,), (), ) + } - fork_with = set(map(lambda x: create_convertor(*x), zip(unwrap_types, defaults))) - - def construct_unwrap(q: QTYPE, b: BTYPE, p: PTYPE, h: HTYPE) -> UnwrappedRequest: - return UnwrappedRequest(q, b, p, h) - - fork_with |= { - ConversionPoint( - construct_unwrap, - req_schema, - unwrap_types, - (), - ) - } - - tmp_repo = self.inj_repo.fork(fork_with) + tmp_repo = self.inj_repo.fork(fork_with) + else: + tmp_repo = self.inj_repo p = tmp_repo.create_pipeline( (Send, BasicRequest), @@ -275,6 +286,12 @@ class SlothApp(HTTPApp, WSApp, LifespanApp, MethodRoutersApp): ) self.router.add(method, path_pattern, p) + + if self.di_autodoc_prefix is not None and not path_pattern.startswith( + self.di_autodoc_prefix + '/' + method + self.di_autodoc_prefix): + self.route('GET', self.di_autodoc_prefix + '/' + method + path_pattern)( + create_di_autodoc_handler(method, path_pattern, p)) + return fn return decorator diff --git a/src/turbosloth/didoc/__init__.py b/src/turbosloth/didoc/__init__.py new file mode 100644 index 0000000..7897137 --- /dev/null +++ b/src/turbosloth/didoc/__init__.py @@ -0,0 +1,50 @@ +import html +import importlib.resources +from dataclasses import dataclass +from typing import Callable + +import breakshaft.util_mermaid +import jinja2 + +from turbosloth.interfaces.serialized import SerializedResponse +from turbosloth.interfaces.serialized.html import HTMLSerializedResponse +from turbosloth.types import InternalHandlerType + + +@dataclass +class MMDiagramData: + name: str + data: str + + +def create_di_autodoc_handler(method: str, path: str, handler: InternalHandlerType) -> Callable[[], SerializedResponse]: + callseq = getattr(handler, '__breakshaft_callseq__', []) + mmd_flowchart = breakshaft.util_mermaid.draw_callseq_mermaid(callseq) + mmd_flowchart1 = breakshaft.util_mermaid.draw_callseq_mermaid([callseq[0]] + callseq) + mmd_flowchart2 = breakshaft.util_mermaid.draw_callseq_mermaid([callseq[-1]] + callseq) + + sources = getattr(handler, '__breakshaft_render_src__', '') + escaped_sources = html.escape(sources) + + template_path = importlib.resources.files('turbosloth.didoc') + loader = jinja2.FileSystemLoader(str(template_path)) + templateEnv = jinja2.Environment(loader=loader) + template = templateEnv.get_template('page.jinja2') + + mmd_diagrams = [ + MMDiagramData('1', html.escape(mmd_flowchart)), + MMDiagramData('2', html.escape(mmd_flowchart1)), + MMDiagramData('3', html.escape(mmd_flowchart2)), + ] + + html_content = template.render( + handler_method=method, + handler_path=path, + escaped_sources=escaped_sources, + mmd_diagrams=mmd_diagrams, + ) + + def _h() -> SerializedResponse: + return HTMLSerializedResponse(200, {}, html_content) + + return _h diff --git a/src/turbosloth/didoc/page.jinja2 b/src/turbosloth/didoc/page.jinja2 new file mode 100644 index 0000000..56c8781 --- /dev/null +++ b/src/turbosloth/didoc/page.jinja2 @@ -0,0 +1,244 @@ + + + + + Code + Mermaid + + + + + + + + + + + + + + + +
+ {{ handler_method }} + :: + {{ handler_path }} +
+ +
+
+

+{{ escaped_sources }}
+      
+
+ +
+ +
+
    + {% for diagram in mmd_diagrams %} +
  • {{ diagram.name }}
  • + {% endfor %} +
+ + {% for diagram in mmd_diagrams %} +
+
+ {{ diagram.data }} +
+
+ {% endfor %} +
+
+ + \ No newline at end of file diff --git a/src/turbosloth/interfaces/serialized/base.py b/src/turbosloth/interfaces/serialized/base.py index 3cc4caf..2e1d130 100644 --- a/src/turbosloth/interfaces/serialized/base.py +++ b/src/turbosloth/interfaces/serialized/base.py @@ -44,6 +44,7 @@ class SerializedResponse: code: int headers: Mapping[str, str] body: dict[str, Any] | list[Any] | str + content_type: str = '' def into_basic(self, charset: str) -> BasicResponse: raise NotImplementedError() diff --git a/src/turbosloth/interfaces/serialized/html.py b/src/turbosloth/interfaces/serialized/html.py new file mode 100644 index 0000000..4e3884a --- /dev/null +++ b/src/turbosloth/interfaces/serialized/html.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass + +from case_insensitive_dict import CaseInsensitiveDict + +from turbosloth.interfaces.base import BasicResponse +from .base import SerializedResponse + + +@dataclass +class HTMLSerializedResponse(SerializedResponse): + body: str + content_type = 'text/html' + + def into_basic(self, charset: str) -> BasicResponse: + headers = CaseInsensitiveDict(self.headers).copy() + headers['content-type'] = self.content_type + '; charset=' + charset + return BasicResponse(self.code, headers, str(self.body).encode(charset)) diff --git a/src/turbosloth/interfaces/serialized/json.py b/src/turbosloth/interfaces/serialized/json.py index 9e22a43..dba941d 100644 --- a/src/turbosloth/interfaces/serialized/json.py +++ b/src/turbosloth/interfaces/serialized/json.py @@ -19,8 +19,10 @@ class JsonSerializedRequest(SerializedRequest): class JsonSerializedResponse(SerializedResponse): + content_type = 'application/json' + def into_basic(self, charset: str) -> BasicResponse: headers = CaseInsensitiveDict(self.headers).copy() - headers['content-type'] = 'application/json; charset=utf-8' + headers['content-type'] = self.content_type + '; charset=utf-8' b = orjson.dumps(self.body) return BasicResponse(self.code, headers, b) diff --git a/src/turbosloth/interfaces/serialized/msgpack.py b/src/turbosloth/interfaces/serialized/msgpack.py index 6978f0b..216bc73 100644 --- a/src/turbosloth/interfaces/serialized/msgpack.py +++ b/src/turbosloth/interfaces/serialized/msgpack.py @@ -16,8 +16,10 @@ class MessagePackSerializedRequest(SerializedRequest): class MessagePackSerializedResponse(SerializedResponse): + content_type = 'application/vnd.msgpack' + def into_basic(self, charset: str) -> BasicResponse: headers = CaseInsensitiveDict(self.headers).copy() - headers['content-type'] = 'application/vnd.msgpack' + headers['content-type'] = self.content_type b = msgpack.packb(self.body) return BasicResponse(self.code, headers, b) diff --git a/src/turbosloth/interfaces/serialized/text.py b/src/turbosloth/interfaces/serialized/text.py index 9fabc4e..e8acaf1 100644 --- a/src/turbosloth/interfaces/serialized/text.py +++ b/src/turbosloth/interfaces/serialized/text.py @@ -12,7 +12,9 @@ class TextSerializedRequest(SerializedRequest): class TextSerializedResponse(SerializedResponse): + content_type = 'text/plain' + def into_basic(self, charset: str) -> BasicResponse: headers = CaseInsensitiveDict(self.headers).copy() - headers['content-type'] = 'text/plain; charset=' + charset + headers['content-type'] = self.content_type + '; charset=' + charset return BasicResponse(self.code, headers, str(self.body).encode(charset)) diff --git a/src/turbosloth/interfaces/serialized/xml.py b/src/turbosloth/interfaces/serialized/xml.py index af73e9e..24828a9 100644 --- a/src/turbosloth/interfaces/serialized/xml.py +++ b/src/turbosloth/interfaces/serialized/xml.py @@ -21,9 +21,11 @@ class XMLSerializedRequest(SerializedRequest): class XMLSerializedResponse(SerializedResponse): + content_type = 'application/xml' + def into_basic(self, charset: str) -> BasicResponse: headers = CaseInsensitiveDict(self.headers).copy() - headers['content-type'] = 'application/xml; charset=' + charset + headers['content-type'] = self.content_type + '; charset=' + charset btxt = _into_xml(self.body) diff --git a/uv.lock b/uv.lock index 5591f88..2185010 100644 --- a/uv.lock +++ b/uv.lock @@ -4,15 +4,15 @@ requires-python = ">=3.13" [[package]] name = "breakshaft" -version = "0.1.1.post1" +version = "0.1.3" source = { registry = "https://git.nikto-b.ru/api/packages/nikto_b/pypi/simple" } dependencies = [ { name = "hatchling" }, { name = "jinja2" }, ] -sdist = { url = "https://git.nikto-b.ru/api/packages/nikto_b/pypi/files/breakshaft/0.1.1.post1/breakshaft-0.1.1.post1.tar.gz", hash = "sha256:0c880a57eb53122cd1d5c7d2f2520d9f536edd7fac333496da72cc0fa7bf5283" } +sdist = { url = "https://git.nikto-b.ru/api/packages/nikto_b/pypi/files/breakshaft/0.1.3/breakshaft-0.1.3.tar.gz", hash = "sha256:2bf0154a9b0824ca8ca7d0908ea9aef78496d28ec824b677fff49cd8af2d1c56" } wheels = [ - { url = "https://git.nikto-b.ru/api/packages/nikto_b/pypi/files/breakshaft/0.1.1.post1/breakshaft-0.1.1.post1-py3-none-any.whl", hash = "sha256:46895336aee55b3fbd0664a2f2deb73a04f15f1dc3ac382fa17884bad4c0f818" }, + { url = "https://git.nikto-b.ru/api/packages/nikto_b/pypi/files/breakshaft/0.1.3/breakshaft-0.1.3-py3-none-any.whl", hash = "sha256:f9693d529a4c5af3f8e196bac191d7549eaf1b4ec601b63db6c1caae38bcec13" }, ] [[package]] @@ -347,6 +347,7 @@ source = { editable = "." } dependencies = [ { name = "breakshaft" }, { name = "case-insensitive-dictionary" }, + { name = "jinja2" }, { name = "megasniff" }, { name = "mypy" }, ] @@ -380,8 +381,9 @@ xml = [ [package.metadata] requires-dist = [ - { name = "breakshaft", specifier = ">=0.1.1", index = "https://git.nikto-b.ru/api/packages/nikto_b/pypi/simple" }, + { name = "breakshaft", specifier = ">=0.1.3", index = "https://git.nikto-b.ru/api/packages/nikto_b/pypi/simple" }, { name = "case-insensitive-dictionary", specifier = ">=0.2.1" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "megasniff", specifier = ">=0.2.0", index = "https://git.nikto-b.ru/api/packages/nikto_b/pypi/simple" }, { name = "mypy", specifier = ">=1.17.0" }, ]