Add content_type field for SerializedResponses, add conversion pipeline basic docs generation
This commit is contained in:
@@ -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" },
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
|
||||
50
src/turbosloth/didoc/__init__.py
Normal file
50
src/turbosloth/didoc/__init__.py
Normal 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
|
||||
244
src/turbosloth/didoc/page.jinja2
Normal file
244
src/turbosloth/didoc/page.jinja2
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
17
src/turbosloth/interfaces/serialized/html.py
Normal file
17
src/turbosloth/interfaces/serialized/html.py
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
10
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user