diff --git a/reflex/reflex.py b/reflex/reflex.py index d3cd4da42..4e7fa9bd3 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -555,6 +555,7 @@ def deploy( **extra, ) + @cli.command() def rename( new_name: str = typer.Argument(..., help="The new name for the app."), diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 26cce12e4..06f113f97 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -2,7 +2,6 @@ from __future__ import annotations -import ast import contextlib import dataclasses import functools @@ -26,7 +25,6 @@ from pathlib import Path from types import ModuleType from typing import Callable, List, NamedTuple, Optional -import astor import httpx import typer from alembic.util.exc import CommandError @@ -480,9 +478,8 @@ def validate_app_name(app_name: str | None = None) -> str: return app_name -def rename_path_up_tree(full_path, old_name, new_name): - """ - Rename all instances of `old_name` in the path (file and directories) to `new_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: @@ -491,7 +488,7 @@ def rename_path_up_tree(full_path, old_name, new_name): new_name: The replacement name. Returns: - Path: The updated path after renaming. + The updated path after renaming. """ current_path = Path(full_path) new_path = None @@ -521,7 +518,9 @@ def rename_path_up_tree(full_path, old_name, new_name): def rename_app(app_name: str): """Rename the app directory.""" if not constants.Config.FILE.exists(): - console.error("No rxconfig.py found. Make sure you are in the root directory of your app.") + console.error( + "No rxconfig.py found. Make sure you are in the root directory of your app." + ) raise typer.Exit(1) config = get_config() @@ -534,16 +533,13 @@ def rename_app(app_name: str): console.error(f"Could not find origin for module {config.module}.") raise typer.Exit(1) - process_directory( - Path.cwd(), config.app_name, app_name, exclude_dirs=[".web"] - ) + process_directory(Path.cwd(), config.app_name, app_name, exclude_dirs=[".web"]) rename_path_up_tree(Path(module_path.origin), config.app_name, app_name) -def rename_imports_and_app_name(file_path, old_name, new_name): - """ - Rename imports and update the app_name in the file using string replacement. +def rename_imports_and_app_name(file_path: str | Path, old_name: str, new_name: str): + """Rename imports and update the app_name in the file using string replacement. Handles both keyword and positional arguments for `rx.Config` and import statements. """ file_path = Path(file_path) @@ -551,15 +547,15 @@ def rename_imports_and_app_name(file_path, old_name, new_name): # 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)}', + 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}', + rf"\bimport {re.escape(old_name)}\b", + f"import {new_name}", content, ) @@ -580,23 +576,29 @@ def rename_imports_and_app_name(file_path, old_name, new_name): file_path.write_text(content) - -def process_directory(directory, old_name, new_name, exclude_dirs=None, extensions=None): - """ - Process files with specified extensions in a directory, excluding specified directories. +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 (str or Path): The root directory to process. - old_name (str): The old name to replace. - new_name (str): The new name to use. - exclude_dirs (list, optional): List of directory names to exclude. Defaults to None. - extensions (list, optional): List of file extensions to process. Defaults to [".py"]. + 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. Defaults to [".py"]. """ exclude_dirs = exclude_dirs or [] extensions = extensions or [".py", ".md"] 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("**/*") @@ -604,11 +606,12 @@ def process_directory(directory, old_name, new_name, exclude_dirs=None, extensio ) for file_path in files: - if not any(part in exclude_dirs for part in file_path.parts): + 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): """Create a new rxconfig file. diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 90afe0963..4630a484c 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -1,20 +1,29 @@ +import importlib.machinery import json import re +import shutil import tempfile +from pathlib import Path from unittest.mock import Mock, mock_open import pytest +from typer.testing import CliRunner from reflex import constants from reflex.config import Config +from reflex.reflex import cli +from reflex.testing import chdir from reflex.utils.prerequisites import ( CpuInfo, _update_next_config, cached_procedure, get_cpu_info, initialize_requirements_txt, + rename_imports_and_app_name, ) +runner = CliRunner() + @pytest.mark.parametrize( "config, export, expected_output", @@ -224,3 +233,169 @@ def test_get_cpu_info(): for attr in ("manufacturer_id", "model_name", "address_width"): value = getattr(cpu_info, attr) assert value.strip() if attr != "address_width" else value + + +@pytest.fixture +def temp_directory(): + """Create a temporary directory for tests.""" + 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): + """Test renaming imports and app_name in a file.""" + 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): + """Test regex edge cases in renaming.""" + 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(mocker, temp_directory): + """Test the CLI rename command.""" + 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) +""" + ) + + mocker.patch( + "importlib.util.find_spec", + mocker.patch( + "importlib.util.find_spec", + return_value=importlib.machinery.ModuleSpec( + name="foo", loader=None, origin=str(Path(foo_dir / "foo" / "foo.py")) + ), + ), + ) + 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) +""" + )