Skip to content
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ To avoid opening a browser window, use `--no-view` option.
$ pyscript run <path_of_folder> --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 <path_of_folder> --default-file <name of default file>
```

### create

#### Create a new pyscript project with the passed in name, creating a new directory
Expand Down
22 changes: 19 additions & 3 deletions src/pyscript/plugins/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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


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

Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ def check_plugin_project_files(
assert dedent(
f""" <div>
<h2> Description </h2>
<p>{ plugin_description }</p>
<p>{plugin_description}</p>
</div>"""
)
assert f'<py-script src="./{python_file}">' in contents
Expand Down
92 changes: 79 additions & 13 deletions tests/test_run_cli_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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:
Expand Down