Browse Source

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

* add custom component command improvements

* fix test

* fix regex to include both single/double quotes
Martin Xu 1 year ago
parent
commit
bf297e2f5b

+ 5 - 5
reflex/compiler/templates.py

@@ -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")

+ 4 - 0
reflex/constants/custom_components.py

@@ -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/"}

+ 123 - 29
reflex/custom_components/custom_components.py

@@ -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():
+def _get_version_to_publish() -> str:
+    """Get the version to publish from the pyproject.toml.
+
+    Raises:
+        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.
+        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!")

+ 16 - 11
reflex/utils/prerequisites.py

@@ -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():

+ 0 - 0
tests/custom_components/__init__.py


+ 30 - 0
tests/custom_components/test_custom_components.py

@@ -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"

+ 4 - 4
tests/utils/test_utils.py

@@ -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):