From bf297e2f5b6fa5bdd085418071712a631df3747c Mon Sep 17 00:00:00 2001 From: Martin Xu <15661672+martinxu9@users.noreply.github.com> Date: Thu, 7 Mar 2024 20:42:22 -0800 Subject: [PATCH] [REF-2141] Custom component command improvements (#2807) * add custom component command improvements * fix test * fix regex to include both single/double quotes --- reflex/compiler/templates.py | 10 +- reflex/constants/custom_components.py | 4 + reflex/custom_components/custom_components.py | 154 ++++++++++++++---- reflex/utils/prerequisites.py | 27 +-- tests/custom_components/__init__.py | 0 .../test_custom_components.py | 30 ++++ tests/utils/test_utils.py | 8 +- 7 files changed, 183 insertions(+), 50 deletions(-) create mode 100644 tests/custom_components/__init__.py create mode 100644 tests/custom_components/test_custom_components.py diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 694ca0cde..eb2a3d6fa 100644 --- a/reflex/compiler/templates.py +++ b/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") diff --git a/reflex/constants/custom_components.py b/reflex/constants/custom_components.py index 3c4ebdb8f..3ea9cf6ed 100644 --- a/reflex/constants/custom_components.py +++ b/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/"} diff --git a/reflex/custom_components/custom_components.py b/reflex/custom_components/custom_components.py index 46dd060b1..4fcce683e 100644 --- a/reflex/custom_components/custom_components.py +++ b/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(): - """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!") diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 7a286ca36..daa7821ca 100644 --- a/reflex/utils/prerequisites.py +++ b/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(): diff --git a/tests/custom_components/__init__.py b/tests/custom_components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/custom_components/test_custom_components.py b/tests/custom_components/test_custom_components.py new file mode 100644 index 000000000..92bac52ee --- /dev/null +++ b/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" diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 0e2c48e50..79dda0a70 100644 --- a/tests/utils/test_utils.py +++ b/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):