Incrementally Add New Packages (#1607)

This commit is contained in:
Alek Petuskey 2023-08-31 14:20:44 -07:00 committed by GitHub
parent 7d7b7901a9
commit fed75ea7f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 192 additions and 55 deletions

View File

@ -7,7 +7,6 @@
"prod": "next start" "prod": "next start"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/icons": "^2.0.19",
"@chakra-ui/react": "^2.6.0", "@chakra-ui/react": "^2.6.0",
"@chakra-ui/system": "^2.5.6", "@chakra-ui/system": "^2.5.6",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
@ -15,28 +14,13 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"chakra-react-select": "^4.6.0", "chakra-react-select": "^4.6.0",
"focus-visible": "^5.2.0", "focus-visible": "^5.2.0",
"framer-motion": "^10.12.4",
"gridjs": "^6.0.6",
"gridjs-react": "^6.0.1",
"json5": "^2.2.3", "json5": "^2.2.3",
"next": "^13.3.1", "next": "^13.3.1",
"next-sitemap": "^4.1.8", "next-sitemap": "^4.1.8",
"plotly.js": "^2.22.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-debounce-input": "^3.3.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-plotly.js": "^2.6.0",
"react-syntax-highlighter": "^15.5.0",
"rehype-katex": "^6.0.3",
"rehype-raw": "^6.1.1",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.6.1",
"universal-cookie": "^4.0.4", "universal-cookie": "^4.0.4"
"victory": "^36.6.8"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",

View File

@ -47,7 +47,7 @@ from reflex.route import (
verify_route_validity, verify_route_validity,
) )
from reflex.state import DefaultState, State, StateManager, StateUpdate from reflex.state import DefaultState, State, StateManager, StateUpdate
from reflex.utils import console, format, types from reflex.utils import console, format, prerequisites, types
# Define custom types. # Define custom types.
ComponentCallable = Callable[[], Component] ComponentCallable = Callable[[], Component]
@ -483,6 +483,27 @@ class App(Base):
admin.mount_to(self.api) admin.mount_to(self.api)
def get_frontend_packages(self, imports: Dict[str, str]):
"""Gets the frontend packages to be installed and filters out the unnecessary ones.
Args:
imports: A dictionary containing the imports used in the current page.
Example:
>>> get_frontend_packages({"react": "16.14.0", "react-dom": "16.14.0"})
"""
page_imports = [
i
for i in imports
if i not in compiler.DEFAULT_IMPORTS.keys()
and i != "focus-visible/dist/focus-visible"
and "next" not in i
and not i.startswith("/")
and i != ""
]
page_imports.extend(get_config().frontend_packages)
prerequisites.install_frontend_packages(page_imports)
def compile(self): def compile(self):
"""Compile the app and output it to the pages folder.""" """Compile the app and output it to the pages folder."""
if os.environ.get(constants.SKIP_COMPILE_ENV_VAR) == "yes": if os.environ.get(constants.SKIP_COMPILE_ENV_VAR) == "yes":
@ -493,12 +514,13 @@ class App(Base):
MofNCompleteColumn(), MofNCompleteColumn(),
TimeElapsedColumn(), TimeElapsedColumn(),
) )
task = progress.add_task("Compiling: ", total=len(self.pages))
# TODO: include all work done in progress indicator, not just self.pages
for render, kwargs in DECORATED_PAGES: for render, kwargs in DECORATED_PAGES:
self.add_page(render, **kwargs) self.add_page(render, **kwargs)
task = progress.add_task("Compiling: ", total=len(self.pages))
# TODO: include all work done in progress indicator, not just self.pages
# Get the env mode. # Get the env mode.
config = get_config() config = get_config()
@ -509,6 +531,7 @@ class App(Base):
custom_components = set() custom_components = set()
# TODO Anecdotally, processes=2 works 10% faster (cpu_count=12) # TODO Anecdotally, processes=2 works 10% faster (cpu_count=12)
thread_pool = ThreadPool() thread_pool = ThreadPool()
all_imports = {}
with progress: with progress:
for route, component in self.pages.items(): for route, component in self.pages.items():
# TODO: this progress does not reflect actual threaded task completion # TODO: this progress does not reflect actual threaded task completion
@ -524,8 +547,12 @@ class App(Base):
), ),
) )
) )
# add component.get_imports() to all_imports
all_imports.update(component.get_imports())
# Add the custom components from the page to the set. # Add the custom components from the page to the set.
custom_components |= component.get_custom_components() custom_components |= component.get_custom_components()
thread_pool.close() thread_pool.close()
thread_pool.join() thread_pool.join()
@ -537,7 +564,11 @@ class App(Base):
# Compile the custom components. # Compile the custom components.
compile_results.append(compiler.compile_components(custom_components)) compile_results.append(compiler.compile_components(custom_components))
# Compile the root document with base styles and fonts. # Iterate through all the custom components and add their imports to the all_imports
for component in custom_components:
all_imports.update(component.get_imports())
# Compile the root document with base styles and fonts
compile_results.append(compiler.compile_document_root(self.stylesheets)) compile_results.append(compiler.compile_document_root(self.stylesheets))
# Compile the theme. # Compile the theme.
@ -556,6 +587,9 @@ class App(Base):
# Empty the .web pages directory # Empty the .web pages directory
compiler.purge_web_pages_dir() compiler.purge_web_pages_dir()
# install frontend packages
self.get_frontend_packages(all_imports)
# Write the pages at the end to trigger the NextJS hot reload only once. # Write the pages at the end to trigger the NextJS hot reload only once.
thread_pool = ThreadPool() thread_pool = ThreadPool()
for output_path, code in compile_results: for output_path, code in compile_results:

