Skip to content

Commit 9f61937

Browse files
authored
Ensure default (built in) commands are plugins. (#25)
Ensure default (built in) commands are plugins.
1 parent 947ee97 commit 9f61937

File tree

8 files changed

+228
-110
lines changed

8 files changed

+228
-110
lines changed

src/pyscript/_generator.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import datetime
12
from pathlib import Path
23
from typing import Optional
4+
from uuid import uuid4
35

46
import jinja2
7+
import toml
58

69
_env = jinja2.Environment(loader=jinja2.PackageLoader("pyscript"))
710

@@ -18,3 +21,35 @@ def file_to_html(input_path: Path, title: str, output_path: Optional[Path]) -> N
1821
output_path = output_path or input_path.with_suffix(".html")
1922
with input_path.open("r") as fp:
2023
string_to_html(fp.read(), title, output_path)
24+
25+
26+
def create_project(
27+
app_name: str,
28+
app_description: str,
29+
author_name: str,
30+
author_email: str,
31+
) -> None:
32+
"""
33+
New files created:
34+
35+
manifest.toml - project metadata
36+
index.html - a "Hello world" start page for the project.
37+
38+
TODO: more files to add to the core project start state.
39+
"""
40+
context = {
41+
"app_name": app_name,
42+
"app_description": app_description,
43+
"author_name": author_name,
44+
"author_email": author_email,
45+
"version": f"{datetime.date.today().year}.1.1",
46+
"created_on": datetime.datetime.now(),
47+
"id": str(uuid4()),
48+
}
49+
app_dir = Path(".") / app_name
50+
app_dir.mkdir()
51+
manifest_file = app_dir / "manifest.toml"
52+
with manifest_file.open("w", encoding="utf-8") as fp:
53+
toml.dump(context, fp)
54+
index_file = app_dir / "index.html"
55+
string_to_html('print("Hello, world!")', app_name, index_file)

src/pyscript/cli.py

Lines changed: 13 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
"""The main CLI entrypoint and commands."""
22
import sys
3-
import time
4-
import webbrowser
5-
from pathlib import Path
63
from typing import Any, Optional
74

85
from pluggy import PluginManager
96

107
from pyscript import __version__, app, console, plugins, typer
11-
from pyscript._generator import file_to_html, string_to_html
128
from pyscript.plugins import hookspecs
139

14-
DEFAULT_PLUGINS = ["create", "delete"]
10+
DEFAULT_PLUGINS = ["create", "wrap"]
1511

1612

17-
def _print_version():
18-
console.print(f"PyScript CLI version: {__version__}", style="bold green")
13+
class Abort(typer.Abort):
14+
"""
15+
Abort with a consistent error message.
16+
"""
17+
18+
def __init__(self, msg: str, *args: Any, **kwargs: Any):
19+
console.print(msg, style="red")
20+
super().__init__(*args, **kwargs)
1921

2022

2123
@app.callback(invoke_without_command=True, no_args_is_help=True)
@@ -24,106 +26,28 @@ def main(
2426
None, "--version", help="Show project version and exit."
2527
)
2628
):
27-
"""Command Line Interface for PyScript."""
29+
"""
30+
Command Line Interface for PyScript.
31+
"""
2832
if version:
29-
_print_version()
33+
console.print(f"PyScript CLI version: {__version__}", style="bold green")
3034
raise typer.Exit()
3135

3236

