Browse Source

Merge pull request #1016 from zauberzeug/add_media_files

Allow streaming of media files and auto-serve local files passed as source for image, interactive_image, video and audio
Falko Schindler 1 year ago
parent
commit
c8c50c053e

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@ dist
 /test.py
 /test.py
 *.pickle
 *.pickle
 tests/screenshots/
 tests/screenshots/
+tests/media/
 venv
 venv
 .idea
 .idea
 .nicegui/
 .nicegui/

+ 87 - 6
nicegui/app.py

@@ -1,13 +1,20 @@
-from typing import Awaitable, Callable, Union
+import hashlib
+from pathlib import Path
+from typing import Awaitable, Callable, Optional, Union
 
 
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
+from fastapi.responses import FileResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 
 
-from . import globals
+from . import globals, helpers
 from .native import Native
 from .native import Native
 from .storage import Storage
 from .storage import Storage
 
 
 
 
+def hash_file_path(path: Path) -> str:
+    return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:32]
+
+
 class App(FastAPI):
 class App(FastAPI):
 
 
     def __init__(self, **kwargs) -> None:
     def __init__(self, **kwargs) -> None:
@@ -62,20 +69,94 @@ class App(FastAPI):
             raise Exception('calling shutdown() is not supported when auto-reload is enabled')
             raise Exception('calling shutdown() is not supported when auto-reload is enabled')
         globals.server.should_exit = True
         globals.server.should_exit = True
 
 
-    def add_static_files(self, url_path: str, local_directory: str) -> None:
-        """Add static files.
+    def add_static_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
+        """Add a directory of static files.
 
 
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
         This is useful for providing local data like images to the frontend.
         This is useful for providing local data like images to the frontend.
         Otherwise the browser would not be able to access the files.
         Otherwise the browser would not be able to access the files.
         Do only put non-security-critical files in there, as they are accessible to everyone.
         Do only put non-security-critical files in there, as they are accessible to everyone.
 
 
+        To make a single file accessible, you can use `add_static_file()`.
+        For media files which should be streamed, you can use `add_media_files()` or `add_media_file()` instead.
+
         :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
         :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
         :param local_directory: local folder with files to serve as static content
         :param local_directory: local folder with files to serve as static content
         """
         """
         if url_path == '/':
         if url_path == '/':
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
-        globals.app.mount(url_path, StaticFiles(directory=local_directory))
+        globals.app.mount(url_path, StaticFiles(directory=str(local_directory)))
+
+    def add_static_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> str:
+        """Add a single static file.
+
+        Allows a local file to be accessed online with enabled caching.
+        If `url_path` is not specified, a path will be generated.
+
+        To make a whole folder of files accessible, use `add_static_files()` instead.
+        For media files which should be streamed, you can use `add_media_files()` or `add_media_file()` instead.
+
+        :param local_file: local file to serve as static content
+        :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :return: URL path which can be used to access the file
+        """
+        file = Path(local_file)
+        if not file.is_file():
+            raise ValueError(f'File not found: {file}')
+        if url_path is None:
+            url_path = f'/_nicegui/auto/static/{hash_file_path(file)}/{file.name}'
+
+        @self.get(url_path)
+        async def read_item() -> FileResponse:
+            return FileResponse(file, headers={'Cache-Control': 'public, max-age=3600'})
+
+        return url_path
+
+    def add_media_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
+        """Add directory of media files.
+
+        `add_media_files()` allows a local files to be streamed from a specified endpoint, e.g. `'/media'`.
+        This should be used for media files to support proper streaming.
+        Otherwise the browser would not be able to access and load the the files incrementally or jump to different positions in the stream.
+        Do only put non-security-critical files in there, as they are accessible to everyone.
+
+        To make a single file accessible via streaming, you can use `add_media_file()`.
+        For small static files, you can use `add_static_files()` or `add_static_file()` instead.
+
+        :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
+        :param local_directory: local folder with files to serve as media content
+        """
+        @self.get(url_path + '/{filename}')
+        async def read_item(request: Request, filename: str) -> StreamingResponse:
+            filepath = Path(local_directory) / filename
+            if not filepath.is_file():
+                return {'detail': 'Not Found'}, 404
+            return helpers.get_streaming_response(filepath, request)
+
+    def add_media_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> None:
+        """Add a single media file.
+
+        Allows a local file to be streamed.
+        If `url_path` is not specified, a path will be generated.
+
+        To make a whole folder of media files accessible via streaming, use `add_media_files()` instead.
+        For small static files, you can use `add_static_files()` or `add_static_file()` instead.
+
+        :param local_file: local file to serve as media content
+        :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :return: URL path which can be used to access the file
+        """
+        file = Path(local_file)
+        if not file.is_file():
+            raise ValueError(f'File not found: {local_file}')
+        if url_path is None:
+            url_path = f'/_nicegui/auto/media/{hash_file_path(file)}/{file.name}'
+
+        @self.get(url_path)
+        async def read_item(request: Request) -> StreamingResponse:
+            return helpers.get_streaming_response(file, request)
+
+        return url_path
 
 
     def remove_route(self, path: str) -> None:
     def remove_route(self, path: str) -> None:
         """Remove routes with the given path."""
         """Remove routes with the given path."""

+ 7 - 2
nicegui/elements/audio.py

@@ -1,5 +1,8 @@
 import warnings
 import warnings
+from pathlib import Path
+from typing import Union
 
 
+from .. import globals
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
 
 
@@ -8,7 +11,7 @@ register_component('audio', __file__, 'audio.js')
 
 
 class Audio(Element):
 class Audio(Element):
 
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  controls: bool = True,
                  autoplay: bool = False,
                  autoplay: bool = False,
                  muted: bool = False,
                  muted: bool = False,
@@ -17,7 +20,7 @@ class Audio(Element):
                  ) -> None:
                  ) -> None:
         """Audio
         """Audio
 
 
