Add upload component (#622)

This commit is contained in:
Nikhil Rao 2023-03-03 19:38:58 -08:00 committed by GitHub
parent 8ba22ed92d
commit f7138bd53f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 326 additions and 39 deletions

40
poetry.lock generated
View File

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

Binary file not shown.

View File

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

View File

@ -1,3 +1,3 @@
{
"version": "0.1.18"
}
"version": "0.1.19"
}

View File

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

View File

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

View File

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

View File

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

View File

@ -146,6 +146,14 @@ EVENT_FN = join(
"}})",
]
).format
UPLOAD_FN = join(
[
"const File = files => {set_state}({{",
" ...{state},",
" files,",
"}})",
]
).format
# Effects.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
"""An image component."""
"""An icon component."""
from pynecone import utils
from pynecone.components.component import Component

View File

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

View File

@ -168,6 +168,7 @@ class Endpoint(Enum):
PING = "ping"
EVENT = "event"
UPLOAD = "upload"
def __str__(self) -> str:
"""Get the string representation of the endpoint.

View File

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

View File

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

View File

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