소스 검색

Add upload component (#622)

Nikhil Rao 2 년 전
부모
커밋
f7138bd53f

+ 33 - 7
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"

BIN
pynecone/.templates/web/bun.lockb


+ 1 - 0
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",

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

+ 9 - 2
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

+ 37 - 1
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."""
 

+ 1 - 1
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},
 }

+ 8 - 0
pynecone/compiler/templates.py

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

+ 11 - 3
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:

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

+ 4 - 1
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()}

+ 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 .switch import Switch
 from .textarea import TextArea
+from .upload import Upload
 
 __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.components.component import Component

+ 28 - 4
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
 

+ 1 - 0
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.

+ 12 - 0
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.

+ 39 - 7
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()
 
 

+ 1 - 0
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"