Explorar o código

[ENG-1796]`reflex rename`- B (#4668)

* `reflex rename`- B

* add unit tests

* precommit

* dont need this comment

* move loglevel

* calm down, darglint

* add current dir to sys path
Elijah Ahianyo hai 3 meses
pai
achega
1ca36fa6c1

+ 2 - 0
reflex/constants/compiler.py

@@ -28,6 +28,8 @@ class Ext(SimpleNamespace):
     ZIP = ".zip"
     ZIP = ".zip"
     # The extension for executable files on Windows.
     # The extension for executable files on Windows.
     EXE = ".exe"
     EXE = ".exe"
+    # The extension for markdown files.
+    MD = ".md"
 
 
 
 
 class CompileVars(SimpleNamespace):
 class CompileVars(SimpleNamespace):

+ 14 - 0
reflex/reflex.py

@@ -573,6 +573,20 @@ def deploy(
     )
     )
 
 
 
 
+@cli.command()
+def rename(
+    new_name: str = typer.Argument(..., help="The new name for the app."),
+    loglevel: constants.LogLevel = typer.Option(
+        config.loglevel, help="The log level to use."
+    ),
+):
+    """Rename the app in the current directory."""
+    from reflex.utils import prerequisites
+
+    prerequisites.validate_app_name(new_name)
+    prerequisites.rename_app(new_name, loglevel)
+
+
 cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.")
 cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.")
 cli.add_typer(script_cli, name="script", help="Subcommands running helper scripts.")
 cli.add_typer(script_cli, name="script", help="Subcommands running helper scripts.")
 cli.add_typer(
 cli.add_typer(

+ 162 - 0
reflex/utils/prerequisites.py

@@ -7,6 +7,7 @@ import dataclasses
 import functools
 import functools
 import importlib
 import importlib
 import importlib.metadata
 import importlib.metadata
+import importlib.util
 import json
 import json
 import os
 import os
 import platform
 import platform
@@ -463,6 +464,167 @@ def validate_app_name(app_name: str | None = None) -> str:
     return app_name
     return app_name
 
 
 
 
+def rename_path_up_tree(full_path: str | Path, old_name: str, new_name: str) -> Path:
+    """Rename all instances of `old_name` in the path (file and directories) to `new_name`.
+    The renaming stops when we reach the directory containing `rxconfig.py`.
+
+    Args:
+        full_path: The full path to start renaming from.
+        old_name: The name to be replaced.
+        new_name: The replacement name.
+
+    Returns:
+         The updated path after renaming.
+    """
+    current_path = Path(full_path)
+    new_path = None
+
+    while True:
+        directory, base = current_path.parent, current_path.name
+        # Stop renaming when we reach the root dir (which contains rxconfig.py)
+        if current_path.is_dir() and (current_path / "rxconfig.py").exists():
+            new_path = current_path
+            break
+
+        if old_name == base.removesuffix(constants.Ext.PY):
+            new_base = base.replace(old_name, new_name)
+            new_path = directory / new_base
+            current_path.rename(new_path)
+            console.debug(f"Renamed {current_path} -> {new_path}")
+            current_path = new_path
+        else:
+            new_path = current_path
+
+        # Move up the directory tree
+        current_path = directory
+
+    return new_path
+
+
+def rename_app(new_app_name: str, loglevel: constants.LogLevel):
+    """Rename the app directory.
+
+    Args:
+        new_app_name: The new name for the app.
+        loglevel: The log level to use.
+
+    Raises:
+        Exit: If the command is not ran in the root dir or the app module cannot be imported.
+    """
+    # Set the log level.
+    console.set_log_level(loglevel)
+
+    if not constants.Config.FILE.exists():
+        console.error(
+            "No rxconfig.py found. Make sure you are in the root directory of your app."
+        )
+        raise typer.Exit(1)
+
+    sys.path.insert(0, str(Path.cwd()))
+
+    config = get_config()
+    module_path = importlib.util.find_spec(config.module)
+    if module_path is None:
+        console.error(f"Could not find module {config.module}.")
+        raise typer.Exit(1)
+
+    if not module_path.origin:
+        console.error(f"Could not find origin for module {config.module}.")
+        raise typer.Exit(1)
+    console.info(f"Renaming app directory to {new_app_name}.")
+    process_directory(
+        Path.cwd(),
+        config.app_name,
+        new_app_name,
+        exclude_dirs=[constants.Dirs.WEB, constants.Dirs.APP_ASSETS],
+    )
+
+    rename_path_up_tree(Path(module_path.origin), config.app_name, new_app_name)
+
+    console.success(f"App directory renamed to [bold]{new_app_name}[/bold].")
+
+
+def rename_imports_and_app_name(file_path: str | Path, old_name: str, new_name: str):
+    """Rename imports the file using string replacement as well as app_name in rxconfig.py.
+
+    Args:
+        file_path: The file to process.
+        old_name: The old name to replace.
+        new_name: The new name to use.
+    """
+    file_path = Path(file_path)
+    content = file_path.read_text()
+
+    # Replace `from old_name.` or `from old_name` with `from new_name`
+    content = re.sub(
+        rf"\bfrom {re.escape(old_name)}(\b|\.|\s)",
+        lambda match: f"from {new_name}{match.group(1)}",
+        content,
+    )
+
+    # Replace `import old_name` with `import new_name`
+    content = re.sub(
+        rf"\bimport {re.escape(old_name)}\b",
+        f"import {new_name}",
+        content,
+    )
+
+    # Replace `app_name="old_name"` in rx.Config
+    content = re.sub(
+        rf'\bapp_name\s*=\s*["\']{re.escape(old_name)}["\']',
+        f'app_name="{new_name}"',
+        content,
+    )
+
+    # Replace positional argument `"old_name"` in rx.Config
+    content = re.sub(
+        rf'\brx\.Config\(\s*["\']{re.escape(old_name)}["\']',
+        f'rx.Config("{new_name}"',
+        content,
+    )
+
+    file_path.write_text(content)
+
+
+def process_directory(
+    directory: str | Path,
+    old_name: str,
+    new_name: str,
+    exclude_dirs: list | None = None,
+    extensions: list | None = None,
+):
+    """Process files with specified extensions in a directory, excluding specified directories.
+
+    Args:
+        directory: The root directory to process.
+        old_name: The old name to replace.
+        new_name: The new name to use.
+        exclude_dirs: List of directory names to exclude. Defaults to None.
+        extensions: List of file extensions to process.
+    """
+    exclude_dirs = exclude_dirs or []
+    extensions = extensions or [
+        constants.Ext.PY,
+        constants.Ext.MD,
+    ]  # include .md files, typically used in reflex-web.
+    extensions_set = {ext.lstrip(".") for ext in extensions}
+    directory = Path(directory)
+
+    root_exclude_dirs = {directory / exclude_dir for exclude_dir in exclude_dirs}
+
+    files = (
+        p.resolve()
+        for p in directory.glob("**/*")
+        if p.is_file() and p.suffix.lstrip(".") in extensions_set
+    )
+
+    for file_path in files:
+        if not any(
+            file_path.is_relative_to(exclude_dir) for exclude_dir in root_exclude_dirs
+        ):
+            rename_imports_and_app_name(file_path, old_name, new_name)
+
+
 def create_config(app_name: str):
 def create_config(app_name: str):
     """Create a new rxconfig file.
     """Create a new rxconfig file.
 
 

+ 161 - 0
tests/units/test_prerequisites.py

@@ -1,20 +1,28 @@
 import json
 import json
 import re
 import re
+import shutil
 import tempfile
 import tempfile
+from pathlib import Path
 from unittest.mock import Mock, mock_open
 from unittest.mock import Mock, mock_open
 
 
 import pytest
 import pytest
+from typer.testing import CliRunner
 
 
 from reflex import constants
 from reflex import constants
 from reflex.config import Config
 from reflex.config import Config
+from reflex.reflex import cli
+from reflex.testing import chdir
 from reflex.utils.prerequisites import (
 from reflex.utils.prerequisites import (
     CpuInfo,
     CpuInfo,
     _update_next_config,
     _update_next_config,
     cached_procedure,
     cached_procedure,
     get_cpu_info,
     get_cpu_info,
     initialize_requirements_txt,
     initialize_requirements_txt,
+    rename_imports_and_app_name,
 )
 )
 
 
+runner = CliRunner()
+
 
 
 @pytest.mark.parametrize(
 @pytest.mark.parametrize(
     "config, export, expected_output",
     "config, export, expected_output",
@@ -224,3 +232,156 @@ def test_get_cpu_info():
     for attr in ("manufacturer_id", "model_name", "address_width"):
     for attr in ("manufacturer_id", "model_name", "address_width"):
         value = getattr(cpu_info, attr)
         value = getattr(cpu_info, attr)
         assert value.strip() if attr != "address_width" else value
         assert value.strip() if attr != "address_width" else value
+
+
+@pytest.fixture
+def temp_directory():
+    temp_dir = tempfile.mkdtemp()
+    yield Path(temp_dir)
+    shutil.rmtree(temp_dir)
+
+
+@pytest.mark.parametrize(
+    "config_code,expected",
+    [
+        ("rx.Config(app_name='old_name')", 'rx.Config(app_name="new_name")'),
+        ('rx.Config(app_name="old_name")', 'rx.Config(app_name="new_name")'),
+        ("rx.Config('old_name')", 'rx.Config("new_name")'),
+        ('rx.Config("old_name")', 'rx.Config("new_name")'),
+    ],
+)
+def test_rename_imports_and_app_name(temp_directory, config_code, expected):
+    file_path = temp_directory / "rxconfig.py"
+    content = f"""
+config = {config_code}
+"""
+    file_path.write_text(content)
+
+    rename_imports_and_app_name(file_path, "old_name", "new_name")
+
+    updated_content = file_path.read_text()
+    expected_content = f"""
+config = {expected}
+"""
+    assert updated_content == expected_content
+
+
+def test_regex_edge_cases(temp_directory):
+    file_path = temp_directory / "example.py"
+    content = """
+from old_name.module import something
+import old_name
+from old_name import something_else as alias
+from old_name
+"""
+    file_path.write_text(content)
+
+    rename_imports_and_app_name(file_path, "old_name", "new_name")
+
+    updated_content = file_path.read_text()
+    expected_content = """
+from new_name.module import something
+import new_name
+from new_name import something_else as alias
+from new_name
+"""
+    assert updated_content == expected_content
+
+
+def test_cli_rename_command(temp_directory):
+    foo_dir = temp_directory / "foo"
+    foo_dir.mkdir()
+    (foo_dir / "__init__").touch()
+    (foo_dir / ".web").mkdir()
+    (foo_dir / "assets").mkdir()
+    (foo_dir / "foo").mkdir()
+    (foo_dir / "foo" / "__init__.py").touch()
+    (foo_dir / "rxconfig.py").touch()
+    (foo_dir / "rxconfig.py").write_text(
+        """
+import reflex as rx
+
+config = rx.Config(
+    app_name="foo",
+)
+"""
+    )
+    (foo_dir / "foo" / "components").mkdir()
+    (foo_dir / "foo" / "components" / "__init__.py").touch()
+    (foo_dir / "foo" / "components" / "base.py").touch()
+    (foo_dir / "foo" / "components" / "views.py").touch()
+    (foo_dir / "foo" / "components" / "base.py").write_text(
+        """
+import reflex as rx
+from foo.components import views
+from foo.components.views import *
+from .base import *
+
+def random_component():
+    return rx.fragment()
+"""
+    )
+    (foo_dir / "foo" / "foo.py").touch()
+    (foo_dir / "foo" / "foo.py").write_text(
+        """
+import reflex as rx
+import foo.components.base
+from foo.components.base import random_component
+
+class State(rx.State):
+  pass
+
+
+def index():
+   return rx.text("Hello, World!")
+
+app = rx.App()
+app.add_page(index)
+"""
+    )
+
+    with chdir(temp_directory / "foo"):
+        result = runner.invoke(cli, ["rename", "bar"])
+
+    assert result.exit_code == 0
+    assert (foo_dir / "rxconfig.py").read_text() == (
+        """
+import reflex as rx
+
+config = rx.Config(
+    app_name="bar",
+)
+"""
+    )
+    assert (foo_dir / "bar").exists()
+    assert not (foo_dir / "foo").exists()
+    assert (foo_dir / "bar" / "components" / "base.py").read_text() == (
+        """
+import reflex as rx
+from bar.components import views
+from bar.components.views import *
+from .base import *
+
+def random_component():
+    return rx.fragment()
+"""
+    )
+    assert (foo_dir / "bar" / "bar.py").exists()
+    assert not (foo_dir / "bar" / "foo.py").exists()
+    assert (foo_dir / "bar" / "bar.py").read_text() == (
+        """
+import reflex as rx
+import bar.components.base
+from bar.components.base import random_component
+
+class State(rx.State):
+  pass
+
+
+def index():
+   return rx.text("Hello, World!")
+
+app = rx.App()
+app.add_page(index)
+"""
+    )