View File

@ -25,7 +25,7 @@ from reflex.components.component import Component, ComponentStyle, CustomCompone
from reflex.state import Cookie, LocalStorage, State from reflex.state import Cookie, LocalStorage, State
from reflex.style import Style from reflex.style import Style
from reflex.utils import format, imports, path_ops from reflex.utils import format, imports, path_ops
from reflex.vars import ImportVar from reflex.vars import ImportVar, NoRenderImportVar
# To re-export this function. # To re-export this function.
merge_imports = imports.merge_imports merge_imports = imports.merge_imports
@ -42,6 +42,9 @@ def compile_import_statement(fields: Set[ImportVar]) -> Tuple[str, Set[str]]:
default: default library. When install "import def from library". default: default library. When install "import def from library".
rest: rest of libraries. When install "import {rest1, rest2} from library" rest: rest of libraries. When install "import {rest1, rest2} from library"
""" """
# ignore the NoRenderImportVar fields during compilation
fields = {field for field in fields if not isinstance(field, NoRenderImportVar)}
# Check for default imports. # Check for default imports.
defaults = {field for field in fields if field.is_default} defaults = {field for field in fields if field.is_default}
assert len(defaults) < 2 assert len(defaults) < 2
@ -72,6 +75,7 @@ def validate_imports(imports: imports.ImportDict):
raise ValueError( raise ValueError(
f"Can not compile, the tag {import_name} is used multiple time from {lib} and {used_tags[import_name]}" f"Can not compile, the tag {import_name} is used multiple time from {lib} and {used_tags[import_name]}"
) )
if import_name is not None:
used_tags[import_name] = lib used_tags[import_name] = lib
@ -87,6 +91,10 @@ def compile_imports(imports: imports.ImportDict) -> List[dict]:
import_dicts = [] import_dicts = []
for lib, fields in imports.items(): for lib, fields in imports.items():
default, rest = compile_import_statement(fields) default, rest = compile_import_statement(fields)
# prevent all NoRenderImportVar from being rendered on the page
if any({f for f in fields if isinstance(f, NoRenderImportVar)}): # type: ignore
continue
if not lib: if not lib:
assert not default, "No default field allowed for empty library." assert not default, "No default field allowed for empty library."
assert rest is not None and len(rest) > 0, "No fields to import." assert rest is not None and len(rest) > 0, "No fields to import."
@ -94,6 +102,11 @@ def compile_imports(imports: imports.ImportDict) -> List[dict]:
import_dicts.append(get_import_dict(module)) import_dicts.append(get_import_dict(module))
continue continue
# remove the version before rendering the package imports
lib, at, version = lib.rpartition("@")
if not lib:
lib = at + version
import_dicts.append(get_import_dict(lib, default, rest)) import_dicts.append(get_import_dict(lib, default, rest))
return import_dicts return import_dicts