-        :param src: URL of the audio source
+        :param src: URL or local file path of the audio source
         :param controls: whether to show the audio controls, like play, pause, and volume (default: `True`)
         :param controls: whether to show the audio controls, like play, pause, and volume (default: `True`)
         :param autoplay: whether to start playing the audio automatically (default: `False`)
         :param autoplay: whether to start playing the audio automatically (default: `False`)
         :param muted: whether the audio should be initially muted (default: `False`)
         :param muted: whether the audio should be initially muted (default: `False`)
@@ -27,6 +30,8 @@ class Audio(Element):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         """
         super().__init__('audio')
         super().__init__('audio')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay
         self._props['autoplay'] = autoplay

+ 5 - 2
nicegui/elements/image.py

@@ -1,13 +1,16 @@
+from pathlib import Path
+from typing import Union
+
 from .mixins.source_element import SourceElement
 from .mixins.source_element import SourceElement
 
 
 
 
 class Image(SourceElement):
 class Image(SourceElement):
 
 
-    def __init__(self, source: str = '') -> None:
+    def __init__(self, source: Union[str, Path] = '') -> None:
         """Image
         """Image
 
 
         Displays an image.
         Displays an image.
 
 
-        :param source: the source of the image; can be a URL or a base64 string
+        :param source: the source of the image; can be a URL, local file path or a base64 string
         """
         """
         super().__init__(tag='q-img', source=source)
         super().__init__(tag='q-img', source=source)

+ 4 - 3
nicegui/elements/interactive_image.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import Any, Callable, Dict, List, Optional
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional, Union
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..events import MouseEventArguments, handle_event
 from ..events import MouseEventArguments, handle_event
@@ -14,7 +15,7 @@ class InteractiveImage(SourceElement, ContentElement):
     CONTENT_PROP = 'content'
     CONTENT_PROP = 'content'
 
 
     def __init__(self,
     def __init__(self,
-                 source: str = '', *,
+                 source: Union[str, Path] = '', *,
                  content: str = '',
                  content: str = '',
                  on_mouse: Optional[Callable[..., Any]] = None,
                  on_mouse: Optional[Callable[..., Any]] = None,
                  events: List[str] = ['click'],
                  events: List[str] = ['click'],
@@ -28,7 +29,7 @@ class InteractiveImage(SourceElement, ContentElement):
         Thereby repeatedly updating the image source will automatically adapt to the available bandwidth.
         Thereby repeatedly updating the image source will automatically adapt to the available bandwidth.
         See `OpenCV Webcam <https://github.com/zauberzeug/nicegui/tree/main/examples/opencv_webcam/main.py>`_ for an example.
         See `OpenCV Webcam <https://github.com/zauberzeug/nicegui/tree/main/examples/opencv_webcam/main.py>`_ for an example.
 
 
-        :param source: the source of the image; can be an URL or a base64 string
+        :param source: the source of the image; can be an URL, local file path or a base64 string
         :param content: SVG content which should be overlayed; viewport has the same dimensions as the image
         :param content: SVG content which should be overlayed; viewport has the same dimensions as the image
         :param on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :param on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)

+ 8 - 4
nicegui/elements/mixins/source_element.py

@@ -1,7 +1,9 @@
-from typing import Any, Callable
+from pathlib import Path
+from typing import Any, Callable, Union
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
+from ... import globals
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...element import Element
 from ...element import Element
 
 
@@ -9,8 +11,10 @@ from ...element import Element
 class SourceElement(Element):
 class SourceElement(Element):
     source = BindableProperty(on_change=lambda sender, source: sender.on_source_change(source))
     source = BindableProperty(on_change=lambda sender, source: sender.on_source_change(source))
 
 
-    def __init__(self, *, source: str, **kwargs: Any) -> None:
+    def __init__(self, *, source: Union[str, Path], **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
+        if Path(source).is_file():
+            source = globals.app.add_static_file(local_file=source)
         self.source = source
         self.source = source
         self._props['src'] = source
         self._props['src'] = source
 
 
@@ -64,14 +68,14 @@ class SourceElement(Element):
         bind(self, 'source', target_object, target_name, forward=forward, backward=backward)
         bind(self, 'source', target_object, target_name, forward=forward, backward=backward)
         return self
         return self
 
 
-    def set_source(self, source: str) -> None:
+    def set_source(self, source: Union[str, Path]) -> None:
         """Set the source of this element.
         """Set the source of this element.
 
 
         :param source: The new source.
         :param source: The new source.
         """
         """
         self.source = source
         self.source = source
 
 
-    def on_source_change(self, source: str) -> None:
+    def on_source_change(self, source: Union[str, Path]) -> None:
         """Called when the source of this element changes.
         """Called when the source of this element changes.
 
 
         :param source: The new source.
         :param source: The new source.

+ 7 - 2
nicegui/elements/video.py

@@ -1,5 +1,8 @@
 import warnings
 import warnings
+from pathlib import Path
+from typing import Union
 
 
+from .. import globals
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
 
 
@@ -8,7 +11,7 @@ register_component('video', __file__, 'video.js')
 
 
 class Video(Element):
 class Video(Element):
 
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  controls: bool = True,
                  autoplay: bool = False,
                  autoplay: bool = False,
                  muted: bool = False,
                  muted: bool = False,
@@ -17,7 +20,7 @@ class Video(Element):
                  ) -> None:
                  ) -> None:
         """Video
         """Video
 
 
-        :param src: URL of the video source
+        :param src: URL or local file path of the video source
         :param controls: whether to show the video controls, like play, pause, and volume (default: `True`)
         :param controls: whether to show the video controls, like play, pause, and volume (default: `True`)
         :param autoplay: whether to start playing the video automatically (default: `False`)
         :param autoplay: whether to start playing the video automatically (default: `False`)
         :param muted: whether the video should be initially muted (default: `False`)
         :param muted: whether the video should be initially muted (default: `False`)
@@ -27,6 +30,8 @@ class Video(Element):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         """
         super().__init__('video')
         super().__init__('video')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay
         self._props['autoplay'] = autoplay

+ 41 - 1
nicegui/helpers.py

