diff --git a/README.md b/README.md index 2cccfe7..bb42b28 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ To avoid opening a browser window, use `--no-view` option. $ pyscript run --no-view ``` +To serve a default file (e.g., `index.html`) instead of a 404 HTTP status when a nonexistent file is accessed, use `--default-file` option. + +```shell +pyscript run --default-file +``` + ### create #### Create a new pyscript project with the passed in name, creating a new directory diff --git a/src/pyscript/plugins/run.py b/src/pyscript/plugins/run.py index 196135e..090c3ad 100644 --- a/src/pyscript/plugins/run.py +++ b/src/pyscript/plugins/run.py @@ -14,6 +14,7 @@ def get_folder_based_http_request_handler( folder: Path, + default_file: Path | None = None, ) -> type[SimpleHTTPRequestHandler]: """ Returns a FolderBasedHTTPRequestHandler with the specified directory. @@ -37,6 +38,16 @@ def end_headers(self): self.send_header("Cache-Control", "no-cache, must-revalidate") SimpleHTTPRequestHandler.end_headers(self) + def do_GET(self): + # intercept accesses to nonexistent files; replace them with the default file + # this is to service SPA use cases (see Github Issue #132) + if default_file: + path = Path(self.translate_path(self.path)) + if not path.exists(): + self.path = f"/{default_file}" + + return super().do_GET() + return FolderBasedHTTPRequestHandler @@ -58,7 +69,7 @@ def split_path_and_filename(path: Path) -> tuple[Path, str]: return abs_path, "" -def start_server(path: Path, show: bool, port: int): +def start_server(path: Path, show: bool, port: int, default_file: Path | None = None): """ Creates a local server to run the app on the path and port specified. @@ -76,7 +87,9 @@ def start_server(path: Path, show: bool, port: int): socketserver.TCPServer.allow_reuse_address = True app_folder, filename = split_path_and_filename(path) - CustomHTTPRequestHandler = get_folder_based_http_request_handler(app_folder) + CustomHTTPRequestHandler = get_folder_based_http_request_handler( + app_folder, default_file=default_file + ) # Start the server within a context manager to make sure we clean up after with socketserver.TCPServer(("", port), CustomHTTPRequestHandler) as httpd: @@ -110,6 +123,9 @@ def run( ), view: bool = typer.Option(True, help="Open the app in web browser."), port: int = typer.Option(8000, help="The port that the app will run on."), + default_file: Path | None = typer.Option( + None, help="A default file to serve when a nonexistent file is accessed." + ), ): """ Creates a local server to run the app on the path and port specified. @@ -120,7 +136,7 @@ def run( raise cli.Abort(f"Error: Path {str(path)} does not exist.", style="red") try: - start_server(path, view, port) + start_server(path, view, port, default_file=default_file) except OSError as e: if e.errno == 48: console.print( diff --git a/tests/test_generator.py b/tests/test_generator.py index c9c8c4a..fa9bbd4 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -237,7 +237,7 @@ def check_plugin_project_files( assert dedent( f"""

Description

-

{ plugin_description }

+

{plugin_description}

""" ) assert f'' in contents diff --git a/tests/test_run_cli_cmd.py b/tests/test_run_cli_cmd.py index 3a71cd3..7efd284 100644 --- a/tests/test_run_cli_cmd.py +++ b/tests/test_run_cli_cmd.py @@ -59,7 +59,8 @@ def test_run_server_with_default_values( # Path("."): path to local folder # show=True: same as passing the --view option (which defaults to True) # port=8000: that is the default port - start_server_mock.assert_called_once_with(Path("."), True, 8000) + # default_file=None: default behavior is to have no default file + start_server_mock.assert_called_once_with(Path("."), True, 8000, default_file=None) @mock.patch("pyscript.plugins.run.start_server") @@ -78,25 +79,90 @@ def test_run_server_with_no_view_flag( # Path("."): path to local folder # show=False: same as passing the --no-view option # port=8000: that is the default port - start_server_mock.assert_called_once_with(Path("."), False, 8000) + # default_file=None: default behavior is to have no default file + start_server_mock.assert_called_once_with(Path("."), False, 8000, default_file=None) @pytest.mark.parametrize( - "run_args, expected_values", + "run_args, expected_posargs, expected_kwargs", [ - (("--no-view",), (Path("."), False, 8000)), - ((BASEPATH,), (Path(BASEPATH), True, 8000)), - (("--port=8001",), (Path("."), True, 8001)), - (("--no-view", "--port=8001"), (Path("."), False, 8001)), - ((BASEPATH, "--no-view"), (Path(BASEPATH), False, 8000)), - ((BASEPATH, "--port=8001"), (Path(BASEPATH), True, 8001)), - ((BASEPATH, "--no-view", "--port=8001"), (Path(BASEPATH), False, 8001)), - ((BASEPATH, "--port=8001"), (Path(BASEPATH), True, 8001)), + (("--no-view",), (Path("."), False, 8000), {"default_file": None}), + ((BASEPATH,), (Path(BASEPATH), True, 8000), {"default_file": None}), + (("--port=8001",), (Path("."), True, 8001), {"default_file": None}), + ( + ("--no-view", "--port=8001"), + (Path("."), False, 8001), + {"default_file": None}, + ), + ( + (BASEPATH, "--no-view"), + (Path(BASEPATH), False, 8000), + {"default_file": None}, + ), + ( + (BASEPATH, "--port=8001"), + (Path(BASEPATH), True, 8001), + {"default_file": None}, + ), + ( + (BASEPATH, "--no-view", "--port=8001"), + (Path(BASEPATH), False, 8001), + {"default_file": None}, + ), + ( + (BASEPATH, "--port=8001"), + (Path(BASEPATH), True, 8001), + {"default_file": None}, + ), + ( + ("--no-view", "--default-file=index.html"), + (Path("."), False, 8000), + {"default_file": Path("index.html")}, + ), + ( + (BASEPATH, "--default-file=index.html"), + (Path(BASEPATH), True, 8000), + {"default_file": Path("index.html")}, + ), + ( + ("--port=8001", "--default-file=index.html"), + (Path("."), True, 8001), + {"default_file": Path("index.html")}, + ), + ( + ("--no-view", "--port=8001", "--default-file=index.html"), + (Path("."), False, 8001), + {"default_file": Path("index.html")}, + ), + ( + (BASEPATH, "--no-view", "--default-file=index.html"), + (Path(BASEPATH), False, 8000), + {"default_file": Path("index.html")}, + ), + ( + (BASEPATH, "--port=8001", "--default-file=index.html"), + (Path(BASEPATH), True, 8001), + {"default_file": Path("index.html")}, + ), + ( + (BASEPATH, "--no-view", "--port=8001", "--default-file=index.html"), + (Path(BASEPATH), False, 8001), + {"default_file": Path("index.html")}, + ), + ( + (BASEPATH, "--port=8001", "--default-file=index.html"), + (Path(BASEPATH), True, 8001), + {"default_file": Path("index.html")}, + ), ], ) @mock.patch("pyscript.plugins.run.start_server") def test_run_server_with_valid_combinations( - start_server_mock, invoke_cli: CLIInvoker, run_args, expected_values # noqa: F811 + start_server_mock, + invoke_cli: CLIInvoker, # noqa: F811 + run_args, + expected_posargs, + expected_kwargs, ): """ Test that when run is called without arguments the command runs with the @@ -107,7 +173,7 @@ def test_run_server_with_valid_combinations( # EXPECT the command to succeed assert result.exit_code == 0 # EXPECT start_server_mock function to be called with the expected values - start_server_mock.assert_called_once_with(*expected_values) + start_server_mock.assert_called_once_with(*expected_posargs, **expected_kwargs) class TestFolderBasedHTTPRequestHandler: