Skip to content

Commit 3bd6a3e

Browse files
authored
Add run cmd (#84)
* add a new run plugin cmd * add run cmd * add logic to run cmd so that it starts a static server and opens the browser at the right location * improve message after serving * move starting server in its own function so we can better handle exceptions * improve error management actually moving how we manage stopping the service into the actuall server context manager * manage socketserver allow_reuse_address to fix ghost instances of the server after process has terminated * improve docstring * make sure path exists * add function to manage users passing explicit path * add proper support for path * app folder to the serving message we print * improve exit conditions * add run cmd test files * replace show option with silent * add test for multiple valid argument permutations * make mypy happy * fix wrong comment on test --------- Co-authored-by: Fabio Pliger <[email protected]>
1 parent 9ad6714 commit 3bd6a3e

File tree

4 files changed

+272
-1
lines changed

4 files changed

+272
-1
lines changed

src/pyscript/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pyscript import __version__, app, console, plugins, typer
88
from pyscript.plugins import hookspecs
99

10-
DEFAULT_PLUGINS = ["create", "wrap"]
10+
DEFAULT_PLUGINS = ["create", "wrap", "run"]
1111

1212

1313
def ok(msg: str = ""):

src/pyscript/plugins/run.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
from __future__ import annotations
2+
3+
import socketserver
4+
import threading
5+
import webbrowser
6+
from functools import partial
7+
from http.server import SimpleHTTPRequestHandler
8+
from pathlib import Path
9+
10+
from pyscript import app, cli, console, plugins
11+
12+
try:
13+
import rich_click.typer as typer
14+
except ImportError: # pragma: no cover
15+
import typer # type: ignore
16+
17+
18+
def get_folder_based_http_request_handler(
19+
folder: Path,
20+
) -> type[SimpleHTTPRequestHandler]:
21+
"""
22+
Returns a FolderBasedHTTPRequestHandler with the specified directory.
23+
24+
Args:
25+
folder (str): The folder that will be served.
26+
27+
Returns:
28+
FolderBasedHTTPRequestHandler: The SimpleHTTPRequestHandler with the
29+
specified directory.
30+
"""
31+
32+
class FolderBasedHTTPRequestHandler(SimpleHTTPRequestHandler):
33+
def __init__(self, *args, **kwargs):
34+
super().__init__(*args, directory=folder, **kwargs)
35+
36+
return FolderBasedHTTPRequestHandler
37+
38+
39+
def split_path_and_filename(path: Path) -> tuple[Path, str]:
40+
"""Receives a path to a pyscript project or file and returns the base
41+
path of the project and the filename that should be opened (filename defaults
42+
to "" (empty string) if the path points to a folder).
43+
44+
Args:
45+
path (str): The path to the pyscript project or file.
46+
47+
48+
Returns:
49+
tuple(str, str): The base path of the project and the filename
50+
"""
51+
abs_path = path.absolute()
52+
if path.is_file():
53+
return Path("/".join(abs_path.parts[:-1])), abs_path.parts[-1]
54+
else:
55+
return abs_path, ""
56+
57+
58+
def start_server(path: Path, show: bool, port: int):
59+
"""
60+
Creates a local server to run the app on the path and port specified.
61+
62+
Args:
63+
path(str): The path of the project that will run.
64+
show(bool): Open the app in web browser.
65+
port(int): The port that the app will run on.
66+
67+
Returns:
68+
None
69+
"""
70+
# We need to set the allow_resuse_address to True because socketserver will
71+
# keep the port in use for a while after the server is stopped.
72+
# see https://stackoverflow.com/questions/31745040/
73+
socketserver.TCPServer.allow_reuse_address = True
74+
75+
app_folder, filename = split_path_and_filename(path)
76+
CustomHTTPRequestHandler = get_folder_based_http_request_handler(app_folder)
77+
78+
# Start the server within a context manager to make sure we clean up after
79+
with socketserver.TCPServer(("", port), CustomHTTPRequestHandler) as httpd:
80+
console.print(
81+
f"Serving from {app_folder} at port {port}. To stop, press Ctrl+C.",
82+
style="green",
83+
)
84+
85+
if show:
86+
# Open the web browser in a separate thread after 0.5 seconds.
87+
open_browser = partial(
88+
webbrowser.open_new_tab, f"http://localhost:{port}/{filename}"
89+
)
90+
threading.Timer(0.5, open_browser).start()
91+
92+
try:
93+
httpd.serve_forever()
94+
except KeyboardInterrupt:
95+
console.print("\nStopping server... Bye bye!")
96+
97+
# Clean after ourselves....
98+
httpd.shutdown()
99+
httpd.socket.close()
100+
raise typer.Exit(1)
101+
102+
103+
@app.command()
104+
def run(
105+
path: Path = typer.Argument(
106+
Path("."), help="The path of the project that will run."
107+
),
108+
silent: bool = typer.Option(False, help="Open the app in web browser."),
109+
port: int = typer.Option(8000, help="The port that the app will run on."),
110+
):
111+
"""
112+
Creates a local server to run the app on the path and port specified.
113+
"""
114+
115+
# First thing we need to do is to check if the path exists
116+
if not path.exists():
117+
raise cli.Abort(f"Error: Path {str(path)} does not exist.", style="red")
118+
119+
try:
120+
start_server(path, not silent, port)
121+
except OSError as e:
122+
if e.errno == 48:
123+
console.print(
124+
f"Error: Port {port} is already in use! :( Please, stop the process using that port"
125+
f"or ry another port using the --port option.",
126+
style="red",
127+
)
128+
else:
129+
console.print(f"Error: {e.strerror}", style="red")
130+
131+
raise cli.Abort("")
132+
133+
134+
@plugins.register
135+
def pyscript_subcommand():
136+
return run

tests/test_run_cli_cmd.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from unittest import mock
5+
6+
import pytest
7+
from utils import CLIInvoker, invoke_cli # noqa: F401
8+
9+
BASEPATH = str(Path(__file__).parent)
10+
11+
12+
@pytest.mark.parametrize(
13+
"path",
14+
["non_existing_folder", "non_existing_file.html"],
15+
)
16+
def test_run_bad_paths(invoke_cli: CLIInvoker, path: str): # noqa: F811
17+
"""
18+
Test that when wrap is called passing a bad path as input the command fails
19+
"""
20+
# GIVEN a call to wrap with a bad path as argument
21+
result = invoke_cli("run", path)
22+
# EXPECT the command to fail
23+
assert result.exit_code == 1
24+
# EXPECT the right error message to be printed
25+
assert f"Error: Path {path} does not exist." in result.stdout
26+
27+
28+
def test_run_server_bad_port(invoke_cli: CLIInvoker): # noqa: F811
29+
"""
30+
Test that when run is called passing a bad port as input the command fails
31+
"""
32+
# GIVEN a call to run with a bad port as argument
33+
result = invoke_cli("run", "--port", "bad_port")
34+
# EXPECT the command to fail
35+
assert result.exit_code == 2
36+
# EXPECT the right error message to be printed
37+
assert "Error" in result.stdout
38+
assert (
39+
"Invalid value for '--port': 'bad_port' is not a valid integer" in result.stdout
40+
)
41+
42+
43+
@mock.patch("pyscript.plugins.run.start_server")
44+
def test_run_server_with_default_values(
45+
start_server_mock, invoke_cli: CLIInvoker # noqa: F811
46+
):
47+
"""
48+
Test that when run is called without arguments the command runs with the
49+
default values
50+
"""
51+
# GIVEN a call to run without arguments
52+
result = invoke_cli("run")
53+
# EXPECT the command to succeed
54+
assert result.exit_code == 0
55+
# EXPECT start_server_mock function to be called with the default values:
56+
# Path("."): path to local folder
57+
# show=True: the opposite of the --silent option (which default to False)
58+
# port=8000: that is the default port
59+
start_server_mock.assert_called_once_with(Path("."), True, 8000)
60+
61+
62+
@mock.patch("pyscript.plugins.run.start_server")
63+
def test_run_server_with_silent_flag(
64+
start_server_mock, invoke_cli: CLIInvoker # noqa: F811
65+
):
66+
"""
67+
Test that when run is called without arguments the command runs with the
68+
default values
69+
"""
70+
# GIVEN a call to run without arguments
71+
result = invoke_cli("run", "--silent")
72+
# EXPECT the command to succeed
73+
assert result.exit_code == 0
74+
# EXPECT start_server_mock function to be called with the default values:
75+
# Path("."): path to local folder
76+
# show=False: the opposite of the --silent option
77+
# port=8000: that is the default port
78+
start_server_mock.assert_called_once_with(Path("."), False, 8000)
79+
80+
81+
@pytest.mark.parametrize(
82+
"run_args, expected_values",
83+
[
84+
(("--silent",), (Path("."), False, 8000)),
85+
((BASEPATH,), (Path(BASEPATH), True, 8000)),
86+
(("--port=8001",), (Path("."), True, 8001)),
87+
(("--silent", "--port=8001"), (Path("."), False, 8001)),
88+
((BASEPATH, "--silent"), (Path(BASEPATH), False, 8000)),
89+
((BASEPATH, "--port=8001"), (Path(BASEPATH), True, 8001)),
90+
((BASEPATH, "--silent", "--port=8001"), (Path(BASEPATH), False, 8001)),
91+
((BASEPATH, "--port=8001"), (Path(BASEPATH), True, 8001)),
92+
],
93+
)
94+
@mock.patch("pyscript.plugins.run.start_server")
95+
def test_run_server_with_valid_combinations(
96+
start_server_mock, invoke_cli: CLIInvoker, run_args, expected_values # noqa: F811
97+
):
98+
"""
99+
Test that when run is called without arguments the command runs with the
100+
default values
101+
"""
102+
# GIVEN a call to run without arguments
103+
result = invoke_cli("run", *run_args)
104+
# EXPECT the command to succeed
105+
assert result.exit_code == 0
106+
# EXPECT start_server_mock function to be called with the expected values
107+
start_server_mock.assert_called_once_with(*expected_values)

tests/utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING, Callable
5+
6+
import pytest
7+
from mypy_extensions import VarArg
8+
from typer.testing import CliRunner, Result
9+
10+
from pyscript.cli import app
11+
12+
if TYPE_CHECKING:
13+
from _pytest.monkeypatch import MonkeyPatch
14+
15+
CLIInvoker = Callable[[VarArg(str)], Result]
16+
17+
18+
@pytest.fixture()
19+
def invoke_cli(tmp_path: Path, monkeypatch: "MonkeyPatch") -> CLIInvoker:
20+
"""Returns a function, which can be used to call the CLI from within a temporary directory."""
21+
runner = CliRunner()
22+
23+
monkeypatch.chdir(tmp_path)
24+
25+
def f(*args: str) -> Result:
26+
return runner.invoke(app, args)
27+
28+
return f

0 commit comments

Comments
 (0)