@@ -7,8 +7,12 @@ import threading
 import time
 import time
 import webbrowser
 import webbrowser
 from contextlib import nullcontext
 from contextlib import nullcontext
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Tuple, Union
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Optional, Tuple, Union
 
 
+import magic
+from fastapi import Request
+from fastapi.responses import StreamingResponse
 from starlette.middleware import Middleware
 from starlette.middleware import Middleware
 from starlette.middleware.sessions import SessionMiddleware
 from starlette.middleware.sessions import SessionMiddleware
 
 
@@ -103,3 +107,39 @@ def set_storage_secret(storage_secret: Optional[str] = None) -> None:
     elif storage_secret is not None:
     elif storage_secret is not None:
         globals.app.add_middleware(RequestTrackingMiddleware)
         globals.app.add_middleware(RequestTrackingMiddleware)
         globals.app.add_middleware(SessionMiddleware, secret_key=storage_secret)
         globals.app.add_middleware(SessionMiddleware, secret_key=storage_secret)
+
+
+def get_streaming_response(file: Path, request: Request) -> StreamingResponse:
+    file_size = file.stat().st_size
+    start = 0
+    end = file_size - 1
+    range_header = request.headers.get('Range')
+    if range_header:
+        byte1, byte2 = range_header.split('=')[1].split('-')
+        start = int(byte1)
+        if byte2:
+            end = int(byte2)
+    content_length = end - start + 1
+    headers = {
+        'Content-Range': f'bytes {start}-{end}/{file_size}',
+        'Content-Length': str(content_length),
+        'Accept-Ranges': 'bytes',
+    }
+
+    def content_reader(file: Path, start: int, end: int, chunk_size: int = 8192) -> Generator[bytes, None, None]:
+        with open(file, 'rb') as data:
+            data.seek(start)
+            remaining_bytes = end - start + 1
+            while remaining_bytes > 0:
+                chunk = data.read(min(chunk_size, remaining_bytes))
+                if not chunk:
+                    break
+                yield chunk
+                remaining_bytes -= len(chunk)
+
+    return StreamingResponse(
+        content_reader(file, start, end),
+        media_type=magic.from_file(str(file), mime=True),
+        headers=headers,
+        status_code=206,
+    )

+ 14 - 101
poetry.lock

