Selaa lähdekoodia

Add upload component (#622)

Nikhil Rao 2 vuotta sitten
vanhempi
säilyke
f7138bd53f

+ 33 - 7
poetry.lock

@@ -480,14 +480,14 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "platformdirs"
 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\"."
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
 category = "dev"
 category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 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]
 [package.dependencies]
@@ -648,14 +648,14 @@ dev = ["twine (>=3.4.1)"]
 
 
 [[package]]
 [[package]]
 name = "pytest"
 name = "pytest"
-version = "7.2.1"
+version = "7.2.2"
 description = "pytest: simple powerful testing with Python"
 description = "pytest: simple powerful testing with Python"
 category = "dev"
 category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 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]
 [package.dependencies]
@@ -725,6 +725,20 @@ files = [
 asyncio-client = ["aiohttp (>=3.4)"]
 asyncio-client = ["aiohttp (>=3.4)"]
 client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
 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]]
 [[package]]
 name = "python-socketio"
 name = "python-socketio"
 version = "5.7.2"
 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 = ["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"]
 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]]
 [[package]]
 name = "sniffio"
 name = "sniffio"
 version = "1.3.0"
 version = "1.3.0"
@@ -1210,4 +1236,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 [metadata]
 lock-version = "2.0"
 lock-version = "2.0"
 python-versions = "^3.7"
 python-versions = "^3.7"
-content-hash = "13e3d8aa740b5a1b24ec4cdd394791f1650ac3bcb618e4430f1620238c6e8a6d"
+content-hash = "b7272a6016a5b9fb3eea7ce834b9f539919e8f71c7f849d626adb2ee7a354d2f"

BIN
pynecone/.templates/web/bun.lockb


+ 1 - 0
pynecone/.templates/web/package.json

@@ -23,6 +23,7 @@
     "react": "^17.0.2",
     "react": "^17.0.2",
     "react-copy-to-clipboard": "^5.1.0",
     "react-copy-to-clipboard": "^5.1.0",
     "react-dom": "^17.0.2",
     "react-dom": "^17.0.2",
+    "react-dropzone": "^14.2.3",
     "react-markdown": "^8.0.3",
     "react-markdown": "^8.0.3",
     "react-plotly.js": "^2.6.0",
     "react-plotly.js": "^2.6.0",
     "react-syntax-highlighter": "^15.5.0",
     "react-syntax-highlighter": "^15.5.0",

+ 2 - 2
pynecone/.templates/web/pynecone.json

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

+ 79 - 10
pynecone/.templates/web/utils/state.js

@@ -1,5 +1,6 @@
 // State management for Pynecone web apps.
 // 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.
 // Global variable to hold the token.
 let token;
 let token;
