Add content_type field for SerializedResponses, add conversion pipeline basic docs generation

This commit is contained in:
2025-07-22 00:35:30 +03:00
parent a763f0960c
commit d531a8c00b
12 changed files with 385 additions and 45 deletions

View File

@@ -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" },
]
]

View File

@@ -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("/")

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Code + Mermaid</title>
<!-- Highlight.js с CDN -->
<link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets@11.11.0/styles/atom-one-dark.min.css">
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.11.0/highlight.min.js"></script>
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.11.0/languages/go.min.js"></script>
<script>hljs.highlightAll();</script>
<!-- Mermaid.js с CDN -->
<script src="https://unpkg.com/mermaid/dist/mermaid.min.js " defer></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Инициализация Mermaid
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
// Инициализация вкладок
const tabs = document.querySelectorAll('.tab');
const tabContents = document.querySelectorAll('.tab-content');
// Активация первой вкладки
if (tabs.length > 0) {
tabs[0].classList.add('active');
tabContents[0].style.display = 'block';
mermaid.init(undefined, tabContents[0].querySelector('.mermaid'));
}
// Обработчик клика по вкладкам
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(c => c.style.display = 'none');
tab.classList.add('active');
tabContents[index].style.display = 'block';
// Инициализация Mermaid для активной вкладки, если еще не была инициализирована
const diagram = tabContents[index].querySelector('.mermaid');
if (!diagram.dataset.initialized) {
mermaid.init(undefined, diagram);
diagram.dataset.initialized = true;
}
});
});
// Логика перетаскивания
const resizer = document.getElementById('resizer');
const codeContainer = document.querySelector('.code-container');
const diagramContainer = document.querySelector('.diagram-container');
let isDragging = false;
resizer.addEventListener('mousedown', (e) => {
isDragging = true;
document.body.style.cursor = 'ew-resize';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const containerWidth = document.body.clientWidth;
const x = e.clientX;
const codeWidth = (x / containerWidth) * 100;
if (codeWidth >= 5 && codeWidth <= 95) {
codeContainer.style.flex = `0 0 ${codeWidth}%`;
diagramContainer.style.flex = `0 0 ${100 - codeWidth}%`;
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
document.body.style.cursor = 'default';
});
document.addEventListener('mouseleave', () => {
isDragging = false;
document.body.style.cursor = 'default';
});
});
</script>
<style>
body {
display: flex;
margin: 0;
height: 100vh;
max-width: 100vw;
font-family: sans-serif;
background-color: #1e1e1e;
color: #e6e6e6;
flex-direction: column;
}
.topbar {
background-color: #2d2d2d;
padding: 0.5rem 1rem;
border-bottom: 1px solid #444;
font-weight: bold;
display: flex;
align-items: center;
gap: 0.5rem;
}
.topbar span {
color: #888;
}
.main-container {
display: flex;
flex: 1;
overflow: hidden;
}
.code-container, .diagram-container {
flex: 1;
overflow: auto;
transition: width 0.2s ease;
}
.code-container {
background-color: #2d2d2d;
border-right: 1px solid #444;
padding: 1rem;
}
.code-container pre {
background: #1e1e1e;
color: #dcdcdc;
padding: 1rem;
border-radius: 4px;
margin: 0;
}
.diagram-container {
background-color: #2d2d2d;
display: flex;
flex-direction: column;
}
.tabs {
display: flex;
border-bottom: 1px solid #444;
padding: 0 1rem;
background-color: #333;
flex-shrink: 0;
}
.tab {
padding: 0.5rem 1rem;
cursor: pointer;
color: #ccc;
border-bottom: 2px solid transparent;
transition: all 0.2s;
display: block;
}
.tab.active {
border-bottom: 2px solid #007acc;
color: #007acc;
}
.tab:hover {
background-color: #444;
}
.tab-content {
flex: 1;
padding: 1rem;
display: none;
overflow: auto;
}
.mermaid {
color: #e6e6e6;
}
.mermaid .node rect,
.mermaid .node circle,
.mermaid .node ellipse {
fill: #3a3a3a !important;
stroke: #888 !important;
}
.mermaid .edgePath path {
stroke: #ccc !important;
}
.resizer {
width: 5px;
background-color: #444;
cursor: ew-resize;
z-index: 10;
}
.resizer::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: #888;
left: 2px;
}
</style>
</head>
<body>
<div class="topbar">
<span style="color: #007acc;">{{ handler_method }}</span>
<span style="color: #888;">::</span>
<span>{{ handler_path }}</span>
</div>
<div class="main-container">
<div class="code-container">
<pre><code class="language-python">
{{ escaped_sources }}
</code></pre>
</div>
<div class="resizer" id="resizer"></div>
<div class="diagram-container">
<ul class="tabs">
{% for diagram in mmd_diagrams %}
<li class="tab">{{ diagram.name }}</li>
{% endfor %}
</ul>
{% for diagram in mmd_diagrams %}
<div class="tab-content">
<div class="mermaid">
{{ diagram.data }}
</div>
</div>
{% endfor %}
</div>
</div>
</body>
</html>

View File

@@ -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()

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

10
uv.lock generated
View File

@@ -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" },
]