Repository: pyscript/pyscript-cli

Issue:
Add `pyscript serve` subcommand
Command will run a simple HTTP server in order to host the files dynamically. This is required in order to allow `<py-script src="./my_module.py">` to work.

```shell
$ pyscript serve my_file.html --show --autoreload
```

The `--autoreload` is a nice-to-have, but not necessary right now.

Comments:

Patch:
diff --git a/src/pyscript/cli.py b/src/pyscript/cli.py
index 398f381..01fc547 100644
--- a/src/pyscript/cli.py
+++ b/src/pyscript/cli.py
@@ -7,7 +7,7 @@ from pluggy import PluginManager
 from pyscript import __version__, app, console, plugins, typer
 from pyscript.plugins import hookspecs
 
-DEFAULT_PLUGINS = ["create", "wrap"]
+DEFAULT_PLUGINS = ["create", "wrap", "run"]
 
 
 def ok(msg: str = ""):
diff --git a/src/pyscript/plugins/run.py b/src/pyscript/plugins/run.py
new file mode 100644
index 0000000..182e71e
--- /dev/null
+++ b/src/pyscript/plugins/run.py
@@ -0,0 +1,136 @@
+from __future__ import annotations
+
+import socketserver
+import threading
+import webbrowser
+from functools import partial
+from http.server import SimpleHTTPRequestHandler
+from pathlib import Path
+
+from pyscript import app, cli, console, plugins
+
+try:
+    import rich_click.typer as typer
+except ImportError:  # pragma: no cover
+    import typer  # type: ignore
+
+
+def get_folder_based_http_request_handler(
+    folder: Path,
+) -> type[SimpleHTTPRequestHandler]:
+    """
+    Returns a FolderBasedHTTPRequestHandler with the specified directory.
+
+    Args:
+        folder (str): The folder that will be served.
+
+    Returns:
+        FolderBasedHTTPRequestHandler: The SimpleHTTPRequestHandler with the
+                                        specified directory.
+    """
+
+    class FolderBasedHTTPRequestHandler(SimpleHTTPRequestHandler):
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, directory=folder, **kwargs)
+
+    return FolderBasedHTTPRequestHandler
+
+
+def split_path_and_filename(path: Path) -> tuple[Path, str]:
+    """Receives a path to a pyscript project or file and returns the base
+    path of the project and the filename that should be opened (filename defaults
+    to "" (empty string) if the path points to a folder).
+
+    Args:
+        path (str): The path to the pyscript project or file.
+
+
+    Returns:
+        tuple(str, str): The base path of the project and the filename
+    """
+    abs_path = path.absolute()
+    if path.is_file():
+        return Path("/".join(abs_path.parts[:-1])), abs_path.parts[-1]
+    else:
+        return abs_path, ""
+
+
+def start_server(path: Path, show: bool, port: int):
+    """
+    Creates a local server to run the app on the path and port specified.
+
+    Args:
+        path(str): The path of the project that will run.
+        show(bool): Open the app in web browser.
+        port(int): The port that the app will run on.
+
+    Returns:
+        None
+    """
+    # We need to set the allow_resuse_address to True because socketserver will
+    # keep the port in use for a while after the server is stopped.
+    # see https://stackoverflow.com/questions/31745040/
+    socketserver.TCPServer.allow_reuse_address = True
+
+    app_folder, filename = split_path_and_filename(path)
+    CustomHTTPRequestHandler = get_folder_based_http_request_handler(app_folder)
+
+    # Start the server within a context manager to make sure we clean up after
+    with socketserver.TCPServer(("", port), CustomHTTPRequestHandler) as httpd:
+        console.print(
+            f"Serving from {app_folder} at port {port}. To stop, press Ctrl+C.",
+            style="green",
+        )
+
+        if show:
+            # Open the web browser in a separate thread after 0.5 seconds.
+            open_browser = partial(
+                webbrowser.open_new_tab, f"http://localhost:{port}/{filename}"
+            )
+            threading.Timer(0.5, open_browser).start()
+
+        try:
+            httpd.serve_forever()
+        except KeyboardInterrupt:
+            console.print("\nStopping server... Bye bye!")
+
+            # Clean after ourselves....
+            httpd.shutdown()
+            httpd.socket.close()
+            raise typer.Exit(1)
+
+
+@app.command()
+def run(
+    path: Path = typer.Argument(
+        Path("."), help="The path of the project that will run."
+    ),
+    silent: bool = typer.Option(False, help="Open the app in web browser."),
+    port: int = typer.Option(8000, help="The port that the app will run on."),
+):
+    """
+    Creates a local server to run the app on the path and port specified.
+    """
+
+    # First thing we need to do is to check if the path exists
+    if not path.exists():
+        raise cli.Abort(f"Error: Path {str(path)} does not exist.", style="red")
+
+    try:
+        start_server(path, not silent, port)
+    except OSError as e:
+        if e.errno == 48:
+            console.print(
+                f"Error: Port {port} is already in use! :( Please, stop the process using that port"
+                f"or ry another port using the --port option.",
+                style="red",
+            )
+        else:
+            console.print(f"Error: {e.strerror}", style="red")
+
+        raise cli.Abort("")
+
+
+@plugins.register
+def pyscript_subcommand():
+    return run


