[REF-2141] Custom component command improvements (#2807)

* add custom component command improvements

* fix test

* fix regex to include both single/double quotes
This commit is contained in:
Martin Xu 2024-03-07 20:42:22 -08:00 committed by GitHub
parent f5b7eca9c7
commit bf297e2f5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 183 additions and 50 deletions

View File

@ -99,19 +99,19 @@ STYLE = get_template("web/styles/styles.css.jinja2")
# Code that generate the package json file # Code that generate the package json file
PACKAGE_JSON = get_template("web/package.json.jinja2") PACKAGE_JSON = get_template("web/package.json.jinja2")
# Code that generate the pyproject.toml file for custom components # Code that generate the pyproject.toml file for custom components.
CUSTOM_COMPONENTS_PYPROJECT_TOML = get_template( CUSTOM_COMPONENTS_PYPROJECT_TOML = get_template(
"custom_components/pyproject.toml.jinja2" "custom_components/pyproject.toml.jinja2"
) )
# Code that generates the README file for custom components # Code that generates the README file for custom components.
CUSTOM_COMPONENTS_README = get_template("custom_components/README.md.jinja2") CUSTOM_COMPONENTS_README = get_template("custom_components/README.md.jinja2")
# Code that generates the source file for custom components # Code that generates the source file for custom components.
CUSTOM_COMPONENTS_SOURCE = get_template("custom_components/src.py.jinja2") CUSTOM_COMPONENTS_SOURCE = get_template("custom_components/src.py.jinja2")
# Code that generates the init file for custom components # Code that generates the init file for custom components.
CUSTOM_COMPONENTS_INIT_FILE = get_template("custom_components/__init__.py.jinja2") CUSTOM_COMPONENTS_INIT_FILE = get_template("custom_components/__init__.py.jinja2")
# Code that generates the demo app main py file for testing custom components # Code that generates the demo app main py file for testing custom components.
CUSTOM_COMPONENTS_DEMO_APP = get_template("custom_components/demo_app.py.jinja2") CUSTOM_COMPONENTS_DEMO_APP = get_template("custom_components/demo_app.py.jinja2")

View File

@ -28,3 +28,7 @@ class CustomComponents(SimpleNamespace):
"pypi": "https://upload.pypi.org/legacy/", "pypi": "https://upload.pypi.org/legacy/",
"testpypi": "https://test.pypi.org/legacy/", "testpypi": "https://test.pypi.org/legacy/",
} }
# The .gitignore file for the custom component project.
FILE = ".gitignore"
# Files to gitignore.
DEFAULTS = {"__pycache__/", "*.py[cod]", "*.egg-info/", "dist/"}

View File

