Skip to content

Commit 6125ebd

Browse files
authored
Support shebangs with a single argument (#765)
Fixes #764
1 parent ec5c5a4 commit 6125ebd

File tree

6 files changed

+110
-33
lines changed

6 files changed

+110
-33
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
### Fixed
1313
* Fix issue with `(count nil)` throwing an exception (#759)
1414
* Fix issue with keyword fn not testing for test membership in sets (#762)
15+
* Fix an issue for executing Basilisp scripts via a shebang where certain platforms may not support more than one argument in the shebang line (#764)
1516

1617
## [v0.1.0b0]
1718
### Added

docs/gettingstarted.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,11 @@ Basilisp code will operate normally (calling into other Basilisp namespaces and
125125

126126
Manual bootstrapping is designed to be as simple as possible, but it is not the long term goal of this project's maintainers that it should be necessary.
127127
Eventually, we plan to release a tool akin to Python's Poetry, or similar tools in other languages that helps facilitate both dependency management and packaging in such a way that bootstrapping is completely transparent to the developer.
128+
129+
Basilisp can also be invoked as a script using a shebang line which would circumvent the need to bootstrap using the methods above.
130+
For systems where the shebang line allows arguments, you can use ``#!/usr/bin/env basilisp run``, but for those where only one argument is permitted ``#!/usr/bin/env basilisp-run`` will work.
131+
132+
.. code-block:: clojure
133+
134+
#!/usr/bin/env basilisp
135+
(println "Hello world!")

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pytest = ["pytest"]
5959

6060
[tool.poetry.scripts]
6161
basilisp = "basilisp.cli:invoke_cli"
62+
basilisp-run = "basilisp.cli:run_script"
6263

6364
[tool.poetry.plugins.pytest11]
6465
basilisp_test_runner = "basilisp.contrib.pytest.testrunner"

src/basilisp/cli.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
import traceback
77
import types
8+
from pathlib import Path
89
from typing import Any, Callable, Optional, Sequence, Type
910

1011
from basilisp import main as basilisp
@@ -26,15 +27,6 @@
2627
DEFAULT_COMPILER_OPTS = {k.name: v for k, v in compiler.compiler_opts().items()}
2728

2829

29-
def eval_file(filename: str, ctx: compiler.CompilerContext, ns: runtime.Namespace):
30-
"""Evaluate a file with the given name into a Python module AST node."""
31-
last = None
32-
for form in reader.read_file(filename, resolver=runtime.resolve_alias):
33-
assert not isinstance(form, reader.ReaderConditional)
34-
last = compiler.compile_and_exec_form(form, ctx, ns)
35-
return last
36-
37-
3830
def eval_stream(stream, ctx: compiler.CompilerContext, ns: runtime.Namespace):
3931
"""Evaluate the forms in stdin into a Python module AST node."""
4032
last = None
@@ -53,6 +45,11 @@ def eval_str(s: str, ctx: compiler.CompilerContext, ns: runtime.Namespace, eof:
5345
return last
5446

5547

48+
def eval_file(filename: str, ctx: compiler.CompilerContext, ns: runtime.Namespace):
49+
"""Evaluate a file with the given name into a Python module AST node."""
50+
return eval_str(f'(load-file "{filename}")', ctx, ns, eof=object())
51+
52+
5653
def bootstrap_repl(ctx: compiler.CompilerContext, which_ns: str) -> types.ModuleType:
5754
"""Bootstrap the REPL with a few useful vars and returned the bootstrapped
5855
module so it's functions can be used by the REPL command."""
@@ -421,17 +418,11 @@ def run(
421418
ns.refer_all(core_ns)
422419

423420
if args.code:
424-
print(runtime.lrepr(eval_str(args.file_or_code, ctx, ns, eof)))
421+
eval_str(args.file_or_code, ctx, ns, eof)
425422
elif args.file_or_code == STDIN_FILE_NAME:
426-
print(
427-
runtime.lrepr(
428-
eval_stream(
429-
io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8"), ctx, ns
430-
)
431-
)
432-
)
423+
eval_stream(io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8"), ctx, ns)
433424
else:
434-
print(runtime.lrepr(eval_file(args.file_or_code, ctx, ns)))
425+
eval_file(args.file_or_code, ctx, ns)
435426

436427

437428
@_subcommand(
@@ -489,7 +480,21 @@ def _add_version_subcommand(_: argparse.ArgumentParser) -> None:
489480
pass
490481

491482

483+
def run_script():
484+
"""Entrypoint to run the Basilisp script named by `sys.argv[1]` as by the
485+
`basilisp run` subcommand.
486+
487+
This is provided as a shim for platforms where shebang lines cannot contain more
488+
than one argument and thus `#!/usr/bin/env basilisp run` would be non-functional.
489+
490+
The current process is replaced as by `os.execlp`."""
491+
# os.exec* functions do not perform shell expansion, so we must do so manually.
492+
script_path = Path(sys.argv[1]).resolve()
493+
os.execlp("basilisp", "basilisp", "run", script_path)
494+
495+
492496
def invoke_cli(args: Optional[Sequence[str]] = None) -> None:
497+
"""Entrypoint to run the Basilisp CLI."""
493498
parser = argparse.ArgumentParser(
494499
description="Basilisp is a Lisp dialect inspired by Clojure targeting Python 3."
495500
)

tests/basilisp/cli_test.py

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import io
22
import os
3+
import pathlib
4+
import platform
35
import re
6+
import stat
7+
import subprocess
48
import tempfile
59
import time
610
from threading import Thread
711
from typing import Optional, Sequence
812
from unittest.mock import patch
913

14+
import attr
1015
import pytest
1116

1217
from basilisp.cli import BOOL_FALSE, BOOL_TRUE, invoke_cli
@@ -35,34 +40,53 @@ def isolated_filesystem():
3540
os.chdir(wd)
3641

3742

43+
@attr.frozen
44+
class CapturedIO:
45+
out: str
46+
err: str
47+
lisp_out: str
48+
lisp_err: str
49+
50+
3851
@pytest.fixture
39-
def run_cli(monkeypatch, capsys):
52+
def run_cli(monkeypatch, capsys, cap_lisp_io):
4053
def _run_cli(args: Sequence[str], input: Optional[str] = None):
4154
if input is not None:
4255
monkeypatch.setattr(
4356
"sys.stdin", io.TextIOWrapper(io.BytesIO(input.encode("utf-8")))
4457
)
4558
invoke_cli([*args])
46-
return capsys.readouterr()
59+
python_io = capsys.readouterr()
60+
lisp_out, lisp_err = cap_lisp_io
61+
return CapturedIO(
62+
out=python_io.out,
63+
err=python_io.err,
64+
lisp_out=lisp_out.getvalue(),
65+
lisp_err=lisp_err.getvalue(),
66+
)
4767

4868
return _run_cli
4969

5070

5171
def test_debug_flag(run_cli):
52-
result = run_cli(["run", "--disable-ns-cache", "true", "-c", "(+ 1 2)"])
53-
assert "3\n" == result.out
72+
result = run_cli(["run", "--disable-ns-cache", "true", "-c", "(println (+ 1 2))"])
73+
assert "3\n" == result.lisp_out
5474
assert os.environ["BASILISP_DO_NOT_CACHE_NAMESPACES"].lower() == "true"
5575

5676

5777
class TestCompilerFlags:
5878
def test_no_flag(self, run_cli):
59-
result = run_cli(["run", "--warn-on-var-indirection", "-c", "(+ 1 2)"])
60-
assert "3\n" == result.out
79+
result = run_cli(
80+
["run", "--warn-on-var-indirection", "-c", "(println (+ 1 2))"]
81+
)
82+
assert "3\n" == result.lisp_out
6183

6284
@pytest.mark.parametrize("val", BOOL_TRUE | BOOL_FALSE)
6385
def test_valid_flag(self, run_cli, val):
64-
result = run_cli(["run", "--warn-on-var-indirection", val, "-c", "(+ 1 2)"])
65-
assert "3\n" == result.out
86+
result = run_cli(
87+
["run", "--warn-on-var-indirection", val, "-c", "(println (+ 1 2))"]
88+
)
89+
assert "3\n" == result.lisp_out
6690

6791
@pytest.mark.parametrize("val", ["maybe", "not-no", "4"])
6892
def test_invalid_flag(self, run_cli, val):
@@ -141,20 +165,45 @@ def test_other_exception(self, run_cli):
141165

142166
class TestRun:
143167
def test_run_code(self, run_cli):
144-
result = run_cli(["run", "-c", "(+ 1 2)"])
145-
assert "3\n" == result.out
168+
result = run_cli(["run", "-c", "(println (+ 1 2))"])
169+
assert "3\n" == result.lisp_out
146170

147171
def test_run_file(self, isolated_filesystem, run_cli):
148172
with open("test.lpy", mode="w") as f:
149-
f.write("(+ 1 2)")
173+
f.write("(println (+ 1 2))")
150174
result = run_cli(["run", "test.lpy"])
151-
assert "3\n" == result.out
175+
assert "3\n" == result.lisp_out
152176

153177
def test_run_stdin(self, run_cli):
154-
result = run_cli(["run", "-"], input="(+ 1 2)")
155-
assert "3\n" == result.out
178+
result = run_cli(["run", "-"], input="(println (+ 1 2))")
179+
assert "3\n" == result.lisp_out
156180

157181

158182
def test_version(run_cli):
159183
result = run_cli(["version"])
160184
assert re.compile(r"^Basilisp (\d+)\.(\d+)\.(\w*)(\d+)\n$").match(result.out)
185+
186+
187+
@pytest.mark.skipif(
188+
platform.system().lower() == "windows",
189+
reason=(
190+
"Shebangs are only supported virtually by Windows Python installations, "
191+
"so this doesn't work natively on Windows"
192+
),
193+
)
194+
def test_run_script(tmp_path: pathlib.Path):
195+
script_path = tmp_path / "script.lpy"
196+
script_path.write_text(
197+
"\n".join(
198+
[
199+
"#!/usr/bin/env basilisp-run",
200+
"(ns test-run-script-ns ",
201+
" (:import os))",
202+
"",
203+
'(println "Hello world from Basilisp!")',
204+
]
205+
)
206+
)
207+
script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC)
208+
res = subprocess.run([script_path.resolve()], check=True, capture_output=True)
209+
assert res.stdout == b"Hello world from Basilisp!\n"

tests/basilisp/conftest.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import io
12
import sys
2-
from typing import Dict, Optional
3+
from typing import Dict, Optional, Tuple
34

45
import pytest
56

67
from basilisp.lang import compiler as compiler
8+
from basilisp.lang import map as lmap
79
from basilisp.lang import reader as reader
810
from basilisp.lang import runtime as runtime
911
from basilisp.lang import symbol as sym
@@ -59,3 +61,14 @@ def _lcompile(
5961
return last
6062

6163
return _lcompile
64+
65+
66+
@pytest.fixture
67+
def cap_lisp_io() -> Tuple[io.StringIO, io.StringIO]:
68+
"""Capture the values of `*out*` and `*err*` during test execution, returning
69+
`io.StringIO` for each."""
70+
with io.StringIO() as outbuf, io.StringIO() as errbuf:
71+
stdout = runtime.resolve_var(sym.symbol("*out*", ns="basilisp.core"))
72+
stderr = runtime.resolve_var(sym.symbol("*err*", ns="basilisp.core"))
73+
with runtime.bindings(lmap.map({stdout: outbuf, stderr: errbuf})):
74+
yield outbuf, errbuf

0 commit comments

Comments
 (0)