From 252489e358a24f24f390fcbc9793747f67df915e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 17 Jul 2023 22:26:18 -0700 Subject: [PATCH 01/84] everything besides component_dispatch_app --- src/py/reactpy/reactpy/backend/asgi.py | 259 ++++ src/py/reactpy/reactpy/backend/mimetypes.py | 1203 +++++++++++++++++++ 2 files changed, 1462 insertions(+) create mode 100644 src/py/reactpy/reactpy/backend/asgi.py create mode 100644 src/py/reactpy/reactpy/backend/mimetypes.py diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py new file mode 100644 index 000000000..b03802783 --- /dev/null +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -0,0 +1,259 @@ +import logging +import mimetypes +import os +import re +from pathlib import Path +from typing import Sequence + +import aiofiles +from asgiref.compatibility import guarantee_single_callable + +from reactpy.backend._common import ( + CLIENT_BUILD_DIR, + traversal_safe_path, + vdom_head_elements_to_html, +) +from reactpy.backend.mimetypes import DEFAULT_MIME_TYPES +from reactpy.config import REACTPY_WEB_MODULES_DIR +from reactpy.core.types import VdomDict + +DEFAULT_STATIC_PATH = f"{os.getcwd()}/static" +DEFAULT_BLOCK_SIZE = 8192 +_logger = logging.getLogger(__name__) + + +class ReactPy: + def __init__( + self, + application=None, + dispatcher_path: str = "^reactpy/stream/([^/]+)/?", + js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", + static_path: str | None = "^reactpy/static/([^/]+)/?", + static_dir: str | None = DEFAULT_STATIC_PATH, + head: Sequence[VdomDict] | VdomDict | str = "", + ) -> None: + self.user_app = guarantee_single_callable(application) + self.dispatch_path = re.compile(dispatcher_path) + self.js_modules_path = re.compile(js_modules_path) + self.static_path = re.compile(static_path) + self.static_dir = static_dir + self.all_paths = re.compile( + "|".join( + path for path in [dispatcher_path, js_modules_path, static_path] if path + ) + ) + self.head = vdom_head_elements_to_html(head) + self._cached_index_html = "" + + async def __call__(self, scope, receive, send) -> None: + """The ASGI callable. This determines whether ReactPy should route the the + request to ourselves or to the user application.""" + + # Determine if ReactPy should handle the request + if not self.user_app or re.match(self.all_paths, scope["path"]): + # Dispatch a Python component + if scope["type"] == "websocket" and re.match( + self.dispatch_path, scope["path"] + ): + await self.component_dispatch_app(scope, receive, send) + return + + # User tried to use an unsupported HTTP method + if scope["method"] not in ("GET", "HEAD"): + await simple_response( + scope, send, status=405, content="Method Not Allowed" + ) + return + + # Serve a JS web module + if scope["type"] == "http" and re.match( + self.js_modules_path, scope["path"] + ): + await self.js_modules_app(scope, receive, send) + return + + # Serve a static file + if scope["type"] == "http" and re.match(self.static_path, scope["path"]): + await self.static_file_app(scope, receive, send) + return + + # Serve index.html + if scope["type"] == "http": + await self.index_html_app(scope, receive, send) + return + + # Serve the user's application + else: + await self.user_app(scope, receive, send) + + async def component_dispatch_app(self, scope, receive, send) -> None: + """The ASGI application for ReactPy Python components.""" + + async def js_modules_app(self, scope, receive, send) -> None: + """The ASGI application for ReactPy web modules.""" + + if not REACTPY_WEB_MODULES_DIR.current: + raise RuntimeError("No web modules directory configured") + + # Get the relative file path from the URL + file_url_path = re.match(self.js_modules_path, scope["path"])[1] + + # Make sure the user hasn't tried to escape the web modules directory + try: + file_path = traversal_safe_path( + REACTPY_WEB_MODULES_DIR.current, + REACTPY_WEB_MODULES_DIR.current, + file_url_path, + ) + except ValueError: + await simple_response(send, 403, "Forbidden") + return + + # Serve the file + await file_response(scope, send, file_path) + + async def static_file_app(self, scope, receive, send) -> None: + """The ASGI application for ReactPy static files.""" + + if self.static_dir is None: + raise RuntimeError("No static directory configured") + + # Get the relative file path from the URL + file_url_path = re.match(self.static_path, scope["path"])[1] + + # Make sure the user hasn't tried to escape the static directory + try: + file_path = traversal_safe_path( + self.static_dir, self.static_dir, file_url_path + ) + except ValueError: + await simple_response(send, 403, "Forbidden") + return + + # Serve the file + await file_response(scope, send, file_path) + + async def index_html_app(self, scope, receive, send) -> None: + """The ASGI application for ReactPy index.html.""" + + # TODO: We want to respect the if-modified-since header, but currently can't + # due to the fact that our HTML is not statically rendered. + file_path = CLIENT_BUILD_DIR / "index.html" + if not self._cached_index_html: + async with aiofiles.open(file_path, "rb") as file_handle: + self._cached_index_html = str(await file_handle.read()).format( + __head__=self.head + ) + + # Head requests don't need a body + if scope["method"] == "HEAD": + await simple_response( + send, + 200, + "", + content_type=b"text/html", + headers=[(b"cache-control", b"no-cache")], + ) + return + + # Send the index.html + await simple_response( + send, + 200, + self._cached_index_html, + content_type=b"text/html", + headers=[(b"cache-control", b"no-cache")], + ) + + +async def simple_response( + send, + code: int, + message: str, + content_type: bytes = b"text/plain", + headers: Sequence = (), +) -> None: + """Send a simple response.""" + + await send( + { + "type": "http.response.start", + "status": code, + "headers": [(b"content-type", content_type, *headers)], + } + ) + await send({"type": "http.response.body", "body": message.encode()}) + + +async def file_response(scope, send, file_path: Path) -> None: + """Send a file in chunks.""" + + # Make sure the file exists + if not os.path.exists(file_path): + await simple_response(send, 404, "File not found.") + return + + # Make sure it's a file + if not os.path.isfile(file_path): + await simple_response(send, 400, "Not a file.") + return + + # Check if the file is already cached by the client + modified_since = await get_val_from_header(scope, b"if-modified-since") + if modified_since and modified_since > os.path.getmtime(file_path): + await simple_response(send, 304, "Not modified.") + return + + # Get the file's MIME type + mime_type = ( + DEFAULT_MIME_TYPES.get(file_path.rsplit(".")[1], None) + or mimetypes.guess_type(file_path, strict=False)[0] + ) + if mime_type is None: + mime_type = "text/plain" + _logger.error(f"Could not determine MIME type for {file_path}.") + + # Send the file in chunks + async with aiofiles.open(file_path, "rb") as file_handle: + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + (b"content-type", mime_type.encode()), + (b"last-modified", str(os.path.getmtime(file_path)).encode()), + ], + } + ) + + # Head requests don't need a body + if scope["method"] == "HEAD": + return + + while True: + chunk = await file_handle.read(DEFAULT_BLOCK_SIZE) + more_body = bool(chunk) + await send( + { + "type": "http.response.body", + "body": chunk, + "more_body": more_body, + } + ) + if not more_body: + break + + +async def get_val_from_header( + scope: dict, key: str, default: str | None = None +) -> str | None: + """Get a value from a scope's headers.""" + + return await anext( + ( + value.decode() + for header_key, value in scope["headers"] + if header_key == key.encode() + ), + default, + ) diff --git a/src/py/reactpy/reactpy/backend/mimetypes.py b/src/py/reactpy/reactpy/backend/mimetypes.py new file mode 100644 index 000000000..9c8a97088 --- /dev/null +++ b/src/py/reactpy/reactpy/backend/mimetypes.py @@ -0,0 +1,1203 @@ +DEFAULT_MIME_TYPES = { + "123": "application/vnd.lotus-1-2-3", + "1km": "application/vnd.1000minds.decision-model+xml", + "3dml": "text/vnd.in3d.3dml", + "3ds": "image/x-3ds", + "3g2": "video/3gpp2", + "3gp": "video/3gpp", + "3gpp": "video/3gpp", + "3mf": "model/3mf", + "7z": "application/x-7z-compressed", + "aab": "application/x-authorware-bin", + "aac": "audio/x-aac", + "aam": "application/x-authorware-map", + "aas": "application/x-authorware-seg", + "abw": "application/x-abiword", + "ac": "application/vnd.nokia.n-gage.ac+xml", + "acc": "application/vnd.americandynamics.acc", + "ace": "application/x-ace-compressed", + "acu": "application/vnd.acucobol", + "acutc": "application/vnd.acucorp", + "adp": "audio/adpcm", + "adts": "audio/aac", + "aep": "application/vnd.audiograph", + "afm": "application/x-font-type1", + "afp": "application/vnd.ibm.modcap", + "age": "application/vnd.age", + "ahead": "application/vnd.ahead.space", + "ai": "application/postscript", + "aif": "audio/x-aiff", + "aifc": "audio/x-aiff", + "aiff": "audio/x-aiff", + "air": "application/vnd.adobe.air-application-installer-package+zip", + "ait": "application/vnd.dvb.ait", + "ami": "application/vnd.amiga.ami", + "aml": "application/automationml-aml+xml", + "amlx": "application/automationml-amlx+zip", + "amr": "audio/amr", + "apk": "application/vnd.android.package-archive", + "apng": "image/apng", + "appcache": "text/cache-manifest", + "appinstaller": "application/appinstaller", + "application": "application/x-ms-application", + "appx": "application/appx", + "appxbundle": "application/appxbundle", + "apr": "application/vnd.lotus-approach", + "arc": "application/x-freearc", + "arj": "application/x-arj", + "asc": "application/pgp-signature", + "asf": "video/x-ms-asf", + "asm": "text/x-asm", + "aso": "application/vnd.accpac.simply.aso", + "asx": "video/x-ms-asf", + "atc": "application/vnd.acucorp", + "atom": "application/atom+xml", + "atomcat": "application/atomcat+xml", + "atomdeleted": "application/atomdeleted+xml", + "atomsvc": "application/atomsvc+xml", + "atx": "application/vnd.antix.game-component", + "au": "audio/basic", + "avci": "image/avci", + "avcs": "image/avcs", + "avi": "video/x-msvideo", + "avif": "image/avif", + "aw": "application/applixware", + "azf": "application/vnd.airzip.filesecure.azf", + "azs": "application/vnd.airzip.filesecure.azs", + "azv": "image/vnd.airzip.accelerator.azv", + "azw": "application/vnd.amazon.ebook", + "b16": "image/vnd.pco.b16", + "bat": "application/x-msdownload", + "bcpio": "application/x-bcpio", + "bdf": "application/x-font-bdf", + "bdm": "application/vnd.syncml.dm+wbxml", + "bdoc": "application/x-bdoc", + "bed": "application/vnd.realvnc.bed", + "bh2": "application/vnd.fujitsu.oasysprs", + "bin": "application/octet-stream", + "blb": "application/x-blorb", + "blorb": "application/x-blorb", + "bmi": "application/vnd.bmi", + "bmml": "application/vnd.balsamiq.bmml+xml", + "bmp": "image/x-ms-bmp", + "book": "application/vnd.framemaker", + "box": "application/vnd.previewsystems.box", + "boz": "application/x-bzip2", + "bpk": "application/octet-stream", + "bsp": "model/vnd.valve.source.compiled-map", + "btf": "image/prs.btif", + "btif": "image/prs.btif", + "buffer": "application/octet-stream", + "bz": "application/x-bzip", + "bz2": "application/x-bzip2", + "c": "text/x-c", + "c11amc": "application/vnd.cluetrust.cartomobile-config", + "c11amz": "application/vnd.cluetrust.cartomobile-config-pkg", + "c4d": "application/vnd.clonk.c4group", + "c4f": "application/vnd.clonk.c4group", + "c4g": "application/vnd.clonk.c4group", + "c4p": "application/vnd.clonk.c4group", + "c4u": "application/vnd.clonk.c4group", + "cab": "application/vnd.ms-cab-compressed", + "caf": "audio/x-caf", + "cap": "application/vnd.tcpdump.pcap", + "car": "application/vnd.curl.car", + "cat": "application/vnd.ms-pki.seccat", + "cb7": "application/x-cbr", + "cba": "application/x-cbr", + "cbr": "application/x-cbr", + "cbt": "application/x-cbr", + "cbz": "application/x-cbr", + "cc": "text/x-c", + "cco": "application/x-cocoa", + "cct": "application/x-director", + "ccxml": "application/ccxml+xml", + "cdbcmsg": "application/vnd.contact.cmsg", + "cdf": "application/x-netcdf", + "cdfx": "application/cdfx+xml", + "cdkey": "application/vnd.mediastation.cdkey", + "cdmia": "application/cdmi-capability", + "cdmic": "application/cdmi-container", + "cdmid": "application/cdmi-domain", + "cdmio": "application/cdmi-object", + "cdmiq": "application/cdmi-queue", + "cdx": "chemical/x-cdx", + "cdxml": "application/vnd.chemdraw+xml", + "cdy": "application/vnd.cinderella", + "cer": "application/pkix-cert", + "cfs": "application/x-cfs-compressed", + "cgm": "image/cgm", + "chat": "application/x-chat", + "chm": "application/vnd.ms-htmlhelp", + "chrt": "application/vnd.kde.kchart", + "cif": "chemical/x-cif", + "cii": "application/vnd.anser-web-certificate-issue-initiation", + "cil": "application/vnd.ms-artgalry", + "cjs": "application/node", + "cla": "application/vnd.claymore", + "class": "application/java-vm", + "cld": "model/vnd.cld", + "clkk": "application/vnd.crick.clicker.keyboard", + "clkp": "application/vnd.crick.clicker.palette", + "clkt": "application/vnd.crick.clicker.template", + "clkw": "application/vnd.crick.clicker.wordbank", + "clkx": "application/vnd.crick.clicker", + "clp": "application/x-msclip", + "cmc": "application/vnd.cosmocaller", + "cmdf": "chemical/x-cmdf", + "cml": "chemical/x-cml", + "cmp": "application/vnd.yellowriver-custom-menu", + "cmx": "image/x-cmx", + "cod": "application/vnd.rim.cod", + "coffee": "text/coffeescript", + "com": "application/x-msdownload", + "conf": "text/plain", + "cpio": "application/x-cpio", + "cpl": "application/cpl+xml", + "cpp": "text/x-c", + "cpt": "application/mac-compactpro", + "crd": "application/x-mscardfile", + "crl": "application/pkix-crl", + "crt": "application/x-x509-ca-cert", + "crx": "application/x-chrome-extension", + "cryptonote": "application/vnd.rig.cryptonote", + "csh": "application/x-csh", + "csl": "application/vnd.citationstyles.style+xml", + "csml": "chemical/x-csml", + "csp": "application/vnd.commonspace", + "css": "text/css", + "cst": "application/x-director", + "csv": "text/csv", + "cu": "application/cu-seeme", + "curl": "text/vnd.curl", + "cwl": "application/cwl", + "cww": "application/prs.cww", + "cxt": "application/x-director", + "cxx": "text/x-c", + "dae": "model/vnd.collada+xml", + "daf": "application/vnd.mobius.daf", + "dart": "application/vnd.dart", + "dataless": "application/vnd.fdsn.seed", + "davmount": "application/davmount+xml", + "dbf": "application/vnd.dbf", + "dbk": "application/docbook+xml", + "dcr": "application/x-director", + "dcurl": "text/vnd.curl.dcurl", + "dd2": "application/vnd.oma.dd2+xml", + "ddd": "application/vnd.fujixerox.ddd", + "ddf": "application/vnd.syncml.dmddf+xml", + "dds": "image/vnd.ms-dds", + "deb": "application/x-debian-package", + "def": "text/plain", + "deploy": "application/octet-stream", + "der": "application/x-x509-ca-cert", + "dfac": "application/vnd.dreamfactory", + "dgc": "application/x-dgc-compressed", + "dib": "image/bmp", + "dic": "text/x-c", + "dir": "application/x-director", + "dis": "application/vnd.mobius.dis", + "disposition-notification": "message/disposition-notification", + "dist": "application/octet-stream", + "distz": "application/octet-stream", + "djv": "image/vnd.djvu", + "djvu": "image/vnd.djvu", + "dll": "application/x-msdownload", + "dmg": "application/x-apple-diskimage", + "dmp": "application/vnd.tcpdump.pcap", + "dms": "application/octet-stream", + "dna": "application/vnd.dna", + "doc": "application/msword", + "docm": "application/vnd.ms-word.document.macroenabled.12", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "dot": "application/msword", + "dotm": "application/vnd.ms-word.template.macroenabled.12", + "dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "dp": "application/vnd.osgi.dp", + "dpg": "application/vnd.dpgraph", + "dpx": "image/dpx", + "dra": "audio/vnd.dra", + "drle": "image/dicom-rle", + "dsc": "text/prs.lines.tag", + "dssc": "application/dssc+der", + "dtb": "application/x-dtbook+xml", + "dtd": "application/xml-dtd", + "dts": "audio/vnd.dts", + "dtshd": "audio/vnd.dts.hd", + "dump": "application/octet-stream", + "dvb": "video/vnd.dvb.file", + "dvi": "application/x-dvi", + "dwd": "application/atsc-dwd+xml", + "dwf": "model/vnd.dwf", + "dwg": "image/vnd.dwg", + "dxf": "image/vnd.dxf", + "dxp": "application/vnd.spotfire.dxp", + "dxr": "application/x-director", + "ear": "application/java-archive", + "ecelp4800": "audio/vnd.nuera.ecelp4800", + "ecelp7470": "audio/vnd.nuera.ecelp7470", + "ecelp9600": "audio/vnd.nuera.ecelp9600", + "ecma": "application/ecmascript", + "edm": "application/vnd.novadigm.edm", + "edx": "application/vnd.novadigm.edx", + "efif": "application/vnd.picsel", + "ei6": "application/vnd.pg.osasli", + "elc": "application/octet-stream", + "emf": "image/emf", + "eml": "message/rfc822", + "emma": "application/emma+xml", + "emotionml": "application/emotionml+xml", + "emz": "application/x-msmetafile", + "eol": "audio/vnd.digital-winds", + "eot": "application/vnd.ms-fontobject", + "eps": "application/postscript", + "epub": "application/epub+zip", + "es3": "application/vnd.eszigno3+xml", + "esa": "application/vnd.osgi.subsystem", + "esf": "application/vnd.epson.esf", + "et3": "application/vnd.eszigno3+xml", + "etx": "text/x-setext", + "eva": "application/x-eva", + "evy": "application/x-envoy", + "exe": "application/x-msdownload", + "exi": "application/exi", + "exp": "application/express", + "exr": "image/aces", + "ext": "application/vnd.novadigm.ext", + "ez": "application/andrew-inset", + "ez2": "application/vnd.ezpix-album", + "ez3": "application/vnd.ezpix-package", + "f": "text/x-fortran", + "f4v": "video/x-f4v", + "f77": "text/x-fortran", + "f90": "text/x-fortran", + "fbs": "image/vnd.fastbidsheet", + "fcdt": "application/vnd.adobe.formscentral.fcdt", + "fcs": "application/vnd.isac.fcs", + "fdf": "application/vnd.fdf", + "fdt": "application/fdt+xml", + "fe_launch": "application/vnd.denovo.fcselayout-link", + "fg5": "application/vnd.fujitsu.oasysgp", + "fgd": "application/x-director", + "fh": "image/x-freehand", + "fh4": "image/x-freehand", + "fh5": "image/x-freehand", + "fh7": "image/x-freehand", + "fhc": "image/x-freehand", + "fig": "application/x-xfig", + "fits": "image/fits", + "flac": "audio/x-flac", + "fli": "video/x-fli", + "flo": "application/vnd.micrografx.flo", + "flv": "video/x-flv", + "flw": "application/vnd.kde.kivio", + "flx": "text/vnd.fmi.flexstor", + "fly": "text/vnd.fly", + "fm": "application/vnd.framemaker", + "fnc": "application/vnd.frogans.fnc", + "fo": "application/vnd.software602.filler.form+xml", + "for": "text/x-fortran", + "fpx": "image/vnd.fpx", + "frame": "application/vnd.framemaker", + "fsc": "application/vnd.fsc.weblaunch", + "fst": "image/vnd.fst", + "ftc": "application/vnd.fluxtime.clip", + "fti": "application/vnd.anser-web-funds-transfer-initiation", + "fvt": "video/vnd.fvt", + "fxp": "application/vnd.adobe.fxp", + "fxpl": "application/vnd.adobe.fxp", + "fzs": "application/vnd.fuzzysheet", + "g2w": "application/vnd.geoplan", + "g3": "image/g3fax", + "g3w": "application/vnd.geospace", + "gac": "application/vnd.groove-account", + "gam": "application/x-tads", + "gbr": "application/rpki-ghostbusters", + "gca": "application/x-gca-compressed", + "gdl": "model/vnd.gdl", + "gdoc": "application/vnd.google-apps.document", + "ged": "text/vnd.familysearch.gedcom", + "geo": "application/vnd.dynageo", + "geojson": "application/geo+json", + "gex": "application/vnd.geometry-explorer", + "ggb": "application/vnd.geogebra.file", + "ggt": "application/vnd.geogebra.tool", + "ghf": "application/vnd.groove-help", + "gif": "image/gif", + "gim": "application/vnd.groove-identity-message", + "glb": "model/gltf-binary", + "gltf": "model/gltf+json", + "gml": "application/gml+xml", + "gmx": "application/vnd.gmx", + "gnumeric": "application/x-gnumeric", + "gph": "application/vnd.flographit", + "gpx": "application/gpx+xml", + "gqf": "application/vnd.grafeq", + "gqs": "application/vnd.grafeq", + "gram": "application/srgs", + "gramps": "application/x-gramps-xml", + "gre": "application/vnd.geometry-explorer", + "grv": "application/vnd.groove-injector", + "grxml": "application/srgs+xml", + "gsf": "application/x-font-ghostscript", + "gsheet": "application/vnd.google-apps.spreadsheet", + "gslides": "application/vnd.google-apps.presentation", + "gtar": "application/x-gtar", + "gtm": "application/vnd.groove-tool-message", + "gtw": "model/vnd.gtw", + "gv": "text/vnd.graphviz", + "gxf": "application/gxf", + "gxt": "application/vnd.geonext", + "gz": "application/gzip", + "h": "text/x-c", + "h261": "video/h261", + "h263": "video/h263", + "h264": "video/h264", + "hal": "application/vnd.hal+xml", + "hbci": "application/vnd.hbci", + "hbs": "text/x-handlebars-template", + "hdd": "application/x-virtualbox-hdd", + "hdf": "application/x-hdf", + "heic": "image/heic", + "heics": "image/heic-sequence", + "heif": "image/heif", + "heifs": "image/heif-sequence", + "hej2": "image/hej2k", + "held": "application/atsc-held+xml", + "hh": "text/x-c", + "hjson": "application/hjson", + "hlp": "application/winhlp", + "hpgl": "application/vnd.hp-hpgl", + "hpid": "application/vnd.hp-hpid", + "hps": "application/vnd.hp-hps", + "hqx": "application/mac-binhex40", + "hsj2": "image/hsj2", + "htc": "text/x-component", + "htke": "application/vnd.kenameaapp", + "htm": "text/html", + "html": "text/html", + "hvd": "application/vnd.yamaha.hv-dic", + "hvp": "application/vnd.yamaha.hv-voice", + "hvs": "application/vnd.yamaha.hv-script", + "i2g": "application/vnd.intergeo", + "icc": "application/vnd.iccprofile", + "ice": "x-conference/x-cooltalk", + "icm": "application/vnd.iccprofile", + "ico": "image/x-icon", + "ics": "text/calendar", + "ief": "image/ief", + "ifb": "text/calendar", + "ifm": "application/vnd.shana.informed.formdata", + "iges": "model/iges", + "igl": "application/vnd.igloader", + "igm": "application/vnd.insors.igm", + "igs": "model/iges", + "igx": "application/vnd.micrografx.igx", + "iif": "application/vnd.shana.informed.interchange", + "img": "application/octet-stream", + "imp": "application/vnd.accpac.simply.imp", + "ims": "application/vnd.ms-ims", + "in": "text/plain", + "ini": "text/plain", + "ink": "application/inkml+xml", + "inkml": "application/inkml+xml", + "install": "application/x-install-instructions", + "iota": "application/vnd.astraea-software.iota", + "ipfix": "application/ipfix", + "ipk": "application/vnd.shana.informed.package", + "irm": "application/vnd.ibm.rights-management", + "irp": "application/vnd.irepository.package+xml", + "iso": "application/x-iso9660-image", + "itp": "application/vnd.shana.informed.formtemplate", + "its": "application/its+xml", + "ivp": "application/vnd.immervision-ivp", + "ivu": "application/vnd.immervision-ivu", + "jad": "text/vnd.sun.j2me.app-descriptor", + "jade": "text/jade", + "jam": "application/vnd.jam", + "jar": "application/java-archive", + "jardiff": "application/x-java-archive-diff", + "java": "text/x-java-source", + "jhc": "image/jphc", + "jisp": "application/vnd.jisp", + "jls": "image/jls", + "jlt": "application/vnd.hp-jlyt", + "jng": "image/x-jng", + "jnlp": "application/x-java-jnlp-file", + "joda": "application/vnd.joost.joda-archive", + "jp2": "image/jp2", + "jpe": "image/jpeg", + "jpeg": "image/jpeg", + "jpf": "image/jpx", + "jpg": "image/jpeg", + "jpg2": "image/jp2", + "jpgm": "video/jpm", + "jpgv": "video/jpeg", + "jph": "image/jph", + "jpm": "video/jpm", + "jpx": "image/jpx", + "js": "text/javascript", + "json": "application/json", + "json5": "application/json5", + "jsonld": "application/ld+json", + "jsonml": "application/jsonml+json", + "jsx": "text/jsx", + "jt": "model/jt", + "jxr": "image/jxr", + "jxra": "image/jxra", + "jxrs": "image/jxrs", + "jxs": "image/jxs", + "jxsc": "image/jxsc", + "jxsi": "image/jxsi", + "jxss": "image/jxss", + "kar": "audio/midi", + "karbon": "application/vnd.kde.karbon", + "kdbx": "application/x-keepass2", + "key": "application/x-iwork-keynote-sffkey", + "kfo": "application/vnd.kde.kformula", + "kia": "application/vnd.kidspiration", + "kml": "application/vnd.google-earth.kml+xml", + "kmz": "application/vnd.google-earth.kmz", + "kne": "application/vnd.kinar", + "knp": "application/vnd.kinar", + "kon": "application/vnd.kde.kontour", + "kpr": "application/vnd.kde.kpresenter", + "kpt": "application/vnd.kde.kpresenter", + "kpxx": "application/vnd.ds-keypoint", + "ksp": "application/vnd.kde.kspread", + "ktr": "application/vnd.kahootz", + "ktx": "image/ktx", + "ktx2": "image/ktx2", + "ktz": "application/vnd.kahootz", + "kwd": "application/vnd.kde.kword", + "kwt": "application/vnd.kde.kword", + "lasxml": "application/vnd.las.las+xml", + "latex": "application/x-latex", + "lbd": "application/vnd.llamagraphics.life-balance.desktop", + "lbe": "application/vnd.llamagraphics.life-balance.exchange+xml", + "les": "application/vnd.hhe.lesson-player", + "less": "text/less", + "lgr": "application/lgr+xml", + "lha": "application/x-lzh-compressed", + "link66": "application/vnd.route66.link66+xml", + "list": "text/plain", + "list3820": "application/vnd.ibm.modcap", + "listafp": "application/vnd.ibm.modcap", + "litcoffee": "text/coffeescript", + "lnk": "application/x-ms-shortcut", + "log": "text/plain", + "lostxml": "application/lost+xml", + "lrf": "application/octet-stream", + "lrm": "application/vnd.ms-lrm", + "ltf": "application/vnd.frogans.ltf", + "lua": "text/x-lua", + "luac": "application/x-lua-bytecode", + "lvp": "audio/vnd.lucent.voice", + "lwp": "application/vnd.lotus-wordpro", + "lzh": "application/x-lzh-compressed", + "m13": "application/x-msmediaview", + "m14": "application/x-msmediaview", + "m1v": "video/mpeg", + "m21": "application/mp21", + "m2a": "audio/mpeg", + "m2v": "video/mpeg", + "m3a": "audio/mpeg", + "m3u": "audio/x-mpegurl", + "m3u8": "application/vnd.apple.mpegurl", + "m4a": "audio/x-m4a", + "m4p": "application/mp4", + "m4s": "video/iso.segment", + "m4u": "video/vnd.mpegurl", + "m4v": "video/x-m4v", + "ma": "application/mathematica", + "mads": "application/mads+xml", + "maei": "application/mmt-aei+xml", + "mag": "application/vnd.ecowin.chart", + "maker": "application/vnd.framemaker", + "man": "text/troff", + "manifest": "text/cache-manifest", + "map": "application/json", + "mar": "application/octet-stream", + "markdown": "text/markdown", + "mathml": "application/mathml+xml", + "mb": "application/mathematica", + "mbk": "application/vnd.mobius.mbk", + "mbox": "application/mbox", + "mc1": "application/vnd.medcalcdata", + "mcd": "application/vnd.mcd", + "mcurl": "text/vnd.curl.mcurl", + "md": "text/markdown", + "mdb": "application/x-msaccess", + "mdi": "image/vnd.ms-modi", + "mdx": "text/mdx", + "me": "text/troff", + "mesh": "model/mesh", + "meta4": "application/metalink4+xml", + "metalink": "application/metalink+xml", + "mets": "application/mets+xml", + "mfm": "application/vnd.mfmp", + "mft": "application/rpki-manifest", + "mgp": "application/vnd.osgeo.mapguide.package", + "mgz": "application/vnd.proteus.magazine", + "mid": "audio/midi", + "midi": "audio/midi", + "mie": "application/x-mie", + "mif": "application/vnd.mif", + "mime": "message/rfc822", + "mj2": "video/mj2", + "mjp2": "video/mj2", + "mjs": "text/javascript", + "mk3d": "video/x-matroska", + "mka": "audio/x-matroska", + "mkd": "text/x-markdown", + "mks": "video/x-matroska", + "mkv": "video/x-matroska", + "mlp": "application/vnd.dolby.mlp", + "mmd": "application/vnd.chipnuts.karaoke-mmd", + "mmf": "application/vnd.smaf", + "mml": "text/mathml", + "mmr": "image/vnd.fujixerox.edmics-mmr", + "mng": "video/x-mng", + "mny": "application/x-msmoney", + "mobi": "application/x-mobipocket-ebook", + "mods": "application/mods+xml", + "mov": "video/quicktime", + "movie": "video/x-sgi-movie", + "mp2": "audio/mpeg", + "mp21": "application/mp21", + "mp2a": "audio/mpeg", + "mp3": "audio/mpeg", + "mp4": "video/mp4", + "mp4a": "audio/mp4", + "mp4s": "application/mp4", + "mp4v": "video/mp4", + "mpc": "application/vnd.mophun.certificate", + "mpd": "application/dash+xml", + "mpe": "video/mpeg", + "mpeg": "video/mpeg", + "mpf": "application/media-policy-dataset+xml", + "mpg": "video/mpeg", + "mpg4": "video/mp4", + "mpga": "audio/mpeg", + "mpkg": "application/vnd.apple.installer+xml", + "mpm": "application/vnd.blueice.multipass", + "mpn": "application/vnd.mophun.application", + "mpp": "application/vnd.ms-project", + "mpt": "application/vnd.ms-project", + "mpy": "application/vnd.ibm.minipay", + "mqy": "application/vnd.mobius.mqy", + "mrc": "application/marc", + "mrcx": "application/marcxml+xml", + "ms": "text/troff", + "mscml": "application/mediaservercontrol+xml", + "mseed": "application/vnd.fdsn.mseed", + "mseq": "application/vnd.mseq", + "msf": "application/vnd.epson.msf", + "msg": "application/vnd.ms-outlook", + "msh": "model/mesh", + "msi": "application/x-msdownload", + "msix": "application/msix", + "msixbundle": "application/msixbundle", + "msl": "application/vnd.mobius.msl", + "msm": "application/octet-stream", + "msp": "application/octet-stream", + "msty": "application/vnd.muvee.style", + "mtl": "model/mtl", + "mts": "model/vnd.mts", + "mus": "application/vnd.musician", + "musd": "application/mmt-usd+xml", + "musicxml": "application/vnd.recordare.musicxml+xml", + "mvb": "application/x-msmediaview", + "mvt": "application/vnd.mapbox-vector-tile", + "mwf": "application/vnd.mfer", + "mxf": "application/mxf", + "mxl": "application/vnd.recordare.musicxml", + "mxmf": "audio/mobile-xmf", + "mxml": "application/xv+xml", + "mxs": "application/vnd.triscape.mxs", + "mxu": "video/vnd.mpegurl", + "n-gage": "application/vnd.nokia.n-gage.symbian.install", + "n3": "text/n3", + "nb": "application/mathematica", + "nbp": "application/vnd.wolfram.player", + "nc": "application/x-netcdf", + "ncx": "application/x-dtbncx+xml", + "nfo": "text/x-nfo", + "ngdat": "application/vnd.nokia.n-gage.data", + "nitf": "application/vnd.nitf", + "nlu": "application/vnd.neurolanguage.nlu", + "nml": "application/vnd.enliven", + "nnd": "application/vnd.noblenet-directory", + "nns": "application/vnd.noblenet-sealer", + "nnw": "application/vnd.noblenet-web", + "npx": "image/vnd.net-fpx", + "nq": "application/n-quads", + "nsc": "application/x-conference", + "nsf": "application/vnd.lotus-notes", + "nt": "application/n-triples", + "ntf": "application/vnd.nitf", + "numbers": "application/x-iwork-numbers-sffnumbers", + "nzb": "application/x-nzb", + "oa2": "application/vnd.fujitsu.oasys2", + "oa3": "application/vnd.fujitsu.oasys3", + "oas": "application/vnd.fujitsu.oasys", + "obd": "application/x-msbinder", + "obgx": "application/vnd.openblox.game+xml", + "obj": "model/obj", + "oda": "application/oda", + "odb": "application/vnd.oasis.opendocument.database", + "odc": "application/vnd.oasis.opendocument.chart", + "odf": "application/vnd.oasis.opendocument.formula", + "odft": "application/vnd.oasis.opendocument.formula-template", + "odg": "application/vnd.oasis.opendocument.graphics", + "odi": "application/vnd.oasis.opendocument.image", + "odm": "application/vnd.oasis.opendocument.text-master", + "odp": "application/vnd.oasis.opendocument.presentation", + "ods": "application/vnd.oasis.opendocument.spreadsheet", + "odt": "application/vnd.oasis.opendocument.text", + "oga": "audio/ogg", + "ogex": "model/vnd.opengex", + "ogg": "audio/ogg", + "ogv": "video/ogg", + "ogx": "application/ogg", + "omdoc": "application/omdoc+xml", + "onepkg": "application/onenote", + "onetmp": "application/onenote", + "onetoc": "application/onenote", + "onetoc2": "application/onenote", + "opf": "application/oebps-package+xml", + "opml": "text/x-opml", + "oprc": "application/vnd.palm", + "opus": "audio/ogg", + "org": "text/x-org", + "osf": "application/vnd.yamaha.openscoreformat", + "osfpvg": "application/vnd.yamaha.openscoreformat.osfpvg+xml", + "osm": "application/vnd.openstreetmap.data+xml", + "otc": "application/vnd.oasis.opendocument.chart-template", + "otf": "font/otf", + "otg": "application/vnd.oasis.opendocument.graphics-template", + "oth": "application/vnd.oasis.opendocument.text-web", + "oti": "application/vnd.oasis.opendocument.image-template", + "otp": "application/vnd.oasis.opendocument.presentation-template", + "ots": "application/vnd.oasis.opendocument.spreadsheet-template", + "ott": "application/vnd.oasis.opendocument.text-template", + "ova": "application/x-virtualbox-ova", + "ovf": "application/x-virtualbox-ovf", + "owl": "application/rdf+xml", + "oxps": "application/oxps", + "oxt": "application/vnd.openofficeorg.extension", + "p": "text/x-pascal", + "p10": "application/pkcs10", + "p12": "application/x-pkcs12", + "p7b": "application/x-pkcs7-certificates", + "p7c": "application/pkcs7-mime", + "p7m": "application/pkcs7-mime", + "p7r": "application/x-pkcs7-certreqresp", + "p7s": "application/pkcs7-signature", + "p8": "application/pkcs8", + "pac": "application/x-ns-proxy-autoconfig", + "pages": "application/x-iwork-pages-sffpages", + "pas": "text/x-pascal", + "paw": "application/vnd.pawaafile", + "pbd": "application/vnd.powerbuilder6", + "pbm": "image/x-portable-bitmap", + "pcap": "application/vnd.tcpdump.pcap", + "pcf": "application/x-font-pcf", + "pcl": "application/vnd.hp-pcl", + "pclxl": "application/vnd.hp-pclxl", + "pct": "image/x-pict", + "pcurl": "application/vnd.curl.pcurl", + "pcx": "image/x-pcx", + "pdb": "application/x-pilot", + "pde": "text/x-processing", + "pdf": "application/pdf", + "pem": "application/x-x509-ca-cert", + "pfa": "application/x-font-type1", + "pfb": "application/x-font-type1", + "pfm": "application/x-font-type1", + "pfr": "application/font-tdpfr", + "pfx": "application/x-pkcs12", + "pgm": "image/x-portable-graymap", + "pgn": "application/x-chess-pgn", + "pgp": "application/pgp-encrypted", + "php": "application/x-httpd-php", + "pic": "image/x-pict", + "pkg": "application/octet-stream", + "pki": "application/pkixcmp", + "pkipath": "application/pkix-pkipath", + "pkpass": "application/vnd.apple.pkpass", + "pl": "application/x-perl", + "plb": "application/vnd.3gpp.pic-bw-large", + "plc": "application/vnd.mobius.plc", + "plf": "application/vnd.pocketlearn", + "pls": "application/pls+xml", + "pm": "application/x-perl", + "pml": "application/vnd.ctc-posml", + "png": "image/png", + "pnm": "image/x-portable-anymap", + "portpkg": "application/vnd.macports.portpkg", + "pot": "application/vnd.ms-powerpoint", + "potm": "application/vnd.ms-powerpoint.template.macroenabled.12", + "potx": "application/vnd.openxmlformats-officedocument.presentationml.template", + "ppam": "application/vnd.ms-powerpoint.addin.macroenabled.12", + "ppd": "application/vnd.cups-ppd", + "ppm": "image/x-portable-pixmap", + "pps": "application/vnd.ms-powerpoint", + "ppsm": "application/vnd.ms-powerpoint.slideshow.macroenabled.12", + "ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "ppt": "application/vnd.ms-powerpoint", + "pptm": "application/vnd.ms-powerpoint.presentation.macroenabled.12", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "pqa": "application/vnd.palm", + "prc": "model/prc", + "pre": "application/vnd.lotus-freelance", + "prf": "application/pics-rules", + "provx": "application/provenance+xml", + "ps": "application/postscript", + "psb": "application/vnd.3gpp.pic-bw-small", + "psd": "image/vnd.adobe.photoshop", + "psf": "application/x-font-linux-psf", + "pskcxml": "application/pskc+xml", + "pti": "image/prs.pti", + "ptid": "application/vnd.pvi.ptid1", + "pub": "application/x-mspublisher", + "pvb": "application/vnd.3gpp.pic-bw-var", + "pwn": "application/vnd.3m.post-it-notes", + "pya": "audio/vnd.ms-playready.media.pya", + "pyo": "model/vnd.pytha.pyox", + "pyox": "model/vnd.pytha.pyox", + "pyv": "video/vnd.ms-playready.media.pyv", + "qam": "application/vnd.epson.quickanime", + "qbo": "application/vnd.intu.qbo", + "qfx": "application/vnd.intu.qfx", + "qps": "application/vnd.publishare-delta-tree", + "qt": "video/quicktime", + "qwd": "application/vnd.quark.quarkxpress", + "qwt": "application/vnd.quark.quarkxpress", + "qxb": "application/vnd.quark.quarkxpress", + "qxd": "application/vnd.quark.quarkxpress", + "qxl": "application/vnd.quark.quarkxpress", + "qxt": "application/vnd.quark.quarkxpress", + "ra": "audio/x-realaudio", + "ram": "audio/x-pn-realaudio", + "raml": "application/raml+yaml", + "rapd": "application/route-apd+xml", + "rar": "application/x-rar-compressed", + "ras": "image/x-cmu-raster", + "rcprofile": "application/vnd.ipunplugged.rcprofile", + "rdf": "application/rdf+xml", + "rdz": "application/vnd.data-vision.rdz", + "relo": "application/p2p-overlay+xml", + "rep": "application/vnd.businessobjects", + "res": "application/x-dtbresource+xml", + "rgb": "image/x-rgb", + "rif": "application/reginfo+xml", + "rip": "audio/vnd.rip", + "ris": "application/x-research-info-systems", + "rl": "application/resource-lists+xml", + "rlc": "image/vnd.fujixerox.edmics-rlc", + "rld": "application/resource-lists-diff+xml", + "rm": "application/vnd.rn-realmedia", + "rmi": "audio/midi", + "rmp": "audio/x-pn-realaudio-plugin", + "rms": "application/vnd.jcp.javame.midlet-rms", + "rmvb": "application/vnd.rn-realmedia-vbr", + "rnc": "application/relax-ng-compact-syntax", + "rng": "application/xml", + "roa": "application/rpki-roa", + "roff": "text/troff", + "rp9": "application/vnd.cloanto.rp9", + "rpm": "application/x-redhat-package-manager", + "rpss": "application/vnd.nokia.radio-presets", + "rpst": "application/vnd.nokia.radio-preset", + "rq": "application/sparql-query", + "rs": "application/rls-services+xml", + "rsat": "application/atsc-rsat+xml", + "rsd": "application/rsd+xml", + "rsheet": "application/urc-ressheet+xml", + "rss": "application/rss+xml", + "rtf": "text/rtf", + "rtx": "text/richtext", + "run": "application/x-makeself", + "rusd": "application/route-usd+xml", + "s": "text/x-asm", + "s3m": "audio/s3m", + "saf": "application/vnd.yamaha.smaf-audio", + "sass": "text/x-sass", + "sbml": "application/sbml+xml", + "sc": "application/vnd.ibm.secure-container", + "scd": "application/x-msschedule", + "scm": "application/vnd.lotus-screencam", + "scq": "application/scvp-cv-request", + "scs": "application/scvp-cv-response", + "scss": "text/x-scss", + "scurl": "text/vnd.curl.scurl", + "sda": "application/vnd.stardivision.draw", + "sdc": "application/vnd.stardivision.calc", + "sdd": "application/vnd.stardivision.impress", + "sdkd": "application/vnd.solent.sdkm+xml", + "sdkm": "application/vnd.solent.sdkm+xml", + "sdp": "application/sdp", + "sdw": "application/vnd.stardivision.writer", + "sea": "application/x-sea", + "see": "application/vnd.seemail", + "seed": "application/vnd.fdsn.seed", + "sema": "application/vnd.sema", + "semd": "application/vnd.semd", + "semf": "application/vnd.semf", + "senmlx": "application/senml+xml", + "sensmlx": "application/sensml+xml", + "ser": "application/java-serialized-object", + "setpay": "application/set-payment-initiation", + "setreg": "application/set-registration-initiation", + "sfd-hdstx": "application/vnd.hydrostatix.sof-data", + "sfs": "application/vnd.spotfire.sfs", + "sfv": "text/x-sfv", + "sgi": "image/sgi", + "sgl": "application/vnd.stardivision.writer-global", + "sgm": "text/sgml", + "sgml": "text/sgml", + "sh": "application/x-sh", + "shar": "application/x-shar", + "shex": "text/shex", + "shf": "application/shf+xml", + "shtml": "text/html", + "sid": "image/x-mrsid-image", + "sieve": "application/sieve", + "sig": "application/pgp-signature", + "sil": "audio/silk", + "silo": "model/mesh", + "sis": "application/vnd.symbian.install", + "sisx": "application/vnd.symbian.install", + "sit": "application/x-stuffit", + "sitx": "application/x-stuffitx", + "siv": "application/sieve", + "skd": "application/vnd.koan", + "skm": "application/vnd.koan", + "skp": "application/vnd.koan", + "skt": "application/vnd.koan", + "sldm": "application/vnd.ms-powerpoint.slide.macroenabled.12", + "sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide", + "slim": "text/slim", + "slm": "text/slim", + "sls": "application/route-s-tsid+xml", + "slt": "application/vnd.epson.salt", + "sm": "application/vnd.stepmania.stepchart", + "smf": "application/vnd.stardivision.math", + "smi": "application/smil+xml", + "smil": "application/smil+xml", + "smv": "video/x-smv", + "smzip": "application/vnd.stepmania.package", + "snd": "audio/basic", + "snf": "application/x-font-snf", + "so": "application/octet-stream", + "spc": "application/x-pkcs7-certificates", + "spdx": "text/spdx", + "spf": "application/vnd.yamaha.smaf-phrase", + "spl": "application/x-futuresplash", + "spot": "text/vnd.in3d.spot", + "spp": "application/scvp-vp-response", + "spq": "application/scvp-vp-request", + "spx": "audio/ogg", + "sql": "application/x-sql", + "src": "application/x-wais-source", + "srt": "application/x-subrip", + "sru": "application/sru+xml", + "srx": "application/sparql-results+xml", + "ssdl": "application/ssdl+xml", + "sse": "application/vnd.kodak-descriptor", + "ssf": "application/vnd.epson.ssf", + "ssml": "application/ssml+xml", + "st": "application/vnd.sailingtracker.track", + "stc": "application/vnd.sun.xml.calc.template", + "std": "application/vnd.sun.xml.draw.template", + "stf": "application/vnd.wt.stf", + "sti": "application/vnd.sun.xml.impress.template", + "stk": "application/hyperstudio", + "stl": "model/stl", + "stpx": "model/step+xml", + "stpxz": "model/step-xml+zip", + "stpz": "model/step+zip", + "str": "application/vnd.pg.format", + "stw": "application/vnd.sun.xml.writer.template", + "styl": "text/stylus", + "stylus": "text/stylus", + "sub": "text/vnd.dvb.subtitle", + "sus": "application/vnd.sus-calendar", + "susp": "application/vnd.sus-calendar", + "sv4cpio": "application/x-sv4cpio", + "sv4crc": "application/x-sv4crc", + "svc": "application/vnd.dvb.service", + "svd": "application/vnd.svd", + "svg": "image/svg+xml", + "svgz": "image/svg+xml", + "swa": "application/x-director", + "swf": "application/x-shockwave-flash", + "swi": "application/vnd.aristanetworks.swi", + "swidtag": "application/swid+xml", + "sxc": "application/vnd.sun.xml.calc", + "sxd": "application/vnd.sun.xml.draw", + "sxg": "application/vnd.sun.xml.writer.global", + "sxi": "application/vnd.sun.xml.impress", + "sxm": "application/vnd.sun.xml.math", + "sxw": "application/vnd.sun.xml.writer", + "t": "text/troff", + "t3": "application/x-t3vm-image", + "t38": "image/t38", + "taglet": "application/vnd.mynfc", + "tao": "application/vnd.tao.intent-module-archive", + "tap": "image/vnd.tencent.tap", + "tar": "application/x-tar", + "tcap": "application/vnd.3gpp2.tcap", + "tcl": "application/x-tcl", + "td": "application/urc-targetdesc+xml", + "teacher": "application/vnd.smart.teacher", + "tei": "application/tei+xml", + "teicorpus": "application/tei+xml", + "tex": "application/x-tex", + "texi": "application/x-texinfo", + "texinfo": "application/x-texinfo", + "text": "text/plain", + "tfi": "application/thraud+xml", + "tfm": "application/x-tex-tfm", + "tfx": "image/tiff-fx", + "tga": "image/x-tga", + "thmx": "application/vnd.ms-officetheme", + "tif": "image/tiff", + "tiff": "image/tiff", + "tk": "application/x-tcl", + "tmo": "application/vnd.tmobile-livetv", + "toml": "application/toml", + "torrent": "application/x-bittorrent", + "tpl": "application/vnd.groove-tool-template", + "tpt": "application/vnd.trid.tpt", + "tr": "text/troff", + "tra": "application/vnd.trueapp", + "trig": "application/trig", + "trm": "application/x-msterminal", + "ts": "video/mp2t", + "tsd": "application/timestamped-data", + "tsv": "text/tab-separated-values", + "ttc": "font/collection", + "ttf": "font/ttf", + "ttl": "text/turtle", + "ttml": "application/ttml+xml", + "twd": "application/vnd.simtech-mindmapper", + "twds": "application/vnd.simtech-mindmapper", + "txd": "application/vnd.genomatix.tuxedo", + "txf": "application/vnd.mobius.txf", + "txt": "text/plain", + "u32": "application/x-authorware-bin", + "u3d": "model/u3d", + "u8dsn": "message/global-delivery-status", + "u8hdr": "message/global-headers", + "u8mdn": "message/global-disposition-notification", + "u8msg": "message/global", + "ubj": "application/ubjson", + "udeb": "application/x-debian-package", + "ufd": "application/vnd.ufdl", + "ufdl": "application/vnd.ufdl", + "ulx": "application/x-glulx", + "umj": "application/vnd.umajin", + "unityweb": "application/vnd.unity", + "uo": "application/vnd.uoml+xml", + "uoml": "application/vnd.uoml+xml", + "uri": "text/uri-list", + "uris": "text/uri-list", + "urls": "text/uri-list", + "usda": "model/vnd.usda", + "usdz": "model/vnd.usdz+zip", + "ustar": "application/x-ustar", + "utz": "application/vnd.uiq.theme", + "uu": "text/x-uuencode", + "uva": "audio/vnd.dece.audio", + "uvd": "application/vnd.dece.data", + "uvf": "application/vnd.dece.data", + "uvg": "image/vnd.dece.graphic", + "uvh": "video/vnd.dece.hd", + "uvi": "image/vnd.dece.graphic", + "uvm": "video/vnd.dece.mobile", + "uvp": "video/vnd.dece.pd", + "uvs": "video/vnd.dece.sd", + "uvt": "application/vnd.dece.ttml+xml", + "uvu": "video/vnd.uvvu.mp4", + "uvv": "video/vnd.dece.video", + "uvva": "audio/vnd.dece.audio", + "uvvd": "application/vnd.dece.data", + "uvvf": "application/vnd.dece.data", + "uvvg": "image/vnd.dece.graphic", + "uvvh": "video/vnd.dece.hd", + "uvvi": "image/vnd.dece.graphic", + "uvvm": "video/vnd.dece.mobile", + "uvvp": "video/vnd.dece.pd", + "uvvs": "video/vnd.dece.sd", + "uvvt": "application/vnd.dece.ttml+xml", + "uvvu": "video/vnd.uvvu.mp4", + "uvvv": "video/vnd.dece.video", + "uvvx": "application/vnd.dece.unspecified", + "uvvz": "application/vnd.dece.zip", + "uvx": "application/vnd.dece.unspecified", + "uvz": "application/vnd.dece.zip", + "vbox": "application/x-virtualbox-vbox", + "vbox-extpack": "application/x-virtualbox-vbox-extpack", + "vcard": "text/vcard", + "vcd": "application/x-cdlink", + "vcf": "text/x-vcard", + "vcg": "application/vnd.groove-vcard", + "vcs": "text/x-vcalendar", + "vcx": "application/vnd.vcx", + "vdi": "application/x-virtualbox-vdi", + "vds": "model/vnd.sap.vds", + "vhd": "application/x-virtualbox-vhd", + "vis": "application/vnd.visionary", + "viv": "video/vnd.vivo", + "vmdk": "application/x-virtualbox-vmdk", + "vob": "video/x-ms-vob", + "vor": "application/vnd.stardivision.writer", + "vox": "application/x-authorware-bin", + "vrml": "model/vrml", + "vsd": "application/vnd.visio", + "vsf": "application/vnd.vsf", + "vss": "application/vnd.visio", + "vst": "application/vnd.visio", + "vsw": "application/vnd.visio", + "vtf": "image/vnd.valve.source.texture", + "vtt": "text/vtt", + "vtu": "model/vnd.vtu", + "vxml": "application/voicexml+xml", + "w3d": "application/x-director", + "wad": "application/x-doom", + "wadl": "application/vnd.sun.wadl+xml", + "war": "application/java-archive", + "wasm": "application/wasm", + "wav": "audio/x-wav", + "wax": "audio/x-ms-wax", + "wbmp": "image/vnd.wap.wbmp", + "wbs": "application/vnd.criticaltools.wbs+xml", + "wbxml": "application/vnd.wap.wbxml", + "wcm": "application/vnd.ms-works", + "wdb": "application/vnd.ms-works", + "wdp": "image/vnd.ms-photo", + "weba": "audio/webm", + "webapp": "application/x-web-app-manifest+json", + "webm": "video/webm", + "webmanifest": "application/manifest+json", + "webp": "image/webp", + "wg": "application/vnd.pmi.widget", + "wgsl": "text/wgsl", + "wgt": "application/widget", + "wif": "application/watcherinfo+xml", + "wks": "application/vnd.ms-works", + "wm": "video/x-ms-wm", + "wma": "audio/x-ms-wma", + "wmd": "application/x-ms-wmd", + "wmf": "image/wmf", + "wml": "text/vnd.wap.wml", + "wmlc": "application/vnd.wap.wmlc", + "wmls": "text/vnd.wap.wmlscript", + "wmlsc": "application/vnd.wap.wmlscriptc", + "wmv": "video/x-ms-wmv", + "wmx": "video/x-ms-wmx", + "wmz": "application/x-msmetafile", + "woff": "font/woff", + "woff2": "font/woff2", + "wpd": "application/vnd.wordperfect", + "wpl": "application/vnd.ms-wpl", + "wps": "application/vnd.ms-works", + "wqd": "application/vnd.wqd", + "wri": "application/x-mswrite", + "wrl": "model/vrml", + "wsc": "message/vnd.wfa.wsc", + "wsdl": "application/wsdl+xml", + "wspolicy": "application/wspolicy+xml", + "wtb": "application/vnd.webturbo", + "wvx": "video/x-ms-wvx", + "x32": "application/x-authorware-bin", + "x3d": "model/x3d+xml", + "x3db": "model/x3d+fastinfoset", + "x3dbz": "model/x3d+binary", + "x3dv": "model/x3d-vrml", + "x3dvz": "model/x3d+vrml", + "x3dz": "model/x3d+xml", + "x_b": "model/vnd.parasolid.transmit.binary", + "x_t": "model/vnd.parasolid.transmit.text", + "xaml": "application/xaml+xml", + "xap": "application/x-silverlight-app", + "xar": "application/vnd.xara", + "xav": "application/xcap-att+xml", + "xbap": "application/x-ms-xbap", + "xbd": "application/vnd.fujixerox.docuworks.binder", + "xbm": "image/x-xbitmap", + "xca": "application/xcap-caps+xml", + "xcs": "application/calendar+xml", + "xdf": "application/xcap-diff+xml", + "xdm": "application/vnd.syncml.dm+xml", + "xdp": "application/vnd.adobe.xdp+xml", + "xdssc": "application/dssc+xml", + "xdw": "application/vnd.fujixerox.docuworks", + "xel": "application/xcap-el+xml", + "xenc": "application/xenc+xml", + "xer": "application/patch-ops-error+xml", + "xfdf": "application/xfdf", + "xfdl": "application/vnd.xfdl", + "xht": "application/xhtml+xml", + "xhtm": "application/vnd.pwg-xhtml-print+xml", + "xhtml": "application/xhtml+xml", + "xhvml": "application/xv+xml", + "xif": "image/vnd.xiff", + "xla": "application/vnd.ms-excel", + "xlam": "application/vnd.ms-excel.addin.macroenabled.12", + "xlc": "application/vnd.ms-excel", + "xlf": "application/xliff+xml", + "xlm": "application/vnd.ms-excel", + "xls": "application/vnd.ms-excel", + "xlsb": "application/vnd.ms-excel.sheet.binary.macroenabled.12", + "xlsm": "application/vnd.ms-excel.sheet.macroenabled.12", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "xlt": "application/vnd.ms-excel", + "xltm": "application/vnd.ms-excel.template.macroenabled.12", + "xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "xlw": "application/vnd.ms-excel", + "xm": "audio/xm", + "xml": "text/xml", + "xns": "application/xcap-ns+xml", + "xo": "application/vnd.olpc-sugar", + "xop": "application/xop+xml", + "xpi": "application/x-xpinstall", + "xpl": "application/xproc+xml", + "xpm": "image/x-xpixmap", + "xpr": "application/vnd.is-xpr", + "xps": "application/vnd.ms-xpsdocument", + "xpw": "application/vnd.intercon.formnet", + "xpx": "application/vnd.intercon.formnet", + "xsd": "application/xml", + "xsf": "application/prs.xsf+xml", + "xsl": "application/xslt+xml", + "xslt": "application/xslt+xml", + "xsm": "application/vnd.syncml+xml", + "xspf": "application/xspf+xml", + "xul": "application/vnd.mozilla.xul+xml", + "xvm": "application/xv+xml", + "xvml": "application/xv+xml", + "xwd": "image/x-xwindowdump", + "xyz": "chemical/x-xyz", + "xz": "application/x-xz", + "yaml": "text/yaml", + "yang": "application/yang", + "yin": "application/yin+xml", + "yml": "text/yaml", + "ymp": "text/x-suse-ymp", + "z1": "application/x-zmachine", + "z2": "application/x-zmachine", + "z3": "application/x-zmachine", + "z4": "application/x-zmachine", + "z5": "application/x-zmachine", + "z6": "application/x-zmachine", + "z7": "application/x-zmachine", + "z8": "application/x-zmachine", + "zaz": "application/vnd.zzazz.deck+xml", + "zip": "application/zip", + "zir": "application/vnd.zul", + "zirz": "application/vnd.zul", + "zmm": "application/vnd.handheld-entertainment+xml", +} From 2546cfa5ef0a2710eedcd77809675eb351c57ed5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 02:57:23 -0700 Subject: [PATCH 02/84] Unfinished component dispatcher --- src/py/reactpy/reactpy/backend/asgi.py | 55 +++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index b03802783..2119bd7bf 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -1,11 +1,14 @@ +import asyncio import logging import mimetypes import os import re +import urllib.parse +from collections.abc import Sequence from pathlib import Path -from typing import Sequence import aiofiles +import orjson from asgiref.compatibility import guarantee_single_callable from reactpy.backend._common import ( @@ -13,8 +16,12 @@ traversal_safe_path, vdom_head_elements_to_html, ) +from reactpy.backend.hooks import ConnectionContext from reactpy.backend.mimetypes import DEFAULT_MIME_TYPES +from reactpy.backend.types import Connection, Location from reactpy.config import REACTPY_WEB_MODULES_DIR +from reactpy.core.layout import Layout +from reactpy.core.serve import serve_layout from reactpy.core.types import VdomDict DEFAULT_STATIC_PATH = f"{os.getcwd()}/static" @@ -89,6 +96,43 @@ async def __call__(self, scope, receive, send) -> None: async def component_dispatch_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy Python components.""" + self._reactpy_recv_queue: asyncio.Queue = asyncio.Queue() + parsed_url = urllib.parse.urlparse(scope["path"]) + + # TODO: Get the component via URL attached to template tag + parsed_url_query = urllib.parse.parse_qs(parsed_url.query) + component = lambda _: None + + while True: + event = await receive() + + if event["type"] == "websocket.connect": + await send({"type": "websocket.accept"}) + + await serve_layout( + Layout( + ConnectionContext( + component(), + value=Connection( + scope=scope, + location=Location( + parsed_url.path, + f"?{parsed_url.query}" if parsed_url.query else "", + ), + carrier=self, + ), + ) + ), + send_json(send), + self._reactpy_recv_queue.get, + ) + + if event["type"] == "websocket.disconnect": + break + + if event["type"] == "websocket.receive": + await self._reactpy_recv_queue.put(orjson.loads(event["text"])) + async def js_modules_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy web modules.""" @@ -166,6 +210,15 @@ async def index_html_app(self, scope, receive, send) -> None: ) +def send_json(send) -> None: + """Use orjson to send JSON over an ASGI websocket.""" + + async def _send_json(value) -> None: + await send({"type": "websocket.send", "text": orjson.dumps(value)}) + + return _send_json + + async def simple_response( send, code: int, From 16ada3382e00a7dcd3c3c74e15bef1d00844eb10 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 16:51:17 -0700 Subject: [PATCH 03/84] threading for disk calls --- src/py/reactpy/reactpy/backend/asgi.py | 13 ++++++++----- src/py/reactpy/reactpy/backend/mimetypes.py | 7 ++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 2119bd7bf..a66be2326 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -242,25 +242,28 @@ async def file_response(scope, send, file_path: Path) -> None: """Send a file in chunks.""" # Make sure the file exists - if not os.path.exists(file_path): + if not await asyncio.to_thread(os.path.exists, file_path): await simple_response(send, 404, "File not found.") return # Make sure it's a file - if not os.path.isfile(file_path): + if not await asyncio.to_thread(os.path.isfile, file_path): await simple_response(send, 400, "Not a file.") return # Check if the file is already cached by the client modified_since = await get_val_from_header(scope, b"if-modified-since") - if modified_since and modified_since > os.path.getmtime(file_path): + if modified_since and modified_since > await asyncio.to_thread( + os.path.getmtime, file_path + ): await simple_response(send, 304, "Not modified.") return # Get the file's MIME type mime_type = ( - DEFAULT_MIME_TYPES.get(file_path.rsplit(".")[1], None) - or mimetypes.guess_type(file_path, strict=False)[0] + MIME_TYPES.get(file_path.rsplit(".")[1], None) + # Fallback to guess_type to allow for the user to define custom MIME types on their system + or (await asyncio.to_thread(mimetypes.guess_type, file_path, strict=False))[0] ) if mime_type is None: mime_type = "text/plain" diff --git a/src/py/reactpy/reactpy/backend/mimetypes.py b/src/py/reactpy/reactpy/backend/mimetypes.py index 9c8a97088..051d5c88e 100644 --- a/src/py/reactpy/reactpy/backend/mimetypes.py +++ b/src/py/reactpy/reactpy/backend/mimetypes.py @@ -1,4 +1,9 @@ -DEFAULT_MIME_TYPES = { +""" +We ship our own mime types to ensure consistent behavior across platforms. +This dictionary is based on: https://github.com/micnic/mime.json +""" + +MIME_TYPES = { "123": "application/vnd.lotus-1-2-3", "1km": "application/vnd.1000minds.decision-model+xml", "3dml": "text/vnd.in3d.3dml", From bde42aa67bcc6d7b2efc501ecf7d6382178385ec Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:35:09 -0700 Subject: [PATCH 04/84] More robust route handling --- src/py/reactpy/reactpy/backend/asgi.py | 38 +++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index a66be2326..dc3e6a983 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -6,6 +6,7 @@ import urllib.parse from collections.abc import Sequence from pathlib import Path +from typing import Coroutine import aiofiles import orjson @@ -17,12 +18,12 @@ vdom_head_elements_to_html, ) from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.mimetypes import DEFAULT_MIME_TYPES +from reactpy.backend.mimetypes import MIME_TYPES from reactpy.backend.types import Connection, Location from reactpy.config import REACTPY_WEB_MODULES_DIR from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.core.types import VdomDict +from reactpy.core.types import ComponentConstructor, VdomDict DEFAULT_STATIC_PATH = f"{os.getcwd()}/static" DEFAULT_BLOCK_SIZE = 8192 @@ -32,14 +33,28 @@ class ReactPy: def __init__( self, - application=None, + app_or_component: ComponentConstructor | Coroutine, + *, dispatcher_path: str = "^reactpy/stream/([^/]+)/?", js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", static_path: str | None = "^reactpy/static/([^/]+)/?", static_dir: str | None = DEFAULT_STATIC_PATH, head: Sequence[VdomDict] | VdomDict | str = "", ) -> None: - self.user_app = guarantee_single_callable(application) + self.component = ( + app_or_component + if isinstance(app_or_component, ComponentConstructor) + else None + ) + self.user_app = ( + guarantee_single_callable(app_or_component) + if not self.component and asyncio.iscoroutinefunction(app_or_component) + else None + ) + if not self.component and not self.user_app: + raise TypeError( + "The first argument to `ReactPy` must be a component or an ASGI application." + ) self.dispatch_path = re.compile(dispatcher_path) self.js_modules_path = re.compile(js_modules_path) self.static_path = re.compile(static_path) @@ -66,32 +81,35 @@ async def __call__(self, scope, receive, send) -> None: return # User tried to use an unsupported HTTP method - if scope["method"] not in ("GET", "HEAD"): + if scope["type"] == "http" and scope["method"] not in ("GET", "HEAD"): await simple_response( scope, send, status=405, content="Method Not Allowed" ) return - # Serve a JS web module + # Route requests to our JS web module app if scope["type"] == "http" and re.match( self.js_modules_path, scope["path"] ): await self.js_modules_app(scope, receive, send) return - # Serve a static file + # Route requests to our static file server app if scope["type"] == "http" and re.match(self.static_path, scope["path"]): await self.static_file_app(scope, receive, send) return - # Serve index.html - if scope["type"] == "http": + # Route all other requests to serve a component (user is in standalone mode) + if scope["type"] == "http" and self.component: await self.index_html_app(scope, receive, send) return # Serve the user's application - else: + if self.user_app: await self.user_app(scope, receive, send) + return + + _logger.error("ReactPy appears to be misconfigured. Request not handled.") async def component_dispatch_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy Python components.""" From fa34c318034572e5d1ec28d28229a93b5ba18269 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:35:23 -0700 Subject: [PATCH 05/84] fix carrier --- src/py/reactpy/reactpy/backend/asgi.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index dc3e6a983..8c4af6cad 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -137,7 +137,11 @@ async def component_dispatch_app(self, scope, receive, send) -> None: parsed_url.path, f"?{parsed_url.query}" if parsed_url.query else "", ), - carrier=self, + carrier={ + "scope": scope, + "send": send, + "receive": receive, + }, ), ) ), From a936c66acd5d7c97b249ff638b185e1a65ba9998 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:38:35 -0700 Subject: [PATCH 06/84] Plan ahead for new websocket URL pattern --- src/py/reactpy/reactpy/backend/asgi.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 8c4af6cad..61f25fa95 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -35,7 +35,7 @@ def __init__( self, app_or_component: ComponentConstructor | Coroutine, *, - dispatcher_path: str = "^reactpy/stream/([^/]+)/?", + dispatcher_path: str = "^reactpy/([^/]+)/?", js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", static_path: str | None = "^reactpy/static/([^/]+)/?", static_dir: str | None = DEFAULT_STATIC_PATH, @@ -117,9 +117,9 @@ async def component_dispatch_app(self, scope, receive, send) -> None: self._reactpy_recv_queue: asyncio.Queue = asyncio.Queue() parsed_url = urllib.parse.urlparse(scope["path"]) - # TODO: Get the component via URL attached to template tag - parsed_url_query = urllib.parse.parse_qs(parsed_url.query) - component = lambda _: None + # If in standalone mode, serve the user provided component. + # In middleware mode, get the component from the URL. + component = self.component or re.match(self.dispatch_path, scope["path"])[1] while True: event = await receive() From a936c86fd05e97fe97e000988185fe879d4ff660 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 21:52:38 -0700 Subject: [PATCH 07/84] better path stuff --- src/py/reactpy/reactpy/backend/_common.py | 14 ++--- src/py/reactpy/reactpy/backend/asgi.py | 62 +++++++++++-------- .../tests/test_backend/test__common.py | 6 +- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 17983a033..8a321b409 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -71,18 +71,17 @@ async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> N def safe_client_build_dir_path(path: str) -> Path: """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" - return traversal_safe_path( - CLIENT_BUILD_DIR, - *("index.html" if path in ("", "/") else path).split("/"), + return safe_join_path( + CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/") ) def safe_web_modules_dir_path(path: str) -> Path: """Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`""" - return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) + return safe_join_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) -def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: +def safe_join_path(root: str | Path, *unsafe: str | Path) -> Path: """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir.""" root = os.path.abspath(root) @@ -92,8 +91,9 @@ def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: if os.path.commonprefix([root, path]) != root: # If the common prefix is not root directory we resolved outside the root dir - msg = "Unsafe path" - raise ValueError(msg) + raise ValueError( + f"Unsafe path detected. Path '{path}' is outside root directory '{root}'" + ) return Path(path) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 61f25fa95..262c2a945 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -4,9 +4,8 @@ import os import re import urllib.parse -from collections.abc import Sequence +from collections.abc import Coroutine, Sequence from pathlib import Path -from typing import Coroutine import aiofiles import orjson @@ -14,7 +13,7 @@ from reactpy.backend._common import ( CLIENT_BUILD_DIR, - traversal_safe_path, + safe_join_path, vdom_head_elements_to_html, ) from reactpy.backend.hooks import ConnectionContext @@ -25,7 +24,6 @@ from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor, VdomDict -DEFAULT_STATIC_PATH = f"{os.getcwd()}/static" DEFAULT_BLOCK_SIZE = 8192 _logger = logging.getLogger(__name__) @@ -38,7 +36,7 @@ def __init__( dispatcher_path: str = "^reactpy/([^/]+)/?", js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", static_path: str | None = "^reactpy/static/([^/]+)/?", - static_dir: str | None = DEFAULT_STATIC_PATH, + static_dir: Path | str | None = None, head: Sequence[VdomDict] | VdomDict | str = "", ) -> None: self.component = ( @@ -56,8 +54,8 @@ def __init__( "The first argument to `ReactPy` must be a component or an ASGI application." ) self.dispatch_path = re.compile(dispatcher_path) - self.js_modules_path = re.compile(js_modules_path) - self.static_path = re.compile(static_path) + self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None + self.static_path = re.compile(static_path) if static_path else None self.static_dir = static_dir self.all_paths = re.compile( "|".join( @@ -88,14 +86,20 @@ async def __call__(self, scope, receive, send) -> None: return # Route requests to our JS web module app - if scope["type"] == "http" and re.match( - self.js_modules_path, scope["path"] + if ( + scope["type"] == "http" + and self.js_modules_path + and re.match(self.js_modules_path, scope["path"]) ): await self.js_modules_app(scope, receive, send) return # Route requests to our static file server app - if scope["type"] == "http" and re.match(self.static_path, scope["path"]): + if ( + scope["type"] == "http" + and self.static_path + and re.match(self.static_path, scope["path"]) + ): await self.static_file_app(scope, receive, send) return @@ -159,45 +163,42 @@ async def js_modules_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy web modules.""" if not REACTPY_WEB_MODULES_DIR.current: - raise RuntimeError("No web modules directory configured") - - # Get the relative file path from the URL - file_url_path = re.match(self.js_modules_path, scope["path"])[1] + raise RuntimeError("No web modules directory configured.") # Make sure the user hasn't tried to escape the web modules directory try: - file_path = traversal_safe_path( + abs_file_path = safe_join_path( REACTPY_WEB_MODULES_DIR.current, REACTPY_WEB_MODULES_DIR.current, - file_url_path, + re.match(self.js_modules_path, scope["path"])[1], ) except ValueError: await simple_response(send, 403, "Forbidden") return # Serve the file - await file_response(scope, send, file_path) + await file_response(scope, send, abs_file_path) async def static_file_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy static files.""" - if self.static_dir is None: - raise RuntimeError("No static directory configured") - - # Get the relative file path from the URL - file_url_path = re.match(self.static_path, scope["path"])[1] + if not self.static_dir: + raise RuntimeError( + "Static files cannot be served without defining `static_dir`." + ) # Make sure the user hasn't tried to escape the static directory try: - file_path = traversal_safe_path( - self.static_dir, self.static_dir, file_url_path + abs_file_path = safe_join_path( + self.static_dir, + re.match(self.static_path, scope["path"])[1], ) except ValueError: await simple_response(send, 403, "Forbidden") return # Serve the file - await file_response(scope, send, file_path) + await file_response(scope, send, abs_file_path) async def index_html_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy index.html.""" @@ -289,7 +290,9 @@ async def file_response(scope, send, file_path: Path) -> None: ) if mime_type is None: mime_type = "text/plain" - _logger.error(f"Could not determine MIME type for {file_path}.") + _logger.error( + f"Could not determine MIME type for {file_path}. Defaulting to 'text/plain'." + ) # Send the file in chunks async with aiofiles.open(file_path, "rb") as file_handle: @@ -299,7 +302,12 @@ async def file_response(scope, send, file_path: Path) -> None: "status": 200, "headers": [ (b"content-type", mime_type.encode()), - (b"last-modified", str(os.path.getmtime(file_path)).encode()), + ( + b"last-modified", + str( + await asyncio.to_thread(os.path.getmtime, file_path) + ).encode(), + ), ], } ) diff --git a/src/py/reactpy/tests/test_backend/test__common.py b/src/py/reactpy/tests/test_backend/test__common.py index 248bf9451..869c7e287 100644 --- a/src/py/reactpy/tests/test_backend/test__common.py +++ b/src/py/reactpy/tests/test_backend/test__common.py @@ -3,7 +3,7 @@ from reactpy import html from reactpy.backend._common import ( CommonOptions, - traversal_safe_path, + safe_join_path, vdom_head_elements_to_html, ) @@ -25,8 +25,8 @@ def test_common_options_url_prefix_starts_with_slash(): ], ) def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): - with pytest.raises(ValueError, match="Unsafe path"): - traversal_safe_path(tmp_path, *bad_path.split("/")) + with pytest.raises(ValueError): + safe_join_path(tmp_path, *bad_path.split("/")) @pytest.mark.parametrize( From 727f3f03603e3230bc374c037a4461949644fbd0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 22:31:51 -0700 Subject: [PATCH 08/84] use etag instead of modified header --- src/py/reactpy/reactpy/backend/asgi.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 262c2a945..4e3c7e187 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -275,8 +275,8 @@ async def file_response(scope, send, file_path: Path) -> None: return # Check if the file is already cached by the client - modified_since = await get_val_from_header(scope, b"if-modified-since") - if modified_since and modified_since > await asyncio.to_thread( + etag = await get_val_from_header(scope, b"ETag") + if etag and etag != await asyncio.to_thread( os.path.getmtime, file_path ): await simple_response(send, 304, "Not modified.") @@ -303,7 +303,7 @@ async def file_response(scope, send, file_path: Path) -> None: "headers": [ (b"content-type", mime_type.encode()), ( - b"last-modified", + b"ETag", str( await asyncio.to_thread(os.path.getmtime, file_path) ).encode(), From daaa23594a759d69bc68547ba7d2603edf0ef0a9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 23:02:41 -0700 Subject: [PATCH 09/84] prepare recv queue for potential threading --- src/py/reactpy/reactpy/backend/asgi.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 4e3c7e187..da67bb98c 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -118,7 +118,6 @@ async def __call__(self, scope, receive, send) -> None: async def component_dispatch_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy Python components.""" - self._reactpy_recv_queue: asyncio.Queue = asyncio.Queue() parsed_url = urllib.parse.urlparse(scope["path"]) # If in standalone mode, serve the user provided component. @@ -130,7 +129,7 @@ async def component_dispatch_app(self, scope, receive, send) -> None: if event["type"] == "websocket.connect": await send({"type": "websocket.accept"}) - + self.recv_queue: asyncio.Queue = asyncio.Queue() await serve_layout( Layout( ConnectionContext( @@ -150,14 +149,14 @@ async def component_dispatch_app(self, scope, receive, send) -> None: ) ), send_json(send), - self._reactpy_recv_queue.get, + self.recv_queue.get, ) if event["type"] == "websocket.disconnect": break if event["type"] == "websocket.receive": - await self._reactpy_recv_queue.put(orjson.loads(event["text"])) + await self.recv_queue.put(orjson.loads(event["text"])) async def js_modules_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy web modules.""" From 3d974307b4a39cd5aeea2843e4e956ca01f61afd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 00:25:08 -0700 Subject: [PATCH 10/84] refactoring --- src/js/app/index.html | 1 - src/py/reactpy/reactpy/backend/asgi.py | 235 ++++++++++++------------- 2 files changed, 108 insertions(+), 128 deletions(-) diff --git a/src/js/app/index.html b/src/js/app/index.html index e94280368..906bcfe3a 100644 --- a/src/js/app/index.html +++ b/src/js/app/index.html @@ -6,7 +6,6 @@ import { app } from "./src/index"; app(document.getElementById("app")); - {__head__} diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index da67bb98c..4b6ff8549 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -51,7 +51,7 @@ def __init__( ) if not self.component and not self.user_app: raise TypeError( - "The first argument to `ReactPy` must be a component or an ASGI application." + "The first argument to ReactPy(...) must be a component or an ASGI application." ) self.dispatch_path = re.compile(dispatcher_path) self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None @@ -64,93 +64,63 @@ def __init__( ) self.head = vdom_head_elements_to_html(head) self._cached_index_html = "" + self.connected = False async def __call__(self, scope, receive, send) -> None: """The ASGI callable. This determines whether ReactPy should route the the request to ourselves or to the user application.""" - # Determine if ReactPy should handle the request if not self.user_app or re.match(self.all_paths, scope["path"]): - # Dispatch a Python component - if scope["type"] == "websocket" and re.match( - self.dispatch_path, scope["path"] - ): - await self.component_dispatch_app(scope, receive, send) - return - - # User tried to use an unsupported HTTP method - if scope["type"] == "http" and scope["method"] not in ("GET", "HEAD"): - await simple_response( - scope, send, status=405, content="Method Not Allowed" - ) - return - - # Route requests to our JS web module app - if ( - scope["type"] == "http" - and self.js_modules_path - and re.match(self.js_modules_path, scope["path"]) - ): - await self.js_modules_app(scope, receive, send) - return - - # Route requests to our static file server app - if ( - scope["type"] == "http" - and self.static_path - and re.match(self.static_path, scope["path"]) - ): - await self.static_file_app(scope, receive, send) - return - - # Route all other requests to serve a component (user is in standalone mode) - if scope["type"] == "http" and self.component: - await self.index_html_app(scope, receive, send) - return + await self.reactpy_app(scope, receive, send) + return # Serve the user's application - if self.user_app: - await self.user_app(scope, receive, send) - return + await self.user_app(scope, receive, send) _logger.error("ReactPy appears to be misconfigured. Request not handled.") - async def component_dispatch_app(self, scope, receive, send) -> None: - """The ASGI application for ReactPy Python components.""" + async def reactpy_app(self, scope, receive, send) -> None: + """Determine what type of request this is and route it to the appropriate + ReactPy ASGI sub-application.""" - parsed_url = urllib.parse.urlparse(scope["path"]) + # Only HTTP and WebSocket requests are supported + if scope["type"] not in {"http", "websocket"}: + return - # If in standalone mode, serve the user provided component. - # In middleware mode, get the component from the URL. - component = self.component or re.match(self.dispatch_path, scope["path"])[1] + # Dispatch a Python component + if scope["type"] == "websocket" and re.match(self.dispatch_path, scope["path"]): + await self.component_dispatch_app(scope, receive, send) + return + + # Only HTTP GET and HEAD requests are supported + if scope["method"] not in {"GET", "HEAD"}: + await http_response(scope, send, 405, "Method Not Allowed") + return + + # JS modules app + if self.js_modules_path and re.match(self.js_modules_path, scope["path"]): + await self.js_modules_app(scope, receive, send) + return + + # Static file app + if self.static_path and re.match(self.static_path, scope["path"]): + await self.static_file_app(scope, receive, send) + return + + # Standalone app: Serve a single component using index.html + if self.component: + await self.standalone_app(scope, receive, send) + return + async def component_dispatch_app(self, scope, receive, send) -> None: + """ASGI app for rendering ReactPy Python components.""" while True: event = await receive() - if event["type"] == "websocket.connect": + if event["type"] == "websocket.connect" and not self.connected: + self.connected = True await send({"type": "websocket.accept"}) - self.recv_queue: asyncio.Queue = asyncio.Queue() - await serve_layout( - Layout( - ConnectionContext( - component(), - value=Connection( - scope=scope, - location=Location( - parsed_url.path, - f"?{parsed_url.query}" if parsed_url.query else "", - ), - carrier={ - "scope": scope, - "send": send, - "receive": receive, - }, - ), - ) - ), - send_json(send), - self.recv_queue.get, - ) + await self.run_dispatcher(scope, receive, send) if event["type"] == "websocket.disconnect": break @@ -159,8 +129,7 @@ async def component_dispatch_app(self, scope, receive, send) -> None: await self.recv_queue.put(orjson.loads(event["text"])) async def js_modules_app(self, scope, receive, send) -> None: - """The ASGI application for ReactPy web modules.""" - + """ASGI app for ReactPy web modules.""" if not REACTPY_WEB_MODULES_DIR.current: raise RuntimeError("No web modules directory configured.") @@ -172,15 +141,14 @@ async def js_modules_app(self, scope, receive, send) -> None: re.match(self.js_modules_path, scope["path"])[1], ) except ValueError: - await simple_response(send, 403, "Forbidden") + await http_response(scope, send, 403, "Forbidden") return # Serve the file await file_response(scope, send, abs_file_path) async def static_file_app(self, scope, receive, send) -> None: - """The ASGI application for ReactPy static files.""" - + """ASGI app for ReactPy static files.""" if not self.static_dir: raise RuntimeError( "Static files cannot be served without defining `static_dir`." @@ -193,17 +161,14 @@ async def static_file_app(self, scope, receive, send) -> None: re.match(self.static_path, scope["path"])[1], ) except ValueError: - await simple_response(send, 403, "Forbidden") + await http_response(scope, send, 403, "Forbidden") return # Serve the file await file_response(scope, send, abs_file_path) - async def index_html_app(self, scope, receive, send) -> None: - """The ASGI application for ReactPy index.html.""" - - # TODO: We want to respect the if-modified-since header, but currently can't - # due to the fact that our HTML is not statically rendered. + async def standalone_app(self, scope, receive, send) -> None: + """ASGI app for ReactPy standalone mode.""" file_path = CLIENT_BUILD_DIR / "index.html" if not self._cached_index_html: async with aiofiles.open(file_path, "rb") as file_handle: @@ -211,24 +176,46 @@ async def index_html_app(self, scope, receive, send) -> None: __head__=self.head ) - # Head requests don't need a body - if scope["method"] == "HEAD": - await simple_response( - send, - 200, - "", - content_type=b"text/html", - headers=[(b"cache-control", b"no-cache")], - ) - return - # Send the index.html - await simple_response( + await http_response( + scope, send, 200, self._cached_index_html, content_type=b"text/html", - headers=[(b"cache-control", b"no-cache")], + headers=[ + (b"content-length", len(self._cached_index_html)), + (b"etag", hash(self._cached_index_html)), + ], + ) + + async def run_dispatcher(self, scope, receive, send): + # If in standalone mode, serve the user provided component. + # In middleware mode, get the component from the URL. + component = self.component or re.match(self.dispatch_path, scope["path"])[1] + parsed_url = urllib.parse.urlparse(scope["path"]) + self.recv_queue: asyncio.Queue = asyncio.Queue() + + await serve_layout( + Layout( + ConnectionContext( + component(), + value=Connection( + scope=scope, + location=Location( + parsed_url.path, + f"?{parsed_url.query}" if parsed_url.query else "", + ), + carrier={ + "scope": scope, + "send": send, + "receive": receive, + }, + ), + ) + ), + send_json(send), + self.recv_queue.get, ) @@ -241,7 +228,8 @@ async def _send_json(value) -> None: return _send_json -async def simple_response( +async def http_response( + scope, send, code: int, message: str, @@ -249,7 +237,6 @@ async def simple_response( headers: Sequence = (), ) -> None: """Send a simple response.""" - await send( { "type": "http.response.start", @@ -257,28 +244,28 @@ async def simple_response( "headers": [(b"content-type", content_type, *headers)], } ) - await send({"type": "http.response.body", "body": message.encode()}) + # Head requests don't need a body + if scope["method"] != "HEAD": + await send({"type": "http.response.body", "body": message.encode()}) async def file_response(scope, send, file_path: Path) -> None: """Send a file in chunks.""" - # Make sure the file exists if not await asyncio.to_thread(os.path.exists, file_path): - await simple_response(send, 404, "File not found.") + await http_response(scope, send, 404, "File not found.") return # Make sure it's a file if not await asyncio.to_thread(os.path.isfile, file_path): - await simple_response(send, 400, "Not a file.") + await http_response(scope, send, 400, "Not a file.") return # Check if the file is already cached by the client - etag = await get_val_from_header(scope, b"ETag") - if etag and etag != await asyncio.to_thread( - os.path.getmtime, file_path - ): - await simple_response(send, 304, "Not modified.") + etag = await get_val_from_header(scope, b"etag") + modification_time = await asyncio.to_thread(os.path.getmtime, file_path) + if etag and etag != modification_time: + await http_response(scope, send, 304, "Not modified.") return # Get the file's MIME type @@ -294,6 +281,7 @@ async def file_response(scope, send, file_path: Path) -> None: ) # Send the file in chunks + file_size = await asyncio.to_thread(os.path.getsize, file_path) async with aiofiles.open(file_path, "rb") as file_handle: await send( { @@ -301,39 +289,32 @@ async def file_response(scope, send, file_path: Path) -> None: "status": 200, "headers": [ (b"content-type", mime_type.encode()), - ( - b"ETag", - str( - await asyncio.to_thread(os.path.getmtime, file_path) - ).encode(), - ), + (b"etag", modification_time), + (b"content-length", file_size), ], } ) # Head requests don't need a body - if scope["method"] == "HEAD": - return - - while True: - chunk = await file_handle.read(DEFAULT_BLOCK_SIZE) - more_body = bool(chunk) - await send( - { - "type": "http.response.body", - "body": chunk, - "more_body": more_body, - } - ) - if not more_body: - break + if scope["method"] != "HEAD": + while True: + chunk = await file_handle.read(DEFAULT_BLOCK_SIZE) + more_body = bool(chunk) + await send( + { + "type": "http.response.body", + "body": chunk, + "more_body": more_body, + } + ) + if not more_body: + break async def get_val_from_header( scope: dict, key: str, default: str | None = None ) -> str | None: """Get a value from a scope's headers.""" - return await anext( ( value.decode() From f16e4130174c54cffda41c9c03a3f0d117b84682 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 00:59:27 -0700 Subject: [PATCH 11/84] add backhaul thread --- src/py/reactpy/reactpy/backend/asgi.py | 32 ++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 4b6ff8549..016113f83 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -6,6 +6,7 @@ import urllib.parse from collections.abc import Coroutine, Sequence from pathlib import Path +from threading import Thread import aiofiles import orjson @@ -26,6 +27,16 @@ DEFAULT_BLOCK_SIZE = 8192 _logger = logging.getLogger(__name__) +_backhaul_loop = asyncio.new_event_loop() + + +def start_backhaul_loop(): + """Starts the asyncio event loop that will perform component rendering tasks.""" + asyncio.set_event_loop(_backhaul_loop) + _backhaul_loop.run_forever() + + +_backhaul_thread = Thread(target=start_backhaul_loop, daemon=True) class ReactPy: @@ -38,6 +49,7 @@ def __init__( static_path: str | None = "^reactpy/static/([^/]+)/?", static_dir: Path | str | None = None, head: Sequence[VdomDict] | VdomDict | str = "", + backhaul_thread: bool = True, ) -> None: self.component = ( app_or_component @@ -65,6 +77,10 @@ def __init__( self.head = vdom_head_elements_to_html(head) self._cached_index_html = "" self.connected = False + self.backhaul_thread = backhaul_thread + self.dispatcher_future = None + if self.backhaul_thread and not _backhaul_thread.is_alive(): + _backhaul_thread.start() async def __call__(self, scope, receive, send) -> None: """The ASGI callable. This determines whether ReactPy should route the the @@ -120,13 +136,25 @@ async def component_dispatch_app(self, scope, receive, send) -> None: if event["type"] == "websocket.connect" and not self.connected: self.connected = True await send({"type": "websocket.accept"}) - await self.run_dispatcher(scope, receive, send) + run_dispatcher = self.run_dispatcher(scope, receive, send) + if self.backhaul_thread: + self.dispatcher_future = asyncio.run_coroutine_threadsafe( + run_dispatcher, _backhaul_loop + ) + else: + await run_dispatcher if event["type"] == "websocket.disconnect": + if self.dispatcher_future: + self.dispatcher_future.cancel() break if event["type"] == "websocket.receive": - await self.recv_queue.put(orjson.loads(event["text"])) + recv_queue_put = self.recv_queue.put(orjson.loads(event["text"])) + if self.backhaul_thread: + asyncio.run_coroutine_threadsafe(recv_queue_put, _backhaul_loop) + else: + await recv_queue_put async def js_modules_app(self, scope, receive, send) -> None: """ASGI app for ReactPy web modules.""" From 291b5387ed58741ec8964e55271e8e2212692ad1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 01:51:57 -0700 Subject: [PATCH 12/84] remove icon from default head --- src/py/reactpy/reactpy/backend/_common.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 97141a118..1e0c17cb5 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -117,16 +117,7 @@ def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str class CommonOptions: """Options for ReactPy's built-in backed server implementations""" - head: Sequence[VdomDict] | VdomDict | str = ( - html.title("ReactPy"), - html.link( - { - "rel": "icon", - "href": "/_reactpy/assets/reactpy-logo.ico", - "type": "image/x-icon", - } - ), - ) + head: Sequence[VdomDict] | VdomDict | str = (html.title("ReactPy App"),) """Add elements to the ```` of the application. For example, this can be used to customize the title of the page, link extra From 0b5ba460459a55683aa2c4f700bed966a7f23530 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 04:44:27 -0700 Subject: [PATCH 13/84] small bug fixes --- src/py/reactpy/reactpy/backend/asgi.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 016113f83..d0ae2c86e 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -78,7 +78,7 @@ def __init__( self._cached_index_html = "" self.connected = False self.backhaul_thread = backhaul_thread - self.dispatcher_future = None + self.dispatcher_future_or_task = None if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() @@ -138,15 +138,15 @@ async def component_dispatch_app(self, scope, receive, send) -> None: await send({"type": "websocket.accept"}) run_dispatcher = self.run_dispatcher(scope, receive, send) if self.backhaul_thread: - self.dispatcher_future = asyncio.run_coroutine_threadsafe( + self.dispatcher_future_or_task = asyncio.run_coroutine_threadsafe( run_dispatcher, _backhaul_loop ) else: - await run_dispatcher + self.dispatcher_future_or_task = asyncio.create_task(run_dispatcher) if event["type"] == "websocket.disconnect": - if self.dispatcher_future: - self.dispatcher_future.cancel() + if self.dispatcher_future_or_task: + self.dispatcher_future_or_task.cancel() break if event["type"] == "websocket.receive": @@ -164,7 +164,6 @@ async def js_modules_app(self, scope, receive, send) -> None: # Make sure the user hasn't tried to escape the web modules directory try: abs_file_path = safe_join_path( - REACTPY_WEB_MODULES_DIR.current, REACTPY_WEB_MODULES_DIR.current, re.match(self.js_modules_path, scope["path"])[1], ) From fc99a65e8210a465b6b0fa31b22540501de5d88d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 04:59:45 -0700 Subject: [PATCH 14/84] fix another typo --- src/py/reactpy/reactpy/backend/asgi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index d0ae2c86e..dabdbba03 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -268,7 +268,7 @@ async def http_response( { "type": "http.response.start", "status": code, - "headers": [(b"content-type", content_type, *headers)], + "headers": [(b"content-type", content_type), *headers], } ) # Head requests don't need a body From 139ba9864652476c2d4905ec174dce3571948055 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 05:27:08 -0700 Subject: [PATCH 15/84] Update asgi.py --- src/py/reactpy/reactpy/backend/asgi.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index dabdbba03..80e8830e5 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -93,8 +93,6 @@ async def __call__(self, scope, receive, send) -> None: # Serve the user's application await self.user_app(scope, receive, send) - _logger.error("ReactPy appears to be misconfigured. Request not handled.") - async def reactpy_app(self, scope, receive, send) -> None: """Determine what type of request this is and route it to the appropriate ReactPy ASGI sub-application.""" From b3505903d7d73881e31aee2004a452cb002eebe7 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 05:31:25 -0700 Subject: [PATCH 16/84] Update asgi.py --- src/py/reactpy/reactpy/backend/asgi.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 80e8830e5..ed46ce965 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -78,7 +78,7 @@ def __init__( self._cached_index_html = "" self.connected = False self.backhaul_thread = backhaul_thread - self.dispatcher_future_or_task = None + self.dispatcher = None if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() @@ -136,15 +136,15 @@ async def component_dispatch_app(self, scope, receive, send) -> None: await send({"type": "websocket.accept"}) run_dispatcher = self.run_dispatcher(scope, receive, send) if self.backhaul_thread: - self.dispatcher_future_or_task = asyncio.run_coroutine_threadsafe( + self.dispatcher = asyncio.run_coroutine_threadsafe( run_dispatcher, _backhaul_loop ) else: - self.dispatcher_future_or_task = asyncio.create_task(run_dispatcher) + self.dispatcher = asyncio.create_task(run_dispatcher) if event["type"] == "websocket.disconnect": - if self.dispatcher_future_or_task: - self.dispatcher_future_or_task.cancel() + if self.dispatcher: + self.dispatcher.cancel() break if event["type"] == "websocket.receive": From 473cdfd714a3407fd30b8089d560ee9de8603034 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 17:11:09 -0700 Subject: [PATCH 17/84] customizable block size --- src/js/app/public/assets/reactpy-logo.ico | Bin 14916 -> 0 bytes src/py/reactpy/reactpy/backend/_common.py | 2 +- src/py/reactpy/reactpy/backend/asgi.py | 17 ++++++++--------- 3 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 src/js/app/public/assets/reactpy-logo.ico diff --git a/src/js/app/public/assets/reactpy-logo.ico b/src/js/app/public/assets/reactpy-logo.ico deleted file mode 100644 index 62be5f5ba7e159e3977d1ae42d4bea1734854c1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14916 zcma)jWmFtIu;?xpgE$A97d)X_Uwdvv?Me-oQ5WX=9mA-d)c$P+wIuN zndWVyXI9yHTMmrOy_bfo!+PuenlmhrV@_EN^?yYd#|R5j?4^ofgw`d~qLR{T?sQ*6 z|AUQDgn!WZ(JO$hW&{gS{0Xh=u@{A0pF%i1v=Zz`0VR2o$OPNQIi%|aT zN2FjdZ5mBwmjqN6i%Kqd5&u5F2d_{m|HhL~C56g?@C^}o9sOCZ$6Hl zcbht{sQ>2Em32p%$|am2hdE6h-l3f^pWa?Rl9QUlD7oiXL8ZoSl{M!44=qn+1w$sA z?om&z?t_g-#%JP5KIRT~g3^)MbcV%3+6ab)y~*^Og*_&Pg+4&+%X?tD{Ni3tx?b)G zi^6CknQ|XfN}@EM!uKgt2ZiVv(|W2yG6dHeDlB#$*o;SIg(sVtgbjr#N`60e=%iVq zkD-&SThFn60M;v5L9j<=(^T~B*9hM4oTgq&@1G-n?2oDmIa>m;?_FOKl(h%|93WWb z4##x8;t^W~+@4Rb`U!&!88_$(oR(K?oD7d0x*Vkorfv$ngg%jg0azaaYj@tu*ZSkZ)vQH!g)>8>b*DH4Yxl1hjv&`pO+}tJ1+5kLBg3 zVBdFKKiw-i@1SFhePH9%LFLyo$e7A!ll)87LLs}_3N2%cG4}D~>(w8PNF89q^8B#J zA1u6E$2AGRns0sP3Afys4s&~e0_mozgU<^LqDqPfmn`lXc9~qp(nAO7#r<{t>MY7p zw$$_ODo^9nc+f&aePwS7ZCIKUt$qSl6Sx|}F1@r+scSCKL8qHzCn?fG%oo{;;Qc5$ zRV5RQulDi6!^d~DXH|SBIOUV41*UDQv-GnD{yUvjBUE??#{|uH)?&ZN%3t>CTl)t2 zI@hm+#7Eqc29gxSXNgotH?g4O7MQD!&i3}2_8o-Yqd95uNLE+vh2d2x4(u(G2~Jh0 z!0UA{3Z$<2z(>epeP80gt;)`;fNhnnl$e7Pe?lrH!`u$Q`@Hy*S|B~JVi-Uf=XY7M zvSz(Uz(%jVzp0Y2=w0$+8)+nXwPv?=2TtX=AvYK87D%zGRp@l10{prNP=Ur$1)(*n z_O-9JR#3$}YCUReN0blP+k3)3WFsjgH@|esag>U9e!e8exm{+XAQNuZu=7{-~xthUwkTt4n0UByM5KR74^U$h(2WcLD3(Yoi+^0W$Zo(XzVSwV0DvNjYW zQL%a#U1W>)A;^l=2Oll3@~Pfu_7qLA&R|1n(AuAvnFxqsYST(bNYj>}bY2DTv@$d^ zyG*q2wjC)Zqsm2#S;Db~Qu^IKEZ|S0M3&mcei)&A^hB4`piV{6IP{*ZCbR{qNb9*( z*xzgzaAIo88?A$1%^r0U)KuzCSHWB*vt8l|W@uewxHPBV8Ahk-d%`VDy`$maKG zcnm}LT`_;=_#;u_rd}1}reuC;(pn1L!|u{d@VT1LJdB&5G5aJ^4!0DlNP`C4<}Xwt z(8&~|gSS@AC_osUVBajVtybD2altr;cip^X>!i?6#ZjXq4|0D(Zv?1%C?(|CZqcTB zhKJH&Bq{cR*HG726RnI_UEoiAKoxoN{{hv|%g= zJIfa@$ilYyg^RJV?}1f#*AOZ0A-@MsiF7_*okxIDT~d%W_QbJ}_{D}mf&k9x26j(N zK@@h>-ohk&Y0$dcamei{H&tR6K8qpO)oawnk72iUyCzoF^Eqazrz>B!2N;eQ6F~U( z#-;tBC(HQHl5RcLFAQUUKcUfV5b-K@986+Pmc&7d_j&OW{g90rIxxBI?3z9V+o(t< zp-^sQ@A6QA^mW`gGBrzpFm4MDTJ|1XT)=TTw#hEGhreG|^wgAPMn1>ipO}L~_r?uf zF=ze41Q}E0zkcCsV7?QP96Ity4ong80KypoDnD?COQ&9W1&ks*C_K(atf69h8+4B}vNCv2 zJ|Ie}JUFY6+TuALDH;)23cNpZdJ(nki4|gZl$anu>goiCL|DF%!M*xCeFUm1lIp#( zESLKxhrXzB&e#Y9{hIj;;c1{qO*wZqn8wO#=E_l7$eozHj64rK5xyn3DFrr5K=w67 z)9fjIs{pE=hf7+K*98iT5ji~wF2J(8$o6P2Rm`_$O>c0iZvfr_)GH_LrG>CaOOOqa z1#H_FnIK_)&YJ8d!Y>@rlR=6%PAAR;2!2AgSA_B5eZAL6*Gt4ajqo5fS*DO;i8WVf zr3~yDMZSPuNofr{1Y!MM9A`eu1{N(Vp!J52cNbQTiB7H)xmkU1_M$vtH_uh!_ zy*G^Tj6kS*GS+App(`@Ui-FQ)H6inFlhgb^gj+dq>rr)zua%-&Ys&4~$y>Gj)ES`+ zvUBr`6378M5iNoONmalwYkI0jny4Zef2nVKb?<;7JrS*YBYp80NO?^Sn-a;W6>_iy zb?OFN%D%_FlI7P_!g#dcN^9bwp9XOD&%(`lj!Q-^>z<@_rCNvWq6JgN?LT*~^wN9_ zzL9G{DBpi}1)7-niOy=mmAz=LE?tIVk3`aNQ^b0wbU5PgdA>Vq5DUKr*^VzCAATDB zWoR_I2@X>3S|x^kh#QZ<>uvuGot5y|c5Y9!s}n~`D2Qxlo?>pVEC`dfZcaCLuKKNRPY;B%hb`0+yw);SJ?2$`z*W% zkTiLbL=OwO7d=DanrBGe@2Bjh*zwv&r>m4 zP7o>7?kCgB@N*?*Iun0u7c8U~b1K3qzhHK3B@M8F9l05Q_=~N!9u9b<3`NW-mjB4? zlRyuW@S~Lf>tcj)uI9H}S$?^gG>a!!(n+q%%7hQ6y{={aS_?t^>47x6UY?$hdXMkM zn5Sy!g&8=4{!%Zpc^af2`#hB&H?H5+>6Gn@xZ_MHn8Bz`T^no*k?2No68q<%2GR0LQ$j$%g=* zW)^(86!5d{`OCOCaNcHaxRmUWJiE8%tB`1doMF4+^uhP1a3Okm;i?m!$5h$TV=^1t zJ`LYK1jo79_M&Ql6Z4K0amKBWt$Ww{Gvd;*3bL$h$;^}a)w$FSikOma;gjH!k|bc# zqY&|8ZYYsQ7WJLT-_2@kRBBWS(#fUkI4d;qWg0JO@`07hg8p$xFD_iNMCu7+LH&h|M(v9MQlQ%d`H?$ z#wTaC==pAbj~@ANPt0LU0`()Ns*s<5kiB>ptPzDPzdulIUyh9Vmi~KT5x)Pf=t0Yc zU_q84rO<0&=t=|)q^Bx}F5e%#F@%H+4i#U5?Jy`!j5o}o2zm*m@<{~7YJLrNT8l#r z`kodUQ93prA^E};*j^`nb2Co>o#ioFkW7tGKgi=ZpdHssS=5}q=bP>KqCppSf_21@ z;>b)~;4gB#{IO$T1&DGm-uYBK`%ck!y& zUkdfRPv7c+%rFJqtoF}*X``{QtQDC;SYr_5_b>HhfpsqnsZmCvE(KZ!rQ6kITqrVg z8PU`sSg_27 zg(#Rl11c6hDS~vste7g1z0y@Ya0E-&AFw80$hA_EdS$Eup=*ORh&g%1u|#5;u0u$) zENRRo(ivN-lf4%0G40RBEX!}9Cq34V;7Tz;KMG$rEDW!5+ukmR?#cIH)y*Ww=ae0$ zcY;nSc8$lDPF59W#X2-L=`jwfY&TC2?|NfU{2D-hYso*_u_MDJM|ZcL3iH`ZVCOnP ziCm2|on)*qiY$$~ffRjGK6%-tti8 zT$2AuDj}=Fs}|yn*JhbAFh><;X+u`pMbG+nNE%L_>a~^ahj#B#r|LrX2)h{Tu+@P1 zVP@>>>Aj?aRE8d@7%8mduMw>=8AMn$JdIJR8sO1*PkdY2U)M$XZRea*lI_a)t>uBG z#Pgkr4jI}C8-qT&etzkkAtLs$ZW!0l4|9S8Oudm?y($K-(e6ObOUey8prFoa>O*11iOL# zo5U{G$2le935x0S`z)X|*PBbADA^HjY9c^|FPwftNDlSHo6Va1^xGTf=j( z^Cq1?(bgN(j#u2vEbD=!$_X{v*9H-ev#a??Y=B`0CIx|idO%rl24gsUPTDr z>~@si@yjJ!hE~1AY=mwNi{FnuV3}>bW-NKy7F^XF*NDk*h7U;*^%zRZar zNG0G~nxr0&Z^|703m7J)4ya)dR{GLc8TB*EyQkG05{-qCr~gUB`dDhMD94|V7)sAj zd(4bJzwjWqRcK?wso`jU-sft<|JFtNEvg}Y;45wcp=&HAyEf{x-s@ix$;(zL>2^kh zP|TzAzI6G1@H$SVC#G5oq&J;SQx>d$a-taxpg8yTuAnE{0DlXEox1nGAe+mpwk`k- z8ht{8eub!N1PiUn!*0xM)J3LEG)rKM%~z%^*rF^$6NAJ-KpT*v}SO zQj2}OED*b2G5aNP`j>6Mi1!*fgLd?>_!FguSbEm)oHe zt_ejd+FTccFoFP;_duj&0hGmgN>A7w5-H?Che~;Z-PX`B*U#)=N`c4Zp#ho6UVNN} zBO8%}<8-ZZkt5r>p1>dv_6CUSeE#`o(Tixlmf5;uoEOO{f=6o5ffeoQv%dw#liSNi zHg0;>3hl2cQJ)lCH^!ygs2a~-Ygnah%qk}DMNB`pex3!Decm|t3H(lHk5~*~GlT_^ zt;W?BPC^ysNZ6bz9$V;$eS_l8pp-R*g}@fp^hTKW}+qFnA@2iH14 zt+GO7SKjtclKM!$b$~;$RiAhtEko1JvuVNHgX+{|i^`_I0k03In>Q{MyENB+o92Uk zVwG-ZbN4~*MGYl$$bCyGvo)VdimF6TgaCSmj6Ov#Bs1A>2QuJCRX1`A^xq*?#83>A zgLX|?bv-8I1B?&Or4!m;o^~?7VVfw&71tnK>xVcM!c+~%8=s`PSNtz+FM9;ZI>cR4r3>^Sr~DN{JJlfBE~sv{VAFQv)9nX(2dXTlg` zl}x73h?vbJY+oIRL#9Z^|Qua%b5ybT#$GTMD#A#!X; z85#;*drrr=pdIC$enmuLd|f|80@ZxM_MF(fAq|tdaUPLlsYS6JHwWl=LAY%j?er!D z{kI=VY)j^^NdQ$HC=|!&6uB9R(17F5oB~`x*GoN2)t`c|A`|uU1QKui?oCeB2N#`_ z6`_*!^6m%T-!V}T6qWRWsFkX}4|4{T@IK-!pU+{)_i_v{QCt9xEu4se>*B-=C6tNP z*VbDXY|YPb*+uEQF3XW~N_FS?ewsQt&Bz-N%ip6(R~GukUHOCATuGkpjXY*YJ2@PW z9yU|qkzP?+&D^sk2a-gWjD{xBd+ad0`$8x!>Tzoiu-$XIqhZ0bJoAE7y25HLTq!^R z;a_;D-g%l#y(VRKUwJ~(!K_D=87Qn6`7{l0dFCIJ`_l*H^^EzseL5AG6_2p!qmpMS z)p8YM`!$hWvU_XqU3M#b(;r(7{762stK(hCnTaYU2v@6Acf|2@Z!_*ul&czn9nbsf ze4f(*sud@S15cO(6n8@B*S-!BYRpBmmwhXRq5VZ7+!Qy14^=kb{p|xioYb|kG(g8kfLYH%^*z+x-=SXTtMtHI#b-2fwtIZLEPrGL*obbHRtw@9nE z$RLOT6Bec+S+LJQKGWtipO{CClbQ4-3b2XtJ)8wP0=yQ7W_)NlcJB{$-{FA{c%}}} zmBcM-g@7JDau$rT2v=$U7Fe_OxYJ}mz}8bfE)3C&?5$B^B!qtAj;)_8zcP3D%q$U3 zVD7}vkJzZUD;b^et;b;3y)(%6m1j>2h<_BrP&Sru8y4~TCAhq*4SqjfI7PS zCUj;BpR252cV!yC;x%gQ7Zk+3wssG~w~?FCN#GI2cn@N2aDv_McR?kb)Yf$*W~*66 zn(5M`>?!wArdj2&jDK*6AP z{=y!@*Hu!T-k74P&&2tZW3| zKGx=XTjR|1Ju;`;wHr*>v9X9dol1L?O560j0!6OLxd%tTA&m>J@4IdErWf=I#XVJk zPd%0?syA4wSbz)OYS7XVCF6`R!7AP6hJA!o;l?8~gEst5R~Hnk+o9)7%Lbcdfna=6 zM{xE8sthC(LLJoiDlPz#ADs6%PVPKp)d@t+nvz=xVe8S?9<=eik+x>q2@3z_?Iu@i zd97V3Nl92khw{`V=%8l|sA5GBG|%3jQdxoy#w4YMRiE13hJGV$6AUwGVy3pYwh zEF9C!L8M#5PcOrFq!-+aDTQZhoiY2V&yC>Iei6l!mMcOdczp z#o~?iyHlhRe)vjN=Jb?Be_(dAvpQh*bG5WNhwmwRTmIjTMQnTG+WNyQvG>>y<2RD+ z$uQq%bT_l?w(F=`sG*B6RxvNaq^s>!)MgPib!SiI_vOXbuWM=d{M~t^v2shQx+8a1 zfEVl&IJCStMM8h3P>e%k`=t1b0BDAys}Y;s?=Lb(Q(&@hNq(OF`R&Q}tfGS|F6$Z- zrd+I>uIaGn+cXbDKRMonp!ewsS2H`DuC(UO^YDiNrgoW=V=R$sJf%ce;}BlqvxhRG z${Mt^BeRq>CE)w_6CZdAd@Bq20y;`saorSaKe=wOTYh~xs&TB*d*aM;Mn$cN820QdwFtOPav)5>7qR5rZdKMN|?8%v*-mt*bWMelqyA1gvw>@BH>2V?Sv$oHH zYh&B+6+zeL^yxyjo9U7U5elU4^;P|B)b{6)UHFTe%Oy7P&gJ@MNu3iq9%f944nwvT zOnsZ$)gSWvY%`+`JCLvn*Rmae_vkEJmu4O(s`_~t0CIG4s7<~9NZ^EbtT!v3+r21OYyT^SM=VGqDi*Z)4@}*`o56b#DUJ;W{XhK*1Y@nBgyo0 z+<82;DRxa(*r^}2JK~Ska^a5kM%3nnO-Cp6F&yI=_!p0M!){tr>!_QWxM4@p=$h1(>y~Iu76ExGV6} zO*e1ab=J^QEmIMy2E%gyJod@Q8!Kly{a;QS@tbCqa|T>~8@r%p#I;xlM$m}?CxVFeHnuy@?ys#c<`3H3_w^C4A~phVoPxTL3 z3)SRqhJPECQR3=BWQQP#yjlTdi6m=OZc{4q-+RwmI>kP9z<7&jC+f4WJtHoFADED1 zLxJptD4PfqV%?eWTX4WaQ1LquH^BO2&4v0>AP{3YX&lfl2Ia8-Eyhg{+d12NZLouL z+k)zKM?B$1GG+ph)mkYQjZtPN+csOPI))z6eKTggJxisK!~RBqD0My6=n2 z%3Yt=LN0F6z{)S)43) zJk^ScKg5TyM}JrmEd^WVC3ZE<^E`s%GL@dGpNuB&FuH%O*?ZchGPh#6UmN9l0Ou53 z{pB18cBrE#G(GhHP~uz6-VRW2DAB=v&l{gKl(JfD^{J^k1#hWO+tTL$?bZ@!+;MoBYIV_41&Uh01AyaHWW|@k)4G;? zi1o05*?#RmI5gb-g0UEQYaoCA{&S;(!C;^<#S>0ebt1qwog*ibpeZ?dEnbpDbhc#& z?#CZB1L3Hl+}x^V$ttGuRZz&ruA~P+IY-}<6HNX0if%6f>8he&icA^ak`yxOZweM^ z_5`wJn2rS>{dF6uSK<9-o9NMF?L9Ivg`*)oL}_i zNVs21y~XqX)?zIGX+Q0Q!-ydQ64=`>^)UN` z^R)RXMkjU!hu4~mUX-9RBaHj#-|j|qqXdR=#thmc+=ZeDV;TZFgcgf2i0q42&AQova|?d1jCtioNPbTi3s*wQu@@0bGG&zbo%9Cp!>!`iMIO;WLTzBgwxhG*4pbQDzov&qopb$@@TC3WwqB~L zF5Lts`2|KbHV`c~&F~b!VrLe--h-hhGP=-w0#8?){YK!_-}goc&$6jV)#=G(k8PQo z9VZ~_f{s+ALDh}A8T^m>b@!!QAiWXeLAptdW#Ho{-0@0 zgu32;%QkJ80W1bKs~7}YsH7ZO;hANdkRqB3UkE}NRn|^;Mg(v8;qSn?1T(+Q(25fU zyAeCMPI!x>MCSgO^I{aUDiN3!o$-L3agAf|3HO0E7-O!@c!|wGf^bHU?0a1WgQ-0i zTyfD3n!{N`+8~R1gmOICvav6e*67x<_Sl`pUALEPutM-c54-fY?uC3Q-2|QxBT_+ zxyg#ePFj3CP5}AYdq;gjwS5o5)-n79;K;+^g8rtm;y2Ox2rU!dFiXkI2Pm!<+e|;h z{FjvyAK5x$SRwAd5x2s1zZhI;ZNdig)e%e+k_iU!CE_cvE7eT^?_w6U|2WpNNaK`KD-HD@SY|RlvptFuuFybI*R0&iEs8Y}16nybEpkHPGeT zDu5r`IyBrJ@E0yr=!}sob*dJQVyPvSQkjOmulJs&#fXj6+_6#NqSkzA+}c`vyxH&C?OV83L$Nh@8!`+9QL&2S&}r717DA z$2L19zaYI@{V)q$a0z#2@9-GT0@W1da zTiu?im-y2?GLZYRyLy8JmqOkANl1LrYC7h+cI21mQ`7YHD>!3Z9Zb3Gxk)ufbIS33 zlAVRTyOXmlh;W?CmO8rC)Glz~M3aW1;$m=IgS`ohy0yK{uip&gA%C(y#s#`iV@DiC z;88jLv@aJI{3!9K52L#G#e=(e{+%?JniN(!6QaCd^%;LEzQcc}RmC>!d*U@kjSFN@5g6H>6ogE}5EFTt{>V4_;=d87ExoCc9gTX%MskU~pppE9U@dcNv38g`?i_F{o?rui-?sU=wb)@`5ANZzH%V+E0)h?gNfr<9{=)LmYAV;% zK=BsM$?BKE)K?w2s|ovFlQwGQg5SdK!_cT%P{MEW$Li;Gw|(`I?>!+F%Ey{65+5Xp z?5P1QJKl_a-RvK2s+jX^H`wRIq4YA=N1!}u$|%$nfoVdb^p z?u{?-_l(m?Rt%@>ZeF4(WR|d`JxOE^TYdaahL*k!^WlAsFbo~^vCA|s$){ zFe?`59VhSv1ZH3YzO`L5wloQq%Wy2k$lfe)6Cd`1V`sgyg|!d6MQ>naO*Ei(xWsaY zKXo8XH#AD95@l;G9(rXQcUyI&^zTDPqi2aVLx0Gj0mQ8mM?XBDqQELIZJ0P8!v?{+ z{LM%&fH7}WRzfn0Zk5I(hHHlqBz1+(VPirh$5$`p8)hw-54e3iv<3d`CPB=D4cicR zt;xKsG|f+cBQS?(7`feq{2Z$ZoG#@3slPyA{IVvjHQ zH7v_8m{2r+fS@hMtK4A!Vxru4pzu%q?XN)Xv~B8pQd^7P09SpaT>s1K!`zmQfZ%19 zq$e-VIi@M!A@rUEZ&a_x(3fy4-PzxY+k{lV^!2BP z7EQ9qpiC9oP@jSU#Rv@7jgWf}AIVk}<)>Ox7nc4;~TO8fLTHKcLDVIOe`ZuCW$n9#L9J2*~ZwM;Nd4Z$1%iDYkvM$ zdg0Fxyjwhk$c|_4fgi2|BNd;G^k_S!f+3@|UG_9au(|zw+M_#|h0N1-d6!VOc}a~O z2$lv?sWvX33~lxg@D}HU#4CEYbjBQ5(|8BTF`#vK%myHB^apN2=X@8qLjZm%#y?xo zoeyTV)P|S7(wm^)4f^-_Zv$xcr%i^pWEI96Ce#1%9d>CCO`VxZ6WnuOOUfN^h=Cvg zYI#DMnEA_>1dLq-+#G<`yeDR4Lu#Ams0w3}LVr$*?D=%4(v`N5e1Tftd^Y7k6Dg~0 z5cx`|v^#a^;R#o=T?xcT0c9@_L{8^tM^27ri8;WBp65wtydoR)en|4UkY8TxPMr*h zs{||Ndx49yF}dkVe@{AlZ|Y*_R|HcQ+-GjPd{)|WnJ!NQ)+?s@Ow zdVt)#=1!k`Hr4iads4YG z-eu!NU=1qK1E)NYzpQyYbbNjw{$Rm_^bq!rko-H^x^inH_Uq2`{jTNJG(rs7l+BrC zk@p7ze>;^AztybU>KKwU)z9_)26b=rqcOKWtoe7k0gK_1f)|ktH0{wm<`)H@HL;@2E|V5DwaebYIc))_Q9`!air7e{9J7;c zpBOY`nhx8FN7y%4zZtaItC9M9%g)BsVOmByy~}0J)4&zsSJg6hmnO5rG-&Kh#*ZM? z!{dLK7-myUjAIBq{yHMI=9%+v6={EWX7*<=k0KqTn9UTQ(@%;1LVR?Vs~8+Th&Ig$ zWI>-tCcmvT$MlFN&?Xc<_*{=pfA{ebrCTIZ-{a$~V$+verfmDbEPovrgZ;L^;ta)T ziz5_VNL=Ek8fy(v$|9#6PjWy*+E5)?&q1E&juJ<^MluDr&C|zNpKTW+*L*;sJDaM{ zT5%QH^n;QJLyuQUqntVVpM2JFEBM4aU#3utrBLI78U~Oy>N)PMr6OF~PKd-T?GAQN z`*SHjHO_UN#1z=d}W*<|3I4 zh_)G~JF8t%cyZ>{Wx@u>#zj(&_+}N^+1R41E0-0p!9?fqX=~i2U`%k7jp7oACVqRp zP)=DsL4(#+Ga5@}gk}ausx>#~jO5*z4Q@@=R63E>v$=%g2a53Tk49+v``f~RG;x*( zKUUkm|SJQ(`o`<<5}usG6{4q%&$5~uqlNG1r%Tl)@Q?_g*qFsA&8~O}R7Q{TL{YXGlszc-Onz1D*0;&BXlTad8vD&jhT6Vqgmyi^ zU|>Ze*8uj#cqMVry1mS{N{{VDdB+z|D_g%GHz6K0Y|ib8A*-)i)ZM}erq^NF85`=z z@p1p!{+oXXPmeZv+!VEmrzmCUn=_{GRGC=XXt>p_Wzxsb@b5Q@qg{WhnrhRDVNGi5 z;QD@SvV2Z0eB4pwkqC&+bomXElmZLETWhKNM;&HM^!gFbHpfl>&CU1ok7KzwCr}t- zYSeD_G$e1(J@RGhM)g;w>^y^5!VM-%uZj4H`9$sl3`uS*M(MJ-Y**<=i`sHQJ_hcM zs^=n{R#9-ldK0GU(0M$*V@BAUzG6xS|Xq;Rg-ikS+> zy$RLXsq^2N0rk4;2FB3W4sb))HA=y;mReh8F&?KFIH9*nFVkwbzzx()cb zzIn}!cb+X;ztJP_VmJlU9WtJJ!-Rc>+}=l%fwKGILWNG^=JZXMFP!O*O6DHgrD)wo z&q2jR)sAgydWB1?0+hgC2)UNwc;7V%F4jWj`5Ws3lZe`+uy*@T$N?nti)ycnSC@hj z9!yPV!cT=)dk{WoUtIAb)u6NJ(}IW@pipbB@`!6lEbX=lhIf8R60eFk7U-T$C@(ab zR;jcrhkLMVKY&QHm>)0ST>z71jr5gGjfXhNL-8wMLu$nFiEnq_H@j(-@%Mmx(aRO% zk^6*%cgDu{_wuT%x~nD$n`$#A2(U}4s(Zm#0GM_tsOyM^H0Z@pzqJUZnPO>+?JAPh zew9NLMPNFsO*Q=_b5Ri?zh5&W9tn6irIpM@2vu1>bzAh1 zH05_w+CAk`IWu|>zB_)hJfU{*hblx=Lk4gF(gsb<@d+Pe0iqK!-w%=;L|l(L0W0Z~ z7`B!@NO>=vDB-Kf3_Q9ZAsY(cF8)5El8>LBeXXc1Uw*=={3r^hq{v(zicaCUhoyl= z^531BN3DokKC-QNi7X( zAnP^e_<GNALwb3X}n)j4?3|ZzMztx`4Yb?{u2I^AFc8L=kq=W${6LPR$idX?o zy0?9Btt;LUm7i(cOOr(|5u1-;6gnGeE$_ikNP8!!!r^8HjiD*;#0HX51mFHC;$MDG zdg7Qj+;;uPs$0Q@BcFNwi?iKkJEgw??RDH>2OQwbuA3DEPKPb{C%)XI9ifTi2Upfe zXEhL*^@seaB!X7n4viXvMtQu*+VwGPRT;aH@fk7ZtUz-{*QP>~Oc}!O7b) zJ)Pdm)o2OYxF6YGfdo5|+nnmyZvkz0+0EvKh5Uk&!5yqUdj@3q3DPqOVRU8*YXBG6 zlM3m}v7$8Trjpmm)U;V@u!Y~0T}AC@rFR@z!-yqc7#IUYvd)(jBXpQA$uMryT=LA7aA9@xE`@{Aq zY^;s;wCO$firv>0(?lvoFK=uxVjx}px&h`z%qESG!#lCx0T*+*#+SMwxh=?XAU!Vu=;SAJIfn_)=XLcI%i#Giu|_o$=i^_>4Lo$o~oTHNwwrU@^!l*BdxBdNB6| z2#$NR0h9HEBy9+8kI`GZ6hx08@fnNV`*%m_ORjCoaYwah0W~X!zf-9t2?3%0PHs~L@tf@zvA zM#a+ZCpu-14@>B(D)ag zn+aWj#v7r&hXHKYyFPbAl`wnG@cchgn~BJGj*$VL_0SncLTvdZP&Yk*1RjSb#N0*g zu}%I)*O}#PnaeT$U>^8BZpM4}Z1ZW^$=Vhbw$V*eqv`>GLX@z#aXS(W?x2Q0%#Sb6 z>X%(T#|vx}Axyyb1<0xj$nDh}+k&H&TPaGq!agUvfR4>;7l!7A07cN>dm@JRp^6pe zjT5Hhfa+{`Z=$_dD!dEE-rjIoP4%C-N`aRZY+v^TyS*F^P<}9pKi+@!eCh5+pBO$Xos-m399WDUp|!{x0Y{FV z1F`aXVen!xkHQeHWMh&#F?)(vm1{GA2Z)Orc)!=+UExIXw8#UeaFo|frkXBsjfk>` zMxF`XUq1G9Wx>U2Rw?@=@*RFuG}J&7_VM#N@TmQp#Hwc|n*^j`pqN5B?Plf%jE_^> zYTk_7PvJo*dBh)okdzBSq^Zc5AYl&oWs3Nzr8n})|&YxaOt2Z`vCh`F-3|e3~;(i@`4Eu0yRjWCsjS`33^x?686tt{`$V6n%NE_D>7T% zlUs*M0|L%cKEg`09fa1>+;pnId}&Tw59-1Q&jJDqLt&>r1iJ6Pb$IY2Xo@t&Q2ji> zaq2Fq#VFj;j{xIvLD~OpB>Dd`oc#YPi^|-62d*#;?~!!~$Nw_`1;|J!idTvn1^f?o C+JImH diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 1e0c17cb5..43fedf4ac 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -86,7 +86,7 @@ def safe_join_path(root: str | Path, *unsafe: str | Path) -> Path: path = os.path.abspath(os.path.join(root, *unsafe)) if os.path.commonprefix([root, path]) != root: - # If the common prefix is not root directory we resolved outside the root dir + # We resolved outside the root dir, potential directory traversal attack. raise ValueError( f"Unsafe path detected. Path '{path}' is outside root directory '{root}'" ) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index dabdbba03..bd7d413f4 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -25,7 +25,6 @@ from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor, VdomDict -DEFAULT_BLOCK_SIZE = 8192 _logger = logging.getLogger(__name__) _backhaul_loop = asyncio.new_event_loop() @@ -50,6 +49,7 @@ def __init__( static_dir: Path | str | None = None, head: Sequence[VdomDict] | VdomDict | str = "", backhaul_thread: bool = True, + block_size: int = 8192, ) -> None: self.component = ( app_or_component @@ -78,6 +78,7 @@ def __init__( self._cached_index_html = "" self.connected = False self.backhaul_thread = backhaul_thread + self.block_size = block_size self.dispatcher_future_or_task = None if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() @@ -172,7 +173,7 @@ async def js_modules_app(self, scope, receive, send) -> None: return # Serve the file - await file_response(scope, send, abs_file_path) + await file_response(scope, send, abs_file_path, self.block_size) async def static_file_app(self, scope, receive, send) -> None: """ASGI app for ReactPy static files.""" @@ -192,7 +193,7 @@ async def static_file_app(self, scope, receive, send) -> None: return # Serve the file - await file_response(scope, send, abs_file_path) + await file_response(scope, send, abs_file_path, self.block_size) async def standalone_app(self, scope, receive, send) -> None: """ASGI app for ReactPy standalone mode.""" @@ -276,7 +277,7 @@ async def http_response( await send({"type": "http.response.body", "body": message.encode()}) -async def file_response(scope, send, file_path: Path) -> None: +async def file_response(scope, send, file_path: Path, block_size: int) -> None: """Send a file in chunks.""" # Make sure the file exists if not await asyncio.to_thread(os.path.exists, file_path): @@ -289,7 +290,7 @@ async def file_response(scope, send, file_path: Path) -> None: return # Check if the file is already cached by the client - etag = await get_val_from_header(scope, b"etag") + etag = await header_val(scope, b"etag") modification_time = await asyncio.to_thread(os.path.getmtime, file_path) if etag and etag != modification_time: await http_response(scope, send, 304, "Not modified.") @@ -325,7 +326,7 @@ async def file_response(scope, send, file_path: Path) -> None: # Head requests don't need a body if scope["method"] != "HEAD": while True: - chunk = await file_handle.read(DEFAULT_BLOCK_SIZE) + chunk = await file_handle.read(block_size) more_body = bool(chunk) await send( { @@ -338,9 +339,7 @@ async def file_response(scope, send, file_path: Path) -> None: break -async def get_val_from_header( - scope: dict, key: str, default: str | None = None -) -> str | None: +async def header_val(scope: dict, key: str, default: str | int | None = None) -> str | int | None: """Get a value from a scope's headers.""" return await anext( ( From 5707fba1b72b2830aa61893d8b422d7062d7171b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 17:36:50 -0700 Subject: [PATCH 18/84] refactor init --- src/py/reactpy/reactpy/backend/asgi.py | 40 +++++++++++++++----------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 8f035b4fc..716899b50 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -24,6 +24,7 @@ from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor, VdomDict +from concurrent.futures import Future _logger = logging.getLogger(__name__) _backhaul_loop = asyncio.new_event_loop() @@ -51,35 +52,40 @@ def __init__( backhaul_thread: bool = True, block_size: int = 8192, ) -> None: - self.component = ( + # Convert kwargs to class attributes + self.dispatch_path = re.compile(dispatcher_path) + self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None + self.static_path = re.compile(static_path) if static_path else None + self.static_dir = static_dir + self.head = vdom_head_elements_to_html(head) + self.backhaul_thread = backhaul_thread + self.block_size = block_size + + # Internal attributes (not using the same name as a kwarg) + self.component: re.Pattern = ( app_or_component if isinstance(app_or_component, ComponentConstructor) else None ) - self.user_app = ( + self.user_app: re.Pattern = ( guarantee_single_callable(app_or_component) if not self.component and asyncio.iscoroutinefunction(app_or_component) else None ) - if not self.component and not self.user_app: - raise TypeError( - "The first argument to ReactPy(...) must be a component or an ASGI application." - ) - self.dispatch_path = re.compile(dispatcher_path) - self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None - self.static_path = re.compile(static_path) if static_path else None - self.static_dir = static_dir - self.all_paths = re.compile( + self.all_paths: re.Pattern = re.compile( "|".join( path for path in [dispatcher_path, js_modules_path, static_path] if path ) ) - self.head = vdom_head_elements_to_html(head) - self._cached_index_html = "" - self.connected = False - self.backhaul_thread = backhaul_thread - self.dispatcher = None - self.block_size = block_size + self.dispatcher: Future | asyncio.Task | None = None + self._cached_index_html: str = "" + self.connected: bool = False + + # Validate the arguments + if not self.component and not self.user_app: + raise TypeError( + "The first argument to ReactPy(...) must be a component or an ASGI application." + ) if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() From 93d7c036fe17e2d3b3cdb30fde158464f693e5bf Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 17:37:15 -0700 Subject: [PATCH 19/84] format --- src/py/reactpy/reactpy/backend/asgi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 716899b50..e2c984520 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -343,7 +343,9 @@ async def file_response(scope, send, file_path: Path, block_size: int) -> None: break -async def header_val(scope: dict, key: str, default: str | int | None = None) -> str | int | None: +async def header_val( + scope: dict, key: str, default: str | int | None = None +) -> str | int | None: """Get a value from a scope's headers.""" return await anext( ( From 24fb8170a7911518577373110b622de26e24df13 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jul 2023 00:47:30 -0700 Subject: [PATCH 20/84] use starlette for static files --- src/py/reactpy/reactpy/backend/asgi.py | 221 ++++++++------------ src/py/reactpy/reactpy/backend/mimetypes.py | 20 ++ 2 files changed, 110 insertions(+), 131 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index e2c984520..7c08dce8d 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -1,37 +1,37 @@ import asyncio import logging -import mimetypes -import os import re import urllib.parse from collections.abc import Coroutine, Sequence +from concurrent.futures import Future +from importlib import import_module from pathlib import Path from threading import Thread +from typing import Any, Callable import aiofiles import orjson from asgiref.compatibility import guarantee_single_callable +from starlette.staticfiles import StaticFiles from reactpy.backend._common import ( CLIENT_BUILD_DIR, - safe_join_path, vdom_head_elements_to_html, ) from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.mimetypes import MIME_TYPES from reactpy.backend.types import Connection, Location from reactpy.config import REACTPY_WEB_MODULES_DIR from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor, VdomDict -from concurrent.futures import Future _logger = logging.getLogger(__name__) _backhaul_loop = asyncio.new_event_loop() def start_backhaul_loop(): - """Starts the asyncio event loop that will perform component rendering tasks.""" + """Starts the asyncio event loop that will perform component rendering + tasks.""" asyncio.set_event_loop(_backhaul_loop) _backhaul_loop.run_forever() @@ -42,7 +42,7 @@ def start_backhaul_loop(): class ReactPy: def __init__( self, - app_or_component: ComponentConstructor | Coroutine, + app_or_component: ComponentConstructor | Callable[..., Coroutine], *, dispatcher_path: str = "^reactpy/([^/]+)/?", js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", @@ -62,16 +62,14 @@ def __init__( self.block_size = block_size # Internal attributes (not using the same name as a kwarg) - self.component: re.Pattern = ( - app_or_component - if isinstance(app_or_component, ComponentConstructor) - else None - ) - self.user_app: re.Pattern = ( + self.user_app: Callable[..., Coroutine] | None = ( guarantee_single_callable(app_or_component) - if not self.component and asyncio.iscoroutinefunction(app_or_component) + if asyncio.iscoroutinefunction(app_or_component) else None ) + self.component: ComponentConstructor | None = ( + None if self.user_app else app_or_component + ) self.all_paths: re.Pattern = re.compile( "|".join( path for path in [dispatcher_path, js_modules_path, static_path] if path @@ -79,18 +77,28 @@ def __init__( ) self.dispatcher: Future | asyncio.Task | None = None self._cached_index_html: str = "" + self._static_file_server: StaticFiles | None = None + self._js_module_server: StaticFiles | None = None self.connected: bool = False + # TODO: Remove this setting from ReactPy config + self.js_modules_dir: Path | None = REACTPY_WEB_MODULES_DIR.current # Validate the arguments if not self.component and not self.user_app: raise TypeError( - "The first argument to ReactPy(...) must be a component or an ASGI application." + "The first argument to ReactPy(...) must be a component or an " + "ASGI application." ) if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() - async def __call__(self, scope, receive, send) -> None: - """The ASGI callable. This determines whether ReactPy should route the the + async def __call__( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """The ASGI callable. This determines whether ReactPy should route the request to ourselves or to the user application.""" # Determine if ReactPy should handle the request if not self.user_app or re.match(self.all_paths, scope["path"]): @@ -100,10 +108,14 @@ async def __call__(self, scope, receive, send) -> None: # Serve the user's application await self.user_app(scope, receive, send) - async def reactpy_app(self, scope, receive, send) -> None: - """Determine what type of request this is and route it to the appropriate - ReactPy ASGI sub-application.""" - + async def reactpy_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """Determine what type of request this is and route it to the + appropriate ReactPy ASGI sub-application.""" # Only HTTP and WebSocket requests are supported if scope["type"] not in {"http", "websocket"}: return @@ -120,7 +132,7 @@ async def reactpy_app(self, scope, receive, send) -> None: # JS modules app if self.js_modules_path and re.match(self.js_modules_path, scope["path"]): - await self.js_modules_app(scope, receive, send) + await self.js_module_app(scope, receive, send) return # Static file app @@ -133,7 +145,12 @@ async def reactpy_app(self, scope, receive, send) -> None: await self.standalone_app(scope, receive, send) return - async def component_dispatch_app(self, scope, receive, send) -> None: + async def component_dispatch_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: """ASGI app for rendering ReactPy Python components.""" while True: event = await receive() @@ -161,45 +178,50 @@ async def component_dispatch_app(self, scope, receive, send) -> None: else: await recv_queue_put - async def js_modules_app(self, scope, receive, send) -> None: + async def js_module_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: """ASGI app for ReactPy web modules.""" - if not REACTPY_WEB_MODULES_DIR.current: + if not self.js_modules_dir: raise RuntimeError("No web modules directory configured.") - - # Make sure the user hasn't tried to escape the web modules directory - try: - abs_file_path = safe_join_path( - REACTPY_WEB_MODULES_DIR.current, - re.match(self.js_modules_path, scope["path"])[1], + if not self.js_modules_path: + raise RuntimeError( + "Web modules cannot be served without defining `js_module_path`." ) - except ValueError: - await http_response(scope, send, 403, "Forbidden") - return + if not self._js_module_server: + self._js_module_server = StaticFiles(directory=self.js_modules_dir) - # Serve the file - await file_response(scope, send, abs_file_path, self.block_size) + await self._js_module_server(scope, receive, send) - async def static_file_app(self, scope, receive, send) -> None: + async def static_file_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: """ASGI app for ReactPy static files.""" if not self.static_dir: raise RuntimeError( "Static files cannot be served without defining `static_dir`." ) - - # Make sure the user hasn't tried to escape the static directory - try: - abs_file_path = safe_join_path( - self.static_dir, - re.match(self.static_path, scope["path"])[1], + if not self.static_path: + raise RuntimeError( + "Static files cannot be served without defining `static_path`." ) - except ValueError: - await http_response(scope, send, 403, "Forbidden") - return + if not self._static_file_server: + self._static_file_server = StaticFiles(directory=self.static_dir) - # Serve the file - await file_response(scope, send, abs_file_path, self.block_size) + await self._static_file_server(scope, receive, send) - async def standalone_app(self, scope, receive, send) -> None: + async def standalone_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: """ASGI app for ReactPy standalone mode.""" file_path = CLIENT_BUILD_DIR / "index.html" if not self._cached_index_html: @@ -221,10 +243,23 @@ async def standalone_app(self, scope, receive, send) -> None: ], ) - async def run_dispatcher(self, scope, receive, send): + async def run_dispatcher( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: # If in standalone mode, serve the user provided component. # In middleware mode, get the component from the URL. - component = self.component or re.match(self.dispatch_path, scope["path"])[1] + component = self.component + if not component: + url_path = re.match(self.dispatch_path, scope["path"]) + if not url_path: + raise RuntimeError("Could not find component in URL path.") + dotted_path = url_path[1] + module_str, component_str = dotted_path.rsplit(".", 1) + module = import_module(module_str) + component = getattr(module, component_str) parsed_url = urllib.parse.urlparse(scope["path"]) self.recv_queue: asyncio.Queue = asyncio.Queue() @@ -251,18 +286,18 @@ async def run_dispatcher(self, scope, receive, send): ) -def send_json(send) -> None: +def send_json(send: Callable) -> Callable[..., Coroutine]: """Use orjson to send JSON over an ASGI websocket.""" - async def _send_json(value) -> None: + async def _send_json(value: Any) -> None: await send({"type": "websocket.send", "text": orjson.dumps(value)}) return _send_json async def http_response( - scope, - send, + scope: dict[str, Any], + send: Callable[..., Coroutine], code: int, message: str, content_type: bytes = b"text/plain", @@ -279,79 +314,3 @@ async def http_response( # Head requests don't need a body if scope["method"] != "HEAD": await send({"type": "http.response.body", "body": message.encode()}) - - -async def file_response(scope, send, file_path: Path, block_size: int) -> None: - """Send a file in chunks.""" - # Make sure the file exists - if not await asyncio.to_thread(os.path.exists, file_path): - await http_response(scope, send, 404, "File not found.") - return - - # Make sure it's a file - if not await asyncio.to_thread(os.path.isfile, file_path): - await http_response(scope, send, 400, "Not a file.") - return - - # Check if the file is already cached by the client - etag = await header_val(scope, b"etag") - modification_time = await asyncio.to_thread(os.path.getmtime, file_path) - if etag and etag != modification_time: - await http_response(scope, send, 304, "Not modified.") - return - - # Get the file's MIME type - mime_type = ( - MIME_TYPES.get(file_path.rsplit(".")[1], None) - # Fallback to guess_type to allow for the user to define custom MIME types on their system - or (await asyncio.to_thread(mimetypes.guess_type, file_path, strict=False))[0] - ) - if mime_type is None: - mime_type = "text/plain" - _logger.error( - f"Could not determine MIME type for {file_path}. Defaulting to 'text/plain'." - ) - - # Send the file in chunks - file_size = await asyncio.to_thread(os.path.getsize, file_path) - async with aiofiles.open(file_path, "rb") as file_handle: - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [ - (b"content-type", mime_type.encode()), - (b"etag", modification_time), - (b"content-length", file_size), - ], - } - ) - - # Head requests don't need a body - if scope["method"] != "HEAD": - while True: - chunk = await file_handle.read(block_size) - more_body = bool(chunk) - await send( - { - "type": "http.response.body", - "body": chunk, - "more_body": more_body, - } - ) - if not more_body: - break - - -async def header_val( - scope: dict, key: str, default: str | int | None = None -) -> str | int | None: - """Get a value from a scope's headers.""" - return await anext( - ( - value.decode() - for header_key, value in scope["headers"] - if header_key == key.encode() - ), - default, - ) diff --git a/src/py/reactpy/reactpy/backend/mimetypes.py b/src/py/reactpy/reactpy/backend/mimetypes.py index 051d5c88e..ee319a2c2 100644 --- a/src/py/reactpy/reactpy/backend/mimetypes.py +++ b/src/py/reactpy/reactpy/backend/mimetypes.py @@ -2,6 +2,11 @@ We ship our own mime types to ensure consistent behavior across platforms. This dictionary is based on: https://github.com/micnic/mime.json """ +import mimetypes +import os +import typing + +from starlette import responses MIME_TYPES = { "123": "application/vnd.lotus-1-2-3", @@ -1206,3 +1211,18 @@ "zirz": "application/vnd.zul", "zmm": "application/vnd.handheld-entertainment+xml", } + + +def guess_type( + url: typing.Union[str, "os.PathLike[str]"], + strict: bool = True, +): + """Mime type checker that prefers our predefined types over the built-in + mimetypes module.""" + mime_type, encoding = mimetypes.guess_type(url, strict) + + return (MIME_TYPES.get(str(url).rsplit(".")[1]) or mime_type, encoding) + + +# Monkey patch starlette's mime types +responses.guess_type = guess_type From 5df567e81e703529ef60ae431c8801a03aa55ae8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 22 Jul 2023 21:42:46 -0700 Subject: [PATCH 21/84] local ws connection --- src/py/reactpy/reactpy/backend/asgi.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 7c08dce8d..80254eb13 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -152,11 +152,14 @@ async def component_dispatch_app( send: Callable[..., Coroutine], ) -> None: """ASGI app for rendering ReactPy Python components.""" + ws_connected: bool = False + while True: + # Future WS events on this connection will always be received here event = await receive() - if event["type"] == "websocket.connect" and not self.connected: - self.connected = True + if event["type"] == "websocket.connect" and not ws_connected: + ws_connected = True await send({"type": "websocket.accept"}) run_dispatcher = self.run_dispatcher(scope, receive, send) if self.backhaul_thread: From 57d47da0d9630416d11e138373795185943c8110 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 23 Jul 2023 00:39:03 -0700 Subject: [PATCH 22/84] more refactoring --- src/py/reactpy/reactpy/backend/asgi.py | 99 ++++++++++++++++---------- 1 file changed, 63 insertions(+), 36 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 80254eb13..f9f0e7d54 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -21,9 +21,10 @@ from reactpy.backend.hooks import ConnectionContext from reactpy.backend.types import Connection, Location from reactpy.config import REACTPY_WEB_MODULES_DIR +from reactpy.core.component import Component from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentConstructor, VdomDict +from reactpy.core.types import ComponentType, VdomDict _logger = logging.getLogger(__name__) _backhaul_loop = asyncio.new_event_loop() @@ -42,20 +43,24 @@ def start_backhaul_loop(): class ReactPy: def __init__( self, - app_or_component: ComponentConstructor | Callable[..., Coroutine], + app_or_component: ComponentType | Callable[..., Coroutine], *, - dispatcher_path: str = "^reactpy/([^/]+)/?", - js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", - static_path: str | None = "^reactpy/static/([^/]+)/?", + dispatcher_path: str = "reactpy/", + web_modules_path: str = "reactpy/modules/", + web_modules_dir: Path | str | None = REACTPY_WEB_MODULES_DIR.current, + static_path: str = "reactpy/static/", static_dir: Path | str | None = None, head: Sequence[VdomDict] | VdomDict | str = "", backhaul_thread: bool = True, block_size: int = 8192, ) -> None: + """Anything initialized in this method will be shared across all + requests.""" # Convert kwargs to class attributes - self.dispatch_path = re.compile(dispatcher_path) - self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None - self.static_path = re.compile(static_path) if static_path else None + self.dispatch_path = re.compile(f"^{dispatcher_path}(?P[^/]+)/?") + self.js_modules_path = re.compile(f"^{web_modules_path}") + self.web_modules_dir = web_modules_dir + self.static_path = re.compile(f"^{static_path}") self.static_dir = static_dir self.head = vdom_head_elements_to_html(head) self.backhaul_thread = backhaul_thread @@ -67,21 +72,26 @@ def __init__( if asyncio.iscoroutinefunction(app_or_component) else None ) - self.component: ComponentConstructor | None = ( - None if self.user_app else app_or_component + self.component: ComponentType | None = ( + None if self.user_app else app_or_component # type: ignore ) self.all_paths: re.Pattern = re.compile( "|".join( - path for path in [dispatcher_path, js_modules_path, static_path] if path + path + for path in [dispatcher_path, web_modules_path, static_path] + if path ) ) self.dispatcher: Future | asyncio.Task | None = None self._cached_index_html: str = "" self._static_file_server: StaticFiles | None = None - self._js_module_server: StaticFiles | None = None - self.connected: bool = False - # TODO: Remove this setting from ReactPy config - self.js_modules_dir: Path | None = REACTPY_WEB_MODULES_DIR.current + self._web_module_server: StaticFiles | None = None + + # Startup tasks + if self.backhaul_thread and not _backhaul_thread.is_alive(): + _backhaul_thread.start() + if self.web_modules_dir != REACTPY_WEB_MODULES_DIR.current: + REACTPY_WEB_MODULES_DIR.set_current(self.web_modules_dir) # Validate the arguments if not self.component and not self.user_app: @@ -89,8 +99,12 @@ def __init__( "The first argument to ReactPy(...) must be a component or an " "ASGI application." ) - if self.backhaul_thread and not _backhaul_thread.is_alive(): - _backhaul_thread.start() + if check_path(dispatcher_path): + raise ValueError("Invalid `dispatcher_path`.") + if check_path(web_modules_path): + raise ValueError("Invalid `web_modules_path`.") + if check_path(static_path): + raise ValueError("Invalid `static_path`.") async def __call__( self, @@ -131,12 +145,12 @@ async def reactpy_app( return # JS modules app - if self.js_modules_path and re.match(self.js_modules_path, scope["path"]): - await self.js_module_app(scope, receive, send) + if re.match(self.js_modules_path, scope["path"]): + await self.web_module_app(scope, receive, send) return # Static file app - if self.static_path and re.match(self.static_path, scope["path"]): + if re.match(self.static_path, scope["path"]): await self.static_file_app(scope, receive, send) return @@ -181,23 +195,25 @@ async def component_dispatch_app( else: await recv_queue_put - async def js_module_app( + async def web_module_app( self, scope: dict[str, Any], receive: Callable[..., Coroutine], send: Callable[..., Coroutine], ) -> None: """ASGI app for ReactPy web modules.""" - if not self.js_modules_dir: - raise RuntimeError("No web modules directory configured.") - if not self.js_modules_path: - raise RuntimeError( - "Web modules cannot be served without defining `js_module_path`." + if not self.web_modules_dir: + await asyncio.to_thread( + _logger.info, + "Tried to serve web module without a configured directory.", ) - if not self._js_module_server: - self._js_module_server = StaticFiles(directory=self.js_modules_dir) + if self.user_app: + await self.user_app(scope, receive, send) + return - await self._js_module_server(scope, receive, send) + if not self._web_module_server: + self._web_module_server = StaticFiles(directory=self.web_modules_dir) + await self._web_module_server(scope, receive, send) async def static_file_app( self, @@ -206,17 +222,18 @@ async def static_file_app( send: Callable[..., Coroutine], ) -> None: """ASGI app for ReactPy static files.""" + # If no static directory is configured, serve the user's application if not self.static_dir: - raise RuntimeError( - "Static files cannot be served without defining `static_dir`." - ) - if not self.static_path: - raise RuntimeError( - "Static files cannot be served without defining `static_path`." + await asyncio.to_thread( + _logger.info, + "Tried to serve static file without a configured directory.", ) + if self.user_app: + await self.user_app(scope, receive, send) + return + if not self._static_file_server: self._static_file_server = StaticFiles(directory=self.static_dir) - await self._static_file_server(scope, receive, send) async def standalone_app( @@ -317,3 +334,13 @@ async def http_response( # Head requests don't need a body if scope["method"] != "HEAD": await send({"type": "http.response.body", "body": message.encode()}) + + +def check_path(url_path: str) -> bool: + """Check that a path is valid URL path.""" + return ( + not url_path + or not isinstance(url_path, str) + or not url_path[0].isalnum() + or not url_path.endswith("/") + ) From 765a6a40b93bc2551e443302680dca5c4b9d1e1c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 26 Jul 2023 21:02:49 -0700 Subject: [PATCH 23/84] fix path check --- src/py/reactpy/reactpy/backend/asgi.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index f9f0e7d54..6a05b4ceb 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -99,11 +99,11 @@ def __init__( "The first argument to ReactPy(...) must be a component or an " "ASGI application." ) - if check_path(dispatcher_path): + if not check_path(dispatcher_path): raise ValueError("Invalid `dispatcher_path`.") - if check_path(web_modules_path): + if not check_path(web_modules_path): raise ValueError("Invalid `web_modules_path`.") - if check_path(static_path): + if not check_path(static_path): raise ValueError("Invalid `static_path`.") async def __call__( From a91ca9992dc86db2ca1d301f79fa2bd579291a1c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 22 Jan 2025 15:29:10 -0800 Subject: [PATCH 24/84] reduce LOC changes --- src/reactpy/backend/_common.py | 6 +- src/reactpy/backend/mimetypes.py | 1228 ----------------------------- tests/test_backend/test_common.py | 4 +- 3 files changed, 5 insertions(+), 1233 deletions(-) delete mode 100644 src/reactpy/backend/mimetypes.py diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index b21b15e99..12b0cff0a 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -67,17 +67,17 @@ async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> N def safe_client_build_dir_path(path: str) -> Path: """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" - return safe_join_path( + return traversal_safe_path( CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/") ) def safe_web_modules_dir_path(path: str) -> Path: """Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`""" - return safe_join_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) + return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) -def safe_join_path(root: str | Path, *unsafe: str | Path) -> Path: +def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir.""" root = os.path.abspath(root) diff --git a/src/reactpy/backend/mimetypes.py b/src/reactpy/backend/mimetypes.py deleted file mode 100644 index ee319a2c2..000000000 --- a/src/reactpy/backend/mimetypes.py +++ /dev/null @@ -1,1228 +0,0 @@ -""" -We ship our own mime types to ensure consistent behavior across platforms. -This dictionary is based on: https://github.com/micnic/mime.json -""" -import mimetypes -import os -import typing - -from starlette import responses - -MIME_TYPES = { - "123": "application/vnd.lotus-1-2-3", - "1km": "application/vnd.1000minds.decision-model+xml", - "3dml": "text/vnd.in3d.3dml", - "3ds": "image/x-3ds", - "3g2": "video/3gpp2", - "3gp": "video/3gpp", - "3gpp": "video/3gpp", - "3mf": "model/3mf", - "7z": "application/x-7z-compressed", - "aab": "application/x-authorware-bin", - "aac": "audio/x-aac", - "aam": "application/x-authorware-map", - "aas": "application/x-authorware-seg", - "abw": "application/x-abiword", - "ac": "application/vnd.nokia.n-gage.ac+xml", - "acc": "application/vnd.americandynamics.acc", - "ace": "application/x-ace-compressed", - "acu": "application/vnd.acucobol", - "acutc": "application/vnd.acucorp", - "adp": "audio/adpcm", - "adts": "audio/aac", - "aep": "application/vnd.audiograph", - "afm": "application/x-font-type1", - "afp": "application/vnd.ibm.modcap", - "age": "application/vnd.age", - "ahead": "application/vnd.ahead.space", - "ai": "application/postscript", - "aif": "audio/x-aiff", - "aifc": "audio/x-aiff", - "aiff": "audio/x-aiff", - "air": "application/vnd.adobe.air-application-installer-package+zip", - "ait": "application/vnd.dvb.ait", - "ami": "application/vnd.amiga.ami", - "aml": "application/automationml-aml+xml", - "amlx": "application/automationml-amlx+zip", - "amr": "audio/amr", - "apk": "application/vnd.android.package-archive", - "apng": "image/apng", - "appcache": "text/cache-manifest", - "appinstaller": "application/appinstaller", - "application": "application/x-ms-application", - "appx": "application/appx", - "appxbundle": "application/appxbundle", - "apr": "application/vnd.lotus-approach", - "arc": "application/x-freearc", - "arj": "application/x-arj", - "asc": "application/pgp-signature", - "asf": "video/x-ms-asf", - "asm": "text/x-asm", - "aso": "application/vnd.accpac.simply.aso", - "asx": "video/x-ms-asf", - "atc": "application/vnd.acucorp", - "atom": "application/atom+xml", - "atomcat": "application/atomcat+xml", - "atomdeleted": "application/atomdeleted+xml", - "atomsvc": "application/atomsvc+xml", - "atx": "application/vnd.antix.game-component", - "au": "audio/basic", - "avci": "image/avci", - "avcs": "image/avcs", - "avi": "video/x-msvideo", - "avif": "image/avif", - "aw": "application/applixware", - "azf": "application/vnd.airzip.filesecure.azf", - "azs": "application/vnd.airzip.filesecure.azs", - "azv": "image/vnd.airzip.accelerator.azv", - "azw": "application/vnd.amazon.ebook", - "b16": "image/vnd.pco.b16", - "bat": "application/x-msdownload", - "bcpio": "application/x-bcpio", - "bdf": "application/x-font-bdf", - "bdm": "application/vnd.syncml.dm+wbxml", - "bdoc": "application/x-bdoc", - "bed": "application/vnd.realvnc.bed", - "bh2": "application/vnd.fujitsu.oasysprs", - "bin": "application/octet-stream", - "blb": "application/x-blorb", - "blorb": "application/x-blorb", - "bmi": "application/vnd.bmi", - "bmml": "application/vnd.balsamiq.bmml+xml", - "bmp": "image/x-ms-bmp", - "book": "application/vnd.framemaker", - "box": "application/vnd.previewsystems.box", - "boz": "application/x-bzip2", - "bpk": "application/octet-stream", - "bsp": "model/vnd.valve.source.compiled-map", - "btf": "image/prs.btif", - "btif": "image/prs.btif", - "buffer": "application/octet-stream", - "bz": "application/x-bzip", - "bz2": "application/x-bzip2", - "c": "text/x-c", - "c11amc": "application/vnd.cluetrust.cartomobile-config", - "c11amz": "application/vnd.cluetrust.cartomobile-config-pkg", - "c4d": "application/vnd.clonk.c4group", - "c4f": "application/vnd.clonk.c4group", - "c4g": "application/vnd.clonk.c4group", - "c4p": "application/vnd.clonk.c4group", - "c4u": "application/vnd.clonk.c4group", - "cab": "application/vnd.ms-cab-compressed", - "caf": "audio/x-caf", - "cap": "application/vnd.tcpdump.pcap", - "car": "application/vnd.curl.car", - "cat": "application/vnd.ms-pki.seccat", - "cb7": "application/x-cbr", - "cba": "application/x-cbr", - "cbr": "application/x-cbr", - "cbt": "application/x-cbr", - "cbz": "application/x-cbr", - "cc": "text/x-c", - "cco": "application/x-cocoa", - "cct": "application/x-director", - "ccxml": "application/ccxml+xml", - "cdbcmsg": "application/vnd.contact.cmsg", - "cdf": "application/x-netcdf", - "cdfx": "application/cdfx+xml", - "cdkey": "application/vnd.mediastation.cdkey", - "cdmia": "application/cdmi-capability", - "cdmic": "application/cdmi-container", - "cdmid": "application/cdmi-domain", - "cdmio": "application/cdmi-object", - "cdmiq": "application/cdmi-queue", - "cdx": "chemical/x-cdx", - "cdxml": "application/vnd.chemdraw+xml", - "cdy": "application/vnd.cinderella", - "cer": "application/pkix-cert", - "cfs": "application/x-cfs-compressed", - "cgm": "image/cgm", - "chat": "application/x-chat", - "chm": "application/vnd.ms-htmlhelp", - "chrt": "application/vnd.kde.kchart", - "cif": "chemical/x-cif", - "cii": "application/vnd.anser-web-certificate-issue-initiation", - "cil": "application/vnd.ms-artgalry", - "cjs": "application/node", - "cla": "application/vnd.claymore", - "class": "application/java-vm", - "cld": "model/vnd.cld", - "clkk": "application/vnd.crick.clicker.keyboard", - "clkp": "application/vnd.crick.clicker.palette", - "clkt": "application/vnd.crick.clicker.template", - "clkw": "application/vnd.crick.clicker.wordbank", - "clkx": "application/vnd.crick.clicker", - "clp": "application/x-msclip", - "cmc": "application/vnd.cosmocaller", - "cmdf": "chemical/x-cmdf", - "cml": "chemical/x-cml", - "cmp": "application/vnd.yellowriver-custom-menu", - "cmx": "image/x-cmx", - "cod": "application/vnd.rim.cod", - "coffee": "text/coffeescript", - "com": "application/x-msdownload", - "conf": "text/plain", - "cpio": "application/x-cpio", - "cpl": "application/cpl+xml", - "cpp": "text/x-c", - "cpt": "application/mac-compactpro", - "crd": "application/x-mscardfile", - "crl": "application/pkix-crl", - "crt": "application/x-x509-ca-cert", - "crx": "application/x-chrome-extension", - "cryptonote": "application/vnd.rig.cryptonote", - "csh": "application/x-csh", - "csl": "application/vnd.citationstyles.style+xml", - "csml": "chemical/x-csml", - "csp": "application/vnd.commonspace", - "css": "text/css", - "cst": "application/x-director", - "csv": "text/csv", - "cu": "application/cu-seeme", - "curl": "text/vnd.curl", - "cwl": "application/cwl", - "cww": "application/prs.cww", - "cxt": "application/x-director", - "cxx": "text/x-c", - "dae": "model/vnd.collada+xml", - "daf": "application/vnd.mobius.daf", - "dart": "application/vnd.dart", - "dataless": "application/vnd.fdsn.seed", - "davmount": "application/davmount+xml", - "dbf": "application/vnd.dbf", - "dbk": "application/docbook+xml", - "dcr": "application/x-director", - "dcurl": "text/vnd.curl.dcurl", - "dd2": "application/vnd.oma.dd2+xml", - "ddd": "application/vnd.fujixerox.ddd", - "ddf": "application/vnd.syncml.dmddf+xml", - "dds": "image/vnd.ms-dds", - "deb": "application/x-debian-package", - "def": "text/plain", - "deploy": "application/octet-stream", - "der": "application/x-x509-ca-cert", - "dfac": "application/vnd.dreamfactory", - "dgc": "application/x-dgc-compressed", - "dib": "image/bmp", - "dic": "text/x-c", - "dir": "application/x-director", - "dis": "application/vnd.mobius.dis", - "disposition-notification": "message/disposition-notification", - "dist": "application/octet-stream", - "distz": "application/octet-stream", - "djv": "image/vnd.djvu", - "djvu": "image/vnd.djvu", - "dll": "application/x-msdownload", - "dmg": "application/x-apple-diskimage", - "dmp": "application/vnd.tcpdump.pcap", - "dms": "application/octet-stream", - "dna": "application/vnd.dna", - "doc": "application/msword", - "docm": "application/vnd.ms-word.document.macroenabled.12", - "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "dot": "application/msword", - "dotm": "application/vnd.ms-word.template.macroenabled.12", - "dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template", - "dp": "application/vnd.osgi.dp", - "dpg": "application/vnd.dpgraph", - "dpx": "image/dpx", - "dra": "audio/vnd.dra", - "drle": "image/dicom-rle", - "dsc": "text/prs.lines.tag", - "dssc": "application/dssc+der", - "dtb": "application/x-dtbook+xml", - "dtd": "application/xml-dtd", - "dts": "audio/vnd.dts", - "dtshd": "audio/vnd.dts.hd", - "dump": "application/octet-stream", - "dvb": "video/vnd.dvb.file", - "dvi": "application/x-dvi", - "dwd": "application/atsc-dwd+xml", - "dwf": "model/vnd.dwf", - "dwg": "image/vnd.dwg", - "dxf": "image/vnd.dxf", - "dxp": "application/vnd.spotfire.dxp", - "dxr": "application/x-director", - "ear": "application/java-archive", - "ecelp4800": "audio/vnd.nuera.ecelp4800", - "ecelp7470": "audio/vnd.nuera.ecelp7470", - "ecelp9600": "audio/vnd.nuera.ecelp9600", - "ecma": "application/ecmascript", - "edm": "application/vnd.novadigm.edm", - "edx": "application/vnd.novadigm.edx", - "efif": "application/vnd.picsel", - "ei6": "application/vnd.pg.osasli", - "elc": "application/octet-stream", - "emf": "image/emf", - "eml": "message/rfc822", - "emma": "application/emma+xml", - "emotionml": "application/emotionml+xml", - "emz": "application/x-msmetafile", - "eol": "audio/vnd.digital-winds", - "eot": "application/vnd.ms-fontobject", - "eps": "application/postscript", - "epub": "application/epub+zip", - "es3": "application/vnd.eszigno3+xml", - "esa": "application/vnd.osgi.subsystem", - "esf": "application/vnd.epson.esf", - "et3": "application/vnd.eszigno3+xml", - "etx": "text/x-setext", - "eva": "application/x-eva", - "evy": "application/x-envoy", - "exe": "application/x-msdownload", - "exi": "application/exi", - "exp": "application/express", - "exr": "image/aces", - "ext": "application/vnd.novadigm.ext", - "ez": "application/andrew-inset", - "ez2": "application/vnd.ezpix-album", - "ez3": "application/vnd.ezpix-package", - "f": "text/x-fortran", - "f4v": "video/x-f4v", - "f77": "text/x-fortran", - "f90": "text/x-fortran", - "fbs": "image/vnd.fastbidsheet", - "fcdt": "application/vnd.adobe.formscentral.fcdt", - "fcs": "application/vnd.isac.fcs", - "fdf": "application/vnd.fdf", - "fdt": "application/fdt+xml", - "fe_launch": "application/vnd.denovo.fcselayout-link", - "fg5": "application/vnd.fujitsu.oasysgp", - "fgd": "application/x-director", - "fh": "image/x-freehand", - "fh4": "image/x-freehand", - "fh5": "image/x-freehand", - "fh7": "image/x-freehand", - "fhc": "image/x-freehand", - "fig": "application/x-xfig", - "fits": "image/fits", - "flac": "audio/x-flac", - "fli": "video/x-fli", - "flo": "application/vnd.micrografx.flo", - "flv": "video/x-flv", - "flw": "application/vnd.kde.kivio", - "flx": "text/vnd.fmi.flexstor", - "fly": "text/vnd.fly", - "fm": "application/vnd.framemaker", - "fnc": "application/vnd.frogans.fnc", - "fo": "application/vnd.software602.filler.form+xml", - "for": "text/x-fortran", - "fpx": "image/vnd.fpx", - "frame": "application/vnd.framemaker", - "fsc": "application/vnd.fsc.weblaunch", - "fst": "image/vnd.fst", - "ftc": "application/vnd.fluxtime.clip", - "fti": "application/vnd.anser-web-funds-transfer-initiation", - "fvt": "video/vnd.fvt", - "fxp": "application/vnd.adobe.fxp", - "fxpl": "application/vnd.adobe.fxp", - "fzs": "application/vnd.fuzzysheet", - "g2w": "application/vnd.geoplan", - "g3": "image/g3fax", - "g3w": "application/vnd.geospace", - "gac": "application/vnd.groove-account", - "gam": "application/x-tads", - "gbr": "application/rpki-ghostbusters", - "gca": "application/x-gca-compressed", - "gdl": "model/vnd.gdl", - "gdoc": "application/vnd.google-apps.document", - "ged": "text/vnd.familysearch.gedcom", - "geo": "application/vnd.dynageo", - "geojson": "application/geo+json", - "gex": "application/vnd.geometry-explorer", - "ggb": "application/vnd.geogebra.file", - "ggt": "application/vnd.geogebra.tool", - "ghf": "application/vnd.groove-help", - "gif": "image/gif", - "gim": "application/vnd.groove-identity-message", - "glb": "model/gltf-binary", - "gltf": "model/gltf+json", - "gml": "application/gml+xml", - "gmx": "application/vnd.gmx", - "gnumeric": "application/x-gnumeric", - "gph": "application/vnd.flographit", - "gpx": "application/gpx+xml", - "gqf": "application/vnd.grafeq", - "gqs": "application/vnd.grafeq", - "gram": "application/srgs", - "gramps": "application/x-gramps-xml", - "gre": "application/vnd.geometry-explorer", - "grv": "application/vnd.groove-injector", - "grxml": "application/srgs+xml", - "gsf": "application/x-font-ghostscript", - "gsheet": "application/vnd.google-apps.spreadsheet", - "gslides": "application/vnd.google-apps.presentation", - "gtar": "application/x-gtar", - "gtm": "application/vnd.groove-tool-message", - "gtw": "model/vnd.gtw", - "gv": "text/vnd.graphviz", - "gxf": "application/gxf", - "gxt": "application/vnd.geonext", - "gz": "application/gzip", - "h": "text/x-c", - "h261": "video/h261", - "h263": "video/h263", - "h264": "video/h264", - "hal": "application/vnd.hal+xml", - "hbci": "application/vnd.hbci", - "hbs": "text/x-handlebars-template", - "hdd": "application/x-virtualbox-hdd", - "hdf": "application/x-hdf", - "heic": "image/heic", - "heics": "image/heic-sequence", - "heif": "image/heif", - "heifs": "image/heif-sequence", - "hej2": "image/hej2k", - "held": "application/atsc-held+xml", - "hh": "text/x-c", - "hjson": "application/hjson", - "hlp": "application/winhlp", - "hpgl": "application/vnd.hp-hpgl", - "hpid": "application/vnd.hp-hpid", - "hps": "application/vnd.hp-hps", - "hqx": "application/mac-binhex40", - "hsj2": "image/hsj2", - "htc": "text/x-component", - "htke": "application/vnd.kenameaapp", - "htm": "text/html", - "html": "text/html", - "hvd": "application/vnd.yamaha.hv-dic", - "hvp": "application/vnd.yamaha.hv-voice", - "hvs": "application/vnd.yamaha.hv-script", - "i2g": "application/vnd.intergeo", - "icc": "application/vnd.iccprofile", - "ice": "x-conference/x-cooltalk", - "icm": "application/vnd.iccprofile", - "ico": "image/x-icon", - "ics": "text/calendar", - "ief": "image/ief", - "ifb": "text/calendar", - "ifm": "application/vnd.shana.informed.formdata", - "iges": "model/iges", - "igl": "application/vnd.igloader", - "igm": "application/vnd.insors.igm", - "igs": "model/iges", - "igx": "application/vnd.micrografx.igx", - "iif": "application/vnd.shana.informed.interchange", - "img": "application/octet-stream", - "imp": "application/vnd.accpac.simply.imp", - "ims": "application/vnd.ms-ims", - "in": "text/plain", - "ini": "text/plain", - "ink": "application/inkml+xml", - "inkml": "application/inkml+xml", - "install": "application/x-install-instructions", - "iota": "application/vnd.astraea-software.iota", - "ipfix": "application/ipfix", - "ipk": "application/vnd.shana.informed.package", - "irm": "application/vnd.ibm.rights-management", - "irp": "application/vnd.irepository.package+xml", - "iso": "application/x-iso9660-image", - "itp": "application/vnd.shana.informed.formtemplate", - "its": "application/its+xml", - "ivp": "application/vnd.immervision-ivp", - "ivu": "application/vnd.immervision-ivu", - "jad": "text/vnd.sun.j2me.app-descriptor", - "jade": "text/jade", - "jam": "application/vnd.jam", - "jar": "application/java-archive", - "jardiff": "application/x-java-archive-diff", - "java": "text/x-java-source", - "jhc": "image/jphc", - "jisp": "application/vnd.jisp", - "jls": "image/jls", - "jlt": "application/vnd.hp-jlyt", - "jng": "image/x-jng", - "jnlp": "application/x-java-jnlp-file", - "joda": "application/vnd.joost.joda-archive", - "jp2": "image/jp2", - "jpe": "image/jpeg", - "jpeg": "image/jpeg", - "jpf": "image/jpx", - "jpg": "image/jpeg", - "jpg2": "image/jp2", - "jpgm": "video/jpm", - "jpgv": "video/jpeg", - "jph": "image/jph", - "jpm": "video/jpm", - "jpx": "image/jpx", - "js": "text/javascript", - "json": "application/json", - "json5": "application/json5", - "jsonld": "application/ld+json", - "jsonml": "application/jsonml+json", - "jsx": "text/jsx", - "jt": "model/jt", - "jxr": "image/jxr", - "jxra": "image/jxra", - "jxrs": "image/jxrs", - "jxs": "image/jxs", - "jxsc": "image/jxsc", - "jxsi": "image/jxsi", - "jxss": "image/jxss", - "kar": "audio/midi", - "karbon": "application/vnd.kde.karbon", - "kdbx": "application/x-keepass2", - "key": "application/x-iwork-keynote-sffkey", - "kfo": "application/vnd.kde.kformula", - "kia": "application/vnd.kidspiration", - "kml": "application/vnd.google-earth.kml+xml", - "kmz": "application/vnd.google-earth.kmz", - "kne": "application/vnd.kinar", - "knp": "application/vnd.kinar", - "kon": "application/vnd.kde.kontour", - "kpr": "application/vnd.kde.kpresenter", - "kpt": "application/vnd.kde.kpresenter", - "kpxx": "application/vnd.ds-keypoint", - "ksp": "application/vnd.kde.kspread", - "ktr": "application/vnd.kahootz", - "ktx": "image/ktx", - "ktx2": "image/ktx2", - "ktz": "application/vnd.kahootz", - "kwd": "application/vnd.kde.kword", - "kwt": "application/vnd.kde.kword", - "lasxml": "application/vnd.las.las+xml", - "latex": "application/x-latex", - "lbd": "application/vnd.llamagraphics.life-balance.desktop", - "lbe": "application/vnd.llamagraphics.life-balance.exchange+xml", - "les": "application/vnd.hhe.lesson-player", - "less": "text/less", - "lgr": "application/lgr+xml", - "lha": "application/x-lzh-compressed", - "link66": "application/vnd.route66.link66+xml", - "list": "text/plain", - "list3820": "application/vnd.ibm.modcap", - "listafp": "application/vnd.ibm.modcap", - "litcoffee": "text/coffeescript", - "lnk": "application/x-ms-shortcut", - "log": "text/plain", - "lostxml": "application/lost+xml", - "lrf": "application/octet-stream", - "lrm": "application/vnd.ms-lrm", - "ltf": "application/vnd.frogans.ltf", - "lua": "text/x-lua", - "luac": "application/x-lua-bytecode", - "lvp": "audio/vnd.lucent.voice", - "lwp": "application/vnd.lotus-wordpro", - "lzh": "application/x-lzh-compressed", - "m13": "application/x-msmediaview", - "m14": "application/x-msmediaview", - "m1v": "video/mpeg", - "m21": "application/mp21", - "m2a": "audio/mpeg", - "m2v": "video/mpeg", - "m3a": "audio/mpeg", - "m3u": "audio/x-mpegurl", - "m3u8": "application/vnd.apple.mpegurl", - "m4a": "audio/x-m4a", - "m4p": "application/mp4", - "m4s": "video/iso.segment", - "m4u": "video/vnd.mpegurl", - "m4v": "video/x-m4v", - "ma": "application/mathematica", - "mads": "application/mads+xml", - "maei": "application/mmt-aei+xml", - "mag": "application/vnd.ecowin.chart", - "maker": "application/vnd.framemaker", - "man": "text/troff", - "manifest": "text/cache-manifest", - "map": "application/json", - "mar": "application/octet-stream", - "markdown": "text/markdown", - "mathml": "application/mathml+xml", - "mb": "application/mathematica", - "mbk": "application/vnd.mobius.mbk", - "mbox": "application/mbox", - "mc1": "application/vnd.medcalcdata", - "mcd": "application/vnd.mcd", - "mcurl": "text/vnd.curl.mcurl", - "md": "text/markdown", - "mdb": "application/x-msaccess", - "mdi": "image/vnd.ms-modi", - "mdx": "text/mdx", - "me": "text/troff", - "mesh": "model/mesh", - "meta4": "application/metalink4+xml", - "metalink": "application/metalink+xml", - "mets": "application/mets+xml", - "mfm": "application/vnd.mfmp", - "mft": "application/rpki-manifest", - "mgp": "application/vnd.osgeo.mapguide.package", - "mgz": "application/vnd.proteus.magazine", - "mid": "audio/midi", - "midi": "audio/midi", - "mie": "application/x-mie", - "mif": "application/vnd.mif", - "mime": "message/rfc822", - "mj2": "video/mj2", - "mjp2": "video/mj2", - "mjs": "text/javascript", - "mk3d": "video/x-matroska", - "mka": "audio/x-matroska", - "mkd": "text/x-markdown", - "mks": "video/x-matroska", - "mkv": "video/x-matroska", - "mlp": "application/vnd.dolby.mlp", - "mmd": "application/vnd.chipnuts.karaoke-mmd", - "mmf": "application/vnd.smaf", - "mml": "text/mathml", - "mmr": "image/vnd.fujixerox.edmics-mmr", - "mng": "video/x-mng", - "mny": "application/x-msmoney", - "mobi": "application/x-mobipocket-ebook", - "mods": "application/mods+xml", - "mov": "video/quicktime", - "movie": "video/x-sgi-movie", - "mp2": "audio/mpeg", - "mp21": "application/mp21", - "mp2a": "audio/mpeg", - "mp3": "audio/mpeg", - "mp4": "video/mp4", - "mp4a": "audio/mp4", - "mp4s": "application/mp4", - "mp4v": "video/mp4", - "mpc": "application/vnd.mophun.certificate", - "mpd": "application/dash+xml", - "mpe": "video/mpeg", - "mpeg": "video/mpeg", - "mpf": "application/media-policy-dataset+xml", - "mpg": "video/mpeg", - "mpg4": "video/mp4", - "mpga": "audio/mpeg", - "mpkg": "application/vnd.apple.installer+xml", - "mpm": "application/vnd.blueice.multipass", - "mpn": "application/vnd.mophun.application", - "mpp": "application/vnd.ms-project", - "mpt": "application/vnd.ms-project", - "mpy": "application/vnd.ibm.minipay", - "mqy": "application/vnd.mobius.mqy", - "mrc": "application/marc", - "mrcx": "application/marcxml+xml", - "ms": "text/troff", - "mscml": "application/mediaservercontrol+xml", - "mseed": "application/vnd.fdsn.mseed", - "mseq": "application/vnd.mseq", - "msf": "application/vnd.epson.msf", - "msg": "application/vnd.ms-outlook", - "msh": "model/mesh", - "msi": "application/x-msdownload", - "msix": "application/msix", - "msixbundle": "application/msixbundle", - "msl": "application/vnd.mobius.msl", - "msm": "application/octet-stream", - "msp": "application/octet-stream", - "msty": "application/vnd.muvee.style", - "mtl": "model/mtl", - "mts": "model/vnd.mts", - "mus": "application/vnd.musician", - "musd": "application/mmt-usd+xml", - "musicxml": "application/vnd.recordare.musicxml+xml", - "mvb": "application/x-msmediaview", - "mvt": "application/vnd.mapbox-vector-tile", - "mwf": "application/vnd.mfer", - "mxf": "application/mxf", - "mxl": "application/vnd.recordare.musicxml", - "mxmf": "audio/mobile-xmf", - "mxml": "application/xv+xml", - "mxs": "application/vnd.triscape.mxs", - "mxu": "video/vnd.mpegurl", - "n-gage": "application/vnd.nokia.n-gage.symbian.install", - "n3": "text/n3", - "nb": "application/mathematica", - "nbp": "application/vnd.wolfram.player", - "nc": "application/x-netcdf", - "ncx": "application/x-dtbncx+xml", - "nfo": "text/x-nfo", - "ngdat": "application/vnd.nokia.n-gage.data", - "nitf": "application/vnd.nitf", - "nlu": "application/vnd.neurolanguage.nlu", - "nml": "application/vnd.enliven", - "nnd": "application/vnd.noblenet-directory", - "nns": "application/vnd.noblenet-sealer", - "nnw": "application/vnd.noblenet-web", - "npx": "image/vnd.net-fpx", - "nq": "application/n-quads", - "nsc": "application/x-conference", - "nsf": "application/vnd.lotus-notes", - "nt": "application/n-triples", - "ntf": "application/vnd.nitf", - "numbers": "application/x-iwork-numbers-sffnumbers", - "nzb": "application/x-nzb", - "oa2": "application/vnd.fujitsu.oasys2", - "oa3": "application/vnd.fujitsu.oasys3", - "oas": "application/vnd.fujitsu.oasys", - "obd": "application/x-msbinder", - "obgx": "application/vnd.openblox.game+xml", - "obj": "model/obj", - "oda": "application/oda", - "odb": "application/vnd.oasis.opendocument.database", - "odc": "application/vnd.oasis.opendocument.chart", - "odf": "application/vnd.oasis.opendocument.formula", - "odft": "application/vnd.oasis.opendocument.formula-template", - "odg": "application/vnd.oasis.opendocument.graphics", - "odi": "application/vnd.oasis.opendocument.image", - "odm": "application/vnd.oasis.opendocument.text-master", - "odp": "application/vnd.oasis.opendocument.presentation", - "ods": "application/vnd.oasis.opendocument.spreadsheet", - "odt": "application/vnd.oasis.opendocument.text", - "oga": "audio/ogg", - "ogex": "model/vnd.opengex", - "ogg": "audio/ogg", - "ogv": "video/ogg", - "ogx": "application/ogg", - "omdoc": "application/omdoc+xml", - "onepkg": "application/onenote", - "onetmp": "application/onenote", - "onetoc": "application/onenote", - "onetoc2": "application/onenote", - "opf": "application/oebps-package+xml", - "opml": "text/x-opml", - "oprc": "application/vnd.palm", - "opus": "audio/ogg", - "org": "text/x-org", - "osf": "application/vnd.yamaha.openscoreformat", - "osfpvg": "application/vnd.yamaha.openscoreformat.osfpvg+xml", - "osm": "application/vnd.openstreetmap.data+xml", - "otc": "application/vnd.oasis.opendocument.chart-template", - "otf": "font/otf", - "otg": "application/vnd.oasis.opendocument.graphics-template", - "oth": "application/vnd.oasis.opendocument.text-web", - "oti": "application/vnd.oasis.opendocument.image-template", - "otp": "application/vnd.oasis.opendocument.presentation-template", - "ots": "application/vnd.oasis.opendocument.spreadsheet-template", - "ott": "application/vnd.oasis.opendocument.text-template", - "ova": "application/x-virtualbox-ova", - "ovf": "application/x-virtualbox-ovf", - "owl": "application/rdf+xml", - "oxps": "application/oxps", - "oxt": "application/vnd.openofficeorg.extension", - "p": "text/x-pascal", - "p10": "application/pkcs10", - "p12": "application/x-pkcs12", - "p7b": "application/x-pkcs7-certificates", - "p7c": "application/pkcs7-mime", - "p7m": "application/pkcs7-mime", - "p7r": "application/x-pkcs7-certreqresp", - "p7s": "application/pkcs7-signature", - "p8": "application/pkcs8", - "pac": "application/x-ns-proxy-autoconfig", - "pages": "application/x-iwork-pages-sffpages", - "pas": "text/x-pascal", - "paw": "application/vnd.pawaafile", - "pbd": "application/vnd.powerbuilder6", - "pbm": "image/x-portable-bitmap", - "pcap": "application/vnd.tcpdump.pcap", - "pcf": "application/x-font-pcf", - "pcl": "application/vnd.hp-pcl", - "pclxl": "application/vnd.hp-pclxl", - "pct": "image/x-pict", - "pcurl": "application/vnd.curl.pcurl", - "pcx": "image/x-pcx", - "pdb": "application/x-pilot", - "pde": "text/x-processing", - "pdf": "application/pdf", - "pem": "application/x-x509-ca-cert", - "pfa": "application/x-font-type1", - "pfb": "application/x-font-type1", - "pfm": "application/x-font-type1", - "pfr": "application/font-tdpfr", - "pfx": "application/x-pkcs12", - "pgm": "image/x-portable-graymap", - "pgn": "application/x-chess-pgn", - "pgp": "application/pgp-encrypted", - "php": "application/x-httpd-php", - "pic": "image/x-pict", - "pkg": "application/octet-stream", - "pki": "application/pkixcmp", - "pkipath": "application/pkix-pkipath", - "pkpass": "application/vnd.apple.pkpass", - "pl": "application/x-perl", - "plb": "application/vnd.3gpp.pic-bw-large", - "plc": "application/vnd.mobius.plc", - "plf": "application/vnd.pocketlearn", - "pls": "application/pls+xml", - "pm": "application/x-perl", - "pml": "application/vnd.ctc-posml", - "png": "image/png", - "pnm": "image/x-portable-anymap", - "portpkg": "application/vnd.macports.portpkg", - "pot": "application/vnd.ms-powerpoint", - "potm": "application/vnd.ms-powerpoint.template.macroenabled.12", - "potx": "application/vnd.openxmlformats-officedocument.presentationml.template", - "ppam": "application/vnd.ms-powerpoint.addin.macroenabled.12", - "ppd": "application/vnd.cups-ppd", - "ppm": "image/x-portable-pixmap", - "pps": "application/vnd.ms-powerpoint", - "ppsm": "application/vnd.ms-powerpoint.slideshow.macroenabled.12", - "ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow", - "ppt": "application/vnd.ms-powerpoint", - "pptm": "application/vnd.ms-powerpoint.presentation.macroenabled.12", - "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "pqa": "application/vnd.palm", - "prc": "model/prc", - "pre": "application/vnd.lotus-freelance", - "prf": "application/pics-rules", - "provx": "application/provenance+xml", - "ps": "application/postscript", - "psb": "application/vnd.3gpp.pic-bw-small", - "psd": "image/vnd.adobe.photoshop", - "psf": "application/x-font-linux-psf", - "pskcxml": "application/pskc+xml", - "pti": "image/prs.pti", - "ptid": "application/vnd.pvi.ptid1", - "pub": "application/x-mspublisher", - "pvb": "application/vnd.3gpp.pic-bw-var", - "pwn": "application/vnd.3m.post-it-notes", - "pya": "audio/vnd.ms-playready.media.pya", - "pyo": "model/vnd.pytha.pyox", - "pyox": "model/vnd.pytha.pyox", - "pyv": "video/vnd.ms-playready.media.pyv", - "qam": "application/vnd.epson.quickanime", - "qbo": "application/vnd.intu.qbo", - "qfx": "application/vnd.intu.qfx", - "qps": "application/vnd.publishare-delta-tree", - "qt": "video/quicktime", - "qwd": "application/vnd.quark.quarkxpress", - "qwt": "application/vnd.quark.quarkxpress", - "qxb": "application/vnd.quark.quarkxpress", - "qxd": "application/vnd.quark.quarkxpress", - "qxl": "application/vnd.quark.quarkxpress", - "qxt": "application/vnd.quark.quarkxpress", - "ra": "audio/x-realaudio", - "ram": "audio/x-pn-realaudio", - "raml": "application/raml+yaml", - "rapd": "application/route-apd+xml", - "rar": "application/x-rar-compressed", - "ras": "image/x-cmu-raster", - "rcprofile": "application/vnd.ipunplugged.rcprofile", - "rdf": "application/rdf+xml", - "rdz": "application/vnd.data-vision.rdz", - "relo": "application/p2p-overlay+xml", - "rep": "application/vnd.businessobjects", - "res": "application/x-dtbresource+xml", - "rgb": "image/x-rgb", - "rif": "application/reginfo+xml", - "rip": "audio/vnd.rip", - "ris": "application/x-research-info-systems", - "rl": "application/resource-lists+xml", - "rlc": "image/vnd.fujixerox.edmics-rlc", - "rld": "application/resource-lists-diff+xml", - "rm": "application/vnd.rn-realmedia", - "rmi": "audio/midi", - "rmp": "audio/x-pn-realaudio-plugin", - "rms": "application/vnd.jcp.javame.midlet-rms", - "rmvb": "application/vnd.rn-realmedia-vbr", - "rnc": "application/relax-ng-compact-syntax", - "rng": "application/xml", - "roa": "application/rpki-roa", - "roff": "text/troff", - "rp9": "application/vnd.cloanto.rp9", - "rpm": "application/x-redhat-package-manager", - "rpss": "application/vnd.nokia.radio-presets", - "rpst": "application/vnd.nokia.radio-preset", - "rq": "application/sparql-query", - "rs": "application/rls-services+xml", - "rsat": "application/atsc-rsat+xml", - "rsd": "application/rsd+xml", - "rsheet": "application/urc-ressheet+xml", - "rss": "application/rss+xml", - "rtf": "text/rtf", - "rtx": "text/richtext", - "run": "application/x-makeself", - "rusd": "application/route-usd+xml", - "s": "text/x-asm", - "s3m": "audio/s3m", - "saf": "application/vnd.yamaha.smaf-audio", - "sass": "text/x-sass", - "sbml": "application/sbml+xml", - "sc": "application/vnd.ibm.secure-container", - "scd": "application/x-msschedule", - "scm": "application/vnd.lotus-screencam", - "scq": "application/scvp-cv-request", - "scs": "application/scvp-cv-response", - "scss": "text/x-scss", - "scurl": "text/vnd.curl.scurl", - "sda": "application/vnd.stardivision.draw", - "sdc": "application/vnd.stardivision.calc", - "sdd": "application/vnd.stardivision.impress", - "sdkd": "application/vnd.solent.sdkm+xml", - "sdkm": "application/vnd.solent.sdkm+xml", - "sdp": "application/sdp", - "sdw": "application/vnd.stardivision.writer", - "sea": "application/x-sea", - "see": "application/vnd.seemail", - "seed": "application/vnd.fdsn.seed", - "sema": "application/vnd.sema", - "semd": "application/vnd.semd", - "semf": "application/vnd.semf", - "senmlx": "application/senml+xml", - "sensmlx": "application/sensml+xml", - "ser": "application/java-serialized-object", - "setpay": "application/set-payment-initiation", - "setreg": "application/set-registration-initiation", - "sfd-hdstx": "application/vnd.hydrostatix.sof-data", - "sfs": "application/vnd.spotfire.sfs", - "sfv": "text/x-sfv", - "sgi": "image/sgi", - "sgl": "application/vnd.stardivision.writer-global", - "sgm": "text/sgml", - "sgml": "text/sgml", - "sh": "application/x-sh", - "shar": "application/x-shar", - "shex": "text/shex", - "shf": "application/shf+xml", - "shtml": "text/html", - "sid": "image/x-mrsid-image", - "sieve": "application/sieve", - "sig": "application/pgp-signature", - "sil": "audio/silk", - "silo": "model/mesh", - "sis": "application/vnd.symbian.install", - "sisx": "application/vnd.symbian.install", - "sit": "application/x-stuffit", - "sitx": "application/x-stuffitx", - "siv": "application/sieve", - "skd": "application/vnd.koan", - "skm": "application/vnd.koan", - "skp": "application/vnd.koan", - "skt": "application/vnd.koan", - "sldm": "application/vnd.ms-powerpoint.slide.macroenabled.12", - "sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide", - "slim": "text/slim", - "slm": "text/slim", - "sls": "application/route-s-tsid+xml", - "slt": "application/vnd.epson.salt", - "sm": "application/vnd.stepmania.stepchart", - "smf": "application/vnd.stardivision.math", - "smi": "application/smil+xml", - "smil": "application/smil+xml", - "smv": "video/x-smv", - "smzip": "application/vnd.stepmania.package", - "snd": "audio/basic", - "snf": "application/x-font-snf", - "so": "application/octet-stream", - "spc": "application/x-pkcs7-certificates", - "spdx": "text/spdx", - "spf": "application/vnd.yamaha.smaf-phrase", - "spl": "application/x-futuresplash", - "spot": "text/vnd.in3d.spot", - "spp": "application/scvp-vp-response", - "spq": "application/scvp-vp-request", - "spx": "audio/ogg", - "sql": "application/x-sql", - "src": "application/x-wais-source", - "srt": "application/x-subrip", - "sru": "application/sru+xml", - "srx": "application/sparql-results+xml", - "ssdl": "application/ssdl+xml", - "sse": "application/vnd.kodak-descriptor", - "ssf": "application/vnd.epson.ssf", - "ssml": "application/ssml+xml", - "st": "application/vnd.sailingtracker.track", - "stc": "application/vnd.sun.xml.calc.template", - "std": "application/vnd.sun.xml.draw.template", - "stf": "application/vnd.wt.stf", - "sti": "application/vnd.sun.xml.impress.template", - "stk": "application/hyperstudio", - "stl": "model/stl", - "stpx": "model/step+xml", - "stpxz": "model/step-xml+zip", - "stpz": "model/step+zip", - "str": "application/vnd.pg.format", - "stw": "application/vnd.sun.xml.writer.template", - "styl": "text/stylus", - "stylus": "text/stylus", - "sub": "text/vnd.dvb.subtitle", - "sus": "application/vnd.sus-calendar", - "susp": "application/vnd.sus-calendar", - "sv4cpio": "application/x-sv4cpio", - "sv4crc": "application/x-sv4crc", - "svc": "application/vnd.dvb.service", - "svd": "application/vnd.svd", - "svg": "image/svg+xml", - "svgz": "image/svg+xml", - "swa": "application/x-director", - "swf": "application/x-shockwave-flash", - "swi": "application/vnd.aristanetworks.swi", - "swidtag": "application/swid+xml", - "sxc": "application/vnd.sun.xml.calc", - "sxd": "application/vnd.sun.xml.draw", - "sxg": "application/vnd.sun.xml.writer.global", - "sxi": "application/vnd.sun.xml.impress", - "sxm": "application/vnd.sun.xml.math", - "sxw": "application/vnd.sun.xml.writer", - "t": "text/troff", - "t3": "application/x-t3vm-image", - "t38": "image/t38", - "taglet": "application/vnd.mynfc", - "tao": "application/vnd.tao.intent-module-archive", - "tap": "image/vnd.tencent.tap", - "tar": "application/x-tar", - "tcap": "application/vnd.3gpp2.tcap", - "tcl": "application/x-tcl", - "td": "application/urc-targetdesc+xml", - "teacher": "application/vnd.smart.teacher", - "tei": "application/tei+xml", - "teicorpus": "application/tei+xml", - "tex": "application/x-tex", - "texi": "application/x-texinfo", - "texinfo": "application/x-texinfo", - "text": "text/plain", - "tfi": "application/thraud+xml", - "tfm": "application/x-tex-tfm", - "tfx": "image/tiff-fx", - "tga": "image/x-tga", - "thmx": "application/vnd.ms-officetheme", - "tif": "image/tiff", - "tiff": "image/tiff", - "tk": "application/x-tcl", - "tmo": "application/vnd.tmobile-livetv", - "toml": "application/toml", - "torrent": "application/x-bittorrent", - "tpl": "application/vnd.groove-tool-template", - "tpt": "application/vnd.trid.tpt", - "tr": "text/troff", - "tra": "application/vnd.trueapp", - "trig": "application/trig", - "trm": "application/x-msterminal", - "ts": "video/mp2t", - "tsd": "application/timestamped-data", - "tsv": "text/tab-separated-values", - "ttc": "font/collection", - "ttf": "font/ttf", - "ttl": "text/turtle", - "ttml": "application/ttml+xml", - "twd": "application/vnd.simtech-mindmapper", - "twds": "application/vnd.simtech-mindmapper", - "txd": "application/vnd.genomatix.tuxedo", - "txf": "application/vnd.mobius.txf", - "txt": "text/plain", - "u32": "application/x-authorware-bin", - "u3d": "model/u3d", - "u8dsn": "message/global-delivery-status", - "u8hdr": "message/global-headers", - "u8mdn": "message/global-disposition-notification", - "u8msg": "message/global", - "ubj": "application/ubjson", - "udeb": "application/x-debian-package", - "ufd": "application/vnd.ufdl", - "ufdl": "application/vnd.ufdl", - "ulx": "application/x-glulx", - "umj": "application/vnd.umajin", - "unityweb": "application/vnd.unity", - "uo": "application/vnd.uoml+xml", - "uoml": "application/vnd.uoml+xml", - "uri": "text/uri-list", - "uris": "text/uri-list", - "urls": "text/uri-list", - "usda": "model/vnd.usda", - "usdz": "model/vnd.usdz+zip", - "ustar": "application/x-ustar", - "utz": "application/vnd.uiq.theme", - "uu": "text/x-uuencode", - "uva": "audio/vnd.dece.audio", - "uvd": "application/vnd.dece.data", - "uvf": "application/vnd.dece.data", - "uvg": "image/vnd.dece.graphic", - "uvh": "video/vnd.dece.hd", - "uvi": "image/vnd.dece.graphic", - "uvm": "video/vnd.dece.mobile", - "uvp": "video/vnd.dece.pd", - "uvs": "video/vnd.dece.sd", - "uvt": "application/vnd.dece.ttml+xml", - "uvu": "video/vnd.uvvu.mp4", - "uvv": "video/vnd.dece.video", - "uvva": "audio/vnd.dece.audio", - "uvvd": "application/vnd.dece.data", - "uvvf": "application/vnd.dece.data", - "uvvg": "image/vnd.dece.graphic", - "uvvh": "video/vnd.dece.hd", - "uvvi": "image/vnd.dece.graphic", - "uvvm": "video/vnd.dece.mobile", - "uvvp": "video/vnd.dece.pd", - "uvvs": "video/vnd.dece.sd", - "uvvt": "application/vnd.dece.ttml+xml", - "uvvu": "video/vnd.uvvu.mp4", - "uvvv": "video/vnd.dece.video", - "uvvx": "application/vnd.dece.unspecified", - "uvvz": "application/vnd.dece.zip", - "uvx": "application/vnd.dece.unspecified", - "uvz": "application/vnd.dece.zip", - "vbox": "application/x-virtualbox-vbox", - "vbox-extpack": "application/x-virtualbox-vbox-extpack", - "vcard": "text/vcard", - "vcd": "application/x-cdlink", - "vcf": "text/x-vcard", - "vcg": "application/vnd.groove-vcard", - "vcs": "text/x-vcalendar", - "vcx": "application/vnd.vcx", - "vdi": "application/x-virtualbox-vdi", - "vds": "model/vnd.sap.vds", - "vhd": "application/x-virtualbox-vhd", - "vis": "application/vnd.visionary", - "viv": "video/vnd.vivo", - "vmdk": "application/x-virtualbox-vmdk", - "vob": "video/x-ms-vob", - "vor": "application/vnd.stardivision.writer", - "vox": "application/x-authorware-bin", - "vrml": "model/vrml", - "vsd": "application/vnd.visio", - "vsf": "application/vnd.vsf", - "vss": "application/vnd.visio", - "vst": "application/vnd.visio", - "vsw": "application/vnd.visio", - "vtf": "image/vnd.valve.source.texture", - "vtt": "text/vtt", - "vtu": "model/vnd.vtu", - "vxml": "application/voicexml+xml", - "w3d": "application/x-director", - "wad": "application/x-doom", - "wadl": "application/vnd.sun.wadl+xml", - "war": "application/java-archive", - "wasm": "application/wasm", - "wav": "audio/x-wav", - "wax": "audio/x-ms-wax", - "wbmp": "image/vnd.wap.wbmp", - "wbs": "application/vnd.criticaltools.wbs+xml", - "wbxml": "application/vnd.wap.wbxml", - "wcm": "application/vnd.ms-works", - "wdb": "application/vnd.ms-works", - "wdp": "image/vnd.ms-photo", - "weba": "audio/webm", - "webapp": "application/x-web-app-manifest+json", - "webm": "video/webm", - "webmanifest": "application/manifest+json", - "webp": "image/webp", - "wg": "application/vnd.pmi.widget", - "wgsl": "text/wgsl", - "wgt": "application/widget", - "wif": "application/watcherinfo+xml", - "wks": "application/vnd.ms-works", - "wm": "video/x-ms-wm", - "wma": "audio/x-ms-wma", - "wmd": "application/x-ms-wmd", - "wmf": "image/wmf", - "wml": "text/vnd.wap.wml", - "wmlc": "application/vnd.wap.wmlc", - "wmls": "text/vnd.wap.wmlscript", - "wmlsc": "application/vnd.wap.wmlscriptc", - "wmv": "video/x-ms-wmv", - "wmx": "video/x-ms-wmx", - "wmz": "application/x-msmetafile", - "woff": "font/woff", - "woff2": "font/woff2", - "wpd": "application/vnd.wordperfect", - "wpl": "application/vnd.ms-wpl", - "wps": "application/vnd.ms-works", - "wqd": "application/vnd.wqd", - "wri": "application/x-mswrite", - "wrl": "model/vrml", - "wsc": "message/vnd.wfa.wsc", - "wsdl": "application/wsdl+xml", - "wspolicy": "application/wspolicy+xml", - "wtb": "application/vnd.webturbo", - "wvx": "video/x-ms-wvx", - "x32": "application/x-authorware-bin", - "x3d": "model/x3d+xml", - "x3db": "model/x3d+fastinfoset", - "x3dbz": "model/x3d+binary", - "x3dv": "model/x3d-vrml", - "x3dvz": "model/x3d+vrml", - "x3dz": "model/x3d+xml", - "x_b": "model/vnd.parasolid.transmit.binary", - "x_t": "model/vnd.parasolid.transmit.text", - "xaml": "application/xaml+xml", - "xap": "application/x-silverlight-app", - "xar": "application/vnd.xara", - "xav": "application/xcap-att+xml", - "xbap": "application/x-ms-xbap", - "xbd": "application/vnd.fujixerox.docuworks.binder", - "xbm": "image/x-xbitmap", - "xca": "application/xcap-caps+xml", - "xcs": "application/calendar+xml", - "xdf": "application/xcap-diff+xml", - "xdm": "application/vnd.syncml.dm+xml", - "xdp": "application/vnd.adobe.xdp+xml", - "xdssc": "application/dssc+xml", - "xdw": "application/vnd.fujixerox.docuworks", - "xel": "application/xcap-el+xml", - "xenc": "application/xenc+xml", - "xer": "application/patch-ops-error+xml", - "xfdf": "application/xfdf", - "xfdl": "application/vnd.xfdl", - "xht": "application/xhtml+xml", - "xhtm": "application/vnd.pwg-xhtml-print+xml", - "xhtml": "application/xhtml+xml", - "xhvml": "application/xv+xml", - "xif": "image/vnd.xiff", - "xla": "application/vnd.ms-excel", - "xlam": "application/vnd.ms-excel.addin.macroenabled.12", - "xlc": "application/vnd.ms-excel", - "xlf": "application/xliff+xml", - "xlm": "application/vnd.ms-excel", - "xls": "application/vnd.ms-excel", - "xlsb": "application/vnd.ms-excel.sheet.binary.macroenabled.12", - "xlsm": "application/vnd.ms-excel.sheet.macroenabled.12", - "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "xlt": "application/vnd.ms-excel", - "xltm": "application/vnd.ms-excel.template.macroenabled.12", - "xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template", - "xlw": "application/vnd.ms-excel", - "xm": "audio/xm", - "xml": "text/xml", - "xns": "application/xcap-ns+xml", - "xo": "application/vnd.olpc-sugar", - "xop": "application/xop+xml", - "xpi": "application/x-xpinstall", - "xpl": "application/xproc+xml", - "xpm": "image/x-xpixmap", - "xpr": "application/vnd.is-xpr", - "xps": "application/vnd.ms-xpsdocument", - "xpw": "application/vnd.intercon.formnet", - "xpx": "application/vnd.intercon.formnet", - "xsd": "application/xml", - "xsf": "application/prs.xsf+xml", - "xsl": "application/xslt+xml", - "xslt": "application/xslt+xml", - "xsm": "application/vnd.syncml+xml", - "xspf": "application/xspf+xml", - "xul": "application/vnd.mozilla.xul+xml", - "xvm": "application/xv+xml", - "xvml": "application/xv+xml", - "xwd": "image/x-xwindowdump", - "xyz": "chemical/x-xyz", - "xz": "application/x-xz", - "yaml": "text/yaml", - "yang": "application/yang", - "yin": "application/yin+xml", - "yml": "text/yaml", - "ymp": "text/x-suse-ymp", - "z1": "application/x-zmachine", - "z2": "application/x-zmachine", - "z3": "application/x-zmachine", - "z4": "application/x-zmachine", - "z5": "application/x-zmachine", - "z6": "application/x-zmachine", - "z7": "application/x-zmachine", - "z8": "application/x-zmachine", - "zaz": "application/vnd.zzazz.deck+xml", - "zip": "application/zip", - "zir": "application/vnd.zul", - "zirz": "application/vnd.zul", - "zmm": "application/vnd.handheld-entertainment+xml", -} - - -def guess_type( - url: typing.Union[str, "os.PathLike[str]"], - strict: bool = True, -): - """Mime type checker that prefers our predefined types over the built-in - mimetypes module.""" - mime_type, encoding = mimetypes.guess_type(url, strict) - - return (MIME_TYPES.get(str(url).rsplit(".")[1]) or mime_type, encoding) - - -# Monkey patch starlette's mime types -responses.guess_type = guess_type diff --git a/tests/test_backend/test_common.py b/tests/test_backend/test_common.py index 869c7e287..cd668574c 100644 --- a/tests/test_backend/test_common.py +++ b/tests/test_backend/test_common.py @@ -3,7 +3,7 @@ from reactpy import html from reactpy.backend._common import ( CommonOptions, - safe_join_path, + traversal_safe_path, vdom_head_elements_to_html, ) @@ -26,7 +26,7 @@ def test_common_options_url_prefix_starts_with_slash(): ) def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): with pytest.raises(ValueError): - safe_join_path(tmp_path, *bad_path.split("/")) + traversal_safe_path(tmp_path, *bad_path.split("/")) @pytest.mark.parametrize( From ae242ba94f0dabb0fcae6751643fdff14d782400 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:36:45 -0800 Subject: [PATCH 25/84] rewrite `@reactpy/client` --- src/js/packages/@reactpy/client/src/client.ts | 83 ++++++ .../@reactpy/client/src/components.tsx | 31 +- src/js/packages/@reactpy/client/src/index.ts | 7 +- src/js/packages/@reactpy/client/src/logger.ts | 1 + .../packages/@reactpy/client/src/messages.ts | 17 -- src/js/packages/@reactpy/client/src/mount.tsx | 39 ++- .../@reactpy/client/src/reactpy-client.ts | 264 ------------------ src/js/packages/@reactpy/client/src/types.ts | 152 ++++++++++ .../client/src/{reactpy-vdom.tsx => vdom.tsx} | 83 ++---- .../packages/@reactpy/client/src/websocket.ts | 69 +++++ 10 files changed, 375 insertions(+), 371 deletions(-) create mode 100644 src/js/packages/@reactpy/client/src/client.ts delete mode 100644 src/js/packages/@reactpy/client/src/messages.ts delete mode 100644 src/js/packages/@reactpy/client/src/reactpy-client.ts create mode 100644 src/js/packages/@reactpy/client/src/types.ts rename src/js/packages/@reactpy/client/src/{reactpy-vdom.tsx => vdom.tsx} (75%) create mode 100644 src/js/packages/@reactpy/client/src/websocket.ts diff --git a/src/js/packages/@reactpy/client/src/client.ts b/src/js/packages/@reactpy/client/src/client.ts new file mode 100644 index 000000000..7965f66a9 --- /dev/null +++ b/src/js/packages/@reactpy/client/src/client.ts @@ -0,0 +1,83 @@ +import logger from "./logger"; +import { + ReactPyClientInterface, + ReactPyModule, + GenericReactPyClientProps, + ReactPyUrls, +} from "./types"; +import { createReconnectingWebSocket } from "./websocket"; + +export abstract class BaseReactPyClient implements ReactPyClientInterface { + private readonly handlers: { [key: string]: ((message: any) => void)[] } = {}; + protected readonly ready: Promise; + private resolveReady: (value: undefined) => void; + + constructor() { + this.resolveReady = () => {}; + this.ready = new Promise((resolve) => (this.resolveReady = resolve)); + } + + onMessage(type: string, handler: (message: any) => void): () => void { + (this.handlers[type] || (this.handlers[type] = [])).push(handler); + this.resolveReady(undefined); + return () => { + this.handlers[type] = this.handlers[type].filter((h) => h !== handler); + }; + } + + abstract sendMessage(message: any): void; + abstract loadModule(moduleName: string): Promise; + + /** + * Handle an incoming message. + * + * This should be called by subclasses when a message is received. + * + * @param message The message to handle. The message must have a `type` property. + */ + protected handleIncoming(message: any): void { + if (!message.type) { + logger.warn("Received message without type", message); + return; + } + + const messageHandlers: ((m: any) => void)[] | undefined = + this.handlers[message.type]; + if (!messageHandlers) { + logger.warn("Received message without handler", message); + return; + } + + messageHandlers.forEach((h) => h(message)); + } +} + +export class ReactPyClient + extends BaseReactPyClient + implements ReactPyClientInterface +{ + urls: ReactPyUrls; + socket: { current?: WebSocket }; + mountElement: HTMLElement; + + constructor(props: GenericReactPyClientProps) { + super(); + + this.urls = props.urls; + this.mountElement = props.mountElement; + this.socket = createReconnectingWebSocket({ + url: this.urls.componentUrl, + readyPromise: this.ready, + ...props.reconnectOptions, + onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), + }); + } + + sendMessage(message: any): void { + this.socket.current?.send(JSON.stringify(message)); + } + + loadModule(moduleName: string): Promise { + return import(`${this.urls.jsModules}/${moduleName}`); + } +} diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index efaa7a759..42f303198 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -1,29 +1,26 @@ +import { set as setJsonPointer } from "json-pointer"; import React, { - createElement, + ChangeEvent, createContext, - useState, - useRef, - useContext, - useEffect, + createElement, Fragment, MutableRefObject, - ChangeEvent, + useContext, + useEffect, + useRef, + useState, } from "preact/compat"; -// @ts-ignore -import { set as setJsonPointer } from "json-pointer"; import { - ReactPyVdom, - ReactPyComponent, - createChildren, - createAttributes, - loadImportSource, ImportSourceBinding, -} from "./reactpy-vdom"; -import { ReactPyClient } from "./reactpy-client"; + ReactPyComponent, + ReactPyVdom, + ReactPyClientInterface, +} from "./types"; +import { createAttributes, createChildren, loadImportSource } from "./vdom"; -const ClientContext = createContext(null as any); +const ClientContext = createContext(null as any); -export function Layout(props: { client: ReactPyClient }): JSX.Element { +export function Layout(props: { client: ReactPyClientInterface }): JSX.Element { const currentModel: ReactPyVdom = useState({ tagName: "" })[0]; const forceUpdate = useForceUpdate(); diff --git a/src/js/packages/@reactpy/client/src/index.ts b/src/js/packages/@reactpy/client/src/index.ts index 548fcbfc7..9ab33297c 100644 --- a/src/js/packages/@reactpy/client/src/index.ts +++ b/src/js/packages/@reactpy/client/src/index.ts @@ -1,5 +1,6 @@ +export * from "./client"; export * from "./components"; -export * from "./messages"; export * from "./mount"; -export * from "./reactpy-client"; -export * from "./reactpy-vdom"; +export * from "./types"; +export * from "./vdom"; +export * from "./websocket"; diff --git a/src/js/packages/@reactpy/client/src/logger.ts b/src/js/packages/@reactpy/client/src/logger.ts index 4c4cdd264..436e74be1 100644 --- a/src/js/packages/@reactpy/client/src/logger.ts +++ b/src/js/packages/@reactpy/client/src/logger.ts @@ -1,5 +1,6 @@ export default { log: (...args: any[]): void => console.log("[ReactPy]", ...args), + info: (...args: any[]): void => console.info("[ReactPy]", ...args), warn: (...args: any[]): void => console.warn("[ReactPy]", ...args), error: (...args: any[]): void => console.error("[ReactPy]", ...args), }; diff --git a/src/js/packages/@reactpy/client/src/messages.ts b/src/js/packages/@reactpy/client/src/messages.ts deleted file mode 100644 index 34001dcb0..000000000 --- a/src/js/packages/@reactpy/client/src/messages.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ReactPyVdom } from "./reactpy-vdom"; - -export type LayoutUpdateMessage = { - type: "layout-update"; - path: string; - model: ReactPyVdom; -}; - -export type LayoutEventMessage = { - type: "layout-event"; - target: string; - data: any; -}; - -export type IncomingMessage = LayoutUpdateMessage; -export type OutgoingMessage = LayoutEventMessage; -export type Message = IncomingMessage | OutgoingMessage; diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index 059dcec1a..25ebb9fa2 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -1,8 +1,37 @@ -import React from "preact/compat"; -import { render } from "preact/compat"; +import { default as React, default as ReactDOM } from "preact/compat"; +import { ReactPyClient } from "./client"; import { Layout } from "./components"; -import { ReactPyClient } from "./reactpy-client"; +import { MountProps } from "./types"; -export function mount(element: HTMLElement, client: ReactPyClient): void { - render(, element); +export function mount(props: MountProps) { + // WebSocket route for component rendering + const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`; + let wsOrigin = `${wsProtocol}//${window.location.host}`; + let componentUrl = new URL(`${wsOrigin}/${props.componentPath}`); + + // Embed the initial HTTP path into the WebSocket URL + componentUrl.searchParams.append("http_pathname", window.location.pathname); + if (window.location.search) { + componentUrl.searchParams.append("http_search", window.location.search); + } + + // Configure a new ReactPy client + const client = new ReactPyClient({ + urls: { + componentUrl: componentUrl, + query: document.location.search, + jsModules: `${window.location.origin}/${props.jsModulesPath}`, + }, + reconnectOptions: { + startInterval: props.reconnectStartInterval || 750, + maxInterval: props.reconnectMaxInterval || 60000, + backoffMultiplier: props.reconnectBackoffMultiplier || 1.25, + maxRetries: props.reconnectMaxRetries || 150, + }, + mountElement: props.mountElement, + }); + + // Start rendering the component + // eslint-disable-next-line react/no-deprecated + ReactDOM.render(, props.mountElement); } diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts deleted file mode 100644 index 6f37b55a1..000000000 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { ReactPyModule } from "./reactpy-vdom"; -import logger from "./logger"; - -/** - * A client for communicating with a ReactPy server. - */ -export interface ReactPyClient { - /** - * Register a handler for a message type. - * - * The first time this is called, the client will be considered ready. - * - * @param type The type of message to handle. - * @param handler The handler to call when a message of the given type is received. - * @returns A function to unregister the handler. - */ - onMessage(type: string, handler: (message: any) => void): () => void; - - /** - * Send a message to the server. - * - * @param message The message to send. Messages must have a `type` property. - */ - sendMessage(message: any): void; - - /** - * Load a module from the server. - * @param moduleName The name of the module to load. - * @returns A promise that resolves to the module. - */ - loadModule(moduleName: string): Promise; -} - -export abstract class BaseReactPyClient implements ReactPyClient { - private readonly handlers: { [key: string]: ((message: any) => void)[] } = {}; - protected readonly ready: Promise; - private resolveReady: (value: undefined) => void; - - constructor() { - this.resolveReady = () => {}; - this.ready = new Promise((resolve) => (this.resolveReady = resolve)); - } - - onMessage(type: string, handler: (message: any) => void): () => void { - (this.handlers[type] || (this.handlers[type] = [])).push(handler); - this.resolveReady(undefined); - return () => { - this.handlers[type] = this.handlers[type].filter((h) => h !== handler); - }; - } - - abstract sendMessage(message: any): void; - abstract loadModule(moduleName: string): Promise; - - /** - * Handle an incoming message. - * - * This should be called by subclasses when a message is received. - * - * @param message The message to handle. The message must have a `type` property. - */ - protected handleIncoming(message: any): void { - if (!message.type) { - logger.warn("Received message without type", message); - return; - } - - const messageHandlers: ((m: any) => void)[] | undefined = - this.handlers[message.type]; - if (!messageHandlers) { - logger.warn("Received message without handler", message); - return; - } - - messageHandlers.forEach((h) => h(message)); - } -} - -export type SimpleReactPyClientProps = { - serverLocation?: LocationProps; - reconnectOptions?: ReconnectProps; -}; - -/** - * The location of the server. - * - * This is used to determine the location of the server's API endpoints. All endpoints - * are expected to be found at the base URL, with the following paths: - * - * - `_reactpy/stream/${route}${query}`: The websocket endpoint for the stream. - * - `_reactpy/modules`: The directory containing the dynamically loaded modules. - * - `_reactpy/assets`: The directory containing the static assets. - */ -type LocationProps = { - /** - * The base URL of the server. - * - * @default - document.location.origin - */ - url: string; - /** - * The route to the page being rendered. - * - * @default - document.location.pathname - */ - route: string; - /** - * The query string of the page being rendered. - * - * @default - document.location.search - */ - query: string; -}; - -type ReconnectProps = { - maxInterval?: number; - maxRetries?: number; - backoffRate?: number; - intervalJitter?: number; -}; - -export class SimpleReactPyClient - extends BaseReactPyClient - implements ReactPyClient -{ - private readonly urls: ServerUrls; - private readonly socket: { current?: WebSocket }; - - constructor(props: SimpleReactPyClientProps) { - super(); - - this.urls = getServerUrls( - props.serverLocation || { - url: document.location.origin, - route: document.location.pathname, - query: document.location.search, - }, - ); - - this.socket = createReconnectingWebSocket({ - readyPromise: this.ready, - url: this.urls.stream, - onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), - ...props.reconnectOptions, - }); - } - - sendMessage(message: any): void { - this.socket.current?.send(JSON.stringify(message)); - } - - loadModule(moduleName: string): Promise { - return import(`${this.urls.modules}/${moduleName}`); - } -} - -type ServerUrls = { - base: URL; - stream: string; - modules: string; - assets: string; -}; - -function getServerUrls(props: LocationProps): ServerUrls { - const base = new URL(`${props.url || document.location.origin}/_reactpy`); - const modules = `${base}/modules`; - const assets = `${base}/assets`; - - const streamProtocol = `ws${base.protocol === "https:" ? "s" : ""}`; - const streamPath = rtrim(`${base.pathname}/stream${props.route || ""}`, "/"); - const stream = `${streamProtocol}://${base.host}${streamPath}${props.query}`; - - return { base, modules, assets, stream }; -} - -function createReconnectingWebSocket( - props: { - url: string; - readyPromise: Promise; - onOpen?: () => void; - onMessage: (message: MessageEvent) => void; - onClose?: () => void; - } & ReconnectProps, -) { - const { - maxInterval = 60000, - maxRetries = 50, - backoffRate = 1.1, - intervalJitter = 0.1, - } = props; - - const startInterval = 750; - let retries = 0; - let interval = startInterval; - const closed = false; - let everConnected = false; - const socket: { current?: WebSocket } = {}; - - const connect = () => { - if (closed) { - return; - } - socket.current = new WebSocket(props.url); - socket.current.onopen = () => { - everConnected = true; - logger.log("client connected"); - interval = startInterval; - retries = 0; - if (props.onOpen) { - props.onOpen(); - } - }; - socket.current.onmessage = props.onMessage; - socket.current.onclose = () => { - if (!everConnected) { - logger.log("failed to connect"); - return; - } - - logger.log("client disconnected"); - if (props.onClose) { - props.onClose(); - } - - if (retries >= maxRetries) { - return; - } - - const thisInterval = addJitter(interval, intervalJitter); - logger.log( - `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, - ); - setTimeout(connect, thisInterval); - interval = nextInterval(interval, backoffRate, maxInterval); - retries++; - }; - }; - - props.readyPromise.then(() => logger.log("starting client...")).then(connect); - - return socket; -} - -function nextInterval( - currentInterval: number, - backoffRate: number, - maxInterval: number, -): number { - return Math.min( - currentInterval * - // increase interval by backoff rate - backoffRate, - // don't exceed max interval - maxInterval, - ); -} - -function addJitter(interval: number, jitter: number): number { - return interval + (Math.random() * jitter * interval * 2 - jitter * interval); -} - -function rtrim(text: string, trim: string): string { - return text.replace(new RegExp(`${trim}+$`), ""); -} diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts new file mode 100644 index 000000000..295c8dfa1 --- /dev/null +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -0,0 +1,152 @@ +import { ComponentType } from "react"; + +// #### CONNECTION TYPES #### + +export type ReconnectOptions = { + startInterval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +}; + +export type CreateReconnectingWebSocketProps = { + url: URL; + readyPromise: Promise; + onMessage: (message: MessageEvent) => void; + onOpen?: () => void; + onClose?: () => void; + startInterval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +}; + +export type ReactPyUrls = { + componentUrl: URL; + query: string; + jsModules: string; +}; + +export type GenericReactPyClientProps = { + urls: ReactPyUrls; + reconnectOptions: ReconnectOptions; + mountElement: HTMLElement; +}; + +export type MountProps = { + mountElement: HTMLElement; + componentPath: string; + jsModulesPath: string; + reconnectStartInterval?: number; + reconnectMaxInterval?: number; + reconnectMaxRetries?: number; + reconnectBackoffMultiplier?: number; +}; + +// #### COMPONENT TYPES #### + +export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>; + +export type ReactPyVdom = { + tagName: string; + key?: string; + attributes?: { [key: string]: string }; + children?: (ReactPyVdom | string)[]; + error?: string; + eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; + importSource?: ReactPyVdomImportSource; +}; + +export type ReactPyVdomEventHandler = { + target: string; + preventDefault?: boolean; + stopPropagation?: boolean; +}; + +export type ReactPyVdomImportSource = { + source: string; + sourceType?: "URL" | "NAME"; + fallback?: string | ReactPyVdom; + unmountBeforeUpdate?: boolean; +}; + +export type ReactPyModule = { + bind: ( + node: HTMLElement, + context: ReactPyModuleBindingContext, + ) => ReactPyModuleBinding; +} & { [key: string]: any }; + +export type ReactPyModuleBindingContext = { + sendMessage: ReactPyClientInterface["sendMessage"]; + onMessage: ReactPyClientInterface["onMessage"]; +}; + +export type ReactPyModuleBinding = { + create: ( + type: any, + props?: any, + children?: (any | string | ReactPyVdom)[], + ) => any; + render: (element: any) => void; + unmount: () => void; +}; + +export type BindImportSource = ( + node: HTMLElement, +) => ImportSourceBinding | null; + +export type ImportSourceBinding = { + render: (model: ReactPyVdom) => void; + unmount: () => void; +}; + +// #### MESSAGE TYPES #### + +export type LayoutUpdateMessage = { + type: "layout-update"; + path: string; + model: ReactPyVdom; +}; + +export type LayoutEventMessage = { + type: "layout-event"; + target: string; + data: any; +}; + +export type IncomingMessage = LayoutUpdateMessage; +export type OutgoingMessage = LayoutEventMessage; +export type Message = IncomingMessage | OutgoingMessage; + +// #### INTERFACES #### + +/** + * A client for communicating with a ReactPy server. + */ +export interface ReactPyClientInterface { + /** + * Register a handler for a message type. + * + * The first time this is called, the client will be considered ready. + * + * @param type The type of message to handle. + * @param handler The handler to call when a message of the given type is received. + * @returns A function to unregister the handler. + */ + onMessage(type: string, handler: (message: any) => void): () => void; + + /** + * Send a message to the server. + * + * @param message The message to send. Messages must have a `type` property. + */ + sendMessage(message: any): void; + + /** + * Load a module from the server. + * @param moduleName The name of the module to load. + * @returns A promise that resolves to the module. + */ + loadModule(moduleName: string): Promise; +} diff --git a/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx similarity index 75% rename from src/js/packages/@reactpy/client/src/reactpy-vdom.tsx rename to src/js/packages/@reactpy/client/src/vdom.tsx index 22fa3e61d..d86d9232a 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -1,10 +1,19 @@ -import React, { ComponentType } from "react"; -import { ReactPyClient } from "./reactpy-client"; +import React from "react"; +import { ReactPyClientInterface } from "./types"; import serializeEvent from "event-to-object"; +import { + ReactPyVdom, + ReactPyVdomImportSource, + ReactPyVdomEventHandler, + ReactPyModule, + BindImportSource, + ReactPyModuleBinding, +} from "./types"; +import log from "./logger"; export async function loadImportSource( vdomImportSource: ReactPyVdomImportSource, - client: ReactPyClient, + client: ReactPyClientInterface, ): Promise { let module: ReactPyModule; if (vdomImportSource.sourceType === "URL") { @@ -30,7 +39,7 @@ export async function loadImportSource( typeof binding.unmount === "function" ) ) { - console.error(`${vdomImportSource.source} returned an impropper binding`); + log.error(`${vdomImportSource.source} returned an impropper binding`); return null; } @@ -51,7 +60,7 @@ export async function loadImportSource( } function createImportSourceElement(props: { - client: ReactPyClient; + client: ReactPyClientInterface; module: ReactPyModule; binding: ReactPyModuleBinding; model: ReactPyVdom; @@ -62,7 +71,7 @@ function createImportSourceElement(props: { if ( !isImportSourceEqual(props.currentImportSource, props.model.importSource) ) { - console.error( + log.error( "Parent element import source " + stringifyImportSource(props.currentImportSource) + " does not match child's import source " + @@ -70,7 +79,7 @@ function createImportSourceElement(props: { ); return null; } else if (!props.module[props.model.tagName]) { - console.error( + log.error( "Module from source " + stringifyImportSource(props.currentImportSource) + ` does not export ${props.model.tagName}`, @@ -131,7 +140,7 @@ export function createChildren( export function createAttributes( model: ReactPyVdom, - client: ReactPyClient, + client: ReactPyClientInterface, ): { [key: string]: any } { return Object.fromEntries( Object.entries({ @@ -149,7 +158,7 @@ export function createAttributes( } function createEventHandler( - client: ReactPyClient, + client: ReactPyClientInterface, name: string, { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, ): [string, () => void] { @@ -203,59 +212,3 @@ function snakeToCamel(str: string): string { // see list of HTML attributes with dashes in them: // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list const DASHED_HTML_ATTRS = ["accept_charset", "http_equiv"]; - -export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>; - -export type ReactPyVdom = { - tagName: string; - key?: string; - attributes?: { [key: string]: string }; - children?: (ReactPyVdom | string)[]; - error?: string; - eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; - importSource?: ReactPyVdomImportSource; -}; - -export type ReactPyVdomEventHandler = { - target: string; - preventDefault?: boolean; - stopPropagation?: boolean; -}; - -export type ReactPyVdomImportSource = { - source: string; - sourceType?: "URL" | "NAME"; - fallback?: string | ReactPyVdom; - unmountBeforeUpdate?: boolean; -}; - -export type ReactPyModule = { - bind: ( - node: HTMLElement, - context: ReactPyModuleBindingContext, - ) => ReactPyModuleBinding; -} & { [key: string]: any }; - -export type ReactPyModuleBindingContext = { - sendMessage: ReactPyClient["sendMessage"]; - onMessage: ReactPyClient["onMessage"]; -}; - -export type ReactPyModuleBinding = { - create: ( - type: any, - props?: any, - children?: (any | string | ReactPyVdom)[], - ) => any; - render: (element: any) => void; - unmount: () => void; -}; - -export type BindImportSource = ( - node: HTMLElement, -) => ImportSourceBinding | null; - -export type ImportSourceBinding = { - render: (model: ReactPyVdom) => void; - unmount: () => void; -}; diff --git a/src/js/packages/@reactpy/client/src/websocket.ts b/src/js/packages/@reactpy/client/src/websocket.ts new file mode 100644 index 000000000..51ae2a69c --- /dev/null +++ b/src/js/packages/@reactpy/client/src/websocket.ts @@ -0,0 +1,69 @@ +import { CreateReconnectingWebSocketProps } from "./types"; +import log from "./logger"; + +export function createReconnectingWebSocket( + props: CreateReconnectingWebSocketProps, +) { + const { startInterval, maxInterval, maxRetries, backoffMultiplier } = props; + let retries = 0; + let interval = startInterval; + let everConnected = false; + const closed = false; + const socket: { current?: WebSocket } = {}; + + const connect = () => { + if (closed) { + return; + } + socket.current = new WebSocket(props.url); + socket.current.onopen = () => { + everConnected = true; + log.info("ReactPy connected!"); + interval = startInterval; + retries = 0; + if (props.onOpen) { + props.onOpen(); + } + }; + socket.current.onmessage = props.onMessage; + socket.current.onclose = () => { + if (props.onClose) { + props.onClose(); + } + if (!everConnected) { + log.info("ReactPy failed to connect!"); + return; + } + log.info("ReactPy disconnected!"); + if (retries >= maxRetries) { + log.info("ReactPy connection max retries exhausted!"); + return; + } + log.info( + `ReactPy reconnecting in ${(interval / 1000).toPrecision(4)} seconds...`, + ); + setTimeout(connect, interval); + interval = nextInterval(interval, backoffMultiplier, maxInterval); + retries++; + }; + }; + + props.readyPromise + .then(() => log.info("Starting ReactPy client...")) + .then(connect); + + return socket; +} + +export function nextInterval( + currentInterval: number, + backoffMultiplier: number, + maxInterval: number, +): number { + return Math.min( + // increase interval by backoff multiplier + currentInterval * backoffMultiplier, + // don't exceed max interval + maxInterval, + ); +} From 28e0119999da958c7213df74f51209e9f19ae73d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:42:27 -0800 Subject: [PATCH 26/84] New build method of `@reactpy/app` --- .gitignore | 2 +- pyproject.toml | 2 +- src/js/packages/@reactpy/app/package.json | 2 +- src/js/packages/@reactpy/app/src/index.html | 15 +++++++++++++++ src/js/packages/@reactpy/app/src/index.ts | 20 +------------------- src/reactpy/static/index.html | 14 -------------- 6 files changed, 19 insertions(+), 36 deletions(-) create mode 100644 src/js/packages/@reactpy/app/src/index.html delete mode 100644 src/reactpy/static/index.html diff --git a/.gitignore b/.gitignore index 6cc8e33ca..247ac896d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # --- Build Artifacts --- -src/reactpy/static/**/index.js* +src/reactpy/static/* # --- Jupyter --- *.ipynb_checkpoints diff --git a/pyproject.toml b/pyproject.toml index 8c348f1e9..757872d6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ commands = [ 'bun run --cwd "src/js/packages/@reactpy/client" build', 'bun install --cwd "src/js/packages/@reactpy/app"', 'bun run --cwd "src/js/packages/@reactpy/app" build', - 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static/assets"', + 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static"', ] artifacts = [] diff --git a/src/js/packages/@reactpy/app/package.json b/src/js/packages/@reactpy/app/package.json index 21e3bcd96..f8417dd0d 100644 --- a/src/js/packages/@reactpy/app/package.json +++ b/src/js/packages/@reactpy/app/package.json @@ -11,7 +11,7 @@ "typescript": "^5.7.3" }, "scripts": { - "build": "bun build \"src/index.ts\" --outdir \"dist\" --minify --sourcemap=linked", + "build": "bun build \"src/index.html\" --outdir=\"dist\" --minify --sourcemap=\"linked\" --experimental-html --public-path=\"reactpy/static/\"", "checkTypes": "tsc --noEmit" } } diff --git a/src/js/packages/@reactpy/app/src/index.html b/src/js/packages/@reactpy/app/src/index.html new file mode 100644 index 000000000..1cb72a031 --- /dev/null +++ b/src/js/packages/@reactpy/app/src/index.html @@ -0,0 +1,15 @@ + + + + + + + + + +
+ {mount_script} + + + + diff --git a/src/js/packages/@reactpy/app/src/index.ts b/src/js/packages/@reactpy/app/src/index.ts index 9a86fe811..36efbe5a1 100644 --- a/src/js/packages/@reactpy/app/src/index.ts +++ b/src/js/packages/@reactpy/app/src/index.ts @@ -1,19 +1 @@ -import { mount, SimpleReactPyClient } from "@reactpy/client"; - -function app(element: HTMLElement) { - const client = new SimpleReactPyClient({ - serverLocation: { - url: document.location.origin, - route: document.location.pathname, - query: document.location.search, - }, - }); - mount(element, client); -} - -const element = document.getElementById("app"); -if (element) { - app(element); -} else { - console.error("Element with id 'app' not found"); -} +export { mount } from "@reactpy/client"; diff --git a/src/reactpy/static/index.html b/src/reactpy/static/index.html deleted file mode 100644 index 77d008332..000000000 --- a/src/reactpy/static/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - {__head__} - - - -
- - - From 732780e577686a48237a54077445f4d9e3c1083a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:42:56 -0800 Subject: [PATCH 27/84] Remove legacy backend code --- src/reactpy/backend/_common.py | 136 -------------- src/reactpy/backend/default.py | 78 -------- src/reactpy/backend/fastapi.py | 25 --- src/reactpy/backend/flask.py | 303 ------------------------------- src/reactpy/backend/hooks.py | 45 ----- src/reactpy/backend/sanic.py | 231 ----------------------- src/reactpy/backend/starlette.py | 185 ------------------- src/reactpy/backend/tornado.py | 235 ------------------------ src/reactpy/backend/types.py | 42 +---- 9 files changed, 4 insertions(+), 1276 deletions(-) delete mode 100644 src/reactpy/backend/_common.py delete mode 100644 src/reactpy/backend/default.py delete mode 100644 src/reactpy/backend/fastapi.py delete mode 100644 src/reactpy/backend/flask.py delete mode 100644 src/reactpy/backend/hooks.py delete mode 100644 src/reactpy/backend/sanic.py delete mode 100644 src/reactpy/backend/starlette.py delete mode 100644 src/reactpy/backend/tornado.py diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py deleted file mode 100644 index 12b0cff0a..000000000 --- a/src/reactpy/backend/_common.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from collections.abc import Awaitable, Sequence -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, Any, cast - -from reactpy import __file__ as _reactpy_file_path -from reactpy import html -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.types import VdomDict -from reactpy.utils import vdom_to_html - -if TYPE_CHECKING: - import uvicorn - from asgiref.typing import ASGIApplication - -PATH_PREFIX = PurePosixPath("/_reactpy") -MODULES_PATH = PATH_PREFIX / "modules" -ASSETS_PATH = PATH_PREFIX / "assets" -STREAM_PATH = PATH_PREFIX / "stream" -CLIENT_BUILD_DIR = Path(_reactpy_file_path).parent / "static" - - -async def serve_with_uvicorn( - app: ASGIApplication | Any, - host: str, - port: int, - started: asyncio.Event | None, -) -> None: - """Run a development server for an ASGI application""" - import uvicorn - - server = uvicorn.Server( - uvicorn.Config( - app, - host=host, - port=port, - loop="asyncio", - ) - ) - server.config.setup_event_loop() - coros: list[Awaitable[Any]] = [server.serve()] - - # If a started event is provided, then use it signal based on `server.started` - if started: - coros.append(_check_if_started(server, started)) - - try: - await asyncio.gather(*coros) - finally: - # Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's - # order of operations. So we need to make sure `shutdown()` always has an initialized - # list of `self.servers` to use. - if not hasattr(server, "servers"): # nocov - server.servers = [] - await asyncio.wait_for(server.shutdown(), timeout=3) - - -async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: - while not server.started: - await asyncio.sleep(0.2) - started.set() - - -def safe_client_build_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" - return traversal_safe_path( - CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/") - ) - - -def safe_web_modules_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`""" - return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) - - -def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: - """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir.""" - root = os.path.abspath(root) - - # Resolve relative paths but not symlinks - symlinks should be ok since their - # presence and where they point is under the control of the developer. - path = os.path.abspath(os.path.join(root, *unsafe)) - - if os.path.commonprefix([root, path]) != root: - # We resolved outside the root dir, potential directory traversal attack. - raise ValueError( - f"Unsafe path detected. Path '{path}' is outside root directory '{root}'" - ) - - return Path(path) - - -def read_client_index_html(options: CommonOptions) -> str: - return ( - (CLIENT_BUILD_DIR / "index.html") - .read_text() - .format(__head__=vdom_head_elements_to_html(options.head)) - ) - - -def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str: - if isinstance(head, str): - return head - elif isinstance(head, dict): - if head.get("tagName") == "head": - head = cast(VdomDict, {**head, "tagName": ""}) - return vdom_to_html(head) - else: - return vdom_to_html(html._(*head)) - - -@dataclass -class CommonOptions: - """Options for ReactPy's built-in backed server implementations""" - - head: Sequence[VdomDict] | VdomDict | str = (html.title("ReactPy"),) - """Add elements to the ```` of the application. - - For example, this can be used to customize the title of the page, link extra - scripts, or load stylesheets. - """ - - url_prefix: str = "" - """The URL prefix where ReactPy resources will be served from""" - - serve_index_route: bool = True - """Automatically generate and serve the index route (``/``)""" - - def __post_init__(self) -> None: - if self.url_prefix and not self.url_prefix.startswith("/"): - msg = "Expected 'url_prefix' to start with '/'" - raise ValueError(msg) diff --git a/src/reactpy/backend/default.py b/src/reactpy/backend/default.py deleted file mode 100644 index 37aad31af..000000000 --- a/src/reactpy/backend/default.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import asyncio -from logging import getLogger -from sys import exc_info -from typing import Any, NoReturn - -from reactpy.backend.types import BackendType -from reactpy.backend.utils import SUPPORTED_BACKENDS, all_implementations -from reactpy.types import RootComponentConstructor - -logger = getLogger(__name__) -_DEFAULT_IMPLEMENTATION: BackendType[Any] | None = None - - -# BackendType.Options -class Options: # nocov - """Configuration options that can be provided to the backend. - This definition should not be used/instantiated. It exists only for - type hinting purposes.""" - - def __init__(self, *args: Any, **kwds: Any) -> NoReturn: - msg = "Default implementation has no options." - raise ValueError(msg) - - -# BackendType.configure -def configure( - app: Any, component: RootComponentConstructor, options: None = None -) -> None: - """Configure the given app instance to display the given component""" - if options is not None: # nocov - msg = "Default implementation cannot be configured with options" - raise ValueError(msg) - return _default_implementation().configure(app, component) - - -# BackendType.create_development_app -def create_development_app() -> Any: - """Create an application instance for development purposes""" - return _default_implementation().create_development_app() - - -# BackendType.serve_development_app -async def serve_development_app( - app: Any, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run an application using a development server""" - return await _default_implementation().serve_development_app( - app, host, port, started - ) - - -def _default_implementation() -> BackendType[Any]: - """Get the first available server implementation""" - global _DEFAULT_IMPLEMENTATION # noqa: PLW0603 - - if _DEFAULT_IMPLEMENTATION is not None: - return _DEFAULT_IMPLEMENTATION - - try: - implementation = next(all_implementations()) - except StopIteration: # nocov - logger.debug("Backend implementation import failed", exc_info=exc_info()) - supported_backends = ", ".join(SUPPORTED_BACKENDS) - msg = ( - "It seems you haven't installed a backend. To resolve this issue, " - "you can install a backend by running:\n\n" - '\033[1mpip install "reactpy[starlette]"\033[0m\n\n' - f"Other supported backends include: {supported_backends}." - ) - raise RuntimeError(msg) from None - else: - _DEFAULT_IMPLEMENTATION = implementation - return implementation diff --git a/src/reactpy/backend/fastapi.py b/src/reactpy/backend/fastapi.py deleted file mode 100644 index a0137a3dc..000000000 --- a/src/reactpy/backend/fastapi.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from fastapi import FastAPI - -from reactpy.backend import starlette - -# BackendType.Options -Options = starlette.Options - -# BackendType.configure -configure = starlette.configure - - -# BackendType.create_development_app -def create_development_app() -> FastAPI: - """Create a development ``FastAPI`` application instance.""" - return FastAPI(debug=True) - - -# BackendType.serve_development_app -serve_development_app = starlette.serve_development_app - -use_connection = starlette.use_connection - -use_websocket = starlette.use_websocket diff --git a/src/reactpy/backend/flask.py b/src/reactpy/backend/flask.py deleted file mode 100644 index 4401fb6f7..000000000 --- a/src/reactpy/backend/flask.py +++ /dev/null @@ -1,303 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import os -from asyncio import Queue as AsyncQueue -from dataclasses import dataclass -from queue import Queue as ThreadQueue -from threading import Event as ThreadEvent -from threading import Thread -from typing import Any, Callable, NamedTuple, NoReturn, cast - -from flask import ( - Blueprint, - Flask, - Request, - copy_current_request_context, - request, - send_file, -) -from flask_cors import CORS -from flask_sock import Sock -from simple_websocket import Server as WebSocket -from werkzeug.serving import BaseWSGIServer, make_server - -import reactpy -from reactpy.backend._common import ( - ASSETS_PATH, - MODULES_PATH, - PATH_PREFIX, - STREAM_PATH, - CommonOptions, - read_client_index_html, - safe_client_build_dir_path, - safe_web_modules_dir_path, -) -from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentType, RootComponentConstructor -from reactpy.utils import Ref - -logger = logging.getLogger(__name__) - - -# BackendType.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.flask.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``flask_cors.CORS`` - """ - - -# BackendType.configure -def configure( - app: Flask, component: RootComponentConstructor, options: Options | None = None -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - - api_bp = Blueprint(f"reactpy_api_{id(app)}", __name__, url_prefix=str(PATH_PREFIX)) - spa_bp = Blueprint( - f"reactpy_spa_{id(app)}", __name__, url_prefix=options.url_prefix - ) - - _setup_single_view_dispatcher_route(api_bp, options, component) - _setup_common_routes(api_bp, spa_bp, options) - - app.register_blueprint(api_bp) - app.register_blueprint(spa_bp) - - -# BackendType.create_development_app -def create_development_app() -> Flask: - """Create an application instance for development purposes""" - os.environ["FLASK_DEBUG"] = "true" - return Flask(__name__) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Flask, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for FastAPI""" - loop = asyncio.get_running_loop() - stopped = asyncio.Event() - - server: Ref[BaseWSGIServer] = Ref() - - def run_server() -> None: - server.current = make_server(host, port, app, threaded=True) - if started: - loop.call_soon_threadsafe(started.set) - try: - server.current.serve_forever() # type: ignore - finally: - loop.call_soon_threadsafe(stopped.set) - - thread = Thread(target=run_server, daemon=True) - thread.start() - - if started: - await started.wait() - - try: - await stopped.wait() - finally: - # we may have exited because this task was cancelled - server.current.shutdown() - # the thread should eventually join - thread.join(timeout=3) - # just double check it happened - if thread.is_alive(): # nocov - msg = "Failed to shutdown server." - raise RuntimeError(msg) - - -def use_websocket() -> WebSocket: - """A handle to the current websocket""" - return use_connection().carrier.websocket - - -def use_request() -> Request: - """Get the current ``Request``""" - return use_connection().carrier.request - - -def use_connection() -> Connection[_FlaskCarrier]: - """Get the current :class:`Connection`""" - conn = _use_connection() - if not isinstance(conn.carrier, _FlaskCarrier): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -def _setup_common_routes( - api_blueprint: Blueprint, - spa_blueprint: Blueprint, - options: Options, -) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = cors_options if isinstance(cors_options, dict) else {} - CORS(api_blueprint, **cors_params) - - @api_blueprint.route(f"/{ASSETS_PATH.name}/") - def send_assets_dir(path: str = "") -> Any: - return send_file(safe_client_build_dir_path(f"assets/{path}")) - - @api_blueprint.route(f"/{MODULES_PATH.name}/") - def send_modules_dir(path: str = "") -> Any: - return send_file(safe_web_modules_dir_path(path), mimetype="text/javascript") - - index_html = read_client_index_html(options) - - if options.serve_index_route: - - @spa_blueprint.route("/") - @spa_blueprint.route("/") - def send_client_dir(_: str = "") -> Any: - return index_html - - -def _setup_single_view_dispatcher_route( - api_blueprint: Blueprint, options: Options, constructor: RootComponentConstructor -) -> None: - sock = Sock(api_blueprint) - - def model_stream(ws: WebSocket, path: str = "") -> None: - def send(value: Any) -> None: - ws.send(json.dumps(value)) - - def recv() -> Any: - return json.loads(ws.receive()) - - _dispatch_in_thread( - ws, - # remove any url prefix from path - path[len(options.url_prefix) :], - constructor(), - send, - recv, - ) - - sock.route(STREAM_PATH.name, endpoint="without_path")(model_stream) - sock.route(f"{STREAM_PATH.name}/", endpoint="with_path")(model_stream) - - -def _dispatch_in_thread( - websocket: WebSocket, - path: str, - component: ComponentType, - send: Callable[[Any], None], - recv: Callable[[], Any | None], -) -> NoReturn: - dispatch_thread_info_created = ThreadEvent() - dispatch_thread_info_ref: reactpy.Ref[_DispatcherThreadInfo | None] = reactpy.Ref( - None - ) - - @copy_current_request_context - def run_dispatcher() -> None: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - thread_send_queue: ThreadQueue[Any] = ThreadQueue() - async_recv_queue: AsyncQueue[Any] = AsyncQueue() - - async def send_coro(value: Any) -> None: - thread_send_queue.put(value) - - async def main() -> None: - search = request.query_string.decode() - await serve_layout( - reactpy.Layout( - ConnectionContext( - component, - value=Connection( - scope=request.environ, - location=Location( - pathname=f"/{path}", - search=f"?{search}" if search else "", - ), - carrier=_FlaskCarrier(request, websocket), - ), - ), - ), - send_coro, - async_recv_queue.get, - ) - - main_future = asyncio.ensure_future(main(), loop=loop) - - dispatch_thread_info_ref.current = _DispatcherThreadInfo( - dispatch_loop=loop, - dispatch_future=main_future, - thread_send_queue=thread_send_queue, - async_recv_queue=async_recv_queue, - ) - dispatch_thread_info_created.set() - - loop.run_until_complete(main_future) - - Thread(target=run_dispatcher, daemon=True).start() - - dispatch_thread_info_created.wait() - dispatch_thread_info = cast(_DispatcherThreadInfo, dispatch_thread_info_ref.current) - - if dispatch_thread_info is None: - raise RuntimeError("Failed to create dispatcher thread") # nocov - - stop = ThreadEvent() - - def run_send() -> None: - while not stop.is_set(): - send(dispatch_thread_info.thread_send_queue.get()) - - Thread(target=run_send, daemon=True).start() - - try: - while True: - value = recv() - dispatch_thread_info.dispatch_loop.call_soon_threadsafe( - dispatch_thread_info.async_recv_queue.put_nowait, value - ) - finally: # nocov - dispatch_thread_info.dispatch_loop.call_soon_threadsafe( - dispatch_thread_info.dispatch_future.cancel - ) - - -class _DispatcherThreadInfo(NamedTuple): - dispatch_loop: asyncio.AbstractEventLoop - dispatch_future: asyncio.Future[Any] - thread_send_queue: ThreadQueue[Any] - async_recv_queue: AsyncQueue[Any] - - -@dataclass -class _FlaskCarrier: - """A simple wrapper for holding a Flask request and WebSocket""" - - request: Request - """The current request object""" - - websocket: WebSocket - """A handle to the current websocket""" diff --git a/src/reactpy/backend/hooks.py b/src/reactpy/backend/hooks.py deleted file mode 100644 index ec761ef0f..000000000 --- a/src/reactpy/backend/hooks.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations # nocov - -from collections.abc import MutableMapping # nocov -from typing import Any # nocov - -from reactpy._warnings import warn # nocov -from reactpy.backend.types import Connection, Location # nocov -from reactpy.core.hooks import ConnectionContext, use_context # nocov - - -def use_connection() -> Connection[Any]: # nocov - """Get the current :class:`~reactpy.backend.types.Connection`.""" - warn( - "The module reactpy.backend.hooks has been deprecated and will be deleted in the future. " - "Call reactpy.use_connection instead.", - DeprecationWarning, - ) - - conn = use_context(ConnectionContext) - if conn is None: - msg = "No backend established a connection." - raise RuntimeError(msg) - return conn - - -def use_scope() -> MutableMapping[str, Any]: # nocov - """Get the current :class:`~reactpy.backend.types.Connection`'s scope.""" - warn( - "The module reactpy.backend.hooks has been deprecated and will be deleted in the future. " - "Call reactpy.use_scope instead.", - DeprecationWarning, - ) - - return use_connection().scope - - -def use_location() -> Location: # nocov - """Get the current :class:`~reactpy.backend.types.Connection`'s location.""" - warn( - "The module reactpy.backend.hooks has been deprecated and will be deleted in the future. " - "Call reactpy.use_location instead.", - DeprecationWarning, - ) - - return use_connection().location diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py deleted file mode 100644 index d272fb4cf..000000000 --- a/src/reactpy/backend/sanic.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from dataclasses import dataclass -from typing import Any -from urllib import parse as urllib_parse -from uuid import uuid4 - -from sanic import Blueprint, Sanic, request, response -from sanic.config import Config -from sanic.server.websockets.connection import WebSocketConnection -from sanic_cors import CORS - -from reactpy.backend._common import ( - ASSETS_PATH, - MODULES_PATH, - PATH_PREFIX, - STREAM_PATH, - CommonOptions, - read_client_index_html, - safe_client_build_dir_path, - safe_web_modules_dir_path, - serve_with_uvicorn, -) -from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, Stop, serve_layout -from reactpy.core.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - - -# BackendType.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.sanic.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``sanic_cors.CORS`` - """ - - -# BackendType.configure -def configure( - app: Sanic[Any, Any], - component: RootComponentConstructor, - options: Options | None = None, -) -> None: - """Configure an application instance to display the given component""" - options = options or Options() - - spa_bp = Blueprint(f"reactpy_spa_{id(app)}", url_prefix=options.url_prefix) - api_bp = Blueprint(f"reactpy_api_{id(app)}", url_prefix=str(PATH_PREFIX)) - - _setup_common_routes(api_bp, spa_bp, options) - _setup_single_view_dispatcher_route(api_bp, component, options) - - app.blueprint([spa_bp, api_bp]) - - -# BackendType.create_development_app -def create_development_app() -> Sanic[Any, Any]: - """Return a :class:`Sanic` app instance in test mode""" - Sanic.test_mode = True - logger.warning("Sanic.test_mode is now active") - return Sanic(f"reactpy_development_app_{uuid4().hex}", Config()) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Sanic[Any, Any], - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for :mod:`sanic`""" - await serve_with_uvicorn(app, host, port, started) - - -def use_request() -> request.Request[Any, Any]: - """Get the current ``Request``""" - return use_connection().carrier.request - - -def use_websocket() -> WebSocketConnection: - """Get the current websocket""" - return use_connection().carrier.websocket - - -def use_connection() -> Connection[_SanicCarrier]: - """Get the current :class:`Connection`""" - conn = _use_connection() - if not isinstance(conn.carrier, _SanicCarrier): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Sanic server?" - raise TypeError(msg) - return conn - - -def _setup_common_routes( - api_blueprint: Blueprint, - spa_blueprint: Blueprint, - options: Options, -) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = cors_options if isinstance(cors_options, dict) else {} - CORS(api_blueprint, **cors_params) - - index_html = read_client_index_html(options) - - async def single_page_app_files( - request: request.Request[Any, Any], - _: str = "", - ) -> response.HTTPResponse: - return response.html(index_html) - - if options.serve_index_route: - spa_blueprint.add_route( - single_page_app_files, - "/", - name="single_page_app_files_root", - ) - spa_blueprint.add_route( - single_page_app_files, - "/<_:path>", - name="single_page_app_files_path", - ) - - async def asset_files( - request: request.Request[Any, Any], - path: str = "", - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file(safe_client_build_dir_path(f"assets/{path}")) - - api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") - - async def web_module_files( - request: request.Request[Any, Any], - path: str, - _: str = "", # this is not used - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file( - safe_web_modules_dir_path(path), - mime_type="text/javascript", - ) - - api_blueprint.add_route(web_module_files, f"/{MODULES_PATH.name}/") - - -def _setup_single_view_dispatcher_route( - api_blueprint: Blueprint, - constructor: RootComponentConstructor, - options: Options, -) -> None: - async def model_stream( - request: request.Request[Any, Any], - socket: WebSocketConnection, - path: str = "", - ) -> None: - asgi_app = getattr(request.app, "_asgi_app", None) - scope = asgi_app.transport.scope if asgi_app else {} - if not scope: # nocov - logger.warning("No scope. Sanic may not be running with an ASGI server") - - send, recv = _make_send_recv_callbacks(socket) - await serve_layout( - Layout( - ConnectionContext( - constructor(), - value=Connection( - scope=scope, - location=Location( - pathname=f"/{path[len(options.url_prefix):]}", - search=( - f"?{request.query_string}" - if request.query_string - else "" - ), - ), - carrier=_SanicCarrier(request, socket), - ), - ) - ), - send, - recv, - ) - - api_blueprint.add_websocket_route( - model_stream, - f"/{STREAM_PATH.name}", - name="model_stream_root", - ) - api_blueprint.add_websocket_route( - model_stream, - f"/{STREAM_PATH.name}//", - name="model_stream_path", - ) - - -def _make_send_recv_callbacks( - socket: WebSocketConnection, -) -> tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: Any) -> None: - await socket.send(json.dumps(value)) - - async def sock_recv() -> Any: - data = await socket.recv() - if data is None: - raise Stop() - return json.loads(data) - - return sock_send, sock_recv - - -@dataclass -class _SanicCarrier: - """A simple wrapper for holding connection information""" - - request: request.Request[Sanic[Any, Any], Any] - """The current request object""" - - websocket: WebSocketConnection - """A handle to the current websocket""" diff --git a/src/reactpy/backend/starlette.py b/src/reactpy/backend/starlette.py deleted file mode 100644 index 20e2b4478..000000000 --- a/src/reactpy/backend/starlette.py +++ /dev/null @@ -1,185 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from collections.abc import Awaitable -from dataclasses import dataclass -from typing import Any, Callable - -from exceptiongroup import BaseExceptionGroup -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware -from starlette.requests import Request -from starlette.responses import HTMLResponse -from starlette.staticfiles import StaticFiles -from starlette.websockets import WebSocket, WebSocketDisconnect - -from reactpy.backend._common import ( - ASSETS_PATH, - CLIENT_BUILD_DIR, - MODULES_PATH, - STREAM_PATH, - CommonOptions, - read_client_index_html, - serve_with_uvicorn, -) -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, serve_layout -from reactpy.core.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - - -# BackendType.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.starlette.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` - """ - - -# BackendType.configure -def configure( - app: Starlette, - component: RootComponentConstructor, - options: Options | None = None, -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - - # this route should take priority so set up it up first - _setup_single_view_dispatcher_route(options, app, component) - - _setup_common_routes(options, app) - - -# BackendType.create_development_app -def create_development_app() -> Starlette: - """Return a :class:`Starlette` app instance in debug mode""" - return Starlette(debug=True) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Starlette, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for starlette""" - await serve_with_uvicorn(app, host, port, started) - - -def use_websocket() -> WebSocket: - """Get the current WebSocket object""" - return use_connection().carrier - - -def use_connection() -> Connection[WebSocket]: - conn = _use_connection() - if not isinstance(conn.carrier, WebSocket): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -def _setup_common_routes(options: Options, app: Starlette) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = ( - cors_options if isinstance(cors_options, dict) else {"allow_origins": ["*"]} - ) - app.add_middleware(CORSMiddleware, **cors_params) - - # This really should be added to the APIRouter, but there's a bug in Starlette - # BUG: https://github.com/tiangolo/fastapi/issues/1469 - url_prefix = options.url_prefix - - app.mount( - str(MODULES_PATH), - StaticFiles(directory=REACTPY_WEB_MODULES_DIR.current, check_dir=False), - ) - app.mount( - str(ASSETS_PATH), - StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False), - ) - # register this last so it takes least priority - index_route = _make_index_route(options) - - if options.serve_index_route: - app.add_route(f"{url_prefix}/", index_route) - app.add_route(url_prefix + "/{path:path}", index_route) - - -def _make_index_route(options: Options) -> Callable[[Request], Awaitable[HTMLResponse]]: - index_html = read_client_index_html(options) - - async def serve_index(request: Request) -> HTMLResponse: - return HTMLResponse(index_html) - - return serve_index - - -def _setup_single_view_dispatcher_route( - options: Options, app: Starlette, component: RootComponentConstructor -) -> None: - async def model_stream(socket: WebSocket) -> None: - await socket.accept() - send, recv = _make_send_recv_callbacks(socket) - - pathname = "/" + socket.scope["path_params"].get("path", "") - pathname = pathname[len(options.url_prefix) :] or "/" - search = socket.scope["query_string"].decode() - - try: - await serve_layout( - Layout( - ConnectionContext( - component(), - value=Connection( - scope=socket.scope, - location=Location(pathname, f"?{search}" if search else ""), - carrier=socket, - ), - ) - ), - send, - recv, - ) - except BaseExceptionGroup as egroup: - for e in egroup.exceptions: - if isinstance(e, WebSocketDisconnect): - logger.info(f"WebSocket disconnect: {e.code}") - break - else: # nocov - raise - - app.add_websocket_route(str(STREAM_PATH), model_stream) - app.add_websocket_route(f"{STREAM_PATH}/{{path:path}}", model_stream) - - -def _make_send_recv_callbacks( - socket: WebSocket, -) -> tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: Any) -> None: - await socket.send_text(json.dumps(value)) - - async def sock_recv() -> Any: - return json.loads(await socket.receive_text()) - - return sock_send, sock_recv diff --git a/src/reactpy/backend/tornado.py b/src/reactpy/backend/tornado.py deleted file mode 100644 index e585553e8..000000000 --- a/src/reactpy/backend/tornado.py +++ /dev/null @@ -1,235 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from asyncio import Queue as AsyncQueue -from asyncio.futures import Future -from typing import Any -from urllib.parse import urljoin - -from tornado.httpserver import HTTPServer -from tornado.httputil import HTTPServerRequest -from tornado.log import enable_pretty_logging -from tornado.platform.asyncio import AsyncIOMainLoop -from tornado.web import Application, RequestHandler, StaticFileHandler -from tornado.websocket import WebSocketHandler -from tornado.wsgi import WSGIContainer -from typing_extensions import TypeAlias - -from reactpy.backend._common import ( - ASSETS_PATH, - CLIENT_BUILD_DIR, - MODULES_PATH, - STREAM_PATH, - CommonOptions, - read_client_index_html, -) -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.layout import Layout -from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentConstructor - -# BackendType.Options -Options = CommonOptions - - -# BackendType.configure -def configure( - app: Application, - component: ComponentConstructor, - options: CommonOptions | None = None, -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - _add_handler( - app, - options, - ( - # this route should take priority so set up it up first - _setup_single_view_dispatcher_route(component, options) - + _setup_common_routes(options) - ), - ) - - -# BackendType.create_development_app -def create_development_app() -> Application: - return Application(debug=True) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Application, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - enable_pretty_logging() - - AsyncIOMainLoop.current().install() - - server = HTTPServer(app) - server.listen(port, host) - - if started: - # at this point the server is accepting connection - started.set() - - try: - # block forever - tornado has already set up its own background tasks - await asyncio.get_running_loop().create_future() - finally: - # stop accepting new connections - server.stop() - # wait for existing connections to complete - await server.close_all_connections() - - -def use_request() -> HTTPServerRequest: - """Get the current ``HTTPServerRequest``""" - return use_connection().carrier - - -def use_connection() -> Connection[HTTPServerRequest]: - conn = _use_connection() - if not isinstance(conn.carrier, HTTPServerRequest): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -_RouteHandlerSpecs: TypeAlias = "list[tuple[str, type[RequestHandler], Any]]" - - -def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: - return [ - ( - rf"{MODULES_PATH}/(.*)", - StaticFileHandler, - {"path": str(REACTPY_WEB_MODULES_DIR.current)}, - ), - ( - rf"{ASSETS_PATH}/(.*)", - StaticFileHandler, - {"path": str(CLIENT_BUILD_DIR / "assets")}, - ), - ] + ( - [ - ( - r"/(.*)", - IndexHandler, - {"index_html": read_client_index_html(options)}, - ), - ] - if options.serve_index_route - else [] - ) - - -def _add_handler( - app: Application, options: Options, handlers: _RouteHandlerSpecs -) -> None: - prefixed_handlers: list[Any] = [ - (urljoin(options.url_prefix, route_pattern), *tuple(handler_info)) - for route_pattern, *handler_info in handlers - ] - app.add_handlers(r".*", prefixed_handlers) - - -def _setup_single_view_dispatcher_route( - constructor: ComponentConstructor, options: Options -) -> _RouteHandlerSpecs: - return [ - ( - rf"{STREAM_PATH}/(.*)", - ModelStreamHandler, - {"component_constructor": constructor, "url_prefix": options.url_prefix}, - ), - ( - str(STREAM_PATH), - ModelStreamHandler, - {"component_constructor": constructor, "url_prefix": options.url_prefix}, - ), - ] - - -class IndexHandler(RequestHandler): # type: ignore - _index_html: str - - def initialize(self, index_html: str) -> None: - self._index_html = index_html - - async def get(self, _: str) -> None: - self.finish(self._index_html) - - -class ModelStreamHandler(WebSocketHandler): # type: ignore - """A web-socket handler that serves up a new model stream to each new client""" - - _dispatch_future: Future[None] - _message_queue: AsyncQueue[str] - - def initialize( - self, component_constructor: ComponentConstructor, url_prefix: str - ) -> None: - self._component_constructor = component_constructor - self._url_prefix = url_prefix - - async def open(self, path: str = "", *args: Any, **kwargs: Any) -> None: - message_queue: AsyncQueue[str] = AsyncQueue() - - async def send(value: Any) -> None: - await self.write_message(json.dumps(value)) - - async def recv() -> Any: - return json.loads(await message_queue.get()) - - self._message_queue = message_queue - self._dispatch_future = asyncio.ensure_future( - serve_layout( - Layout( - ConnectionContext( - self._component_constructor(), - value=Connection( - scope=_FAKE_WSGI_CONTAINER.environ(self.request), - location=Location( - pathname=f"/{path[len(self._url_prefix) :]}", - search=( - f"?{self.request.query}" - if self.request.query - else "" - ), - ), - carrier=self.request, - ), - ) - ), - send, - recv, - ) - ) - - async def on_message(self, message: str | bytes) -> None: - await self._message_queue.put( - message if isinstance(message, str) else message.decode() - ) - - def on_close(self) -> None: - if not self._dispatch_future.done(): - self._dispatch_future.cancel() - - -# The interface for WSGIContainer.environ changed in Tornado version 6.3 from -# a staticmethod to an instance method. Since we're not that concerned with -# the details of the WSGI app itself, we can just use a fake one. -# see: https://github.com/tornadoweb/tornado/pull/3231#issuecomment-1518957578 -_FAKE_WSGI_CONTAINER = WSGIContainer(lambda *a, **kw: iter([])) diff --git a/src/reactpy/backend/types.py b/src/reactpy/backend/types.py index 51e7bef04..aaaf7c754 100644 --- a/src/reactpy/backend/types.py +++ b/src/reactpy/backend/types.py @@ -1,48 +1,14 @@ from __future__ import annotations -import asyncio from collections.abc import MutableMapping from dataclasses import dataclass -from typing import Any, Callable, Generic, Protocol, TypeVar, runtime_checkable +from typing import Any, Generic, TypeVar -from reactpy.core.types import RootComponentConstructor - -_App = TypeVar("_App") - - -@runtime_checkable -class BackendType(Protocol[_App]): - """Common interface for built-in web server/framework integrations""" - - Options: Callable[..., Any] - """A constructor for options passed to :meth:`BackendType.configure`""" - - def configure( - self, - app: _App, - component: RootComponentConstructor, - options: Any | None = None, - ) -> None: - """Configure the given app instance to display the given component""" - - def create_development_app(self) -> _App: - """Create an application instance for development purposes""" - - async def serve_development_app( - self, - app: _App, - host: str, - port: int, - started: asyncio.Event | None = None, - ) -> None: - """Run an application using a development server""" - - -_Carrier = TypeVar("_Carrier") +CarrierType = TypeVar("CarrierType") @dataclass -class Connection(Generic[_Carrier]): +class Connection(Generic[CarrierType]): """Represents a connection with a client""" scope: MutableMapping[str, Any] @@ -51,7 +17,7 @@ class Connection(Generic[_Carrier]): location: Location """The current location (URL)""" - carrier: _Carrier + carrier: CarrierType """How the connection is mediated. For example, a request or websocket. This typically depends on the backend implementation. From d1fe8c747823556b0d7559cc35965a66353358cb Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 24 Jan 2025 17:18:57 -0800 Subject: [PATCH 28/84] allow extension matching in copy dir --- src/build_scripts/copy_dir.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/build_scripts/copy_dir.py b/src/build_scripts/copy_dir.py index 34c87bf4d..6c28f8c95 100644 --- a/src/build_scripts/copy_dir.py +++ b/src/build_scripts/copy_dir.py @@ -5,36 +5,39 @@ # ruff: noqa: INP001 import logging +import re import shutil import sys from pathlib import Path -def copy_files(source: Path, destination: Path) -> None: +def copy_files(source: Path, destination: Path, pattern: str) -> None: if destination.exists(): shutil.rmtree(destination) destination.mkdir() for file in source.iterdir(): if file.is_file(): - shutil.copy(file, destination / file.name) + if not pattern or re.match(pattern, file.name): + shutil.copy(file, destination / file.name) else: - copy_files(file, destination / file.name) + copy_files(file, destination / file.name, pattern) if __name__ == "__main__": - if len(sys.argv) != 3: # noqa + if len(sys.argv) not in {3, 4}: logging.error( - "Script used incorrectly!\nUsage: python copy_dir.py " + "Script used incorrectly!\nUsage: python copy_dir.py " ) sys.exit(1) root_dir = Path(__file__).parent.parent.parent - src = Path(root_dir / sys.argv[1]) - dest = Path(root_dir / sys.argv[2]) + _source = Path(root_dir / sys.argv[1]) + _destintation = Path(root_dir / sys.argv[2]) + _pattern = sys.argv[3] if len(sys.argv) == 4 else "" # noqa - if not src.exists(): - logging.error("Source directory %s does not exist", src) + if not _source.exists(): + logging.error("Source directory %s does not exist", _source) sys.exit(1) - copy_files(src, dest) + copy_files(_source, _destintation, _pattern) From ba899d6a6fbce0f304e0295d2ad4448913a59612 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 24 Jan 2025 21:52:39 -0800 Subject: [PATCH 29/84] functional standalone app and static apps, broken dispatcher --- pyproject.toml | 3 +- src/js/packages/@reactpy/app/package.json | 2 +- src/js/packages/@reactpy/app/src/index.html | 15 - src/js/packages/@reactpy/app/src/index.ts | 2 +- src/js/packages/@reactpy/client/src/mount.tsx | 10 +- src/js/packages/@reactpy/client/src/types.ts | 9 +- .../packages/@reactpy/client/src/websocket.ts | 28 +- src/reactpy/backend/asgi.py | 346 ------------------ src/reactpy/backend/middleware.py | 240 ++++++++++++ src/reactpy/backend/standalone.py | 193 ++++++++++ src/reactpy/backend/utils.py | 106 +++--- src/reactpy/core/layout.py | 3 +- src/reactpy/templates/index.html | 23 ++ src/reactpy/types.py | 3 +- 14 files changed, 552 insertions(+), 431 deletions(-) delete mode 100644 src/js/packages/@reactpy/app/src/index.html delete mode 100644 src/reactpy/backend/asgi.py create mode 100644 src/reactpy/backend/middleware.py create mode 100644 src/reactpy/backend/standalone.py create mode 100644 src/reactpy/templates/index.html diff --git a/pyproject.toml b/pyproject.toml index 757872d6c..84bb94678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "colorlog >=6", "asgiref >=3", "lxml >=4", + "servestatic >= 3.0.0", ] dynamic = ["version"] urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" @@ -69,7 +70,7 @@ commands = [ 'bun run --cwd "src/js/packages/@reactpy/client" build', 'bun install --cwd "src/js/packages/@reactpy/app"', 'bun run --cwd "src/js/packages/@reactpy/app" build', - 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static"', + 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static" ".*\.js(\.map)?$"', ] artifacts = [] diff --git a/src/js/packages/@reactpy/app/package.json b/src/js/packages/@reactpy/app/package.json index f8417dd0d..5efc163c3 100644 --- a/src/js/packages/@reactpy/app/package.json +++ b/src/js/packages/@reactpy/app/package.json @@ -11,7 +11,7 @@ "typescript": "^5.7.3" }, "scripts": { - "build": "bun build \"src/index.html\" --outdir=\"dist\" --minify --sourcemap=\"linked\" --experimental-html --public-path=\"reactpy/static/\"", + "build": "bun build \"src/index.ts\" --outdir=\"dist\" --minify --sourcemap=\"linked\"", "checkTypes": "tsc --noEmit" } } diff --git a/src/js/packages/@reactpy/app/src/index.html b/src/js/packages/@reactpy/app/src/index.html deleted file mode 100644 index 1cb72a031..000000000 --- a/src/js/packages/@reactpy/app/src/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - -
- {mount_script} - - - - diff --git a/src/js/packages/@reactpy/app/src/index.ts b/src/js/packages/@reactpy/app/src/index.ts index 36efbe5a1..55ebf2c10 100644 --- a/src/js/packages/@reactpy/app/src/index.ts +++ b/src/js/packages/@reactpy/app/src/index.ts @@ -1 +1 @@ -export { mount } from "@reactpy/client"; +export { mountReactPy } from "@reactpy/client"; diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index 25ebb9fa2..f2ffc87c4 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -3,11 +3,11 @@ import { ReactPyClient } from "./client"; import { Layout } from "./components"; import { MountProps } from "./types"; -export function mount(props: MountProps) { +export function mountReactPy(props: MountProps) { // WebSocket route for component rendering const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`; - let wsOrigin = `${wsProtocol}//${window.location.host}`; - let componentUrl = new URL(`${wsOrigin}/${props.componentPath}`); + const wsOrigin = `${wsProtocol}//${window.location.host}`; + const componentUrl = new URL(`${wsOrigin}/${props.pathPrefix}/`); // Embed the initial HTTP path into the WebSocket URL componentUrl.searchParams.append("http_pathname", window.location.pathname); @@ -20,10 +20,10 @@ export function mount(props: MountProps) { urls: { componentUrl: componentUrl, query: document.location.search, - jsModules: `${window.location.origin}/${props.jsModulesPath}`, + jsModules: `${window.location.origin}/${props.pathPrefix}/modules/`, }, reconnectOptions: { - startInterval: props.reconnectStartInterval || 750, + interval: props.reconnectInterval || 750, maxInterval: props.reconnectMaxInterval || 60000, backoffMultiplier: props.reconnectBackoffMultiplier || 1.25, maxRetries: props.reconnectMaxRetries || 150, diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 295c8dfa1..7244f99ba 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -3,7 +3,7 @@ import { ComponentType } from "react"; // #### CONNECTION TYPES #### export type ReconnectOptions = { - startInterval: number; + interval: number; maxInterval: number; maxRetries: number; backoffMultiplier: number; @@ -15,7 +15,7 @@ export type CreateReconnectingWebSocketProps = { onMessage: (message: MessageEvent) => void; onOpen?: () => void; onClose?: () => void; - startInterval: number; + interval: number; maxInterval: number; maxRetries: number; backoffMultiplier: number; @@ -35,9 +35,8 @@ export type GenericReactPyClientProps = { export type MountProps = { mountElement: HTMLElement; - componentPath: string; - jsModulesPath: string; - reconnectStartInterval?: number; + pathPrefix: string; + reconnectInterval?: number; reconnectMaxInterval?: number; reconnectMaxRetries?: number; reconnectBackoffMultiplier?: number; diff --git a/src/js/packages/@reactpy/client/src/websocket.ts b/src/js/packages/@reactpy/client/src/websocket.ts index 51ae2a69c..f36deadcd 100644 --- a/src/js/packages/@reactpy/client/src/websocket.ts +++ b/src/js/packages/@reactpy/client/src/websocket.ts @@ -4,9 +4,9 @@ import log from "./logger"; export function createReconnectingWebSocket( props: CreateReconnectingWebSocketProps, ) { - const { startInterval, maxInterval, maxRetries, backoffMultiplier } = props; + const { interval, maxInterval, maxRetries, backoffMultiplier } = props; let retries = 0; - let interval = startInterval; + let currentInterval = interval; let everConnected = false; const closed = false; const socket: { current?: WebSocket } = {}; @@ -18,8 +18,8 @@ export function createReconnectingWebSocket( socket.current = new WebSocket(props.url); socket.current.onopen = () => { everConnected = true; - log.info("ReactPy connected!"); - interval = startInterval; + log.info("Connected!"); + currentInterval = interval; retries = 0; if (props.onOpen) { props.onOpen(); @@ -31,26 +31,28 @@ export function createReconnectingWebSocket( props.onClose(); } if (!everConnected) { - log.info("ReactPy failed to connect!"); + log.info("Failed to connect!"); return; } - log.info("ReactPy disconnected!"); + log.info("Disconnected!"); if (retries >= maxRetries) { - log.info("ReactPy connection max retries exhausted!"); + log.info("Connection max retries exhausted!"); return; } log.info( - `ReactPy reconnecting in ${(interval / 1000).toPrecision(4)} seconds...`, + `Reconnecting in ${(currentInterval / 1000).toPrecision(4)} seconds...`, + ); + setTimeout(connect, currentInterval); + currentInterval = nextInterval( + currentInterval, + backoffMultiplier, + maxInterval, ); - setTimeout(connect, interval); - interval = nextInterval(interval, backoffMultiplier, maxInterval); retries++; }; }; - props.readyPromise - .then(() => log.info("Starting ReactPy client...")) - .then(connect); + props.readyPromise.then(() => log.info("Starting client...")).then(connect); return socket; } diff --git a/src/reactpy/backend/asgi.py b/src/reactpy/backend/asgi.py deleted file mode 100644 index 6a05b4ceb..000000000 --- a/src/reactpy/backend/asgi.py +++ /dev/null @@ -1,346 +0,0 @@ -import asyncio -import logging -import re -import urllib.parse -from collections.abc import Coroutine, Sequence -from concurrent.futures import Future -from importlib import import_module -from pathlib import Path -from threading import Thread -from typing import Any, Callable - -import aiofiles -import orjson -from asgiref.compatibility import guarantee_single_callable -from starlette.staticfiles import StaticFiles - -from reactpy.backend._common import ( - CLIENT_BUILD_DIR, - vdom_head_elements_to_html, -) -from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.component import Component -from reactpy.core.layout import Layout -from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentType, VdomDict - -_logger = logging.getLogger(__name__) -_backhaul_loop = asyncio.new_event_loop() - - -def start_backhaul_loop(): - """Starts the asyncio event loop that will perform component rendering - tasks.""" - asyncio.set_event_loop(_backhaul_loop) - _backhaul_loop.run_forever() - - -_backhaul_thread = Thread(target=start_backhaul_loop, daemon=True) - - -class ReactPy: - def __init__( - self, - app_or_component: ComponentType | Callable[..., Coroutine], - *, - dispatcher_path: str = "reactpy/", - web_modules_path: str = "reactpy/modules/", - web_modules_dir: Path | str | None = REACTPY_WEB_MODULES_DIR.current, - static_path: str = "reactpy/static/", - static_dir: Path | str | None = None, - head: Sequence[VdomDict] | VdomDict | str = "", - backhaul_thread: bool = True, - block_size: int = 8192, - ) -> None: - """Anything initialized in this method will be shared across all - requests.""" - # Convert kwargs to class attributes - self.dispatch_path = re.compile(f"^{dispatcher_path}(?P[^/]+)/?") - self.js_modules_path = re.compile(f"^{web_modules_path}") - self.web_modules_dir = web_modules_dir - self.static_path = re.compile(f"^{static_path}") - self.static_dir = static_dir - self.head = vdom_head_elements_to_html(head) - self.backhaul_thread = backhaul_thread - self.block_size = block_size - - # Internal attributes (not using the same name as a kwarg) - self.user_app: Callable[..., Coroutine] | None = ( - guarantee_single_callable(app_or_component) - if asyncio.iscoroutinefunction(app_or_component) - else None - ) - self.component: ComponentType | None = ( - None if self.user_app else app_or_component # type: ignore - ) - self.all_paths: re.Pattern = re.compile( - "|".join( - path - for path in [dispatcher_path, web_modules_path, static_path] - if path - ) - ) - self.dispatcher: Future | asyncio.Task | None = None - self._cached_index_html: str = "" - self._static_file_server: StaticFiles | None = None - self._web_module_server: StaticFiles | None = None - - # Startup tasks - if self.backhaul_thread and not _backhaul_thread.is_alive(): - _backhaul_thread.start() - if self.web_modules_dir != REACTPY_WEB_MODULES_DIR.current: - REACTPY_WEB_MODULES_DIR.set_current(self.web_modules_dir) - - # Validate the arguments - if not self.component and not self.user_app: - raise TypeError( - "The first argument to ReactPy(...) must be a component or an " - "ASGI application." - ) - if not check_path(dispatcher_path): - raise ValueError("Invalid `dispatcher_path`.") - if not check_path(web_modules_path): - raise ValueError("Invalid `web_modules_path`.") - if not check_path(static_path): - raise ValueError("Invalid `static_path`.") - - async def __call__( - self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - ) -> None: - """The ASGI callable. This determines whether ReactPy should route the - request to ourselves or to the user application.""" - # Determine if ReactPy should handle the request - if not self.user_app or re.match(self.all_paths, scope["path"]): - await self.reactpy_app(scope, receive, send) - return - - # Serve the user's application - await self.user_app(scope, receive, send) - - async def reactpy_app( - self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - ) -> None: - """Determine what type of request this is and route it to the - appropriate ReactPy ASGI sub-application.""" - # Only HTTP and WebSocket requests are supported - if scope["type"] not in {"http", "websocket"}: - return - - # Dispatch a Python component - if scope["type"] == "websocket" and re.match(self.dispatch_path, scope["path"]): - await self.component_dispatch_app(scope, receive, send) - return - - # Only HTTP GET and HEAD requests are supported - if scope["method"] not in {"GET", "HEAD"}: - await http_response(scope, send, 405, "Method Not Allowed") - return - - # JS modules app - if re.match(self.js_modules_path, scope["path"]): - await self.web_module_app(scope, receive, send) - return - - # Static file app - if re.match(self.static_path, scope["path"]): - await self.static_file_app(scope, receive, send) - return - - # Standalone app: Serve a single component using index.html - if self.component: - await self.standalone_app(scope, receive, send) - return - - async def component_dispatch_app( - self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - ) -> None: - """ASGI app for rendering ReactPy Python components.""" - ws_connected: bool = False - - while True: - # Future WS events on this connection will always be received here - event = await receive() - - if event["type"] == "websocket.connect" and not ws_connected: - ws_connected = True - await send({"type": "websocket.accept"}) - run_dispatcher = self.run_dispatcher(scope, receive, send) - if self.backhaul_thread: - self.dispatcher = asyncio.run_coroutine_threadsafe( - run_dispatcher, _backhaul_loop - ) - else: - self.dispatcher = asyncio.create_task(run_dispatcher) - - if event["type"] == "websocket.disconnect": - if self.dispatcher: - self.dispatcher.cancel() - break - - if event["type"] == "websocket.receive": - recv_queue_put = self.recv_queue.put(orjson.loads(event["text"])) - if self.backhaul_thread: - asyncio.run_coroutine_threadsafe(recv_queue_put, _backhaul_loop) - else: - await recv_queue_put - - async def web_module_app( - self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - ) -> None: - """ASGI app for ReactPy web modules.""" - if not self.web_modules_dir: - await asyncio.to_thread( - _logger.info, - "Tried to serve web module without a configured directory.", - ) - if self.user_app: - await self.user_app(scope, receive, send) - return - - if not self._web_module_server: - self._web_module_server = StaticFiles(directory=self.web_modules_dir) - await self._web_module_server(scope, receive, send) - - async def static_file_app( - self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - ) -> None: - """ASGI app for ReactPy static files.""" - # If no static directory is configured, serve the user's application - if not self.static_dir: - await asyncio.to_thread( - _logger.info, - "Tried to serve static file without a configured directory.", - ) - if self.user_app: - await self.user_app(scope, receive, send) - return - - if not self._static_file_server: - self._static_file_server = StaticFiles(directory=self.static_dir) - await self._static_file_server(scope, receive, send) - - async def standalone_app( - self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - ) -> None: - """ASGI app for ReactPy standalone mode.""" - file_path = CLIENT_BUILD_DIR / "index.html" - if not self._cached_index_html: - async with aiofiles.open(file_path, "rb") as file_handle: - self._cached_index_html = str(await file_handle.read()).format( - __head__=self.head - ) - - # Send the index.html - await http_response( - scope, - send, - 200, - self._cached_index_html, - content_type=b"text/html", - headers=[ - (b"content-length", len(self._cached_index_html)), - (b"etag", hash(self._cached_index_html)), - ], - ) - - async def run_dispatcher( - self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - ) -> None: - # If in standalone mode, serve the user provided component. - # In middleware mode, get the component from the URL. - component = self.component - if not component: - url_path = re.match(self.dispatch_path, scope["path"]) - if not url_path: - raise RuntimeError("Could not find component in URL path.") - dotted_path = url_path[1] - module_str, component_str = dotted_path.rsplit(".", 1) - module = import_module(module_str) - component = getattr(module, component_str) - parsed_url = urllib.parse.urlparse(scope["path"]) - self.recv_queue: asyncio.Queue = asyncio.Queue() - - await serve_layout( - Layout( - ConnectionContext( - component(), - value=Connection( - scope=scope, - location=Location( - parsed_url.path, - f"?{parsed_url.query}" if parsed_url.query else "", - ), - carrier={ - "scope": scope, - "send": send, - "receive": receive, - }, - ), - ) - ), - send_json(send), - self.recv_queue.get, - ) - - -def send_json(send: Callable) -> Callable[..., Coroutine]: - """Use orjson to send JSON over an ASGI websocket.""" - - async def _send_json(value: Any) -> None: - await send({"type": "websocket.send", "text": orjson.dumps(value)}) - - return _send_json - - -async def http_response( - scope: dict[str, Any], - send: Callable[..., Coroutine], - code: int, - message: str, - content_type: bytes = b"text/plain", - headers: Sequence = (), -) -> None: - """Send a simple response.""" - await send( - { - "type": "http.response.start", - "status": code, - "headers": [(b"content-type", content_type), *headers], - } - ) - # Head requests don't need a body - if scope["method"] != "HEAD": - await send({"type": "http.response.body", "body": message.encode()}) - - -def check_path(url_path: str) -> bool: - """Check that a path is valid URL path.""" - return ( - not url_path - or not isinstance(url_path, str) - or not url_path[0].isalnum() - or not url_path.endswith("/") - ) diff --git a/src/reactpy/backend/middleware.py b/src/reactpy/backend/middleware.py new file mode 100644 index 000000000..ece2cafe8 --- /dev/null +++ b/src/reactpy/backend/middleware.py @@ -0,0 +1,240 @@ +import asyncio +import logging +import re +import urllib.parse +from collections.abc import Coroutine, Iterable +from concurrent.futures import Future +from importlib import import_module +from pathlib import Path +from threading import Thread +from typing import Any, Callable + +import orjson +from asgiref.compatibility import guarantee_single_callable +from servestatic import ServeStaticASGI + +from reactpy.backend.types import Connection, Location +from reactpy.backend.utils import check_path, import_components, normalize_url_path +from reactpy.config import REACTPY_WEB_MODULES_DIR +from reactpy.core.hooks import ConnectionContext +from reactpy.core.layout import Layout +from reactpy.core.serve import serve_layout +from reactpy.core.types import ComponentType + +_logger = logging.getLogger(__name__) +_backhaul_loop = asyncio.new_event_loop() + + +def start_backhaul_loop(): + """Starts the asyncio event loop that will perform component rendering + tasks.""" + asyncio.set_event_loop(_backhaul_loop) + _backhaul_loop.run_forever() + + +_backhaul_thread = Thread(target=start_backhaul_loop, daemon=True) + + +class ReactPyMiddleware: + def __init__( + self, + app: Callable[..., Coroutine], + root_components: Iterable[str], + *, + # TODO: Add a setting attribute to this class. Or maybe just put a shit ton of kwargs here. Or add a **kwargs that resolves to a TypedDict? + path_prefix: str = "reactpy/", + web_modules_dir: Path | None = None, + ) -> None: + """Configure the ASGI app. Anything initialized in this method will be shared across all future requests.""" + # Configure class attributes + self.path_prefix = normalize_url_path(path_prefix) + self.dispatcher_path = f"/{self.path_prefix}/" + self.web_modules_path = f"/{self.path_prefix}/modules/" + self.static_path = f"/{self.path_prefix}/static/" + self.dispatcher_pattern = re.compile( + f"^{self.dispatcher_path}(?P[^/]+)/?" + ) + self.js_modules_pattern = re.compile(f"^{self.web_modules_path}.*") + self.static_pattern = re.compile(f"^{self.static_path}.*") + self.web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR.current + self.static_dir = Path(__file__).parent.parent / "static" + self.backhaul_thread = False # TODO: Add backhaul_thread settings + self.user_app = guarantee_single_callable(app) + self.servestatic_static: ServeStaticASGI | None = None + self.servestatic_web_modules: ServeStaticASGI | None = None + self.component_dotted_paths = set(root_components) + self.components: dict[str, ComponentType] = import_components( + self.component_dotted_paths + ) + self.dispatcher: Future | asyncio.Task | None = None + self.recv_queue: asyncio.Queue = asyncio.Queue() + if self.web_modules_dir != REACTPY_WEB_MODULES_DIR.current: + REACTPY_WEB_MODULES_DIR.set_current(self.web_modules_dir) + + # Start the backhaul thread if it's not already running + if self.backhaul_thread and not _backhaul_thread.is_alive(): + _backhaul_thread.start() + + # Validate the arguments + reason = check_path(self.path_prefix) + if reason: + raise ValueError(f"Invalid `path_prefix`. {reason}") + + async def __call__( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """The ASGI entrypoint that determines whether ReactPy should route the + request to ourselves or to the user application.""" + # URL routing for the ReactPy renderer + if scope["type"] == "websocket" and self.match_dispatch_path(scope): + return await self.component_dispatch_app(scope, receive, send) + + # URL routing for ReactPy web modules + if scope["type"] == "http" and re.match(self.js_modules_pattern, scope["path"]): + return await self.web_module_app(scope, receive, send) + + # URL routing for ReactPy static files + if scope["type"] == "http" and re.match(self.static_pattern, scope["path"]): + return await self.static_file_app(scope, receive, send) + + # Serve the user's application + await self.user_app(scope, receive, send) + + async def component_dispatch_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """ASGI app for rendering ReactPy Python components.""" + ws_connected: bool = False + + while True: + # Future WS events on this connection will always be received here + event = await receive() + + if event["type"] == "websocket.connect" and not ws_connected: + ws_connected = True + await send({"type": "websocket.accept"}) + run_dispatcher_coro = self.run_dispatcher(scope, receive, send) + if self.backhaul_thread: + self.dispatcher = asyncio.run_coroutine_threadsafe( + run_dispatcher_coro, _backhaul_loop + ) + else: + self.dispatcher = asyncio.create_task(run_dispatcher_coro) + + if event["type"] == "websocket.disconnect": + if self.dispatcher: + self.dispatcher.cancel() + break + + if event["type"] == "websocket.receive": + queue_put_coro = self.recv_queue.put(orjson.loads(event["text"])) + if self.backhaul_thread: + asyncio.run_coroutine_threadsafe(queue_put_coro, _backhaul_loop) + else: + await queue_put_coro + + async def web_module_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """ASGI app for ReactPy web modules.""" + if not self.web_modules_dir: + await asyncio.to_thread( + _logger.info, + "Tried to serve web module without a configured directory.", + ) + return await self.user_app(scope, receive, send) + + if not self.servestatic_web_modules: + self.servestatic_web_modules = ServeStaticASGI( + self.user_app, + root=self.web_modules_dir, + prefix=self.web_modules_path, + autorefresh=True, + ) + + return await self.servestatic_web_modules(scope, receive, send) + + async def static_file_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """ASGI app for ReactPy static files.""" + # If no static directory is configured, serve the user's application + if not self.static_dir: + await asyncio.to_thread( + _logger.info, + "Tried to serve static file without a configured directory.", + ) + return await self.user_app(scope, receive, send) + + if not self.servestatic_static: + self.servestatic_static = ServeStaticASGI( + self.user_app, root=self.static_dir, prefix=self.static_path + ) + + return await self.servestatic_static(scope, receive, send) + + async def run_dispatcher( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + # Get the component from the URL. + url_path = re.match(self.dispatcher_pattern, scope["path"]) + if not url_path: + raise RuntimeError("Could not find component in URL path.") + dotted_path = url_path[1] + module_str, component_str = dotted_path.rsplit(".", 1) + module = import_module(module_str) + component = getattr(module, component_str) + parsed_url = urllib.parse.urlparse(scope["path"]) + + await serve_layout( + Layout( # type: ignore + ConnectionContext( + component(), + value=Connection( + scope=scope, + location=Location( + parsed_url.path, + f"?{parsed_url.query}" if parsed_url.query else "", + ), + carrier={ + "scope": scope, + "send": send, + "receive": receive, + }, + ), + ) + ), + self.send_json_ws(send), + self.recv_queue.get, + ) + + @staticmethod + def send_json_ws(send: Callable) -> Callable[..., Coroutine]: + """Use orjson to send JSON over an ASGI websocket.""" + + async def _send_json(value: Any) -> None: + await send({"type": "websocket.send", "text": orjson.dumps(value)}) + + return _send_json + + def match_dispatch_path(self, scope: dict) -> bool: + match = re.match(self.dispatcher_pattern, scope["path"]) + return bool( + match + and match.groupdict().get("dotted_path") in self.component_dotted_paths + ) diff --git a/src/reactpy/backend/standalone.py b/src/reactpy/backend/standalone.py new file mode 100644 index 000000000..e1dbf3506 --- /dev/null +++ b/src/reactpy/backend/standalone.py @@ -0,0 +1,193 @@ +import hashlib +import os +from collections.abc import Coroutine, Sequence +from email.utils import formatdate +from logging import getLogger +from pathlib import Path +from typing import Any, Callable + +from reactpy.backend.middleware import ReactPyMiddleware +from reactpy.core.types import ComponentType + +_logger = getLogger(__name__) + + +class ReactPyStandalone(ReactPyMiddleware): + cached_index_html: str = "" + etag: str = "" + last_modified: str = "" + templates_dir = Path(__file__).parent.parent / "templates" + index_html_path = templates_dir / "index.html" + + def __init__( + self, + root_component: ComponentType, + *, + path_prefix: str = "reactpy/", + web_modules_dir: Path | None = None, + http_headers: dict[str, str | int] | None = None, + ) -> None: + super().__init__( + app=self.standalone_app, + root_components=[], + path_prefix=path_prefix, + web_modules_dir=web_modules_dir, + ) + self.root_component = root_component + self.extra_headers = http_headers or {} + + async def standalone_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """ASGI app for ReactPy standalone mode.""" + if scope["type"] != "http": + if scope["type"] != "lifespan": + _logger.warning( + "ReactPy app received unsupported request of type '%s' at path '%s'", + scope["type"], + scope["path"], + ) + return + + # Store the HTTP response in memory for performance + if not self.cached_index_html: + await self.process_index_html() + + # Return headers for all HTTP responses + request_headers = dict(scope["headers"]) + response_headers: dict[str, str | int] = { + "etag": f'"{self.etag}"', + "last-modified": self.last_modified, + "access-control-allow-origin": "*", + "cache-control": "max-age=60, public", + **self.extra_headers, + } + + # Browser is asking for the headers + if scope["method"] == "HEAD": + return await http_response( + scope["method"], + send, + 200, + "", + content_type=b"text/html", + headers=self.dict_to_byte_list(response_headers), + ) + + # Browser already has the content cached + if request_headers.get(b"if-none-match") == self.etag.encode(): + return await http_response( + scope["method"], + send, + 304, + "", + content_type=b"text/html", + headers=self.dict_to_byte_list(response_headers), + ) + + # Send the index.html + await http_response( + scope["method"], + send, + 200, + self.cached_index_html, + content_type=b"text/html", + headers=self.dict_to_byte_list( + response_headers | {"content-length": len(self.cached_index_html)} + ), + ) + + def match_dispatch_path(self, scope: dict) -> bool: + """Check if the path matches the dispatcher path.""" + return str(scope["path"]).startswith(self.dispatcher_path) + + async def process_index_html(self): + """Process the index.html file.""" + with open(self.index_html_path, encoding="utf-8") as file_handle: + cached_index_html = file_handle.read() + + self.cached_index_html = self.find_and_replace( + cached_index_html, + { + 'from "index.ts"': f'from "{self.static_path}index.js"', + "{path_prefix}": self.path_prefix, + "{reconnect_interval}": "750", + "{reconnect_max_interval}": "60000", + "{reconnect_max_retries}": "150", + "{reconnect_backoff_multiplier}": "1.25", + }, + ) + + self.etag = hashlib.md5( + self.cached_index_html.encode(), usedforsecurity=False + ).hexdigest() + + last_modified = os.stat(self.index_html_path).st_mtime + self.last_modified = formatdate(last_modified, usegmt=True) + + # @staticmethod + # def find_js_filename(content: str) -> str: + # """Find the qualified filename of the index.js file.""" + # substring = 'src="reactpy/static/index-' + # location = content.find(substring) + # if location == -1: + # raise ValueError(f"Could not find {substring} in content") + # start = content[location + len(substring) :] + # end = start.find('"') + # return f"index-{start[:end]}" + + @staticmethod + def dict_to_byte_list( + data: dict[str, str | int], + ) -> list[tuple[bytes, bytes]]: + """Convert a dictionary to a list of byte tuples.""" + result: list[tuple[bytes, bytes]] = [] + for key, value in data.items(): + new_key = key.encode() + new_value = ( + value.encode() if isinstance(value, str) else str(value).encode() + ) + result.append((new_key, new_value)) + return result + + @staticmethod + def find_and_replace(content: str, replacements: dict[str, str]) -> str: + """Find and replace content. Throw and error if the substring is not found.""" + for key, value in replacements.items(): + if key not in content: + raise ValueError(f"Could not find {key} in content") + content = content.replace(key, value) + return content + + +async def http_response( + method: str, + send: Callable[..., Coroutine], + code: int, + message: str, + content_type: bytes = b"text/plain", + headers: Sequence = (), +) -> None: + """Send a simple response.""" + # Head requests don't need body content + if method == "HEAD": + await send( + { + "type": "http.response.start", + "status": code, + "headers": [*headers], + } + ) + await send({"type": "http.response.body"}) + else: + await send( + { + "type": "http.response.start", + "status": code, + "headers": [(b"content-type", content_type), *headers], + } + ) + await send({"type": "http.response.body", "body": message.encode()}) diff --git a/src/reactpy/backend/utils.py b/src/reactpy/backend/utils.py index 74e87bb7b..9102dfa28 100644 --- a/src/reactpy/backend/utils.py +++ b/src/reactpy/backend/utils.py @@ -1,51 +1,42 @@ from __future__ import annotations -import asyncio import logging +import re import socket import sys -from collections.abc import Iterator +from collections.abc import Iterable from contextlib import closing from importlib import import_module from typing import Any -from reactpy.backend.types import BackendType +from reactpy.core.types import ComponentType from reactpy.types import RootComponentConstructor logger = logging.getLogger(__name__) -SUPPORTED_BACKENDS = ( - "fastapi", - "sanic", - "tornado", - "flask", - "starlette", -) - def run( component: RootComponentConstructor, - host: str = "127.0.0.1", + host: str = "localhost", port: int | None = None, - implementation: BackendType[Any] | None = None, ) -> None: """Run a component with a development server""" - logger.warning(_DEVELOPMENT_RUN_FUNC_WARNING) + logger.warning( + "The `run()` function is only intended for testing purposes! To run in production, " + "refer to ReactPy's documentation." + ) + + try: + import uvicorn + except ImportError as e: + raise ImportError( + "The `uvicorn` package is required to use `reactpy.run`. " + "Please install it with `pip install uvicorn`." + ) from e - implementation = implementation or import_module("reactpy.backend.default") - app = implementation.create_development_app() - implementation.configure(app, component) + app = ... port = port or find_available_port(host) - app_cls = type(app) - - logger.info( - "ReactPy is running with '%s.%s' at http://%s:%s", - app_cls.__module__, - app_cls.__name__, - host, - port, - ) - asyncio.run(implementation.serve_development_app(app, host, port)) + uvicorn.run(app, host=host, port=port) def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) -> int: @@ -68,20 +59,53 @@ def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) - raise RuntimeError(msg) -def all_implementations() -> Iterator[BackendType[Any]]: - """Yield all available server implementations""" - for name in SUPPORTED_BACKENDS: - try: - import_module(name) - except ImportError: # nocov - logger.debug("Failed to import %s", name, exc_info=True) - continue +def normalize_url_path(url: str) -> str: + """Normalize a URL path.""" + new_url = re.sub(r"/+", "/", url) + new_url = new_url.lstrip("/") + new_url = new_url.rstrip("/") + return new_url + + +def import_dotted_path(dotted_path: str) -> Any: + """Imports a dotted path and returns the callable.""" + module_name, component_name = dotted_path.rsplit(".", 1) + + try: + module = import_module(module_name) + except ImportError as error: + msg = f"Failed to import {module_name!r} while loading {component_name!r}" + raise RuntimeError(msg) from error + + return getattr(module, component_name) + + +def import_components(dotted_paths: Iterable[str]) -> dict[str, Any]: + """Imports a list of dotted paths and returns the callables.""" + results = { + dotted_path: import_dotted_path(dotted_path) for dotted_path in dotted_paths + } + + # Check that all imports are components + for dotted_path, component in results.items(): + errors: list[str] = [] + if not isinstance(component, ComponentType): + errors.append( + f"Expected ComponentType, got {type(component)} for {dotted_path}" + ) + if errors: + raise RuntimeError(". ".join(errors)) + + return results - reactpy_backend_name = f"{__name__.rsplit('.', 1)[0]}.{name}" - yield import_module(reactpy_backend_name) +def check_path(url_path: str) -> str: + """Check that a path is valid URL path.""" + if not url_path: + return "URL path must not be empty." + if not isinstance(url_path, str): + return "URL path is must be a string." + if not url_path[0].isalnum(): + return "URL path must start with an alphanumeric character." -_DEVELOPMENT_RUN_FUNC_WARNING = """\ -The `run()` function is only intended for testing during development! To run \ -in production, refer to the docs on how to use reactpy.backend.*.configure.\ -""" + return "" diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 88cb2fa35..3fbfca252 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -37,6 +37,7 @@ from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.types import ( ComponentType, + Context, EventHandlerDict, Key, LayoutEventMessage, @@ -67,7 +68,7 @@ class Layout: if not hasattr(abc.ABC, "__weakref__"): # nocov __slots__ += ("__weakref__",) - def __init__(self, root: ComponentType) -> None: + def __init__(self, root: ComponentType | Context[Any]) -> None: super().__init__() if not isinstance(root, ComponentType): msg = f"Expected a ComponentType, not {type(root)!r}." diff --git a/src/reactpy/templates/index.html b/src/reactpy/templates/index.html new file mode 100644 index 000000000..4cf1cdcbd --- /dev/null +++ b/src/reactpy/templates/index.html @@ -0,0 +1,23 @@ + + + + + + + + +
+ + + + diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 1ac04395a..acfeeb4c7 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -4,7 +4,7 @@ - :mod:`reactpy.backend.types` """ -from reactpy.backend.types import BackendType, Connection, Location +from reactpy.backend.types import Connection, Location from reactpy.core.component import Component from reactpy.core.types import ( ComponentConstructor, @@ -27,7 +27,6 @@ ) __all__ = [ - "BackendType", "Component", "ComponentConstructor", "ComponentType", From ef4938a9d3f31c438dbd9d0ed631f42c4119b981 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 24 Jan 2025 22:24:58 -0800 Subject: [PATCH 30/84] move utils --- src/reactpy/backend/standalone.py | 54 +++++++------------------------ src/reactpy/backend/utils.py | 21 ++++++++++++ 2 files changed, 33 insertions(+), 42 deletions(-) diff --git a/src/reactpy/backend/standalone.py b/src/reactpy/backend/standalone.py index e1dbf3506..c9825236c 100644 --- a/src/reactpy/backend/standalone.py +++ b/src/reactpy/backend/standalone.py @@ -7,6 +7,7 @@ from typing import Any, Callable from reactpy.backend.middleware import ReactPyMiddleware +from reactpy.backend.utils import dict_to_byte_list, find_and_replace from reactpy.core.types import ComponentType _logger = getLogger(__name__) @@ -45,11 +46,13 @@ async def standalone_app( """ASGI app for ReactPy standalone mode.""" if scope["type"] != "http": if scope["type"] != "lifespan": - _logger.warning( + msg = ( "ReactPy app received unsupported request of type '%s' at path '%s'", scope["type"], scope["path"], ) + _logger.warning(msg) + raise NotImplementedError(msg) return # Store the HTTP response in memory for performance @@ -59,10 +62,11 @@ async def standalone_app( # Return headers for all HTTP responses request_headers = dict(scope["headers"]) response_headers: dict[str, str | int] = { - "etag": f'"{self.etag}"', + "etag": self.etag, "last-modified": self.last_modified, "access-control-allow-origin": "*", "cache-control": "max-age=60, public", + "content-length": len(self.cached_index_html), **self.extra_headers, } @@ -74,18 +78,19 @@ async def standalone_app( 200, "", content_type=b"text/html", - headers=self.dict_to_byte_list(response_headers), + headers=dict_to_byte_list(response_headers), ) # Browser already has the content cached if request_headers.get(b"if-none-match") == self.etag.encode(): + response_headers.pop("content-length") return await http_response( scope["method"], send, 304, "", content_type=b"text/html", - headers=self.dict_to_byte_list(response_headers), + headers=dict_to_byte_list(response_headers), ) # Send the index.html @@ -95,9 +100,7 @@ async def standalone_app( 200, self.cached_index_html, content_type=b"text/html", - headers=self.dict_to_byte_list( - response_headers | {"content-length": len(self.cached_index_html)} - ), + headers=dict_to_byte_list(response_headers), ) def match_dispatch_path(self, scope: dict) -> bool: @@ -109,7 +112,7 @@ async def process_index_html(self): with open(self.index_html_path, encoding="utf-8") as file_handle: cached_index_html = file_handle.read() - self.cached_index_html = self.find_and_replace( + self.cached_index_html = find_and_replace( cached_index_html, { 'from "index.ts"': f'from "{self.static_path}index.js"', @@ -124,44 +127,11 @@ async def process_index_html(self): self.etag = hashlib.md5( self.cached_index_html.encode(), usedforsecurity=False ).hexdigest() + self.etag = f'"{self.etag}"' last_modified = os.stat(self.index_html_path).st_mtime self.last_modified = formatdate(last_modified, usegmt=True) - # @staticmethod - # def find_js_filename(content: str) -> str: - # """Find the qualified filename of the index.js file.""" - # substring = 'src="reactpy/static/index-' - # location = content.find(substring) - # if location == -1: - # raise ValueError(f"Could not find {substring} in content") - # start = content[location + len(substring) :] - # end = start.find('"') - # return f"index-{start[:end]}" - - @staticmethod - def dict_to_byte_list( - data: dict[str, str | int], - ) -> list[tuple[bytes, bytes]]: - """Convert a dictionary to a list of byte tuples.""" - result: list[tuple[bytes, bytes]] = [] - for key, value in data.items(): - new_key = key.encode() - new_value = ( - value.encode() if isinstance(value, str) else str(value).encode() - ) - result.append((new_key, new_value)) - return result - - @staticmethod - def find_and_replace(content: str, replacements: dict[str, str]) -> str: - """Find and replace content. Throw and error if the substring is not found.""" - for key, value in replacements.items(): - if key not in content: - raise ValueError(f"Could not find {key} in content") - content = content.replace(key, value) - return content - async def http_response( method: str, diff --git a/src/reactpy/backend/utils.py b/src/reactpy/backend/utils.py index 9102dfa28..203180749 100644 --- a/src/reactpy/backend/utils.py +++ b/src/reactpy/backend/utils.py @@ -109,3 +109,24 @@ def check_path(url_path: str) -> str: return "URL path must start with an alphanumeric character." return "" + + +def find_and_replace(content: str, replacements: dict[str, str]) -> str: + """Find and replace several key-values, and throw and error if the substring is not found.""" + for key, value in replacements.items(): + if key not in content: + raise ValueError(f"Could not find {key} in content") + content = content.replace(key, value) + return content + + +def dict_to_byte_list( + data: dict[str, str | int], +) -> list[tuple[bytes, bytes]]: + """Convert a dictionary to a list of byte tuples.""" + result: list[tuple[bytes, bytes]] = [] + for key, value in data.items(): + new_key = key.encode() + new_value = value.encode() if isinstance(value, str) else str(value).encode() + result.append((new_key, new_value)) + return result From b2a429fd36ef0ffa568b8b257c587d58114bd857 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 24 Jan 2025 23:54:22 -0800 Subject: [PATCH 31/84] Functional standalone mode --- src/js/packages/@reactpy/client/src/client.ts | 2 +- .../packages/@reactpy/client/src/websocket.ts | 6 +- src/reactpy/backend/middleware.py | 125 ++++++++---------- src/reactpy/backend/standalone.py | 3 + 4 files changed, 63 insertions(+), 73 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/client.ts b/src/js/packages/@reactpy/client/src/client.ts index 7965f66a9..76465aacf 100644 --- a/src/js/packages/@reactpy/client/src/client.ts +++ b/src/js/packages/@reactpy/client/src/client.ts @@ -69,7 +69,7 @@ export class ReactPyClient url: this.urls.componentUrl, readyPromise: this.ready, ...props.reconnectOptions, - onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), + onMessage: (event) => this.handleIncoming(JSON.parse(event.data)), }); } diff --git a/src/js/packages/@reactpy/client/src/websocket.ts b/src/js/packages/@reactpy/client/src/websocket.ts index f36deadcd..ba3fdc09f 100644 --- a/src/js/packages/@reactpy/client/src/websocket.ts +++ b/src/js/packages/@reactpy/client/src/websocket.ts @@ -25,7 +25,11 @@ export function createReconnectingWebSocket( props.onOpen(); } }; - socket.current.onmessage = props.onMessage; + socket.current.onmessage = (event) => { + if (props.onMessage) { + props.onMessage(event); + } + }; socket.current.onclose = () => { if (props.onClose) { props.onClose(); diff --git a/src/reactpy/backend/middleware.py b/src/reactpy/backend/middleware.py index ece2cafe8..98c3c82ad 100644 --- a/src/reactpy/backend/middleware.py +++ b/src/reactpy/backend/middleware.py @@ -1,12 +1,11 @@ import asyncio import logging import re +import traceback import urllib.parse from collections.abc import Coroutine, Iterable from concurrent.futures import Future -from importlib import import_module from pathlib import Path -from threading import Thread from typing import Any, Callable import orjson @@ -22,20 +21,15 @@ from reactpy.core.types import ComponentType _logger = logging.getLogger(__name__) -_backhaul_loop = asyncio.new_event_loop() - - -def start_backhaul_loop(): - """Starts the asyncio event loop that will perform component rendering - tasks.""" - asyncio.set_event_loop(_backhaul_loop) - _backhaul_loop.run_forever() - - -_backhaul_thread = Thread(target=start_backhaul_loop, daemon=True) class ReactPyMiddleware: + _asgi_single_callable = True + servestatic_static: ServeStaticASGI | None = None + servestatic_web_modules: ServeStaticASGI | None = None + single_root_component: bool = False + root_component: ComponentType | None = None + def __init__( self, app: Callable[..., Coroutine], @@ -58,23 +52,15 @@ def __init__( self.static_pattern = re.compile(f"^{self.static_path}.*") self.web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR.current self.static_dir = Path(__file__).parent.parent / "static" - self.backhaul_thread = False # TODO: Add backhaul_thread settings self.user_app = guarantee_single_callable(app) - self.servestatic_static: ServeStaticASGI | None = None - self.servestatic_web_modules: ServeStaticASGI | None = None self.component_dotted_paths = set(root_components) self.components: dict[str, ComponentType] = import_components( self.component_dotted_paths ) - self.dispatcher: Future | asyncio.Task | None = None - self.recv_queue: asyncio.Queue = asyncio.Queue() + if self.web_modules_dir != REACTPY_WEB_MODULES_DIR.current: REACTPY_WEB_MODULES_DIR.set_current(self.web_modules_dir) - # Start the backhaul thread if it's not already running - if self.backhaul_thread and not _backhaul_thread.is_alive(): - _backhaul_thread.start() - # Validate the arguments reason = check_path(self.path_prefix) if reason: @@ -110,34 +96,26 @@ async def component_dispatch_app( send: Callable[..., Coroutine], ) -> None: """ASGI app for rendering ReactPy Python components.""" - ws_connected: bool = False + dispatcher: Future | asyncio.Task | None = None + recv_queue: asyncio.Queue = asyncio.Queue() + # Start a loop that handles ASGI websocket events while True: - # Future WS events on this connection will always be received here event = await receive() - - if event["type"] == "websocket.connect" and not ws_connected: - ws_connected = True + if event["type"] == "websocket.connect": await send({"type": "websocket.accept"}) - run_dispatcher_coro = self.run_dispatcher(scope, receive, send) - if self.backhaul_thread: - self.dispatcher = asyncio.run_coroutine_threadsafe( - run_dispatcher_coro, _backhaul_loop - ) - else: - self.dispatcher = asyncio.create_task(run_dispatcher_coro) + dispatcher = asyncio.create_task( + self.run_dispatcher(scope, receive, send, recv_queue) + ) if event["type"] == "websocket.disconnect": - if self.dispatcher: - self.dispatcher.cancel() + if dispatcher: + dispatcher.cancel() break if event["type"] == "websocket.receive": - queue_put_coro = self.recv_queue.put(orjson.loads(event["text"])) - if self.backhaul_thread: - asyncio.run_coroutine_threadsafe(queue_put_coro, _backhaul_loop) - else: - await queue_put_coro + queue_put_func = recv_queue.put(orjson.loads(event["text"])) + await queue_put_func async def web_module_app( self, @@ -190,45 +168,50 @@ async def run_dispatcher( scope: dict[str, Any], receive: Callable[..., Coroutine], send: Callable[..., Coroutine], + recv_queue: asyncio.Queue, ) -> None: # Get the component from the URL. - url_path = re.match(self.dispatcher_pattern, scope["path"]) - if not url_path: - raise RuntimeError("Could not find component in URL path.") - dotted_path = url_path[1] - module_str, component_str = dotted_path.rsplit(".", 1) - module = import_module(module_str) - component = getattr(module, component_str) - parsed_url = urllib.parse.urlparse(scope["path"]) - - await serve_layout( - Layout( # type: ignore - ConnectionContext( - component(), - value=Connection( - scope=scope, - location=Location( - parsed_url.path, - f"?{parsed_url.query}" if parsed_url.query else "", + try: + if not self.single_root_component: + url_match = re.match(self.dispatcher_pattern, scope["path"]) + if not url_match: + raise RuntimeError("Could not find component in URL path.") + dotted_path = url_match[1] + component = self.components[dotted_path] + else: + component = self.root_component + parsed_url = urllib.parse.urlparse(scope["path"]) + + await serve_layout( + Layout( # type: ignore + ConnectionContext( + component, + value=Connection( + scope=scope, + location=Location( + parsed_url.path, + f"?{parsed_url.query}" if parsed_url.query else "", + ), + carrier={ + "scope": scope, + "send": send, + "receive": receive, + }, ), - carrier={ - "scope": scope, - "send": send, - "receive": receive, - }, - ), - ) - ), - self.send_json_ws(send), - self.recv_queue.get, - ) + ) + ), + self.send_json_ws(send), + recv_queue.get, + ) + except Exception as error: + await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}") @staticmethod def send_json_ws(send: Callable) -> Callable[..., Coroutine]: """Use orjson to send JSON over an ASGI websocket.""" async def _send_json(value: Any) -> None: - await send({"type": "websocket.send", "text": orjson.dumps(value)}) + await send({"type": "websocket.send", "text": orjson.dumps(value).decode()}) return _send_json diff --git a/src/reactpy/backend/standalone.py b/src/reactpy/backend/standalone.py index c9825236c..95932d042 100644 --- a/src/reactpy/backend/standalone.py +++ b/src/reactpy/backend/standalone.py @@ -1,5 +1,6 @@ import hashlib import os +import re from collections.abc import Coroutine, Sequence from email.utils import formatdate from logging import getLogger @@ -19,6 +20,7 @@ class ReactPyStandalone(ReactPyMiddleware): last_modified: str = "" templates_dir = Path(__file__).parent.parent / "templates" index_html_path = templates_dir / "index.html" + single_root_component: bool = True def __init__( self, @@ -36,6 +38,7 @@ def __init__( ) self.root_component = root_component self.extra_headers = http_headers or {} + self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?") async def standalone_app( self, From 4d539fcc5a39d7cb4704eb7ccbcfc33179fcfc0b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 25 Jan 2025 01:30:05 -0800 Subject: [PATCH 32/84] refactoring --- src/reactpy/__init__.py | 2 - src/reactpy/backend/middleware.py | 186 +++++++++++++++++------------- src/reactpy/backend/standalone.py | 34 +++--- src/reactpy/backend/utils.py | 60 ++-------- src/reactpy/templates/index.html | 4 +- 5 files changed, 142 insertions(+), 144 deletions(-) diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index d54e82174..c4121bd08 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -1,5 +1,4 @@ from reactpy import backend, config, html, logging, sample, svg, types, web, widgets -from reactpy.backend.utils import run from reactpy.core import hooks from reactpy.core.component import component from reactpy.core.events import event @@ -36,7 +35,6 @@ "html", "html_to_vdom", "logging", - "run", "sample", "svg", "types", diff --git a/src/reactpy/backend/middleware.py b/src/reactpy/backend/middleware.py index 98c3c82ad..14edb222f 100644 --- a/src/reactpy/backend/middleware.py +++ b/src/reactpy/backend/middleware.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import asyncio import logging import re import traceback import urllib.parse from collections.abc import Coroutine, Iterable -from concurrent.futures import Future +from dataclasses import dataclass from pathlib import Path from typing import Any, Callable @@ -18,17 +20,15 @@ from reactpy.core.hooks import ConnectionContext from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentType +from reactpy.types import RootComponentConstructor _logger = logging.getLogger(__name__) class ReactPyMiddleware: _asgi_single_callable = True - servestatic_static: ServeStaticASGI | None = None - servestatic_web_modules: ServeStaticASGI | None = None single_root_component: bool = False - root_component: ComponentType | None = None + root_component: RootComponentConstructor | None = None def __init__( self, @@ -40,7 +40,7 @@ def __init__( web_modules_dir: Path | None = None, ) -> None: """Configure the ASGI app. Anything initialized in this method will be shared across all future requests.""" - # Configure class attributes + # URL path attributes self.path_prefix = normalize_url_path(path_prefix) self.dispatcher_path = f"/{self.path_prefix}/" self.web_modules_path = f"/{self.path_prefix}/modules/" @@ -50,19 +50,27 @@ def __init__( ) self.js_modules_pattern = re.compile(f"^{self.web_modules_path}.*") self.static_pattern = re.compile(f"^{self.static_path}.*") - self.web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR.current - self.static_dir = Path(__file__).parent.parent / "static" + + # Component attributes self.user_app = guarantee_single_callable(app) self.component_dotted_paths = set(root_components) - self.components: dict[str, ComponentType] = import_components( + self.components: dict[str, RootComponentConstructor] = import_components( self.component_dotted_paths ) + # Directory attributes + self.web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR.current + self.static_dir = Path(__file__).parent.parent / "static" if self.web_modules_dir != REACTPY_WEB_MODULES_DIR.current: REACTPY_WEB_MODULES_DIR.set_current(self.web_modules_dir) - # Validate the arguments - reason = check_path(self.path_prefix) + # Sub-applications + self.component_dispatch_app = ComponentDispatchApp(parent=self) + self.static_file_app = StaticFileApp(parent=self) + self.web_modules_app = WebModuleApp(parent=self) + + # Validate the configuration + reason = check_path(path_prefix) if reason: raise ValueError(f"Invalid `path_prefix`. {reason}") @@ -78,25 +86,39 @@ async def __call__( if scope["type"] == "websocket" and self.match_dispatch_path(scope): return await self.component_dispatch_app(scope, receive, send) - # URL routing for ReactPy web modules - if scope["type"] == "http" and re.match(self.js_modules_pattern, scope["path"]): - return await self.web_module_app(scope, receive, send) - # URL routing for ReactPy static files - if scope["type"] == "http" and re.match(self.static_pattern, scope["path"]): + if scope["type"] == "http" and self.match_static_path(scope): return await self.static_file_app(scope, receive, send) + # URL routing for ReactPy web modules + if scope["type"] == "http" and self.match_web_modules_path(scope): + return await self.web_modules_app(scope, receive, send) + # Serve the user's application await self.user_app(scope, receive, send) - async def component_dispatch_app( + def match_dispatch_path(self, scope: dict) -> bool: + return bool(re.match(self.dispatcher_pattern, scope["path"])) + + def match_static_path(self, scope: dict) -> bool: + return bool(re.match(self.static_pattern, scope["path"])) + + def match_web_modules_path(self, scope: dict) -> bool: + return bool(re.match(self.js_modules_pattern, scope["path"])) + + +@dataclass +class ComponentDispatchApp: + parent: ReactPyMiddleware + + async def __call__( self, scope: dict[str, Any], receive: Callable[..., Coroutine], send: Callable[..., Coroutine], ) -> None: """ASGI app for rendering ReactPy Python components.""" - dispatcher: Future | asyncio.Task | None = None + dispatcher: asyncio.Task | None = None recv_queue: asyncio.Queue = asyncio.Queue() # Start a loop that handles ASGI websocket events @@ -117,52 +139,6 @@ async def component_dispatch_app( queue_put_func = recv_queue.put(orjson.loads(event["text"])) await queue_put_func - async def web_module_app( - self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - ) -> None: - """ASGI app for ReactPy web modules.""" - if not self.web_modules_dir: - await asyncio.to_thread( - _logger.info, - "Tried to serve web module without a configured directory.", - ) - return await self.user_app(scope, receive, send) - - if not self.servestatic_web_modules: - self.servestatic_web_modules = ServeStaticASGI( - self.user_app, - root=self.web_modules_dir, - prefix=self.web_modules_path, - autorefresh=True, - ) - - return await self.servestatic_web_modules(scope, receive, send) - - async def static_file_app( - self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - ) -> None: - """ASGI app for ReactPy static files.""" - # If no static directory is configured, serve the user's application - if not self.static_dir: - await asyncio.to_thread( - _logger.info, - "Tried to serve static file without a configured directory.", - ) - return await self.user_app(scope, receive, send) - - if not self.servestatic_static: - self.servestatic_static = ServeStaticASGI( - self.user_app, root=self.static_dir, prefix=self.static_path - ) - - return await self.servestatic_static(scope, receive, send) - async def run_dispatcher( self, scope: dict[str, Any], @@ -172,20 +148,23 @@ async def run_dispatcher( ) -> None: # Get the component from the URL. try: - if not self.single_root_component: - url_match = re.match(self.dispatcher_pattern, scope["path"]) + if not self.parent.single_root_component: + url_match = re.match(self.parent.dispatcher_pattern, scope["path"]) if not url_match: raise RuntimeError("Could not find component in URL path.") dotted_path = url_match[1] - component = self.components[dotted_path] + component = self.parent.components[dotted_path] + elif self.parent.root_component: + component = self.parent.root_component else: - component = self.root_component + raise RuntimeError("No root component provided.") + parsed_url = urllib.parse.urlparse(scope["path"]) await serve_layout( Layout( # type: ignore ConnectionContext( - component, + component(), value=Connection( scope=scope, location=Location( @@ -200,14 +179,14 @@ async def run_dispatcher( ), ) ), - self.send_json_ws(send), + self.send_json(send), recv_queue.get, ) except Exception as error: await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}") @staticmethod - def send_json_ws(send: Callable) -> Callable[..., Coroutine]: + def send_json(send: Callable) -> Callable[..., Coroutine]: """Use orjson to send JSON over an ASGI websocket.""" async def _send_json(value: Any) -> None: @@ -215,9 +194,62 @@ async def _send_json(value: Any) -> None: return _send_json - def match_dispatch_path(self, scope: dict) -> bool: - match = re.match(self.dispatcher_pattern, scope["path"]) - return bool( - match - and match.groupdict().get("dotted_path") in self.component_dotted_paths - ) + +@dataclass +class StaticFileApp: + parent: ReactPyMiddleware + _static_file_server: ServeStaticASGI | None = None + + async def __call__( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """ASGI app for ReactPy static files.""" + # If no static directory is configured, serve the user's application + if not self.parent.static_dir: + await asyncio.to_thread( + _logger.info, + "Tried to serve static file without a configured directory.", + ) + return await self.parent.user_app(scope, receive, send) + + if not self._static_file_server: + self._static_file_server = ServeStaticASGI( + self.parent.user_app, + root=self.parent.static_dir, + prefix=self.parent.static_path, + ) + + return await self._static_file_server(scope, receive, send) + + +@dataclass +class WebModuleApp: + parent: ReactPyMiddleware + _static_file_server: ServeStaticASGI | None = None + + async def __call__( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """ASGI app for ReactPy web modules.""" + if not self.parent.web_modules_dir: + await asyncio.to_thread( + _logger.info, + "Tried to serve web module without a configured directory.", + ) + return await self.parent.user_app(scope, receive, send) + + if not self._static_file_server: + self._static_file_server = ServeStaticASGI( + self.parent.user_app, + root=self.parent.web_modules_dir, + prefix=self.parent.web_modules_path, + autorefresh=True, + ) + + return await self._static_file_server(scope, receive, send) diff --git a/src/reactpy/backend/standalone.py b/src/reactpy/backend/standalone.py index 95932d042..10e997042 100644 --- a/src/reactpy/backend/standalone.py +++ b/src/reactpy/backend/standalone.py @@ -7,31 +7,35 @@ from pathlib import Path from typing import Any, Callable +from reactpy import html from reactpy.backend.middleware import ReactPyMiddleware -from reactpy.backend.utils import dict_to_byte_list, find_and_replace -from reactpy.core.types import ComponentType +from reactpy.backend.utils import dict_to_byte_list, find_and_replace, vdom_head_to_html +from reactpy.core.types import VdomDict +from reactpy.types import RootComponentConstructor _logger = getLogger(__name__) -class ReactPyStandalone(ReactPyMiddleware): - cached_index_html: str = "" - etag: str = "" - last_modified: str = "" +class ReactPy(ReactPyMiddleware): + cached_index_html = "" + etag = "" + last_modified = "" templates_dir = Path(__file__).parent.parent / "templates" index_html_path = templates_dir / "index.html" - single_root_component: bool = True + single_root_component = True def __init__( self, - root_component: ComponentType, + root_component: RootComponentConstructor, *, path_prefix: str = "reactpy/", web_modules_dir: Path | None = None, http_headers: dict[str, str | int] | None = None, + html_head: VdomDict | None = None, + html_lang: str = "en", ) -> None: super().__init__( - app=self.standalone_app, + app=self.reactpy_app, root_components=[], path_prefix=path_prefix, web_modules_dir=web_modules_dir, @@ -39,8 +43,10 @@ def __init__( self.root_component = root_component self.extra_headers = http_headers or {} self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?") + self.html_head = html_head or html.head() + self.html_lang = html_lang - async def standalone_app( + async def reactpy_app( self, scope: dict[str, Any], receive: Callable[..., Coroutine], @@ -119,6 +125,8 @@ async def process_index_html(self): cached_index_html, { 'from "index.ts"': f'from "{self.static_path}index.js"', + '': f'', + "": vdom_head_to_html(self.html_head), "{path_prefix}": self.path_prefix, "{reconnect_interval}": "750", "{reconnect_max_interval}": "60000", @@ -131,9 +139,9 @@ async def process_index_html(self): self.cached_index_html.encode(), usedforsecurity=False ).hexdigest() self.etag = f'"{self.etag}"' - - last_modified = os.stat(self.index_html_path).st_mtime - self.last_modified = formatdate(last_modified, usegmt=True) + self.last_modified = formatdate( + os.stat(self.index_html_path).st_mtime, usegmt=True + ) async def http_response( diff --git a/src/reactpy/backend/utils.py b/src/reactpy/backend/utils.py index 203180749..1f0dbe6fc 100644 --- a/src/reactpy/backend/utils.py +++ b/src/reactpy/backend/utils.py @@ -2,63 +2,16 @@ import logging import re -import socket -import sys from collections.abc import Iterable -from contextlib import closing from importlib import import_module from typing import Any -from reactpy.core.types import ComponentType -from reactpy.types import RootComponentConstructor +from reactpy.core.types import ComponentType, VdomDict +from reactpy.utils import vdom_to_html logger = logging.getLogger(__name__) -def run( - component: RootComponentConstructor, - host: str = "localhost", - port: int | None = None, -) -> None: - """Run a component with a development server""" - logger.warning( - "The `run()` function is only intended for testing purposes! To run in production, " - "refer to ReactPy's documentation." - ) - - try: - import uvicorn - except ImportError as e: - raise ImportError( - "The `uvicorn` package is required to use `reactpy.run`. " - "Please install it with `pip install uvicorn`." - ) from e - - app = ... - port = port or find_available_port(host) - uvicorn.run(app, host=host, port=port) - - -def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) -> int: - """Get a port that's available for the given host and port range""" - for port in range(port_min, port_max): - with closing(socket.socket()) as sock: - try: - if sys.platform in ("linux", "darwin"): - # Fixes bug on Unix-like systems where every time you restart the - # server you'll get a different port on Linux. This cannot be set - # on Windows otherwise address will always be reused. - # Ref: https://stackoverflow.com/a/19247688/3159288 - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((host, port)) - except OSError: - pass - else: - return port - msg = f"Host {host!r} has no available port in range {port_max}-{port_max}" - raise RuntimeError(msg) - - def normalize_url_path(url: str) -> str: """Normalize a URL path.""" new_url = re.sub(r"/+", "/", url) @@ -130,3 +83,12 @@ def dict_to_byte_list( new_value = value.encode() if isinstance(value, str) else str(value).encode() result.append((new_key, new_value)) return result + + +def vdom_head_to_html(head: VdomDict) -> str: + if isinstance(head, dict) and head.get("tagName") == "head": + return vdom_to_html(head) + + raise ValueError( + "Invalid head element! Element must be either `html.head` or a string." + ) diff --git a/src/reactpy/templates/index.html b/src/reactpy/templates/index.html index 4cf1cdcbd..76a9585c2 100644 --- a/src/reactpy/templates/index.html +++ b/src/reactpy/templates/index.html @@ -1,9 +1,7 @@ - - - +
From 1304126703f4836bbfc361bb8712868b9b9957ad Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 25 Jan 2025 03:57:09 -0800 Subject: [PATCH 33/84] Add Jinja to deps --- pyproject.toml | 35 +++++------------------------------ 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 84bb94678..f804afabd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ classifiers = [ dependencies = [ "exceptiongroup >=1.0", "typing-extensions >=3.10", - "mypy-extensions >=0.4.3", "anyio >=3", "jsonpatch >=1.32", "fastjsonschema >=2.14.5", @@ -37,7 +36,8 @@ dependencies = [ "colorlog >=6", "asgiref >=3", "lxml >=4", - "servestatic >= 3.0.0", + "servestatic >=3.0.0", + "jinja2 >=3", ] dynamic = ["version"] urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" @@ -76,18 +76,8 @@ artifacts = [] [project.optional-dependencies] # TODO: Nuke backends from the optional deps -all = ["reactpy[starlette,sanic,fastapi,flask,tornado,testing]"] -starlette = ["starlette >=0.13.6", "uvicorn[standard] >=0.19.0"] -sanic = [ - "sanic>=21", - "sanic-cors", - "tracerite>=1.1.1", - "setuptools", - "uvicorn[standard]>=0.19.0", -] -fastapi = ["fastapi >=0.63.0", "uvicorn[standard] >=0.19.0"] -flask = ["flask", "markupsafe>=1.1.1,<2.1", "flask-cors", "flask-sock"] -tornado = ["tornado"] +all = ["reactpy[uvicorn,testing]"] +uvicorn = ["uvicorn[standard]"] testing = ["playwright"] @@ -104,22 +94,7 @@ extra-dependencies = [ "responses", "playwright", "jsonpointer", - # TODO: Nuke everything past this point after removing backends from deps - "starlette >=0.13.6", - "uvicorn[standard] >=0.19.0", - "sanic>=21", - "sanic-cors", - "sanic-testing", - "tracerite>=1.1.1", - "setuptools", - "uvicorn[standard]>=0.19.0", - "fastapi >=0.63.0", - "uvicorn[standard] >=0.19.0", - "flask", - "markupsafe>=1.1.1,<2.1", - "flask-cors", - "flask-sock", - "tornado", + "uvicorn[standard]", ] [[tool.hatch.envs.hatch-test.matrix]] From b23f6b616a347395c3f1ee44e5a0ff9a62e5daaa Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 16:41:15 -0800 Subject: [PATCH 34/84] refactoring in preparation for functional middleware --- src/js/packages/@reactpy/client/src/client.ts | 2 +- src/js/packages/@reactpy/client/src/mount.tsx | 10 +++++----- src/js/packages/@reactpy/client/src/types.ts | 5 +++-- src/reactpy/backend/middleware.py | 20 ++++++++++--------- src/reactpy/backend/standalone.py | 19 ++++++++---------- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/client.ts b/src/js/packages/@reactpy/client/src/client.ts index 76465aacf..12e1d6bc8 100644 --- a/src/js/packages/@reactpy/client/src/client.ts +++ b/src/js/packages/@reactpy/client/src/client.ts @@ -78,6 +78,6 @@ export class ReactPyClient } loadModule(moduleName: string): Promise { - return import(`${this.urls.jsModules}/${moduleName}`); + return import(`${this.urls.jsModulesPath}/${moduleName}`); } } diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index f2ffc87c4..1dbdd177a 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -7,26 +7,26 @@ export function mountReactPy(props: MountProps) { // WebSocket route for component rendering const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`; const wsOrigin = `${wsProtocol}//${window.location.host}`; - const componentUrl = new URL(`${wsOrigin}/${props.pathPrefix}/`); + const componentUrl = new URL(`${wsOrigin}/${props.pathPrefix}/${props.appendComponentPath || ""}`); // Embed the initial HTTP path into the WebSocket URL componentUrl.searchParams.append("http_pathname", window.location.pathname); if (window.location.search) { - componentUrl.searchParams.append("http_search", window.location.search); + componentUrl.searchParams.append("http_query_string", window.location.search); } // Configure a new ReactPy client const client = new ReactPyClient({ urls: { componentUrl: componentUrl, - query: document.location.search, - jsModules: `${window.location.origin}/${props.pathPrefix}/modules/`, + jsModulesPath: `${window.location.origin}/${props.pathPrefix}/modules/`, + queryString: document.location.search, }, reconnectOptions: { interval: props.reconnectInterval || 750, maxInterval: props.reconnectMaxInterval || 60000, - backoffMultiplier: props.reconnectBackoffMultiplier || 1.25, maxRetries: props.reconnectMaxRetries || 150, + backoffMultiplier: props.reconnectBackoffMultiplier || 1.25, }, mountElement: props.mountElement, }); diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 7244f99ba..ca79fb753 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -23,8 +23,8 @@ export type CreateReconnectingWebSocketProps = { export type ReactPyUrls = { componentUrl: URL; - query: string; - jsModules: string; + jsModulesPath: string; + queryString: string; }; export type GenericReactPyClientProps = { @@ -36,6 +36,7 @@ export type GenericReactPyClientProps = { export type MountProps = { mountElement: HTMLElement; pathPrefix: string; + appendComponentPath?: string; reconnectInterval?: number; reconnectMaxInterval?: number; reconnectMaxRetries?: number; diff --git a/src/reactpy/backend/middleware.py b/src/reactpy/backend/middleware.py index 14edb222f..4429ff242 100644 --- a/src/reactpy/backend/middleware.py +++ b/src/reactpy/backend/middleware.py @@ -26,9 +26,10 @@ class ReactPyMiddleware: - _asgi_single_callable = True - single_root_component: bool = False + _asgi_single_callable: bool = True root_component: RootComponentConstructor | None = None + root_components: dict[str, RootComponentConstructor] + multiple_root_components: bool = True def __init__( self, @@ -53,10 +54,7 @@ def __init__( # Component attributes self.user_app = guarantee_single_callable(app) - self.component_dotted_paths = set(root_components) - self.components: dict[str, RootComponentConstructor] = import_components( - self.component_dotted_paths - ) + self.root_components = import_components(root_components) # Directory attributes self.web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR.current @@ -148,12 +146,16 @@ async def run_dispatcher( ) -> None: # Get the component from the URL. try: - if not self.parent.single_root_component: + if self.parent.multiple_root_components: url_match = re.match(self.parent.dispatcher_pattern, scope["path"]) if not url_match: raise RuntimeError("Could not find component in URL path.") - dotted_path = url_match[1] - component = self.parent.components[dotted_path] + dotted_path = url_match["dotted_path"] + if dotted_path not in self.parent.root_components: + raise RuntimeError( + f"Attempting to use an unregistered root component {dotted_path}." + ) + component = self.parent.root_components[dotted_path] elif self.parent.root_component: component = self.parent.root_component else: diff --git a/src/reactpy/backend/standalone.py b/src/reactpy/backend/standalone.py index 10e997042..36d6d6639 100644 --- a/src/reactpy/backend/standalone.py +++ b/src/reactpy/backend/standalone.py @@ -22,7 +22,7 @@ class ReactPy(ReactPyMiddleware): last_modified = "" templates_dir = Path(__file__).parent.parent / "templates" index_html_path = templates_dir / "index.html" - single_root_component = True + multiple_root_components = False def __init__( self, @@ -66,7 +66,7 @@ async def reactpy_app( # Store the HTTP response in memory for performance if not self.cached_index_html: - await self.process_index_html() + self.process_index_html() # Return headers for all HTTP responses request_headers = dict(scope["headers"]) @@ -113,11 +113,11 @@ async def reactpy_app( ) def match_dispatch_path(self, scope: dict) -> bool: - """Check if the path matches the dispatcher path.""" - return str(scope["path"]).startswith(self.dispatcher_path) + """Method override to remove `dotted_path` from the dispatcher URL.""" + return str(scope["path"]) == self.dispatcher_path - async def process_index_html(self): - """Process the index.html file.""" + def process_index_html(self): + """Process the index.html and store the results in memory.""" with open(self.index_html_path, encoding="utf-8") as file_handle: cached_index_html = file_handle.read() @@ -135,10 +135,7 @@ async def process_index_html(self): }, ) - self.etag = hashlib.md5( - self.cached_index_html.encode(), usedforsecurity=False - ).hexdigest() - self.etag = f'"{self.etag}"' + self.etag = f'"{hashlib.md5(self.cached_index_html.encode(), usedforsecurity=False).hexdigest()}"' self.last_modified = formatdate( os.stat(self.index_html_path).st_mtime, usegmt=True ) @@ -152,7 +149,7 @@ async def http_response( content_type: bytes = b"text/plain", headers: Sequence = (), ) -> None: - """Send a simple response.""" + """Sends a HTTP response using the ASGI `send` API.""" # Head requests don't need body content if method == "HEAD": await send( From 3480475201b74229369be8a24d265c25a7c3094f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 17:28:06 -0800 Subject: [PATCH 35/84] Force user to add slashes to tail and head of `path_prefix`, and create `ReactPyApp` --- src/js/packages/@reactpy/client/src/mount.tsx | 4 +- src/reactpy/backend/middleware.py | 14 +++-- src/reactpy/backend/standalone.py | 58 ++++++++++--------- src/reactpy/backend/utils.py | 16 ++--- 4 files changed, 47 insertions(+), 45 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index 1dbdd177a..74a664251 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -7,7 +7,7 @@ export function mountReactPy(props: MountProps) { // WebSocket route for component rendering const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`; const wsOrigin = `${wsProtocol}//${window.location.host}`; - const componentUrl = new URL(`${wsOrigin}/${props.pathPrefix}/${props.appendComponentPath || ""}`); + const componentUrl = new URL(`${wsOrigin}${props.pathPrefix}${props.appendComponentPath || ""}`); // Embed the initial HTTP path into the WebSocket URL componentUrl.searchParams.append("http_pathname", window.location.pathname); @@ -19,7 +19,7 @@ export function mountReactPy(props: MountProps) { const client = new ReactPyClient({ urls: { componentUrl: componentUrl, - jsModulesPath: `${window.location.origin}/${props.pathPrefix}/modules/`, + jsModulesPath: `${window.location.origin}${props.pathPrefix}modules/`, queryString: document.location.search, }, reconnectOptions: { diff --git a/src/reactpy/backend/middleware.py b/src/reactpy/backend/middleware.py index 4429ff242..8e76ae2ab 100644 --- a/src/reactpy/backend/middleware.py +++ b/src/reactpy/backend/middleware.py @@ -15,7 +15,7 @@ from servestatic import ServeStaticASGI from reactpy.backend.types import Connection, Location -from reactpy.backend.utils import check_path, import_components, normalize_url_path +from reactpy.backend.utils import check_path, import_components from reactpy.config import REACTPY_WEB_MODULES_DIR from reactpy.core.hooks import ConnectionContext from reactpy.core.layout import Layout @@ -37,15 +37,15 @@ def __init__( root_components: Iterable[str], *, # TODO: Add a setting attribute to this class. Or maybe just put a shit ton of kwargs here. Or add a **kwargs that resolves to a TypedDict? - path_prefix: str = "reactpy/", + path_prefix: str = "/reactpy/", web_modules_dir: Path | None = None, ) -> None: """Configure the ASGI app. Anything initialized in this method will be shared across all future requests.""" # URL path attributes - self.path_prefix = normalize_url_path(path_prefix) - self.dispatcher_path = f"/{self.path_prefix}/" - self.web_modules_path = f"/{self.path_prefix}/modules/" - self.static_path = f"/{self.path_prefix}/static/" + self.path_prefix = path_prefix + self.dispatcher_path = self.path_prefix + self.web_modules_path = f"{self.path_prefix}modules/" + self.static_path = f"{self.path_prefix}static/" self.dispatcher_pattern = re.compile( f"^{self.dispatcher_path}(?P[^/]+)/?" ) @@ -161,6 +161,7 @@ async def run_dispatcher( else: raise RuntimeError("No root component provided.") + # TODO: Get HTTP URL from `http_pathname` and `http_query_string` parsed_url = urllib.parse.urlparse(scope["path"]) await serve_layout( @@ -169,6 +170,7 @@ async def run_dispatcher( component(), value=Connection( scope=scope, + # TODO: Rename `search` to `query_string` location=Location( parsed_url.path, f"?{parsed_url.query}" if parsed_url.query else "", diff --git a/src/reactpy/backend/standalone.py b/src/reactpy/backend/standalone.py index 36d6d6639..ff630ef83 100644 --- a/src/reactpy/backend/standalone.py +++ b/src/reactpy/backend/standalone.py @@ -2,6 +2,7 @@ import os import re from collections.abc import Coroutine, Sequence +from dataclasses import dataclass from email.utils import formatdate from logging import getLogger from pathlib import Path @@ -9,7 +10,7 @@ from reactpy import html from reactpy.backend.middleware import ReactPyMiddleware -from reactpy.backend.utils import dict_to_byte_list, find_and_replace, vdom_head_to_html +from reactpy.backend.utils import dict_to_byte_list, replace_many, vdom_head_to_html from reactpy.core.types import VdomDict from reactpy.types import RootComponentConstructor @@ -17,25 +18,20 @@ class ReactPy(ReactPyMiddleware): - cached_index_html = "" - etag = "" - last_modified = "" - templates_dir = Path(__file__).parent.parent / "templates" - index_html_path = templates_dir / "index.html" multiple_root_components = False def __init__( self, root_component: RootComponentConstructor, *, - path_prefix: str = "reactpy/", + path_prefix: str = "/reactpy/", web_modules_dir: Path | None = None, http_headers: dict[str, str | int] | None = None, html_head: VdomDict | None = None, html_lang: str = "en", ) -> None: super().__init__( - app=self.reactpy_app, + app=ReactPyApp(self), root_components=[], path_prefix=path_prefix, web_modules_dir=web_modules_dir, @@ -46,7 +42,17 @@ def __init__( self.html_head = html_head or html.head() self.html_lang = html_lang - async def reactpy_app( + +@dataclass +class ReactPyApp: + parent: ReactPy + _cached_index_html = "" + _etag = "" + _last_modified = "" + _templates_dir = Path(__file__).parent.parent / "templates" + _index_html_path = _templates_dir / "index.html" + + async def __call__( self, scope: dict[str, Any], receive: Callable[..., Coroutine], @@ -65,18 +71,18 @@ async def reactpy_app( return # Store the HTTP response in memory for performance - if not self.cached_index_html: + if not self._cached_index_html: self.process_index_html() # Return headers for all HTTP responses request_headers = dict(scope["headers"]) response_headers: dict[str, str | int] = { - "etag": self.etag, - "last-modified": self.last_modified, + "etag": self._etag, + "last-modified": self._last_modified, "access-control-allow-origin": "*", "cache-control": "max-age=60, public", - "content-length": len(self.cached_index_html), - **self.extra_headers, + "content-length": len(self._cached_index_html), + **self.parent.extra_headers, } # Browser is asking for the headers @@ -91,7 +97,7 @@ async def reactpy_app( ) # Browser already has the content cached - if request_headers.get(b"if-none-match") == self.etag.encode(): + if request_headers.get(b"if-none-match") == self._etag.encode(): response_headers.pop("content-length") return await http_response( scope["method"], @@ -107,27 +113,27 @@ async def reactpy_app( scope["method"], send, 200, - self.cached_index_html, + self._cached_index_html, content_type=b"text/html", headers=dict_to_byte_list(response_headers), ) def match_dispatch_path(self, scope: dict) -> bool: """Method override to remove `dotted_path` from the dispatcher URL.""" - return str(scope["path"]) == self.dispatcher_path + return str(scope["path"]) == self.parent.dispatcher_path def process_index_html(self): """Process the index.html and store the results in memory.""" - with open(self.index_html_path, encoding="utf-8") as file_handle: + with open(self._index_html_path, encoding="utf-8") as file_handle: cached_index_html = file_handle.read() - self.cached_index_html = find_and_replace( + self._cached_index_html = replace_many( cached_index_html, { - 'from "index.ts"': f'from "{self.static_path}index.js"', - '': f'', - "": vdom_head_to_html(self.html_head), - "{path_prefix}": self.path_prefix, + 'from "index.ts"': f'from "{self.parent.static_path}index.js"', + '': f'', + "": vdom_head_to_html(self.parent.html_head), + "{path_prefix}": self.parent.path_prefix, "{reconnect_interval}": "750", "{reconnect_max_interval}": "60000", "{reconnect_max_retries}": "150", @@ -135,9 +141,9 @@ def process_index_html(self): }, ) - self.etag = f'"{hashlib.md5(self.cached_index_html.encode(), usedforsecurity=False).hexdigest()}"' - self.last_modified = formatdate( - os.stat(self.index_html_path).st_mtime, usegmt=True + self._etag = f'"{hashlib.md5(self._cached_index_html.encode(), usedforsecurity=False).hexdigest()}"' + self._last_modified = formatdate( + os.stat(self._index_html_path).st_mtime, usegmt=True ) diff --git a/src/reactpy/backend/utils.py b/src/reactpy/backend/utils.py index 1f0dbe6fc..7218eefcc 100644 --- a/src/reactpy/backend/utils.py +++ b/src/reactpy/backend/utils.py @@ -12,14 +12,6 @@ logger = logging.getLogger(__name__) -def normalize_url_path(url: str) -> str: - """Normalize a URL path.""" - new_url = re.sub(r"/+", "/", url) - new_url = new_url.lstrip("/") - new_url = new_url.rstrip("/") - return new_url - - def import_dotted_path(dotted_path: str) -> Any: """Imports a dotted path and returns the callable.""" module_name, component_name = dotted_path.rsplit(".", 1) @@ -58,13 +50,15 @@ def check_path(url_path: str) -> str: return "URL path must not be empty." if not isinstance(url_path, str): return "URL path is must be a string." - if not url_path[0].isalnum(): - return "URL path must start with an alphanumeric character." + if not url_path.startswith("/"): + return "URL path must start with a forward slash." + if not url_path.endswith("/"): + return "URL path must end with a forward slash." return "" -def find_and_replace(content: str, replacements: dict[str, str]) -> str: +def replace_many(content: str, replacements: dict[str, str]) -> str: """Find and replace several key-values, and throw and error if the substring is not found.""" for key, value in replacements.items(): if key not in content: From 459d3bce0ceee2673ebeccada4da490832206a9d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 17:31:13 -0800 Subject: [PATCH 36/84] more refactoring to standalone mode --- src/reactpy/backend/standalone.py | 69 +++++++++---------------------- src/reactpy/backend/utils.py | 25 +++++++++-- 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/src/reactpy/backend/standalone.py b/src/reactpy/backend/standalone.py index ff630ef83..7fa922bd5 100644 --- a/src/reactpy/backend/standalone.py +++ b/src/reactpy/backend/standalone.py @@ -1,7 +1,7 @@ import hashlib import os import re -from collections.abc import Coroutine, Sequence +from collections.abc import Coroutine from dataclasses import dataclass from email.utils import formatdate from logging import getLogger @@ -10,7 +10,12 @@ from reactpy import html from reactpy.backend.middleware import ReactPyMiddleware -from reactpy.backend.utils import dict_to_byte_list, replace_many, vdom_head_to_html +from reactpy.backend.utils import ( + dict_to_byte_list, + http_response, + replace_many, + vdom_head_to_html, +) from reactpy.core.types import VdomDict from reactpy.types import RootComponentConstructor @@ -45,6 +50,9 @@ def __init__( @dataclass class ReactPyApp: + """ASGI app for ReactPy's standalone mode. This is utilized by `ReactPyMiddleware` as an alternative + to a user provided ASGI app.""" + parent: ReactPy _cached_index_html = "" _etag = "" @@ -58,7 +66,6 @@ async def __call__( receive: Callable[..., Coroutine], send: Callable[..., Coroutine], ) -> None: - """ASGI app for ReactPy standalone mode.""" if scope["type"] != "http": if scope["type"] != "lifespan": msg = ( @@ -74,7 +81,7 @@ async def __call__( if not self._cached_index_html: self.process_index_html() - # Return headers for all HTTP responses + # Response headers for `index.html` responses request_headers = dict(scope["headers"]) response_headers: dict[str, str | int] = { "etag": self._etag, @@ -82,17 +89,15 @@ async def __call__( "access-control-allow-origin": "*", "cache-control": "max-age=60, public", "content-length": len(self._cached_index_html), + "content-type": "text/html; charset=utf-8", **self.parent.extra_headers, } # Browser is asking for the headers if scope["method"] == "HEAD": return await http_response( - scope["method"], - send, - 200, - "", - content_type=b"text/html", + send=send, + method=scope["method"], headers=dict_to_byte_list(response_headers), ) @@ -100,21 +105,17 @@ async def __call__( if request_headers.get(b"if-none-match") == self._etag.encode(): response_headers.pop("content-length") return await http_response( - scope["method"], - send, - 304, - "", - content_type=b"text/html", + send=send, + method=scope["method"], + code=304, headers=dict_to_byte_list(response_headers), ) # Send the index.html await http_response( - scope["method"], - send, - 200, - self._cached_index_html, - content_type=b"text/html", + send=send, + method=scope["method"], + message=self._cached_index_html, headers=dict_to_byte_list(response_headers), ) @@ -145,33 +146,3 @@ def process_index_html(self): self._last_modified = formatdate( os.stat(self._index_html_path).st_mtime, usegmt=True ) - - -async def http_response( - method: str, - send: Callable[..., Coroutine], - code: int, - message: str, - content_type: bytes = b"text/plain", - headers: Sequence = (), -) -> None: - """Sends a HTTP response using the ASGI `send` API.""" - # Head requests don't need body content - if method == "HEAD": - await send( - { - "type": "http.response.start", - "status": code, - "headers": [*headers], - } - ) - await send({"type": "http.response.body"}) - else: - await send( - { - "type": "http.response.start", - "status": code, - "headers": [(b"content-type", content_type), *headers], - } - ) - await send({"type": "http.response.body", "body": message.encode()}) diff --git a/src/reactpy/backend/utils.py b/src/reactpy/backend/utils.py index 7218eefcc..b67d4f40a 100644 --- a/src/reactpy/backend/utils.py +++ b/src/reactpy/backend/utils.py @@ -1,10 +1,9 @@ from __future__ import annotations import logging -import re -from collections.abc import Iterable +from collections.abc import Coroutine, Iterable, Sequence from importlib import import_module -from typing import Any +from typing import Any, Callable from reactpy.core.types import ComponentType, VdomDict from reactpy.utils import vdom_to_html @@ -86,3 +85,23 @@ def vdom_head_to_html(head: VdomDict) -> str: raise ValueError( "Invalid head element! Element must be either `html.head` or a string." ) + + +async def http_response( + *, + send: Callable[[dict[str, Any]], Coroutine], + method: str, + code: int = 200, + message: str = "", + headers: Sequence = (), +) -> None: + """Sends a HTTP response using the ASGI `send` API.""" + start_msg = {"type": "http.response.start", "status": code, "headers": [*headers]} + body_msg: dict[str, str | bytes] = {"type": "http.response.body"} + + # Add the content type and body to everything other than a HEAD request + if method != "HEAD": + body_msg["body"] = message.encode() + + await send(start_msg) + await send(body_msg) From 278790dc3352298320d9b6bdbc0cc8ac58df192a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 18:14:43 -0800 Subject: [PATCH 37/84] functional template tag --- src/reactpy/backend/utils.py | 14 +------- src/reactpy/templatetag/__init__.py | 0 src/reactpy/templatetag/jinja.py | 53 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 src/reactpy/templatetag/__init__.py create mode 100644 src/reactpy/templatetag/jinja.py diff --git a/src/reactpy/backend/utils.py b/src/reactpy/backend/utils.py index b67d4f40a..17dd3e347 100644 --- a/src/reactpy/backend/utils.py +++ b/src/reactpy/backend/utils.py @@ -26,22 +26,10 @@ def import_dotted_path(dotted_path: str) -> Any: def import_components(dotted_paths: Iterable[str]) -> dict[str, Any]: """Imports a list of dotted paths and returns the callables.""" - results = { + return { dotted_path: import_dotted_path(dotted_path) for dotted_path in dotted_paths } - # Check that all imports are components - for dotted_path, component in results.items(): - errors: list[str] = [] - if not isinstance(component, ComponentType): - errors.append( - f"Expected ComponentType, got {type(component)} for {dotted_path}" - ) - if errors: - raise RuntimeError(". ".join(errors)) - - return results - def check_path(url_path: str) -> str: """Check that a path is valid URL path.""" diff --git a/src/reactpy/templatetag/__init__.py b/src/reactpy/templatetag/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/reactpy/templatetag/jinja.py b/src/reactpy/templatetag/jinja.py new file mode 100644 index 000000000..292273ed4 --- /dev/null +++ b/src/reactpy/templatetag/jinja.py @@ -0,0 +1,53 @@ +import urllib.parse +from typing import ClassVar +from uuid import uuid4 + +from jinja2_simple_tags import StandaloneTag + + +class ComponentTag(StandaloneTag): + """This allows enables a `component` tag to be used in any Jinja2 rendering context.""" + + safe_output = True + tags: ClassVar[set[str]] = {"reactpy_component"} + + def render(self, dotted_path: str, *args, **kwargs): + uuid = uuid4().hex + class_ = kwargs.pop("class", "") + kwargs.pop("key", "") # `key` is effectively useless for the root node + + # TODO: Fetch these from ReactPy settings + client_js_path = "/reactpy/static/index.js" + path_prefix = "/reactpy/" + reconnect_interval = 750 + reconnect_max_interval = 60000 + reconnect_max_retries = 150 + reconnect_backoff_multiplier = 1.25 + + # Generate the websocket URL + # TODO: This will require rewriting the websocket URL to `reactpy-ws/?` + component_path = f"{dotted_path}/" + if kwargs.get("args") is not None: + raise ValueError("Cannot specify `args` as a keyword argument") + if args: + kwargs["args"] = args + if kwargs: + component_path += f"?{urllib.parse.urlencode(kwargs)}" + + # TODO: Turn this into a util function and maybe use it for the standalone version too? + return ( + f'
' + '" + ) From a14d455539a9222709ece2dd91cdc4793f7471f3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 18:19:35 -0800 Subject: [PATCH 38/84] Rename location.search to location.query_string --- src/reactpy/backend/middleware.py | 6 +++--- src/reactpy/backend/types.py | 4 ++-- tests/test_backend/test_all.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/reactpy/backend/middleware.py b/src/reactpy/backend/middleware.py index 8e76ae2ab..e6cd9c08d 100644 --- a/src/reactpy/backend/middleware.py +++ b/src/reactpy/backend/middleware.py @@ -163,6 +163,8 @@ async def run_dispatcher( # TODO: Get HTTP URL from `http_pathname` and `http_query_string` parsed_url = urllib.parse.urlparse(scope["path"]) + pathname = parsed_url.path + query_string = f"?{parsed_url.query}" if parsed_url.query else "" await serve_layout( Layout( # type: ignore @@ -170,10 +172,8 @@ async def run_dispatcher( component(), value=Connection( scope=scope, - # TODO: Rename `search` to `query_string` location=Location( - parsed_url.path, - f"?{parsed_url.query}" if parsed_url.query else "", + pathname=pathname, query_string=query_string ), carrier={ "scope": scope, diff --git a/src/reactpy/backend/types.py b/src/reactpy/backend/types.py index aaaf7c754..80d5f5ced 100644 --- a/src/reactpy/backend/types.py +++ b/src/reactpy/backend/types.py @@ -35,8 +35,8 @@ class Location: pathname: str """the path of the URL for the location""" - search: str - """A search or query string - a '?' followed by the parameters of the URL. + query_string: str + """HTTP query string - a '?' followed by the parameters of the URL. If there are no search parameters this should be an empty string """ diff --git a/tests/test_backend/test_all.py b/tests/test_backend/test_all.py index 62aa2bca0..7dd96b3eb 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_backend/test_all.py @@ -124,7 +124,7 @@ def ShowRoute(): Location("/another/something/file.txt", "?key=value"), Location("/another/something/file.txt", "?key1=value1&key2=value2"), ]: - await display.goto(loc.pathname + loc.search) + await display.goto(loc.pathname + loc.query_string) await poll_location.until_equals(loc) From 318f1fb4c4fa688b9d108ad343260f48148e8d94 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 18:24:06 -0800 Subject: [PATCH 39/84] Add jinja to deps --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f804afabd..58824f7d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,8 @@ artifacts = [] [project.optional-dependencies] # TODO: Nuke backends from the optional deps -all = ["reactpy[uvicorn,testing]"] +all = ["reactpy[jinja,uvicorn,testing]"] +jinja = ["jinja2-simple-tags", "jinja2"] uvicorn = ["uvicorn[standard]"] testing = ["playwright"] From df3c0f7cf14bd01efd84215dd77f23e00f988066 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 19:31:55 -0800 Subject: [PATCH 40/84] Move some project files around --- middleware.py | 37 ++++++++++++++++ pyproject.toml | 3 +- src/reactpy/__init__.py | 4 +- src/reactpy/{templatetag => asgi}/__init__.py | 0 src/reactpy/{backend => asgi}/middleware.py | 5 +-- src/reactpy/{backend => asgi}/standalone.py | 4 +- src/reactpy/{backend => asgi}/utils.py | 0 src/reactpy/backend/__init__.py | 22 ---------- src/reactpy/backend/types.py | 42 ------------------- src/reactpy/core/hooks.py | 6 +-- src/reactpy/core/types.py | 40 +++++++++++++++++- src/reactpy/{templatetag => }/jinja.py | 13 +++++- src/reactpy/testing/backend.py | 6 +-- src/reactpy/types.py | 3 +- standalone.py | 21 ++++++++++ templates/index.html | 11 +++++ tests/test_backend/test_all.py | 8 ++-- tests/test_backend/test_common.py | 2 +- tests/test_backend/test_utils.py | 6 +-- tests/test_client.py | 2 +- tests/test_testing.py | 2 +- tests/test_web/test_module.py | 2 +- 22 files changed, 145 insertions(+), 94 deletions(-) create mode 100644 middleware.py rename src/reactpy/{templatetag => asgi}/__init__.py (100%) rename src/reactpy/{backend => asgi}/middleware.py (98%) rename src/reactpy/{backend => asgi}/standalone.py (98%) rename src/reactpy/{backend => asgi}/utils.py (100%) delete mode 100644 src/reactpy/backend/__init__.py delete mode 100644 src/reactpy/backend/types.py rename src/reactpy/{templatetag => }/jinja.py (85%) create mode 100644 standalone.py create mode 100644 templates/index.html diff --git a/middleware.py b/middleware.py new file mode 100644 index 000000000..582f386bd --- /dev/null +++ b/middleware.py @@ -0,0 +1,37 @@ +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +from reactpy import component, hooks, html +from reactpy.backend.middleware import ReactPyMiddleware + +templates = Jinja2Templates( + directory="templates", extensions=["reactpy.jinja.ReactPyTemplateTag"] +) + + +@component +def Counter(): + count, set_count = hooks.use_state(0) + + def increment(event): + set_count(count + 1) + + return html.div( + html.button({"onClick": increment}, "Increment"), + html.p(f"Count: {count}"), + ) + + +async def homepage(request): + return templates.TemplateResponse(request, "index.html") + + +app = Starlette( + debug=True, + routes=[ + Route("/", homepage), + ], +) + +app = ReactPyMiddleware(app, ["middleware.Counter"]) diff --git a/pyproject.toml b/pyproject.toml index 58824f7d7..aecb0a435 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "asgiref >=3", "lxml >=4", "servestatic >=3.0.0", - "jinja2 >=3", ] dynamic = ["version"] urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" @@ -77,7 +76,7 @@ artifacts = [] [project.optional-dependencies] # TODO: Nuke backends from the optional deps all = ["reactpy[jinja,uvicorn,testing]"] -jinja = ["jinja2-simple-tags", "jinja2"] +jinja = ["jinja2-simple-tags", "jinja2 >=3"] uvicorn = ["uvicorn[standard]"] testing = ["playwright"] diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index bc8df9be2..5d7b9334e 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -1,4 +1,4 @@ -from reactpy import backend, config, logging, types, web, widgets +from reactpy import asgi, config, logging, types, web, widgets from reactpy._html import html from reactpy.core import hooks from reactpy.core.component import component @@ -27,7 +27,7 @@ __all__ = [ "Layout", "Ref", - "backend", + "asgi", "component", "config", "create_context", diff --git a/src/reactpy/templatetag/__init__.py b/src/reactpy/asgi/__init__.py similarity index 100% rename from src/reactpy/templatetag/__init__.py rename to src/reactpy/asgi/__init__.py diff --git a/src/reactpy/backend/middleware.py b/src/reactpy/asgi/middleware.py similarity index 98% rename from src/reactpy/backend/middleware.py rename to src/reactpy/asgi/middleware.py index e6cd9c08d..688536b07 100644 --- a/src/reactpy/backend/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -14,13 +14,12 @@ from asgiref.compatibility import guarantee_single_callable from servestatic import ServeStaticASGI -from reactpy.backend.types import Connection, Location -from reactpy.backend.utils import check_path, import_components +from reactpy.asgi.utils import check_path, import_components from reactpy.config import REACTPY_WEB_MODULES_DIR from reactpy.core.hooks import ConnectionContext from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.types import RootComponentConstructor +from reactpy.types import Connection, Location, RootComponentConstructor _logger = logging.getLogger(__name__) diff --git a/src/reactpy/backend/standalone.py b/src/reactpy/asgi/standalone.py similarity index 98% rename from src/reactpy/backend/standalone.py rename to src/reactpy/asgi/standalone.py index 7fa922bd5..9a21d66d4 100644 --- a/src/reactpy/backend/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -9,8 +9,8 @@ from typing import Any, Callable from reactpy import html -from reactpy.backend.middleware import ReactPyMiddleware -from reactpy.backend.utils import ( +from reactpy.asgi.middleware import ReactPyMiddleware +from reactpy.asgi.utils import ( dict_to_byte_list, http_response, replace_many, diff --git a/src/reactpy/backend/utils.py b/src/reactpy/asgi/utils.py similarity index 100% rename from src/reactpy/backend/utils.py rename to src/reactpy/asgi/utils.py diff --git a/src/reactpy/backend/__init__.py b/src/reactpy/backend/__init__.py deleted file mode 100644 index e08e50649..000000000 --- a/src/reactpy/backend/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -import mimetypes -from logging import getLogger - -_logger = getLogger(__name__) - -# Fix for missing mime types due to OS corruption/misconfiguration -# Example: https://github.com/encode/starlette/issues/829 -if not mimetypes.inited: - mimetypes.init() -for extension, mime_type in { - ".js": "application/javascript", - ".css": "text/css", - ".json": "application/json", -}.items(): - if not mimetypes.types_map.get(extension): # pragma: no cover - _logger.warning( - "Mime type '%s = %s' is missing. Please research how to " - "fix missing mime types on your operating system.", - extension, - mime_type, - ) - mimetypes.add_type(mime_type, extension) diff --git a/src/reactpy/backend/types.py b/src/reactpy/backend/types.py deleted file mode 100644 index 80d5f5ced..000000000 --- a/src/reactpy/backend/types.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from collections.abc import MutableMapping -from dataclasses import dataclass -from typing import Any, Generic, TypeVar - -CarrierType = TypeVar("CarrierType") - - -@dataclass -class Connection(Generic[CarrierType]): - """Represents a connection with a client""" - - scope: MutableMapping[str, Any] - """An ASGI scope or WSGI environment dictionary""" - - location: Location - """The current location (URL)""" - - carrier: CarrierType - """How the connection is mediated. For example, a request or websocket. - - This typically depends on the backend implementation. - """ - - -@dataclass -class Location: - """Represents the current location (URL) - - Analogous to, but not necessarily identical to, the client-side - ``document.location`` object. - """ - - pathname: str - """the path of the URL for the location""" - - query_string: str - """HTTP query string - a '?' followed by the parameters of the URL. - - If there are no search parameters this should be an empty string - """ diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 5a3c9fd13..d5a33d7bc 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -18,10 +18,10 @@ from typing_extensions import TypeAlias -from reactpy.backend.types import Connection, Location from reactpy.config import REACTPY_DEBUG_MODE from reactpy.core._life_cycle_hook import current_hook from reactpy.core.types import Context, Key, State, VdomDict +from reactpy.types import Connection, Location from reactpy.utils import Ref if not TYPE_CHECKING: @@ -264,12 +264,12 @@ def use_connection() -> Connection[Any]: def use_scope() -> MutableMapping[str, Any]: - """Get the current :class:`~reactpy.backend.types.Connection`'s scope.""" + """Get the current :class:`~reactpy.types.Connection`'s scope.""" return use_connection().scope def use_location() -> Location: - """Get the current :class:`~reactpy.backend.types.Connection`'s location.""" + """Get the current :class:`~reactpy.types.Connection`'s location.""" return use_connection().location diff --git a/src/reactpy/core/types.py b/src/reactpy/core/types.py index b451be30a..29bb407e2 100644 --- a/src/reactpy/core/types.py +++ b/src/reactpy/core/types.py @@ -2,7 +2,8 @@ import sys from collections import namedtuple -from collections.abc import Mapping, Sequence +from collections.abc import Mapping, MutableMapping, Sequence +from dataclasses import dataclass from types import TracebackType from typing import ( TYPE_CHECKING, @@ -19,6 +20,8 @@ from typing_extensions import TypeAlias, TypedDict +CarrierType = TypeVar("CarrierType") + _Type = TypeVar("_Type") @@ -246,3 +249,38 @@ class ContextProviderType(ComponentType, Protocol[_Type]): @property def value(self) -> _Type: "Current context value" + + +@dataclass +class Connection(Generic[CarrierType]): + """Represents a connection with a client""" + + scope: MutableMapping[str, Any] + """An ASGI scope dictionary""" + + location: Location + """The current location (URL)""" + + carrier: CarrierType + """How the connection is mediated. For example, a request or websocket. + + This typically depends on the backend implementation. + """ + + +@dataclass +class Location: + """Represents the current location (URL) + + Analogous to, but not necessarily identical to, the client-side + ``document.location`` object. + """ + + pathname: str + """the path of the URL for the location""" + + query_string: str + """HTTP query string - a '?' followed by the parameters of the URL. + + If there are no search parameters this should be an empty string + """ diff --git a/src/reactpy/templatetag/jinja.py b/src/reactpy/jinja.py similarity index 85% rename from src/reactpy/templatetag/jinja.py rename to src/reactpy/jinja.py index 292273ed4..5f2e8da84 100644 --- a/src/reactpy/templatetag/jinja.py +++ b/src/reactpy/jinja.py @@ -1,15 +1,24 @@ import urllib.parse +from importlib import import_module from typing import ClassVar from uuid import uuid4 from jinja2_simple_tags import StandaloneTag +try: + import_module("jinja2") +except ImportError as e: + raise ImportError( + "The Jinja2 library is required to use the ReactPy template tag. " + "Please install it via `pip install reactpy[jinja]`." + ) from e -class ComponentTag(StandaloneTag): + +class ReactPyTemplateTag(StandaloneTag): """This allows enables a `component` tag to be used in any Jinja2 rendering context.""" safe_output = True - tags: ClassVar[set[str]] = {"reactpy_component"} + tags: ClassVar[set[str]] = {"component"} def render(self, dotted_path: str, *args, **kwargs): uuid = uuid4().hex diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 3f56a5ecb..47c3f7790 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -7,9 +7,8 @@ from typing import Any, Callable from urllib.parse import urlencode, urlunparse -from reactpy.backend import default as default_server -from reactpy.backend.types import BackendType -from reactpy.backend.utils import find_available_port +from reactpy.asgi import default as default_server +from reactpy.asgi.utils import find_available_port from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT from reactpy.core.component import component from reactpy.core.hooks import use_callback, use_effect, use_state @@ -19,6 +18,7 @@ capture_reactpy_logs, list_logged_exceptions, ) +from reactpy.types import BackendType from reactpy.utils import Ref diff --git a/src/reactpy/types.py b/src/reactpy/types.py index acfeeb4c7..f29c8c3e8 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -4,11 +4,11 @@ - :mod:`reactpy.backend.types` """ -from reactpy.backend.types import Connection, Location from reactpy.core.component import Component from reactpy.core.types import ( ComponentConstructor, ComponentType, + Connection, Context, EventHandlerDict, EventHandlerFunc, @@ -17,6 +17,7 @@ ImportSourceDict, Key, LayoutType, + Location, RootComponentConstructor, State, VdomAttributes, diff --git a/standalone.py b/standalone.py new file mode 100644 index 000000000..9811c0ee7 --- /dev/null +++ b/standalone.py @@ -0,0 +1,21 @@ +from reactpy import component, hooks, html +from reactpy.backend.standalone import ReactPy + + +@component +def Counter(): + count, set_count = hooks.use_state(0) + + def increment(event): + set_count(count + 1) + + return html.div( + html.button({"onClick": increment}, "Increment"), + html.p(f"Count: {count}"), + ) + + +app = ReactPy(Counter) +# from reactpy import run + +# run(Counter) diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 000000000..21505c4d5 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,11 @@ + + + + + + +
+ {% component "middleware.Counter" %} + + + diff --git a/tests/test_backend/test_all.py b/tests/test_backend/test_all.py index 7dd96b3eb..6f2a7943b 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_backend/test_all.py @@ -4,11 +4,11 @@ import reactpy from reactpy import html -from reactpy.backend import default as default_implementation -from reactpy.backend._common import PATH_PREFIX -from reactpy.backend.types import BackendType, Connection, Location -from reactpy.backend.utils import all_implementations +from reactpy.asgi import default as default_implementation +from reactpy.asgi._common import PATH_PREFIX +from reactpy.asgi.utils import all_implementations from reactpy.testing import BackendFixture, DisplayFixture, poll +from reactpy.types import BackendType, Connection, Location @pytest.fixture( diff --git a/tests/test_backend/test_common.py b/tests/test_backend/test_common.py index b1cd3271c..4e8b8c4f2 100644 --- a/tests/test_backend/test_common.py +++ b/tests/test_backend/test_common.py @@ -1,7 +1,7 @@ import pytest from reactpy import html -from reactpy.backend._common import ( +from reactpy.asgi._common import ( CommonOptions, traversal_safe_path, vdom_head_elements_to_html, diff --git a/tests/test_backend/test_utils.py b/tests/test_backend/test_utils.py index 319dd816f..3b56229ab 100644 --- a/tests/test_backend/test_utils.py +++ b/tests/test_backend/test_utils.py @@ -5,9 +5,9 @@ import pytest from playwright.async_api import Page -from reactpy.backend import flask as flask_implementation -from reactpy.backend.utils import find_available_port -from reactpy.backend.utils import run as sync_run +from reactpy.asgi import flask as flask_implementation +from reactpy.asgi.utils import find_available_port +from reactpy.asgi.utils import run as sync_run from tests.sample import SampleApp diff --git a/tests/test_client.py b/tests/test_client.py index ea7ebcb6b..7be07a6ec 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,7 +5,7 @@ from playwright.async_api import Browser import reactpy -from reactpy.backend.utils import find_available_port +from reactpy.asgi.utils import find_available_port from reactpy.testing import BackendFixture, DisplayFixture, poll from tests.tooling.common import DEFAULT_TYPE_DELAY from tests.tooling.hooks import use_counter diff --git a/tests/test_testing.py b/tests/test_testing.py index a6517abc0..f8b260162 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -4,7 +4,7 @@ import pytest from reactpy import Ref, component, html, testing -from reactpy.backend import starlette as starlette_implementation +from reactpy.asgi import starlette as starlette_implementation from reactpy.logging import ROOT_LOGGER from reactpy.testing.backend import _hotswap from reactpy.testing.display import DisplayFixture diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 388794741..b3d3a2fa8 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -4,7 +4,7 @@ from sanic import Sanic import reactpy -from reactpy.backend import sanic as sanic_implementation +from reactpy.asgi import sanic as sanic_implementation from reactpy.testing import ( BackendFixture, DisplayFixture, From bb63511e17c66747b82a1ba60a814801f529f03b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 19:33:24 -0800 Subject: [PATCH 41/84] Remove `module_from_template` --- src/reactpy/future.py | 0 src/reactpy/types.py | 1 - src/reactpy/web/__init__.py | 4 +- src/reactpy/web/module.py | 87 ------------------------------------- 4 files changed, 1 insertion(+), 91 deletions(-) delete mode 100644 src/reactpy/future.py diff --git a/src/reactpy/future.py b/src/reactpy/future.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/reactpy/types.py b/src/reactpy/types.py index f29c8c3e8..d0f77aab0 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1,7 +1,6 @@ """Exports common types from: - :mod:`reactpy.core.types` -- :mod:`reactpy.backend.types` """ from reactpy.core.component import Component diff --git a/src/reactpy/web/__init__.py b/src/reactpy/web/__init__.py index 308429dbb..f27d58ff9 100644 --- a/src/reactpy/web/__init__.py +++ b/src/reactpy/web/__init__.py @@ -2,14 +2,12 @@ export, module_from_file, module_from_string, - module_from_template, module_from_url, ) __all__ = [ + "export", "module_from_file", "module_from_string", - "module_from_template", "module_from_url", - "export", ] diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index e1a5db82f..c08ec3484 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -5,11 +5,8 @@ import shutil from dataclasses import dataclass from pathlib import Path -from string import Template from typing import Any, NewType, overload -from urllib.parse import urlparse -from reactpy._warnings import warn from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_WEB_MODULES_DIR from reactpy.core.types import ImportSourceDict, VdomDictConstructor from reactpy.core.vdom import make_vdom_constructor @@ -73,90 +70,6 @@ def module_from_url( ) -_FROM_TEMPLATE_DIR = "__from_template__" - - -def module_from_template( - template: str, - package: str, - cdn: str = "https://esm.sh", - fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, - unmount_before_update: bool = False, -) -> WebModule: - """Create a :class:`WebModule` from a framework template - - This is useful for experimenting with component libraries that do not already - support ReactPy's :ref:`Custom Javascript Component` interface. - - .. warning:: - - This approach is not recommended for use in a production setting because the - framework templates may use unpinned dependencies that could change without - warning. It's best to author a module adhering to the - :ref:`Custom Javascript Component` interface instead. - - **Templates** - - - ``react``: for modules exporting React components - - Parameters: - template: - The name of the framework template to use with the given ``package``. - package: - The name of a package to load. May include a file extension (defaults to - ``.js`` if not given) - cdn: - Where the package should be loaded from. The CDN must distribute ESM modules - fallback: - What to temporarily display while the module is being loaded. - resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. - unmount_before_update: - Cause the component to be unmounted before each update. This option should - only be used if the imported package fails to re-render when props change. - Using this option has negative performance consequences since all DOM - elements must be changed on each render. See :issue:`461` for more info. - """ - warn( - "module_from_template() is deprecated due to instability - use the Javascript " - "Components API instead. This function will be removed in a future release.", - DeprecationWarning, - ) - template_name, _, template_version = template.partition("@") - template_version = "@" + template_version if template_version else "" - - # We do this since the package may be any valid URL path. Thus we may need to strip - # object parameters or query information so we save the resulting template under the - # correct file name. - package_name = urlparse(package).path - - # downstream code assumes no trailing slash - cdn = cdn.rstrip("/") - - template_file_name = template_name + module_name_suffix(package_name) - - template_file = Path(__file__).parent / "templates" / template_file_name - if not template_file.exists(): - msg = f"No template for {template_file_name!r} exists" - raise ValueError(msg) - - variables = {"PACKAGE": package, "CDN": cdn, "VERSION": template_version} - content = Template(template_file.read_text(encoding="utf-8")).substitute(variables) - - return module_from_string( - _FROM_TEMPLATE_DIR + "/" + package_name, - content, - fallback, - resolve_exports, - resolve_exports_depth, - unmount_before_update=unmount_before_update, - ) - - def module_from_file( name: str, file: str | Path, From 353185ab34336341c1b4e26dce26ad20714a5a38 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 19:38:27 -0800 Subject: [PATCH 42/84] Remove unused import --- src/reactpy/asgi/utils.py | 2 +- src/reactpy/testing/__init__.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py index 17dd3e347..780cec0d2 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/asgi/utils.py @@ -5,7 +5,7 @@ from importlib import import_module from typing import Any, Callable -from reactpy.core.types import ComponentType, VdomDict +from reactpy.core.types import VdomDict from reactpy.utils import vdom_to_html logger = logging.getLogger(__name__) diff --git a/src/reactpy/testing/__init__.py b/src/reactpy/testing/__init__.py index 9f61cec57..27247a88f 100644 --- a/src/reactpy/testing/__init__.py +++ b/src/reactpy/testing/__init__.py @@ -14,14 +14,14 @@ ) __all__ = [ - "assert_reactpy_did_not_log", - "assert_reactpy_did_log", - "capture_reactpy_logs", - "clear_reactpy_web_modules_dir", + "BackendFixture", "DisplayFixture", "HookCatcher", "LogAssertionError", - "poll", - "BackendFixture", "StaticEventHandler", + "assert_reactpy_did_log", + "assert_reactpy_did_not_log", + "capture_reactpy_logs", + "clear_reactpy_web_modules_dir", + "poll", ] From 3209467df7fd89e9b9b43635ae4a2e03f96f5edf Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 21:59:50 -0800 Subject: [PATCH 43/84] Expose new APIs --- src/reactpy/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 5d7b9334e..3f0bd42e7 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -1,5 +1,7 @@ from reactpy import asgi, config, logging, types, web, widgets from reactpy._html import html +from reactpy.asgi.middleware import ReactPyMiddleware +from reactpy.asgi.standalone import ReactPy from reactpy.core import hooks from reactpy.core.component import component from reactpy.core.events import event @@ -26,6 +28,8 @@ __all__ = [ "Layout", + "ReactPy", + "ReactPyMiddleware", "Ref", "asgi", "component", From 31b40458cadcf27bb41b3821d4d5dc5bc2de2fd1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Jan 2025 21:59:57 -0800 Subject: [PATCH 44/84] add changelog --- .gitignore | 2 +- docs/source/about/changelog.rst | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 247ac896d..6ea5b5bc7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ src/reactpy/static/* # --- Python --- .hatch -.venv +.venv* venv MANIFEST build diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 178fbba19..45e3ce0fd 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -15,6 +15,12 @@ Changelog Unreleased ---------- +**Added** +- :pull:`1113` - Added ``reactpy.ReactPy`` that can be used to run ReactPy in standalone mode. +- :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework. +- :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework. +- :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[uvicorn,jinja]``). + **Changed** - :pull:`1251` - Substitute client-side usage of ``react`` with ``preact``. @@ -22,6 +28,7 @@ Unreleased - :pull:`1255` - The ``reactpy.html`` module has been modified to allow for auto-creation of any HTML nodes. For example, you can create a ```` element by calling ``html.data_table()``. - :pull:`1256` - Change ``set_state`` comparison method to check equality with ``==`` more consistently. - :pull:`1257` - Add support for rendering ``@component`` children within ``vdom_to_html``. +- :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``. **Removed** @@ -29,6 +36,10 @@ Unreleased - :pull:`1255` - Removed ``reactpy.sample`` module. - :pull:`1255` - Removed ``reactpy.svg`` module. Contents previously within ``reactpy.svg.*`` can now be accessed via ``html.svg.*``. - :pull:`1255` - Removed ``reactpy.html._`` function. Use ``html.fragment`` instead. +- :pull:`1113` - Removed ``reactpy.run``. See the documentation for the new method to run ReactPy applications. +- :pull:`1113` - Removed ``reactpy.backend.*``. See the documentation for the new method to run ReactPy applications. +- :pull:`1113` - All backend related installation extras (such as ``pip install reactpy[starlette]``) have been removed. +- :pull:`1113` - Removed deprecated function ``module_from_template``. **Fixed** From dbdfa9bbf0a7fc2cc315d9dbb0abded0e0d94a1a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 00:35:56 -0800 Subject: [PATCH 45/84] Remove accidentally commited test files --- middleware.py | 37 ------------------------------------- standalone.py | 21 --------------------- templates/index.html | 11 ----------- 3 files changed, 69 deletions(-) delete mode 100644 middleware.py delete mode 100644 standalone.py delete mode 100644 templates/index.html diff --git a/middleware.py b/middleware.py deleted file mode 100644 index 582f386bd..000000000 --- a/middleware.py +++ /dev/null @@ -1,37 +0,0 @@ -from starlette.applications import Starlette -from starlette.routing import Route -from starlette.templating import Jinja2Templates - -from reactpy import component, hooks, html -from reactpy.backend.middleware import ReactPyMiddleware - -templates = Jinja2Templates( - directory="templates", extensions=["reactpy.jinja.ReactPyTemplateTag"] -) - - -@component -def Counter(): - count, set_count = hooks.use_state(0) - - def increment(event): - set_count(count + 1) - - return html.div( - html.button({"onClick": increment}, "Increment"), - html.p(f"Count: {count}"), - ) - - -async def homepage(request): - return templates.TemplateResponse(request, "index.html") - - -app = Starlette( - debug=True, - routes=[ - Route("/", homepage), - ], -) - -app = ReactPyMiddleware(app, ["middleware.Counter"]) diff --git a/standalone.py b/standalone.py deleted file mode 100644 index 9811c0ee7..000000000 --- a/standalone.py +++ /dev/null @@ -1,21 +0,0 @@ -from reactpy import component, hooks, html -from reactpy.backend.standalone import ReactPy - - -@component -def Counter(): - count, set_count = hooks.use_state(0) - - def increment(event): - set_count(count + 1) - - return html.div( - html.button({"onClick": increment}, "Increment"), - html.p(f"Count: {count}"), - ) - - -app = ReactPy(Counter) -# from reactpy import run - -# run(Counter) diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 21505c4d5..000000000 --- a/templates/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - -
- {% component "middleware.Counter" %} - - - From 130bc00d0d7a7eb887d3942215835eb86050cd1e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 01:21:19 -0800 Subject: [PATCH 46/84] Implement ReactPyConfig(TypedDict) --- docs/source/about/changelog.rst | 1 + pyproject.toml | 1 - src/reactpy/asgi/middleware.py | 96 ++++++++++++++------------------- src/reactpy/asgi/standalone.py | 18 +++---- src/reactpy/asgi/utils.py | 16 +++++- src/reactpy/config.py | 52 +++++++++++++++--- src/reactpy/core/hooks.py | 4 +- src/reactpy/core/layout.py | 6 +-- src/reactpy/core/serve.py | 4 +- src/reactpy/core/types.py | 13 +++++ src/reactpy/core/vdom.py | 4 +- src/reactpy/jinja.py | 36 ++++++------- src/reactpy/logging.py | 4 +- src/reactpy/testing/backend.py | 4 +- src/reactpy/testing/common.py | 8 +-- src/reactpy/testing/display.py | 4 +- src/reactpy/types.py | 2 + src/reactpy/web/module.py | 8 +-- tests/conftest.py | 4 +- tests/test_config.py | 4 +- tests/test_core/test_hooks.py | 8 +-- tests/test_core/test_layout.py | 6 +-- tests/test_core/test_vdom.py | 8 +-- tests/tooling/aio.py | 4 +- 24 files changed, 180 insertions(+), 135 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 45e3ce0fd..57cb30bc7 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -29,6 +29,7 @@ Unreleased - :pull:`1256` - Change ``set_state`` comparison method to check equality with ``==`` more consistently. - :pull:`1257` - Add support for rendering ``@component`` children within ``vdom_to_html``. - :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``. +- :pull:`1113` - Renamed ``reatpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``. **Removed** diff --git a/pyproject.toml b/pyproject.toml index aecb0a435..5f4b7feec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,6 @@ commands = [ artifacts = [] [project.optional-dependencies] -# TODO: Nuke backends from the optional deps all = ["reactpy[jinja,uvicorn,testing]"] jinja = ["jinja2-simple-tags", "jinja2 >=3"] uvicorn = ["uvicorn[standard]"] diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index 688536b07..d91a62744 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -13,13 +13,14 @@ import orjson from asgiref.compatibility import guarantee_single_callable from servestatic import ServeStaticASGI +from typing_extensions import Unpack -from reactpy.asgi.utils import check_path, import_components -from reactpy.config import REACTPY_WEB_MODULES_DIR +from reactpy import config +from reactpy.asgi.utils import check_path, import_components, process_settings from reactpy.core.hooks import ConnectionContext from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.types import Connection, Location, RootComponentConstructor +from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor _logger = logging.getLogger(__name__) @@ -34,14 +35,15 @@ def __init__( self, app: Callable[..., Coroutine], root_components: Iterable[str], - *, - # TODO: Add a setting attribute to this class. Or maybe just put a shit ton of kwargs here. Or add a **kwargs that resolves to a TypedDict? - path_prefix: str = "/reactpy/", - web_modules_dir: Path | None = None, + **settings: Unpack[ReactPyConfig], ) -> None: - """Configure the ASGI app. Anything initialized in this method will be shared across all future requests.""" + """Configure the ASGI app. Anything initialized in this method will be shared across all future requests. + TODO: Add types in docstring""" + # Process global settings + process_settings(settings) + # URL path attributes - self.path_prefix = path_prefix + self.path_prefix = config.REACTPY_PATH_PREFIX.current self.dispatcher_path = self.path_prefix self.web_modules_path = f"{self.path_prefix}modules/" self.static_path = f"{self.path_prefix}static/" @@ -56,10 +58,8 @@ def __init__( self.root_components = import_components(root_components) # Directory attributes - self.web_modules_dir = web_modules_dir or REACTPY_WEB_MODULES_DIR.current + self.web_modules_dir = config.REACTPY_WEB_MODULES_DIR.current self.static_dir = Path(__file__).parent.parent / "static" - if self.web_modules_dir != REACTPY_WEB_MODULES_DIR.current: - REACTPY_WEB_MODULES_DIR.set_current(self.web_modules_dir) # Sub-applications self.component_dispatch_app = ComponentDispatchApp(parent=self) @@ -67,9 +67,13 @@ def __init__( self.web_modules_app = WebModuleApp(parent=self) # Validate the configuration - reason = check_path(path_prefix) + reason = check_path(self.path_prefix) if reason: raise ValueError(f"Invalid `path_prefix`. {reason}") + if not self.web_modules_dir.exists(): + raise ValueError( + f"Web modules directory {self.web_modules_dir} does not exist." + ) async def __call__( self, @@ -127,12 +131,12 @@ async def __call__( self.run_dispatcher(scope, receive, send, recv_queue) ) - if event["type"] == "websocket.disconnect": + elif event["type"] == "websocket.disconnect": if dispatcher: dispatcher.cancel() break - if event["type"] == "websocket.receive": + elif event["type"] == "websocket.receive": queue_put_func = recv_queue.put(orjson.loads(event["text"])) await queue_put_func @@ -143,8 +147,9 @@ async def run_dispatcher( send: Callable[..., Coroutine], recv_queue: asyncio.Queue, ) -> None: - # Get the component from the URL. + """Asyncio background task that renders and transmits layout updates of ReactPy components.""" try: + # Determine component to serve by analyzing the URL and/or class parameters. if self.parent.multiple_root_components: url_match = re.match(self.parent.dispatcher_pattern, scope["path"]) if not url_match: @@ -160,42 +165,38 @@ async def run_dispatcher( else: raise RuntimeError("No root component provided.") - # TODO: Get HTTP URL from `http_pathname` and `http_query_string` - parsed_url = urllib.parse.urlparse(scope["path"]) - pathname = parsed_url.path - query_string = f"?{parsed_url.query}" if parsed_url.query else "" + # Create a connection object by analyzing the websocket's query string. + ws_query_string = urllib.parse.parse_qs( + scope["query_string"].decode(), strict_parsing=True + ) + connection = Connection( + scope=scope, + location=Location( + pathname=ws_query_string.get("http_pathname", [""])[0], + query_string=ws_query_string.get("http_search", [""])[0], + ), + carrier=self, + ) + # Start the ReactPy component rendering loop await serve_layout( - Layout( # type: ignore - ConnectionContext( - component(), - value=Connection( - scope=scope, - location=Location( - pathname=pathname, query_string=query_string - ), - carrier={ - "scope": scope, - "send": send, - "receive": receive, - }, - ), - ) - ), - self.send_json(send), + Layout(ConnectionContext(component(), value=connection)), # type: ignore + self._send_json(send), recv_queue.get, ) + + # Manually log exceptions since this function is running in a separate asyncio task. except Exception as error: await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}") @staticmethod - def send_json(send: Callable) -> Callable[..., Coroutine]: + def _send_json(send: Callable) -> Callable[..., Coroutine]: """Use orjson to send JSON over an ASGI websocket.""" - async def _send_json(value: Any) -> None: + async def _send(value: Any) -> None: await send({"type": "websocket.send", "text": orjson.dumps(value).decode()}) - return _send_json + return _send @dataclass @@ -210,14 +211,6 @@ async def __call__( send: Callable[..., Coroutine], ) -> None: """ASGI app for ReactPy static files.""" - # If no static directory is configured, serve the user's application - if not self.parent.static_dir: - await asyncio.to_thread( - _logger.info, - "Tried to serve static file without a configured directory.", - ) - return await self.parent.user_app(scope, receive, send) - if not self._static_file_server: self._static_file_server = ServeStaticASGI( self.parent.user_app, @@ -240,13 +233,6 @@ async def __call__( send: Callable[..., Coroutine], ) -> None: """ASGI app for ReactPy web modules.""" - if not self.parent.web_modules_dir: - await asyncio.to_thread( - _logger.info, - "Tried to serve web module without a configured directory.", - ) - return await self.parent.user_app(scope, receive, send) - if not self._static_file_server: self._static_file_server = ServeStaticASGI( self.parent.user_app, diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index 9a21d66d4..57f981818 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import Any, Callable +from typing_extensions import Unpack + from reactpy import html from reactpy.asgi.middleware import ReactPyMiddleware from reactpy.asgi.utils import ( @@ -16,8 +18,7 @@ replace_many, vdom_head_to_html, ) -from reactpy.core.types import VdomDict -from reactpy.types import RootComponentConstructor +from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict _logger = getLogger(__name__) @@ -29,18 +30,13 @@ def __init__( self, root_component: RootComponentConstructor, *, - path_prefix: str = "/reactpy/", - web_modules_dir: Path | None = None, http_headers: dict[str, str | int] | None = None, html_head: VdomDict | None = None, html_lang: str = "en", + **settings: Unpack[ReactPyConfig], ) -> None: - super().__init__( - app=ReactPyApp(self), - root_components=[], - path_prefix=path_prefix, - web_modules_dir=web_modules_dir, - ) + """TODO: Add docstring""" + super().__init__(app=ReactPyApp(self), root_components=[], **settings) self.root_component = root_component self.extra_headers = http_headers or {} self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?") @@ -123,7 +119,7 @@ def match_dispatch_path(self, scope: dict) -> bool: """Method override to remove `dotted_path` from the dispatcher URL.""" return str(scope["path"]) == self.parent.dispatcher_path - def process_index_html(self): + def process_index_html(self) -> None: """Process the index.html and store the results in memory.""" with open(self._index_html_path, encoding="utf-8") as file_handle: cached_index_html = file_handle.read() diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py index 780cec0d2..b4979cdba 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/asgi/utils.py @@ -5,7 +5,8 @@ from importlib import import_module from typing import Any, Callable -from reactpy.core.types import VdomDict +from reactpy._option import Option +from reactpy.types import ReactPyConfig, VdomDict from reactpy.utils import vdom_to_html logger = logging.getLogger(__name__) @@ -93,3 +94,16 @@ async def http_response( await send(start_msg) await send(body_msg) + + +def process_settings(settings: ReactPyConfig): + """Process the settings and return the final configuration.""" + from reactpy import config + + for setting in settings: + config_name = f"REACTPY_{setting.upper()}" + config_object: Option | None = getattr(config, config_name, None) + if config_object: + config_object.set_current(settings[setting]) + else: + raise ValueError(f"Unknown ReactPy setting {setting!r}.") diff --git a/src/reactpy/config.py b/src/reactpy/config.py index 426398208..be6ceb3da 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -33,9 +33,7 @@ def boolean(value: str | bool | int) -> bool: ) -REACTPY_DEBUG_MODE = Option( - "REACTPY_DEBUG_MODE", default=False, validator=boolean, mutable=True -) +REACTPY_DEBUG = Option("REACTPY_DEBUG", default=False, validator=boolean, mutable=True) """Get extra logs and validation checks at the cost of performance. This will enable the following: @@ -44,13 +42,13 @@ def boolean(value: str | bool | int) -> bool: - :data:`REACTPY_CHECK_JSON_ATTRS` """ -REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG_MODE) +REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG) """Checks which ensure VDOM is rendered to spec For more info on the VDOM spec, see here: :ref:`VDOM JSON Schema` """ -REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG_MODE) +REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG) """Checks that all VDOM attributes are JSON serializable The VDOM spec is not able to enforce this on its own since attributes could anything. @@ -73,8 +71,8 @@ def boolean(value: str | bool | int) -> bool: set of publicly available APIs for working with the client. """ -REACTPY_TESTING_DEFAULT_TIMEOUT = Option( - "REACTPY_TESTING_DEFAULT_TIMEOUT", +REACTPY_TESTS_DEFAULT_TIMEOUT = Option( + "REACTPY_TESTS_DEFAULT_TIMEOUT", 10.0, mutable=False, validator=float, @@ -88,3 +86,43 @@ def boolean(value: str | bool | int) -> bool: validator=boolean, ) """Whether to render components asynchronously. This is currently an experimental feature.""" + +REACTPY_RECONNECT_INTERVAL = Option( + "REACTPY_RECONNECT_INTERVAL", + default=750, + mutable=True, + validator=int, +) +"""The interval in milliseconds between reconnection attempts for the websocket server""" + +REACTPY_RECONNECT_MAX_INTERVAL = Option( + "REACTPY_RECONNECT_MAX_INTERVAL", + default=60000, + mutable=True, + validator=int, +) +"""The maximum interval in milliseconds between reconnection attempts for the websocket server""" + +REACTPY_RECONNECT_MAX_RETRIES = Option( + "REACTPY_RECONNECT_MAX_RETRIES", + default=150, + mutable=True, + validator=int, +) +"""The maximum number of reconnection attempts for the websocket server""" + +REACTPY_RECONNECT_BACKOFF_MULTIPLIER = Option( + "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", + default=1.25, + mutable=True, + validator=float, +) +"""The multiplier for exponential backoff between reconnection attempts for the websocket server""" + +REACTPY_PATH_PREFIX = Option( + "REACTPY_PATH_PREFIX", + default="/reactpy/", + mutable=True, + validator=str, +) +"""The prefix for all ReactPy routes""" diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index d5a33d7bc..e72b8c467 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -18,7 +18,7 @@ from typing_extensions import TypeAlias -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG from reactpy.core._life_cycle_hook import current_hook from reactpy.core.types import Context, Key, State, VdomDict from reactpy.types import Connection, Location @@ -204,7 +204,7 @@ def use_debug_value( memo_func = message if callable(message) else lambda: message new = use_memo(memo_func, dependencies) - if REACTPY_DEBUG_MODE.current and old.current != new: + if REACTPY_DEBUG.current and old.current != new: old.current = new logger.debug(f"{current_hook().component} {new}") diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 3fbfca252..1149d65ee 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -32,7 +32,7 @@ from reactpy.config import ( REACTPY_ASYNC_RENDERING, REACTPY_CHECK_VDOM_SPEC, - REACTPY_DEBUG_MODE, + REACTPY_DEBUG, ) from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.types import ( @@ -202,9 +202,7 @@ async def _render_component( new_state.model.current = { "tagName": "", "error": ( - f"{type(error).__name__}: {error}" - if REACTPY_DEBUG_MODE.current - else "" + f"{type(error).__name__}: {error}" if REACTPY_DEBUG.current else "" ), } finally: diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 3a540af59..674462da2 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -8,7 +8,7 @@ from anyio import create_task_group from anyio.abc import TaskGroup -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage logger = getLogger(__name__) @@ -62,7 +62,7 @@ async def _single_outgoing_loop( try: await send(update) except Exception: # nocov - if not REACTPY_DEBUG_MODE.current: + if not REACTPY_DEBUG.current: msg = ( "Failed to send update. More info may be available " "if you enabling debug mode by setting " diff --git a/src/reactpy/core/types.py b/src/reactpy/core/types.py index 29bb407e2..c559936f4 100644 --- a/src/reactpy/core/types.py +++ b/src/reactpy/core/types.py @@ -4,6 +4,7 @@ from collections import namedtuple from collections.abc import Mapping, MutableMapping, Sequence from dataclasses import dataclass +from pathlib import Path from types import TracebackType from typing import ( TYPE_CHECKING, @@ -284,3 +285,15 @@ class Location: If there are no search parameters this should be an empty string """ + + +class ReactPyConfig(TypedDict, total=False): + path_prefix: str + web_modules_dir: Path + reconnect_interval: int + reconnect_max_interval: int + reconnect_max_retries: int + reconnect_backoff_multiplier: float + async_rendering: bool + debug: bool + tests_default_timeout: int diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index dfff32805..231ef4505 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -8,7 +8,7 @@ from fastjsonschema import compile as compile_json_schema from reactpy._warnings import warn -from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG from reactpy.core._f_back import f_module_name from reactpy.core.events import EventHandler, to_event_handler_function from reactpy.core.types import ( @@ -314,7 +314,7 @@ def _is_attributes(value: Any) -> bool: def _is_single_child(value: Any) -> bool: if isinstance(value, (str, Mapping)) or not hasattr(value, "__iter__"): return True - if REACTPY_DEBUG_MODE.current: + if REACTPY_DEBUG.current: _validate_child_key_integrity(value) return False diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py index 5f2e8da84..ebed99434 100644 --- a/src/reactpy/jinja.py +++ b/src/reactpy/jinja.py @@ -5,6 +5,14 @@ from jinja2_simple_tags import StandaloneTag +from reactpy.config import ( + REACTPY_PATH_PREFIX, + REACTPY_RECONNECT_BACKOFF_MULTIPLIER, + REACTPY_RECONNECT_INTERVAL, + REACTPY_RECONNECT_MAX_INTERVAL, + REACTPY_RECONNECT_MAX_RETRIES, +) + try: import_module("jinja2") except ImportError as e: @@ -25,38 +33,28 @@ def render(self, dotted_path: str, *args, **kwargs): class_ = kwargs.pop("class", "") kwargs.pop("key", "") # `key` is effectively useless for the root node - # TODO: Fetch these from ReactPy settings - client_js_path = "/reactpy/static/index.js" - path_prefix = "/reactpy/" - reconnect_interval = 750 - reconnect_max_interval = 60000 - reconnect_max_retries = 150 - reconnect_backoff_multiplier = 1.25 - # Generate the websocket URL - # TODO: This will require rewriting the websocket URL to `reactpy-ws/?` - component_path = f"{dotted_path}/" + append_component_path = f"{dotted_path}/" if kwargs.get("args") is not None: raise ValueError("Cannot specify `args` as a keyword argument") if args: kwargs["args"] = args if kwargs: - component_path += f"?{urllib.parse.urlencode(kwargs)}" + append_component_path += f"?{urllib.parse.urlencode(kwargs)}" # TODO: Turn this into a util function and maybe use it for the standalone version too? return ( f'
' '" ) diff --git a/src/reactpy/logging.py b/src/reactpy/logging.py index f10414cb6..62b507db8 100644 --- a/src/reactpy/logging.py +++ b/src/reactpy/logging.py @@ -2,7 +2,7 @@ import sys from logging.config import dictConfig -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG dictConfig( { @@ -33,7 +33,7 @@ """ReactPy's root logger instance""" -@REACTPY_DEBUG_MODE.subscribe +@REACTPY_DEBUG.subscribe def _set_debug_level(debug: bool) -> None: if debug: ROOT_LOGGER.setLevel("DEBUG") diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 47c3f7790..fd41f25b6 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -9,7 +9,7 @@ from reactpy.asgi import default as default_server from reactpy.asgi.utils import find_available_port -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core.component import component from reactpy.core.hooks import use_callback, use_effect, use_state from reactpy.core.types import ComponentConstructor @@ -51,7 +51,7 @@ def __init__( self.port = port or find_available_port(host) self.mount, self._root_component = _hotswap() self.timeout = ( - REACTPY_TESTING_DEFAULT_TIMEOUT.current if timeout is None else timeout + REACTPY_TESTS_DEFAULT_TIMEOUT.current if timeout is None else timeout ) if app is not None and implementation is None: diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index c1eb18ba5..de5afaba7 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -12,7 +12,7 @@ from typing_extensions import ParamSpec -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook from reactpy.core.events import EventHandler, to_event_handler_function @@ -54,7 +54,7 @@ async def coro(*args: _P.args, **kwargs: _P.kwargs) -> _R: async def until( self, condition: Callable[[_R], bool], - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, delay: float = _DEFAULT_POLL_DELAY, description: str = "condition to be true", ) -> None: @@ -72,7 +72,7 @@ async def until( async def until_is( self, right: _R, - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, delay: float = _DEFAULT_POLL_DELAY, ) -> None: """Wait until the result is identical to the given value""" @@ -86,7 +86,7 @@ async def until_is( async def until_equals( self, right: _R, - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, delay: float = _DEFAULT_POLL_DELAY, ) -> None: """Wait until the result is equal to the given value""" diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index bb0d8351d..a4951ccc8 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -12,7 +12,7 @@ async_playwright, ) -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.testing.backend import BackendFixture from reactpy.types import RootComponentConstructor @@ -73,7 +73,7 @@ async def __aenter__(self) -> DisplayFixture: browser = self._browser self.page = await browser.new_page() - self.page.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000) + self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) if not hasattr(self, "backend"): self.backend = BackendFixture() diff --git a/src/reactpy/types.py b/src/reactpy/types.py index d0f77aab0..735b49452 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -17,6 +17,7 @@ Key, LayoutType, Location, + ReactPyConfig, RootComponentConstructor, State, VdomAttributes, @@ -40,6 +41,7 @@ "Key", "LayoutType", "Location", + "ReactPyConfig", "RootComponentConstructor", "State", "VdomAttributes", diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index c08ec3484..b646422ea 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any, NewType, overload -from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_WEB_MODULES_DIR +from reactpy.config import REACTPY_DEBUG, REACTPY_WEB_MODULES_DIR from reactpy.core.types import ImportSourceDict, VdomDictConstructor from reactpy.core.vdom import make_vdom_constructor from reactpy.web.utils import ( @@ -62,7 +62,7 @@ def module_from_url( if ( resolve_exports if resolve_exports is not None - else REACTPY_DEBUG_MODE.current + else REACTPY_DEBUG.current ) else None ), @@ -128,7 +128,7 @@ def module_from_file( if ( resolve_exports if resolve_exports is not None - else REACTPY_DEBUG_MODE.current + else REACTPY_DEBUG.current ) else None ), @@ -203,7 +203,7 @@ def module_from_string( if ( resolve_exports if resolve_exports is not None - else REACTPY_DEBUG_MODE.current + else REACTPY_DEBUG.current ) else None ), diff --git a/tests/conftest.py b/tests/conftest.py index 17231a2ac..9f1662f1f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from reactpy.config import ( REACTPY_ASYNC_RENDERING, - REACTPY_TESTING_DEFAULT_TIMEOUT, + REACTPY_TESTS_DEFAULT_TIMEOUT, ) from reactpy.testing import ( BackendFixture, @@ -56,7 +56,7 @@ async def server(): @pytest.fixture async def page(browser): pg = await browser.new_page() - pg.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000) + pg.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) try: yield pg finally: diff --git a/tests/test_config.py b/tests/test_config.py index 3428c3e28..f90468f38 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,8 +25,8 @@ def reset_options(): def test_reactpy_debug_mode_toggle(): # just check that nothing breaks - config.REACTPY_DEBUG_MODE.current = True - config.REACTPY_DEBUG_MODE.current = False + config.REACTPY_DEBUG.current = True + config.REACTPY_DEBUG.current = False def test_boolean(): diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 1f444cb68..8fe5fdab1 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -4,7 +4,7 @@ import reactpy from reactpy import html -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.hooks import strictly_equal, use_effect from reactpy.core.layout import Layout @@ -1044,7 +1044,7 @@ def SetStateDuringRender(): assert render_count.current == 2 -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="only logs in debug mode") async def test_use_debug_mode(): set_message = reactpy.Ref() component_hook = HookCatcher() @@ -1071,7 +1071,7 @@ def SomeComponent(): await layout.render() -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="only logs in debug mode") async def test_use_debug_mode_with_factory(): set_message = reactpy.Ref() component_hook = HookCatcher() @@ -1098,7 +1098,7 @@ def SomeComponent(): await layout.render() -@pytest.mark.skipif(REACTPY_DEBUG_MODE.current, reason="logs in debug mode") +@pytest.mark.skipif(REACTPY_DEBUG.current, reason="logs in debug mode") async def test_use_debug_mode_does_not_log_if_not_in_debug_mode(): set_message = reactpy.Ref() diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index f86a80cd2..42c6c9ff1 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -10,7 +10,7 @@ import reactpy from reactpy import html -from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG from reactpy.core.component import component from reactpy.core.hooks import use_effect, use_state from reactpy.core.layout import Layout @@ -156,7 +156,7 @@ def make_child_model(state): @pytest.mark.skipif( - not REACTPY_DEBUG_MODE.current, + not REACTPY_DEBUG.current, reason="errors only reported in debug mode", ) async def test_layout_render_error_has_partial_update_with_error_message(): @@ -207,7 +207,7 @@ def BadChild(): @pytest.mark.skipif( - REACTPY_DEBUG_MODE.current, + REACTPY_DEBUG.current, reason="errors only reported in debug mode", ) async def test_layout_render_error_has_partial_update_without_error_message(): diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 37abad1d2..dccfadb0b 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -4,7 +4,7 @@ from fastjsonschema import JsonSchemaException import reactpy -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG from reactpy.core.events import EventHandler from reactpy.core.types import VdomDict from reactpy.core.vdom import is_vdom, make_vdom_constructor, validate_vdom_json @@ -280,7 +280,7 @@ def test_invalid_vdom(value, error_message_pattern): validate_vdom_json(value) -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="Only warns in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_cannot_verify_keypath_for_genereators(): with pytest.warns(UserWarning) as record: reactpy.vdom("div", (1 for i in range(10))) @@ -292,7 +292,7 @@ def test_warn_cannot_verify_keypath_for_genereators(): ) -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="Only warns in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_dynamic_children_must_have_keys(): with pytest.warns(UserWarning) as record: reactpy.vdom("div", [reactpy.vdom("div")]) @@ -309,7 +309,7 @@ def MyComponent(): assert record[0].message.args[0].startswith("Key not specified for child") -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only checked in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="only checked in debug mode") def test_raise_for_non_json_attrs(): with pytest.raises(TypeError, match="JSON serializable"): reactpy.html.div({"non_json_serializable_object": object()}) diff --git a/tests/tooling/aio.py b/tests/tooling/aio.py index b0f719400..7fe8f03b2 100644 --- a/tests/tooling/aio.py +++ b/tests/tooling/aio.py @@ -3,7 +3,7 @@ from asyncio import Event as _Event from asyncio import wait_for -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT class Event(_Event): @@ -12,5 +12,5 @@ class Event(_Event): async def wait(self, timeout: float | None = None): return await wait_for( super().wait(), - timeout=timeout or REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout=timeout or REACTPY_TESTS_DEFAULT_TIMEOUT.current, ) From 097bbc01d8665d8dc34633ff8f944bc9a47c0a8b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 01:29:40 -0800 Subject: [PATCH 47/84] Rename pathname to path --- docs/source/about/changelog.rst | 1 + src/reactpy/asgi/middleware.py | 2 +- src/reactpy/core/types.py | 5 +++-- tests/test_backend/test_all.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 57cb30bc7..1ca62d0dc 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -29,6 +29,7 @@ Unreleased - :pull:`1256` - Change ``set_state`` comparison method to check equality with ``==`` more consistently. - :pull:`1257` - Add support for rendering ``@component`` children within ``vdom_to_html``. - :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``. +- :pull:`1113` - Renamed the ``use_location`` hook's ``pathname`` attribute to ``path``. - :pull:`1113` - Renamed ``reatpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``. **Removed** diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index d91a62744..be5f60476 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -172,7 +172,7 @@ async def run_dispatcher( connection = Connection( scope=scope, location=Location( - pathname=ws_query_string.get("http_pathname", [""])[0], + path=ws_query_string.get("http_pathname", [""])[0], query_string=ws_query_string.get("http_search", [""])[0], ), carrier=self, diff --git a/src/reactpy/core/types.py b/src/reactpy/core/types.py index c559936f4..5215385e5 100644 --- a/src/reactpy/core/types.py +++ b/src/reactpy/core/types.py @@ -277,8 +277,9 @@ class Location: ``document.location`` object. """ - pathname: str - """the path of the URL for the location""" + path: str + """The URL's path segment. This typically represents the current + HTTP request's path.""" query_string: str """HTTP query string - a '?' followed by the parameters of the URL. diff --git a/tests/test_backend/test_all.py b/tests/test_backend/test_all.py index 6f2a7943b..f295d0ccb 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_backend/test_all.py @@ -124,7 +124,7 @@ def ShowRoute(): Location("/another/something/file.txt", "?key=value"), Location("/another/something/file.txt", "?key1=value1&key2=value2"), ]: - await display.goto(loc.pathname + loc.query_string) + await display.goto(loc.path + loc.query_string) await poll_location.until_equals(loc) From 594e217b50155061a221f4eb948fff0e20e0056b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 01:30:28 -0800 Subject: [PATCH 48/84] Fix typo --- docs/source/about/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 1ca62d0dc..3fa55e322 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -30,7 +30,7 @@ Unreleased - :pull:`1257` - Add support for rendering ``@component`` children within ``vdom_to_html``. - :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``. - :pull:`1113` - Renamed the ``use_location`` hook's ``pathname`` attribute to ``path``. -- :pull:`1113` - Renamed ``reatpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``. +- :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``. **Removed** From b0822d0a9faef5c2a0347f7db7a528f8f34f8919 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 01:33:59 -0800 Subject: [PATCH 49/84] Add missing gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6ea5b5bc7..c5f91d024 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ src/reactpy/static/* # --- Python --- .hatch .venv* -venv +venv* MANIFEST build dist From ca8839498c02cfcd86ba090743cd00f58dafd6c5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 01:38:56 -0800 Subject: [PATCH 50/84] Revert changes to copy_dir.py --- pyproject.toml | 2 +- src/build_scripts/copy_dir.py | 23 ++++++++++------------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5f4b7feec..da35b8f69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ commands = [ 'bun run --cwd "src/js/packages/@reactpy/client" build', 'bun install --cwd "src/js/packages/@reactpy/app"', 'bun run --cwd "src/js/packages/@reactpy/app" build', - 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static" ".*\.js(\.map)?$"', + 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static"', ] artifacts = [] diff --git a/src/build_scripts/copy_dir.py b/src/build_scripts/copy_dir.py index 6c28f8c95..34c87bf4d 100644 --- a/src/build_scripts/copy_dir.py +++ b/src/build_scripts/copy_dir.py @@ -5,39 +5,36 @@ # ruff: noqa: INP001 import logging -import re import shutil import sys from pathlib import Path -def copy_files(source: Path, destination: Path, pattern: str) -> None: +def copy_files(source: Path, destination: Path) -> None: if destination.exists(): shutil.rmtree(destination) destination.mkdir() for file in source.iterdir(): if file.is_file(): - if not pattern or re.match(pattern, file.name): - shutil.copy(file, destination / file.name) + shutil.copy(file, destination / file.name) else: - copy_files(file, destination / file.name, pattern) + copy_files(file, destination / file.name) if __name__ == "__main__": - if len(sys.argv) not in {3, 4}: + if len(sys.argv) != 3: # noqa logging.error( - "Script used incorrectly!\nUsage: python copy_dir.py " + "Script used incorrectly!\nUsage: python copy_dir.py " ) sys.exit(1) root_dir = Path(__file__).parent.parent.parent - _source = Path(root_dir / sys.argv[1]) - _destintation = Path(root_dir / sys.argv[2]) - _pattern = sys.argv[3] if len(sys.argv) == 4 else "" # noqa + src = Path(root_dir / sys.argv[1]) + dest = Path(root_dir / sys.argv[2]) - if not _source.exists(): - logging.error("Source directory %s does not exist", _source) + if not src.exists(): + logging.error("Source directory %s does not exist", src) sys.exit(1) - copy_files(_source, _destintation, _pattern) + copy_files(src, dest) From 451c651980439344710e8d8ee1ac53be267f68ff Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 01:44:34 -0800 Subject: [PATCH 51/84] remove useless parameter in client --- src/js/packages/@reactpy/client/src/mount.tsx | 1 - src/js/packages/@reactpy/client/src/types.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index 74a664251..d7e5567a2 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -20,7 +20,6 @@ export function mountReactPy(props: MountProps) { urls: { componentUrl: componentUrl, jsModulesPath: `${window.location.origin}${props.pathPrefix}modules/`, - queryString: document.location.search, }, reconnectOptions: { interval: props.reconnectInterval || 750, diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index ca79fb753..0792b3586 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -24,7 +24,6 @@ export type CreateReconnectingWebSocketProps = { export type ReactPyUrls = { componentUrl: URL; jsModulesPath: string; - queryString: string; }; export type GenericReactPyClientProps = { From 38e22be6dc4c326b1aab4f74c13628dbe478e093 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 01:46:40 -0800 Subject: [PATCH 52/84] Remove reactpy.core.types --- docs/docs_app/app.py | 2 +- src/reactpy/_html.py | 2 +- src/reactpy/core/_life_cycle_hook.py | 2 +- src/reactpy/core/component.py | 2 +- src/reactpy/core/events.py | 2 +- src/reactpy/core/hooks.py | 3 +- src/reactpy/core/layout.py | 4 +- src/reactpy/core/serve.py | 2 +- src/reactpy/core/types.py | 300 ----------------------- src/reactpy/core/vdom.py | 2 +- src/reactpy/testing/backend.py | 3 +- src/reactpy/types.py | 348 +++++++++++++++++++++++---- src/reactpy/utils.py | 2 +- src/reactpy/web/module.py | 2 +- src/reactpy/widgets.py | 2 +- tests/sample.py | 2 +- tests/test_core/test_layout.py | 2 +- tests/test_core/test_serve.py | 2 +- tests/test_core/test_vdom.py | 2 +- tests/tooling/common.py | 2 +- tests/tooling/layout.py | 2 +- tests/tooling/select.py | 2 +- 22 files changed, 319 insertions(+), 373 deletions(-) delete mode 100644 src/reactpy/core/types.py diff --git a/docs/docs_app/app.py b/docs/docs_app/app.py index 3fe4669ff..393b68439 100644 --- a/docs/docs_app/app.py +++ b/docs/docs_app/app.py @@ -6,7 +6,7 @@ from docs_app.examples import get_normalized_example_name, load_examples from reactpy import component from reactpy.backend.sanic import Options, configure, use_request -from reactpy.core.types import ComponentConstructor +from reactpy.types import ComponentConstructor THIS_DIR = Path(__file__).parent DOCS_DIR = THIS_DIR.parent diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index e2d4f096a..61c6ae77f 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -6,7 +6,7 @@ from reactpy.core.vdom import custom_vdom_constructor, make_vdom_constructor if TYPE_CHECKING: - from reactpy.core.types import ( + from reactpy.types import ( EventHandlerDict, Key, VdomAttributes, diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index 88d3386a8..0b69702f3 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -7,7 +7,7 @@ from anyio import Semaphore from reactpy.core._thread_local import ThreadLocal -from reactpy.core.types import ComponentType, Context, ContextProviderType +from reactpy.types import ComponentType, Context, ContextProviderType T = TypeVar("T") diff --git a/src/reactpy/core/component.py b/src/reactpy/core/component.py index 19eb99a94..d2cfcfe31 100644 --- a/src/reactpy/core/component.py +++ b/src/reactpy/core/component.py @@ -4,7 +4,7 @@ from functools import wraps from typing import Any, Callable -from reactpy.core.types import ComponentType, VdomDict +from reactpy.types import ComponentType, VdomDict def component( diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index e906cefe8..fc6eca04f 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -6,7 +6,7 @@ from anyio import create_task_group -from reactpy.core.types import EventHandlerFunc, EventHandlerType +from reactpy.types import EventHandlerFunc, EventHandlerType @overload diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index e72b8c467..943cc2980 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -20,8 +20,7 @@ from reactpy.config import REACTPY_DEBUG from reactpy.core._life_cycle_hook import current_hook -from reactpy.core.types import Context, Key, State, VdomDict -from reactpy.types import Connection, Location +from reactpy.types import Connection, Context, Key, Location, State, VdomDict from reactpy.utils import Ref if not TYPE_CHECKING: diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 1149d65ee..309644b24 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -35,7 +35,8 @@ REACTPY_DEBUG, ) from reactpy.core._life_cycle_hook import LifeCycleHook -from reactpy.core.types import ( +from reactpy.core.vdom import validate_vdom_json +from reactpy.types import ( ComponentType, Context, EventHandlerDict, @@ -46,7 +47,6 @@ VdomDict, VdomJson, ) -from reactpy.core.vdom import validate_vdom_json from reactpy.utils import Ref logger = getLogger(__name__) diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 674462da2..bbb3d64d7 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -9,7 +9,7 @@ from anyio.abc import TaskGroup from reactpy.config import REACTPY_DEBUG -from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage +from reactpy.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage logger = getLogger(__name__) diff --git a/src/reactpy/core/types.py b/src/reactpy/core/types.py deleted file mode 100644 index 5215385e5..000000000 --- a/src/reactpy/core/types.py +++ /dev/null @@ -1,300 +0,0 @@ -from __future__ import annotations - -import sys -from collections import namedtuple -from collections.abc import Mapping, MutableMapping, Sequence -from dataclasses import dataclass -from pathlib import Path -from types import TracebackType -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Generic, - Literal, - NamedTuple, - Protocol, - TypeVar, - overload, - runtime_checkable, -) - -from typing_extensions import TypeAlias, TypedDict - -CarrierType = TypeVar("CarrierType") - -_Type = TypeVar("_Type") - - -if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11): - - class State(NamedTuple, Generic[_Type]): - value: _Type - set_value: Callable[[_Type | Callable[[_Type], _Type]], None] - -else: # nocov - State = namedtuple("State", ("value", "set_value")) - - -ComponentConstructor = Callable[..., "ComponentType"] -"""Simple function returning a new component""" - -RootComponentConstructor = Callable[[], "ComponentType"] -"""The root component should be constructed by a function accepting no arguments.""" - - -Key: TypeAlias = "str | int" - - -_OwnType = TypeVar("_OwnType") - - -@runtime_checkable -class ComponentType(Protocol): - """The expected interface for all component-like objects""" - - key: Key | None - """An identifier which is unique amongst a component's immediate siblings""" - - type: Any - """The function or class defining the behavior of this component - - This is used to see if two component instances share the same definition. - """ - - def render(self) -> VdomDict | ComponentType | str | None: - """Render the component's view model.""" - - -_Render_co = TypeVar("_Render_co", covariant=True) -_Event_contra = TypeVar("_Event_contra", contravariant=True) - - -@runtime_checkable -class LayoutType(Protocol[_Render_co, _Event_contra]): - """Renders and delivers, updates to views and events to handlers, respectively""" - - async def render(self) -> _Render_co: - """Render an update to a view""" - - async def deliver(self, event: _Event_contra) -> None: - """Relay an event to its respective handler""" - - async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: - """Prepare the layout for its first render""" - - async def __aexit__( - self, - exc_type: type[Exception], - exc_value: Exception, - traceback: TracebackType, - ) -> bool | None: - """Clean up the view after its final render""" - - -VdomAttributes = Mapping[str, Any] -"""Describes the attributes of a :class:`VdomDict`""" - -VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" -"""A single child element of a :class:`VdomDict`""" - -VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" -"""Describes a series of :class:`VdomChild` elements""" - - -class _VdomDictOptional(TypedDict, total=False): - key: Key | None - children: Sequence[ComponentType | VdomChild] - attributes: VdomAttributes - eventHandlers: EventHandlerDict - importSource: ImportSourceDict - - -class _VdomDictRequired(TypedDict, total=True): - tagName: str - - -class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A :ref:`VDOM` dictionary""" - - -class ImportSourceDict(TypedDict): - source: str - fallback: Any - sourceType: str - unmountBeforeUpdate: bool - - -class _OptionalVdomJson(TypedDict, total=False): - key: Key - error: str - children: list[Any] - attributes: dict[str, Any] - eventHandlers: dict[str, _JsonEventTarget] - importSource: _JsonImportSource - - -class _RequiredVdomJson(TypedDict, total=True): - tagName: str - - -class VdomJson(_RequiredVdomJson, _OptionalVdomJson): - """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" - - -class _JsonEventTarget(TypedDict): - target: str - preventDefault: bool - stopPropagation: bool - - -class _JsonImportSource(TypedDict): - source: str - fallback: Any - - -EventHandlerMapping = Mapping[str, "EventHandlerType"] -"""A generic mapping between event names to their handlers""" - -EventHandlerDict: TypeAlias = "dict[str, EventHandlerType]" -"""A dict mapping between event names to their handlers""" - - -class EventHandlerFunc(Protocol): - """A coroutine which can handle event data""" - - async def __call__(self, data: Sequence[Any]) -> None: ... - - -@runtime_checkable -class EventHandlerType(Protocol): - """Defines a handler for some event""" - - prevent_default: bool - """Whether to block the event from propagating further up the DOM""" - - stop_propagation: bool - """Stops the default action associate with the event from taking place.""" - - function: EventHandlerFunc - """A coroutine which can respond to an event and its data""" - - target: str | None - """Typically left as ``None`` except when a static target is useful. - - When testing, it may be useful to specify a static target ID so events can be - triggered programmatically. - - .. note:: - - When ``None``, it is left to a :class:`LayoutType` to auto generate a unique ID. - """ - - -class VdomDictConstructor(Protocol): - """Standard function for constructing a :class:`VdomDict`""" - - @overload - def __call__( - self, attributes: VdomAttributes, *children: VdomChildren - ) -> VdomDict: ... - - @overload - def __call__(self, *children: VdomChildren) -> VdomDict: ... - - @overload - def __call__( - self, *attributes_and_children: VdomAttributes | VdomChildren - ) -> VdomDict: ... - - -class LayoutUpdateMessage(TypedDict): - """A message describing an update to a layout""" - - type: Literal["layout-update"] - """The type of message""" - path: str - """JSON Pointer path to the model element being updated""" - model: VdomJson - """The model to assign at the given JSON Pointer path""" - - -class LayoutEventMessage(TypedDict): - """Message describing an event originating from an element in the layout""" - - type: Literal["layout-event"] - """The type of message""" - target: str - """The ID of the event handler.""" - data: Sequence[Any] - """A list of event data passed to the event handler.""" - - -class Context(Protocol[_Type]): - """Returns a :class:`ContextProvider` component""" - - def __call__( - self, - *children: Any, - value: _Type = ..., - key: Key | None = ..., - ) -> ContextProviderType[_Type]: ... - - -class ContextProviderType(ComponentType, Protocol[_Type]): - """A component which provides a context value to its children""" - - type: Context[_Type] - """The context type""" - - @property - def value(self) -> _Type: - "Current context value" - - -@dataclass -class Connection(Generic[CarrierType]): - """Represents a connection with a client""" - - scope: MutableMapping[str, Any] - """An ASGI scope dictionary""" - - location: Location - """The current location (URL)""" - - carrier: CarrierType - """How the connection is mediated. For example, a request or websocket. - - This typically depends on the backend implementation. - """ - - -@dataclass -class Location: - """Represents the current location (URL) - - Analogous to, but not necessarily identical to, the client-side - ``document.location`` object. - """ - - path: str - """The URL's path segment. This typically represents the current - HTTP request's path.""" - - query_string: str - """HTTP query string - a '?' followed by the parameters of the URL. - - If there are no search parameters this should be an empty string - """ - - -class ReactPyConfig(TypedDict, total=False): - path_prefix: str - web_modules_dir: Path - reconnect_interval: int - reconnect_max_interval: int - reconnect_max_retries: int - reconnect_backoff_multiplier: float - async_rendering: bool - debug: bool - tests_default_timeout: int diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 231ef4505..77b173f8f 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -11,7 +11,7 @@ from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG from reactpy.core._f_back import f_module_name from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.core.types import ( +from reactpy.types import ( ComponentType, EventHandlerDict, EventHandlerType, diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index fd41f25b6..766f893fd 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -12,13 +12,12 @@ from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core.component import component from reactpy.core.hooks import use_callback, use_effect, use_state -from reactpy.core.types import ComponentConstructor from reactpy.testing.logs import ( LogAssertionError, capture_reactpy_logs, list_logged_exceptions, ) -from reactpy.types import BackendType +from reactpy.types import BackendType, ComponentConstructor from reactpy.utils import Ref diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 735b49452..5215385e5 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1,52 +1,300 @@ -"""Exports common types from: - -- :mod:`reactpy.core.types` -""" - -from reactpy.core.component import Component -from reactpy.core.types import ( - ComponentConstructor, - ComponentType, - Connection, - Context, - EventHandlerDict, - EventHandlerFunc, - EventHandlerMapping, - EventHandlerType, - ImportSourceDict, - Key, - LayoutType, - Location, - ReactPyConfig, - RootComponentConstructor, - State, - VdomAttributes, - VdomChild, - VdomChildren, - VdomDict, - VdomJson, +from __future__ import annotations + +import sys +from collections import namedtuple +from collections.abc import Mapping, MutableMapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Generic, + Literal, + NamedTuple, + Protocol, + TypeVar, + overload, + runtime_checkable, ) -__all__ = [ - "Component", - "ComponentConstructor", - "ComponentType", - "Connection", - "Context", - "EventHandlerDict", - "EventHandlerFunc", - "EventHandlerMapping", - "EventHandlerType", - "ImportSourceDict", - "Key", - "LayoutType", - "Location", - "ReactPyConfig", - "RootComponentConstructor", - "State", - "VdomAttributes", - "VdomChild", - "VdomChildren", - "VdomDict", - "VdomJson", -] +from typing_extensions import TypeAlias, TypedDict + +CarrierType = TypeVar("CarrierType") + +_Type = TypeVar("_Type") + + +if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11): + + class State(NamedTuple, Generic[_Type]): + value: _Type + set_value: Callable[[_Type | Callable[[_Type], _Type]], None] + +else: # nocov + State = namedtuple("State", ("value", "set_value")) + + +ComponentConstructor = Callable[..., "ComponentType"] +"""Simple function returning a new component""" + +RootComponentConstructor = Callable[[], "ComponentType"] +"""The root component should be constructed by a function accepting no arguments.""" + + +Key: TypeAlias = "str | int" + + +_OwnType = TypeVar("_OwnType") + + +@runtime_checkable +class ComponentType(Protocol): + """The expected interface for all component-like objects""" + + key: Key | None + """An identifier which is unique amongst a component's immediate siblings""" + + type: Any + """The function or class defining the behavior of this component + + This is used to see if two component instances share the same definition. + """ + + def render(self) -> VdomDict | ComponentType | str | None: + """Render the component's view model.""" + + +_Render_co = TypeVar("_Render_co", covariant=True) +_Event_contra = TypeVar("_Event_contra", contravariant=True) + + +@runtime_checkable +class LayoutType(Protocol[_Render_co, _Event_contra]): + """Renders and delivers, updates to views and events to handlers, respectively""" + + async def render(self) -> _Render_co: + """Render an update to a view""" + + async def deliver(self, event: _Event_contra) -> None: + """Relay an event to its respective handler""" + + async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: + """Prepare the layout for its first render""" + + async def __aexit__( + self, + exc_type: type[Exception], + exc_value: Exception, + traceback: TracebackType, + ) -> bool | None: + """Clean up the view after its final render""" + + +VdomAttributes = Mapping[str, Any] +"""Describes the attributes of a :class:`VdomDict`""" + +VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" +"""A single child element of a :class:`VdomDict`""" + +VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" +"""Describes a series of :class:`VdomChild` elements""" + + +class _VdomDictOptional(TypedDict, total=False): + key: Key | None + children: Sequence[ComponentType | VdomChild] + attributes: VdomAttributes + eventHandlers: EventHandlerDict + importSource: ImportSourceDict + + +class _VdomDictRequired(TypedDict, total=True): + tagName: str + + +class VdomDict(_VdomDictRequired, _VdomDictOptional): + """A :ref:`VDOM` dictionary""" + + +class ImportSourceDict(TypedDict): + source: str + fallback: Any + sourceType: str + unmountBeforeUpdate: bool + + +class _OptionalVdomJson(TypedDict, total=False): + key: Key + error: str + children: list[Any] + attributes: dict[str, Any] + eventHandlers: dict[str, _JsonEventTarget] + importSource: _JsonImportSource + + +class _RequiredVdomJson(TypedDict, total=True): + tagName: str + + +class VdomJson(_RequiredVdomJson, _OptionalVdomJson): + """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" + + +class _JsonEventTarget(TypedDict): + target: str + preventDefault: bool + stopPropagation: bool + + +class _JsonImportSource(TypedDict): + source: str + fallback: Any + + +EventHandlerMapping = Mapping[str, "EventHandlerType"] +"""A generic mapping between event names to their handlers""" + +EventHandlerDict: TypeAlias = "dict[str, EventHandlerType]" +"""A dict mapping between event names to their handlers""" + + +class EventHandlerFunc(Protocol): + """A coroutine which can handle event data""" + + async def __call__(self, data: Sequence[Any]) -> None: ... + + +@runtime_checkable +class EventHandlerType(Protocol): + """Defines a handler for some event""" + + prevent_default: bool + """Whether to block the event from propagating further up the DOM""" + + stop_propagation: bool + """Stops the default action associate with the event from taking place.""" + + function: EventHandlerFunc + """A coroutine which can respond to an event and its data""" + + target: str | None + """Typically left as ``None`` except when a static target is useful. + + When testing, it may be useful to specify a static target ID so events can be + triggered programmatically. + + .. note:: + + When ``None``, it is left to a :class:`LayoutType` to auto generate a unique ID. + """ + + +class VdomDictConstructor(Protocol): + """Standard function for constructing a :class:`VdomDict`""" + + @overload + def __call__( + self, attributes: VdomAttributes, *children: VdomChildren + ) -> VdomDict: ... + + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... + + @overload + def __call__( + self, *attributes_and_children: VdomAttributes | VdomChildren + ) -> VdomDict: ... + + +class LayoutUpdateMessage(TypedDict): + """A message describing an update to a layout""" + + type: Literal["layout-update"] + """The type of message""" + path: str + """JSON Pointer path to the model element being updated""" + model: VdomJson + """The model to assign at the given JSON Pointer path""" + + +class LayoutEventMessage(TypedDict): + """Message describing an event originating from an element in the layout""" + + type: Literal["layout-event"] + """The type of message""" + target: str + """The ID of the event handler.""" + data: Sequence[Any] + """A list of event data passed to the event handler.""" + + +class Context(Protocol[_Type]): + """Returns a :class:`ContextProvider` component""" + + def __call__( + self, + *children: Any, + value: _Type = ..., + key: Key | None = ..., + ) -> ContextProviderType[_Type]: ... + + +class ContextProviderType(ComponentType, Protocol[_Type]): + """A component which provides a context value to its children""" + + type: Context[_Type] + """The context type""" + + @property + def value(self) -> _Type: + "Current context value" + + +@dataclass +class Connection(Generic[CarrierType]): + """Represents a connection with a client""" + + scope: MutableMapping[str, Any] + """An ASGI scope dictionary""" + + location: Location + """The current location (URL)""" + + carrier: CarrierType + """How the connection is mediated. For example, a request or websocket. + + This typically depends on the backend implementation. + """ + + +@dataclass +class Location: + """Represents the current location (URL) + + Analogous to, but not necessarily identical to, the client-side + ``document.location`` object. + """ + + path: str + """The URL's path segment. This typically represents the current + HTTP request's path.""" + + query_string: str + """HTTP query string - a '?' followed by the parameters of the URL. + + If there are no search parameters this should be an empty string + """ + + +class ReactPyConfig(TypedDict, total=False): + path_prefix: str + web_modules_dir: Path + reconnect_interval: int + reconnect_max_interval: int + reconnect_max_retries: int + reconnect_backoff_multiplier: float + async_rendering: bool + debug: bool + tests_default_timeout: int diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 77df473fb..2638ac0c3 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -8,8 +8,8 @@ from lxml import etree from lxml.html import fromstring, tostring -from reactpy.core.types import ComponentType, VdomDict from reactpy.core.vdom import vdom as make_vdom +from reactpy.types import ComponentType, VdomDict _RefValue = TypeVar("_RefValue") _ModelTransform = Callable[[VdomDict], Any] diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index b646422ea..5148c9669 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -8,8 +8,8 @@ from typing import Any, NewType, overload from reactpy.config import REACTPY_DEBUG, REACTPY_WEB_MODULES_DIR -from reactpy.core.types import ImportSourceDict, VdomDictConstructor from reactpy.core.vdom import make_vdom_constructor +from reactpy.types import ImportSourceDict, VdomDictConstructor from reactpy.web.utils import ( module_name_suffix, resolve_module_exports_from_file, diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index 92676b92f..bc559c15d 100644 --- a/src/reactpy/widgets.py +++ b/src/reactpy/widgets.py @@ -7,7 +7,7 @@ import reactpy from reactpy._html import html from reactpy._warnings import warn -from reactpy.core.types import ComponentConstructor, VdomDict +from reactpy.types import ComponentConstructor, VdomDict def image( diff --git a/tests/sample.py b/tests/sample.py index 8509c773d..fe5dfde07 100644 --- a/tests/sample.py +++ b/tests/sample.py @@ -2,7 +2,7 @@ from reactpy import html from reactpy.core.component import component -from reactpy.core.types import VdomDict +from reactpy.types import VdomDict @component diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 42c6c9ff1..01472edd2 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -14,7 +14,6 @@ from reactpy.core.component import component from reactpy.core.hooks import use_effect, use_state from reactpy.core.layout import Layout -from reactpy.core.types import State from reactpy.testing import ( HookCatcher, StaticEventHandler, @@ -22,6 +21,7 @@ capture_reactpy_logs, ) from reactpy.testing.common import poll +from reactpy.types import State from reactpy.utils import Ref from tests.tooling import select from tests.tooling.aio import Event diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index bae3c1e01..8dee3e19e 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -10,8 +10,8 @@ from reactpy.core.hooks import use_effect from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.core.types import LayoutUpdateMessage from reactpy.testing import StaticEventHandler +from reactpy.types import LayoutUpdateMessage from tests.tooling.aio import Event from tests.tooling.common import event_message diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index dccfadb0b..0f3cdafc4 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -6,8 +6,8 @@ import reactpy from reactpy.config import REACTPY_DEBUG from reactpy.core.events import EventHandler -from reactpy.core.types import VdomDict from reactpy.core.vdom import is_vdom, make_vdom_constructor, validate_vdom_json +from reactpy.types import VdomDict FAKE_EVENT_HANDLER = EventHandler(lambda data: None) FAKE_EVENT_HANDLER_DICT = {"on_event": FAKE_EVENT_HANDLER} diff --git a/tests/tooling/common.py b/tests/tooling/common.py index 1803b8aed..f7b7933f0 100644 --- a/tests/tooling/common.py +++ b/tests/tooling/common.py @@ -1,7 +1,7 @@ import os from typing import Any -from reactpy.core.types import LayoutEventMessage, LayoutUpdateMessage +from reactpy.types import LayoutEventMessage, LayoutUpdateMessage GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") DEFAULT_TYPE_DELAY = ( diff --git a/tests/tooling/layout.py b/tests/tooling/layout.py index fe78684fe..034770bf6 100644 --- a/tests/tooling/layout.py +++ b/tests/tooling/layout.py @@ -8,7 +8,7 @@ from jsonpointer import set_pointer from reactpy.core.layout import Layout -from reactpy.core.types import VdomJson +from reactpy.types import VdomJson from tests.tooling.common import event_message logger = logging.getLogger(__name__) diff --git a/tests/tooling/select.py b/tests/tooling/select.py index cf7a9c004..2a0f170b8 100644 --- a/tests/tooling/select.py +++ b/tests/tooling/select.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Callable -from reactpy.core.types import VdomJson +from reactpy.types import VdomJson Selector = Callable[[VdomJson, "ElementInfo"], bool] From 7cd7388db64ee2870493fa90b3718dfa4cd146de Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 01:58:30 -0800 Subject: [PATCH 53/84] self review --- docs/source/about/changelog.rst | 1 + src/reactpy/jinja.py | 3 ++- src/reactpy/types.py | 5 +---- tests/test_backend/test_common.py | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 3fa55e322..af3f885c4 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -40,6 +40,7 @@ Unreleased - :pull:`1255` - Removed ``reactpy.html._`` function. Use ``html.fragment`` instead. - :pull:`1113` - Removed ``reactpy.run``. See the documentation for the new method to run ReactPy applications. - :pull:`1113` - Removed ``reactpy.backend.*``. See the documentation for the new method to run ReactPy applications. +- :pull:`1113` - Removed ``reactpy.core.types`` module. Use ``reactpy.types`` instead. - :pull:`1113` - All backend related installation extras (such as ``pip install reactpy[starlette]``) have been removed. - :pull:`1113` - Removed deprecated function ``module_from_template``. diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py index ebed99434..356254bb0 100644 --- a/src/reactpy/jinja.py +++ b/src/reactpy/jinja.py @@ -23,7 +23,8 @@ class ReactPyTemplateTag(StandaloneTag): - """This allows enables a `component` tag to be used in any Jinja2 rendering context.""" + """This allows enables a `component` tag to be used in any Jinja2 rendering context, + as long as this template tag is registered as a Jinja2 extension.""" safe_output = True tags: ClassVar[set[str]] = {"component"} diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 5215385e5..3626669a7 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -26,7 +26,7 @@ _Type = TypeVar("_Type") -if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11): +if TYPE_CHECKING or sys.version_info >= (3, 11): class State(NamedTuple, Generic[_Type]): value: _Type @@ -46,9 +46,6 @@ class State(NamedTuple, Generic[_Type]): Key: TypeAlias = "str | int" -_OwnType = TypeVar("_OwnType") - - @runtime_checkable class ComponentType(Protocol): """The expected interface for all component-like objects""" diff --git a/tests/test_backend/test_common.py b/tests/test_backend/test_common.py index 4e8b8c4f2..2b83e1cae 100644 --- a/tests/test_backend/test_common.py +++ b/tests/test_backend/test_common.py @@ -25,7 +25,7 @@ def test_common_options_url_prefix_starts_with_slash(): ], ) def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Unsafe path"): traversal_safe_path(tmp_path, *bad_path.split("/")) From 7650f13ca410771fbe16a0d108ad0026eb4b290b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 04:39:06 -0800 Subject: [PATCH 54/84] Add missing docstrings --- src/reactpy/asgi/middleware.py | 9 ++++++++- src/reactpy/asgi/standalone.py | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index be5f60476..4d9342333 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -38,7 +38,14 @@ def __init__( **settings: Unpack[ReactPyConfig], ) -> None: """Configure the ASGI app. Anything initialized in this method will be shared across all future requests. - TODO: Add types in docstring""" + + Parameters: + app: The ASGI application to serve when the request does not match a ReactPy route. + root_components: + A list, set, or tuple containing the dotted path of your root components. This dotted path + must be valid to Python's import system. + settings: Global ReactPy configuration settings that affect behavior and performance. + """ # Process global settings process_settings(settings) diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index 57f981818..ead65612c 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -35,7 +35,15 @@ def __init__( html_lang: str = "en", **settings: Unpack[ReactPyConfig], ) -> None: - """TODO: Add docstring""" + """ReactPy's standalone ASGI application. + + Parameters: + root_component: The root component to render. This component is assumed to be a single page application. + http_headers: Additional headers to include in the HTTP response for the base HTML document. + html_head: Additional head elements to include in the HTML response. + html_lang: The language of the HTML document. + settings: Global ReactPy configuration settings that affect behavior and performance. + """ super().__init__(app=ReactPyApp(self), root_components=[], **settings) self.root_component = root_component self.extra_headers = http_headers or {} From 1778d1c327651355c5667bcd257807b2a82b7487 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 05:10:09 -0800 Subject: [PATCH 55/84] render_reactpy_template func --- src/reactpy/asgi/standalone.py | 38 +++++++++++--------------------- src/reactpy/asgi/utils.py | 9 -------- src/reactpy/jinja.py | 25 ++------------------- src/reactpy/templates/index.html | 21 ------------------ src/reactpy/utils.py | 21 ++++++++++++++++++ 5 files changed, 36 insertions(+), 78 deletions(-) delete mode 100644 src/reactpy/templates/index.html diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index ead65612c..e562231f9 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -1,8 +1,8 @@ import hashlib -import os import re from collections.abc import Coroutine from dataclasses import dataclass +from datetime import datetime from email.utils import formatdate from logging import getLogger from pathlib import Path @@ -12,13 +12,9 @@ from reactpy import html from reactpy.asgi.middleware import ReactPyMiddleware -from reactpy.asgi.utils import ( - dict_to_byte_list, - http_response, - replace_many, - vdom_head_to_html, -) +from reactpy.asgi.utils import dict_to_byte_list, http_response, vdom_head_to_html from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict +from reactpy.utils import render_reactpy_template _logger = getLogger(__name__) @@ -129,24 +125,16 @@ def match_dispatch_path(self, scope: dict) -> bool: def process_index_html(self) -> None: """Process the index.html and store the results in memory.""" - with open(self._index_html_path, encoding="utf-8") as file_handle: - cached_index_html = file_handle.read() - - self._cached_index_html = replace_many( - cached_index_html, - { - 'from "index.ts"': f'from "{self.parent.static_path}index.js"', - '': f'', - "": vdom_head_to_html(self.parent.html_head), - "{path_prefix}": self.parent.path_prefix, - "{reconnect_interval}": "750", - "{reconnect_max_interval}": "60000", - "{reconnect_max_retries}": "150", - "{reconnect_backoff_multiplier}": "1.25", - }, + self._cached_index_html = ( + "" + f'' + f"{vdom_head_to_html(self.parent.html_head)}" + "" + f'
' + f"{render_reactpy_template('app', '', '')}" + "" + "" ) self._etag = f'"{hashlib.md5(self._cached_index_html.encode(), usedforsecurity=False).hexdigest()}"' - self._last_modified = formatdate( - os.stat(self._index_html_path).st_mtime, usegmt=True - ) + self._last_modified = formatdate(datetime.now().timestamp(), usegmt=True) diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py index b4979cdba..4ecf6fa2d 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/asgi/utils.py @@ -46,15 +46,6 @@ def check_path(url_path: str) -> str: return "" -def replace_many(content: str, replacements: dict[str, str]) -> str: - """Find and replace several key-values, and throw and error if the substring is not found.""" - for key, value in replacements.items(): - if key not in content: - raise ValueError(f"Could not find {key} in content") - content = content.replace(key, value) - return content - - def dict_to_byte_list( data: dict[str, str | int], ) -> list[tuple[bytes, bytes]]: diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py index 356254bb0..6591c37af 100644 --- a/src/reactpy/jinja.py +++ b/src/reactpy/jinja.py @@ -5,13 +5,7 @@ from jinja2_simple_tags import StandaloneTag -from reactpy.config import ( - REACTPY_PATH_PREFIX, - REACTPY_RECONNECT_BACKOFF_MULTIPLIER, - REACTPY_RECONNECT_INTERVAL, - REACTPY_RECONNECT_MAX_INTERVAL, - REACTPY_RECONNECT_MAX_RETRIES, -) +from reactpy.utils import render_reactpy_template try: import_module("jinja2") @@ -43,19 +37,4 @@ def render(self, dotted_path: str, *args, **kwargs): if kwargs: append_component_path += f"?{urllib.parse.urlencode(kwargs)}" - # TODO: Turn this into a util function and maybe use it for the standalone version too? - return ( - f'
' - '" - ) + return render_reactpy_template(uuid, class_, append_component_path) diff --git a/src/reactpy/templates/index.html b/src/reactpy/templates/index.html deleted file mode 100644 index 76a9585c2..000000000 --- a/src/reactpy/templates/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - -
- - - - diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 2638ac0c3..2d2358070 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -8,6 +8,7 @@ from lxml import etree from lxml.html import fromstring, tostring +from reactpy import config from reactpy.core.vdom import vdom as make_vdom from reactpy.types import ComponentType, VdomDict @@ -313,3 +314,23 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: # Pattern for delimitting camelCase names (e.g. camelCase to camel-case) _CAMEL_CASE_SUB_PATTERN = re.compile(r"(? str: + return ( + f'
' + '" + ) From 101a23b36717b1df9f3c19402b4e13e57e924ee7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 05:10:43 -0800 Subject: [PATCH 56/84] if-modified-since matching --- src/reactpy/asgi/standalone.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index e562231f9..ff4f18e4f 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -102,7 +102,10 @@ async def __call__( ) # Browser already has the content cached - if request_headers.get(b"if-none-match") == self._etag.encode(): + if ( + request_headers.get(b"if-none-match") == self._etag.encode() + or request_headers.get(b"if-modified-since") == self._last_modified.encode() + ): response_headers.pop("content-length") return await http_response( send=send, From ebd2676e26549e68cde2ef375e88e8b52725da7a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 05:14:48 -0800 Subject: [PATCH 57/84] rename to render_mount_template --- src/reactpy/asgi/standalone.py | 4 ++-- src/reactpy/jinja.py | 4 ++-- src/reactpy/utils.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index ff4f18e4f..d6d1fe1ba 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -14,7 +14,7 @@ from reactpy.asgi.middleware import ReactPyMiddleware from reactpy.asgi.utils import dict_to_byte_list, http_response, vdom_head_to_html from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict -from reactpy.utils import render_reactpy_template +from reactpy.utils import render_mount_template _logger = getLogger(__name__) @@ -134,7 +134,7 @@ def process_index_html(self) -> None: f"{vdom_head_to_html(self.parent.html_head)}" "" f'
' - f"{render_reactpy_template('app', '', '')}" + f"{render_mount_template('app', '', '')}" "" "" ) diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py index 6591c37af..2676dc969 100644 --- a/src/reactpy/jinja.py +++ b/src/reactpy/jinja.py @@ -5,7 +5,7 @@ from jinja2_simple_tags import StandaloneTag -from reactpy.utils import render_reactpy_template +from reactpy.utils import render_mount_template try: import_module("jinja2") @@ -37,4 +37,4 @@ def render(self, dotted_path: str, *args, **kwargs): if kwargs: append_component_path += f"?{urllib.parse.urlencode(kwargs)}" - return render_reactpy_template(uuid, class_, append_component_path) + return render_mount_template(uuid, class_, append_component_path) diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 2d2358070..30495d6c1 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -316,7 +316,7 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: _CAMEL_CASE_SUB_PATTERN = re.compile(r"(? str: return ( From fbda6c5f4beb17edc152fe534c1139335242c72f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 05:22:54 -0800 Subject: [PATCH 58/84] Remove fluff from ReactPyTemplateTag --- src/reactpy/jinja.py | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py index 2676dc969..9c16d54f3 100644 --- a/src/reactpy/jinja.py +++ b/src/reactpy/jinja.py @@ -1,5 +1,3 @@ -import urllib.parse -from importlib import import_module from typing import ClassVar from uuid import uuid4 @@ -7,14 +5,6 @@ from reactpy.utils import render_mount_template -try: - import_module("jinja2") -except ImportError as e: - raise ImportError( - "The Jinja2 library is required to use the ReactPy template tag. " - "Please install it via `pip install reactpy[jinja]`." - ) from e - class ReactPyTemplateTag(StandaloneTag): """This allows enables a `component` tag to be used in any Jinja2 rendering context, @@ -23,18 +13,9 @@ class ReactPyTemplateTag(StandaloneTag): safe_output = True tags: ClassVar[set[str]] = {"component"} - def render(self, dotted_path: str, *args, **kwargs): - uuid = uuid4().hex - class_ = kwargs.pop("class", "") - kwargs.pop("key", "") # `key` is effectively useless for the root node - - # Generate the websocket URL - append_component_path = f"{dotted_path}/" - if kwargs.get("args") is not None: - raise ValueError("Cannot specify `args` as a keyword argument") - if args: - kwargs["args"] = args - if kwargs: - append_component_path += f"?{urllib.parse.urlencode(kwargs)}" - - return render_mount_template(uuid, class_, append_component_path) + def render(self, dotted_path: str, **kwargs): + return render_mount_template( + element_id=uuid4().hex, + class_=kwargs.pop("class", ""), + append_component_path=f"{dotted_path}/", + ) From df03c9d9d29be5fc35a7a180484a6e74a3cdd614 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 05:24:03 -0800 Subject: [PATCH 59/84] Remove obsolete attributes from ReactPyApp --- src/reactpy/asgi/standalone.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index d6d1fe1ba..88eec81bb 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -57,8 +57,6 @@ class ReactPyApp: _cached_index_html = "" _etag = "" _last_modified = "" - _templates_dir = Path(__file__).parent.parent / "templates" - _index_html_path = _templates_dir / "index.html" async def __call__( self, From ff261585bb9de3b49b7003a7d270d0b34e5b29ac Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 05:26:49 -0800 Subject: [PATCH 60/84] Simplier send func for ComponentDispatchApp --- src/reactpy/asgi/middleware.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index 4d9342333..917f1c67d 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -68,11 +68,6 @@ def __init__( self.web_modules_dir = config.REACTPY_WEB_MODULES_DIR.current self.static_dir = Path(__file__).parent.parent / "static" - # Sub-applications - self.component_dispatch_app = ComponentDispatchApp(parent=self) - self.static_file_app = StaticFileApp(parent=self) - self.web_modules_app = WebModuleApp(parent=self) - # Validate the configuration reason = check_path(self.path_prefix) if reason: @@ -82,6 +77,11 @@ def __init__( f"Web modules directory {self.web_modules_dir} does not exist." ) + # Initialize the sub-applications + self.component_dispatch_app = ComponentDispatchApp(parent=self) + self.static_file_app = StaticFileApp(parent=self) + self.web_modules_app = WebModuleApp(parent=self) + async def __call__( self, scope: dict[str, Any], @@ -188,7 +188,9 @@ async def run_dispatcher( # Start the ReactPy component rendering loop await serve_layout( Layout(ConnectionContext(component(), value=connection)), # type: ignore - self._send_json(send), + lambda msg: send( + {"type": "websocket.send", "text": orjson.dumps(msg).decode()} + ), recv_queue.get, ) @@ -196,15 +198,6 @@ async def run_dispatcher( except Exception as error: await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}") - @staticmethod - def _send_json(send: Callable) -> Callable[..., Coroutine]: - """Use orjson to send JSON over an ASGI websocket.""" - - async def _send(value: Any) -> None: - await send({"type": "websocket.send", "text": orjson.dumps(value).decode()}) - - return _send - @dataclass class StaticFileApp: From 3fe68d2150fb92a98be6312a8c8ad4195c3cb4a1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:17:03 -0800 Subject: [PATCH 61/84] Bump to 2.0.0a0 --- src/reactpy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 3f0bd42e7..a184905a6 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -24,7 +24,7 @@ from reactpy.utils import Ref, html_to_vdom, vdom_to_html __author__ = "The Reactive Python Team" -__version__ = "1.1.0" +__version__ = "2.0.0a0" __all__ = [ "Layout", From 3d2941ef82684469a1377f3f9364f36c524b784d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:16:06 -0800 Subject: [PATCH 62/84] fix query string parsing --- src/reactpy/asgi/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index 917f1c67d..afaddd2fb 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -180,7 +180,7 @@ async def run_dispatcher( scope=scope, location=Location( path=ws_query_string.get("http_pathname", [""])[0], - query_string=ws_query_string.get("http_search", [""])[0], + query_string=ws_query_string.get("http_query_string", [""])[0], ), carrier=self, ) From f7b15f498e17dbd5eef751e68986baf77bdb1a43 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:17:47 -0800 Subject: [PATCH 63/84] Fix backend fixture --- src/reactpy/testing/backend.py | 57 +++++++++++----------------------- src/reactpy/testing/display.py | 12 ++----- src/reactpy/testing/utils.py | 25 +++++++++++++++ 3 files changed, 45 insertions(+), 49 deletions(-) create mode 100644 src/reactpy/testing/utils.py diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 766f893fd..9d0108028 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -2,13 +2,16 @@ import asyncio import logging -from contextlib import AsyncExitStack, suppress +from collections.abc import Coroutine +from contextlib import AsyncExitStack +from threading import Thread from types import TracebackType from typing import Any, Callable from urllib.parse import urlencode, urlunparse -from reactpy.asgi import default as default_server -from reactpy.asgi.utils import find_available_port +import uvicorn + +from reactpy.asgi.standalone import ReactPy from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core.component import component from reactpy.core.hooks import use_callback, use_effect, use_state @@ -17,7 +20,8 @@ capture_reactpy_logs, list_logged_exceptions, ) -from reactpy.types import BackendType, ComponentConstructor +from reactpy.testing.utils import find_available_port +from reactpy.types import ComponentConstructor from reactpy.utils import Ref @@ -39,11 +43,9 @@ class BackendFixture: def __init__( self, + app: Callable[..., Coroutine] | None = None, host: str = "127.0.0.1", port: int | None = None, - app: Any | None = None, - implementation: BackendType[Any] | None = None, - options: Any | None = None, timeout: float | None = None, ) -> None: self.host = host @@ -52,14 +54,12 @@ def __init__( self.timeout = ( REACTPY_TESTS_DEFAULT_TIMEOUT.current if timeout is None else timeout ) - - if app is not None and implementation is None: - msg = "If an application instance its corresponding server implementation must be provided too." - raise ValueError(msg) - - self._app = app - self.implementation = implementation or default_server - self._options = options + self._app = app or ReactPy(self._root_component) + self.webserver = uvicorn.Server( + uvicorn.Config( + app=self._app, host=self.host, port=self.port, loop="asyncio" + ) + ) @property def log_records(self) -> list[logging.LogRecord]: @@ -109,30 +109,7 @@ def list_logged_exceptions( async def __aenter__(self) -> BackendFixture: self._exit_stack = AsyncExitStack() self._records = self._exit_stack.enter_context(capture_reactpy_logs()) - - app = self._app or self.implementation.create_development_app() - self.implementation.configure(app, self._root_component, self._options) - - started = asyncio.Event() - server_future = asyncio.create_task( - self.implementation.serve_development_app( - app, self.host, self.port, started - ) - ) - - async def stop_server() -> None: - server_future.cancel() - with suppress(asyncio.CancelledError): - await asyncio.wait_for(server_future, timeout=self.timeout) - - self._exit_stack.push_async_callback(stop_server) - - try: - await asyncio.wait_for(started.wait(), timeout=self.timeout) - except Exception: # nocov - # see if we can await the future for a more helpful error - await asyncio.wait_for(server_future, timeout=self.timeout) - raise + Thread(target=self.webserver.run, daemon=True).start() return self @@ -151,6 +128,8 @@ async def __aexit__( msg = "Unexpected logged exception" raise LogAssertionError(msg) from logged_errors[0] + await asyncio.wait_for(self.webserver.shutdown(), timeout=5) + _MountFunc = Callable[["Callable[[], Any] | None"], None] diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index a4951ccc8..ce1875d43 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -26,7 +26,6 @@ def __init__( self, backend: BackendFixture | None = None, driver: Browser | BrowserContext | Page | None = None, - url_prefix: str = "", ) -> None: if backend is not None: self.backend = backend @@ -35,7 +34,6 @@ def __init__( self.page = driver else: self._browser = driver - self.url_prefix = url_prefix async def show( self, @@ -45,14 +43,8 @@ async def show( await self.goto("/") await self.root_element() # check that root element is attached - async def goto( - self, path: str, query: Any | None = None, add_url_prefix: bool = True - ) -> None: - await self.page.goto( - self.backend.url( - f"{self.url_prefix}{path}" if add_url_prefix else path, query - ) - ) + async def goto(self, path: str, query: Any | None = None) -> None: + await self.page.goto(self.backend.url(path, query)) async def root_element(self) -> ElementHandle: element = await self.page.wait_for_selector("#app", state="attached") diff --git a/src/reactpy/testing/utils.py b/src/reactpy/testing/utils.py new file mode 100644 index 000000000..9e906f7c5 --- /dev/null +++ b/src/reactpy/testing/utils.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import socket +import sys +from contextlib import closing + + +def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) -> int: + """Get a port that's available for the given host and port range""" + for port in range(port_min, port_max): + with closing(socket.socket()) as sock: + try: + if sys.platform in ("linux", "darwin"): + # Fixes bug on Unix-like systems where every time you restart the + # server you'll get a different port on Linux. This cannot be set + # on Windows otherwise address will always be reused. + # Ref: https://stackoverflow.com/a/19247688/3159288 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((host, port)) + except OSError: + pass + else: + return port + msg = f"Host {host!r} has no available port in range {port_max}-{port_max}" + raise RuntimeError(msg) From c92079a8fe8909d79a832542a099b12857711d75 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:24:39 -0800 Subject: [PATCH 64/84] test_standalone --- tests/{test_backend => test_asgi}/__init__.py | 0 .../test_standalone.py} | 56 ++++----------- tests/test_backend/test_common.py | 70 ------------------- tests/test_backend/test_utils.py | 46 ------------ 4 files changed, 15 insertions(+), 157 deletions(-) rename tests/{test_backend => test_asgi}/__init__.py (100%) rename tests/{test_backend/test_all.py => test_asgi/test_standalone.py} (67%) delete mode 100644 tests/test_backend/test_common.py delete mode 100644 tests/test_backend/test_utils.py diff --git a/tests/test_backend/__init__.py b/tests/test_asgi/__init__.py similarity index 100% rename from tests/test_backend/__init__.py rename to tests/test_asgi/__init__.py diff --git a/tests/test_backend/test_all.py b/tests/test_asgi/test_standalone.py similarity index 67% rename from tests/test_backend/test_all.py rename to tests/test_asgi/test_standalone.py index f295d0ccb..3dcbb9eca 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_asgi/test_standalone.py @@ -4,34 +4,15 @@ import reactpy from reactpy import html -from reactpy.asgi import default as default_implementation -from reactpy.asgi._common import PATH_PREFIX -from reactpy.asgi.utils import all_implementations +from reactpy.asgi.standalone import ReactPy from reactpy.testing import BackendFixture, DisplayFixture, poll -from reactpy.types import BackendType, Connection, Location +from reactpy.types import Connection, Location -@pytest.fixture( - params=[*list(all_implementations()), default_implementation], - ids=lambda imp: imp.__name__, -) +@pytest.fixture() async def display(page, request): - imp: BackendType = request.param - - # we do this to check that route priorities for each backend are correct - if imp is default_implementation: - url_prefix = "" - opts = None - else: - url_prefix = str(PATH_PREFIX) - opts = imp.Options(url_prefix=url_prefix) - - async with BackendFixture(implementation=imp, options=opts) as server: - async with DisplayFixture( - backend=server, - driver=page, - url_prefix=url_prefix, - ) as display: + async with BackendFixture() as server: + async with DisplayFixture(backend=server, driver=page) as display: yield display @@ -128,17 +109,12 @@ def ShowRoute(): await poll_location.until_equals(loc) -@pytest.mark.parametrize("hook_name", ["use_request", "use_websocket"]) -async def test_use_request(display: DisplayFixture, hook_name): - hook = getattr(display.backend.implementation, hook_name, None) - if hook is None: - pytest.skip(f"{display.backend.implementation} has no '{hook_name}' hook") - +async def test_carrier(display: DisplayFixture): hook_val = reactpy.Ref() @reactpy.component def ShowRoute(): - hook_val.current = hook() + hook_val.current = reactpy.hooks.use_connection().carrier return html.pre({"id": "hook"}, str(hook_val.current)) await display.show(ShowRoute) @@ -149,18 +125,16 @@ def ShowRoute(): assert hook_val.current is not None -@pytest.mark.parametrize("imp", all_implementations()) -async def test_customized_head(imp: BackendType, page): - custom_title = f"Custom Title for {imp.__name__}" +async def test_customized_head(page): + custom_title = "Custom Title for ReactPy" @reactpy.component def sample(): return html.h1(f"^ Page title is customized to: '{custom_title}'") - async with BackendFixture( - implementation=imp, - options=imp.Options(head=html.title(custom_title)), - ) as server: - async with DisplayFixture(backend=server, driver=page) as display: - await display.show(sample) - assert (await display.page.title()) == custom_title + app = ReactPy(sample, html_head=html.head(html.title(custom_title))) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server, driver=page) as new_display: + await new_display.show(sample) + assert (await new_display.page.title()) == custom_title diff --git a/tests/test_backend/test_common.py b/tests/test_backend/test_common.py deleted file mode 100644 index 2b83e1cae..000000000 --- a/tests/test_backend/test_common.py +++ /dev/null @@ -1,70 +0,0 @@ -import pytest - -from reactpy import html -from reactpy.asgi._common import ( - CommonOptions, - traversal_safe_path, - vdom_head_elements_to_html, -) - - -def test_common_options_url_prefix_starts_with_slash(): - # no prefix specified - CommonOptions(url_prefix="") - - with pytest.raises(ValueError, match="start with '/'"): - CommonOptions(url_prefix="not-start-withslash") - - -@pytest.mark.parametrize( - "bad_path", - [ - "../escaped", - "ok/../../escaped", - "ok/ok-again/../../ok-yet-again/../../../escaped", - ], -) -def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): - with pytest.raises(ValueError, match="Unsafe path"): - traversal_safe_path(tmp_path, *bad_path.split("/")) - - -@pytest.mark.parametrize( - "vdom_in, html_out", - [ - ( - "example", - "example", - ), - ( - # We do not modify strings given by user. If given as VDOM we would have - # striped this head element, but since provided as string, we leav as-is. - "", - "", - ), - ( - html.head( - html.meta({"charset": "utf-8"}), - html.title("example"), - ), - # we strip the head element - 'example', - ), - ( - html.fragment( - html.meta({"charset": "utf-8"}), - html.title("example"), - ), - 'example', - ), - ( - [ - html.meta({"charset": "utf-8"}), - html.title("example"), - ], - 'example', - ), - ], -) -def test_vdom_head_elements_to_html(vdom_in, html_out): - assert vdom_head_elements_to_html(vdom_in) == html_out diff --git a/tests/test_backend/test_utils.py b/tests/test_backend/test_utils.py deleted file mode 100644 index 3b56229ab..000000000 --- a/tests/test_backend/test_utils.py +++ /dev/null @@ -1,46 +0,0 @@ -import threading -import time -from contextlib import ExitStack - -import pytest -from playwright.async_api import Page - -from reactpy.asgi import flask as flask_implementation -from reactpy.asgi.utils import find_available_port -from reactpy.asgi.utils import run as sync_run -from tests.sample import SampleApp - - -@pytest.fixture -def exit_stack(): - with ExitStack() as es: - yield es - - -def test_find_available_port(): - assert find_available_port("localhost", port_min=5000, port_max=6000) - with pytest.raises(RuntimeError, match="no available port"): - # check that if port range is exhausted we raise - find_available_port("localhost", port_min=0, port_max=0) - - -async def test_run(page: Page): - host = "127.0.0.1" - port = find_available_port(host) - url = f"http://{host}:{port}" - - threading.Thread( - target=lambda: sync_run( - SampleApp, - host, - port, - implementation=flask_implementation, - ), - daemon=True, - ).start() - - # give the server a moment to start - time.sleep(0.5) - - await page.goto(url) - await page.wait_for_selector("#sample") From fd3adae764c97c83ea7f1cb1e4f30f5405c2a153 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 15:30:15 -0800 Subject: [PATCH 65/84] fix old tests --- pyproject.toml | 19 +++++++++------- src/js/packages/@reactpy/client/src/client.ts | 2 +- src/reactpy/asgi/standalone.py | 3 ++- tests/conftest.py | 22 ++++++++++++++----- tests/test_client.py | 2 +- tests/test_html.py | 2 +- tests/test_testing.py | 14 ------------ tests/test_web/test_module.py | 21 ++++++------------ 8 files changed, 39 insertions(+), 46 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da35b8f69..f7e5c2c71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "asgiref >=3", "lxml >=4", "servestatic >=3.0.0", + "orjson >=3", ] dynamic = ["version"] urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" @@ -94,6 +95,9 @@ extra-dependencies = [ "playwright", "jsonpointer", "uvicorn[standard]", + "jinja2-simple-tags", + "jinja2 >=3", + "starlette", ] [[tool.hatch.envs.hatch-test.matrix]] @@ -101,19 +105,18 @@ python = ["3.9", "3.10", "3.11", "3.12"] [tool.pytest.ini_options] addopts = """\ - --strict-config - --strict-markers - """ + --strict-config + --strict-markers +""" +filterwarnings = """ + ignore::DeprecationWarning:uvicorn.* + ignore::DeprecationWarning:websockets.* +""" testpaths = "tests" xfail_strict = true asyncio_mode = "auto" log_cli_level = "INFO" -[tool.hatch.envs.default.scripts] -test-cov = "playwright install && coverage run -m pytest {args:tests}" -cov-report = ["coverage report"] -cov = ["test-cov {args}", "cov-report"] - [tool.hatch.envs.default.env-vars] REACTPY_DEBUG_MODE = "1" diff --git a/src/js/packages/@reactpy/client/src/client.ts b/src/js/packages/@reactpy/client/src/client.ts index 12e1d6bc8..ea4e1aed5 100644 --- a/src/js/packages/@reactpy/client/src/client.ts +++ b/src/js/packages/@reactpy/client/src/client.ts @@ -78,6 +78,6 @@ export class ReactPyClient } loadModule(moduleName: string): Promise { - return import(`${this.urls.jsModulesPath}/${moduleName}`); + return import(`${this.urls.jsModulesPath}${moduleName}`); } } diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index 88eec81bb..a9403cf3f 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import hashlib import re from collections.abc import Coroutine @@ -5,7 +7,6 @@ from datetime import datetime from email.utils import formatdate from logging import getLogger -from pathlib import Path from typing import Any, Callable from typing_extensions import Unpack diff --git a/tests/conftest.py b/tests/conftest.py index 9f1662f1f..ef93660df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,14 +20,22 @@ ) REACTPY_ASYNC_RENDERING.current = True +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") in { + "y", + "yes", + "t", + "true", + "on", + "1", +} def pytest_addoption(parser: Parser) -> None: parser.addoption( - "--headed", - dest="headed", + "--headless", + dest="headless", action="store_true", - help="Open a browser window when running web-based tests", + help="Don't open a browser window when running web-based tests", ) @@ -37,8 +45,8 @@ def install_playwright(): @pytest.fixture(autouse=True, scope="session") -def rebuild_javascript(): - subprocess.run(["hatch", "run", "javascript:build"], check=True) # noqa: S607, S603 +def rebuild(): + subprocess.run(["hatch", "build", "-t", "wheel"], check=True) # noqa: S607, S603 @pytest.fixture @@ -68,7 +76,9 @@ async def browser(pytestconfig: Config): from playwright.async_api import async_playwright async with async_playwright() as pw: - yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed)) + yield await pw.chromium.launch( + headless=bool(pytestconfig.option.headless) or GITHUB_ACTIONS + ) @pytest.fixture(scope="session") diff --git a/tests/test_client.py b/tests/test_client.py index 7be07a6ec..bc3b5d2ff 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,8 +5,8 @@ from playwright.async_api import Browser import reactpy -from reactpy.asgi.utils import find_available_port from reactpy.testing import BackendFixture, DisplayFixture, poll +from reactpy.testing.utils import find_available_port from tests.tooling.common import DEFAULT_TYPE_DELAY from tests.tooling.hooks import use_counter diff --git a/tests/test_html.py b/tests/test_html.py index aa541dedf..e20fcc006 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -46,7 +46,7 @@ def HasScript(): html.div({"id": "run-count", "data_value": 0}), html.script( { - "src": f"/_reactpy/modules/{file_name_template.format(src_id=src_id)}" + "src": f"/reactpy/modules/{file_name_template.format(src_id=src_id)}" } ), ) diff --git a/tests/test_testing.py b/tests/test_testing.py index f8b260162..e2c227d61 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -4,7 +4,6 @@ import pytest from reactpy import Ref, component, html, testing -from reactpy.asgi import starlette as starlette_implementation from reactpy.logging import ROOT_LOGGER from reactpy.testing.backend import _hotswap from reactpy.testing.display import DisplayFixture @@ -144,19 +143,6 @@ async def test_simple_display_fixture(): await display.page.wait_for_selector("#sample") -def test_if_app_is_given_implementation_must_be_too(): - with pytest.raises( - ValueError, - match=r"If an application instance its corresponding server implementation must be provided too", - ): - testing.BackendFixture(app=starlette_implementation.create_development_app()) - - testing.BackendFixture( - app=starlette_implementation.create_development_app(), - implementation=starlette_implementation, - ) - - def test_list_logged_excptions(): the_error = None with testing.capture_reactpy_logs() as records: diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index b3d3a2fa8..6693a5301 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -1,10 +1,10 @@ from pathlib import Path import pytest -from sanic import Sanic +from servestatic import ServeStaticASGI import reactpy -from reactpy.asgi import sanic as sanic_implementation +from reactpy.asgi.standalone import ReactPy from reactpy.testing import ( BackendFixture, DisplayFixture, @@ -50,19 +50,9 @@ def ShowCurrentComponent(): await display.page.wait_for_selector("#unmount-flag", state="attached") -@pytest.mark.flaky(reruns=3) async def test_module_from_url(browser): - app = Sanic("test_module_from_url") - - # instead of directing the URL to a CDN, we just point it to this static file - app.static( - "/simple-button.js", - str(JS_FIXTURES_DIR / "simple-button.js"), - content_type="text/javascript", - ) - SimpleButton = reactpy.web.export( - reactpy.web.module_from_url("/simple-button.js", resolve_exports=False), + reactpy.web.module_from_url("/static/simple-button.js", resolve_exports=False), "SimpleButton", ) @@ -70,7 +60,10 @@ async def test_module_from_url(browser): def ShowSimpleButton(): return SimpleButton({"id": "my-button"}) - async with BackendFixture(app=app, implementation=sanic_implementation) as server: + app = ReactPy(ShowSimpleButton) + app = ServeStaticASGI(app, JS_FIXTURES_DIR, "/static/") + + async with BackendFixture(app) as server: async with DisplayFixture(server, browser) as display: await display.show(ShowSimpleButton) From 2fe399b696eb9652bc371d34dd24dbe2af13fa9c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 15:32:29 -0800 Subject: [PATCH 66/84] fix hatch fmt error --- src/reactpy/asgi/standalone.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index a9403cf3f..eee4449af 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -4,7 +4,7 @@ import re from collections.abc import Coroutine from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from email.utils import formatdate from logging import getLogger from typing import Any, Callable @@ -139,4 +139,6 @@ def process_index_html(self) -> None: ) self._etag = f'"{hashlib.md5(self._cached_index_html.encode(), usedforsecurity=False).hexdigest()}"' - self._last_modified = formatdate(datetime.now().timestamp(), usegmt=True) + self._last_modified = formatdate( + datetime.now(tz=timezone.utc).timestamp(), usegmt=True + ) From 9520bf134a72b40cdd8cfb22338514a0cb71aa16 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:28:58 -0800 Subject: [PATCH 67/84] Fix type hints --- src/js/packages/@reactpy/client/src/mount.tsx | 9 ++- src/reactpy/asgi/middleware.py | 71 ++++++++++--------- src/reactpy/asgi/standalone.py | 11 ++- src/reactpy/asgi/utils.py | 29 +++++--- src/reactpy/core/hooks.py | 5 +- src/reactpy/jinja.py | 4 +- src/reactpy/testing/backend.py | 4 +- src/reactpy/types.py | 7 +- 8 files changed, 82 insertions(+), 58 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index d7e5567a2..820bc0631 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -7,12 +7,17 @@ export function mountReactPy(props: MountProps) { // WebSocket route for component rendering const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`; const wsOrigin = `${wsProtocol}//${window.location.host}`; - const componentUrl = new URL(`${wsOrigin}${props.pathPrefix}${props.appendComponentPath || ""}`); + const componentUrl = new URL( + `${wsOrigin}${props.pathPrefix}${props.appendComponentPath || ""}`, + ); // Embed the initial HTTP path into the WebSocket URL componentUrl.searchParams.append("http_pathname", window.location.pathname); if (window.location.search) { - componentUrl.searchParams.append("http_query_string", window.location.search); + componentUrl.searchParams.append( + "http_query_string", + window.location.search, + ); } // Configure a new ReactPy client diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index afaddd2fb..6a59a38cd 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -5,12 +5,13 @@ import re import traceback import urllib.parse -from collections.abc import Coroutine, Iterable +from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable +from typing import Any import orjson +from asgiref import typing as asgi_types from asgiref.compatibility import guarantee_single_callable from servestatic import ServeStaticASGI from typing_extensions import Unpack @@ -33,7 +34,7 @@ class ReactPyMiddleware: def __init__( self, - app: Callable[..., Coroutine], + app: asgi_types.ASGIApplication, root_components: Iterable[str], **settings: Unpack[ReactPyConfig], ) -> None: @@ -61,7 +62,7 @@ def __init__( self.static_pattern = re.compile(f"^{self.static_path}.*") # Component attributes - self.user_app = guarantee_single_callable(app) + self.user_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore self.root_components = import_components(root_components) # Directory attributes @@ -84,9 +85,9 @@ def __init__( async def __call__( self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], + scope: asgi_types.Scope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, ) -> None: """The ASGI entrypoint that determines whether ReactPy should route the request to ourselves or to the user application.""" @@ -105,13 +106,13 @@ async def __call__( # Serve the user's application await self.user_app(scope, receive, send) - def match_dispatch_path(self, scope: dict) -> bool: + def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: return bool(re.match(self.dispatcher_pattern, scope["path"])) - def match_static_path(self, scope: dict) -> bool: + def match_static_path(self, scope: asgi_types.HTTPScope) -> bool: return bool(re.match(self.static_pattern, scope["path"])) - def match_web_modules_path(self, scope: dict) -> bool: + def match_web_modules_path(self, scope: asgi_types.HTTPScope) -> bool: return bool(re.match(self.js_modules_pattern, scope["path"])) @@ -121,19 +122,21 @@ class ComponentDispatchApp: async def __call__( self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], + scope: asgi_types.WebSocketScope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, ) -> None: """ASGI app for rendering ReactPy Python components.""" - dispatcher: asyncio.Task | None = None - recv_queue: asyncio.Queue = asyncio.Queue() + dispatcher: asyncio.Task[Any] | None = None + recv_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() # Start a loop that handles ASGI websocket events while True: event = await receive() if event["type"] == "websocket.connect": - await send({"type": "websocket.accept"}) + await send( + {"type": "websocket.accept", "subprotocol": None, "headers": []} + ) dispatcher = asyncio.create_task( self.run_dispatcher(scope, receive, send, recv_queue) ) @@ -143,16 +146,16 @@ async def __call__( dispatcher.cancel() break - elif event["type"] == "websocket.receive": + elif event["type"] == "websocket.receive" and event["text"]: queue_put_func = recv_queue.put(orjson.loads(event["text"])) await queue_put_func async def run_dispatcher( self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], - recv_queue: asyncio.Queue, + scope: asgi_types.WebSocketScope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, + recv_queue: asyncio.Queue[dict[str, Any]], ) -> None: """Asyncio background task that renders and transmits layout updates of ReactPy components.""" try: @@ -187,11 +190,15 @@ async def run_dispatcher( # Start the ReactPy component rendering loop await serve_layout( - Layout(ConnectionContext(component(), value=connection)), # type: ignore + Layout(ConnectionContext(component(), value=connection)), lambda msg: send( - {"type": "websocket.send", "text": orjson.dumps(msg).decode()} + { + "type": "websocket.send", + "text": orjson.dumps(msg).decode(), + "bytes": None, + } ), - recv_queue.get, + recv_queue.get, # type: ignore ) # Manually log exceptions since this function is running in a separate asyncio task. @@ -206,9 +213,9 @@ class StaticFileApp: async def __call__( self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], + scope: asgi_types.HTTPScope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, ) -> None: """ASGI app for ReactPy static files.""" if not self._static_file_server: @@ -218,7 +225,7 @@ async def __call__( prefix=self.parent.static_path, ) - return await self._static_file_server(scope, receive, send) + await self._static_file_server(scope, receive, send) @dataclass @@ -228,9 +235,9 @@ class WebModuleApp: async def __call__( self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], + scope: asgi_types.HTTPScope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, ) -> None: """ASGI app for ReactPy web modules.""" if not self._static_file_server: @@ -241,4 +248,4 @@ async def __call__( autorefresh=True, ) - return await self._static_file_server(scope, receive, send) + await self._static_file_server(scope, receive, send) diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index eee4449af..72ce6bbce 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -2,13 +2,12 @@ import hashlib import re -from collections.abc import Coroutine from dataclasses import dataclass from datetime import datetime, timezone from email.utils import formatdate from logging import getLogger -from typing import Any, Callable +from asgiref import typing as asgi_types from typing_extensions import Unpack from reactpy import html @@ -61,9 +60,9 @@ class ReactPyApp: async def __call__( self, - scope: dict[str, Any], - receive: Callable[..., Coroutine], - send: Callable[..., Coroutine], + scope: asgi_types.Scope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, ) -> None: if scope["type"] != "http": if scope["type"] != "lifespan": @@ -121,7 +120,7 @@ async def __call__( headers=dict_to_byte_list(response_headers), ) - def match_dispatch_path(self, scope: dict) -> bool: + def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: """Method override to remove `dotted_path` from the dispatcher URL.""" return str(scope["path"]) == self.parent.dispatcher_path diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py index 4ecf6fa2d..5c34624ed 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/asgi/utils.py @@ -1,9 +1,11 @@ from __future__ import annotations import logging -from collections.abc import Coroutine, Iterable, Sequence +from collections.abc import Iterable from importlib import import_module -from typing import Any, Callable +from typing import Any + +from asgiref import typing as asgi_types from reactpy._option import Option from reactpy.types import ReactPyConfig, VdomDict @@ -69,15 +71,24 @@ def vdom_head_to_html(head: VdomDict) -> str: async def http_response( *, - send: Callable[[dict[str, Any]], Coroutine], + send: asgi_types.ASGISendCallable, method: str, code: int = 200, message: str = "", - headers: Sequence = (), + headers: Iterable[tuple[bytes, bytes]] = (), ) -> None: """Sends a HTTP response using the ASGI `send` API.""" - start_msg = {"type": "http.response.start", "status": code, "headers": [*headers]} - body_msg: dict[str, str | bytes] = {"type": "http.response.body"} + start_msg: asgi_types.HTTPResponseStartEvent = { + "type": "http.response.start", + "status": code, + "headers": [*headers], + "trailers": False, + } + body_msg: asgi_types.HTTPResponseBodyEvent = { + "type": "http.response.body", + "body": b"", + "more_body": False, + } # Add the content type and body to everything other than a HEAD request if method != "HEAD": @@ -87,14 +98,14 @@ async def http_response( await send(body_msg) -def process_settings(settings: ReactPyConfig): +def process_settings(settings: ReactPyConfig) -> None: """Process the settings and return the final configuration.""" from reactpy import config for setting in settings: config_name = f"REACTPY_{setting.upper()}" - config_object: Option | None = getattr(config, config_name, None) + config_object: Option[Any] | None = getattr(config, config_name, None) if config_object: - config_object.set_current(settings[setting]) + config_object.set_current(settings[setting]) # type: ignore else: raise ValueError(f"Unknown ReactPy setting {setting!r}.") diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 943cc2980..218138287 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -2,7 +2,7 @@ import asyncio import contextlib -from collections.abc import Coroutine, MutableMapping, Sequence +from collections.abc import Coroutine, Sequence from logging import getLogger from types import FunctionType from typing import ( @@ -16,6 +16,7 @@ overload, ) +from asgiref import typing as asgi_types from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG @@ -262,7 +263,7 @@ def use_connection() -> Connection[Any]: return conn -def use_scope() -> MutableMapping[str, Any]: +def use_scope() -> asgi_types.HTTPScope | asgi_types.WebSocketScope: """Get the current :class:`~reactpy.types.Connection`'s scope.""" return use_connection().scope diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py index 9c16d54f3..257d9065a 100644 --- a/src/reactpy/jinja.py +++ b/src/reactpy/jinja.py @@ -6,14 +6,14 @@ from reactpy.utils import render_mount_template -class ReactPyTemplateTag(StandaloneTag): +class ReactPyTemplateTag(StandaloneTag): # type: ignore """This allows enables a `component` tag to be used in any Jinja2 rendering context, as long as this template tag is registered as a Jinja2 extension.""" safe_output = True tags: ClassVar[set[str]] = {"component"} - def render(self, dotted_path: str, **kwargs): + def render(self, dotted_path: str, **kwargs: str) -> str: return render_mount_template( element_id=uuid4().hex, class_=kwargs.pop("class", ""), diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 9d0108028..1bc254714 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -2,7 +2,6 @@ import asyncio import logging -from collections.abc import Coroutine from contextlib import AsyncExitStack from threading import Thread from types import TracebackType @@ -10,6 +9,7 @@ from urllib.parse import urlencode, urlunparse import uvicorn +from asgiref import typing as asgi_types from reactpy.asgi.standalone import ReactPy from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT @@ -43,7 +43,7 @@ class BackendFixture: def __init__( self, - app: Callable[..., Coroutine] | None = None, + app: asgi_types.ASGIApplication | None = None, host: str = "127.0.0.1", port: int | None = None, timeout: float | None = None, diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 3626669a7..986ac36b7 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -2,7 +2,7 @@ import sys from collections import namedtuple -from collections.abc import Mapping, MutableMapping, Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path from types import TracebackType @@ -19,6 +19,7 @@ runtime_checkable, ) +from asgiref import typing as asgi_types from typing_extensions import TypeAlias, TypedDict CarrierType = TypeVar("CarrierType") @@ -253,8 +254,8 @@ def value(self) -> _Type: class Connection(Generic[CarrierType]): """Represents a connection with a client""" - scope: MutableMapping[str, Any] - """An ASGI scope dictionary""" + scope: asgi_types.HTTPScope | asgi_types.WebSocketScope + """A scope dictionary related to the current connection.""" location: Location """The current location (URL)""" From dc479c1a4cc7ff0ca56fc894970776c5e56c5dbe Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:38:56 -0800 Subject: [PATCH 68/84] remove pip upgrade from workflow --- .github/workflows/.hatch-run.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index 0a5579d77..c8770d184 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -43,7 +43,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: pip install --upgrade pip hatch uv + run: pip install --upgrade hatch uv - name: Run Scripts env: NPM_CONFIG_TOKEN: ${{ secrets.node-auth-token }} From 1a4571d8217409ff25eb6abee7e7299d10e150e5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:12:50 -0800 Subject: [PATCH 69/84] Fix test issues --- src/reactpy/testing/backend.py | 3 +++ tests/tooling/common.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 1bc254714..e26dd540b 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -111,6 +111,9 @@ async def __aenter__(self) -> BackendFixture: self._records = self._exit_stack.enter_context(capture_reactpy_logs()) Thread(target=self.webserver.run, daemon=True).start() + # Wait for the server to start + await asyncio.sleep(1) + return self async def __aexit__( diff --git a/tests/tooling/common.py b/tests/tooling/common.py index f7b7933f0..c850d714b 100644 --- a/tests/tooling/common.py +++ b/tests/tooling/common.py @@ -5,7 +5,7 @@ GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") DEFAULT_TYPE_DELAY = ( - 250 if GITHUB_ACTIONS.lower() in {"y", "yes", "t", "true", "on", "1"} else 25 + 250 if GITHUB_ACTIONS.lower() in {"y", "yes", "t", "true", "on", "1"} else 50 ) From 16edba0af35c758a918b3d91dd2e51430496baa0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:16:25 -0800 Subject: [PATCH 70/84] Remove usage of deprecated co_lnotab --- src/reactpy/core/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 218138287..77092f0d4 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -535,7 +535,7 @@ def strictly_equal(x: Any, y: Any) -> bool: getattr(x.__code__, attr) == getattr(y.__code__, attr) for attr in dir(x.__code__) if attr.startswith("co_") - and attr not in {"co_positions", "co_linetable", "co_lines"} + and attr not in {"co_positions", "co_linetable", "co_lines", "co_lnotab"} ) # Check via the `==` operator if possible From 65d2469bd35cfc882b8211e94b845f516e3c57dc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 19:04:41 -0800 Subject: [PATCH 71/84] fix test_automatic_reconnect test --- src/reactpy/testing/backend.py | 14 ++++++-- tests/test_client.py | 59 ++++++++++++++-------------------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index e26dd540b..57735c818 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -60,6 +60,7 @@ def __init__( app=self._app, host=self.host, port=self.port, loop="asyncio" ) ) + self.webserver_thread: Thread @property def log_records(self) -> list[logging.LogRecord]: @@ -109,9 +110,10 @@ def list_logged_exceptions( async def __aenter__(self) -> BackendFixture: self._exit_stack = AsyncExitStack() self._records = self._exit_stack.enter_context(capture_reactpy_logs()) - Thread(target=self.webserver.run, daemon=True).start() # Wait for the server to start + self.webserver_thread: Thread = Thread(target=self.webserver.run, daemon=True) + self.webserver_thread.start() await asyncio.sleep(1) return self @@ -131,7 +133,15 @@ async def __aexit__( msg = "Unexpected logged exception" raise LogAssertionError(msg) from logged_errors[0] - await asyncio.wait_for(self.webserver.shutdown(), timeout=5) + await asyncio.wait_for( + self.webserver.shutdown(), timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current + ) + self.webserver_thread.join(timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current) + + async def restart(self) -> None: + """Restart the server""" + await self.__aexit__(None, None, None) + await self.__aenter__() _MountFunc = Callable[["Callable[[], Any] | None"], None] diff --git a/tests/test_client.py b/tests/test_client.py index bc3b5d2ff..85530b962 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,7 +2,7 @@ from contextlib import AsyncExitStack from pathlib import Path -from playwright.async_api import Browser +from playwright.async_api import Page import reactpy from reactpy.testing import BackendFixture, DisplayFixture, poll @@ -13,13 +13,9 @@ JS_DIR = Path(__file__).parent / "js" -async def test_automatic_reconnect(browser: Browser): - port = find_available_port("localhost") - page = await browser.new_page() - - # we need to wait longer here because the automatic reconnect is not instant - page.set_default_timeout(10000) - +async def test_automatic_reconnect( + display: DisplayFixture, page: Page, server: BackendFixture +): @reactpy.component def SomeComponent(): count, incr_count = use_counter(0) @@ -35,39 +31,34 @@ async def get_count(): count = await page.wait_for_selector("#count") return await count.get_attribute("data-count") - async with AsyncExitStack() as exit_stack: - server = await exit_stack.enter_async_context(BackendFixture(port=port)) - display = await exit_stack.enter_async_context( - DisplayFixture(server, driver=page) - ) - - await display.show(SomeComponent) - - incr = await page.wait_for_selector("#incr") + await display.show(SomeComponent) - for i in range(3): - await poll(get_count).until_equals(str(i)) - await incr.click() + await poll(get_count).until_equals("0") + incr = await page.wait_for_selector("#incr") + await incr.click() - # the server is disconnected but the last view state is still shown - await page.wait_for_selector("#count") + await poll(get_count).until_equals("1") + incr = await page.wait_for_selector("#incr") + await incr.click() - async with AsyncExitStack() as exit_stack: - server = await exit_stack.enter_async_context(BackendFixture(port=port)) - display = await exit_stack.enter_async_context( - DisplayFixture(server, driver=page) - ) + await poll(get_count).until_equals("2") + incr = await page.wait_for_selector("#incr") + await incr.click() - # use mount instead of show to avoid a page refresh - display.backend.mount(SomeComponent) + await server.restart() + await display.show(SomeComponent) - for i in range(3): - await poll(get_count).until_equals(str(i)) + await poll(get_count).until_equals("0") + incr = await page.wait_for_selector("#incr") + await incr.click() - # need to refetch element because may unmount on reconnect - incr = await page.wait_for_selector("#incr") + await poll(get_count).until_equals("1") + incr = await page.wait_for_selector("#incr") + await incr.click() - await incr.click() + await poll(get_count).until_equals("2") + incr = await page.wait_for_selector("#incr") + await incr.click() async def test_style_can_be_changed(display: DisplayFixture): From 2959d9bd5fc664565d399adec0d025aa429f2b80 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Jan 2025 19:46:31 -0800 Subject: [PATCH 72/84] fix type error --- src/reactpy/testing/backend.py | 7 +------ tests/test_client.py | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 57735c818..f48472785 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -60,7 +60,6 @@ def __init__( app=self._app, host=self.host, port=self.port, loop="asyncio" ) ) - self.webserver_thread: Thread @property def log_records(self) -> list[logging.LogRecord]: @@ -112,8 +111,7 @@ async def __aenter__(self) -> BackendFixture: self._records = self._exit_stack.enter_context(capture_reactpy_logs()) # Wait for the server to start - self.webserver_thread: Thread = Thread(target=self.webserver.run, daemon=True) - self.webserver_thread.start() + Thread(target=self.webserver.run, daemon=True).start() await asyncio.sleep(1) return self @@ -126,8 +124,6 @@ async def __aexit__( ) -> None: await self._exit_stack.aclose() - self.mount(None) # reset the view - logged_errors = self.list_logged_exceptions(del_log_records=False) if logged_errors: # nocov msg = "Unexpected logged exception" @@ -136,7 +132,6 @@ async def __aexit__( await asyncio.wait_for( self.webserver.shutdown(), timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current ) - self.webserver_thread.join(timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current) async def restart(self) -> None: """Restart the server""" diff --git a/tests/test_client.py b/tests/test_client.py index 85530b962..d00d52812 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -46,7 +46,6 @@ async def get_count(): await incr.click() await server.restart() - await display.show(SomeComponent) await poll(get_count).until_equals("0") incr = await page.wait_for_selector("#incr") From 5182c834aa0c962c94acf32e8e219b375cb53b39 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:28:20 -0800 Subject: [PATCH 73/84] Fix flakey test_script_re_run_on_content_change --- tests/test_html.py | 36 +++++++++++++++++++++++++----------- tests/tooling/hooks.py | 1 + 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/tests/test_html.py b/tests/test_html.py index e20fcc006..1281d8c90 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,34 +1,48 @@ import pytest +from playwright.async_api import expect -from reactpy import component, config, html +from reactpy import component, config, hooks, html from reactpy.testing import DisplayFixture, poll from reactpy.utils import Ref +from tests.tooling.common import DEFAULT_TYPE_DELAY from tests.tooling.hooks import use_counter async def test_script_re_run_on_content_change(display: DisplayFixture): - incr_count = Ref() - @component def HasScript(): - count, incr_count.current = use_counter(1) + count, set_count = hooks.use_state(0) + + def on_click(event): + set_count(count + 1) + return html.div( html.div({"id": "mount-count", "data_value": 0}), html.script( f'document.getElementById("mount-count").setAttribute("data-value", {count});' ), + html.button({"onClick": on_click, "id": "incr"}, "Increment"), ) await display.show(HasScript) - mount_count = await display.page.wait_for_selector("#mount-count", state="attached") - poll_mount_count = poll(mount_count.get_attribute, "data-value") + await display.page.wait_for_selector("#mount-count", state="attached") + button = await display.page.wait_for_selector("#incr", state="attached") + + await button.click(delay=DEFAULT_TYPE_DELAY) + await expect(display.page.locator("#mount-count")).to_have_attribute( + "data-value", "1" + ) + + await button.click(delay=DEFAULT_TYPE_DELAY) + await expect(display.page.locator("#mount-count")).to_have_attribute( + "data-value", "2" + ) - await poll_mount_count.until_equals("1") - incr_count.current() - await poll_mount_count.until_equals("2") - incr_count.current() - await poll_mount_count.until_equals("3") + await button.click(delay=DEFAULT_TYPE_DELAY) + await expect(display.page.locator("#mount-count")).to_have_attribute( + "data-value", "3", timeout=100000 + ) async def test_script_from_src(display: DisplayFixture): diff --git a/tests/tooling/hooks.py b/tests/tooling/hooks.py index 1926a93bc..e5a4b6fb1 100644 --- a/tests/tooling/hooks.py +++ b/tests/tooling/hooks.py @@ -10,6 +10,7 @@ def use_toggle(init=False): return state, lambda: set_state(lambda old: not old) +# TODO: Remove this def use_counter(initial_value): state, set_state = use_state(initial_value) return state, lambda: set_state(lambda old: old + 1) From a0584034a110d0e14de4b40e4f15b86dc6b0f098 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:41:51 -0800 Subject: [PATCH 74/84] Fix a few coverage hits --- pyproject.toml | 9 ++++++--- src/reactpy/asgi/middleware.py | 2 +- src/reactpy/asgi/standalone.py | 11 +++++------ src/reactpy/asgi/utils.py | 2 +- src/reactpy/core/hooks.py | 2 +- src/reactpy/testing/utils.py | 4 +++- tests/test_asgi/test_standalone.py | 23 +++++++++++++++++++++++ tests/test_html.py | 24 ++++++++++++++++++++++++ 8 files changed, 64 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f7e5c2c71..f98afb56e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,7 +117,7 @@ xfail_strict = true asyncio_mode = "auto" log_cli_level = "INFO" -[tool.hatch.envs.default.env-vars] +[tool.hatch.envs.hatch-test.env-vars] REACTPY_DEBUG_MODE = "1" ####################################### @@ -234,7 +234,11 @@ warn_unused_ignores = true source_pkgs = ["reactpy"] branch = false parallel = false -omit = ["reactpy/__init__.py"] +omit = [ + "src/reactpy/__init__.py", + "src/reactpy/_console/*", + "src/reactpy/__main__.py", +] [tool.coverage.report] fail_under = 98 @@ -247,7 +251,6 @@ exclude_also = [ "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] -omit = ["**/reactpy/__main__.py"] [tool.ruff] target-version = "py39" diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index 6a59a38cd..bd4e3f749 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -56,7 +56,7 @@ def __init__( self.web_modules_path = f"{self.path_prefix}modules/" self.static_path = f"{self.path_prefix}static/" self.dispatcher_pattern = re.compile( - f"^{self.dispatcher_path}(?P[^/]+)/?" + f"^{self.dispatcher_path}(?P[a-zA-Z0-9_.]+)/$" ) self.js_modules_pattern = re.compile(f"^{self.web_modules_path}.*") self.static_pattern = re.compile(f"^{self.static_path}.*") diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index 72ce6bbce..3f7692045 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -47,6 +47,10 @@ def __init__( self.html_head = html_head or html.head() self.html_lang = html_lang + def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: + """Method override to remove `dotted_path` from the dispatcher URL.""" + return str(scope["path"]) == self.dispatcher_path + @dataclass class ReactPyApp: @@ -64,7 +68,7 @@ async def __call__( receive: asgi_types.ASGIReceiveCallable, send: asgi_types.ASGISendCallable, ) -> None: - if scope["type"] != "http": + if scope["type"] != "http": # pragma: no cover if scope["type"] != "lifespan": msg = ( "ReactPy app received unsupported request of type '%s' at path '%s'", @@ -120,10 +124,6 @@ async def __call__( headers=dict_to_byte_list(response_headers), ) - def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: - """Method override to remove `dotted_path` from the dispatcher URL.""" - return str(scope["path"]) == self.parent.dispatcher_path - def process_index_html(self) -> None: """Process the index.html and store the results in memory.""" self._cached_index_html = ( @@ -131,7 +131,6 @@ def process_index_html(self) -> None: f'' f"{vdom_head_to_html(self.parent.html_head)}" "" - f'
' f"{render_mount_template('app', '', '')}" "" "" diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py index 5c34624ed..38523fbe2 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/asgi/utils.py @@ -34,7 +34,7 @@ def import_components(dotted_paths: Iterable[str]) -> dict[str, Any]: } -def check_path(url_path: str) -> str: +def check_path(url_path: str) -> str: # pragma: no cover """Check that a path is valid URL path.""" if not url_path: return "URL path must not be empty." diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 77092f0d4..5fd10677e 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -544,4 +544,4 @@ def strictly_equal(x: Any, y: Any) -> bool: return x == y # type: ignore # Fallback to identity check - return x is y + return x is y # pragma: no cover diff --git a/src/reactpy/testing/utils.py b/src/reactpy/testing/utils.py index 9e906f7c5..f1808022c 100644 --- a/src/reactpy/testing/utils.py +++ b/src/reactpy/testing/utils.py @@ -5,7 +5,9 @@ from contextlib import closing -def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) -> int: +def find_available_port( + host: str, port_min: int = 8000, port_max: int = 9000 +) -> int: # pragma: no cover """Get a port that's available for the given host and port range""" for port in range(port_min, port_max): with closing(socket.socket()) as sock: diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 3dcbb9eca..5b7a73be6 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -1,11 +1,13 @@ from collections.abc import MutableMapping import pytest +from requests import request import reactpy from reactpy import html from reactpy.asgi.standalone import ReactPy from reactpy.testing import BackendFixture, DisplayFixture, poll +from reactpy.testing.common import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.types import Connection, Location @@ -138,3 +140,24 @@ def sample(): async with DisplayFixture(backend=server, driver=page) as new_display: await new_display.show(sample) assert (await new_display.page.title()) == custom_title + + +async def test_head_request(page): + @reactpy.component + def sample(): + return html.h1("Hello World") + + app = ReactPy(sample) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server, driver=page) as new_display: + await new_display.show(sample) + url = f"http://{server.host}:{server.port}" + response = request( + "HEAD", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "text/html; charset=utf-8" + assert response.headers["cache-control"] == "max-age=60, public" + assert response.headers["access-control-allow-origin"] == "*" + assert response.content == b"" diff --git a/tests/test_html.py b/tests/test_html.py index 1281d8c90..68e353681 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -115,3 +115,27 @@ def test_simple_fragment(): def test_fragment_can_have_no_attributes(): with pytest.raises(TypeError, match="Fragments cannot have attributes"): html.fragment({"some_attribute": 1}) + + +async def test_svg(display: DisplayFixture): + @component + def SvgComponent(): + return html.svg( + {"width": 100, "height": 100}, + html.svg.circle( + {"cx": 50, "cy": 50, "r": 40, "fill": "red"}, + ), + html.svg.circle( + {"cx": 50, "cy": 50, "r": 40, "fill": "red"}, + ), + ) + + await display.show(SvgComponent) + svg = await display.page.wait_for_selector("svg", state="attached") + assert await svg.get_attribute("width") == "100" + assert await svg.get_attribute("height") == "100" + circle = await display.page.wait_for_selector("circle", state="attached") + assert await circle.get_attribute("cx") == "50" + assert await circle.get_attribute("cy") == "50" + assert await circle.get_attribute("r") == "40" + assert await circle.get_attribute("fill") == "red" From a8c19b8e6a299a1ed009d57a6440952cb9f2f18e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 30 Jan 2025 17:43:14 -0800 Subject: [PATCH 75/84] tests for middleware --- src/reactpy/asgi/middleware.py | 25 ++++---- src/reactpy/asgi/utils.py | 3 + src/reactpy/testing/backend.py | 33 +++++++---- tests/templates/index.html | 11 ++++ tests/test_asgi/test_middleware.py | 95 ++++++++++++++++++++++++++++++ tests/test_asgi/test_standalone.py | 2 +- 6 files changed, 146 insertions(+), 23 deletions(-) create mode 100644 tests/templates/index.html create mode 100644 tests/test_asgi/test_middleware.py diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index bd4e3f749..ef108b3f4 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -47,6 +47,18 @@ def __init__( must be valid to Python's import system. settings: Global ReactPy configuration settings that affect behavior and performance. """ + # Validate the configuration + if "path_prefix" in settings: + reason = check_path(settings["path_prefix"]) + if reason: + raise ValueError( + f'Invalid `path_prefix` of "{settings["path_prefix"]}". {reason}' + ) + if "web_modules_dir" in settings and not settings["web_modules_dir"].exists(): + raise ValueError( + f'Web modules directory "{settings["web_modules_dir"]}" does not exist.' + ) + # Process global settings process_settings(settings) @@ -69,15 +81,6 @@ def __init__( self.web_modules_dir = config.REACTPY_WEB_MODULES_DIR.current self.static_dir = Path(__file__).parent.parent / "static" - # Validate the configuration - reason = check_path(self.path_prefix) - if reason: - raise ValueError(f"Invalid `path_prefix`. {reason}") - if not self.web_modules_dir.exists(): - raise ValueError( - f"Web modules directory {self.web_modules_dir} does not exist." - ) - # Initialize the sub-applications self.component_dispatch_app = ComponentDispatchApp(parent=self) self.static_file_app = StaticFileApp(parent=self) @@ -162,7 +165,7 @@ async def run_dispatcher( # Determine component to serve by analyzing the URL and/or class parameters. if self.parent.multiple_root_components: url_match = re.match(self.parent.dispatcher_pattern, scope["path"]) - if not url_match: + if not url_match: # pragma: no cover raise RuntimeError("Could not find component in URL path.") dotted_path = url_match["dotted_path"] if dotted_path not in self.parent.root_components: @@ -172,7 +175,7 @@ async def run_dispatcher( component = self.parent.root_components[dotted_path] elif self.parent.root_component: component = self.parent.root_component - else: + else: # pragma: no cover raise RuntimeError("No root component provided.") # Create a connection object by analyzing the websocket's query string. diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py index 38523fbe2..0b1fc5899 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/asgi/utils.py @@ -16,6 +16,9 @@ def import_dotted_path(dotted_path: str) -> Any: """Imports a dotted path and returns the callable.""" + if "." not in dotted_path: + raise ValueError(f"{dotted_path!r} is not a valid dotted path.") + module_name, component_name = dotted_path.rsplit(".", 1) try: diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index f48472785..14b4acc2e 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -11,7 +11,7 @@ import uvicorn from asgiref import typing as asgi_types -from reactpy.asgi.standalone import ReactPy +from reactpy.asgi.standalone import ReactPy, ReactPyMiddleware from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core.component import component from reactpy.core.hooks import use_callback, use_effect, use_state @@ -21,7 +21,7 @@ list_logged_exceptions, ) from reactpy.testing.utils import find_available_port -from reactpy.types import ComponentConstructor +from reactpy.types import ComponentConstructor, ReactPyConfig from reactpy.utils import Ref @@ -37,7 +37,7 @@ class BackendFixture: server.mount(MyComponent) """ - _records: list[logging.LogRecord] + log_records: list[logging.LogRecord] _server_future: asyncio.Task[Any] _exit_stack = AsyncExitStack() @@ -47,25 +47,33 @@ def __init__( host: str = "127.0.0.1", port: int | None = None, timeout: float | None = None, + reactpy_config: ReactPyConfig | None = None, ) -> None: self.host = host self.port = port or find_available_port(host) - self.mount, self._root_component = _hotswap() + self.mount = mount_to_hotswap self.timeout = ( REACTPY_TESTS_DEFAULT_TIMEOUT.current if timeout is None else timeout ) - self._app = app or ReactPy(self._root_component) + if isinstance(app, (ReactPyMiddleware, ReactPy)): + self._app = app + elif app: + self._app = ReactPyMiddleware( + app, + root_components=["reactpy.testing.backend.root_hotswap_component"], + **(reactpy_config or {}), + ) + else: + self._app = ReactPy( + root_hotswap_component, + **(reactpy_config or {}), + ) self.webserver = uvicorn.Server( uvicorn.Config( app=self._app, host=self.host, port=self.port, loop="asyncio" ) ) - @property - def log_records(self) -> list[logging.LogRecord]: - """A list of captured log records""" - return self._records - def url(self, path: str = "", query: Any | None = None) -> str: """Return a URL string pointing to the host and point of the server @@ -108,7 +116,7 @@ def list_logged_exceptions( async def __aenter__(self) -> BackendFixture: self._exit_stack = AsyncExitStack() - self._records = self._exit_stack.enter_context(capture_reactpy_logs()) + self.log_records = self._exit_stack.enter_context(capture_reactpy_logs()) # Wait for the server to start Thread(target=self.webserver.run, daemon=True).start() @@ -215,3 +223,6 @@ def swap(constructor: Callable[[], Any] | None) -> None: constructor_ref.current = constructor or (lambda: None) return swap, HotSwap + + +mount_to_hotswap, root_hotswap_component = _hotswap() diff --git a/tests/templates/index.html b/tests/templates/index.html new file mode 100644 index 000000000..8238b6b09 --- /dev/null +++ b/tests/templates/index.html @@ -0,0 +1,11 @@ + + + + + + +
+ {% component "reactpy.testing.backend.root_hotswap_component" %} + + + diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py new file mode 100644 index 000000000..e8972cad5 --- /dev/null +++ b/tests/test_asgi/test_middleware.py @@ -0,0 +1,95 @@ +from pathlib import Path + +import pytest +from jinja2 import Environment as JinjaEnvironment +from jinja2 import FileSystemLoader as JinjaFileSystemLoader +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +import reactpy +from reactpy.asgi.middleware import ReactPyMiddleware +from reactpy.testing import BackendFixture, DisplayFixture + + +@pytest.fixture() +async def display(page): + templates = Jinja2Templates( + env=JinjaEnvironment( + loader=JinjaFileSystemLoader("tests/templates"), + extensions=["reactpy.jinja.ReactPyTemplateTag"], + ) + ) + + async def homepage(request): + return templates.TemplateResponse(request, "index.html") + + app = Starlette(routes=[Route("/", homepage)]) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server, driver=page) as new_display: + yield new_display + + +def test_invalid_path_prefix(): + with pytest.raises(ValueError, match="Invalid `path_prefix`*"): + + async def app(scope, receive, send): + pass + + reactpy.ReactPyMiddleware(app, root_components=["abc"], path_prefix="invalid") + + +def test_invalid_web_modules_dir(): + with pytest.raises( + ValueError, match='Web modules directory "invalid" does not exist.' + ): + + async def app(scope, receive, send): + pass + + reactpy.ReactPyMiddleware( + app, root_components=["abc"], web_modules_dir=Path("invalid") + ) + + +async def test_unregistered_root_component(): + templates = Jinja2Templates( + env=JinjaEnvironment( + loader=JinjaFileSystemLoader("tests/templates"), + extensions=["reactpy.jinja.ReactPyTemplateTag"], + ) + ) + + async def homepage(request): + return templates.TemplateResponse(request, "index.html") + + @reactpy.component + def Stub(): + return reactpy.html.p("Hello") + + app = Starlette(routes=[Route("/", homepage)]) + app = ReactPyMiddleware(app, root_components=["tests.sample.SampleApp"]) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server) as new_display: + await new_display.show(Stub) + assert ( + "Attempting to use an unregistered root component" + in server.log_records[-1].message + ) + + +async def test_display_simple_hello_world(display: DisplayFixture): + @reactpy.component + def Hello(): + return reactpy.html.p({"id": "hello"}, ["Hello World"]) + + await display.show(Hello) + + await display.page.wait_for_selector("#hello") + + # test that we can reconnect successfully + await display.page.reload() + + await display.page.wait_for_selector("#hello") diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 5b7a73be6..8c477b21d 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -12,7 +12,7 @@ @pytest.fixture() -async def display(page, request): +async def display(page): async with BackendFixture() as server: async with DisplayFixture(backend=server, driver=page) as display: yield display From 8581391d6545c77966c1e2b2041f8ad85e1192ef Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:04:11 -0800 Subject: [PATCH 76/84] 100% test coverage --- docs/Dockerfile | 2 +- pyproject.toml | 5 +--- src/reactpy/asgi/utils.py | 14 +++++++---- src/reactpy/core/hooks.py | 2 +- src/reactpy/core/serve.py | 2 +- src/reactpy/testing/backend.py | 3 ++- src/reactpy/testing/display.py | 2 +- tests/conftest.py | 4 +++- tests/test_asgi/test_middleware.py | 1 + tests/test_asgi/test_utils.py | 38 ++++++++++++++++++++++++++++++ tests/test_client.py | 2 -- tests/test_config.py | 2 +- tests/test_web/test_utils.py | 13 ++++++++++ 13 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 tests/test_asgi/test_utils.py diff --git a/docs/Dockerfile b/docs/Dockerfile index 1f8bd0aaf..fad5643c3 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -33,6 +33,6 @@ RUN sphinx-build -v -W -b html source build # Define Entrypoint # ----------------- ENV PORT=5000 -ENV REACTPY_DEBUG_MODE=1 +ENV REACTPY_DEBUG=1 ENV REACTPY_CHECK_VDOM_SPEC=0 CMD ["python", "main.py"] diff --git a/pyproject.toml b/pyproject.toml index f98afb56e..532d4a07c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,9 +117,6 @@ xfail_strict = true asyncio_mode = "auto" log_cli_level = "INFO" -[tool.hatch.envs.hatch-test.env-vars] -REACTPY_DEBUG_MODE = "1" - ####################################### # >>> Hatch Documentation Scripts <<< # ####################################### @@ -241,7 +238,7 @@ omit = [ ] [tool.coverage.report] -fail_under = 98 +fail_under = 100 show_missing = true skip_covered = true sort = "Name" diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py index 0b1fc5899..fe4f1ef64 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/asgi/utils.py @@ -17,17 +17,21 @@ def import_dotted_path(dotted_path: str) -> Any: """Imports a dotted path and returns the callable.""" if "." not in dotted_path: - raise ValueError(f"{dotted_path!r} is not a valid dotted path.") + raise ValueError(f'"{dotted_path}" is not a valid dotted path.') module_name, component_name = dotted_path.rsplit(".", 1) try: module = import_module(module_name) except ImportError as error: - msg = f"Failed to import {module_name!r} while loading {component_name!r}" - raise RuntimeError(msg) from error + msg = f'ReactPy failed to import "{module_name}"' + raise ImportError(msg) from error - return getattr(module, component_name) + try: + return getattr(module, component_name) + except AttributeError as error: + msg = f'ReactPy failed to import "{component_name}" from "{module_name}"' + raise AttributeError(msg) from error def import_components(dotted_paths: Iterable[str]) -> dict[str, Any]: @@ -111,4 +115,4 @@ def process_settings(settings: ReactPyConfig) -> None: if config_object: config_object.set_current(settings[setting]) # type: ignore else: - raise ValueError(f"Unknown ReactPy setting {setting!r}.") + raise ValueError(f'Unknown ReactPy setting "{setting}".') diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 5fd10677e..f7321ef58 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -185,7 +185,7 @@ def use_debug_value( """Log debug information when the given message changes. .. note:: - This hook only logs if :data:`~reactpy.config.REACTPY_DEBUG_MODE` is active. + This hook only logs if :data:`~reactpy.config.REACTPY_DEBUG` is active. Unlike other hooks, a message is considered to have changed if the old and new values are ``!=``. Because this comparison is performed on every render of the diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index bbb3d64d7..40a5761cf 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -66,7 +66,7 @@ async def _single_outgoing_loop( msg = ( "Failed to send update. More info may be available " "if you enabling debug mode by setting " - "`reactpy.config.REACTPY_DEBUG_MODE.current = True`." + "`reactpy.config.REACTPY_DEBUG.current = True`." ) logger.error(msg) raise diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 14b4acc2e..353370af6 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -11,7 +11,8 @@ import uvicorn from asgiref import typing as asgi_types -from reactpy.asgi.standalone import ReactPy, ReactPyMiddleware +from reactpy.asgi.middleware import ReactPyMiddleware +from reactpy.asgi.standalone import ReactPy from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core.component import component from reactpy.core.hooks import use_callback, use_effect, use_state diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index ce1875d43..cc429c059 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -67,7 +67,7 @@ async def __aenter__(self) -> DisplayFixture: self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) - if not hasattr(self, "backend"): + if not hasattr(self, "backend"): # pragma: no cover self.backend = BackendFixture() await es.enter_async_context(self.backend) diff --git a/tests/conftest.py b/tests/conftest.py index ef93660df..119e7571d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from reactpy.config import ( REACTPY_ASYNC_RENDERING, + REACTPY_DEBUG, REACTPY_TESTS_DEFAULT_TIMEOUT, ) from reactpy.testing import ( @@ -19,7 +20,8 @@ clear_reactpy_web_modules_dir, ) -REACTPY_ASYNC_RENDERING.current = True +REACTPY_ASYNC_RENDERING.set_current(True) +REACTPY_DEBUG.set_current(True) GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") in { "y", "yes", diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index e8972cad5..a9bec5ca2 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -1,3 +1,4 @@ +# ruff: noqa: S701 from pathlib import Path import pytest diff --git a/tests/test_asgi/test_utils.py b/tests/test_asgi/test_utils.py new file mode 100644 index 000000000..ff3019c27 --- /dev/null +++ b/tests/test_asgi/test_utils.py @@ -0,0 +1,38 @@ +import pytest + +from reactpy import config +from reactpy.asgi import utils + + +def test_invalid_dotted_path(): + with pytest.raises(ValueError, match='"abc" is not a valid dotted path.'): + utils.import_dotted_path("abc") + + +def test_invalid_component(): + with pytest.raises( + AttributeError, match='ReactPy failed to import "foobar" from "reactpy"' + ): + utils.import_dotted_path("reactpy.foobar") + + +def test_invalid_module(): + with pytest.raises(ImportError, match='ReactPy failed to import "foo"'): + utils.import_dotted_path("foo.bar") + + +def test_invalid_vdom_head(): + with pytest.raises(ValueError, match="Invalid head element!*"): + utils.vdom_head_to_html({"tagName": "invalid"}) + + +def test_process_settings(): + utils.process_settings({"async_rendering": False}) + assert config.REACTPY_ASYNC_RENDERING.current is False + utils.process_settings({"async_rendering": True}) + assert config.REACTPY_ASYNC_RENDERING.current is True + + +def test_invalid_setting(): + with pytest.raises(ValueError, match='Unknown ReactPy setting "foobar".'): + utils.process_settings({"foobar": True}) diff --git a/tests/test_client.py b/tests/test_client.py index d00d52812..7d1da4007 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,12 +1,10 @@ import asyncio -from contextlib import AsyncExitStack from pathlib import Path from playwright.async_api import Page import reactpy from reactpy.testing import BackendFixture, DisplayFixture, poll -from reactpy.testing.utils import find_available_port from tests.tooling.common import DEFAULT_TYPE_DELAY from tests.tooling.hooks import use_counter diff --git a/tests/test_config.py b/tests/test_config.py index f90468f38..e5c6457c5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,7 +23,7 @@ def reset_options(): opt.current = val -def test_reactpy_debug_mode_toggle(): +def test_reactpy_debug_toggle(): # just check that nothing breaks config.REACTPY_DEBUG.current = True config.REACTPY_DEBUG.current = False diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index 14c3e2e13..2f9d72618 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -5,6 +5,7 @@ from reactpy.testing import assert_reactpy_did_log from reactpy.web.utils import ( + _resolve_relative_url, module_name_suffix, resolve_module_exports_from_file, resolve_module_exports_from_source, @@ -150,3 +151,15 @@ def test_log_on_unknown_export_type(): assert resolve_module_exports_from_source( "export something unknown;", exclude_default=False ) == (set(), set()) + + +def test_resolve_relative_url(): + assert ( + _resolve_relative_url("https://some.url", "path/to/another.js") + == "path/to/another.js" + ) + assert ( + _resolve_relative_url("https://some.url", "/path/to/another.js") + == "https://some.url/path/to/another.js" + ) + assert _resolve_relative_url("/some/path", "to/another.js") == "to/another.js" From cc2836b02a038a77ec9f3bb82dd7dd33c56cacf5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:23:48 -0800 Subject: [PATCH 77/84] Remove Python 3.9 support --- .github/workflows/check.yml | 2 +- docs/source/about/changelog.rst | 2 ++ pyproject.toml | 4 +++- src/reactpy/testing/backend.py | 4 +--- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9fd513e89..fc2c8120d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,7 +27,7 @@ jobs: job-name: "python-{0} {1}" run-cmd: "hatch test" runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]' - python-version: '["3.9", "3.10", "3.11"]' + python-version: '["3.10", "3.11", "3.12", "3.13"]' test-documentation: uses: ./.github/workflows/.hatch-run.yml with: diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index af3f885c4..c3a612811 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -20,6 +20,7 @@ Unreleased - :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework. - :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework. - :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[uvicorn,jinja]``). +- :pull:`1113` - Added support for Python 3.12 and 3.13. **Changed** @@ -43,6 +44,7 @@ Unreleased - :pull:`1113` - Removed ``reactpy.core.types`` module. Use ``reactpy.types`` instead. - :pull:`1113` - All backend related installation extras (such as ``pip install reactpy[starlette]``) have been removed. - :pull:`1113` - Removed deprecated function ``module_from_template``. +- :pull:`1113` - Removed support for Python 3.9. **Fixed** diff --git a/pyproject.toml b/pyproject.toml index 532d4a07c..e060c046e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,7 @@ extra-dependencies = [ ] [[tool.hatch.envs.hatch-test.matrix]] -python = ["3.9", "3.10", "3.11", "3.12"] +python = ["3.10", "3.11", "3.12", "3.13"] [tool.pytest.ini_options] addopts = """\ @@ -111,10 +111,12 @@ addopts = """\ filterwarnings = """ ignore::DeprecationWarning:uvicorn.* ignore::DeprecationWarning:websockets.* + ignore::UserWarning:tests.test_core.test_vdom """ testpaths = "tests" xfail_strict = true asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" log_cli_level = "INFO" ####################################### diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 353370af6..bbf3866d5 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -138,9 +138,7 @@ async def __aexit__( msg = "Unexpected logged exception" raise LogAssertionError(msg) from logged_errors[0] - await asyncio.wait_for( - self.webserver.shutdown(), timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current - ) + await asyncio.wait_for(self.webserver.shutdown(), timeout=20) async def restart(self) -> None: """Restart the server""" From 9820c86ce580cb0b224db3f37d668243866e2450 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:33:04 -0800 Subject: [PATCH 78/84] Add wait method to test_unregistered_root_component --- tests/test_asgi/test_middleware.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index a9bec5ca2..c1d484255 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -1,4 +1,5 @@ # ruff: noqa: S701 +import asyncio from pathlib import Path import pytest @@ -75,6 +76,12 @@ def Stub(): async with BackendFixture(app) as server: async with DisplayFixture(backend=server) as new_display: await new_display.show(Stub) + + for _ in range(10): + await asyncio.sleep(0.25) + if len(server.log_records) > 0: + break + assert ( "Attempting to use an unregistered root component" in server.log_records[-1].message From 285f2e25f7e4a0ff5c27a3531290511675cdbe70 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:37:32 -0800 Subject: [PATCH 79/84] reverse sleep position --- tests/test_asgi/test_middleware.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index c1d484255..ccb3a4a9e 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -1,5 +1,6 @@ # ruff: noqa: S701 import asyncio +import os from pathlib import Path import pytest @@ -77,11 +78,13 @@ def Stub(): async with DisplayFixture(backend=server) as new_display: await new_display.show(Stub) + # Wait for the log record to be popualted for _ in range(10): - await asyncio.sleep(0.25) if len(server.log_records) > 0: break + await asyncio.sleep(0.25) + # Check that the log record was populated with the "unregistered component" message assert ( "Attempting to use an unregistered root component" in server.log_records[-1].message From 71520cd922cb603a09d7da485134984ab3c7f2c1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:41:39 -0800 Subject: [PATCH 80/84] longer server shutdown timeout --- src/reactpy/testing/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index bbf3866d5..863ec885b 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -138,7 +138,7 @@ async def __aexit__( msg = "Unexpected logged exception" raise LogAssertionError(msg) from logged_errors[0] - await asyncio.wait_for(self.webserver.shutdown(), timeout=20) + await asyncio.wait_for(self.webserver.shutdown(), timeout=30) async def restart(self) -> None: """Restart the server""" From 122c00bae906236fcc2eacff11473fd56e56740c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:41:36 -0800 Subject: [PATCH 81/84] self review --- docs/source/about/changelog.rst | 4 ++-- .../guides/escape-hatches/distributing-javascript.rst | 2 +- docs/source/guides/getting-started/running-reactpy.rst | 8 ++++---- pyproject.toml | 1 + src/js/packages/@reactpy/client/src/index.ts | 2 ++ src/reactpy/jinja.py | 2 +- tests/test_asgi/test_middleware.py | 5 ++--- 7 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index c3a612811..9f833d28f 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -18,8 +18,8 @@ Unreleased **Added** - :pull:`1113` - Added ``reactpy.ReactPy`` that can be used to run ReactPy in standalone mode. - :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework. -- :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework. -- :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[uvicorn,jinja]``). +- :pull:`1113` - Added ``reactpy.jinja.Component`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. +- :pull:`1113` - Added ``standard``, ``uvicorn``, ``jinja`` installation extras (for example ``pip install reactpy[standard]``). - :pull:`1113` - Added support for Python 3.12 and 3.13. **Changed** diff --git a/docs/source/guides/escape-hatches/distributing-javascript.rst b/docs/source/guides/escape-hatches/distributing-javascript.rst index 9eb478965..5333742ce 100644 --- a/docs/source/guides/escape-hatches/distributing-javascript.rst +++ b/docs/source/guides/escape-hatches/distributing-javascript.rst @@ -188,7 +188,7 @@ loaded with :func:`~reactpy.web.module.export`. .. note:: - When :data:`reactpy.config.REACTPY_DEBUG_MODE` is active, named exports will be validated. + When :data:`reactpy.config.REACTPY_DEBUG` is active, named exports will be validated. The remaining files that we need to create are concerned with creating a Python package. We won't cover all the details here, so refer to the Setuptools_ documentation for diff --git a/docs/source/guides/getting-started/running-reactpy.rst b/docs/source/guides/getting-started/running-reactpy.rst index 8abbd574f..90a03cbc3 100644 --- a/docs/source/guides/getting-started/running-reactpy.rst +++ b/docs/source/guides/getting-started/running-reactpy.rst @@ -103,7 +103,7 @@ Running ReactPy in Debug Mode ----------------------------- ReactPy provides a debug mode that is turned off by default. This can be enabled when you -run your application by setting the ``REACTPY_DEBUG_MODE`` environment variable. +run your application by setting the ``REACTPY_DEBUG`` environment variable. .. tab-set:: @@ -111,21 +111,21 @@ run your application by setting the ``REACTPY_DEBUG_MODE`` environment variable. .. code-block:: - export REACTPY_DEBUG_MODE=1 + export REACTPY_DEBUG=1 python my_reactpy_app.py .. tab-item:: Command Prompt .. code-block:: text - set REACTPY_DEBUG_MODE=1 + set REACTPY_DEBUG=1 python my_reactpy_app.py .. tab-item:: PowerShell .. code-block:: powershell - $env:REACTPY_DEBUG_MODE = "1" + $env:REACTPY_DEBUG = "1" python my_reactpy_app.py .. danger:: diff --git a/pyproject.toml b/pyproject.toml index e060c046e..92430e71b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ artifacts = [] [project.optional-dependencies] all = ["reactpy[jinja,uvicorn,testing]"] +standard = ["reactpy[jinja,uvicorn]"] jinja = ["jinja2-simple-tags", "jinja2 >=3"] uvicorn = ["uvicorn[standard]"] testing = ["playwright"] diff --git a/src/js/packages/@reactpy/client/src/index.ts b/src/js/packages/@reactpy/client/src/index.ts index 9ab33297c..15192823d 100644 --- a/src/js/packages/@reactpy/client/src/index.ts +++ b/src/js/packages/@reactpy/client/src/index.ts @@ -4,3 +4,5 @@ export * from "./mount"; export * from "./types"; export * from "./vdom"; export * from "./websocket"; +export { default as React } from "preact/compat"; +export { default as ReactDOM } from "preact/compat"; diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py index 257d9065a..77d1570f1 100644 --- a/src/reactpy/jinja.py +++ b/src/reactpy/jinja.py @@ -6,7 +6,7 @@ from reactpy.utils import render_mount_template -class ReactPyTemplateTag(StandaloneTag): # type: ignore +class Component(StandaloneTag): # type: ignore """This allows enables a `component` tag to be used in any Jinja2 rendering context, as long as this template tag is registered as a Jinja2 extension.""" diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index ccb3a4a9e..84dc545b8 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -1,6 +1,5 @@ # ruff: noqa: S701 import asyncio -import os from pathlib import Path import pytest @@ -20,7 +19,7 @@ async def display(page): templates = Jinja2Templates( env=JinjaEnvironment( loader=JinjaFileSystemLoader("tests/templates"), - extensions=["reactpy.jinja.ReactPyTemplateTag"], + extensions=["reactpy.jinja.Component"], ) ) @@ -60,7 +59,7 @@ async def test_unregistered_root_component(): templates = Jinja2Templates( env=JinjaEnvironment( loader=JinjaFileSystemLoader("tests/templates"), - extensions=["reactpy.jinja.ReactPyTemplateTag"], + extensions=["reactpy.jinja.Component"], ) ) From 1a9dfa1daa4aa629e7d5fab38c41feabee01979d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:15:31 -0800 Subject: [PATCH 82/84] temporarily disable docs tests --- .github/workflows/check.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index fc2c8120d..aa185d358 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -29,6 +29,8 @@ jobs: runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]' python-version: '["3.10", "3.11", "3.12", "3.13"]' test-documentation: + # Temporarily disabled + if: 0 uses: ./.github/workflows/.hatch-run.yml with: job-name: "python-{0}" From 0c027c68a9fcf98efba2df85f2585da94c8089dc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:22:51 -0800 Subject: [PATCH 83/84] Even higher shutdown timeout - GH sometimes lags really badly --- src/reactpy/testing/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 863ec885b..9ebd15f3a 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -138,7 +138,7 @@ async def __aexit__( msg = "Unexpected logged exception" raise LogAssertionError(msg) from logged_errors[0] - await asyncio.wait_for(self.webserver.shutdown(), timeout=30) + await asyncio.wait_for(self.webserver.shutdown(), timeout=60) async def restart(self) -> None: """Restart the server""" From 4aeb365e31d1b2f544fb7804ab9043567888e7a7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:25:39 -0800 Subject: [PATCH 84/84] Allow CI to run on all PRs --- .github/workflows/check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index aa185d358..86a457136 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -6,7 +6,7 @@ on: - main pull_request: branches: - - main + - "*" schedule: - cron: "0 0 * * 0"