Skip to content

Commit cc01604

Browse files
author
Release Manager
committed
sagemathgh-39015: Meson: add sage cli <!-- ^ Please provide a concise and informative title. --> <!-- ^ Don't put issue numbers in the title, do this in the PR description below. --> <!-- ^ For example, instead of "Fixes sagemath#12345" use "Introduce new method to calculate 1 + 2". --> <!-- v Describe your changes below in detail. --> <!-- v Why is this change required? What problem does it solve? --> <!-- v If this PR resolves an open issue, please link to it here. For example, "Fixes sagemath#12345". --> Meson currently doesn't install any of the scripts in `src/bin`. This is because - they mostly don't make sense for meson - especially doctests, sage packages interactions and various other developer tools shouldn't be installed for normal users - they rely on tricky environment variable manipulation - they use bash and thus do not work on Windows Here, we reimplement a very small subset of the sage cli functionality in Python without any env hacks. At the moment only `--version`, the interactive sage shell, `--notebook` and `-c` are implemented ### 📝 Checklist <!-- Put an `x` in all the boxes that apply. --> - [ ] The title is concise and informative. - [ ] The description explains in detail what this PR is about. - [ ] I have linked a relevant issue or discussion. - [ ] I have created tests covering the changes. - [ ] I have updated the documentation and checked the documentation preview. ### ⌛ Dependencies <!-- List all open PRs that this PR logically depends on. For example, --> <!-- - sagemath#12345: short description why this is a dependency --> <!-- - sagemath#34567: ... --> URL: sagemath#39015 Reported by: Tobias Diez Reviewer(s): Dima Pasechnik, Gonzalo Tornaría
2 parents 2ff6eb6 + 1a872e3 commit cc01604

16 files changed

