From 028f4762a09f1e1f5d721dd886b4e67af4350055 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 16 Feb 2023 17:15:18 -0800 Subject: [PATCH 1/2] Add rename functionality for files and directories --- tests/web/test_main.py | 134 ++++++++++++++++++++++++ web/server/api/endpoints/directories.py | 25 ++++- web/server/api/endpoints/files.py | 34 +++++- 3 files changed, 184 insertions(+), 9 deletions(-) diff --git a/tests/web/test_main.py b/tests/web/test_main.py index 4ee28b4ab5..cdd1aaecdc 100644 --- a/tests/web/test_main.py +++ b/tests/web/test_main.py @@ -138,6 +138,81 @@ def test_update_file(project_tmp_path: Path) -> None: assert (project_tmp_path / "foo.txt").read_text() == "baz" +def test_rename_file(project_tmp_path: Path) -> None: + txt_file = project_tmp_path / "foo.txt" + txt_file.write_text("bar") + + response = client.post("/api/files/foo.txt", json={"new_path": "baz.txt"}) + assert response.status_code == 200 + assert response.json() == { + "name": "baz.txt", + "path": "baz.txt", + "extension": ".txt", + "is_supported": False, + "content": "bar", + } + assert not txt_file.exists() + assert (project_tmp_path / "baz.txt").read_text() == "bar" + + +def test_rename_and_update_file(project_tmp_path: Path) -> None: + txt_file = project_tmp_path / "foo.txt" + txt_file.write_text("bar") + + response = client.post( + "/api/files/foo.txt", json={"content": "hello world", "new_path": "baz.txt"} + ) + assert response.status_code == 200 + assert response.json() == { + "name": "baz.txt", + "path": "baz.txt", + "extension": ".txt", + "is_supported": False, + "content": "hello world", + } + assert not txt_file.exists() + assert (project_tmp_path / "baz.txt").read_text() == "hello world" + + +def test_rename_file_not_found(project_tmp_path: Path) -> None: + response = client.post("/api/files/foo.txt", json={"new_path": "baz.txt"}) + assert response.status_code == 404 + + +def test_rename_file_already_exists(project_tmp_path: Path) -> None: + foo_file = project_tmp_path / "foo.txt" + foo_file.write_text("foo") + bar_file = project_tmp_path / "bar.txt" + bar_file.write_text("bar") + + response = client.post("/api/files/foo.txt", json={"new_path": "bar.txt"}) + assert response.status_code == 200 + assert response.json() == { + "name": "bar.txt", + "path": "bar.txt", + "extension": ".txt", + "is_supported": False, + "content": "foo", + } + assert not foo_file.exists() + + +def test_rename_file_to_existing_directory(project_tmp_path: Path) -> None: + foo_file = project_tmp_path / "foo.txt" + foo_file.touch() + existing_dir = project_tmp_path / "existing_dir" + existing_dir.mkdir() + + response = client.post("/api/files/foo.txt", json={"new_path": "existing_dir"}) + assert response.status_code == 422 + assert foo_file.exists() + + +def test_write_file_empty_body(project_tmp_path: Path) -> None: + response = client.post("/api/files/foo.txt", json={}) + assert response.status_code == 404 + + def test_delete_file(project_tmp_path: Path) -> None: txt_file = project_tmp_path / "foo.txt" txt_file.write_text("bar") @@ -156,6 +231,7 @@ def test_create_directory(project_tmp_path: Path) -> None: response = client.post("/api/directories/new_dir") assert response.status_code == 200 assert (project_tmp_path / "new_dir").exists() + assert response.json() == {"directories": [], "files": [], "name": "new_dir", "path": "new_dir"} def test_create_directory_already_exists(project_tmp_path: Path) -> None: @@ -167,6 +243,64 @@ def test_create_directory_already_exists(project_tmp_path: Path) -> None: assert response.json() == {"detail": "Directory already exists"} +def test_rename_directory(project_tmp_path: Path) -> None: + new_dir = project_tmp_path / "new_dir" + new_dir.mkdir() + + response = client.post("/api/directories/new_dir", json={"new_path": "renamed_dir"}) + assert response.status_code == 200 + assert not new_dir.exists() + assert (project_tmp_path / "renamed_dir").exists() + assert response.json() == { + "directories": [], + "files": [], + "name": "renamed_dir", + "path": "renamed_dir", + } + + +def test_rename_directory_already_exists_empty(project_tmp_path: Path) -> None: + new_dir = project_tmp_path / "new_dir" + new_dir.mkdir() + existing_dir = project_tmp_path / "renamed_dir" + existing_dir.mkdir() + + response = client.post("/api/directories/new_dir", json={"new_path": "renamed_dir"}) + assert response.status_code == 200 + assert not new_dir.exists() + assert (project_tmp_path / "renamed_dir").exists() + assert response.json() == { + "directories": [], + "files": [], + "name": "renamed_dir", + "path": "renamed_dir", + } + + +def test_rename_directory_already_exists_not_empty(project_tmp_path: Path) -> None: + new_dir = project_tmp_path / "new_dir" + new_dir.mkdir() + existing_dir = project_tmp_path / "renamed_dir" + existing_dir.mkdir() + existing_file = existing_dir / "foo.txt" + existing_file.touch() + + response = client.post("/api/directories/new_dir", json={"new_path": "renamed_dir"}) + assert response.status_code == 422 + assert new_dir.exists() + + +def test_rename_directory_to_existing_file(project_tmp_path: Path) -> None: + new_dir = project_tmp_path / "new_dir" + new_dir.mkdir() + existing_file = project_tmp_path / "foo.txt" + existing_file.touch() + + response = client.post("/api/directories/new_dir", json={"new_path": "foo.txt"}) + assert response.status_code == 422 + assert new_dir.exists() + + def test_delete_directory(project_tmp_path: Path) -> None: new_dir = project_tmp_path / "new_dir" new_dir.mkdir() diff --git a/web/server/api/endpoints/directories.py b/web/server/api/endpoints/directories.py index b64fedd940..e3f106e3e0 100644 --- a/web/server/api/endpoints/directories.py +++ b/web/server/api/endpoints/directories.py @@ -1,23 +1,40 @@ import os import shutil +import traceback +import typing as t -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Body, Depends, HTTPException, Response, status from starlette.status import HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY +from sqlmesh.core.context import Context from web.server import models -from web.server.settings import Settings, get_settings +from web.server.settings import Settings, get_context, get_settings from web.server.utils import validate_path router = APIRouter() @router.post("/{path:path}", response_model=models.Directory) -async def create_directory( +async def write_directory( response: Response, path: str = Depends(validate_path), + new_path: t.Optional[str] = Body(None, embed=True), settings: Settings = Depends(get_settings), + context: Context = Depends(get_context), ) -> models.Directory: - """Create a directory.""" + """Create or rename a directory.""" + if new_path and new_path != path: + validate_path(new_path, context) + try: + (settings.project_path / path).replace(settings.project_path / new_path) + except FileNotFoundError: + raise HTTPException(status_code=HTTP_404_NOT_FOUND) + except OSError: + raise HTTPException( + status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=traceback.format_exc() + ) + return models.Directory(name=os.path.basename(new_path), path=new_path) + try: (settings.project_path / path).mkdir(parents=True) return models.Directory(name=os.path.basename(path), path=path) diff --git a/web/server/api/endpoints/files.py b/web/server/api/endpoints/files.py index b53765e642..ba2b9fe59e 100644 --- a/web/server/api/endpoints/files.py +++ b/web/server/api/endpoints/files.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import traceback import typing as t from pathlib import Path @@ -77,14 +78,37 @@ def get_file( @router.post("/{path:path}", response_model=models.File) async def write_file( - content: str = Body(embed=True), + content: t.Optional[str] = Body(None, embed=True), + new_path: t.Optional[str] = Body(None, embed=True), path: str = Depends(validate_path), settings: Settings = Depends(get_settings), + context: Context = Depends(get_context), ) -> models.File: - """Create or update a file.""" - with open(settings.project_path / path, "w", encoding="utf-8") as f: - f.write(content) - return models.File(name=os.path.basename(path), path=path, content=content) + """Create, update, or rename a file.""" + path_or_new_path = path + if new_path and new_path != path: + path_or_new_path = validate_path(new_path, context) + try: + (settings.project_path / path).replace(settings.project_path / path_or_new_path) + except FileNotFoundError: + raise HTTPException(status_code=HTTP_404_NOT_FOUND) + except OSError: + raise HTTPException( + status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=traceback.format_exc() + ) + + if content: + with open(settings.project_path / path_or_new_path, "w", encoding="utf-8") as f: + f.write(content) + else: + try: + with open(settings.project_path / path_or_new_path) as f: + content = f.read() + except FileNotFoundError: + raise HTTPException(status_code=HTTP_404_NOT_FOUND) + return models.File( + name=os.path.basename(path_or_new_path), path=path_or_new_path, content=content + ) @router.delete("/{path:path}") From f5133152b4dfe3864e869511f11068c7fd51b260 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Fri, 17 Feb 2023 12:57:24 -0800 Subject: [PATCH 2/2] PR feedback --- web/server/api/endpoints/directories.py | 14 +++----------- web/server/api/endpoints/files.py | 14 +++----------- web/server/utils.py | 17 ++++++++++++++++- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/web/server/api/endpoints/directories.py b/web/server/api/endpoints/directories.py index e3f106e3e0..038b1884e9 100644 --- a/web/server/api/endpoints/directories.py +++ b/web/server/api/endpoints/directories.py @@ -1,6 +1,5 @@ import os import shutil -import traceback import typing as t from fastapi import APIRouter, Body, Depends, HTTPException, Response, status @@ -9,7 +8,7 @@ from sqlmesh.core.context import Context from web.server import models from web.server.settings import Settings, get_context, get_settings -from web.server.utils import validate_path +from web.server.utils import replace_file, validate_path router = APIRouter() @@ -23,16 +22,9 @@ async def write_directory( context: Context = Depends(get_context), ) -> models.Directory: """Create or rename a directory.""" - if new_path and new_path != path: + if new_path: validate_path(new_path, context) - try: - (settings.project_path / path).replace(settings.project_path / new_path) - except FileNotFoundError: - raise HTTPException(status_code=HTTP_404_NOT_FOUND) - except OSError: - raise HTTPException( - status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=traceback.format_exc() - ) + replace_file(settings.project_path / path, settings.project_path / new_path) return models.Directory(name=os.path.basename(new_path), path=new_path) try: diff --git a/web/server/api/endpoints/files.py b/web/server/api/endpoints/files.py index ba2b9fe59e..af18e93b38 100644 --- a/web/server/api/endpoints/files.py +++ b/web/server/api/endpoints/files.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import traceback import typing as t from pathlib import Path @@ -11,7 +10,7 @@ from sqlmesh.core.context import Context from web.server import models from web.server.settings import Settings, get_context, get_settings -from web.server.utils import validate_path +from web.server.utils import replace_file, validate_path router = APIRouter() @@ -86,16 +85,9 @@ async def write_file( ) -> models.File: """Create, update, or rename a file.""" path_or_new_path = path - if new_path and new_path != path: + if new_path: path_or_new_path = validate_path(new_path, context) - try: - (settings.project_path / path).replace(settings.project_path / path_or_new_path) - except FileNotFoundError: - raise HTTPException(status_code=HTTP_404_NOT_FOUND) - except OSError: - raise HTTPException( - status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=traceback.format_exc() - ) + replace_file(settings.project_path / path, settings.project_path / path_or_new_path) if content: with open(settings.project_path / path_or_new_path, "w", encoding="utf-8") as f: diff --git a/web/server/utils.py b/web/server/utils.py index 485de88057..d18ba7991c 100644 --- a/web/server/utils.py +++ b/web/server/utils.py @@ -1,8 +1,10 @@ import asyncio +import traceback import typing as t +from pathlib import Path from fastapi import Depends, HTTPException -from starlette.status import HTTP_404_NOT_FOUND +from starlette.status import HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY from sqlmesh.core.context import Context from web.server.settings import get_context @@ -29,3 +31,16 @@ def validate_path( raise HTTPException(status_code=HTTP_404_NOT_FOUND) return path + + +def replace_file(src: Path, dst: Path) -> None: + """Move a file or directory at src to dst.""" + if src != dst: + try: + src.replace(dst) + except FileNotFoundError: + raise HTTPException(status_code=HTTP_404_NOT_FOUND) + except OSError: + raise HTTPException( + status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=traceback.format_exc() + )