diff --git a/poetry.lock b/poetry.lock index 148964ce0..76c6f8ab6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -480,14 +480,14 @@ files = [ [[package]] name = "platformdirs" -version = "3.0.0" +version = "3.1.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, - {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, + {file = "platformdirs-3.1.0-py3-none-any.whl", hash = "sha256:13b08a53ed71021350c9e300d4ea8668438fb0046ab3937ac9a29913a1a1350a"}, + {file = "platformdirs-3.1.0.tar.gz", hash = "sha256:accc3665857288317f32c7bebb5a8e482ba717b474f3fc1d18ca7f9214be0cef"}, ] [package.dependencies] @@ -648,14 +648,14 @@ dev = ["twine (>=3.4.1)"] [[package]] name = "pytest" -version = "7.2.1" +version = "7.2.2" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, - {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, + {file = "pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e"}, + {file = "pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4"}, ] [package.dependencies] @@ -725,6 +725,20 @@ files = [ asyncio-client = ["aiohttp (>=3.4)"] client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] +[[package]] +name = "python-multipart" +version = "0.0.5" +description = "A streaming multipart parser for Python" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, +] + +[package.dependencies] +six = ">=1.4.0" + [[package]] name = "python-socketio" version = "5.7.2" @@ -847,6 +861,18 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-g testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.0" @@ -1210,4 +1236,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "13e3d8aa740b5a1b24ec4cdd394791f1650ac3bcb618e4430f1620238c6e8a6d" +content-hash = "b7272a6016a5b9fb3eea7ce834b9f539919e8f71c7f849d626adb2ee7a354d2f" diff --git a/pynecone/.templates/web/bun.lockb b/pynecone/.templates/web/bun.lockb index 1da69bbaa..565ca1346 100755 Binary files a/pynecone/.templates/web/bun.lockb and b/pynecone/.templates/web/bun.lockb differ diff --git a/pynecone/.templates/web/package.json b/pynecone/.templates/web/package.json index 38817fc3d..8fd3486a4 100644 --- a/pynecone/.templates/web/package.json +++ b/pynecone/.templates/web/package.json @@ -23,6 +23,7 @@ "react": "^17.0.2", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^17.0.2", + "react-dropzone": "^14.2.3", "react-markdown": "^8.0.3", "react-plotly.js": "^2.6.0", "react-syntax-highlighter": "^15.5.0", diff --git a/pynecone/.templates/web/pynecone.json b/pynecone/.templates/web/pynecone.json index eb98579f2..cf29361fa 100644 --- a/pynecone/.templates/web/pynecone.json +++ b/pynecone/.templates/web/pynecone.json @@ -1,3 +1,3 @@ { - "version": "0.1.18" -} \ No newline at end of file + "version": "0.1.19" +} diff --git a/pynecone/.templates/web/utils/state.js b/pynecone/.templates/web/utils/state.js index fedf85b8e..0babb5fb2 100644 --- a/pynecone/.templates/web/utils/state.js +++ b/pynecone/.templates/web/utils/state.js @@ -1,5 +1,6 @@ // State management for Pynecone web apps. -import io from 'socket.io-client'; +import axios from "axios"; +import io from "socket.io-client"; // Global variable to hold the token. let token; @@ -103,12 +104,19 @@ export const applyEvent = async (event, router, socket) => { * Process an event off the event queue. * @param state The state with the event queue. * @param setState The function to set the state. - * @param result The current result + * @param result The current result. * @param setResult The function to set the result. * @param router The router object. * @param socket The socket object to send the event on. */ -export const updateState = async (state, setState, result, setResult, router, socket) => { +export const updateState = async ( + state, + setState, + result, + setResult, + router, + socket +) => { // If we are already processing an event, or there are no events to process, return. if (result.processing || state.events.length == 0) { return; @@ -118,7 +126,7 @@ export const updateState = async (state, setState, result, setResult, router, so setResult({ ...result, processing: true }); // Pop the next event off the queue and apply it. - const event = state.events.shift() + const event = state.events.shift(); // Set new events to avoid reprocessing the same event. setState({ ...state, events: state.events }); @@ -127,7 +135,7 @@ export const updateState = async (state, setState, result, setResult, router, so const eventSent = await applyEvent(event, router, socket); if (!eventSent) { // If no event was sent, set processing to false and return. - setResult({...state, processing: false}) + setResult({ ...state, processing: false }); } }; @@ -136,26 +144,37 @@ export const updateState = async (state, setState, result, setResult, router, so * @param socket The socket object to connect. * @param state The state object to apply the deltas to. * @param setState The function to set the state. + * @param result The current result. * @param setResult The function to set the result. * @param endpoint The endpoint to connect to. + * @param transports The transports to use. */ -export const connect = async (socket, state, setState, result, setResult, router, endpoint, transports) => { +export const connect = async ( + socket, + state, + setState, + result, + setResult, + router, + endpoint, + transports +) => { // Get backend URL object from the endpoint - const endpoint_url = new URL(endpoint) + const endpoint_url = new URL(endpoint); // Create the socket. socket.current = io(endpoint, { - path: endpoint_url['pathname'], + path: endpoint_url["pathname"], transports: transports, autoUnref: false, }); // Once the socket is open, hydrate the page. - socket.current.on('connect', () => { + socket.current.on("connect", () => { updateState(state, setState, result, setResult, router, socket.current); }); // On each received message, apply the delta and set the result. - socket.current.on('event', function (update) { + socket.current.on("event", function (update) { update = JSON.parse(update); applyDelta(state, update.delta); setResult({ @@ -166,6 +185,56 @@ export const connect = async (socket, state, setState, result, setResult, router }); }; +/** + * Upload files to the server. + * + * @param state The state to apply the delta to. + * @param setResult The function to set the result. + * @param files The files to upload. + * @param handler The handler to use. + * @param endpoint The endpoint to upload to. + */ +export const uploadFiles = async ( + state, + result, + setResult, + files, + handler, + endpoint +) => { + // If we are already processing an event, or there are no upload files, return. + if (result.processing || files.length == 0) { + return; + } + + // Set processing to true to block other events from being processed. + setResult({ ...result, processing: true }); + + // Currently only supports uploading one file. + const file = files[0]; + const headers = { + "Content-Type": file.type, + }; + const formdata = new FormData(); + + // Add the token and handler to the file name. + formdata.append("file", file, getToken() + ":" + handler + ":" + file.name); + + // Send the file to the server. + await axios.post(endpoint, formdata, headers).then((response) => { + // Apply the delta and set the result. + const update = response.data; + applyDelta(state, update.delta); + + // Set processing to false and return. + setResult({ + processing: false, + state: state, + events: update.events, + }); + }); +}; + /** * Create an event object. * @param name The name of the event. diff --git a/pynecone/__init__.py b/pynecone/__init__.py index 9027df00b..c32255c38 100644 --- a/pynecone/__init__.py +++ b/pynecone/__init__.py @@ -3,14 +3,21 @@ Anything imported here will be available in the default Pynecone import as `pc.*`. """ -from .app import App +from .app import App, UploadFile from .base import Base from .components import * from .components.component import custom_component as component from .components.graphing.victory import data from .config import Config from .constants import Env, Transports -from .event import EVENT_ARG, EventChain, console_log, redirect, window_alert +from .event import ( + EVENT_ARG, + EventChain, + console_log, + redirect, + window_alert, +) +from .event import FileUpload as upload_files from .middleware import Middleware from .model import Model, session from .route import route diff --git a/pynecone/app.py b/pynecone/app.py index d2d5cc22d..95ce2bab6 100644 --- a/pynecone/app.py +++ b/pynecone/app.py @@ -2,7 +2,7 @@ from typing import Any, Callable, Coroutine, Dict, List, Optional, Type, Union -from fastapi import FastAPI +from fastapi import FastAPI, UploadFile from fastapi.middleware import cors from socketio import ASGIApp, AsyncNamespace, AsyncServer @@ -124,6 +124,9 @@ class App(Base): # To test the server. self.api.get(str(constants.Endpoint.PING))(ping) + # To upload files. + self.api.post(str(constants.Endpoint.UPLOAD))(upload(self)) + def add_cors(self): """Add CORS middleware to the app.""" self.api.add_middleware( @@ -131,6 +134,7 @@ class App(Base): allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + allow_origins=["*"], ) def preprocess(self, state: State, event: Event) -> Optional[Delta]: @@ -428,6 +432,38 @@ async def ping() -> str: return "pong" +def upload(app: App): + """Upload a file. + + Args: + app: The app to upload the file for. + + Returns: + The upload function. + """ + + async def upload_file(file: UploadFile): + """Upload a file. + + Args: + file: The file to upload. + + Returns: + The state update after processing the event. + """ + # Get the token and filename. + token, handler, filename = file.filename.split(":", 2) + file.filename = filename + + # Get the state for the session. + state = app.state_manager.get_state(token) + event = Event(token=token, name=handler, payload={"file": file}) + update = await state.process(event) + return update + + return upload_file + + class EventNamespace(AsyncNamespace): """The event namespace.""" diff --git a/pynecone/compiler/compiler.py b/pynecone/compiler/compiler.py index 297fbfc27..42213b90a 100644 --- a/pynecone/compiler/compiler.py +++ b/pynecone/compiler/compiler.py @@ -15,7 +15,7 @@ from pynecone.style import Style DEFAULT_IMPORTS: ImportDict = { "react": {"useEffect", "useRef", "useState"}, "next/router": {"useRouter"}, - f"/{constants.STATE_PATH}": {"connect", "updateState", "E"}, + f"/{constants.STATE_PATH}": {"connect", "updateState", "uploadFiles", "E"}, "": {"focus-visible/dist/focus-visible"}, "@chakra-ui/react": {constants.USE_COLOR_MODE}, } diff --git a/pynecone/compiler/templates.py b/pynecone/compiler/templates.py index c70f4424b..e0acad7fd 100644 --- a/pynecone/compiler/templates.py +++ b/pynecone/compiler/templates.py @@ -146,6 +146,14 @@ EVENT_FN = join( "}})", ] ).format +UPLOAD_FN = join( + [ + "const File = files => {set_state}({{", + " ...{state},", + " files,", + "}})", + ] +).format # Effects. diff --git a/pynecone/compiler/utils.py b/pynecone/compiler/utils.py index 17acf9b01..1c49adc5b 100644 --- a/pynecone/compiler/utils.py +++ b/pynecone/compiler/utils.py @@ -85,9 +85,11 @@ def compile_constants() -> str: Returns: A string of all the compiled constants. """ - endpoint = constants.Endpoint.EVENT return templates.join( - [compile_constant_declaration(name=endpoint.name, value=endpoint.get_url())] + [ + compile_constant_declaration(name=endpoint.name, value=endpoint.get_url()) + for endpoint in constants.Endpoint + ] ) @@ -104,6 +106,7 @@ def compile_state(state: Type[State]) -> str: initial_state.update( { "events": [{"name": utils.get_hydrate_event(state)}], + "files": [], } ) initial_state = utils.format_state(initial_state) @@ -137,7 +140,12 @@ def compile_events(state: Type[State]) -> str: """ state_name = state.get_name() state_setter = templates.format_state_setter(state_name) - return templates.EVENT_FN(state=state_name, set_state=state_setter) + return templates.join( + [ + templates.EVENT_FN(state=state_name, set_state=state_setter), + templates.UPLOAD_FN(state=state_name, set_state=state_setter), + ] + ) def compile_effects(state: Type[State]) -> str: diff --git a/pynecone/components/__init__.py b/pynecone/components/__init__.py index cf34bcb6b..48965191b 100644 --- a/pynecone/components/__init__.py +++ b/pynecone/components/__init__.py @@ -112,6 +112,7 @@ slider_thumb = SliderThumb.create slider_track = SliderTrack.create switch = Switch.create text_area = TextArea.create +upload = Upload.create area = Area.create bar = Bar.create box_plot = BoxPlot.create diff --git a/pynecone/components/component.py b/pynecone/components/component.py index a175a8003..c2c9ce45d 100644 --- a/pynecone/components/component.py +++ b/pynecone/components/component.py @@ -50,6 +50,9 @@ class Component(Base, ABC): # The class name for the component. class_name: Any = None + # Special component props. + special_props: Set[Var] = set() + @classmethod def __init_subclass__(cls, **kwargs): """Set default properties. @@ -290,7 +293,7 @@ class Component(Base, ABC): # Create the base tag. alias = self.get_alias() name = alias if alias is not None else self.tag - tag = Tag(name=name) + tag = Tag(name=name, special_props=self.special_props) # Add component props to the tag. props = {attr: getattr(self, attr) for attr in self.get_props()} diff --git a/pynecone/components/forms/__init__.py b/pynecone/components/forms/__init__.py index 3ee68238d..1d424c742 100644 --- a/pynecone/components/forms/__init__.py +++ b/pynecone/components/forms/__init__.py @@ -27,5 +27,6 @@ from .select import Option, Select from .slider import Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack from .switch import Switch from .textarea import TextArea +from .upload import Upload __all__ = [f for f in dir() if f[0].isupper()] # type: ignore diff --git a/pynecone/components/forms/upload.py b/pynecone/components/forms/upload.py new file mode 100644 index 000000000..5717a5d2d --- /dev/null +++ b/pynecone/components/forms/upload.py @@ -0,0 +1,57 @@ +"""A file upload component.""" + +from typing import Dict + +from pynecone.components.component import EVENT_ARG, Component +from pynecone.components.forms.input import Input +from pynecone.components.layout.box import Box +from pynecone.event import EventChain +from pynecone.var import BaseVar, Var + +upload_file = BaseVar(name="e => File(e)", type_=EventChain) + + +class Upload(Component): + """A file upload component.""" + + library = "react-dropzone" + + tag = "ReactDropzone" + + @classmethod + def create(cls, *children, **props) -> Component: + """Create an upload component. + + Args: + children: The children of the component. + props: The properties of the component. + + Returns: + The upload component. + """ + # The file input to use. + upload = Input.create(type_="file") + upload.special_props = {BaseVar(name="{...getInputProps()}", type_=None)} + + # The dropzone to use. + zone = Box.create(upload, *children, **props) + zone.special_props = {BaseVar(name="{...getRootProps()}", type_=None)} + + # Create the component. + return super().create(zone, on_drop=upload_file) + + @classmethod + def get_controlled_triggers(cls) -> Dict[str, Var]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + A dict mapping the event trigger to the var that is passed to the handler. + """ + return { + "on_drop": EVENT_ARG, + } + + def _render(self): + out = super()._render() + out.args = ("getRootProps", "getInputProps") + return out diff --git a/pynecone/components/media/icon.py b/pynecone/components/media/icon.py index 6408e42b8..f224ad2aa 100644 --- a/pynecone/components/media/icon.py +++ b/pynecone/components/media/icon.py @@ -1,4 +1,4 @@ -"""An image component.""" +"""An icon component.""" from pynecone import utils from pynecone.components.component import Component diff --git a/pynecone/components/tags/tag.py b/pynecone/components/tags/tag.py index 87f2550fc..9fd801b9e 100644 --- a/pynecone/components/tags/tag.py +++ b/pynecone/components/tags/tag.py @@ -5,7 +5,7 @@ from __future__ import annotations import json import os import re -from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Tuple, Union from plotly.graph_objects import Figure from plotly.io import to_json @@ -31,6 +31,12 @@ class Tag(Base): # The inner contents of the tag. contents: str = "" + # Args to pass to the tag. + args: Optional[Tuple[str, ...]] = None + + # Special props that aren't key value pairs. + special_props: Set[Var] = set() + def __init__(self, *args, **kwargs): """Initialize the tag. @@ -68,8 +74,15 @@ class Tag(Base): # Handle event props. elif isinstance(prop, EventChain): local_args = ",".join(prop.events[0].local_args) - events = ",".join([utils.format_event(event) for event in prop.events]) - prop = f"({local_args}) => Event([{events}])" + + if len(prop.events) == 1 and prop.events[0].upload: + # Special case for upload events. + event = utils.format_upload_event(prop.events[0]) + else: + # All other events. + chain = ",".join([utils.format_event(event) for event in prop.events]) + event = f"Event([{chain}])" + prop = f"({local_args}) => {event}" # Handle other types. elif isinstance(prop, str): @@ -125,6 +138,11 @@ class Tag(Base): """ # Get the tag props. props_str = self.format_props() + + # Add the special props. + props_str += " ".join([str(prop) for prop in self.special_props]) + + # Add a space if there are props. if len(props_str) > 0: props_str = " " + props_str @@ -132,10 +150,16 @@ class Tag(Base): # If there is no inner content, we don't need a closing tag. tag_str = utils.wrap(f"{self.name}{props_str}/", "<") else: + if self.args is not None: + # If there are args, wrap the tag in a function call. + args_str = ", ".join(self.args) + contents = f"{{({{{args_str}}}) => ({self.contents})}}" + else: + contents = self.contents # Otherwise wrap it in opening and closing tags. open = utils.wrap(f"{self.name}{props_str}", "<") close = utils.wrap(f"/{self.name}", "<") - tag_str = utils.wrap(self.contents, open, close) + tag_str = utils.wrap(contents, open, close) return tag_str diff --git a/pynecone/constants.py b/pynecone/constants.py index a6054616b..9429ad940 100644 --- a/pynecone/constants.py +++ b/pynecone/constants.py @@ -168,6 +168,7 @@ class Endpoint(Enum): PING = "ping" EVENT = "event" + UPLOAD = "upload" def __str__(self) -> str: """Get the string representation of the endpoint. diff --git a/pynecone/event.py b/pynecone/event.py index a09248b78..2cefc99c1 100644 --- a/pynecone/event.py +++ b/pynecone/event.py @@ -62,6 +62,9 @@ class EventHandler(Base): values.append(arg.full_name) continue + if isinstance(arg, FileUpload): + return EventSpec(handler=self, upload=True) + # Otherwise, convert to JSON. try: values.append(json.dumps(arg, ensure_ascii=False)) @@ -91,6 +94,9 @@ class EventSpec(Base): # The arguments to pass to the function. args: Tuple[Any, ...] = () + # Whether to upload files. + upload: bool = False + class Config: """The Pydantic config.""" @@ -122,6 +128,12 @@ class FrontendEvent(Base): EVENT_ARG = BaseVar(name="_e", type_=FrontendEvent, is_local=True) +class FileUpload(Base): + """Class to represent a file upload.""" + + pass + + # Special server-side events. def redirect(path: str) -> EventSpec: """Redirect to a new path. diff --git a/pynecone/utils.py b/pynecone/utils.py index acb016042..0dd737fe1 100644 --- a/pynecone/utils.py +++ b/pynecone/utils.py @@ -1138,21 +1138,21 @@ def format_cond( return expr -def format_event_handler(handler: EventHandler) -> str: - """Format an event handler. +def get_event_handler_parts(handler: EventHandler) -> Tuple[str, str]: + """Get the state and function name of an event handler. Args: - handler: The event handler to format. + handler: The event handler to get the parts of. Returns: - The formatted function. + The state and function name. """ # Get the class that defines the event handler. parts = handler.fn.__qualname__.split(".") # If there's no enclosing class, just return the function name. if len(parts) == 1: - return parts[-1] + return ("", parts[-1]) # Get the state and the function name. state_name, name = parts[-2:] @@ -1163,8 +1163,24 @@ def format_event_handler(handler: EventHandler) -> str: state = vars(sys.modules[handler.fn.__module__])[state_name] except Exception: # If the state isn't in the module, just return the function name. - return handler.fn.__qualname__ - return ".".join([state.get_full_name(), name]) + return ("", handler.fn.__qualname__) + + return (state.get_full_name(), name) + + +def format_event_handler(handler: EventHandler) -> str: + """Format an event handler. + + Args: + handler: The event handler to format. + + Returns: + The formatted function. + """ + state, name = get_event_handler_parts(handler) + if state == "": + return name + return f"{state}.{name}" def format_event(event_spec: EventSpec) -> str: @@ -1180,6 +1196,21 @@ def format_event(event_spec: EventSpec) -> str: return f"E(\"{format_event_handler(event_spec.handler)}\", {wrap(args, '{')})" +def format_upload_event(event_spec: EventSpec) -> str: + """Format an upload event. + + Args: + event_spec: The event to format. + + Returns: + The compiled event. + """ + from pynecone.compiler import templates + + state, name = get_event_handler_parts(event_spec.handler) + return f'uploadFiles({state}, {templates.RESULT}, {templates.SET_RESULT}, {state}.files, "{name}", UPLOAD)' + + def format_query_params(router_data: Dict[str, Any]) -> Dict[str, str]: """Convert back query params name to python-friendly case. @@ -1193,6 +1224,7 @@ def format_query_params(router_data: Dict[str, Any]) -> Dict[str, str]: return {k.replace("-", "_"): v for k, v in params.items()} +# Set of unique variable names. USED_VARIABLES = set() diff --git a/pyproject.toml b/pyproject.toml index a244936e7..1d9b524bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ python-socketio = "^5.7.2" psutil = "^5.9.4" websockets = "^10.4" cloudpickle = "^2.2.1" +python-multipart = "^0.0.5" [tool.poetry.group.dev.dependencies] pytest = "^7.1.2"