33-
@app.command()
34-
def version() -> None:
35-
"""Show project version and exit."""
36-
_print_version()
37-
38-
39-
_input_file_argument = typer.Argument(
40-
None,
41-
help="An optional path to the input .py script. If not provided, must use '-c' flag.",
42-
)
43-
_output_file_option = typer.Option(
44-
None,
45-
"-o",
46-
"--output",
47-
help="Path to the resulting HTML output file. Defaults to input_file with suffix replaced.",
48-
)
49-
_command_option = typer.Option(
50-
None, "-c", "--command", help="If provided, embed a single command string."
51-
)
52-
_show_option = typer.Option(None, help="Open output file in web browser.")
53-
_title_option = typer.Option(None, help="Add title to HTML file.")
54-
55-
56-
class Abort(typer.Abort):
57-
def __init__(self, msg: str, *args: Any, **kwargs: Any):
58-
console.print(msg, style="red")
59-
super().__init__(*args, **kwargs)
60-
61-
62-
@app.command()
63-
def wrap(
64-
input_file: Optional[Path] = _input_file_argument,
65-
output: Optional[Path] = _output_file_option,
66-
command: Optional[str] = _command_option,
67-
show: Optional[bool] = _show_option,
68-
title: Optional[str] = _title_option,
69-
) -> None:
70-
"""Wrap a Python script inside an HTML file."""
71-
title = title or "PyScript App"
72-
73-
if not input_file and not command:
74-
raise Abort(
75-
"Must provide either an input '.py' file or a command with the '-c' option."
76-
)
77-
if input_file and command:
78-
raise Abort("Cannot provide both an input '.py' file and '-c' option.")
79-
80-
# Derive the output path if it is not provided
81-
remove_output = False
82-
if output is None:
83-
if command and show:
84-
output = Path("pyscript_tmp.html")
85-
remove_output = True
86-
elif not command:
87-
assert input_file is not None
88-
output = input_file.with_suffix(".html")
89-
else:
90-
raise Abort("Must provide an output file or use `--show` option")
91-
92-
if input_file is not None:
93-
file_to_html(input_file, title, output)
94-
95-
if command:
96-
string_to_html(command, title, output)
97-
98-
assert output is not None
99-
100-
if show:
101-
console.print("Opening in web browser!")
102-
webbrowser.open(f"file://{output.resolve()}")
103-
104-
if remove_output:
105-
time.sleep(1)
106-
output.unlink()
107-
108-
10937
pm = PluginManager("pyscript")
11038

11139
pm.add_hookspecs(hookspecs)
112-
11340
for modname in DEFAULT_PLUGINS:
11441
importspec = f"pyscript.plugins.{modname}"
115-
11642
try:
11743
__import__(importspec)
11844
except ImportError as e:
11945
raise ImportError(
12046
f'Error importing plugin "{modname}": {e.args[0]}'
12147
).with_traceback(e.__traceback__) from e
122-
12348
else:
12449
mod = sys.modules[importspec]
12550
pm.register(mod, modname)
126-
12751
loaded = pm.load_setuptools_entrypoints("pyscript")
12852

12953
for cmd in pm.hook.pyscript_subcommand():

src/pyscript/plugins/create.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
1-
from pyscript import console, plugins
1+
from pyscript import app, cli, plugins
2+
from pyscript._generator import create_project
23

4+
try:
5+
import rich_click.typer as typer
6+
except ImportError: # pragma: no cover
7+
import typer # type: ignore
38

4-
def create():
5-
"""Creates a new PyScript Project from scratch."""
6-
console.print("pyscript create cmd not yet available..", style="bold green")
7-
return True
9+
10+
@app.command()
11+
def create(
12+
app_name: str = typer.Argument(..., help="The name of your new app."),
13+
app_description: str = typer.Option(..., prompt=True),
14+
author_name: str = typer.Option(..., prompt=True),
15+
author_email: str = typer.Option(..., prompt=True),
16+
):
17+
"""
18+
Create a new pyscript project with the passed in name, creating a new
19+
directory in the current directory.
20+
Inspired by Sphinx guided setup.
21+
TODO: Agree on the metadata to be collected from the user.
22+
"""
23+
try:
24+
create_project(app_name, app_description, author_name, author_email)
25+
except FileExistsError:
26+
raise cli.Abort(
27+
f"A directory called {app_name} already exists in this location."
28+
)
829

930

1031
@plugins.register