@ -141,26 +141,40 @@ def _populate_demo_app(name_variants: NameVariants):
module_name=name_variants.module_name, module_name=name_variants.module_name,
) )
) )
# Append the custom component package to the requirements.txt file.
with open(f"{constants.RequirementsTxt.FILE}", "a") as f:
f.write(f"{name_variants.package_name}\n")
def _get_default_library_name_parts() -> list[str]: def _get_default_library_name_parts() -> list[str]:
"""Get the default library name. Based on the current directory name, remove any non-alphanumeric characters. """Get the default library name. Based on the current directory name, remove any non-alphanumeric characters.
Raises: Raises:
ValueError: If the current directory name is not suitable for python projects, and we cannot find a valid library name based off it. Exit: If the current directory name is not suitable for python projects, and we cannot find a valid library name based off it.
Returns: Returns:
The parts of default library name. The parts of default library name.
""" """
current_dir_name = os.getcwd().split(os.path.sep)[-1] current_dir_name = os.getcwd().split(os.path.sep)[-1]
cleaned_dir_name = re.sub("[^0-9a-zA-Z-_]+", "", current_dir_name) cleaned_dir_name = re.sub("[^0-9a-zA-Z-_]+", "", current_dir_name).lower()
parts = re.split("-|_", cleaned_dir_name) parts = [part for part in re.split("-|_", cleaned_dir_name) if part]
if parts and parts[0] == constants.Reflex.MODULE_NAME:
# If the directory name already starts with "reflex", remove it from the parts.
parts = parts[1:]
# If no parts left, cannot find a valid library name, exit.
if not parts:
# The folder likely has a name not suitable for python paths.
console.error(
f"Based on current directory name {current_dir_name}, the library name is {constants.Reflex.MODULE_NAME}. This package already exists. Please use --library-name to specify a different name."
)
raise typer.Exit(code=1)
if not parts: if not parts:
# The folder likely has a name not suitable for python paths. # The folder likely has a name not suitable for python paths.
raise ValueError( console.error(
f"Could not find a valid library name based on the current directory: got {current_dir_name}." f"Could not find a valid library name based on the current directory: got {current_dir_name}."
) )
raise typer.Exit(code=1)
return parts return parts
@ -209,21 +223,21 @@ def _validate_library_name(library_name: str | None) -> NameVariants:
# Component class name is the camel case. # Component class name is the camel case.
component_class_name = "".join([part.capitalize() for part in name_parts]) component_class_name = "".join([part.capitalize() for part in name_parts])
console.info(f"Component class name: {component_class_name}") console.debug(f"Component class name: {component_class_name}")
# Package name is commonly kebab case. # Package name is commonly kebab case.
package_name = f"reflex-{library_name}" package_name = f"reflex-{library_name}"
console.info(f"Package name: {package_name}") console.debug(f"Package name: {package_name}")
# Module name is the snake case. # Module name is the snake case.
module_name = "_".join(name_parts) module_name = "_".join(name_parts)
custom_component_module_dir = f"reflex_{module_name}" custom_component_module_dir = f"reflex_{module_name}"
console.info(f"Custom component source directory: {custom_component_module_dir}") console.debug(f"Custom component source directory: {custom_component_module_dir}")
# Use the same name for the directory and the app. # Use the same name for the directory and the app.
demo_app_dir = demo_app_name = f"{module_name}_demo" demo_app_dir = demo_app_name = f"{module_name}_demo"
console.info(f"Demo app directory: {demo_app_dir}") console.debug(f"Demo app directory: {demo_app_dir}")
return NameVariants( return NameVariants(
library_name=library_name, library_name=library_name,
@ -304,31 +318,38 @@ def init(
# Check the name follows the convention if picked. # Check the name follows the convention if picked.
name_variants = _validate_library_name(library_name) name_variants = _validate_library_name(library_name)
console.rule(f"[bold]Initializing {name_variants.package_name} project")
_populate_custom_component_project(name_variants) _populate_custom_component_project(name_variants)
_populate_demo_app(name_variants) _populate_demo_app(name_variants)
# Initialize the .gitignore. # Initialize the .gitignore.
prerequisites.initialize_gitignore() prerequisites.initialize_gitignore(
gitignore_file=CustomComponents.FILE, files_to_ignore=CustomComponents.DEFAULTS
)
if install: if install:
package_name = name_variants.package_name package_name = name_variants.package_name
console.info(f"Installing {package_name} in editable mode.") console.rule(f"[bold]Installing {package_name} in editable mode.")
if _pip_install_on_demand(package_name=".", install_args=["-e"]): if _pip_install_on_demand(package_name=".", install_args=["-e"]):
console.info(f"Package {package_name} installed!") console.info(f"Package {package_name} installed!")
else: else:
raise typer.Exit(code=1) raise typer.Exit(code=1)
console.print("Custom component initialized successfully!") console.print("[bold]Custom component initialized successfully!")
console.print("Here's the summary:") console.rule("[bold]Project Summary")
console.print( console.print(
f"{CustomComponents.PYPROJECT_TOML} and {CustomComponents.PACKAGE_README} created. [bold]Please fill in details such as your name, email, homepage URL.[/bold]" f"[ {CustomComponents.PACKAGE_README} ]: Package description. Please add usage examples."
) )
console.print( console.print(
f"Source code template is in {CustomComponents.SRC_DIR}. [bold]Start by editing it with your component implementation.[/bold]" f"[ {CustomComponents.PYPROJECT_TOML} ]: Project configuration file. Please fill in details such as your name, email, homepage URL."
) )
console.print( console.print(
f"Demo app created in {name_variants.demo_app_dir}. [bold]Use this app to test your custom component.[/bold]" f"[ {CustomComponents.SRC_DIR}/ ]: Custom component code template. Start by editing it with your component implementation."
)
console.print(
f"[ {name_variants.demo_app_dir}/ ]: Demo App. Add more code to this app and test."
) )
@ -379,6 +400,21 @@ def _run_commands_in_subprocess(cmds: list[str]) -> bool:
return False return False
def _run_build():
"""Run the build command.
Raises:
Exit: If the build fails.
"""
console.print("Building custom component...")
cmds = [sys.executable, "-m", "build", "."]
if _run_commands_in_subprocess(cmds):
console.info("Custom component built successfully!")
else:
raise typer.Exit(code=1)
@custom_components_cli.command(name="build") @custom_components_cli.command(name="build")
def build( def build(
loglevel: constants.LogLevel = typer.Option( loglevel: constants.LogLevel = typer.Option(
@ -389,18 +425,9 @@ def build(
Args: Args:
loglevel: The log level to use. loglevel: The log level to use.
Raises:
Exit: If the build fails.
""" """
console.set_log_level(loglevel) console.set_log_level(loglevel)
console.print("Building custom component...") _run_build()
cmds = [sys.executable, "-m", "build", "."]
if _run_commands_in_subprocess(cmds):
console.info("Custom component built successfully!")
else:
raise typer.Exit(code=1)
def _validate_repository_name(repository: str | None) -> str: def _validate_repository_name(repository: str | None) -> str:
@ -456,14 +483,73 @@ def _validate_credentials(
return username, password return username, password
def _ensure_dist_dir(): def _get_version_to_publish() -> str:
"""Ensure the distribution directory and the expected files exist. """Get the version to publish from the pyproject.toml.
Raises: Raises:
Exit: If the distribution directory does not exist or the expected files are not found. Exit: If the version is not found in the pyproject.toml.
Returns:
The version to publish.
"""
# Get the version from the pyproject.toml.
version_to_publish = None
with open(CustomComponents.PYPROJECT_TOML, "r") as f:
pyproject_toml = f.read()
# Note below does not capture non-matching quotes. Not performing full syntax check here.
match = re.search(r'version\s*=\s*["\'](.*?)["\']', pyproject_toml)
if match:
version_to_publish = match.group(1)
console.debug(f"Version to be published: {version_to_publish}")
if not version_to_publish:
console.error(
f"Could not find the version to be published in {CustomComponents.PYPROJECT_TOML}"
)
raise typer.Exit(code=1)
return version_to_publish
def _ensure_dist_dir(version_to_publish: str, build: bool):
"""Ensure the distribution directory and the expected files exist.
Args:
version_to_publish: The version to be published.
build: Whether to build the package first.
Raises:
Exit: If the distribution directory does not exist, or the expected files are not found.
""" """
dist_dir = Path(CustomComponents.DIST_DIR) dist_dir = Path(CustomComponents.DIST_DIR)
if build:
# Need to check if the files here are for the version to be published.
if dist_dir.exists():
# Check if the distribution files are for the version to be published.
needs_rebuild = False
for suffix in CustomComponents.DISTRIBUTION_FILE_SUFFIXES:
if not list(dist_dir.glob(f"*{version_to_publish}*{suffix}")):
console.debug(
f"Expected distribution file with suffix {suffix} for version {version_to_publish} not found in directory {dist_dir.name}"
)
needs_rebuild = True
break
else:
needs_rebuild = True
if not needs_rebuild:
needs_rebuild = (
console.ask(
"Distribution files for the version to be published already exist. Do you want to rebuild?",
choices=["n", "y"],
default="n",
)
== "y"
)
if needs_rebuild:
_run_build()
# Check if the distribution directory exists. # Check if the distribution directory exists.
if not dist_dir.exists(): if not dist_dir.exists():
console.error(f"Directory {dist_dir.name} does not exist. Please build first.") console.error(f"Directory {dist_dir.name} does not exist. Please build first.")
@ -511,6 +597,10 @@ def publish(
"--password", "--password",
help="The password to use for authentication on python package repository. Username and password must both be provided.", help="The password to use for authentication on python package repository. Username and password must both be provided.",
), ),
build: bool = typer.Option(
True,
help="Whether to build the package before publishing. If the package is already built, set this to False.",
),
loglevel: constants.LogLevel = typer.Option( loglevel: constants.LogLevel = typer.Option(
config.loglevel, help="The log level to use." config.loglevel, help="The log level to use."
), ),
@ -522,6 +612,7 @@ def publish(
token: The token to use for authentication on python package repository. If token is provided, no username/password should be provided at the same time. token: The token to use for authentication on python package repository. If token is provided, no username/password should be provided at the same time.
username: The username to use for authentication on python package repository. username: The username to use for authentication on python package repository.
password: The password to use for authentication on python package repository. password: The password to use for authentication on python package repository.
build: Whether to build the distribution files. Defaults to True.
loglevel: The log level to use. loglevel: The log level to use.
Raises: Raises:
@ -536,8 +627,11 @@ def publish(
# Validate the credentials. # Validate the credentials.
username, password = _validate_credentials(username, password, token) username, password = _validate_credentials(username, password, token)
# Get the version to publish from the pyproject.toml.
version_to_publish = _get_version_to_publish()
# Validate the distribution directory. # Validate the distribution directory.
_ensure_dist_dir() _ensure_dist_dir(version_to_publish=version_to_publish, build=build)
# We install twine on the fly if required so it is not a stable dependency of reflex. # We install twine on the fly if required so it is not a stable dependency of reflex.
try: try:
@ -557,7 +651,7 @@ def publish(
"--password", "--password",
password, password,
"--non-interactive", "--non-interactive",
f"{CustomComponents.DIST_DIR}/*", f"{CustomComponents.DIST_DIR}/*{version_to_publish}*",
] ]
if _run_commands_in_subprocess(publish_cmds): if _run_commands_in_subprocess(publish_cmds):
console.info("Custom component published successfully!") console.info("Custom component published successfully!")

View File

@ -327,20 +327,25 @@ def create_config(app_name: str):
f.write(templates.RXCONFIG.render(app_name=app_name, config_name=config_name)) f.write(templates.RXCONFIG.render(app_name=app_name, config_name=config_name))
def initialize_gitignore(): def initialize_gitignore(
"""Initialize the template .gitignore file.""" gitignore_file: str = constants.GitIgnore.FILE,
# The files to add to the .gitignore file. files_to_ignore: set[str] = constants.GitIgnore.DEFAULTS,
files = constants.GitIgnore.DEFAULTS ):
"""Initialize the template .gitignore file.
# Subtract current ignored files. Args:
if os.path.exists(constants.GitIgnore.FILE): gitignore_file: The .gitignore file to create.
with open(constants.GitIgnore.FILE, "r") as f: files_to_ignore: The files to add to the .gitignore file.
files |= set([line.strip() for line in f.readlines()]) """
# Combine with the current ignored files.
if os.path.exists(gitignore_file):
with open(gitignore_file, "r") as f:
files_to_ignore |= set([line.strip() for line in f.readlines()])
# Write files to the .gitignore file. # Write files to the .gitignore file.
with open(constants.GitIgnore.FILE, "w", newline="\n") as f: with open(gitignore_file, "w", newline="\n") as f:
console.debug(f"Creating {constants.GitIgnore.FILE}") console.debug(f"Creating {gitignore_file}")
f.write(f"{(path_ops.join(sorted(files))).lstrip()}") f.write(f"{(path_ops.join(sorted(files_to_ignore))).lstrip()}")
def initialize_requirements_txt(): def initialize_requirements_txt():

View File

View File

@ -0,0 +1,30 @@
from unittest.mock import mock_open
import pytest
from reflex.custom_components.custom_components import _get_version_to_publish
@pytest.mark.parametrize(
"version_string",
[
"version='0.1.0'",
"version ='0.1.0'",
"version= '0.1.0'",
"version = '0.1.0'",
"version = '0.1.0' ",
'version="0.1.0"',
'version ="0.1.0"',
'version = "0.1.0"',
'version = "0.1.0" ',
],
)
def test_get_version_to_publish(version_string, mocker):
python_toml = f"""[tool.poetry]
name = \"test\"
{version_string}
description = \"test\"
"""
open_mock = mock_open(read_data=python_toml)
mocker.patch("builtins.open", open_mock)
assert _get_version_to_publish() == "0.1.0"

View File

@ -309,7 +309,7 @@ def test_initialize_non_existent_gitignore(tmp_path, mocker, gitignore_exists):
""" """
) )
prerequisites.initialize_gitignore() prerequisites.initialize_gitignore(gitignore_file=gitignore_file)
assert gitignore_file.exists() assert gitignore_file.exists()
file_content = [ file_content = [
@ -515,9 +515,9 @@ def test_style_prop_with_event_handler_value(callable):
""" """
style = { style = {
"color": EventHandler(fn=callable) "color": (
if type(callable) != EventHandler EventHandler(fn=callable) if type(callable) != EventHandler else callable
else callable )
} }
with pytest.raises(TypeError): with pytest.raises(TypeError):