Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pyscript/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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 = ""):
Expand Down
136 changes: 136 additions & 0 deletions src/pyscript/plugins/run.py
Original file line number Diff line number Diff line change
@@ -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
107 changes: 107 additions & 0 deletions tests/test_run_cli_cmd.py
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 28 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
@@ -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