src/pyscript/plugins/delete.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/pyscript/plugins/wrap.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import time
2+
import webbrowser
3+
from pathlib import Path
4+
from typing import Optional
5+
6+
from pyscript import app, cli, console, plugins
7+
from pyscript._generator import file_to_html, string_to_html
8+
9+
try:
10+
import rich_click.typer as typer
11+
except ImportError: # pragma: no cover
12+
import typer # type: ignore
13+
14+
15+
@app.command()
16+
def wrap(
17+
input_file: Optional[Path] = typer.Argument(
18+
None,
19+
help="An optional path to the input .py script. If not provided, must use '-c' flag.",
20+
),
21+
output: Optional[Path] = typer.Option(
22+
None,
23+
"-o",
24+
"--output",
25+
help="Path to the resulting HTML output file. Defaults to input_file with suffix replaced.",
26+
),
27+
command: Optional[str] = typer.Option(
28+
None, "-c", "--command", help="If provided, embed a single command string."
29+
),
30+
show: Optional[bool] = typer.Option(None, help="Open output file in web browser."),
31+
title: Optional[str] = typer.Option(None, help="Add title to HTML file."),
32+
) -> None:
33+
"""Wrap a Python script inside an HTML file."""
34+
title = title or "PyScript App"
35+
36+
if not input_file and not command:
37+
raise cli.Abort(
38+
"Must provide either an input '.py' file or a command with the '-c' option."
39+
)
40+
if input_file and command:
41+
raise cli.Abort("Cannot provide both an input '.py' file and '-c' option.")
42+
43+
# Derive the output path if it is not provided
44+
remove_output = False
45+
if output is None:
46+
if command and show:
47+
output = Path("pyscript_tmp.html")
48+
remove_output = True
49+
elif not command:
50+
assert input_file is not None
51+
output = input_file.with_suffix(".html")
52+
else:
53+
raise cli.Abort("Must provide an output file or use `--show` option")
54+
if input_file is not None:
55+
file_to_html(input_file, title, output)
56+
if command:
57+
string_to_html(command, title, output)
58+
if output:
59+
if show:
60+
console.print("Opening in web browser!")
61+
webbrowser.open(f"file://{output.resolve()}")
62+
if remove_output:
63+
time.sleep(1)
64+
output.unlink()
65+
66+
67+
@plugins.register
68+
def pyscript_subcommand():
69+
return wrap

tests/conftest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from pathlib import Path
2+
from typing import Any
3+
4+
import pytest
5+
from _pytest.monkeypatch import MonkeyPatch
6+
7+
8+
@pytest.fixture()
9+
def tmp_cwd(monkeypatch: MonkeyPatch, tmp_path: Path) -> Path:
10+
"""Create & return a temporary directory after setting current working directory to it."""
11+
monkeypatch.chdir(tmp_path)
12+
return tmp_path
13+
14+
15+
@pytest.fixture(scope="session")
16+
def is_not_none() -> Any:
17+
"""
18+
An object that can be used to test whether another is None.
19+
20+
This is particularly useful when testing contents of collections, e.g.:
21+
22+
```python
23+
def test_data(data, is_not_none):
24+
assert data == {"some_key": is_not_none, "some_other_key": 5}
25+
```
26+
27+
"""
28+
29+
class _NotNone:
30+
def __eq__(self, other):
31+
return other is not None
32+
33+
return _NotNone()

tests/test_cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ def f(*args: str) -> Result:
3030
return f
3131

3232

33-
@pytest.mark.parametrize("cli_arg", ["version", "--version"])
34-
def test_version(invoke_cli: CLIInvoker, cli_arg: str) -> None:
35-
result = invoke_cli(cli_arg)
33+
def test_version() -> None:
34+
runner = CliRunner()
35+
result = runner.invoke(app, "--version")
3636
assert result.exit_code == 0
3737
assert f"PyScript CLI version: {__version__}" in result.stdout
3838

@@ -117,7 +117,7 @@ def test_wrap_show(
117117
else:
118118
args = additional_args
119119

120-
with unittest.mock.patch("pyscript.cli.webbrowser.open") as browser_mock:
120+
with unittest.mock.patch("pyscript.plugins.wrap.webbrowser.open") as browser_mock:
121121
result = invoke_cli("wrap", "--show", *args)
122122

123123
assert result.exit_code == 0

0 commit comments

Comments
 (0)