View File

@ -22,7 +22,7 @@ from reflex.event import (
) )
from reflex.style import Style from reflex.style import Style
from reflex.utils import format, imports, types from reflex.utils import format, imports, types
from reflex.vars import BaseVar, ImportVar, Var from reflex.vars import BaseVar, ImportVar, NoRenderImportVar, Var
class Component(Base, ABC): class Component(Base, ABC):
@ -40,6 +40,9 @@ class Component(Base, ABC):
# The library that the component is based on. # The library that the component is based on.
library: Optional[str] = None library: Optional[str] = None
# List here the non-react dependency needed by `library`
lib_dependencies: List[str] = []
# The tag to use when rendering the component. # The tag to use when rendering the component.
tag: Optional[str] = None tag: Optional[str] = None
@ -515,9 +518,12 @@ class Component(Base, ABC):
return code return code
def _get_imports(self) -> imports.ImportDict: def _get_imports(self) -> imports.ImportDict:
imports = {}
if self.library is not None and self.tag is not None: if self.library is not None and self.tag is not None:
return {self.library: {self.import_var}} imports[self.library] = {self.import_var}
return {} for dep in self.lib_dependencies:
imports[dep] = {NoRenderImportVar()} # type: ignore
return imports
def get_imports(self) -> imports.ImportDict: def get_imports(self) -> imports.ImportDict:
"""Get all the libraries and fields that are used by the component. """Get all the libraries and fields that are used by the component.
@ -833,11 +839,26 @@ class NoSSRComponent(Component):
"""A dynamic component that is not rendered on the server.""" """A dynamic component that is not rendered on the server."""
def _get_imports(self): def _get_imports(self):
return {"next/dynamic": {ImportVar(tag="dynamic", is_default=True)}} imports = {"next/dynamic": {ImportVar(tag="dynamic", is_default=True)}}
for dep in [self.library, *self.lib_dependencies]:
imports[dep] = {NoRenderImportVar()} # type: ignore
return imports
def _get_custom_code(self) -> str: def _get_custom_code(self) -> str:
opts_fragment = ", { ssr: false });" opts_fragment = ", { ssr: false });"
library_import = f"const {self.tag} = dynamic(() => import('{self.library}')"
# extract the correct import name from library name
if self.library is None:
raise ValueError("Undefined library for NoSSRComponent")
import_name_parts = [p for p in self.library.rpartition("@") if p != ""]
import_name = (
import_name_parts[0] if import_name_parts[0] != "@" else self.library
)
library_import = f"const {self.tag} = dynamic(() => import('{import_name}')"
mod_import = ( mod_import = (
# https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading#with-named-exports # https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading#with-named-exports
f".then((mod) => mod.{self.tag})" f".then((mod) => mod.{self.tag})"

View File

@ -19,7 +19,7 @@ PRISM_STYLES_PATH = "/styles/code/prism"
class CodeBlock(Component): class CodeBlock(Component):
"""A code block.""" """A code block."""
library = "react-syntax-highlighter" library = "react-syntax-highlighter@^15.5.0"
tag = "Prism" tag = "Prism"

View File

@ -11,7 +11,9 @@ from reflex.vars import BaseVar, ComputedVar, ImportVar, Var
class Gridjs(Component): class Gridjs(Component):
"""A component that wraps a nivo bar component.""" """A component that wraps a nivo bar component."""
library = "gridjs-react" library = "gridjs-react@^6.0.1"
lib_dependencies: List[str] = ["gridjs@^6.0.6"]
class DataTable(Gridjs): class DataTable(Gridjs):

View File

@ -16,7 +16,7 @@ class DebounceInput(Component):
is experiencing high latency. is experiencing high latency.
""" """
library = "react-debounce-input" library = "react-debounce-input@^3.3.0"
tag = "DebounceInput" tag = "DebounceInput"
# Minimum input characters before triggering the on_change event # Minimum input characters before triggering the on_change event

View File

@ -21,7 +21,7 @@ clear_selected_files = BaseVar(name="_e => setFiles((files) => [])", type_=Event
class Upload(Component): class Upload(Component):
"""A file upload component.""" """A file upload component."""
library = "react-dropzone" library = "react-dropzone@^14.2.3"
tag = "ReactDropzone" tag = "ReactDropzone"

View File

@ -1,6 +1,6 @@
"""Component for displaying a plotly graph.""" """Component for displaying a plotly graph."""
from typing import Dict from typing import Dict, List
from plotly.graph_objects import Figure from plotly.graph_objects import Figure
@ -12,7 +12,9 @@ from reflex.vars import Var
class PlotlyLib(NoSSRComponent): class PlotlyLib(NoSSRComponent):
"""A component that wraps a plotly lib.""" """A component that wraps a plotly lib."""
library = "react-plotly.js" library = "react-plotly.js@^2.6.0"
lib_dependencies: List[str] = ["plotly.js@^2.22.0"]
class Plotly(PlotlyLib): class Plotly(PlotlyLib):

View File

@ -334,7 +334,7 @@ def data(graph: str, x: List, y: Optional[List] = None, **kwargs) -> List:
class Victory(Component): class Victory(Component):
"""A component that wraps a victory lib.""" """A component that wraps a victory lib."""
library = "victory" library = "victory@^36.6.8"
# The data to display. # The data to display.
data: Var[List[Dict]] data: Var[List[Dict]]

View File

@ -11,7 +11,7 @@ class ReactPlayerComponent(NoSSRComponent):
reference: https://github.com/cookpete/react-player. reference: https://github.com/cookpete/react-player.
""" """
library = "react-player/lazy" library = "react-player@^2.12.0"
tag = "ReactPlayer" tag = "ReactPlayer"

View File

@ -7,7 +7,7 @@ from reflex.utils import format
class ChakraIconComponent(Component): class ChakraIconComponent(Component):
"""A component that wraps a Chakra icon component.""" """A component that wraps a Chakra icon component."""
library = "@chakra-ui/icons" library = "@chakra-ui/icons@^2.0.19"
class Icon(ChakraIconComponent): class Icon(ChakraIconComponent):

View File

@ -32,7 +32,14 @@ components_by_tag: Dict[str, Callable] = {
class Markdown(Component): class Markdown(Component):
"""A markdown component.""" """A markdown component."""
library = "react-markdown" library = "react-markdown@^8.0.7"
lib_dependencies: List[str] = [
"rehype-katex@^6.0.3",
"remark-math@^5.1.1",
"rehype-raw@^6.1.1",
"remark-gfm@^3.0.1",
]
tag = "ReactMarkdown" tag = "ReactMarkdown"

View File

View File

@ -127,6 +127,9 @@ def run(
frontend = True frontend = True
backend = True backend = True
if not frontend and backend:
_skip_compile()
# Check that the app is initialized. # Check that the app is initialized.
prerequisites.check_initialized(frontend=frontend) prerequisites.check_initialized(frontend=frontend)

View File

@ -223,9 +223,6 @@ def setup_frontend(
root: The root path of the project. root: The root path of the project.
disable_telemetry: Whether to disable the Next telemetry. disable_telemetry: Whether to disable the Next telemetry.
""" """
# Install frontend packages.
prerequisites.install_frontend_packages()
# Copy asset files to public folder. # Copy asset files to public folder.
path_ops.cp( path_ops.cp(
src=str(root / constants.APP_ASSETS_DIR), src=str(root / constants.APP_ASSETS_DIR),

View File

@ -2,11 +2,14 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json
import os import os
import platform import platform
import sys import sys
from pathlib import Path from pathlib import Path
import psutil
import uvicorn import uvicorn
from reflex import constants from reflex import constants
@ -25,20 +28,78 @@ def start_watching_assets_folder(root):
asset_watch.start() asset_watch.start()
def detect_package_change(json_file_path: str) -> str:
"""Calculates the SHA-256 hash of a JSON file and returns it as a hexadecimal string.
Args:
json_file_path (str): The path to the JSON file to be hashed.
Returns:
str: The SHA-256 hash of the JSON file as a hexadecimal string.
Example:
>>> detect_package_change("package.json")
'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2'
"""
with open(json_file_path, "r") as file:
json_data = json.load(file)
# Calculate the hash
json_string = json.dumps(json_data, sort_keys=True)
hash_object = hashlib.sha256(json_string.encode())
return hash_object.hexdigest()
def kill(proc_pid: int):
"""Kills a process and all its child processes.
Args:
proc_pid (int): The process ID of the process to be killed.
Example:
>>> kill(1234)
"""
process = psutil.Process(proc_pid)
for proc in process.children(recursive=True):
proc.kill()
process.kill()
def run_process_and_launch_url(run_command: list[str]): def run_process_and_launch_url(run_command: list[str]):
"""Run the process and launch the URL. """Run the process and launch the URL.
Args: Args:
run_command: The command to run. run_command: The command to run.
""" """
json_file_path = os.path.join(constants.WEB_DIR, "package.json")
last_hash = detect_package_change(json_file_path)
process = None
first_run = True
while True:
if process is None:
process = processes.new_process( process = processes.new_process(
run_command, cwd=constants.WEB_DIR, shell=constants.IS_WINDOWS run_command, cwd=constants.WEB_DIR, shell=constants.IS_WINDOWS
) )
if process.stdout:
for line in processes.stream_logs("Starting frontend", process): for line in processes.stream_logs("Starting frontend", process):
if "ready started server on" in line: if "ready started server on" in line:
if first_run:
url = line.split("url: ")[-1].strip() url = line.split("url: ")[-1].strip()
console.print(f"App running at: [bold green]{url}") console.print(f"App running at: [bold green]{url}")
first_run = False
else:
console.print(f"New packages detected updating app...")
else:
console.debug(line)
new_hash = detect_package_change(json_file_path)
if new_hash != last_hash:
last_hash = new_hash
kill(process.pid)
process = None
break # for line in process.stdout
if process is not None:
break # while True
def run_frontend(root: Path, port: str): def run_frontend(root: Path, port: str):

View File

@ -14,7 +14,7 @@ import zipfile
from fileinput import FileInput from fileinput import FileInput
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Optional from typing import List, Optional
import httpx import httpx
import typer import typer
@ -376,25 +376,32 @@ def install_bun():
) )
def install_frontend_packages(): def install_frontend_packages(packages: List[str]):
"""Installs the base and custom frontend packages.""" """Installs the base and custom frontend packages.
Args:
packages (List[str]): A list of package names to be installed.
Example:
>>> install_frontend_packages(["react", "react-dom"])
"""
# Install the base packages. # Install the base packages.
process = processes.new_process( process = processes.new_process(
[get_install_package_manager(), "install", "--loglevel", "silly"], [get_install_package_manager(), "install", "--loglevel", "silly"],
cwd=constants.WEB_DIR, cwd=constants.WEB_DIR,
shell=constants.IS_WINDOWS, shell=constants.IS_WINDOWS,
) )
processes.show_status("Installing base frontend packages", process) processes.show_status("Installing base frontend packages", process)
# Install the app packages. # Install the custom packages, if any.
packages = get_config().frontend_packages
if len(packages) > 0: if len(packages) > 0:
process = processes.new_process( process = processes.new_process(
[get_install_package_manager(), "add", *packages], [get_install_package_manager(), "add", *packages],
cwd=constants.WEB_DIR, cwd=constants.WEB_DIR,
shell=constants.IS_WINDOWS, shell=constants.IS_WINDOWS,
) )
processes.show_status("Installing custom frontend packages", process) processes.show_status("Installing frontend packages for components", process)
def check_initialized(frontend: bool = True): def check_initialized(frontend: bool = True):

View File

@ -1372,6 +1372,12 @@ class ImportVar(Base):
return hash((self.tag, self.is_default, self.alias)) return hash((self.tag, self.is_default, self.alias))
class NoRenderImportVar(ImportVar):
"""A import that doesn't need to be rendered."""
...
def get_local_storage(key: Optional[Union[Var, str]] = None) -> BaseVar: def get_local_storage(key: Optional[Union[Var, str]] = None) -> BaseVar:
"""Provide a base var as payload to get local storage item(s). """Provide a base var as payload to get local storage item(s).