from __future__ import annotations
import inspect
import optparse
import os
import shlex
import sys
import traceback
from ast import literal_eval as safe_eval
from typing import TYPE_CHECKING, Any, Callable, NoReturn, TypeVar, cast
if TYPE_CHECKING:
from IPython.core.interactiveshell import InteractiveShell
from . import MetaKernel
_F = TypeVar("_F", bound=Callable[..., Any])
_maxsize = sys.maxsize
class MagicOptionParser(optparse.OptionParser):
def error(self, msg: str) -> NoReturn:
raise Exception(f'Magic Parse error: "{msg}"')
def exit(self, status: int = 0, msg: Any = None) -> NoReturn:
if msg:
sys.stderr.write(msg)
raise Exception(msg)
## FIXME: override help to also stop processing
## currently --help gives syntax error
[docs]
class Magic:
"""
Base class to define magics for MetaKernel based kernels.
Users can redefine the default magics provided by Metakernel
by creating a module with the exact same name as the
Metakernel magic.
For example, you can override %matplotlib in your kernel by
writing a new magic inside magics/matplotlib_magic.py
"""
def __init__(self, kernel: MetaKernel) -> None:
self.kernel = kernel
self.evaluate = True
self.code = ""
def get_args(self, mtype: str, name: str, code: str, args: Any) -> Any:
self.code = code
old_args = args
mtype = mtype.replace("sticky", "cell")
func = getattr(self, mtype + "_" + name)
try:
args, kwargs = _parse_args(func, args, usage=self.get_help(mtype, name))
except Exception as e:
self.kernel.Error(str(e))
return self
arg_spec = inspect.getfullargspec(func)
fargs = arg_spec.args
if fargs[0] == "self":
fargs = fargs[1:]
fargs = [f for f in fargs if f not in kwargs.keys()]
if len(args) > len(fargs) and not arg_spec.varargs:
extra = " ".join(str(s) for s in (args[len(fargs) - 1 :]))
args = args[: len(fargs) - 1] + [extra]
return (args, kwargs, old_args)
def call_magic(self, mtype: str, name: str, code: str, args: Any) -> Magic:
self.code = code
old_args = args
mtype = mtype.replace("sticky", "cell")
func = getattr(self, mtype + "_" + name)
try:
args, kwargs = _parse_args(func, args, usage=self.get_help(mtype, name))
except Exception as e:
self.kernel.Error(str(e))
return self
arg_spec = inspect.getfullargspec(func)
fargs = arg_spec.args
if fargs[0] == "self":
fargs = fargs[1:]
fargs = [f for f in fargs if f not in kwargs.keys()]
if len(args) > len(fargs) and not arg_spec.varargs:
extra = " ".join(str(s) for s in (args[len(fargs) - 1 :]))
args = args[: len(fargs) - 1] + [extra]
try:
try:
func(*args, **kwargs)
except TypeError:
func(old_args)
except Exception as exc:
msg = f"Error in calling magic '{name}' on {mtype}:\n {exc!s}\n args: {args}\n kwargs: {kwargs}"
self.kernel.Error(msg)
self.kernel.Error(traceback.format_exc())
self.kernel.Error(self.get_help(mtype, name))
# return dummy magic to end processing:
return Magic(self.kernel)
return self
def get_help(self, mtype: str, name: str, level: int = 0) -> str:
if hasattr(self, mtype + "_" + name):
func = getattr(self, mtype + "_" + name)
if level == 0:
if func.__doc__:
return _trim(func.__doc__) # type: ignore[return-value]
else:
return f"No help available for magic '{name}' for {mtype}s."
else:
filename = inspect.getfile(func)
if filename and os.path.exists(filename):
with open(filename) as f:
return f.read()
else:
return f"No help available for magic '{name}' for {mtype}s."
else:
return f"No such magic '{name}' for {mtype}s."
def get_help_on(self, info: dict[str, Any], level: int = 0) -> str | None:
return "Sorry, no help is available on '{}'.".format(info["code"])
[docs]
def get_completions(self, info: dict[str, Any]) -> list[str]:
"""
Get completions based on info dict from magic.
"""
return []
def get_magics(self, mtype: str) -> list[str]:
magics = []
for name in dir(self):
if name.startswith(mtype + "_"):
magics.append(name.replace(mtype + "_", ""))
return magics
def get_code(self) -> str:
return self.code
def post_process(self, retval: Any) -> Any:
return retval
def get_ipython() -> InteractiveShell | None:
"""Return the running IPython shell instance, or None if not in IPython."""
from IPython import get_ipython as _get_ipython # type: ignore[attr-defined]
return cast("InteractiveShell | None", _get_ipython()) # type: ignore[no-untyped-call]
[docs]
def option(*args: Any, **kwargs: Any) -> Callable[[_F], _F]:
"""Return decorator that adds a magic option to a function."""
def decorator(func: _F) -> _F:
help_text = ""
if not getattr(func, "has_options", False):
func.has_options = True # type:ignore[attr-defined]
func.options = [] # type:ignore[attr-defined]
help_text += "Options:\n-------\n"
try:
option = optparse.Option(*args, **kwargs)
except optparse.OptionError:
help_text += args[0] + "\n"
else:
help_text += _format_option(option) + "\n"
func.options.append(option) # type:ignore[attr-defined]
if func.__doc__:
func.__doc__ += _indent(func.__doc__, help_text)
else:
func.__doc__ = help_text
return func
return decorator
def register_line_magic(func: _F) -> _F:
"""Register a function as an IPython line magic, preserving its type."""
from IPython.core.magic import register_line_magic as _rlm
_rlm(func)
return func
def register_cell_magic(func: _F) -> _F:
"""Register a function as an IPython cell magic, preserving its type."""
from IPython.core.magic import register_cell_magic as _rcm
_rcm(func)
return func
def _parse_args(
func: Any, args: Any, usage: Any = None
) -> tuple[list[Any], dict[str, Any]]:
"""Parse the arguments given to a magic function"""
if isinstance(args, list):
args = " ".join(args)
args = _split_args(args)
kwargs = dict()
if getattr(func, "has_options", False):
parser = MagicOptionParser(usage=usage, conflict_handler="resolve")
parser.add_options(func.options)
left = []
value = None
if "--" in args:
left = args[: args.index("--")]
value, args = parser.parse_args(args[args.index("--") + 1 :])
else:
while args:
try:
value, args = parser.parse_args(args)
except Exception:
left.append(args.pop(0))
else:
break
args = left + args
if value:
kwargs = value.__dict__
new_args = []
for arg in args:
try:
new_args.append(safe_eval(arg))
except Exception:
new_args.append(arg)
for key, value in kwargs.items():
try:
kwargs[key] = safe_eval(value)
except Exception:
pass
return new_args, kwargs
def _split_args(args: Any) -> list[Any]:
try:
# do not use posix mode, to avoid eating quote characters
args = shlex.split(args, posix=False)
except Exception:
# parse error; let's pass args along rather than crashing
args = args.split()
new_args = []
temp = ""
for arg in args:
if arg.startswith("-"):
new_args.append(arg)
elif temp:
arg = temp + " " + arg
try:
safe_eval(arg)
except Exception:
temp = arg
else:
new_args.append(arg)
temp = ""
elif arg.startswith(("(", "[", "{")) or "(" in arg:
try:
safe_eval(arg)
except Exception:
temp = arg
else:
new_args.append(arg)
else:
new_args.append(arg)
if temp:
new_args.append(temp)
return new_args
def _format_option(option: Any) -> str:
output = ""
if option._short_opts:
output = option._short_opts[0] + " "
output += option.get_opt_string() + " "
output += " " * (15 - len(output))
output += option.help + " "
if not option.default == ("NO", "DEFAULT"):
output += f"[default: {option.default}]"
return str(output)
def _trim(docstring: str, return_lines: bool = False) -> str | list[str]:
"""
Trim of unnecessary leading indentations.
"""
# from: http://legacy.python.org/dev/peps/pep-0257/
if not docstring:
return ""
# Convert tabs to spaces (following the normal Python rules)
# and split into a list of lines:
lines = docstring.expandtabs().splitlines()
indent = _min_indent(lines)
# Remove indentation (first line is special):
trimmed = [lines[0].strip()]
if indent < _maxsize:
for line in lines[1:]:
trimmed.append(line[indent:].rstrip())
# Strip off trailing and leading blank lines:
while trimmed and not trimmed[-1]:
trimmed.pop()
while trimmed and not trimmed[0]:
trimmed.pop(0)
if return_lines:
return trimmed
else:
# Return a single string:
return "\n".join(trimmed)
def _min_indent(lines: list[str]) -> int:
"""
Determine minimum indentation (first line doesn't count):
"""
indent = _maxsize
for line in lines[1:]:
stripped = line.lstrip()
if stripped:
indent = min(indent, len(line) - len(stripped))
return indent
def _indent(docstring: str, text: str) -> str:
"""
Returns text indented at appropriate indententation level.
"""
if not docstring:
return text
lines = docstring.expandtabs().splitlines()
indent = _min_indent(lines)
if indent < _maxsize:
newlines = _trim(text, return_lines=True)
return "\n" + ("\n".join([(" " * indent) + line for line in newlines]))
else:
return "\n" + text