This commit is contained in:
long2ice
2021-05-04 22:33:30 +08:00
parent 16c54efb2c
commit 6af5852082
20 changed files with 338 additions and 162 deletions

View File

@ -41,6 +41,8 @@ Or pro version online demo [here](https://fastapi-admin-pro.long2ice.cn/admin/lo
![](https://raw.githubusercontent.com/fastapi-admin/fastapi-admin/dev/images/dashboard.png)
## Run examples in local
## Documentation
See documentation at [https://fastapi-admin.github.io/fastapi-admin](https://fastapi-admin.github.io/fastapi-admin).

View File

@ -11,10 +11,10 @@ from tortoise.contrib.fastapi import register_tortoise
from examples import settings
from examples.constants import BASE_DIR
from examples.models import Admin
from examples.providers import LoginProvider
from fastapi_admin.app import app as admin_app
from fastapi_admin.providers.login import UsernamePasswordProvider
login_provider = UsernamePasswordProvider(admin_model=Admin)
login_provider = LoginProvider(admin_model=Admin)
def create_app():

7
examples/providers.py Normal file
View File

@ -0,0 +1,7 @@
from fastapi_admin.models import AbstractAdmin
from fastapi_admin.providers.login import UsernamePasswordProvider
class LoginProvider(UsernamePasswordProvider):
async def update_password(self, admin: AbstractAdmin, password: str):
pass

View File

@ -1,11 +1,12 @@
import os
from typing import List
from examples import enums
from examples.constants import BASE_DIR
from examples.models import Admin, Category, Config, Product
from fastapi_admin.app import app
from fastapi_admin.providers.file_upload import FileUploadProvider
from fastapi_admin.resources import Dropdown, Field, Link, Model
from fastapi_admin.resources import Action, Dropdown, Field, Link, Model
from fastapi_admin.widgets import displays, filters, inputs
upload_provider = FileUploadProvider(uploads_dir=os.path.join(BASE_DIR, "static", "uploads"))
@ -14,7 +15,7 @@ upload_provider = FileUploadProvider(uploads_dir=os.path.join(BASE_DIR, "static"
@app.register
class Home(Link):
label = "Home"
icon = "ti ti-home"
icon = "fas fa-home"
url = "/admin"
@ -22,7 +23,7 @@ class Home(Link):
class AdminResource(Model):
label = "Admin"
model = Admin
icon = "ti ti-user"
icon = "fas fa-user"
page_pre_title = "admin list"
page_title = "admin model"
filters = [
@ -49,6 +50,13 @@ class AdminResource(Model):
),
"created_at",
]
can_create = False
def get_actions(self) -> List[Action]:
return []
def get_bulk_actions(self) -> List[Action]:
return []
@app.register
@ -78,7 +86,7 @@ class Content(Dropdown):
]
label = "Content"
icon = "ti ti-package"
icon = "fas fa-bars"
resources = [ProductResource, CategoryResource]
@ -86,7 +94,7 @@ class Content(Dropdown):
class ConfigResource(Model):
label = "Config"
model = Config
icon = "ti ti-settings"
icon = "fas fa-cogs"
filters = [
filters.Enum(enum=enums.Status, name="status", label="Status"),
filters.Search(name="key", label="Key", search_mode="equal"),
@ -107,8 +115,8 @@ class ConfigResource(Model):
@app.register
class GithubLink(Link):
label = "Github"
url = "https://github.com/long2ice"
icon = "ti ti-brand-github"
url = "https://github.com/fastapi-admin/fastapi-admin"
icon = "fab fa-github"
target = "_blank"
@ -116,5 +124,5 @@ class GithubLink(Link):
class DocumentationLink(Link):
label = "Documentation"
url = "https://long2ice.github.io/fastadmin"
icon = "ti ti-file-text"
icon = "fas fa-file-code"
target = "_blank"

View File

@ -0,0 +1,37 @@
{% extends "layout.html" %}
{% block page_body %}
{% include "components/alert_error.html" %}
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ _('update_password') }} (This will not take effect because is overwrite in examples)</h3>
</div>
<div class="card-body">
<form method="post" action="{{ request.app.admin_path }}/password">
<div class="form-group mb-3">
<label class="form-label">
{{ _('old_password') }}
</label>
<input class="form-control" type="password" name="old_password"
placeholder="{{ _('old_password_placeholder') }}"/>
</div>
<div class="form-group mb-3">
<label class="form-label">
{{ _('new_password') }}
</label>
<input class="form-control" type="password" name="new_password"
placeholder="{{ _('new_password_placeholder') }}"/>
</div>
<div class="form-group mb-3">
<label class="form-label">
{{ _('re_new_password') }}
</label>
<input class="form-control" type="password" name="re_new_password"
placeholder="{{ _('re_new_password_placeholder') }}"/>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">{{ _('submit') }}</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -21,7 +21,7 @@ class FastAdmin(FastAPI):
admin_path: str
resources: List[Type[Resource]] = []
model_resources: Dict[Type[Model], Type[Resource]] = {}
login_provider: Optional[Type[LoginProvider]] = LoginProvider
login_provider: Optional[LoginProvider]
redis: Redis
def configure(
@ -77,7 +77,7 @@ class FastAdmin(FastAPI):
self.resources.append(resource)
def get_model_resource(self, model: Type[Model]):
return self.model_resources[model]
return self.model_resources[model]()
app = FastAdmin(

View File

@ -19,7 +19,12 @@ def get_model(resource: Optional[str] = Path(...)):
def get_model_resource(request: Request, model=Depends(get_model)):
return request.app.get_model_resource(model)
model_resource = request.app.get_model_resource(model) # type:Model
actions = model_resource.get_actions()
bulk_actions = model_resource.get_bulk_actions()
model_resource.actions = actions
model_resource.bulk_actions = bulk_actions
return model_resource
def _get_resources(resources: List[Type[Resource]]):

View File

@ -2,7 +2,7 @@ from typing import Callable
from starlette.requests import Request
from fastapi_admin import i18n, template
from fastapi_admin import i18n
async def language_processor(request: Request, call_next: Callable):

View File

@ -1,14 +1,14 @@
from typing import List, Optional, Type, Union
from pydantic import BaseModel
from tortoise import ForeignKeyFieldInstance
from starlette.datastructures import FormData
from tortoise import ForeignKeyFieldInstance, ManyToManyFieldInstance
from tortoise import Model as TortoiseModel
from tortoise.fields import BooleanField, DateField, DatetimeField, JSONField
from tortoise.fields.data import CharEnumFieldInstance, IntEnumFieldInstance, IntField, TextField
from fastapi_admin.exceptions import NoSuchFieldFound
from fastapi_admin.i18n import _
from fastapi_admin.utils import ClassProperty
from fastapi_admin.widgets import Widget, displays, inputs
from fastapi_admin.widgets.filters import Filter
@ -67,20 +67,16 @@ class Model(Resource):
page_pre_title: Optional[str] = None
page_title: Optional[str] = None
filters: Optional[List[Union[str, Filter]]] = []
can_edit: bool = True
can_delete: bool = True
can_create: bool = True
enctype = "application/x-www-form-urlencoded"
@ClassProperty
def actions(self) -> List[Action]:
def get_actions(self) -> List[Action]:
return [
Action(label=_("update"), icon="ti ti-edit", name="update", ajax=False),
Action(label=_("delete"), icon="ti ti-trash", name="delete", method="DELETE"),
]
@ClassProperty
def bulk_actions(self) -> List[Action]:
def get_bulk_actions(self) -> List[Action]:
return [
Action(label=_("delete_selected"), icon="ti ti-trash", name="delete", method="DELETE"),
]
@ -109,16 +105,23 @@ class Model(Resource):
return ret
@classmethod
async def resolve_data(cls, data: dict):
async def resolve_data(cls, data: FormData):
ret = {}
m2m_ret = {}
for field in cls.get_fields(is_display=False):
input_ = field.input
if input_.context.get("disabled") or isinstance(input_, inputs.DisplayOnly):
continue
name = input_.context.get("name")
if isinstance(input_, inputs.ManyToMany):
v = data.getlist(name)
value = await input_.parse_value(v)
m2m_ret[name] = await input_.model.filter(pk__in=value)
else:
v = data.get(name)
ret[name] = await input_.parse_value(v)
return ret
value = await input_.parse_value(v)
ret[name] = value
return ret, m2m_ret
@classmethod
async def get_filters(cls, values: Optional[dict] = None):
@ -191,7 +194,8 @@ class Model(Resource):
field.related_model, null=null, default=field.default
)
field_name = field.source_field
elif isinstance(field, ManyToManyFieldInstance):
display, input_ = displays.InputOnly(), inputs.ManyToMany(field.related_model)
return Field(name=field_name, label=label.title(), display=display, input_=input_)
@classmethod
@ -213,6 +217,14 @@ class Model(Resource):
def get_fields_label(cls, display: bool = True):
return cls._get_fields_attr("label", display)
@classmethod
def get_m2m_field(cls):
ret = []
for field in cls.fields or cls.model._meta.fields:
if field in cls.model._meta.m2m_fields:
ret.append(field)
return ret
class Dropdown(Resource):
resources: List[Type[Resource]]

View File

@ -1,5 +1,4 @@
from fastapi import APIRouter, Depends, Form
from pydantic import BaseModel
from starlette.requests import Request
from fastapi_admin.depends import get_current_admin, get_resources

View File

@ -1,10 +1,11 @@
from typing import Optional
from fastapi import APIRouter, Depends, Path
from jinja2 import TemplateNotFound
from starlette.requests import Request
from starlette.responses import RedirectResponse
from starlette.status import HTTP_303_SEE_OTHER
from tortoise import Model
from tortoise.fields import ManyToManyRelation
from tortoise.transactions import in_transaction
from fastapi_admin.depends import get_model, get_model_resource, get_resources
from fastapi_admin.resources import Model as ModelResource
@ -38,8 +39,6 @@ async def list_view(
qs = qs.offset((page_num - 1) * page_size)
values = await qs.values_list(*fields_name)
values = await render_values(fields, values)
return templates.TemplateResponse(
"list.html",
context = {
"request": request,
"resources": resources,
@ -57,7 +56,16 @@ async def list_view(
"to": page_size * page_num,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
}
try:
return templates.TemplateResponse(
f"{resource}/list.html",
context=context,
)
except TemplateNotFound:
return templates.TemplateResponse(
"list.html",
context=context,
)
@ -71,13 +79,29 @@ async def update(
model=Depends(get_model),
):
form = await request.form()
data = await model_resource.resolve_data(dict(form))
obj = await model.get(pk=pk)
await obj.update_from_dict(data).save()
data, m2m_data = await model_resource.resolve_data(form)
async with in_transaction() as conn:
obj = (
await model.filter(pk=pk)
.using_db(conn)
.select_for_update()
.get()
.prefetch_related(*model_resource.get_m2m_field())
)
await obj.update_from_dict(data).save(using_db=conn)
for k, items in m2m_data.items():
m2m_obj = getattr(obj, k)
await m2m_obj.clear()
if items:
await m2m_obj.add(*items)
obj = (
await model.filter(pk=pk)
.using_db(conn)
.get()
.prefetch_related(*model_resource.get_m2m_field())
)
inputs = await model_resource.get_inputs(obj)
if "save" in form.keys():
return templates.TemplateResponse(
"update.html",
context = {
"request": request,
"resources": resources,
@ -88,7 +112,16 @@ async def update(
"pk": pk,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
}
try:
return templates.TemplateResponse(
f"{resource}/update.html",
context=context,
)
except TemplateNotFound:
return templates.TemplateResponse(
"update.html",
context=context,
)
return redirect(request, "list_view", resource=resource)
@ -104,8 +137,6 @@ async def update_view(
):
obj = await model.get(pk=pk)
inputs = await model_resource.get_inputs(obj)
return templates.TemplateResponse(
"update.html",
context = {
"request": request,
"resources": resources,
@ -116,7 +147,16 @@ async def update_view(
"model_resource": model_resource,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
}
try:
return templates.TemplateResponse(
f"{resource}/update.html",
context=context,
)
except TemplateNotFound:
return templates.TemplateResponse(
"update.html",
context=context,
)
@ -128,8 +168,6 @@ async def create_view(
model_resource: ModelResource = Depends(get_model_resource),
):
inputs = await model_resource.get_inputs()
return templates.TemplateResponse(
"create.html",
context = {
"request": request,
"resources": resources,
@ -139,7 +177,16 @@ async def create_view(
"model_resource": model_resource,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
}
try:
return templates.TemplateResponse(
f"{resource}/create.html",
context=context,
)
except TemplateNotFound:
return templates.TemplateResponse(
"create.html",
context=context,
)
@ -153,12 +200,14 @@ async def create(
):
inputs = await model_resource.get_inputs()
form = await request.form()
data = await model_resource.resolve_data(dict(form))
await model.create(**data)
data, m2m_data = await model_resource.resolve_data(form)
async with in_transaction() as conn:
obj = await model.create(**data, using_db=conn)
for k, items in m2m_data.items():
m2m_obj = getattr(obj, k) # type:ManyToManyRelation
await m2m_obj.add(*items, using_db=conn)
if "save" in form.keys():
return redirect(request, "list_view", resource=resource)
return templates.TemplateResponse(
"create.html",
context = {
"request": request,
"resources": resources,
@ -168,7 +217,16 @@ async def create(
"model_resource": model_resource,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
}
try:
return templates.TemplateResponse(
f"{resource}/create.html",
context=context,
)
except TemplateNotFound:
return templates.TemplateResponse(
"create.html",
context=context,
)

View File

@ -42,7 +42,7 @@ def set_global_env(name: str, value: Any):
def add_template_folder(*folders: str):
for folder in folders:
templates.env.loader.searchpath.append(folder)
templates.env.loader.searchpath.insert(0, folder)
async def render_values(

View File

@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons@latest/iconfont/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler-flags.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler-payments.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler-vendors.min.css">
<script src="https://kit.fontawesome.com/65694932fa.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.js"></script>
<script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/jquery/latest/jquery.min.js"></script>

View File

@ -116,7 +116,8 @@
>
</li>
<li class="list-inline-item">
<a href="#" class="link-secondary">License</a>
<a href="https://github.com/fastapi-admin/fastapi-admin/blob/dev/LICENSE"
class="link-secondary">License</a>
</li>
<li class="list-inline-item">
<a
@ -160,8 +161,8 @@
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
Copyright © {{ 2021 }} - {{ NOW_YEAR }}
<a href="http://github.com/fastapi-admin" class="link-secondary"
>FastAPI-Admin</a
<a href="http://github.com/long2ice" class="link-secondary"
>long2ice</a
>. All rights reserved.
</li>
<li class="list-inline-item">

View File

@ -16,7 +16,7 @@
type="text"
class="form-control"
value="{{ page_size }}"
size="1"
size="2"
name="page_size"
aria-label="entries count"
/>
@ -89,6 +89,7 @@
<table class="table card-table table-vcenter text-nowrap datatable">
<thead>
<tr>
{% if model_resource.bulk_actions %}
<th class="w-1">
<input
class="form-check-input m-0 align-middle"
@ -96,15 +97,19 @@
id="checkbox-select-all"
/>
</th>
{% endif %}
{% for label in fields_label %}
<th>{{ label }}</th>
{% endfor %}
{% if model_resource.actions %}
<th></th>
{% endif %}
</tr>
</thead>
<tbody>
{% for value in values %}
<tr>
{% if model_resource.bulk_actions %}
<td>
<input
data-id="{{ value[0]|int }}"
@ -112,6 +117,7 @@
type="checkbox"
/>
</td>
{% endif %}
{% for v in value %}
<td>{{ v|safe }}</td>
{% endfor %}

View File

@ -0,0 +1,21 @@
{% with id = 'form-select-' + name %}
<div class="form-group mb-3">
<div class="form-label">{{ label }}</div>
<select multiple class="form-select" name="{{ name }}" id="{{ id }}">
</select>
</div>
<script>
new Choices(el = document.getElementById('{{ id }}'), {
classNames: {
containerInner: el.className,
listDropdown: 'dropdown-menu',
itemChoice: 'dropdown-item',
activeState: 'show',
selectedState: 'active',
inputCloned: 'bg-white',
},
removeItemButton: true,
choices:{{ options|safe }}
});
</script>
{% endwith %}

View File

@ -1,9 +0,0 @@
class ClassProperty(property):
def __get__(self, obj, obj_type=None):
return super(ClassProperty, self).__get__(obj_type)
def __set__(self, obj, value):
super(ClassProperty, self).__set__(type(obj), value)
def __delete__(self, obj):
super(ClassProperty, self).__delete__(type(obj))

View File

@ -50,5 +50,5 @@ class Json(Display):
async def render(self, value: dict):
return self.templates.get_template(self.template).render(
value=json.dumps(value, indent=4, sort_keys=True),
value=json.dumps(value),
)

View File

@ -96,6 +96,35 @@ class ForeignKey(Select):
return await self.model.all()
class ManyToMany(Select):
template = "widgets/inputs/many_to_many.html"
def __init__(
self,
model: Type[Model],
disabled: bool = False,
):
super().__init__(disabled=disabled)
self.model = model
async def get_options(self):
ret = await self.get_queryset()
options = [dict(label=str(x), value=x.pk) for x in ret]
return options
async def get_queryset(self):
return await self.model.all()
async def render(self, value: Any):
options = await self.get_options()
selected = list(map(lambda x: x.pk, value.related_objects if value else []))
for option in options:
if option.get("value") in selected:
option["selected"] = True
self.context.update(options=json.dumps(options))
return await super(Input, self).render(value)
class Enum(Select):
def __init__(
self,

26
poetry.lock generated
View File

@ -428,7 +428,7 @@ tornado = ">=5.0"
[[package]]
name = "mkdocs-git-revision-date-localized-plugin"
version = "0.9"
version = "0.9.2"
description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file."
category = "dev"
optional = false
@ -593,7 +593,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pygments"
version = "2.8.1"
version = "2.9.0"
description = "Pygments is a syntax highlighting package written in Python."
category = "dev"
optional = false
@ -722,7 +722,7 @@ testing = ["filelock"]
[[package]]
name = "python-dotenv"
version = "0.17.0"
version = "0.17.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
category = "main"
optional = false
@ -853,7 +853,7 @@ python-versions = "*"
[[package]]
name = "typing-extensions"
version = "3.7.4.3"
version = "3.10.0.0"
description = "Backported and Experimental Type Hints for Python 3.5+"
category = "main"
optional = false
@ -1249,8 +1249,8 @@ mkdocs = [
{file = "mkdocs-1.1.2.tar.gz", hash = "sha256:f0b61e5402b99d7789efa032c7a74c90a20220a9c81749da06dbfbcbd52ffb39"},
]
mkdocs-git-revision-date-localized-plugin = [
{file = "mkdocs-git-revision-date-localized-plugin-0.9.tar.gz", hash = "sha256:49e59396f1e83264b8f54fcb339a9137925a1af19e639f03dc59dae7f22e914f"},
{file = "mkdocs_git_revision_date_localized_plugin-0.9-py3-none-any.whl", hash = "sha256:5d319398e9ce325d02df1cba232a92b215f8d0c0ffd3810b6a61d6c5eb6306e6"},
{file = "mkdocs-git-revision-date-localized-plugin-0.9.2.tar.gz", hash = "sha256:c15c76d5baa1f8f37e3a4146b9a6f1b00c5a361ea959ada0703fd9ec462afbe3"},
{file = "mkdocs_git_revision_date_localized_plugin-0.9.2-py3-none-any.whl", hash = "sha256:d4b21eeb9f212efd314a05e771cf69f2bab1f51bdaa5c9955d09410a396a069b"},
]
mkdocs-material = [
{file = "mkdocs-material-7.1.3.tar.gz", hash = "sha256:e34bba93ad1a0e6f9afc371f4ef55bedabbf13b9a786b013b0ce26ac55ec2932"},
@ -1345,8 +1345,8 @@ pyflakes = [
{file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
]
pygments = [
{file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"},
{file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"},
{file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"},
{file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"},
]
pylint = [
{file = "pylint-2.8.2-py3-none-any.whl", hash = "sha256:f7e2072654a6b6afdf5e2fb38147d3e2d2d43c89f648637baab63e026481279b"},
@ -1385,8 +1385,8 @@ pytest-xdist = [
{file = "pytest_xdist-2.2.1-py3-none-any.whl", hash = "sha256:2447a1592ab41745955fb870ac7023026f20a5f0bfccf1b52a879bd193d46450"},
]
python-dotenv = [
{file = "python-dotenv-0.17.0.tar.gz", hash = "sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a"},
{file = "python_dotenv-0.17.0-py2.py3-none-any.whl", hash = "sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"},
{file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"},
{file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"},
]
python-multipart = [
{file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"},
@ -1569,9 +1569,9 @@ typed-ast = [
{file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
]
typing-extensions = [
{file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
{file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
{file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
{file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"},
{file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"},
{file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
]
uvicorn = [
{file = "uvicorn-0.13.4-py3-none-any.whl", hash = "sha256:7587f7b08bd1efd2b9bad809a3d333e972f1d11af8a5e52a9371ee3a5de71524"},