Skip to content

Commit b30be21

Browse files
committed
Add a CLI subcommand for bootstrapping the Python installation
1 parent 5f5ebe4 commit b30be21

File tree

11 files changed

+261
-13
lines changed

11 files changed

+261
-13
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
* Added support for passing through `:tag` metadata to the generated Python AST (#354)
1010
* Added support for calling symbols as functions on maps and sets (#775)
1111
* Added support for passing command line arguments to Basilisp (#779)
12-
* Added support for autocompleting names in the `python/` pseudo-namespace for Python builtins at the REPL (#787)
12+
* Added support for autocompleting names in the `python/` pseudo-namespace for Python builtins at the REPL (#787)
13+
* Added a subcommand for bootstrapping the Python installation with Basilisp (#7??)
1314

1415
### Changed
1516
* Optimize calls to Python's `operator` module into their corresponding native operators (#754)

Makefile

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ format:
88
@poetry run sh -c 'isort . && black .'
99

1010

11+
.PHONY: check
12+
check:
13+
@rm -f .coverage*
14+
@TOX_SKIP_ENV='pypy3|safety|coverage' poetry run tox run-parallel -p auto
15+
16+
17+
.PHONY: lint
18+
lint:
19+
@poetry run tox run-parallel -m lint
20+
21+
1122
.PHONY: repl
1223
repl:
1324
@BASILISP_USE_DEV_LOGGER=true poetry run basilisp repl
@@ -16,7 +27,12 @@ repl:
1627
.PHONY: test
1728
test:
1829
@rm -f .coverage*
19-
@TOX_SKIP_ENV='pypy3|safety|coverage' poetry run tox run-parallel -p 4
30+
@TOX_SKIP_ENV='pypy3' poetry run tox run-parallel -m test
31+
32+
33+
.PHONY: type-check
34+
type-check:
35+
@poetry run tox run-parallel -m mypy
2036

2137

2238
lispcore.py:

docs/cli.rst

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,22 @@ If you installed the `PyTest <https://docs.pytest.org/en/7.0.x/>`_ extra, you ca
8282
8383
basilisp test
8484
85-
Because Basilisp defers all testing logic to PyTest, you can use any standard PyTest arguments and flags from this entrypoint.
85+
Because Basilisp defers all testing logic to PyTest, you can use any standard PyTest arguments and flags from this entrypoint.
86+
87+
.. _bootstrap_cli_command:
88+
89+
Bootstrap Python Installation
90+
-----------------------------
91+
92+
For some installations, it may be desirable to have Basilisp readily importable whenever the Python interpreter is started.
93+
You can enable that as described in :ref:`bootstrapping`:
94+
95+
.. code-block:: bash
96+
97+
basilisp bootstrap
98+
99+
If you would like to remove the bootstrapped Basilisp from your installation, you can remove it:
100+
101+
.. code-block:: bash
102+
103+
basilisp bootstrap --uninstall

docs/contributing.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,15 @@ All three steps can be performed across all supported versions of CPython using
8484

8585
.. code-block:: bash
8686
87+
make check
88+
89+
Likewise, individual steps can be run across all supported verions using their respective targets:
90+
91+
.. code-block::
92+
93+
make lint
8794
make test
95+
make type-check
8896
8997
To run a more targeted CI check directly from within the Poetry shell, developers can use ``tox`` commands directly.
9098
For instance, to run only the tests for ``basilisp.io`` on Python 3.12, you could use the following command:

docs/gettingstarted.rst

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,25 @@ For systems where the shebang line allows arguments, you can use ``#!/usr/bin/en
132132
.. code-block:: clojure
133133
134134
#!/usr/bin/env basilisp-run
135-
(println "Hello world!")
135+
(println "Hello world!")
136+
137+
Finally, Basilisp has a command line option to bootstrap your Python installation such that Basilisp will already be importable whenever Python is started.
138+
This takes advantage of the ``.pth`` file feature supported by the `site <https://docs.python.org/3/library/site.html>`_ package.
139+
Specifically, any file with a ``.pth`` extension located in any of the known ``site-packages`` directories will be read at startup and, if any line of such a file starts with ``import``, it is executed.
140+
141+
.. code-block:: bash
142+
143+
$ basilisp bootstrap
144+
Your Python installation has been bootstrapped! You can undo this at any time with with `basilisp bootstrap --uninstall`.
145+
$ python
146+
Python 3.12.1 (main, Jan 3 2024, 10:01:43) [GCC 11.4.0] on linux
147+
Type "help", "copyright", "credits" or "license" for more information.
148+
>>> import importlib; importlib.import_module("basilisp.core")
149+
<module 'basilisp.core' (/home/chris/Projects/basilisp/src/basilisp/core.lpy)>
150+
151+
.. warning::
152+
153+
Code in ``.pth`` files is executed each time the Python interpreter is started.
154+
The Python ``site`` documentation warns that "[i]ts impact should thus be kept to a minimum".
155+
Bootstrapping Basilisp can take as long as 30 seconds (or perhaps longer) on the first run due to needing to compile :lpy:ns:`basilisp.core` to Python bytecode.
156+
Subsequent startups should be considerable faster unless users have taken any measures to disable :ref:`namespace_caching`.

src/basilisp/cli.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import importlib.metadata
33
import io
44
import os
5+
import site
56
import sys
7+
import textwrap
68
import traceback
79
import types
810
from pathlib import Path
@@ -242,6 +244,61 @@ def _wrapped_subcommand(subparsers: "argparse._SubParsersAction"):
242244
return _wrap_add_subcommand
243245

244246

247+
def bootstrap_basilisp_installation(_, args: argparse.Namespace) -> None:
248+
if args.quiet:
249+
print_ = lambda v: v
250+
else:
251+
print_ = print
252+
253+
if args.uninstall:
254+
if not (removed := basilisp.unbootstrap_python()):
255+
print_("No Basilisp bootstrap files were found.")
256+
else:
257+
for file in removed:
258+
print_(f"Removed '{file}'")
259+
else:
260+
basilisp.bootstrap_python()
261+
print_(
262+
"Your Python installation has been bootstrapped! You can undo this at any "
263+
"time with with `basilisp bootstrap --uninstall`."
264+
)
265+
266+
267+
@_subcommand(
268+
"bootstrap",
269+
help="bootstrap the Python installation to allow importing Basilisp namespaces",
270+
description=textwrap.dedent(
271+
"""Bootstrap the Python installation to allow importing Basilisp namespaces"
272+
without requiring an additional bootstrapping step.
273+
274+
Python installations are bootstrapped by installing a `basilispbootstrap.pth`
275+
file in your `site-packages` directory. Python installations execute `*.pth`
276+
files found at startup.
277+
278+
Bootstrapping your Python installation in this way can help avoid needing to
279+
perform manual bootstrapping from Python code within your application.
280+
281+
On the first startup, Basilisp will compile `basilisp.core` to byte code
282+
which could take up to 30 seconds in some cases depending on your system and
283+
which version of Python you are using. Subsequent startups should be
284+
considerably faster so long as you allow Basilisp to cache bytecode for"""
285+
),
286+
handler=bootstrap_basilisp_installation,
287+
)
288+
def _add_bootstrap_subcommand(parser: argparse.ArgumentParser) -> None:
289+
parser.add_argument(
290+
"--uninstall",
291+
action="store_true",
292+
help="if true, remove any `.pth` files installed by Basilisp in all site-packages directories",
293+
)
294+
parser.add_argument(
295+
"-q",
296+
"--quiet",
297+
action="store_true",
298+
help="if true, do not print out any",
299+
)
300+
301+
245302
def nrepl_server(
246303
_,
247304
args: argparse.Namespace,
@@ -418,6 +475,10 @@ def run(
418475
with runtime.ns_bindings(args.in_ns) as ns:
419476
ns.refer_all(core_ns)
420477

478+
main_ns_var = core_ns.find(sym.symbol(runtime.MAIN_NS_VAR_NAME))
479+
assert main_ns_var is not None
480+
main_ns_var.bind_root(sym.symbol(args.in_ns))
481+
421482
if args.args:
422483
cli_args_var = core_ns.find(sym.symbol(runtime.COMMAND_LINE_ARGS_VAR_NAME))
423484
assert cli_args_var is not None
@@ -516,6 +577,7 @@ def invoke_cli(args: Optional[Sequence[str]] = None) -> None:
516577
)
517578

518579
subparsers = parser.add_subparsers(help="sub-commands")
580+
_add_bootstrap_subcommand(subparsers)
519581
_add_nrepl_server_subcommand(subparsers)
520582
_add_repl_subcommand(subparsers)
521583
_add_run_subcommand(subparsers)

src/basilisp/importer.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import os
55
import os.path
66
import sys
7+
import traceback
78
import types
89
from functools import lru_cache
910
from importlib.abc import MetaPathFinder, SourceLoader
1011
from importlib.machinery import ModuleSpec
11-
from typing import Iterable, List, Mapping, MutableMapping, Optional, cast
12+
from typing import Iterable, List, Mapping, MutableMapping, Optional, Sequence, cast
1213

1314
from basilisp.lang import compiler as compiler
1415
from basilisp.lang import reader as reader
@@ -133,7 +134,7 @@ def __init__(self):
133134
def find_spec(
134135
self,
135136
fullname: str,
136-
path, # Optional[List[str]] # MyPy complains this is incompatible with supertype
137+
path: Optional[Sequence[str]],
137138
target: Optional[types.ModuleType] = None,
138139
) -> Optional[ModuleSpec]:
139140
"""Find the ModuleSpec for the specified Basilisp module.
@@ -214,9 +215,61 @@ def get_filename(self, fullname: str) -> str: # pragma: no cover
214215
try:
215216
cached = self._cache[fullname]
216217
except KeyError as e:
217-
raise ImportError(f"Could not import module '{fullname}'") from e
218-
spec = cached["spec"]
219-
return spec.loader_state.filename
218+
if (spec := self.find_spec(fullname, None)) is None:
219+
raise ImportError(f"Could not import module '{fullname}'") from e
220+
else:
221+
spec = cached["spec"]
222+
assert spec is not None, "spec must be defined here"
223+
return spec.loader_state["filename"]
224+
225+
def get_code(self, fullname: str) -> Optional[types.CodeType]:
226+
"""Return code to load a Basilisp module.
227+
228+
This function is part of the ABC for `importlib.abc.ExecutionLoader` which is
229+
what Python uses to execute modules at the command line as `python -m module`.
230+
"""
231+
core_ns = runtime.Namespace.get(runtime.CORE_NS_SYM)
232+
assert core_ns is not None
233+
234+
with runtime.ns_bindings("basilisp.namespace-executor") as ns:
235+
ns.refer_all(core_ns)
236+
237+
# Set the *main-ns* variable to the current namespace.
238+
main_ns_var = core_ns.find(sym.symbol(runtime.MAIN_NS_VAR_NAME))
239+
assert main_ns_var is not None
240+
main_ns_var.bind_root(sym.symbol(fullname))
241+
242+
# Basilisp can only ever product multiple `types.CodeType` objects for any
243+
# given module because it compiles each form as a separate unit, but
244+
# `ExecutionLoader.get_code` expects a single `types.CodeType` object. To
245+
# simulate this requirement, we generate a single `(load "...")` to execute
246+
# in a synthetic namespace.
247+
#
248+
# The target namespace is free to interpret
249+
code: List[types.CodeType] = []
250+
path = "/" + "/".join(fullname.split("."))
251+
forms = cast(
252+
List[ReaderForm],
253+
list(
254+
reader.read_str(f'(load "{path}")', resolver=runtime.resolve_alias)
255+
),
256+
)
257+
assert len(forms) == 1
258+
try:
259+
compiler.compile_and_exec_form(
260+
forms[0],
261+
compiler.CompilerContext(
262+
filename="<Basilisp Namespace Executor>",
263+
opts=runtime.get_compiler_opts(),
264+
),
265+
ns,
266+
collect_bytecode=code.append,
267+
)
268+
except Exception as e:
269+
raise ImportError(f"Could not import module '{fullname}'") from e
270+
else:
271+
assert len(code) == 1
272+
return code[0]
220273

221274
def create_module(self, spec: ModuleSpec):
222275
logger.debug(f"Creating Basilisp module '{spec.name}'")
@@ -234,7 +287,7 @@ def _exec_cached_module(
234287
loader_state: Mapping[str, str],
235288
path_stats: Mapping[str, int],
236289
ns: runtime.Namespace,
237-
):
290+
) -> None:
238291
"""Load and execute a cached Basilisp module."""
239292
filename = loader_state["filename"]
240293
cache_filename = loader_state["cache_filename"]
@@ -264,7 +317,7 @@ def _exec_module(
264317
loader_state: Mapping[str, str],
265318
path_stats: Mapping[str, int],
266319
ns: runtime.Namespace,
267-
):
320+
) -> None:
268321
"""Load and execute a non-cached Basilisp module."""
269322
filename = loader_state["filename"]
270323
cache_filename = loader_state["cache_filename"]
@@ -302,7 +355,7 @@ def _exec_module(
302355
)
303356
self._cache_bytecode(filename, cache_filename, cache_file_bytes)
304357

305-
def exec_module(self, module):
358+
def exec_module(self, module: types.ModuleType) -> None:
306359
"""Compile the Basilisp module into Python code.
307360
308361
Basilisp is fundamentally a form-at-a-time compilation, meaning that

src/basilisp/lang/runtime.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
DEFAULT_READER_FEATURES_VAR_NAME = "*default-reader-features*"
8787
GENERATED_PYTHON_VAR_NAME = "*generated-python*"
8888
PRINT_GENERATED_PY_VAR_NAME = "*print-generated-python*"
89+
MAIN_NS_VAR_NAME = "*main-ns*"
8990
PRINT_DUP_VAR_NAME = "*print-dup*"
9091
PRINT_LENGTH_VAR_NAME = "*print-length*"
9192
PRINT_LEVEL_VAR_NAME = "*print-level*"
@@ -2106,6 +2107,25 @@ def in_ns(s: sym.Symbol):
21062107
),
21072108
)
21082109

2110+
# Dynamic Var containing command line arguments passed via `basilisp run`
2111+
Var.intern(
2112+
CORE_NS_SYM,
2113+
sym.symbol(MAIN_NS_VAR_NAME),
2114+
None,
2115+
dynamic=True,
2116+
meta=lmap.map(
2117+
{
2118+
_DOC_META_KEY: (
2119+
"The name of the main namespace as a symbol if this process was "
2120+
"executed as ``basilisp run {file}`` or ``python -m {namespace}`` "
2121+
"or ``nil`` otherwise.\n\n"
2122+
"This can be useful for detecting scripts similarly to how Python "
2123+
'scripts use ``if __name__ == "__main__":``.'
2124+
)
2125+
}
2126+
),
2127+
)
2128+
21092129
# Dynamic Var for introspecting the default reader featureset
21102130
Var.intern(
21112131
CORE_NS_SYM,

0 commit comments

Comments
 (0)