From 7d36610cae79aee228a2117044a6fcf54ecf41d2 Mon Sep 17 00:00:00 2001 From: Martin Xu <15661672+martinxu9@users.noreply.github.com> Date: Thu, 4 Apr 2024 15:31:34 -0700 Subject: [PATCH] Support reflex app creation from templates from github (#2490) --- .coveragerc | 2 +- .../init-test/in_docker_test_script.sh | 1 - reflex/.templates/apps/sidebar/README.md | 69 ----- .../apps/sidebar/assets/favicon.ico | Bin 4286 -> 0 bytes .../.templates/apps/sidebar/assets/github.svg | 10 - .../.templates/apps/sidebar/assets/logo.svg | 68 ----- .../apps/sidebar/assets/paneleft.svg | 13 - .../apps/sidebar/assets/reflex_black.svg | 37 --- .../apps/sidebar/assets/reflex_white.svg | 8 - .../.templates/apps/sidebar/code/__init__.py | 1 - .../apps/sidebar/code/components/__init__.py | 0 .../apps/sidebar/code/components/sidebar.py | 141 ---------- .../apps/sidebar/code/pages/__init__.py | 3 - .../apps/sidebar/code/pages/dashboard.py | 22 -- .../apps/sidebar/code/pages/index.py | 18 -- .../apps/sidebar/code/pages/settings.py | 76 ------ .../.templates/apps/sidebar/code/sidebar.py | 14 - reflex/.templates/apps/sidebar/code/styles.py | 45 ---- .../apps/sidebar/code/templates/__init__.py | 1 - .../apps/sidebar/code/templates/template.py | 144 ---------- reflex/.templates/web/utils/state.js | 7 +- reflex/constants/base.py | 9 +- reflex/constants/base.pyi | 94 +++++++ reflex/custom_components/custom_components.py | 2 +- reflex/reflex.py | 35 +-- reflex/testing.py | 2 +- reflex/utils/console.py | 10 +- reflex/utils/prerequisites.py | 245 ++++++++++++++++-- 28 files changed, 341 insertions(+), 736 deletions(-) delete mode 100644 reflex/.templates/apps/sidebar/README.md delete mode 100644 reflex/.templates/apps/sidebar/assets/favicon.ico delete mode 100644 reflex/.templates/apps/sidebar/assets/github.svg delete mode 100644 reflex/.templates/apps/sidebar/assets/logo.svg delete mode 100644 reflex/.templates/apps/sidebar/assets/paneleft.svg delete mode 100644 reflex/.templates/apps/sidebar/assets/reflex_black.svg delete mode 100644 reflex/.templates/apps/sidebar/assets/reflex_white.svg delete mode 100644 reflex/.templates/apps/sidebar/code/__init__.py delete mode 100644 reflex/.templates/apps/sidebar/code/components/__init__.py delete mode 100644 reflex/.templates/apps/sidebar/code/components/sidebar.py delete mode 100644 reflex/.templates/apps/sidebar/code/pages/__init__.py delete mode 100644 reflex/.templates/apps/sidebar/code/pages/dashboard.py delete mode 100644 reflex/.templates/apps/sidebar/code/pages/index.py delete mode 100644 reflex/.templates/apps/sidebar/code/pages/settings.py delete mode 100644 reflex/.templates/apps/sidebar/code/sidebar.py delete mode 100644 reflex/.templates/apps/sidebar/code/styles.py delete mode 100644 reflex/.templates/apps/sidebar/code/templates/__init__.py delete mode 100644 reflex/.templates/apps/sidebar/code/templates/template.py create mode 100644 reflex/constants/base.pyi diff --git a/.coveragerc b/.coveragerc index 67e9b345a..1f383608e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,7 +10,7 @@ omit = [report] show_missing = true # TODO bump back to 79 -fail_under = 72 +fail_under = 70 precision = 2 # Regexes for lines to exclude from consideration diff --git a/integration/init-test/in_docker_test_script.sh b/integration/init-test/in_docker_test_script.sh index 733cbf4ad..4fb836fd1 100755 --- a/integration/init-test/in_docker_test_script.sh +++ b/integration/init-test/in_docker_test_script.sh @@ -34,4 +34,3 @@ redis-server & echo "Running reflex init in test project dir" do_export blank -do_export sidebar \ No newline at end of file diff --git a/reflex/.templates/apps/sidebar/README.md b/reflex/.templates/apps/sidebar/README.md deleted file mode 100644 index d80145d34..000000000 --- a/reflex/.templates/apps/sidebar/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Welcome to Reflex! - -This is the base Reflex template - installed when you run `reflex init`. - -If you want to use a different template, pass the `--template` flag to `reflex init`. -For example, if you want a more basic starting point, you can run: - -```bash -reflex init --template blank -``` - -## About this Template - -This template has the following directory structure: - -```bash -├── README.md -├── assets -├── rxconfig.py -└── {your_app} - ├── __init__.py - ├── components - │   ├── __init__.py - │   └── sidebar.py - ├── pages - │   ├── __init__.py - │   ├── dashboard.py - │   ├── index.py - │   └── settings.py - ├── styles.py - ├── templates - │   ├── __init__.py - │   └── template.py - └── {your_app}.py -``` - -See the [Project Structure docs](https://reflex.dev/docs/getting-started/project-structure/) for more information on general Reflex project structure. - -### Adding Pages - -In this template, the pages in your app are defined in `{your_app}/pages/`. -Each page is a function that returns a Reflex component. -For example, to edit this page you can modify `{your_app}/pages/index.py`. -See the [pages docs](https://reflex.dev/docs/pages/routes/) for more information on pages. - -In this template, instead of using `rx.add_page` or the `@rx.page` decorator, -we use the `@template` decorator from `{your_app}/templates/template.py`. - -To add a new page: - -1. Add a new file in `{your_app}/pages/`. We recommend using one file per page, but you can also group pages in a single file. -2. Add a new function with the `@template` decorator, which takes the same arguments as `@rx.page`. -3. Import the page in your `{your_app}/pages/__init__.py` file and it will automatically be added to the app. - - -### Adding Components - -In order to keep your code organized, we recommend putting components that are -used across multiple pages in the `{your_app}/components/` directory. - -In this template, we have a sidebar component in `{your_app}/components/sidebar.py`. - -### Adding State - -As your app grows, we recommend using [substates](https://reflex.dev/docs/substates/overview/) -to organize your state. - -You can either define substates in their own files, or if the state is -specific to a page, you can define it in the page file itself. diff --git a/reflex/.templates/apps/sidebar/assets/favicon.ico b/reflex/.templates/apps/sidebar/assets/favicon.ico deleted file mode 100644 index 166ae995eaa63fc96771410a758282dc30e925cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4286 zcmeHL>rYc>81ELdEe;}zmYd}cUgmJRfwjUwD1`#s5KZP>mMqza#Viv|_7|8f+0+bX zHuqusuw-7Ca`DTu#4U4^o2bjO#K>4%N?Wdi*wZ3Vx%~Ef4}D1`U_EMRg3u z#2#M|V>}}q-@IaO@{9R}d*u7f&~5HfxSkmHVcazU#i30H zAGxQ5Spe!j9`KuGqR@aExK`-}sH1jvqoIp3C7Vm)9Tu=UPE;j^esN~a6^a$ZILngo;^ zGLXl(ZFyY&U!li`6}y-hUQ99v?s`U4O!kgog74FPw-9g+V)qs!jFGEQyvBf><U|E2vRmx|+(VI~S=lT?@~C5pvZOd`x{Q_+3tG6H=gtdWcf z)+7-Zp=UqH^J4sk^>_G-Ufn-2Hz z2mN12|C{5}U`^eCQuFz=F%wp@}SzA1MHEaM^CtJs<{}Tzu$bx2orTKiedgmtVGM{ zdd#vX`&cuiec|My_KW;y{Ryz2kFu9}=~us6hvx1ZqQCk(d+>HP>ks>mmHCjjDh{pe zKQkKpk0SeDX#XMqf$}QV{z=xrN!mQczJAvud@;zFqaU1ocq==Py)qsa=8UKrt!J7r z{RsTo^rgtZo%$rak)DN*D)!(Y^$@yL6Nd=#eu&?unzhH8yq>v{gkt8xcG3S%H)-y_ zqQ1|v|JT$0R~Y}omg2Y+nDvR+K|kzR5i^fmKF>j~N;A35Vr`JWh4yRqKl#P|qlx?` z@|CmBiP}ysYO%m2{eBG6&ix5 zr#u((F2{vb=W4jNmTQh3M^F2o80T49?w>*rv0mt)-o1y!{hRk`E#UVPdna6jnz`rw dKpn)r^--YJZpr;bYU`N~>#v3X5BRU&{{=gv-{1fM diff --git a/reflex/.templates/apps/sidebar/assets/github.svg b/reflex/.templates/apps/sidebar/assets/github.svg deleted file mode 100644 index 61c9d791b..000000000 --- a/reflex/.templates/apps/sidebar/assets/github.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/reflex/.templates/apps/sidebar/assets/logo.svg b/reflex/.templates/apps/sidebar/assets/logo.svg deleted file mode 100644 index 94fe1f511..000000000 --- a/reflex/.templates/apps/sidebar/assets/logo.svg +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/reflex/.templates/apps/sidebar/assets/paneleft.svg b/reflex/.templates/apps/sidebar/assets/paneleft.svg deleted file mode 100644 index ac9c5040a..000000000 --- a/reflex/.templates/apps/sidebar/assets/paneleft.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/reflex/.templates/apps/sidebar/assets/reflex_black.svg b/reflex/.templates/apps/sidebar/assets/reflex_black.svg deleted file mode 100644 index b9cc89da9..000000000 --- a/reflex/.templates/apps/sidebar/assets/reflex_black.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/reflex/.templates/apps/sidebar/assets/reflex_white.svg b/reflex/.templates/apps/sidebar/assets/reflex_white.svg deleted file mode 100644 index 63876bdd1..000000000 --- a/reflex/.templates/apps/sidebar/assets/reflex_white.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/reflex/.templates/apps/sidebar/code/__init__.py b/reflex/.templates/apps/sidebar/code/__init__.py deleted file mode 100644 index e1d286346..000000000 --- a/reflex/.templates/apps/sidebar/code/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Base template for Reflex.""" diff --git a/reflex/.templates/apps/sidebar/code/components/__init__.py b/reflex/.templates/apps/sidebar/code/components/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/reflex/.templates/apps/sidebar/code/components/sidebar.py b/reflex/.templates/apps/sidebar/code/components/sidebar.py deleted file mode 100644 index fbdc56db4..000000000 --- a/reflex/.templates/apps/sidebar/code/components/sidebar.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Sidebar component for the app.""" - -from code import styles - -import reflex as rx - - -def sidebar_header() -> rx.Component: - """Sidebar header. - - Returns: - The sidebar header component. - """ - return rx.hstack( - # The logo. - rx.color_mode_cond( - rx.image(src="/reflex_black.svg", height="2em"), - rx.image(src="/reflex_white.svg", height="2em"), - ), - rx.spacer(), - rx.link( - rx.button( - rx.icon("github"), - color_scheme="gray", - variant="soft", - ), - href="https://github.com/reflex-dev/reflex", - ), - align="center", - width="100%", - border_bottom=styles.border, - padding_x="1em", - padding_y="2em", - ) - - -def sidebar_footer() -> rx.Component: - """Sidebar footer. - - Returns: - The sidebar footer component. - """ - return rx.hstack( - rx.spacer(), - rx.link( - rx.text("Docs"), - href="https://reflex.dev/docs/getting-started/introduction/", - color_scheme="gray", - ), - rx.link( - rx.text("Blog"), - href="https://reflex.dev/blog/", - color_scheme="gray", - ), - width="100%", - border_top=styles.border, - padding="1em", - ) - - -def sidebar_item(text: str, url: str) -> rx.Component: - """Sidebar item. - - Args: - text: The text of the item. - url: The URL of the item. - - Returns: - rx.Component: The sidebar item component. - """ - # Whether the item is active. - active = (rx.State.router.page.path == f"/{text.lower()}") | ( - (rx.State.router.page.path == "/") & text == "Home" - ) - - return rx.link( - rx.hstack( - rx.text( - text, - ), - bg=rx.cond( - active, - rx.color("accent", 2), - "transparent", - ), - border=rx.cond( - active, - f"1px solid {rx.color('accent', 6)}", - f"1px solid {rx.color('gray', 6)}", - ), - color=rx.cond( - active, - styles.accent_text_color, - styles.text_color, - ), - align="center", - border_radius=styles.border_radius, - width="100%", - padding="1em", - ), - href=url, - width="100%", - ) - - -def sidebar() -> rx.Component: - """The sidebar. - - Returns: - The sidebar component. - """ - # Get all the decorated pages and add them to the sidebar. - from reflex.page import get_decorated_pages - - return rx.box( - rx.vstack( - sidebar_header(), - rx.vstack( - *[ - sidebar_item( - text=page.get("title", page["route"].strip("/").capitalize()), - url=page["route"], - ) - for page in get_decorated_pages() - ], - width="100%", - overflow_y="auto", - align_items="flex-start", - padding="1em", - ), - rx.spacer(), - sidebar_footer(), - height="100dvh", - ), - display=["none", "none", "block"], - min_width=styles.sidebar_width, - height="100%", - position="sticky", - top="0px", - border_right=styles.border, - ) diff --git a/reflex/.templates/apps/sidebar/code/pages/__init__.py b/reflex/.templates/apps/sidebar/code/pages/__init__.py deleted file mode 100644 index 8e5da1a38..000000000 --- a/reflex/.templates/apps/sidebar/code/pages/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .dashboard import dashboard -from .index import index -from .settings import settings diff --git a/reflex/.templates/apps/sidebar/code/pages/dashboard.py b/reflex/.templates/apps/sidebar/code/pages/dashboard.py deleted file mode 100644 index f64f9158d..000000000 --- a/reflex/.templates/apps/sidebar/code/pages/dashboard.py +++ /dev/null @@ -1,22 +0,0 @@ -"""The dashboard page.""" - -from code.templates import template - -import reflex as rx - - -@template(route="/dashboard", title="Dashboard") -def dashboard() -> rx.Component: - """The dashboard page. - - Returns: - The UI for the dashboard page. - """ - return rx.vstack( - rx.heading("Dashboard", size="8"), - rx.text("Welcome to Reflex!"), - rx.text( - "You can edit this page in ", - rx.code("{your_app}/pages/dashboard.py"), - ), - ) diff --git a/reflex/.templates/apps/sidebar/code/pages/index.py b/reflex/.templates/apps/sidebar/code/pages/index.py deleted file mode 100644 index e79ab75c2..000000000 --- a/reflex/.templates/apps/sidebar/code/pages/index.py +++ /dev/null @@ -1,18 +0,0 @@ -"""The home page of the app.""" - -from code import styles -from code.templates import template - -import reflex as rx - - -@template(route="/", title="Home") -def index() -> rx.Component: - """The home page. - - Returns: - The UI for the home page. - """ - with open("README.md", encoding="utf-8") as readme: - content = readme.read() - return rx.markdown(content, component_map=styles.markdown_style) diff --git a/reflex/.templates/apps/sidebar/code/pages/settings.py b/reflex/.templates/apps/sidebar/code/pages/settings.py deleted file mode 100644 index 1272c8fe7..000000000 --- a/reflex/.templates/apps/sidebar/code/pages/settings.py +++ /dev/null @@ -1,76 +0,0 @@ -"""The settings page.""" - -from code.templates import ThemeState, template - -import reflex as rx - - -@template(route="/settings", title="Settings") -def settings() -> rx.Component: - """The settings page. - - Returns: - The UI for the settings page. - """ - return rx.vstack( - rx.heading("Settings", size="8"), - rx.hstack( - rx.text("Dark mode: "), - rx.color_mode.switch(), - ), - rx.hstack( - rx.text("Primary color: "), - rx.select( - [ - "tomato", - "red", - "ruby", - "crimson", - "pink", - "plum", - "purple", - "violet", - "iris", - "indigo", - "blue", - "cyan", - "teal", - "jade", - "green", - "grass", - "brown", - "orange", - "sky", - "mint", - "lime", - "yellow", - "amber", - "gold", - "bronze", - "gray", - ], - value=ThemeState.accent_color, - on_change=ThemeState.set_accent_color, - ), - ), - rx.hstack( - rx.text("Secondary color: "), - rx.select( - [ - "gray", - "mauve", - "slate", - "sage", - "olive", - "sand", - ], - value=ThemeState.gray_color, - on_change=ThemeState.set_gray_color, - ), - ), - rx.text( - "You can edit this page in ", - rx.code("{your_app}/pages/settings.py"), - size="1", - ), - ) diff --git a/reflex/.templates/apps/sidebar/code/sidebar.py b/reflex/.templates/apps/sidebar/code/sidebar.py deleted file mode 100644 index 8b1e2c224..000000000 --- a/reflex/.templates/apps/sidebar/code/sidebar.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Welcome to Reflex!.""" - -# Import all the pages. -from code.pages import * - -import reflex as rx - - -class State(rx.State): - """Define empty state to allow access to rx.State.router.""" - - -# Create the app. -app = rx.App() diff --git a/reflex/.templates/apps/sidebar/code/styles.py b/reflex/.templates/apps/sidebar/code/styles.py deleted file mode 100644 index e999c8845..000000000 --- a/reflex/.templates/apps/sidebar/code/styles.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Styles for the app.""" - -import reflex as rx - -border_radius = "0.375rem" -border = f"1px solid {rx.color('gray', 6)}" -text_color = rx.color("gray", 11) -accent_text_color = rx.color("accent", 10) -accent_color = rx.color("accent", 1) -hover_accent_color = {"_hover": {"color": accent_text_color}} -hover_accent_bg = {"_hover": {"background_color": accent_color}} -content_width_vw = "90vw" -sidebar_width = "20em" - -template_page_style = {"padding_top": "5em", "padding_x": ["auto", "2em"], "flex": "1"} - -template_content_style = { - "align_items": "flex-start", - "border_radius": border_radius, - "padding": "1em", - "margin_bottom": "2em", -} - -link_style = { - "color": accent_text_color, - "text_decoration": "none", - **hover_accent_color, -} - -overlapping_button_style = { - "background_color": "white", - "border_radius": border_radius, -} - -markdown_style = { - "code": lambda text: rx.code(text, color_scheme="gray"), - "codeblock": lambda text, **props: rx.code_block(text, **props, margin_y="1em"), - "a": lambda text, **props: rx.link( - text, - **props, - font_weight="bold", - text_decoration="underline", - text_decoration_color=accent_text_color, - ), -} diff --git a/reflex/.templates/apps/sidebar/code/templates/__init__.py b/reflex/.templates/apps/sidebar/code/templates/__init__.py deleted file mode 100644 index 54af7bc09..000000000 --- a/reflex/.templates/apps/sidebar/code/templates/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .template import ThemeState, template diff --git a/reflex/.templates/apps/sidebar/code/templates/template.py b/reflex/.templates/apps/sidebar/code/templates/template.py deleted file mode 100644 index c1c3acf63..000000000 --- a/reflex/.templates/apps/sidebar/code/templates/template.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Common templates used between pages in the app.""" - -from __future__ import annotations - -from code import styles -from code.components.sidebar import sidebar -from typing import Callable - -import reflex as rx - -# Meta tags for the app. -default_meta = [ - { - "name": "viewport", - "content": "width=device-width, shrink-to-fit=no, initial-scale=1", - }, -] - - -def menu_item_link(text, href): - return rx.menu.item( - rx.link( - text, - href=href, - width="100%", - color="inherit", - ), - _hover={ - "color": styles.accent_color, - "background_color": styles.accent_text_color, - }, - ) - - -def menu_button() -> rx.Component: - """The menu button on the top right of the page. - - Returns: - The menu button component. - """ - from reflex.page import get_decorated_pages - - return rx.box( - rx.menu.root( - rx.menu.trigger( - rx.button( - rx.icon("menu"), - variant="soft", - ) - ), - rx.menu.content( - *[ - menu_item_link(page["title"], page["route"]) - for page in get_decorated_pages() - ], - rx.menu.separator(), - menu_item_link("About", "https://github.com/reflex-dev"), - menu_item_link("Contact", "mailto:founders@=reflex.dev"), - ), - ), - position="fixed", - right="2em", - top="2em", - z_index="500", - ) - - -class ThemeState(rx.State): - """The state for the theme of the app.""" - - accent_color: str = "crimson" - - gray_color: str = "gray" - - -def template( - route: str | None = None, - title: str | None = None, - description: str | None = None, - meta: str | None = None, - script_tags: list[rx.Component] | None = None, - on_load: rx.event.EventHandler | list[rx.event.EventHandler] | None = None, -) -> Callable[[Callable[[], rx.Component]], rx.Component]: - """The template for each page of the app. - - Args: - route: The route to reach the page. - title: The title of the page. - description: The description of the page. - meta: Additionnal meta to add to the page. - on_load: The event handler(s) called when the page load. - script_tags: Scripts to attach to the page. - - Returns: - The template with the page content. - """ - - def decorator(page_content: Callable[[], rx.Component]) -> rx.Component: - """The template for each page of the app. - - Args: - page_content: The content of the page. - - Returns: - The template with the page content. - """ - # Get the meta tags for the page. - all_meta = [*default_meta, *(meta or [])] - - def templated_page(): - return rx.hstack( - sidebar(), - rx.box( - rx.box( - page_content(), - **styles.template_content_style, - ), - **styles.template_page_style, - ), - menu_button(), - align="start", - background=f"radial-gradient(circle at top right, {rx.color('accent', 2)}, {rx.color('mauve', 1)});", - position="relative", - ) - - @rx.page( - route=route, - title=title, - description=description, - meta=all_meta, - script_tags=script_tags, - on_load=on_load, - ) - def theme_wrap(): - return rx.theme( - templated_page(), - has_background=True, - accent_color=ThemeState.accent_color, - gray_color=ThemeState.gray_color, - ) - - return theme_wrap - - return decorator diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 012ff0c3c..95f7c5a05 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -646,7 +646,12 @@ export const useEventLoop = ( // Route after the initial page hydration. useEffect(() => { - const change_start = () => dispatch["state"]({is_hydrated: false}) + const change_start = () => { + const main_state_dispatch = dispatch["state"] + if (main_state_dispatch !== undefined) { + main_state_dispatch({is_hydrated: false}) + } + } const change_complete = () => addEvents(onLoadInternalEvent()); router.events.on("routeChangeStart", change_start); router.events.on("routeChangeComplete", change_complete); diff --git a/reflex/constants/base.py b/reflex/constants/base.py index 11d73a7c1..733859ad4 100644 --- a/reflex/constants/base.py +++ b/reflex/constants/base.py @@ -88,12 +88,11 @@ class ReflexHostingCLI(SimpleNamespace): class Templates(SimpleNamespace): """Constants related to Templates.""" - # Dynamically get the enum values from the .templates folder - template_dir = os.path.join(Reflex.ROOT_DIR, Reflex.MODULE_NAME, ".templates/apps") - template_dirs = next(os.walk(template_dir))[1] + # The route on Reflex backend to query which templates are available and their URLs. + APP_TEMPLATES_ROUTE = "/app-templates" - # Create an enum value for each directory in the .templates folder - Kind = Enum("Kind", {template.upper(): template for template in template_dirs}) + # The default template + DEFAULT = "blank" class Dirs(SimpleNamespace): """Folders used by the template system of Reflex.""" diff --git a/reflex/constants/base.pyi b/reflex/constants/base.pyi new file mode 100644 index 000000000..90804a080 --- /dev/null +++ b/reflex/constants/base.pyi @@ -0,0 +1,94 @@ +"""Stub file for reflex/constants/base.py""" +# ------------------- DO NOT EDIT ---------------------- +# This file was generated by `reflex/utils/pyi_generator.py`! +# ------------------------------------------------------ + +from typing import Any, Dict, Literal, Optional, Union, overload +from reflex.vars import Var, BaseVar, ComputedVar +from reflex.event import EventChain, EventHandler, EventSpec +from reflex.style import Style +import os +import platform +from enum import Enum +from importlib import metadata +from types import SimpleNamespace +from platformdirs import PlatformDirs + +IS_WINDOWS = platform.system() == "Windows" + +class Dirs(SimpleNamespace): + WEB = ".web" + APP_ASSETS = "assets" + UTILS = "utils" + STATIC = "_static" + STATE_PATH = "/".join([UTILS, "state"]) + COMPONENTS_PATH = "/".join([UTILS, "components"]) + CONTEXTS_PATH = "/".join([UTILS, "context"]) + WEB_PAGES = os.path.join(WEB, "pages") + WEB_STATIC = os.path.join(WEB, STATIC) + WEB_UTILS = os.path.join(WEB, UTILS) + WEB_ASSETS = os.path.join(WEB, "public") + ENV_JSON = os.path.join(WEB, "env.json") + REFLEX_JSON = os.path.join(WEB, "reflex.json") + POSTCSS_JS = os.path.join(WEB, "postcss.config.js") + +class Reflex(SimpleNamespace): + MODULE_NAME = "reflex" + VERSION = metadata.version(MODULE_NAME) + JSON = os.path.join(Dirs.WEB, "reflex.json") + _dir = os.environ.get("REFLEX_DIR", "") + DIR = _dir or PlatformDirs(MODULE_NAME, False).user_data_dir + ROOT_DIR = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + +class ReflexHostingCLI(SimpleNamespace): + MODULE_NAME = "reflex-hosting-cli" + +class Templates(SimpleNamespace): + APP_TEMPLATES_ROUTE = "/app-templates" + DEFAULT = "blank" + + class Dirs(SimpleNamespace): + BASE = os.path.join(Reflex.ROOT_DIR, Reflex.MODULE_NAME, ".templates") + WEB_TEMPLATE = os.path.join(BASE, "web") + JINJA_TEMPLATE = os.path.join(BASE, "jinja") + CODE = "code" + +class Next(SimpleNamespace): + CONFIG_FILE = "next.config.js" + SITEMAP_CONFIG_FILE = os.path.join(Dirs.WEB, "next-sitemap.config.js") + NODE_MODULES = "node_modules" + PACKAGE_LOCK = "package-lock.json" + FRONTEND_LISTENING_REGEX = "Local:[\\s]+(.*)" + +class ColorMode(SimpleNamespace): + NAME = "colorMode" + USE = "useColorMode" + TOGGLE = "toggleColorMode" + +class Env(str, Enum): + DEV = "dev" + PROD = "prod" + +class LogLevel(str, Enum): + DEBUG = "debug" + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + +POLLING_MAX_HTTP_BUFFER_SIZE = 1000 * 1000 + +class Ping(SimpleNamespace): + INTERVAL = 25 + TIMEOUT = 120 + +COOKIES = "cookies" +LOCAL_STORAGE = "local_storage" +SKIP_COMPILE_ENV_VAR = "__REFLEX_SKIP_COMPILE" +ENV_MODE_ENV_VAR = "REFLEX_ENV_MODE" +PYTEST_CURRENT_TEST = "PYTEST_CURRENT_TEST" +RELOAD_CONFIG = "__REFLEX_RELOAD_CONFIG" +REFLEX_VAR_OPENING_TAG = "" +REFLEX_VAR_CLOSING_TAG = "" diff --git a/reflex/custom_components/custom_components.py b/reflex/custom_components/custom_components.py index 40bd3a8d9..030942c50 100644 --- a/reflex/custom_components/custom_components.py +++ b/reflex/custom_components/custom_components.py @@ -145,7 +145,7 @@ def _populate_demo_app(name_variants: NameVariants): with set_directory(demo_app_dir): # We start with the blank template as basis. - _init(name=demo_app_name, template=constants.Templates.Kind.BLANK) + _init(name=demo_app_name, template=constants.Templates.DEFAULT) # Then overwrite the app source file with the one we want for testing custom components. # This source file is rendered using jinja template file. with open(f"{demo_app_name}/{demo_app_name}.py", "w") as f: diff --git a/reflex/reflex.py b/reflex/reflex.py index e7496c2ea..754d96af9 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -63,7 +63,7 @@ def main( def _init( name: str, - template: constants.Templates.Kind | None = constants.Templates.Kind.BLANK, + template: str | None = None, loglevel: constants.LogLevel = config.loglevel, ): """Initialize a new Reflex app in the given directory.""" @@ -79,31 +79,20 @@ def _init( app_name = prerequisites.validate_app_name(name) console.rule(f"[bold]Initializing {app_name}") + # Check prerequisites. prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME) - prerequisites.initialize_reflex_user_directory() - prerequisites.ensure_reflex_installation_id() # When upgrading to 0.4, show migration instructions. if prerequisites.should_show_rx_chakra_migration_instructions(): prerequisites.show_rx_chakra_migration_instructions() - # Set up the app directory, only if the config doesn't exist. - if not os.path.exists(constants.Config.FILE): - if template is None: - template = prerequisites.prompt_for_template() - prerequisites.create_config(app_name) - prerequisites.initialize_app_directory(app_name, template) - telemetry_event = "init" - else: - telemetry_event = "reinit" - # Set up the web project. prerequisites.initialize_frontend_dependencies() - # Send the telemetry event after the .web folder is initialized. - telemetry.send(telemetry_event) + # Initialize the app. + prerequisites.initialize_app(app_name, template) # Migrate Pynecone projects to Reflex. prerequisites.migrate_to_reflex() @@ -123,7 +112,7 @@ def init( name: str = typer.Option( None, metavar="APP_NAME", help="The name of the app to initialize." ), - template: constants.Templates.Kind = typer.Option( + template: str = typer.Option( None, help="The template to initialize the app with.", ), @@ -576,20 +565,6 @@ def demo( # Open the demo app in a terminal. webbrowser.open("https://demo.reflex.run") - # Later: open the demo app locally. - # with tempfile.TemporaryDirectory() as tmp_dir: - # os.chdir(tmp_dir) - # _init( - # name="reflex_demo", - # template=constants.Templates.Kind.DEMO, - # loglevel=constants.LogLevel.DEBUG, - # ) - # _run( - # frontend_port=frontend_port, - # backend_port=backend_port, - # loglevel=constants.LogLevel.DEBUG, - # ) - cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.") cli.add_typer(script_cli, name="script", help="Subcommands running helper scripts.") diff --git a/reflex/testing.py b/reflex/testing.py index fcaeefdff..9a90a5c6b 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -223,7 +223,7 @@ class AppHarness: with chdir(self.app_path): reflex.reflex._init( name=self.app_name, - template=reflex.constants.Templates.Kind.BLANK, + template=reflex.constants.Templates.DEFAULT, loglevel=reflex.constants.LogLevel.INFO, ) self.app_module_path.write_text(source_code) diff --git a/reflex/utils/console.py b/reflex/utils/console.py index ef37a2aa5..3dfff2828 100644 --- a/reflex/utils/console.py +++ b/reflex/utils/console.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import List, Optional - from rich.console import Console from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn from rich.prompt import Prompt @@ -150,7 +148,10 @@ def error(msg: str, **kwargs): def ask( - question: str, choices: Optional[List[str]] = None, default: Optional[str] = None + question: str, + choices: list[str] | None = None, + default: str | None = None, + show_choices: bool = True, ) -> str: """Takes a prompt question and optionally a list of choices and returns the user input. @@ -159,11 +160,12 @@ def ask( question: The question to ask the user. choices: A list of choices to select from. default: The default option selected. + show_choices: Whether to show the choices. Returns: A string with the user input. """ - return Prompt.ask(question, choices=choices, default=default) # type: ignore + return Prompt.ask(question, choices=choices, default=default, show_choices=show_choices) # type: ignore def progress(): diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 0d73ea247..ac445bf08 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -10,6 +10,7 @@ import os import platform import random import re +import shutil import stat import sys import tempfile @@ -30,6 +31,7 @@ from redis.asyncio import Redis import reflex from reflex import constants, model +from reflex.base import Base from reflex.compiler import templates from reflex.config import Config, get_config from reflex.utils import console, path_ops, processes @@ -37,6 +39,15 @@ from reflex.utils import console, path_ops, processes CURRENTLY_INSTALLING_NODE = False +class Template(Base): + """A template for a Reflex app.""" + + name: str + description: str + code_url: str + demo_url: str + + def check_latest_package_version(package_name: str): """Check if the latest version of the package is installed. @@ -407,17 +418,42 @@ def initialize_requirements_txt(): console.info(f"Unable to check {fp} for reflex dependency.") -def initialize_app_directory(app_name: str, template: constants.Templates.Kind): +def initialize_app_directory( + app_name: str, + template_name: str = constants.Templates.DEFAULT, + template_code_dir_name: str | None = None, + template_dir: Path | None = None, +): """Initialize the app directory on reflex init. Args: app_name: The name of the app. - template: The template to use. + template_name: The name of the template to use. + template_code_dir_name: The name of the code directory in the template. + template_dir: The directory of the template source files. + + Raises: + Exit: If template_name, template_code_dir_name, template_dir combination is not supported. """ console.log("Initializing the app directory.") - # Copy the template to the current directory. - template_dir = Path(constants.Templates.Dirs.BASE, "apps", template.value) + # By default, use the blank template from local assets. + if template_name == constants.Templates.DEFAULT: + if template_code_dir_name is not None or template_dir is not None: + console.error( + f"Only {template_name=} should be provided, got {template_code_dir_name=}, {template_dir=}." + ) + raise typer.Exit(1) + template_code_dir_name = constants.Templates.Dirs.CODE + template_dir = Path(constants.Templates.Dirs.BASE, "apps", template_name) + else: + if template_code_dir_name is None or template_dir is None: + console.error( + f"For `{template_name}` template, `template_code_dir_name` and `template_dir` should both be provided." + ) + raise typer.Exit(1) + + console.debug(f"Using {template_name=} {template_dir=} {template_code_dir_name=}.") # Remove all pyc and __pycache__ dirs in template directory. for pyc_file in template_dir.glob("**/*.pyc"): @@ -430,16 +466,16 @@ def initialize_app_directory(app_name: str, template: constants.Templates.Kind): path_ops.cp(str(file), file.name) # Rename the template app to the app name. - path_ops.mv(constants.Templates.Dirs.CODE, app_name) + path_ops.mv(template_code_dir_name, app_name) path_ops.mv( - os.path.join(app_name, template_dir.name + constants.Ext.PY), + os.path.join(app_name, template_name + constants.Ext.PY), os.path.join(app_name, app_name + constants.Ext.PY), ) # Fix up the imports. path_ops.find_replace( app_name, - f"from {constants.Templates.Dirs.CODE}", + f"from {template_name}", f"from {app_name}", ) @@ -999,33 +1035,35 @@ def check_schema_up_to_date(): ) -def prompt_for_template() -> constants.Templates.Kind: +def prompt_for_template(templates: list[Template]) -> str: """Prompt the user to specify a template. + Args: + templates: The templates to choose from. + Returns: - The template the user selected. + The template name the user selects. """ - # Show the user the URLs of each temlate to preview. + # Show the user the URLs of each template to preview. console.print("\nGet started with a template:") - console.print("blank (https://blank-template.reflex.run) - A minimal template.") - console.print( - "sidebar (https://sidebar-template.reflex.run) - A template with a sidebar to navigate pages." - ) - console.print("") # Prompt the user to select a template. + id_to_name = { + str(idx): f"{template.name} ({template.demo_url}) - {template.description}" + for idx, template in enumerate(templates) + } + for id in range(len(id_to_name)): + console.print(f"({id}) {id_to_name[str(id)]}") + template = console.ask( "Which template would you like to use?", - choices=[ - template.value - for template in constants.Templates.Kind - if template.value != "demo" - ], - default=constants.Templates.Kind.BLANK.value, + choices=[str(i) for i in range(len(id_to_name))], + show_choices=False, + default="0", ) # Return the template. - return constants.Templates.Kind(template) + return templates[int(template)].name def should_show_rx_chakra_migration_instructions() -> bool: @@ -1178,3 +1216,166 @@ def migrate_to_reflex(): for old, new in updates.items(): line = line.replace(old, new) print(line, end="") + + +def fetch_app_templates() -> dict[str, Template]: + """Fetch the list of app templates from the Reflex backend server. + + Returns: + The name and download URL as a dictionary. + """ + config = get_config() + if not config.cp_backend_url: + console.info( + "Skip fetching App templates. No backend URL is specified in the config." + ) + return {} + try: + response = httpx.get( + f"{config.cp_backend_url}{constants.Templates.APP_TEMPLATES_ROUTE}" + ) + response.raise_for_status() + return { + template["name"]: Template.parse_obj(template) + for template in response.json() + } + except httpx.HTTPError as ex: + console.info(f"Failed to fetch app templates: {ex}") + return {} + except (TypeError, KeyError, json.JSONDecodeError) as tkje: + console.info(f"Unable to process server response for app templates: {tkje}") + return {} + + +def create_config_init_app_from_remote_template( + app_name: str, + template_url: str, +): + """Create new rxconfig and initialize app using a remote template. + + Args: + app_name: The name of the app. + template_url: The path to the template source code as a zip file. + + Raises: + Exit: If any download, file operations fail or unexpected zip file format. + + """ + # Create a temp directory for the zip download. + try: + temp_dir = tempfile.mkdtemp() + except OSError as ose: + console.error(f"Failed to create temp directory for download: {ose}") + raise typer.Exit(1) from ose + + # Use httpx GET with redirects to download the zip file. + zip_file_path = Path(temp_dir) / "template.zip" + try: + # Note: following redirects can be risky. We only allow this for reflex built templates at the moment. + response = httpx.get(template_url, follow_redirects=True) + console.debug(f"Server responded download request: {response}") + response.raise_for_status() + except httpx.HTTPError as he: + console.error(f"Failed to download the template: {he}") + raise typer.Exit(1) from he + try: + with open(zip_file_path, "wb") as f: + f.write(response.content) + console.debug(f"Downloaded the zip to {zip_file_path}") + except OSError as ose: + console.error(f"Unable to write the downloaded zip to disk {ose}") + raise typer.Exit(1) from ose + + # Create a temp directory for the zip extraction. + try: + unzip_dir = Path(tempfile.mkdtemp()) + except OSError as ose: + console.error(f"Failed to create temp directory for extracting zip: {ose}") + raise typer.Exit(1) from ose + try: + zipfile.ZipFile(zip_file_path).extractall(path=unzip_dir) + # The zip file downloaded from github looks like: + # repo-name-branch/**/*, so we need to remove the top level directory. + if len(subdirs := os.listdir(unzip_dir)) != 1: + console.error(f"Expected one directory in the zip, found {subdirs}") + raise typer.Exit(1) + template_dir = unzip_dir / subdirs[0] + console.debug(f"Template folder is located at {template_dir}") + except Exception as uze: + console.error(f"Failed to unzip the template: {uze}") + raise typer.Exit(1) from uze + + # Move the rxconfig file here first. + path_ops.mv(str(template_dir / constants.Config.FILE), constants.Config.FILE) + new_config = get_config(reload=True) + + # Get the template app's name from rxconfig in case it is different than + # the source code repo name on github. + template_name = new_config.app_name + + create_config(app_name) + initialize_app_directory( + app_name, + template_name=template_name, + template_code_dir_name=template_name, + template_dir=template_dir, + ) + + # Clean up the temp directories. + shutil.rmtree(temp_dir) + shutil.rmtree(unzip_dir) + + +def initialize_app(app_name: str, template: str | None = None): + """Initialize the app either from a remote template or a blank app. If the config file exists, it is considered as reinit. + + Args: + app_name: The name of the app. + template: The name of the template to use. + + Raises: + Exit: If template is directly provided in the command flag and is invalid. + """ + # Local imports to avoid circular imports. + from reflex.utils import telemetry + + # Check if the app is already initialized. + if os.path.exists(constants.Config.FILE): + telemetry.send("reinit") + return + + # Get the available templates + templates: dict[str, Template] = fetch_app_templates() + + # Prompt for a template if not provided. + if template is None and len(templates) > 0: + template = prompt_for_template(list(templates.values())) + elif template is None: + template = constants.Templates.DEFAULT + assert template is not None + + # If the blank template is selected, create a blank app. + if template == constants.Templates.DEFAULT: + # Default app creation behavior: a blank app. + create_config(app_name) + initialize_app_directory(app_name) + else: + # Fetch App templates from the backend server. + console.debug(f"Available templates: {templates}") + + # If user selects a template, it needs to exist. + if template in templates: + template_url = templates[template].code_url + else: + # Check if the template is a github repo. + if template.startswith("https://github.com"): + template_url = f"{template.strip('/')}/archive/main.zip" + else: + console.error(f"Template `{template}` not found.") + raise typer.Exit(1) + create_config_init_app_from_remote_template( + app_name=app_name, + template_url=template_url, + ) + + telemetry.send("init")