+302
-11
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ R = [
8080
file = "README.md"
8181
content-type = "text/markdown"
8282

83+
[project.scripts]
84+
sage = "sage.cli:main"
85+
8386
[tool.conda-lock]
8487
platforms = [
8588
'osx-64', 'linux-64', 'linux-aarch64', 'osx-arm64'

src/.relint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
Sage library code should not import from sage.PAC.KAGE.all.
4646
pattern: 'from\s+sage(|[.](arith|categories|combinat|crypto|databases|data_structures|dynamics|ext|game_theory|games|geometry|graphs|groups|interfaces|manifolds|matrix|matroids|misc|modules|monoids|numerical|probability|quadratic_forms|quivers|rings|sat|schemes|sets|stats|symbolic|tensor)[a-z0-9_.]*|[.]libs)[.]all\s+import'
4747
# imports from .all are allowed in all.py; also allow in some modules that need sage.all
48-
filePattern: '(.*/|)(?!(all|benchmark|dev_tools|parsing|sage_eval|explain_pickle|.*_test))[^/.]*[.](py|pyx|pxi)$'
48+
filePattern: '(.*/|)(?!(all|benchmark|dev_tools|parsing|sage_eval|explain_pickle|.*_test|eval_cmd))[^/.]*[.](py|pyx|pxi)$'
4949

5050
- name: 'namespace_pkg_all_import_2: Module-level import of .all of a namespace package'
5151
hint: |

src/sage/cli/__init__.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import logging
5+
import sys
6+
7+
from sage.cli.eval_cmd import EvalCmd
8+
from sage.cli.interactive_shell_cmd import InteractiveShellCmd
9+
from sage.cli.notebook_cmd import JupyterNotebookCmd
10+
from sage.cli.options import CliOptions
11+
from sage.cli.version_cmd import VersionCmd
12+
13+
14+
def main() -> int:
15+
input_args = sys.argv[1:]
16+
parser = argparse.ArgumentParser(
17+
prog="sage",
18+
description="If no command is given, starts the interactive interpreter where you can enter statements and expressions, immediately execute them and see their results.",
19+
)
20+
parser.add_argument(
21+
"-v",
22+
"--verbose",
23+
action="store_true",
24+
default=False,
25+
help="print additional information",
26+
)
27+
28+
VersionCmd.extend_parser(parser)
29+
JupyterNotebookCmd.extend_parser(parser)
30+
EvalCmd.extend_parser(parser)
31+
32+
if not input_args:
33+
return InteractiveShellCmd(CliOptions()).run()
34+
35+
args = parser.parse_args(input_args)
36+
options = CliOptions(**vars(args))
37+
38+
logging.basicConfig(level=logging.DEBUG if options.verbose else logging.INFO)
39+
40+
if args.command:
41+
return EvalCmd(options).run()
42+
elif args.notebook:
43+
return JupyterNotebookCmd(options).run()
44+
return InteractiveShellCmd(options).run()

src/sage/cli/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
3+
from sage.cli import main
4+
5+
sys.exit(main())

src/sage/cli/eval_cmd.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import argparse
2+
3+
from sage.cli.options import CliOptions
4+
from sage.repl.preparse import preparse
5+
from sage.all import sage_globals
6+
7+
8+
class EvalCmd:
9+
@staticmethod
10+
def extend_parser(parser: argparse.ArgumentParser):
11+
r"""
12+
Extend the parser with the "run" command.
13+
14+
INPUT:
15+
16+
- ``parsers`` -- the parsers to extend.
17+
18+
OUTPUT:
19+
20+
- the extended parser.
21+
"""
22+
parser.add_argument(
23+
"-c",
24+
"--command",
25+
nargs="?",
26+
help="execute the given command as sage code",
27+
)
28+
29+
def __init__(self, options: CliOptions):
30+
r"""
31+
Initialize the command.
32+
"""
33+
self.options = options
34+
35+
def run(self) -> int:
36+
r"""
37+
Execute the given command.
38+
"""
39+
code = preparse(self.options.command)
40+
try:
41+
eval(compile(code, "<cmdline>", "exec"), sage_globals())
42+
except Exception as e:
43+
print(f"An error occurred while executing the command: {e}")
44+
return 1
45+
return 0

src/sage/cli/eval_cmd_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from sage.cli.eval_cmd import EvalCmd
2+
from sage.cli.options import CliOptions
3+
4+
5+
def test_eval_cmd_print(capsys):
6+
options = CliOptions(command="print(3^33)")
7+
eval_cmd = EvalCmd(options)
8+
9+
result = eval_cmd.run()
10+
captured = capsys.readouterr()
11+
assert captured.out == "5559060566555523\n"
12+
assert result == 0
13+
14+
15+
def test_eval_cmd_invalid_command(capsys):
16+
options = CliOptions(command="invalid_command")
17+
eval_cmd = EvalCmd(options)
18+
19+
result = eval_cmd.run()
20+
captured = capsys.readouterr()
21+
assert (
22+
"An error occurred while executing the command: name 'invalid_command' is not defined"
23+
in captured.out
24+
)
25+
assert result == 1
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from sage.cli.options import CliOptions
2+
3+
4+
class InteractiveShellCmd:
5+
def __init__(self, options: CliOptions):
6+
r"""
7+
Initialize the command.
8+
"""
9+
self.options = options
10+
11+
def run(self) -> int:
12+
r"""
13+
Start the interactive shell.
14+
"""
15+
# Display startup banner. Do this before anything else to give the user
16+
# early feedback that Sage is starting.
17+
from sage.misc.banner import banner
18+
19+
banner()
20+
21+
from sage.repl.interpreter import SageTerminalApp
22+
23+
app = SageTerminalApp.instance()
24+
app.initialize([])
25+
return app.start() # type: ignore

src/sage/cli/notebook_cmd.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import argparse
2+
3+
from sage.cli.options import CliOptions
4+
5+
6+
class JupyterNotebookCmd:
7+
@staticmethod
8+
def extend_parser(parser: argparse.ArgumentParser):
9+
r"""
10+
Extend the parser with the Jupyter notebook command.
11+
12+
INPUT:
13+
14+
- ``parsers`` -- the parsers to extend.
15+
16+
OUTPUT:
17+
18+
- the extended parser.
19+
"""
20+
parser.add_argument(
21+
"-n",
22+
"--notebook",
23+
nargs="?",
24+
const="jupyter",
25+
choices=["jupyter", "jupyterlab"],
26+
default="jupyter",
27+
help="start the Jupyter notebook server (default: jupyter)",
28+
)
29+
30+
def __init__(self, options: CliOptions):
31+
r"""
32+
Initialize the command.
33+
"""
34+
self.options = options
35+
36+
def run(self) -> int:
37+
r"""
38+
Start the Jupyter notebook server.
39+
"""
40+
if self.options.notebook == "jupyter":
41+
try:
42+
# notebook 6
43+
from notebook.notebookapp import main
44+
except ImportError:
45+
# notebook 7
46+
from notebook.app import main
47+
elif self.options.notebook == "jupyterlab":
48+
from jupyterlab.labapp import main
49+
else:
50+
raise ValueError(f"Unknown notebook type: {self.options.notebook}")
51+
52+
return main([])

src/sage/cli/notebook_cmd_test.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import argparse
2+
3+
import pytest
4+
5+
from sage.cli.notebook_cmd import JupyterNotebookCmd
6+
7+
8+
def test_jupyter_as_default():
9+
parser = argparse.ArgumentParser()
10+
JupyterNotebookCmd.extend_parser(parser)
11+
args = parser.parse_args(["--notebook"])
12+
assert args.notebook == "jupyter"
13+
14+
15+
def test_jupyter_explicitly():
16+
parser = argparse.ArgumentParser()
17+
JupyterNotebookCmd.extend_parser(parser)
18+
args = parser.parse_args(["--notebook", "jupyter"])
19+
assert args.notebook == "jupyter"
20+
21+
22+
def test_jupyterlab_explicitly():
23+
parser = argparse.ArgumentParser()
24+
JupyterNotebookCmd.extend_parser(parser)
25+
args = parser.parse_args(["--notebook", "jupyterlab"])
26+
assert args.notebook == "jupyterlab"
27+
28+
29+
def test_invalid_notebook_choice():
30+
parser = argparse.ArgumentParser()
31+
JupyterNotebookCmd.extend_parser(parser)
32+
with pytest.raises(SystemExit):
33+
parser.parse_args(["--notebook", "invalid"])
34+
35+
36+
def test_help():
37+
parser = argparse.ArgumentParser()
38+
JupyterNotebookCmd.extend_parser(parser)
39+
assert parser.format_usage() == "usage: pytest [-h] [-n [{jupyter,jupyterlab}]]\n"

src/sage/cli/options.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass
5+
class CliOptions:
6+
"""
7+
A TypedDict for command-line interface options.
8+
"""
9+
10+
"""Indicates whether verbose output is enabled."""
11+
verbose: bool = False
12+
13+
"""The notebook type to start."""
14+
notebook: str = "jupyter"
15+
16+
"""The command to execute."""
17+
command: str | None = None

0 commit comments

Comments
 (0)