@@ -103,12 +104,19 @@ export const applyEvent = async (event, router, socket) => {
  * Process an event off the event queue.
  * Process an event off the event queue.
  * @param state The state with the event queue.
  * @param state The state with the event queue.
  * @param setState The function to set the state.
  * @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 setResult The function to set the result.
  * @param router The router object.
  * @param router The router object.
  * @param socket The socket object to send the event on.
  * @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 we are already processing an event, or there are no events to process, return.
   if (result.processing || state.events.length == 0) {
   if (result.processing || state.events.length == 0) {
     return;
     return;
@@ -118,7 +126,7 @@ export const updateState = async (state, setState, result, setResult, router, so
   setResult({ ...result, processing: true });
   setResult({ ...result, processing: true });
 
 
   // Pop the next event off the queue and apply it.
   // 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.
   // Set new events to avoid reprocessing the same event.
   setState({ ...state, events: state.events });
   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);
   const eventSent = await applyEvent(event, router, socket);
   if (!eventSent) {
   if (!eventSent) {
     // If no event was sent, set processing to false and return.
     // 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 socket The socket object to connect.
  * @param state The state object to apply the deltas to.
  * @param state The state object to apply the deltas to.
  * @param setState The function to set the state.
  * @param setState The function to set the state.
+ * @param result The current result.
  * @param setResult The function to set the result.
  * @param setResult The function to set the result.
  * @param endpoint The endpoint to connect to.
  * @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
   // Get backend URL object from the endpoint
-  const endpoint_url = new URL(endpoint)
+  const endpoint_url = new URL(endpoint);
   // Create the socket.
   // Create the socket.
   socket.current = io(endpoint, {
   socket.current = io(endpoint, {
-    path: endpoint_url['pathname'],
+    path: endpoint_url["pathname"],
     transports: transports,
     transports: transports,
     autoUnref: false,
     autoUnref: false,
   });
   });
 
 
   // Once the socket is open, hydrate the page.
   // Once the socket is open, hydrate the page.
-  socket.current.on('connect', () => {
+  socket.current.on("connect", () => {
     updateState(state, setState, result, setResult, router, socket.current);
     updateState(state, setState, result, setResult, router, socket.current);
   });
   });
 
 
   // On each received message, apply the delta and set the result.
   // 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);
     update = JSON.parse(update);
     applyDelta(state, update.delta);
     applyDelta(state, update.delta);
     setResult({
     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.
  * Create an event object.
  * @param name The name of the event.
  * @param name The name of the event.

+ 9 - 2
pynecone/__init__.py

@@ -3,14 +3,21 @@
 Anything imported here will be available in the default Pynecone import as `pc.*`.
 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 .base import Base
 from .components import *
 from .components import *
 from .components.component import custom_component as component
 from .components.component import custom_component as component
 from .components.graphing.victory import data
 from .components.graphing.victory import data
 from .config import Config
 from .config import Config
 from .constants import Env, Transports
 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 .middleware import Middleware
 from .model import Model, session
 from .model import Model, session
 from .route import route
 from .route import route

+ 37 - 1
pynecone/app.py

@@ -2,7 +2,7 @@
 
 
 from typing import Any, Callable, Coroutine, Dict, List, Optional, Type, Union
 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 fastapi.middleware import cors
 from socketio import ASGIApp, AsyncNamespace, AsyncServer
 from socketio import ASGIApp, AsyncNamespace, AsyncServer
 
 
@@ -124,6 +124,9 @@ class App(Base):
         # To test the server.
         # To test the server.
         self.api.get(str(constants.Endpoint.PING))(ping)
         self.api.get(str(constants.Endpoint.PING))(ping)
 
 
+        # To upload files.
+        self.api.post(str(constants.Endpoint.UPLOAD))(upload(self))
+
     def add_cors(self):
     def add_cors(self):
         """Add CORS middleware to the app."""
         """Add CORS middleware to the app."""
         self.api.add_middleware(
         self.api.add_middleware(
@@ -131,6 +134,7 @@ class App(Base):
             allow_credentials=True,
             allow_credentials=True,
             allow_methods=["*"],
             allow_methods=["*"],
             allow_headers=["*"],
             allow_headers=["*"],
+            allow_origins=["*"],
         )
         )
 
 
     def preprocess(self, state: State, event: Event) -> Optional[Delta]:
     def preprocess(self, state: State, event: Event) -> Optional[Delta]:
@@ -428,6 +432,38 @@ async def ping() -> str:
     return "pong"
     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):
 class EventNamespace(AsyncNamespace):
     """The event namespace."""
     """The event namespace."""
 
 

+ 1 - 1
pynecone/compiler/compiler.py

@@ -15,7 +15,7 @@ from pynecone.style import Style
 DEFAULT_IMPORTS: ImportDict = {
 DEFAULT_IMPORTS: ImportDict = {
     "react": {"useEffect", "useRef", "useState"},
     "react": {"useEffect", "useRef", "useState"},
     "next/router": {"useRouter"},
     "next/router": {"useRouter"},
-    f"/{constants.STATE_PATH}": {"connect", "updateState", "E"},
+    f"/{constants.STATE_PATH}": {"connect", "updateState", "uploadFiles", "E"},
     "": {"focus-visible/dist/focus-visible"},
     "": {"focus-visible/dist/focus-visible"},
     "@chakra-ui/react": {constants.USE_COLOR_MODE},
     "@chakra-ui/react": {constants.USE_COLOR_MODE},
 }
 }

+ 8 - 0
pynecone/compiler/templates.py

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

+ 11 - 3
pynecone/compiler/utils.py

@@ -85,9 +85,11 @@ def compile_constants() -> str:
     Returns:
     Returns:
         A string of all the compiled constants.
         A string of all the compiled constants.
     """
     """
-    endpoint = constants.Endpoint.EVENT
     return templates.join(
     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(
     initial_state.update(
         {
         {
             "events": [{"name": utils.get_hydrate_event(state)}],
             "events": [{"name": utils.get_hydrate_event(state)}],
+            "files": [],
         }
         }
     )
     )
     initial_state = utils.format_state(initial_state)
     initial_state = utils.format_state(initial_state)
@@ -137,7 +140,12 @@ def compile_events(state: Type[State]) -> str:
     """
     """
     state_name = state.get_name()
     state_name = state.get_name()
     state_setter = templates.format_state_setter(state_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:
 def compile_effects(state: Type[State]) -> str:

+ 1 - 0
pynecone/components/__init__.py

@@ -112,6 +112,7 @@ slider_thumb = SliderThumb.create
 slider_track = SliderTrack.create
 slider_track = SliderTrack.create
 switch = Switch.create
 switch = Switch.create
 text_area = TextArea.create
 text_area = TextArea.create
+upload = Upload.create
 area = Area.create
 area = Area.create
 bar = Bar.create
 bar = Bar.create
 box_plot = BoxPlot.create
 box_plot = BoxPlot.create

+ 4 - 1
pynecone/components/component.py

@@ -50,6 +50,9 @@ class Component(Base, ABC):
     # The class name for the component.
     # The class name for the component.
     class_name: Any = None
     class_name: Any = None
 
 
+    # Special component props.
+    special_props: Set[Var] = set()
+
     @classmethod
     @classmethod
     def __init_subclass__(cls, **kwargs):
     def __init_subclass__(cls, **kwargs):
         """Set default properties.
         """Set default properties.
@@ -290,7 +293,7 @@ class Component(Base, ABC):
         # Create the base tag.
         # Create the base tag.
         alias = self.get_alias()
         alias = self.get_alias()
         name = alias if alias is not None else self.tag
         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.
         # Add component props to the tag.
         props = {attr: getattr(self, attr) for attr in self.get_props()}
         props = {attr: getattr(self, attr) for attr in self.get_props()}

+ 1 - 0
pynecone/components/forms/__init__.py

@@ -27,5 +27,6 @@ from .select import Option, Select
 from .slider import Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack
 from .slider import Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack
 from .switch import Switch
 from .switch import Switch
 from .textarea import TextArea
 from .textarea import TextArea
+from .upload import Upload
 
 
 __all__ = [f for f in dir() if f[0].isupper()]  # type: ignore
 __all__ = [f for f in dir() if f[0].isupper()]  # type: ignore

+ 57 - 0
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

+ 1 - 1
pynecone/components/media/icon.py

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

+ 28 - 4
pynecone/components/tags/tag.py

@@ -5,7 +5,7 @@ from __future__ import annotations
 import json
 import json
 import os
 import os
 import re
 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.graph_objects import Figure
 from plotly.io import to_json
 from plotly.io import to_json
@@ -31,6 +31,12 @@ class Tag(Base):
     # The inner contents of the tag.
     # The inner contents of the tag.
     contents: str = ""
     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):
     def __init__(self, *args, **kwargs):
         """Initialize the tag.
         """Initialize the tag.
 
 
@@ -68,8 +74,15 @@ class Tag(Base):
         # Handle event props.
         # Handle event props.
         elif isinstance(prop, EventChain):
         elif isinstance(prop, EventChain):
             local_args = ",".join(prop.events[0].local_args)
             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.
         # Handle other types.
         elif isinstance(prop, str):
         elif isinstance(prop, str):
@@ -125,6 +138,11 @@ class Tag(Base):
         """
         """
         # Get the tag props.
         # Get the tag props.
         props_str = self.format_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:
         if len(props_str) > 0:
             props_str = " " + props_str
             props_str = " " + props_str
 
 
@@ -132,10 +150,16 @@ class Tag(Base):
             # If there is no inner content, we don't need a closing tag.
             # If there is no inner content, we don't need a closing tag.
             tag_str = utils.wrap(f"{self.name}{props_str}/", "<")
             tag_str = utils.wrap(f"{self.name}{props_str}/", "<")
         else:
         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.
             # Otherwise wrap it in opening and closing tags.
             open = utils.wrap(f"{self.name}{props_str}", "<")
             open = utils.wrap(f"{self.name}{props_str}", "<")
             close = utils.wrap(f"/{self.name}", "<")
             close = utils.wrap(f"/{self.name}", "<")
-            tag_str = utils.wrap(self.contents, open, close)
+            tag_str = utils.wrap(contents, open, close)
 
 
         return tag_str
         return tag_str
 
 

+ 1 - 0
pynecone/constants.py

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

+ 12 - 0
pynecone/event.py

@@ -62,6 +62,9 @@ class EventHandler(Base):
                 values.append(arg.full_name)
                 values.append(arg.full_name)
                 continue
                 continue
 
 
+            if isinstance(arg, FileUpload):
+                return EventSpec(handler=self, upload=True)
+
             # Otherwise, convert to JSON.
             # Otherwise, convert to JSON.
             try:
             try:
                 values.append(json.dumps(arg, ensure_ascii=False))
                 values.append(json.dumps(arg, ensure_ascii=False))
@@ -91,6 +94,9 @@ class EventSpec(Base):
     # The arguments to pass to the function.
     # The arguments to pass to the function.
     args: Tuple[Any, ...] = ()
     args: Tuple[Any, ...] = ()
 
 
+    # Whether to upload files.
+    upload: bool = False
+
     class Config:
     class Config:
         """The Pydantic config."""
         """The Pydantic config."""
 
 
@@ -122,6 +128,12 @@ class FrontendEvent(Base):
 EVENT_ARG = BaseVar(name="_e", type_=FrontendEvent, is_local=True)
 EVENT_ARG = BaseVar(name="_e", type_=FrontendEvent, is_local=True)
 
 
 
 
+class FileUpload(Base):
+    """Class to represent a file upload."""
+
+    pass
+
+
 # Special server-side events.
 # Special server-side events.
 def redirect(path: str) -> EventSpec:
 def redirect(path: str) -> EventSpec:
     """Redirect to a new path.
     """Redirect to a new path.

+ 39 - 7
pynecone/utils.py

@@ -1138,21 +1138,21 @@ def format_cond(
     return expr
     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:
     Args:
-        handler: The event handler to format.
+        handler: The event handler to get the parts of.
 
 
     Returns:
     Returns:
-        The formatted function.
+        The state and function name.
     """
     """
     # Get the class that defines the event handler.
     # Get the class that defines the event handler.
     parts = handler.fn.__qualname__.split(".")
     parts = handler.fn.__qualname__.split(".")
 
 
     # If there's no enclosing class, just return the function name.
     # If there's no enclosing class, just return the function name.
     if len(parts) == 1:
     if len(parts) == 1:
-        return parts[-1]
+        return ("", parts[-1])
 
 
     # Get the state and the function name.
     # Get the state and the function name.
     state_name, name = parts[-2:]
     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]
         state = vars(sys.modules[handler.fn.__module__])[state_name]
     except Exception:
     except Exception:
         # If the state isn't in the module, just return the function name.
         # 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:
 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, '{')})"
     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]:
 def format_query_params(router_data: Dict[str, Any]) -> Dict[str, str]:
     """Convert back query params name to python-friendly case.
     """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()}
     return {k.replace("-", "_"): v for k, v in params.items()}
 
 
 
 
+# Set of unique variable names.
 USED_VARIABLES = set()
 USED_VARIABLES = set()
 
 
 
 

+ 1 - 0
pyproject.toml

@@ -38,6 +38,7 @@ python-socketio = "^5.7.2"
 psutil = "^5.9.4"
 psutil = "^5.9.4"
 websockets = "^10.4"
 websockets = "^10.4"
 cloudpickle = "^2.2.1"
 cloudpickle = "^2.2.1"
+python-multipart = "^0.0.5"
 
 
 [tool.poetry.group.dev.dependencies]
 [tool.poetry.group.dev.dependencies]
 pytest = "^7.1.2"
 pytest = "^7.1.2"