442 lines
14 KiB
Python
442 lines
14 KiB
Python
"""This module provides utilities for managing Reflex app templates."""
|
|
|
|
import dataclasses
|
|
import shutil
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from reflex_base import constants
|
|
from reflex_base.config import get_config
|
|
|
|
from reflex.utils import console, net, path_ops, redir
|
|
from reflex.utils.rename import rename_imports_and_app_name
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Template:
|
|
"""A template for a Reflex app."""
|
|
|
|
name: str
|
|
description: str
|
|
code_url: str
|
|
|
|
|
|
def create_config(app_name: str):
|
|
"""Create a new rxconfig file.
|
|
|
|
Args:
|
|
app_name: The name of the app.
|
|
"""
|
|
# Import here to avoid circular imports.
|
|
from reflex.compiler import templates
|
|
|
|
console.debug(f"Creating {constants.Config.FILE}")
|
|
constants.Config.FILE.write_text(templates.rxconfig_template(app_name=app_name))
|
|
|
|
|
|
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_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:
|
|
SystemExit: If template_name, template_code_dir_name, template_dir combination is not supported.
|
|
"""
|
|
console.log("Initializing the app directory.")
|
|
|
|
# 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 SystemExit(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 SystemExit(1)
|
|
|
|
console.debug(f"Using {template_name=} {template_dir=} {template_code_dir_name=}.")
|
|
|
|
# Remove __pycache__ dirs in template directory and current directory.
|
|
for pycache_dir in [
|
|
*template_dir.glob("**/__pycache__"),
|
|
*Path.cwd().glob("**/__pycache__"),
|
|
]:
|
|
shutil.rmtree(pycache_dir, ignore_errors=True)
|
|
|
|
for file in template_dir.iterdir():
|
|
# Copy the file to current directory but keep the name the same.
|
|
path_ops.cp(str(file), file.name)
|
|
|
|
# Rename the template app to the app name.
|
|
path_ops.mv(template_code_dir_name, app_name)
|
|
path_ops.mv(
|
|
Path(app_name) / (template_name + constants.Ext.PY),
|
|
Path(app_name) / (app_name + constants.Ext.PY),
|
|
)
|
|
|
|
# Fix up the imports.
|
|
path_ops.find_replace(
|
|
app_name,
|
|
f"from {template_name}",
|
|
f"from {app_name}",
|
|
)
|
|
|
|
|
|
def initialize_default_app(app_name: str):
|
|
"""Initialize the default app.
|
|
|
|
Args:
|
|
app_name: The name of the app.
|
|
"""
|
|
create_config(app_name)
|
|
initialize_app_directory(app_name)
|
|
|
|
|
|
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:
|
|
SystemExit: If any download, file operations fail or unexpected zip file format.
|
|
|
|
"""
|
|
import httpx
|
|
|
|
# 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 SystemExit(1) from None
|
|
|
|
# Use httpx GET with redirects to download the zip file.
|
|
zip_file_path: 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 = net.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 SystemExit(1) from None
|
|
try:
|
|
zip_file_path.write_bytes(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 SystemExit(1) from None
|
|
|
|
# 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 SystemExit(1) from None
|
|
|
|
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.
|
|
except Exception as uze:
|
|
console.error(f"Failed to unzip the template: {uze}")
|
|
raise SystemExit(1) from None
|
|
|
|
if len(subdirs := list(unzip_dir.iterdir())) != 1:
|
|
console.error(f"Expected one directory in the zip, found {subdirs}")
|
|
raise SystemExit(1)
|
|
|
|
template_dir = unzip_dir / subdirs[0]
|
|
console.debug(f"Template folder is located at {template_dir}")
|
|
|
|
# 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
|
|
|
|
# Rewrite in place instead of regenerating from a stock template, so the
|
|
# template's own config (db_url, redis_url, plugins, etc.) is preserved.
|
|
rename_imports_and_app_name(constants.Config.FILE, template_name, app_name)
|
|
initialize_app_directory(
|
|
app_name,
|
|
template_name=template_name,
|
|
template_code_dir_name=template_name,
|
|
template_dir=template_dir,
|
|
)
|
|
pyproject_file = Path(constants.PyprojectToml.FILE)
|
|
req_file = Path(constants.RequirementsTxt.FILE)
|
|
if pyproject_file.exists():
|
|
console.info(
|
|
"Run `uv sync` to install the required Python packages for this template."
|
|
)
|
|
elif req_file.exists() and len(req_file.read_text().splitlines()) > 1:
|
|
console.info(
|
|
"Run `uv pip install -r requirements.txt` to install the required Python packages for this template."
|
|
)
|
|
# Clean up the temp directories.
|
|
shutil.rmtree(temp_dir)
|
|
shutil.rmtree(unzip_dir)
|
|
|
|
|
|
def validate_and_create_app_using_remote_template(
|
|
app_name: str, template: str, templates: dict[str, Template]
|
|
):
|
|
"""Validate and create an app using a remote template.
|
|
|
|
Args:
|
|
app_name: The name of the app.
|
|
template: The name of the template.
|
|
templates: The available templates.
|
|
|
|
Raises:
|
|
SystemExit: If the template is not found.
|
|
"""
|
|
# If user selects a template, it needs to exist.
|
|
if template in templates:
|
|
from reflex_cli.v2.utils import hosting
|
|
|
|
authenticated_token = hosting.authenticated_token()
|
|
if not authenticated_token or not authenticated_token[0]:
|
|
console.print(
|
|
f"Please use `reflex login` to access the '{template}' template."
|
|
)
|
|
raise SystemExit(3)
|
|
|
|
template_url = templates[template].code_url
|
|
else:
|
|
template_parsed_url = urlparse(template)
|
|
# Check if the template is a github repo.
|
|
if template_parsed_url.hostname == "github.com":
|
|
path = template_parsed_url.path.strip("/").removesuffix(".git")
|
|
template_url = f"https://github.com/{path}/archive/main.zip"
|
|
else:
|
|
console.error(f"Template `{template}` not found or invalid.")
|
|
raise SystemExit(1)
|
|
|
|
if template_url is None:
|
|
return
|
|
|
|
create_config_init_app_from_remote_template(
|
|
app_name=app_name, template_url=template_url
|
|
)
|
|
|
|
|
|
def fetch_app_templates(version: str) -> dict[str, Template]:
|
|
"""Fetch a dict of templates from the templates repo using github API.
|
|
|
|
Args:
|
|
version: The version of the templates to fetch.
|
|
|
|
Returns:
|
|
The dict of templates.
|
|
"""
|
|
|
|
def get_release_by_tag(tag: str) -> dict | None:
|
|
url = f"{constants.Reflex.RELEASES_URL}/tags/v{tag}"
|
|
response = net.get(url)
|
|
if response.status_code == 404:
|
|
return None
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
release = get_release_by_tag(version)
|
|
if release is None:
|
|
console.warn(f"No templates known for version {version}")
|
|
return {}
|
|
|
|
asset_map = {
|
|
a["name"]: a["browser_download_url"] for a in release.get("assets", [])
|
|
}
|
|
|
|
templates_url = asset_map.get("templates.json")
|
|
if not templates_url:
|
|
console.warn(f"Templates metadata not found for version {version}")
|
|
return {}
|
|
|
|
templates_data = (
|
|
net.get(templates_url, follow_redirects=True).json().get("templates", [])
|
|
)
|
|
|
|
known_fields = {f.name for f in dataclasses.fields(Template)}
|
|
|
|
filtered_templates = {}
|
|
for template in templates_data:
|
|
code_url = (
|
|
""
|
|
if template["name"] == "blank"
|
|
else asset_map.get(f"{template['name']}.zip")
|
|
)
|
|
if template["hidden"] or code_url is None:
|
|
continue
|
|
filtered_templates[template["name"]] = Template(
|
|
**{k: v for k, v in template.items() if k in known_fields},
|
|
code_url=code_url,
|
|
)
|
|
return filtered_templates
|
|
|
|
|
|
def fetch_remote_templates(
|
|
template: str,
|
|
) -> tuple[str, dict[str, Template]]:
|
|
"""Fetch the available remote templates.
|
|
|
|
Args:
|
|
template: The name of the template.
|
|
|
|
Returns:
|
|
The selected template and the available templates.
|
|
"""
|
|
available_templates = {}
|
|
|
|
try:
|
|
# Get the available templates
|
|
available_templates = fetch_app_templates(constants.Reflex.VERSION)
|
|
except Exception as e:
|
|
console.warn("Failed to fetch templates. Falling back to default template.")
|
|
console.debug(f"Error while fetching templates: {e}")
|
|
template = constants.Templates.DEFAULT
|
|
|
|
return template, available_templates
|
|
|
|
|
|
def prompt_for_template_options(templates: list[Template]) -> str:
|
|
"""Prompt the user to specify a template.
|
|
|
|
Args:
|
|
templates: The templates to choose from.
|
|
|
|
Returns:
|
|
The template name the user selects.
|
|
|
|
Raises:
|
|
SystemExit: If the user does not select a template.
|
|
"""
|
|
# Show the user the URLs of each template to preview.
|
|
console.print("\nGet started with a template:")
|
|
|
|
# Prompt the user to select a template.
|
|
for index, template in enumerate(templates):
|
|
console.print(f"({index}) {template.description}")
|
|
|
|
template = console.ask(
|
|
"Which template would you like to use?",
|
|
choices=[str(i) for i in range(len(templates))],
|
|
show_choices=False,
|
|
default="0",
|
|
)
|
|
|
|
if not template:
|
|
console.error("No template selected.")
|
|
raise SystemExit(1)
|
|
|
|
try:
|
|
template_index = int(template)
|
|
except ValueError:
|
|
console.error("Invalid template selected.")
|
|
raise SystemExit(1) from None
|
|
|
|
if template_index < 0 or template_index >= len(templates):
|
|
console.error("Invalid template selected.")
|
|
raise SystemExit(1)
|
|
|
|
# Return the template.
|
|
return templates[template_index].name
|
|
|
|
|
|
def initialize_app(app_name: str, template: str | None = None) -> str | 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.
|
|
|
|
Returns:
|
|
The name of the template.
|
|
|
|
Raises:
|
|
SystemExit: If the template is not valid or unspecified.
|
|
"""
|
|
# Local imports to avoid circular imports.
|
|
from reflex.utils import telemetry
|
|
|
|
# Check if the app is already initialized.
|
|
if constants.Config.FILE.exists():
|
|
telemetry.send("reinit")
|
|
return None
|
|
|
|
templates: dict[str, Template] = {}
|
|
|
|
# Don't fetch app templates if the user directly asked for DEFAULT.
|
|
if template is not None and template != constants.Templates.DEFAULT:
|
|
template, templates = fetch_remote_templates(template)
|
|
|
|
if template is None:
|
|
template = prompt_for_template_options(get_init_cli_prompt_options())
|
|
|
|
if template == constants.Templates.CHOOSE_TEMPLATES:
|
|
redir.reflex_templates()
|
|
raise SystemExit(0)
|
|
|
|
if template == constants.Templates.AI:
|
|
redir.reflex_build_redirect()
|
|
raise SystemExit(0)
|
|
|
|
# If the blank template is selected, create a blank app.
|
|
if template == constants.Templates.DEFAULT:
|
|
# Default app creation behavior: a blank app.
|
|
initialize_default_app(app_name)
|
|
else:
|
|
validate_and_create_app_using_remote_template(
|
|
app_name=app_name, template=template, templates=templates
|
|
)
|
|
|
|
telemetry.send("init", template=template)
|
|
|
|
return template
|
|
|
|
|
|
def get_init_cli_prompt_options() -> list[Template]:
|
|
"""Get the CLI options for initializing a Reflex app.
|
|
|
|
Returns:
|
|
The CLI options.
|
|
"""
|
|
return [
|
|
Template(
|
|
name=constants.Templates.DEFAULT,
|
|
description="A blank Reflex app.",
|
|
code_url="",
|
|
),
|
|
Template(
|
|
name=constants.Templates.CHOOSE_TEMPLATES,
|
|
description="Premade templates built by the Reflex team.",
|
|
code_url="",
|
|
),
|
|
Template(
|
|
name=constants.Templates.AI,
|
|
description="[bold]Try our AI builder.",
|
|
code_url="",
|
|
),
|
|
]
|