[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
This commit is contained in:
Elijah Ahianyo 2025-01-22 23:26:12 +00:00 committed by GitHub
parent 818788da63
commit 1ca36fa6c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 339 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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