@@ -1,10 +1,9 @@
-# This file is automatically @generated by Poetry and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
 
 
 [[package]]
 [[package]]
 name = "aiofiles"
 name = "aiofiles"
 version = "23.1.0"
 version = "23.1.0"
 description = "File support for asyncio."
 description = "File support for asyncio."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7,<4.0"
 python-versions = ">=3.7,<4.0"
 files = [
 files = [
@@ -16,7 +15,6 @@ files = [
 name = "anyio"
 name = "anyio"
 version = "3.7.0"
 version = "3.7.0"
 description = "High level compatibility layer for multiple asynchronous event loop implementations"
 description = "High level compatibility layer for multiple asynchronous event loop implementations"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -39,7 +37,6 @@ trio = ["trio (<0.22)"]
 name = "asttokens"
 name = "asttokens"
 version = "2.2.1"
 version = "2.2.1"
 description = "Annotate AST trees with source code positions"
 description = "Annotate AST trees with source code positions"
-category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -57,7 +54,6 @@ test = ["astroid", "pytest"]
 name = "async-generator"
 name = "async-generator"
 version = "1.10"
 version = "1.10"
 description = "Async generators and context managers for Python 3.5+"
 description = "Async generators and context managers for Python 3.5+"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.5"
 python-versions = ">=3.5"
 files = [
 files = [
@@ -69,7 +65,6 @@ files = [
 name = "atomicwrites"
 name = "atomicwrites"
 version = "1.4.1"
 version = "1.4.1"
 description = "Atomic file writes."
 description = "Atomic file writes."
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
 files = [
@@ -80,7 +75,6 @@ files = [
 name = "attrs"
 name = "attrs"
 version = "23.1.0"
 version = "23.1.0"
 description = "Classes Without Boilerplate"
 description = "Classes Without Boilerplate"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -102,7 +96,6 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte
 name = "autopep8"
 name = "autopep8"
 version = "1.7.0"
 version = "1.7.0"
 description = "A tool that automatically formats Python code to conform to the PEP 8 style guide"
 description = "A tool that automatically formats Python code to conform to the PEP 8 style guide"
-category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -118,7 +111,6 @@ toml = "*"
 name = "bidict"
 name = "bidict"
 version = "0.22.1"
 version = "0.22.1"
 description = "The bidirectional mapping library for Python."
 description = "The bidirectional mapping library for Python."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -135,7 +127,6 @@ test = ["hypothesis", "pytest", "pytest-benchmark[histogram]", "pytest-cov", "py
 name = "bottle"
 name = "bottle"
 version = "0.12.25"
 version = "0.12.25"
 description = "Fast and simple WSGI-framework for small web-applications."
 description = "Fast and simple WSGI-framework for small web-applications."
-category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -147,7 +138,6 @@ files = [
 name = "certifi"
 name = "certifi"
 version = "2023.5.7"
 version = "2023.5.7"
 description = "Python package for providing Mozilla's CA Bundle."
 description = "Python package for providing Mozilla's CA Bundle."
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -159,7 +149,6 @@ files = [
 name = "cffi"
 name = "cffi"
 version = "1.15.1"
 version = "1.15.1"
 description = "Foreign Function Interface for Python calling C code."
 description = "Foreign Function Interface for Python calling C code."
-category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -236,7 +225,6 @@ pycparser = "*"
 name = "charset-normalizer"
 name = "charset-normalizer"
 version = "3.1.0"
 version = "3.1.0"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7.0"
 python-versions = ">=3.7.0"
 files = [
 files = [
@@ -321,7 +309,6 @@ files = [
 name = "click"
 name = "click"
 version = "8.1.3"
 version = "8.1.3"
 description = "Composable command line interface toolkit"
 description = "Composable command line interface toolkit"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -337,7 +324,6 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
 name = "colorama"
 name = "colorama"
 version = "0.4.6"
 version = "0.4.6"
 description = "Cross-platform colored terminal text."
 description = "Cross-platform colored terminal text."
-category = "main"
 optional = false
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 files = [
 files = [
@@ -349,7 +335,6 @@ files = [
 name = "contourpy"
 name = "contourpy"
 version = "1.0.7"
 version = "1.0.7"
 description = "Python library for calculating contours of 2D quadrilateral grids"
 description = "Python library for calculating contours of 2D quadrilateral grids"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -424,7 +409,6 @@ test-no-images = ["pytest"]
 name = "cycler"
 name = "cycler"
 version = "0.11.0"
 version = "0.11.0"
 description = "Composable style cycles"
 description = "Composable style cycles"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -436,7 +420,6 @@ files = [
 name = "debugpy"
 name = "debugpy"
 version = "1.6.7"
 version = "1.6.7"
 description = "An implementation of the Debug Adapter Protocol for Python"
 description = "An implementation of the Debug Adapter Protocol for Python"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -464,7 +447,6 @@ files = [
 name = "docutils"
 name = "docutils"
 version = "0.19"
 version = "0.19"
 description = "Docutils -- Python Documentation Utilities"
 description = "Docutils -- Python Documentation Utilities"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -476,7 +458,6 @@ files = [
 name = "exceptiongroup"
 name = "exceptiongroup"
 version = "1.1.1"
 version = "1.1.1"
 description = "Backport of PEP 654 (exception groups)"
 description = "Backport of PEP 654 (exception groups)"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -491,7 +472,6 @@ test = ["pytest (>=6)"]
 name = "executing"
 name = "executing"
 version = "1.2.0"
 version = "1.2.0"
 description = "Get the currently executing AST node of a frame, and other information"
 description = "Get the currently executing AST node of a frame, and other information"
-category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -506,7 +486,6 @@ tests = ["asttokens", "littleutils", "pytest", "rich"]
 name = "fastapi"
 name = "fastapi"
 version = "0.95.2"
 version = "0.95.2"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -528,7 +507,6 @@ test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6
 name = "fastapi-socketio"
 name = "fastapi-socketio"
 version = "0.0.10"
 version = "0.0.10"
 description = "Easily integrate socket.io with your FastAPI app."
 description = "Easily integrate socket.io with your FastAPI app."
-category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -547,7 +525,6 @@ test = ["pytest"]
 name = "fonttools"
 name = "fonttools"
 version = "4.38.0"
 version = "4.38.0"
 description = "Tools to manipulate font files"
 description = "Tools to manipulate font files"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -573,7 +550,6 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
 name = "fonttools"
 name = "fonttools"
 version = "4.39.4"
 version = "4.39.4"
 description = "Tools to manipulate font files"
 description = "Tools to manipulate font files"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -599,7 +575,6 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
 name = "h11"
 name = "h11"
 version = "0.14.0"
 version = "0.14.0"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -614,7 +589,6 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
 name = "httptools"
 name = "httptools"
 version = "0.5.0"
 version = "0.5.0"
 description = "A collection of framework independent HTTP protocol utils."
 description = "A collection of framework independent HTTP protocol utils."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.5.0"
 python-versions = ">=3.5.0"
 files = [
 files = [
@@ -668,7 +642,6 @@ test = ["Cython (>=0.29.24,<0.30.0)"]
 name = "icecream"
 name = "icecream"
 version = "2.1.3"
 version = "2.1.3"
 description = "Never use print() to debug again; inspect variables, expressions, and program execution with a single, simple function call."
 description = "Never use print() to debug again; inspect variables, expressions, and program execution with a single, simple function call."
-category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -686,7 +659,6 @@ pygments = ">=2.2.0"
 name = "idna"
 name = "idna"
 version = "3.4"
 version = "3.4"
 description = "Internationalized Domain Names in Applications (IDNA)"
 description = "Internationalized Domain Names in Applications (IDNA)"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.5"
 python-versions = ">=3.5"
 files = [
 files = [
@@ -698,7 +670,6 @@ files = [
 name = "importlib-metadata"
 name = "importlib-metadata"
 version = "6.6.0"
 version = "6.6.0"
 description = "Read metadata from Python packages"
 description = "Read metadata from Python packages"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -719,7 +690,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag
 name = "iniconfig"
 name = "iniconfig"
 version = "2.0.0"
 version = "2.0.0"
 description = "brain-dead simple config-ini parsing"
 description = "brain-dead simple config-ini parsing"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -731,7 +701,6 @@ files = [
 name = "isort"
 name = "isort"
 version = "5.11.5"
 version = "5.11.5"
 description = "A Python utility / library to sort Python imports."
 description = "A Python utility / library to sort Python imports."
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7.0"
 python-versions = ">=3.7.0"
 files = [
 files = [
@@ -749,7 +718,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"]
 name = "itsdangerous"
 name = "itsdangerous"
 version = "2.1.2"
 version = "2.1.2"
 description = "Safely pass data to untrusted environments and back."
 description = "Safely pass data to untrusted environments and back."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -761,7 +729,6 @@ files = [
 name = "jinja2"
 name = "jinja2"
 version = "3.1.2"
 version = "3.1.2"
 description = "A very fast and expressive template engine."
 description = "A very fast and expressive template engine."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -779,7 +746,6 @@ i18n = ["Babel (>=2.7)"]
 name = "kiwisolver"
 name = "kiwisolver"
 version = "1.4.4"
 version = "1.4.4"
 description = "A fast implementation of the Cassowary constraint solver"
 description = "A fast implementation of the Cassowary constraint solver"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -860,7 +826,6 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
 name = "markdown2"
 name = "markdown2"
 version = "2.4.8"
 version = "2.4.8"
 description = "A fast and complete Python implementation of Markdown"
 description = "A fast and complete Python implementation of Markdown"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.5, <4"
 python-versions = ">=3.5, <4"
 files = [
 files = [
@@ -877,7 +842,6 @@ wavedrom = ["wavedrom"]
 name = "markupsafe"
 name = "markupsafe"
 version = "2.1.2"
 version = "2.1.2"
 description = "Safely add untrusted strings to HTML/XML markup."
 description = "Safely add untrusted strings to HTML/XML markup."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -937,7 +901,6 @@ files = [
 name = "matplotlib"
 name = "matplotlib"
 version = "3.5.3"
 version = "3.5.3"
 description = "Python plotting package"
 description = "Python plotting package"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -992,7 +955,6 @@ python-dateutil = ">=2.7"
 name = "matplotlib"
 name = "matplotlib"
 version = "3.7.1"
 version = "3.7.1"
 description = "Python plotting package"
 description = "Python plotting package"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -1054,7 +1016,6 @@ python-dateutil = ">=2.7"
 name = "numpy"
 name = "numpy"
 version = "1.21.1"
 version = "1.21.1"
 description = "NumPy is the fundamental package for array computing with Python."
 description = "NumPy is the fundamental package for array computing with Python."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1092,7 +1053,6 @@ files = [
 name = "numpy"
 name = "numpy"
 version = "1.24.3"
 version = "1.24.3"
 description = "Fundamental package for array computing in Python"
 description = "Fundamental package for array computing in Python"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -1130,7 +1090,6 @@ files = [
 name = "orjson"
 name = "orjson"
 version = "3.9.0"
 version = "3.9.0"
 description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
 description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1186,7 +1145,6 @@ files = [
 name = "outcome"
 name = "outcome"
 version = "1.2.0"
 version = "1.2.0"
 description = "Capture the outcome of Python function calls."
 description = "Capture the outcome of Python function calls."
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1201,7 +1159,6 @@ attrs = ">=19.2.0"
 name = "packaging"
 name = "packaging"
 version = "23.1"
 version = "23.1"
 description = "Core utilities for Python packages"
 description = "Core utilities for Python packages"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1213,7 +1170,6 @@ files = [
 name = "pandas"
 name = "pandas"
 version = "1.1.5"
 version = "1.1.5"
 description = "Powerful data structures for data analysis, time series, and statistics"
 description = "Powerful data structures for data analysis, time series, and statistics"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6.1"
 python-versions = ">=3.6.1"
 files = [
 files = [
@@ -1255,7 +1211,6 @@ test = ["hypothesis (>=3.58)", "pytest (>=4.0.2)", "pytest-xdist"]
 name = "pandas"
 name = "pandas"
 version = "2.0.2"
 version = "2.0.2"
 description = "Powerful data structures for data analysis, time series, and statistics"
 description = "Powerful data structures for data analysis, time series, and statistics"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -1323,7 +1278,6 @@ xml = ["lxml (>=4.6.3)"]
 name = "pillow"
 name = "pillow"
 version = "9.5.0"
 version = "9.5.0"
 description = "Python Imaging Library (Fork)"
 description = "Python Imaging Library (Fork)"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1403,7 +1357,6 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
 name = "plotly"
 name = "plotly"
 version = "5.14.1"
 version = "5.14.1"
 description = "An open-source, interactive data visualization library for Python"
 description = "An open-source, interactive data visualization library for Python"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1419,7 +1372,6 @@ tenacity = ">=6.2.0"
 name = "pluggy"
 name = "pluggy"
 version = "1.0.0"
 version = "1.0.0"
 description = "plugin and hook calling mechanisms for python"
 description = "plugin and hook calling mechanisms for python"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1438,7 +1390,6 @@ testing = ["pytest", "pytest-benchmark"]
 name = "proxy-tools"
 name = "proxy-tools"
 version = "0.1.0"
 version = "0.1.0"
 description = "Proxy Implementation"
 description = "Proxy Implementation"
-category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1449,7 +1400,6 @@ files = [
 name = "pscript"
 name = "pscript"
 version = "0.7.7"
 version = "0.7.7"
 description = "Python to JavaScript compiler."
 description = "Python to JavaScript compiler."
-category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1461,7 +1411,6 @@ files = [
 name = "py"
 name = "py"
 version = "1.11.0"
 version = "1.11.0"
 description = "library with cross-python path, ini-parsing, io, code, log facilities"
 description = "library with cross-python path, ini-parsing, io, code, log facilities"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 files = [
 files = [
@@ -1473,7 +1422,6 @@ files = [
 name = "pycodestyle"
 name = "pycodestyle"
 version = "2.10.0"
 version = "2.10.0"
 description = "Python style guide checker"
 description = "Python style guide checker"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1485,7 +1433,6 @@ files = [
 name = "pycparser"
 name = "pycparser"
 version = "2.21"
 version = "2.21"
 description = "C parser in Python"
 description = "C parser in Python"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
 files = [
@@ -1497,7 +1444,6 @@ files = [
 name = "pydantic"
 name = "pydantic"
 version = "1.10.8"
 version = "1.10.8"
 description = "Data validation and settings management using python type hints"
 description = "Data validation and settings management using python type hints"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1550,7 +1496,6 @@ email = ["email-validator (>=1.0.3)"]
 name = "pygments"
 name = "pygments"
 version = "2.15.1"
 version = "2.15.1"
 description = "Pygments is a syntax highlighting package written in Python."
 description = "Pygments is a syntax highlighting package written in Python."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1565,7 +1510,6 @@ plugins = ["importlib-metadata"]
 name = "pyobjc-core"
 name = "pyobjc-core"
 version = "9.1.1"
 version = "9.1.1"
 description = "Python<->ObjC Interoperability Module"
 description = "Python<->ObjC Interoperability Module"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1582,7 +1526,6 @@ files = [
 name = "pyobjc-framework-cocoa"
 name = "pyobjc-framework-cocoa"
 version = "9.1.1"
 version = "9.1.1"
 description = "Wrappers for the Cocoa frameworks on macOS"
 description = "Wrappers for the Cocoa frameworks on macOS"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1602,7 +1545,6 @@ pyobjc-core = ">=9.1.1"
 name = "pyobjc-framework-webkit"
 name = "pyobjc-framework-webkit"
 version = "9.1.1"
 version = "9.1.1"
 description = "Wrappers for the framework WebKit on macOS"
 description = "Wrappers for the framework WebKit on macOS"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1620,7 +1562,6 @@ pyobjc-framework-Cocoa = ">=9.1.1"
 name = "pyparsing"
 name = "pyparsing"
 version = "3.0.9"
 version = "3.0.9"
 description = "pyparsing module - Classes and methods to define and execute parsing grammars"
 description = "pyparsing module - Classes and methods to define and execute parsing grammars"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6.8"
 python-versions = ">=3.6.8"
 files = [
 files = [
@@ -1635,7 +1576,6 @@ diagrams = ["jinja2", "railroad-diagrams"]
 name = "pysocks"
 name = "pysocks"
 version = "1.7.1"
 version = "1.7.1"
 description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
 description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
 files = [
@@ -1648,7 +1588,6 @@ files = [
 name = "pytest"
 name = "pytest"
 version = "6.2.5"
 version = "6.2.5"
 description = "pytest: simple powerful testing with Python"
 description = "pytest: simple powerful testing with Python"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1674,7 +1613,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm
 name = "pytest-asyncio"
 name = "pytest-asyncio"
 version = "0.19.0"
 version = "0.19.0"
 description = "Pytest support for asyncio"
 description = "Pytest support for asyncio"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1693,7 +1631,6 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy
 name = "pytest-base-url"
 name = "pytest-base-url"
 version = "2.0.0"
 version = "2.0.0"
 description = "pytest plugin for URL based testing"
 description = "pytest plugin for URL based testing"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7,<4.0"
 python-versions = ">=3.7,<4.0"
 files = [
 files = [
@@ -1709,7 +1646,6 @@ requests = ">=2.9"
 name = "pytest-html"
 name = "pytest-html"
 version = "3.2.0"
 version = "3.2.0"
 description = "pytest plugin for generating HTML reports"
 description = "pytest plugin for generating HTML reports"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1726,7 +1662,6 @@ pytest-metadata = "*"
 name = "pytest-metadata"
 name = "pytest-metadata"
 version = "2.0.4"
 version = "2.0.4"
 description = "pytest plugin for test session metadata"
 description = "pytest plugin for test session metadata"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7,<4.0"
 python-versions = ">=3.7,<4.0"
 files = [
 files = [
@@ -1741,7 +1676,6 @@ pytest = ">=3.0.0,<8.0.0"
 name = "pytest-selenium"
 name = "pytest-selenium"
 version = "4.0.1"
 version = "4.0.1"
 description = "pytest plugin for Selenium"
 description = "pytest plugin for Selenium"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1766,7 +1700,6 @@ test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest
 name = "pytest-variables"
 name = "pytest-variables"
 version = "2.0.0"
 version = "2.0.0"
 description = "pytest plugin for providing variables to tests/fixtures"
 description = "pytest plugin for providing variables to tests/fixtures"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7,<4.0"
 python-versions = ">=3.7,<4.0"
 files = [
 files = [
@@ -1786,7 +1719,6 @@ yaml = ["PyYAML"]
 name = "python-dateutil"
 name = "python-dateutil"
 version = "2.8.2"
 version = "2.8.2"
 description = "Extensions to the standard Python datetime module"
 description = "Extensions to the standard Python datetime module"
-category = "main"
 optional = false
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 files = [
 files = [
@@ -1801,7 +1733,6 @@ six = ">=1.5"
 name = "python-dotenv"
 name = "python-dotenv"
 version = "0.21.1"
 version = "0.21.1"
 description = "Read key-value pairs from a .env file and set them as environment variables"
 description = "Read key-value pairs from a .env file and set them as environment variables"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1816,7 +1747,6 @@ cli = ["click (>=5.0)"]
 name = "python-engineio"
 name = "python-engineio"
 version = "4.4.1"
 version = "4.4.1"
 description = "Engine.IO server and client for Python"
 description = "Engine.IO server and client for Python"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1828,11 +1758,21 @@ 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-magic"
+version = "0.4.27"
+description = "File type identification using libmagic"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+    {file = "python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b"},
+    {file = "python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3"},
+]
+
 [[package]]
 [[package]]
 name = "python-multipart"
 name = "python-multipart"
 version = "0.0.6"
 version = "0.0.6"
 description = "A streaming multipart parser for Python"
 description = "A streaming multipart parser for Python"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1847,7 +1787,6 @@ dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatc
 name = "python-socketio"
 name = "python-socketio"
 version = "5.8.0"
 version = "5.8.0"
 description = "Socket.IO server and client for Python"
 description = "Socket.IO server and client for Python"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1867,7 +1806,6 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
 name = "pythonnet"
 name = "pythonnet"
 version = "2.5.2"
 version = "2.5.2"
 description = ".Net and Mono integration for Python"
 description = ".Net and Mono integration for Python"
-category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1891,7 +1829,6 @@ pycparser = "*"
 name = "pytz"
 name = "pytz"
 version = "2023.3"
 version = "2023.3"
 description = "World timezone definitions, modern and historical"
 description = "World timezone definitions, modern and historical"
-category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1903,7 +1840,6 @@ files = [
 name = "pywebview"
 name = "pywebview"
 version = "4.1"
 version = "4.1"
 description = "Build GUI for your Python program with JavaScript, HTML, and CSS."
 description = "Build GUI for your Python program with JavaScript, HTML, and CSS."
-category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1931,7 +1867,6 @@ qt = ["PyQt5", "QtPy", "pyqtwebengine"]
 name = "pyyaml"
 name = "pyyaml"
 version = "6.0"
 version = "6.0"
 description = "YAML parser and emitter for Python"
 description = "YAML parser and emitter for Python"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1981,7 +1916,6 @@ files = [
 name = "qtpy"
 name = "qtpy"
 version = "2.3.1"
 version = "2.3.1"
 description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6)."
 description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6)."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1999,7 +1933,6 @@ test = ["pytest (>=6,!=7.0.0,!=7.0.1)", "pytest-cov (>=3.0.0)", "pytest-qt"]
 name = "requests"
 name = "requests"
 version = "2.31.0"
 version = "2.31.0"
 description = "Python HTTP for Humans."
 description = "Python HTTP for Humans."
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2021,7 +1954,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 name = "secure"
 name = "secure"
 version = "0.3.0"
 version = "0.3.0"
 description = "A lightweight package that adds security headers for Python web frameworks."
 description = "A lightweight package that adds security headers for Python web frameworks."
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -2033,7 +1965,6 @@ files = [
 name = "selenium"
 name = "selenium"
 version = "4.9.1"
 version = "4.9.1"
 description = ""
 description = ""
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2051,7 +1982,6 @@ urllib3 = {version = ">=1.26,<3", extras = ["socks"]}
 name = "six"
 name = "six"
 version = "1.16.0"
 version = "1.16.0"
 description = "Python 2 and 3 compatibility utilities"
 description = "Python 2 and 3 compatibility utilities"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
 files = [
@@ -2063,7 +1993,6 @@ files = [
 name = "sniffio"
 name = "sniffio"
 version = "1.3.0"
 version = "1.3.0"
 description = "Sniff out which async library your code is running under"
 description = "Sniff out which async library your code is running under"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2075,7 +2004,6 @@ files = [
 name = "sortedcontainers"
 name = "sortedcontainers"
 version = "2.4.0"
 version = "2.4.0"
 description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
 description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
-category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -2087,7 +2015,6 @@ files = [
 name = "starlette"
 name = "starlette"
 version = "0.27.0"
 version = "0.27.0"
 description = "The little ASGI library that shines."
 description = "The little ASGI library that shines."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2106,7 +2033,6 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyam
 name = "tenacity"
 name = "tenacity"
 version = "8.2.2"
 version = "8.2.2"
 description = "Retry code until it succeeds"
 description = "Retry code until it succeeds"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -2121,7 +2047,6 @@ doc = ["reno", "sphinx", "tornado (>=4.5)"]
 name = "toml"
 name = "toml"
 version = "0.10.2"
 version = "0.10.2"
 description = "Python Library for Tom's Obvious, Minimal Language"
 description = "Python Library for Tom's Obvious, Minimal Language"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
 files = [
@@ -2133,7 +2058,6 @@ files = [
 name = "trio"
 name = "trio"
 version = "0.22.0"
 version = "0.22.0"
 description = "A friendly Python library for async concurrency and I/O"
 description = "A friendly Python library for async concurrency and I/O"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2155,7 +2079,6 @@ sortedcontainers = "*"
 name = "trio-websocket"
 name = "trio-websocket"
 version = "0.10.2"
 version = "0.10.2"
 description = "WebSocket library for Trio"
 description = "WebSocket library for Trio"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2172,7 +2095,6 @@ wsproto = ">=0.14"
 name = "typing-extensions"
 name = "typing-extensions"
 version = "4.6.3"
 version = "4.6.3"
 description = "Backported and Experimental Type Hints for Python 3.7+"
 description = "Backported and Experimental Type Hints for Python 3.7+"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2184,7 +2106,6 @@ files = [
 name = "tzdata"
 name = "tzdata"
 version = "2023.3"
 version = "2023.3"
 description = "Provider of IANA time zone data"
 description = "Provider of IANA time zone data"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=2"
 python-versions = ">=2"
 files = [
 files = [
@@ -2196,7 +2117,6 @@ files = [
 name = "urllib3"
 name = "urllib3"
 version = "2.0.2"
 version = "2.0.2"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 description = "HTTP library with thread-safe connection pooling, file post, and more."
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2217,7 +2137,6 @@ zstd = ["zstandard (>=0.18.0)"]
 name = "uvicorn"
 name = "uvicorn"
 version = "0.20.0"
 version = "0.20.0"
 description = "The lightning-fast ASGI server."
 description = "The lightning-fast ASGI server."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2233,7 +2152,7 @@ httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standar
 python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
 pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
 typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
 typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
-uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
+uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
 watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
 websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
 
 
@@ -2244,7 +2163,6 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
 name = "uvloop"
 name = "uvloop"
 version = "0.17.0"
 version = "0.17.0"
 description = "Fast implementation of asyncio event loop on top of libuv"
 description = "Fast implementation of asyncio event loop on top of libuv"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2289,7 +2207,6 @@ test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "my
 name = "vbuild"
 name = "vbuild"
 version = "0.8.1"
 version = "0.8.1"
 description = "A simple module to extract html/script/style from a vuejs '.vue' file (can minimize/es2015 compliant js) ... just py2 or py3, NO nodejs !"
 description = "A simple module to extract html/script/style from a vuejs '.vue' file (can minimize/es2015 compliant js) ... just py2 or py3, NO nodejs !"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 files = [
 files = [
@@ -2304,7 +2221,6 @@ pscript = ">=0.7.0,<0.8.0"
 name = "watchfiles"
 name = "watchfiles"
 version = "0.18.1"
 version = "0.18.1"
 description = "Simple, modern and high performance file watching and code reload in python."
 description = "Simple, modern and high performance file watching and code reload in python."
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2335,7 +2251,6 @@ anyio = ">=3.0.0"
 name = "websockets"
 name = "websockets"
 version = "11.0.3"
 version = "11.0.3"
 description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
 description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2415,7 +2330,6 @@ files = [
 name = "wsproto"
 name = "wsproto"
 version = "1.2.0"
 version = "1.2.0"
 description = "WebSockets state-machine based protocol implementation"
 description = "WebSockets state-machine based protocol implementation"
-category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7.0"
 python-versions = ">=3.7.0"
 files = [
 files = [
@@ -2430,7 +2344,6 @@ h11 = ">=0.9.0,<1"
 name = "zipp"
 name = "zipp"
 version = "3.15.0"
 version = "3.15.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 description = "Backport of pathlib-compatible object wrapper for zip files"
-category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2445,4 +2358,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 = "01dd4e6d62f913d2f5206dcd946dc9804767c4b57b115ef69eb56a73213ae5e4"
+content-hash = "1a742c40e30d5ab7652be705fef2a96dc06a8a8f23e8ff4cc6b7190212a5a3fe"

+ 1 - 0
pyproject.toml

@@ -30,6 +30,7 @@ pywebview = "^4.0.2"
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
 itsdangerous = "^2.1.2"
 itsdangerous = "^2.1.2"
 aiofiles = "^23.1.0"
 aiofiles = "^23.1.0"
+python-magic = "^0.4.27"
 
 
 [tool.poetry.group.dev.dependencies]
 [tool.poetry.group.dev.dependencies]
 icecream = "^2.1.0"
 icecream = "^2.1.0"

+ 3 - 1
tests/screen.py

@@ -14,13 +14,15 @@ from selenium.webdriver.remote.webelement import WebElement
 
 
 from nicegui import globals, ui
 from nicegui import globals, ui
 
 
+from .test_helpers import TEST_DIR
+
 PORT = 3392
 PORT = 3392
 IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-input']
 IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-input']
 
 
 
 
 class Screen:
 class Screen:
     IMPLICIT_WAIT = 4
     IMPLICIT_WAIT = 4
-    SCREENSHOT_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'screenshots')
+    SCREENSHOT_DIR = TEST_DIR / 'screenshots'
 
 
     def __init__(self, selenium: webdriver.Chrome, caplog: pytest.LogCaptureFixture) -> None:
     def __init__(self, selenium: webdriver.Chrome, caplog: pytest.LogCaptureFixture) -> None:
         self.selenium = selenium
         self.selenium = selenium

+ 3 - 0
tests/test_helpers.py

@@ -2,9 +2,12 @@ import contextlib
 import socket
 import socket
 import time
 import time
 import webbrowser
 import webbrowser
+from pathlib import Path
 
 
 from nicegui import helpers
 from nicegui import helpers
 
 
+TEST_DIR = Path(__file__).parent
+
 
 
 def test_is_port_open():
 def test_is_port_open():
     with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
     with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:

+ 83 - 0
tests/test_serving_files.py

@@ -0,0 +1,83 @@
+
+from pathlib import Path
+
+import httpx
+import pytest
+
+from nicegui import app, ui
+
+from .screen import PORT, Screen
+from .test_helpers import TEST_DIR
+
+IMAGE_FILE = Path(TEST_DIR).parent / 'examples' / 'slideshow' / 'slides' / 'slide1.jpg'
+VIDEO_FILE = Path(TEST_DIR) / 'media' / 'test.mp4'
+
+
+@pytest.fixture(autouse=True)
+def provide_media_files():
+    if not VIDEO_FILE.exists():
+        VIDEO_FILE.parent.mkdir(exist_ok=True)
+        url = 'https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4'
+        with httpx.stream('GET', url) as response:
+            with open(VIDEO_FILE, 'wb') as file:
+                for chunk in response.iter_raw():
+                    file.write(chunk)
+
+
+def assert_video_file_streaming(path: str) -> None:
+    with httpx.Client() as http_client:
+        r = http_client.get(
+            path if 'http' in path else f'http://localhost:{PORT}{path}',
+            headers={'Range': 'bytes=0-1000'},
+        )
+        assert r.status_code == 206
+        assert r.headers['Accept-Ranges'] == 'bytes'
+        assert r.headers['Content-Range'].startswith('bytes 0-1000/')
+        assert r.headers['Content-Length'] == '1001'
+        assert r.headers['Content-Type'] == 'video/mp4'
+
+
+def test_media_files_can_be_streamed(screen: Screen):
+    app.add_media_files('/media', Path(TEST_DIR) / 'media')
+
+    screen.open('/')
+    assert_video_file_streaming('/media/test.mp4')
+
+
+def test_adding_single_media_file(screen: Screen):
+    url_path = app.add_media_file(local_file=VIDEO_FILE)
+
+    screen.open('/')
+    assert_video_file_streaming(url_path)
+
+
+def test_adding_single_static_file(screen: Screen):
+    url_path = app.add_static_file(local_file=IMAGE_FILE)
+
+    screen.open('/')
+    with httpx.Client() as http_client:
+        r = http_client.get(f'http://localhost:{PORT}{url_path}')
+        assert r.status_code == 200
+        assert 'max-age=' in r.headers['Cache-Control']
+
+
+def test_auto_serving_file_from_image_source(screen: Screen):
+    ui.image(IMAGE_FILE)
+
+    screen.open('/')
+    img = screen.find_by_tag('img')
+    assert '/_nicegui/auto/static/' in img.get_attribute('src')
+    assert screen.selenium.execute_script("""
+    return arguments[0].complete && 
+        typeof arguments[0].naturalWidth != "undefined" && 
+        arguments[0].naturalWidth > 0
+    """, img), 'image should load successfully'
+
+
+def test_auto_serving_file_from_video_source(screen: Screen):
+    ui.video(VIDEO_FILE)
+
+    screen.open('/')
+    video = screen.find_by_tag('video')
+    assert '/_nicegui/auto/media/' in video.get_attribute('src')
+    assert_video_file_streaming(video.get_attribute('src'))

+ 17 - 0
website/documentation.py

@@ -427,6 +427,23 @@ def create_full() -> None:
         ui.link('Custom FastAPI app', '/examples/fastapi/main.py')
         ui.link('Custom FastAPI app', '/examples/fastapi/main.py')
         ui.link('Authentication', '/examples/authentication/main.py')
         ui.link('Authentication', '/examples/authentication/main.py')
 
 
+    @element_demo(app.add_media_files)
+    def add_media_files_demo():
+        from pathlib import Path
+
+        import requests
+
+        from nicegui import app
+
+        media = Path('media')
+        # media.mkdir(exist_ok=True)
+        # r = requests.get('https://cdn.coverr.co/videos/coverr-cloudy-sky-2765/1080p.mp4')
+        # (media  / 'clouds.mp4').write_bytes(r.content)
+        # app.add_media_files('/my_videos', media)
+        # ui.video('/my_videos/clouds.mp4')
+        # END OF DEMO
+        ui.video('https://cdn.coverr.co/videos/coverr-cloudy-sky-2765/1080p.mp4')
+
     @text_demo('API Responses', '''
     @text_demo('API Responses', '''
         NiceGUI is based on [FastAPI](https://fastapi.tiangolo.com/).
         NiceGUI is based on [FastAPI](https://fastapi.tiangolo.com/).
         This means you can use all of FastAPI's features.
         This means you can use all of FastAPI's features.