Test patch:
diff --git a/tests/test_run_cli_cmd.py b/tests/test_run_cli_cmd.py
new file mode 100644
index 0000000..88cbc46
--- /dev/null
+++ b/tests/test_run_cli_cmd.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest import mock
+
+import pytest
+from utils import CLIInvoker, invoke_cli  # noqa: F401
+
+BASEPATH = str(Path(__file__).parent)
+
+
+@pytest.mark.parametrize(
+    "path",
+    ["non_existing_folder", "non_existing_file.html"],
+)
+def test_run_bad_paths(invoke_cli: CLIInvoker, path: str):  # noqa: F811
+    """
+    Test that when wrap is called passing a bad path as input the command fails
+    """
+    # GIVEN a call to wrap with a bad path as argument
+    result = invoke_cli("run", path)
+    # EXPECT the command to fail
+    assert result.exit_code == 1
+    # EXPECT the right error message to be printed
+    assert f"Error: Path {path} does not exist." in result.stdout
+
+
+def test_run_server_bad_port(invoke_cli: CLIInvoker):  # noqa: F811
+    """
+    Test that when run is called passing a bad port as input the command fails
+    """
+    # GIVEN a call to run with a bad port as argument
+    result = invoke_cli("run", "--port", "bad_port")
+    # EXPECT the command to fail
+    assert result.exit_code == 2
+    # EXPECT the right error message to be printed
+    assert "Error" in result.stdout
+    assert (
+        "Invalid value for '--port': 'bad_port' is not a valid integer" in result.stdout
+    )
+
+
+@mock.patch("pyscript.plugins.run.start_server")
+def test_run_server_with_default_values(
+    start_server_mock, invoke_cli: CLIInvoker  # noqa: F811
+):
+    """
+    Test that when run is called without arguments the command runs with the
+    default values
+    """
+    # GIVEN a call to run without arguments
+    result = invoke_cli("run")
+    # EXPECT the command to succeed
+    assert result.exit_code == 0
+    # EXPECT start_server_mock function to be called with the default values:
+    # Path("."): path to local folder
+    # show=True: the opposite of the --silent option (which default to False)
+    # port=8000: that is the default port
+    start_server_mock.assert_called_once_with(Path("."), True, 8000)
+
+
+@mock.patch("pyscript.plugins.run.start_server")
+def test_run_server_with_silent_flag(
+    start_server_mock, invoke_cli: CLIInvoker  # noqa: F811
+):
+    """
+    Test that when run is called without arguments the command runs with the
+    default values
+    """
+    # GIVEN a call to run without arguments
+    result = invoke_cli("run", "--silent")
+    # EXPECT the command to succeed
+    assert result.exit_code == 0
+    # EXPECT start_server_mock function to be called with the default values:
+    # Path("."): path to local folder
+    # show=False: the opposite of the --silent option
+    # port=8000: that is the default port
+    start_server_mock.assert_called_once_with(Path("."), False, 8000)
+
+
+@pytest.mark.parametrize(
+    "run_args, expected_values",
+    [
+        (("--silent",), (Path("."), False, 8000)),
+        ((BASEPATH,), (Path(BASEPATH), True, 8000)),
+        (("--port=8001",), (Path("."), True, 8001)),
+        (("--silent", "--port=8001"), (Path("."), False, 8001)),
+        ((BASEPATH, "--silent"), (Path(BASEPATH), False, 8000)),
+        ((BASEPATH, "--port=8001"), (Path(BASEPATH), True, 8001)),
+        ((BASEPATH, "--silent", "--port=8001"), (Path(BASEPATH), False, 8001)),
+        ((BASEPATH, "--port=8001"), (Path(BASEPATH), True, 8001)),
+    ],
+)
+@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
+):
+    """
+    Test that when run is called without arguments the command runs with the
+    default values
+    """
+    # GIVEN a call to run without arguments
+    result = invoke_cli("run", *run_args)
+    # 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)
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..5abc565
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING, Callable
+
+import pytest
+from mypy_extensions import VarArg
+from typer.testing import CliRunner, Result
+
+from pyscript.cli import app
+
+if TYPE_CHECKING:
+    from _pytest.monkeypatch import MonkeyPatch
+
+CLIInvoker = Callable[[VarArg(str)], Result]
+
+
+@pytest.fixture()
+def invoke_cli(tmp_path: Path, monkeypatch: "MonkeyPatch") -> CLIInvoker:
+    """Returns a function, which can be used to call the CLI from within a temporary directory."""
+    runner = CliRunner()
+
+    monkeypatch.chdir(tmp_path)
+
+    def f(*args: str) -> Result:
+        return runner.invoke(app, args)
+
+    return f