[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
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.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")
# 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")
# 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")
# 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")

View File

@ -28,3 +28,7 @@ class CustomComponents(SimpleNamespace):
"pypi": "https://upload.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,
)
)
# 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]:
"""Get the default library name. Based on the current directory name, remove any non-alphanumeric characters.
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:
The parts of default library name.
"""
current_dir_name = os.getcwd().split(os.path.sep)[-1]
cleaned_dir_name = re.sub("[^0-9a-zA-Z-_]+", "", current_dir_name)
parts = re.split("-|_", cleaned_dir_name)
cleaned_dir_name = re.sub("[^0-9a-zA-Z-_]+", "", current_dir_name).lower()
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:
# 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}."
)
raise typer.Exit(code=1)
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 = "".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 = 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 = "_".join(name_parts)
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.
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(
library_name=library_name,
@ -304,31 +318,38 @@ def init(
# Check the name follows the convention if picked.
name_variants = _validate_library_name(library_name)
console.rule(f"[bold]Initializing {name_variants.package_name} project")
_populate_custom_component_project(name_variants)
_populate_demo_app(name_variants)
# Initialize the .gitignore.
prerequisites.initialize_gitignore()
prerequisites.initialize_gitignore(
gitignore_file=CustomComponents.FILE, files_to_ignore=CustomComponents.DEFAULTS
)
if install:
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"]):
console.info(f"Package {package_name} installed!")
else:
raise typer.Exit(code=1)
console.print("Custom component initialized successfully!")
console.print("Here's the summary:")
console.print("[bold]Custom component initialized successfully!")
console.rule("[bold]Project Summary")
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(
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(
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
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")
def build(
loglevel: constants.LogLevel = typer.Option(
@ -389,18 +425,9 @@ def build(
Args:
loglevel: The log level to use.
Raises:
Exit: If the build fails.
"""
console.set_log_level(loglevel)
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)
_run_build()
def _validate_repository_name(repository: str | None) -> str:
@ -456,14 +483,73 @@ def _validate_credentials(
return username, password
def _ensure_dist_dir():
"""Ensure the distribution directory and the expected files exist.
def _get_version_to_publish() -> str:
"""Get the version to publish from the pyproject.toml.
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)
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.
if not dist_dir.exists():
console.error(f"Directory {dist_dir.name} does not exist. Please build first.")
@ -511,6 +597,10 @@ def publish(
"--password",
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(
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.
username: The username 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.
Raises:
@ -536,8 +627,11 @@ def publish(
# Validate the credentials.
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.
_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.
try:
@ -557,7 +651,7 @@ def publish(
"--password",
password,
"--non-interactive",
f"{CustomComponents.DIST_DIR}/*",
f"{CustomComponents.DIST_DIR}/*{version_to_publish}*",
]
if _run_commands_in_subprocess(publish_cmds):
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))
def initialize_gitignore():
"""Initialize the template .gitignore file."""
# The files to add to the .gitignore file.
files = constants.GitIgnore.DEFAULTS
def initialize_gitignore(
gitignore_file: str = constants.GitIgnore.FILE,
files_to_ignore: set[str] = constants.GitIgnore.DEFAULTS,
):
"""Initialize the template .gitignore file.
# Subtract current ignored files.
if os.path.exists(constants.GitIgnore.FILE):
with open(constants.GitIgnore.FILE, "r") as f:
files |= set([line.strip() for line in f.readlines()])
Args:
gitignore_file: The .gitignore file to create.
files_to_ignore: The files to add to the .gitignore file.
"""
# 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.
with open(constants.GitIgnore.FILE, "w", newline="\n") as f:
console.debug(f"Creating {constants.GitIgnore.FILE}")
f.write(f"{(path_ops.join(sorted(files))).lstrip()}")
with open(gitignore_file, "w", newline="\n") as f:
console.debug(f"Creating {gitignore_file}")
f.write(f"{(path_ops.join(sorted(files_to_ignore))).lstrip()}")
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()
file_content = [
@ -515,9 +515,9 @@ def test_style_prop_with_event_handler_value(callable):
"""
style = {
"color": EventHandler(fn=callable)
if type(callable) != EventHandler
else callable
"color": (
EventHandler(fn=callable) if type(callable) != EventHandler else callable
)
}
with pytest.raises(TypeError):