diff --git a/IPython/core/alias.py b/IPython/core/alias.py index 0905c19..0140b2e 100644 --- a/IPython/core/alias.py +++ b/IPython/core/alias.py @@ -20,17 +20,15 @@ Authors: # Imports #----------------------------------------------------------------------------- -import __builtin__ -import keyword import os import re import sys from IPython.config.configurable import Configurable -from IPython.core.splitinput import split_user_input +from IPython.core.error import UsageError from IPython.utils.traitlets import List, Instance -from IPython.utils.warn import warn, error +from IPython.utils.warn import error #----------------------------------------------------------------------------- # Utilities @@ -104,6 +102,70 @@ class AliasError(Exception): class InvalidAliasError(AliasError): pass +class Alias(object): + """Callable object storing the details of one alias. + + Instances are registered as magic functions to allow use of aliases. + """ + + # Prepare blacklist + blacklist = {'cd','popd','pushd','dhist','alias','unalias'} + + def __init__(self, shell, name, cmd): + self.shell = shell + self.name = name + self.cmd = cmd + self.nargs = self.validate() + + def validate(self): + """Validate the alias, and return the number of arguments.""" + if self.name in self.blacklist: + raise InvalidAliasError("The name %s can't be aliased " + "because it is a keyword or builtin." % self.name) + try: + caller = self.shell.magics_manager.magics['line'][self.name] + except KeyError: + pass + else: + if not isinstance(caller, Alias): + raise InvalidAliasError("The name %s can't be aliased " + "because it is another magic command." % self.name) + + if not (isinstance(self.cmd, basestring)): + raise InvalidAliasError("An alias command must be a string, " + "got: %r" % self.cmd) + + nargs = self.cmd.count('%s') + + if (nargs > 0) and (self.cmd.find('%l') >= 0): + raise InvalidAliasError('The %s and %l specifiers are mutually ' + 'exclusive in alias definitions.') + + return nargs + + def __repr__(self): + return "".format(self.name, self.cmd) + + def __call__(self, rest=''): + cmd = self.cmd + nargs = self.nargs + # Expand the %l special to be the user's input line + if cmd.find('%l') >= 0: + cmd = cmd.replace('%l', rest) + rest = '' + if nargs==0: + # Simple, argument-less aliases + cmd = '%s %s' % (cmd, rest) + else: + # Handle aliases with positional arguments + args = rest.split(None, nargs) + if len(args) < nargs: + raise UsageError('Alias <%s> requires %s arguments, %s given.' % + (self.name, nargs, len(args))) + cmd = '%s %s' % (cmd % tuple(args[:nargs]),' '.join(args[nargs:])) + + self.shell.system(cmd) + #----------------------------------------------------------------------------- # Main AliasManager class #----------------------------------------------------------------------------- @@ -116,35 +178,19 @@ class AliasManager(Configurable): def __init__(self, shell=None, **kwargs): super(AliasManager, self).__init__(shell=shell, **kwargs) - self.alias_table = {} - self.exclude_aliases() + # For convenient access + self.linemagics = self.shell.magics_manager.magics['line'] self.init_aliases() - def __contains__(self, name): - return name in self.alias_table - - @property - def aliases(self): - return [(item[0], item[1][1]) for item in self.alias_table.iteritems()] - - def exclude_aliases(self): - # set of things NOT to alias (keywords, builtins and some magics) - no_alias = set(['cd','popd','pushd','dhist','alias','unalias']) - no_alias.update(set(keyword.kwlist)) - no_alias.update(set(__builtin__.__dict__.keys())) - self.no_alias = no_alias - def init_aliases(self): - # Load default aliases - for name, cmd in self.default_aliases: - self.soft_define_alias(name, cmd) - - # Load user aliases - for name, cmd in self.user_aliases: + # Load default & user aliases + for name, cmd in self.default_aliases + self.user_aliases: self.soft_define_alias(name, cmd) - def clear_aliases(self): - self.alias_table.clear() + @property + def aliases(self): + return [(n, func.cmd) for (n, func) in self.linemagics.items() + if isinstance(func, Alias)] def soft_define_alias(self, name, cmd): """Define an alias, but don't raise on an AliasError.""" @@ -159,104 +205,33 @@ class AliasManager(Configurable): This will raise an :exc:`AliasError` if there are validation problems. """ - nargs = self.validate_alias(name, cmd) - self.alias_table[name] = (nargs, cmd) + caller = Alias(shell=self.shell, name=name, cmd=cmd) + self.shell.magics_manager.register_function(caller, magic_kind='line', + magic_name=name) - def undefine_alias(self, name): - if name in self.alias_table: - del self.alias_table[name] + def get_alias(self, name): + """Return an alias, or None if no alias by that name exists.""" + aname = self.linemagics.get(name, None) + return aname if isinstance(aname, Alias) else None - def validate_alias(self, name, cmd): - """Validate an alias and return the its number of arguments.""" - if name in self.no_alias: - raise InvalidAliasError("The name %s can't be aliased " - "because it is a keyword or builtin." % name) - if not (isinstance(cmd, basestring)): - raise InvalidAliasError("An alias command must be a string, " - "got: %r" % cmd) - nargs = cmd.count('%s') - if nargs>0 and cmd.find('%l')>=0: - raise InvalidAliasError('The %s and %l specifiers are mutually ' - 'exclusive in alias definitions.') - return nargs + def is_alias(self, name): + """Return whether or not a given name has been defined as an alias""" + return self.get_alias(name) is not None - def call_alias(self, alias, rest=''): - """Call an alias given its name and the rest of the line.""" - cmd = self.transform_alias(alias, rest) - try: - self.shell.system(cmd) - except: - self.shell.showtraceback() - - def transform_alias(self, alias,rest=''): - """Transform alias to system command string.""" - nargs, cmd = self.alias_table[alias] - - if ' ' in cmd and os.path.isfile(cmd): - cmd = '"%s"' % cmd - - # Expand the %l special to be the user's input line - if cmd.find('%l') >= 0: - cmd = cmd.replace('%l', rest) - rest = '' - if nargs==0: - # Simple, argument-less aliases - cmd = '%s %s' % (cmd, rest) + def undefine_alias(self, name): + if self.is_alias(name): + del self.linemagics[name] else: - # Handle aliases with positional arguments - args = rest.split(None, nargs) - if len(args) < nargs: - raise AliasError('Alias <%s> requires %s arguments, %s given.' % - (alias, nargs, len(args))) - cmd = '%s %s' % (cmd % tuple(args[:nargs]),' '.join(args[nargs:])) - return cmd - - def expand_alias(self, line): - """ Expand an alias in the command line + raise ValueError('%s is not an alias' % name) - Returns the provided command line, possibly with the first word - (command) translated according to alias expansion rules. - - [ipython]|16> _ip.expand_aliases("np myfile.txt") - <16> 'q:/opt/np/notepad++.exe myfile.txt' - """ - - pre,_,fn,rest = split_user_input(line) - res = pre + self.expand_aliases(fn, rest) - return res - - def expand_aliases(self, fn, rest): - """Expand multiple levels of aliases: - - if: - - alias foo bar /tmp - alias baz foo - - then: - - baz huhhahhei -> bar /tmp huhhahhei - """ - line = fn + " " + rest - - done = set() - while 1: - pre,_,fn,rest = split_user_input(line, shell_line_split) - if fn in self.alias_table: - if fn in done: - warn("Cyclic alias definition, repeated '%s'" % fn) - return "" - done.add(fn) - - l2 = self.transform_alias(fn, rest) - if l2 == line: - break - # ls -> ls -F should not recurse forever - if l2.split(None,1)[0] == line.split(None,1)[0]: - line = l2 - break - line = l2 - else: - break - - return line + def clear_aliases(self): + for name, cmd in self.aliases: + self.undefine_alias(name) + + def retrieve_alias(self, name): + """Retrieve the command to which an alias expands.""" + caller = self.get_alias(name) + if caller: + return caller.cmd + else: + raise ValueError('%s is not an alias' % name) diff --git a/IPython/core/completer.py b/IPython/core/completer.py index 642bdae..ee07b26 100644 --- a/IPython/core/completer.py +++ b/IPython/core/completer.py @@ -431,8 +431,7 @@ class IPCompleter(Completer): ) def __init__(self, shell=None, namespace=None, global_namespace=None, - alias_table=None, use_readline=True, - config=None, **kwargs): + use_readline=True, config=None, **kwargs): """IPCompleter() -> completer Return a completer object suitable for use by the readline library @@ -450,9 +449,6 @@ class IPCompleter(Completer): handle cases (such as IPython embedded inside functions) where both Python scopes are visible. - - If alias_table is supplied, it should be a dictionary of aliases - to complete. - use_readline : bool, optional If true, use the readline library. This completer can still function without readline, though in that case callers must provide some extra @@ -476,9 +472,6 @@ class IPCompleter(Completer): # List where completion matches will be stored self.matches = [] self.shell = shell - if alias_table is None: - alias_table = {} - self.alias_table = alias_table # Regexp to split filenames with spaces in them self.space_name_re = re.compile(r'([^\\] )') # Hold a local ref. to glob.glob for speed @@ -505,7 +498,6 @@ class IPCompleter(Completer): self.matchers = [self.python_matches, self.file_matches, self.magic_matches, - self.alias_matches, self.python_func_kw_matches, ] @@ -628,22 +620,6 @@ class IPCompleter(Completer): comp += [ pre+m for m in line_magics if m.startswith(bare_text)] return comp - def alias_matches(self, text): - """Match internal system aliases""" - #print 'Completer->alias_matches:',text,'lb',self.text_until_cursor # dbg - - # if we are not in the first 'item', alias matching - # doesn't make sense - unless we are starting with 'sudo' command. - main_text = self.text_until_cursor.lstrip() - if ' ' in main_text and not main_text.startswith('sudo'): - return [] - text = os.path.expanduser(text) - aliases = self.alias_table.keys() - if text == '': - return aliases - else: - return [a for a in aliases if a.startswith(text)] - def python_matches(self,text): """Match attributes or global python names""" diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py index 5c9a7a4..75f064d 100644 --- a/IPython/core/interactiveshell.py +++ b/IPython/core/interactiveshell.py @@ -470,7 +470,6 @@ class InteractiveShell(SingletonConfigurable): # because it and init_io have to come after init_readline. self.init_user_ns() self.init_logger() - self.init_alias() self.init_builtins() # The following was in post_config_initialization @@ -502,6 +501,7 @@ class InteractiveShell(SingletonConfigurable): self.init_displayhook() self.init_latextool() self.init_magics() + self.init_alias() self.init_logstart() self.init_pdb() self.init_extension_manager() @@ -1363,9 +1363,7 @@ class InteractiveShell(SingletonConfigurable): namespaces = [ ('Interactive', self.user_ns), ('Interactive (global)', self.user_global_ns), ('Python builtin', builtin_mod.__dict__), - ('Alias', self.alias_manager.alias_table), ] - alias_ns = self.alias_manager.alias_table # initialize results to 'null' found = False; obj = None; ospace = None; ds = None; @@ -1404,8 +1402,6 @@ class InteractiveShell(SingletonConfigurable): # If we finish the for loop (no break), we got all members found = True ospace = nsname - if ns == alias_ns: - isalias = True break # namespace loop # Try to see if it's magic @@ -1940,7 +1936,6 @@ class InteractiveShell(SingletonConfigurable): self.Completer = IPCompleter(shell=self, namespace=self.user_ns, global_namespace=self.user_global_ns, - alias_table=self.alias_manager.alias_table, use_readline=self.has_readline, parent=self, ) @@ -2305,7 +2300,6 @@ class InteractiveShell(SingletonConfigurable): def init_alias(self): self.alias_manager = AliasManager(shell=self, parent=self) self.configurables.append(self.alias_manager) - self.ns_table['alias'] = self.alias_manager.alias_table, #------------------------------------------------------------------------- # Things related to extensions diff --git a/IPython/core/magics/osm.py b/IPython/core/magics/osm.py index 71f0035..3cfc0f0 100644 --- a/IPython/core/magics/osm.py +++ b/IPython/core/magics/osm.py @@ -26,6 +26,7 @@ from pprint import pformat from IPython.core import magic_arguments from IPython.core import oinspect from IPython.core import page +from IPython.core.alias import AliasError, Alias from IPython.core.error import UsageError from IPython.core.magic import ( Magics, compress_dhist, magics_class, line_magic, cell_magic, line_cell_magic @@ -113,10 +114,14 @@ class OSMagics(Magics): # Now try to define a new one try: alias,cmd = par.split(None, 1) - except: - print oinspect.getdoc(self.alias) - else: - self.shell.alias_manager.soft_define_alias(alias, cmd) + except TypeError: + print(oinspect.getdoc(self.alias)) + return + + try: + self.shell.alias_manager.define_alias(alias, cmd) + except AliasError as e: + print(e) # end magic_alias @line_magic @@ -124,7 +129,12 @@ class OSMagics(Magics): """Remove an alias""" aname = parameter_s.strip() - self.shell.alias_manager.undefine_alias(aname) + try: + self.shell.alias_manager.undefine_alias(aname) + except ValueError as e: + print(e) + return + stored = self.shell.db.get('stored_aliases', {} ) if aname in stored: print "Removing %stored alias",aname @@ -182,7 +192,7 @@ class OSMagics(Magics): try: # Removes dots from the name since ipython # will assume names with dots to be python. - if ff not in self.shell.alias_manager: + if not self.shell.alias_manager.is_alias(ff): self.shell.alias_manager.define_alias( ff.replace('.',''), ff) except InvalidAliasError: @@ -190,7 +200,7 @@ class OSMagics(Magics): else: syscmdlist.append(ff) else: - no_alias = self.shell.alias_manager.no_alias + no_alias = Alias.blacklist for pdir in path: os.chdir(pdir) for ff in os.listdir(pdir): diff --git a/IPython/core/prefilter.py b/IPython/core/prefilter.py index 49204bf..15aba20 100644 --- a/IPython/core/prefilter.py +++ b/IPython/core/prefilter.py @@ -488,22 +488,6 @@ class AutoMagicChecker(PrefilterChecker): return self.prefilter_manager.get_handler_by_name('magic') -class AliasChecker(PrefilterChecker): - - priority = Integer(800, config=True) - - def check(self, line_info): - "Check if the initital identifier on the line is an alias." - # Note: aliases can not contain '.' - head = line_info.ifun.split('.',1)[0] - if line_info.ifun not in self.shell.alias_manager \ - or head not in self.shell.alias_manager \ - or is_shadowed(head, self.shell): - return None - - return self.prefilter_manager.get_handler_by_name('alias') - - class PythonOpsChecker(PrefilterChecker): priority = Integer(900, config=True) @@ -591,20 +575,6 @@ class PrefilterHandler(Configurable): return "<%s(name=%s)>" % (self.__class__.__name__, self.handler_name) -class AliasHandler(PrefilterHandler): - - handler_name = Unicode('alias') - - def handle(self, line_info): - """Handle alias input lines. """ - transformed = self.shell.alias_manager.expand_aliases(line_info.ifun,line_info.the_rest) - # pre is needed, because it carries the leading whitespace. Otherwise - # aliases won't work in indented sections. - line_out = '%sget_ipython().system(%r)' % (line_info.pre_whitespace, transformed) - - return line_out - - class MacroHandler(PrefilterHandler): handler_name = Unicode("macro") @@ -730,14 +700,12 @@ _default_checkers = [ IPyAutocallChecker, AssignmentChecker, AutoMagicChecker, - AliasChecker, PythonOpsChecker, AutocallChecker ] _default_handlers = [ PrefilterHandler, - AliasHandler, MacroHandler, MagicHandler, AutoHandler, diff --git a/IPython/core/tests/test_alias.py b/IPython/core/tests/test_alias.py new file mode 100644 index 0000000..8ae57f6 --- /dev/null +++ b/IPython/core/tests/test_alias.py @@ -0,0 +1,41 @@ +from IPython.utils.capture import capture_output + +import nose.tools as nt + +def test_alias_lifecycle(): + name = 'test_alias1' + cmd = 'echo "Hello"' + am = _ip.alias_manager + am.clear_aliases() + am.define_alias(name, cmd) + assert am.is_alias(name) + nt.assert_equal(am.retrieve_alias(name), cmd) + nt.assert_in((name, cmd), am.aliases) + + # Test running the alias + orig_system = _ip.system + result = [] + _ip.system = result.append + try: + _ip.run_cell('%{}'.format(name)) + result = [c.strip() for c in result] + nt.assert_equal(result, [cmd]) + finally: + _ip.system = orig_system + + # Test removing the alias + am.undefine_alias(name) + assert not am.is_alias(name) + with nt.assert_raises(ValueError): + am.retrieve_alias(name) + nt.assert_not_in((name, cmd), am.aliases) + + +def test_alias_args_error(): + """Error expanding with wrong number of arguments""" + _ip.alias_manager.define_alias('parts', 'echo first %s second %s') + # capture stderr: + with capture_output() as cap: + _ip.run_cell('parts 1') + + nt.assert_equal(cap.stderr.split(':')[0], 'UsageError') \ No newline at end of file diff --git a/IPython/core/tests/test_handlers.py b/IPython/core/tests/test_handlers.py index 5601717..20f956a 100644 --- a/IPython/core/tests/test_handlers.py +++ b/IPython/core/tests/test_handlers.py @@ -45,28 +45,6 @@ def run(tests): def test_handlers(): - # alias expansion - - # We're using 'true' as our syscall of choice because it doesn't - # write anything to stdout. - - # Turn off actual execution of aliases, because it's noisy - old_system_cmd = ip.system - ip.system = lambda cmd: None - - - ip.alias_manager.alias_table['an_alias'] = (0, 'true') - # These are useful for checking a particular recursive alias issue - ip.alias_manager.alias_table['top'] = (0, 'd:/cygwin/top') - ip.alias_manager.alias_table['d'] = (0, 'true') - run([(i,py3compat.u_format(o)) for i,o in \ - [("an_alias", "get_ipython().system({u}'true ')"), # alias - # Below: recursive aliases should expand whitespace-surrounded - # chars, *not* initial chars which happen to be aliases: - ("top", "get_ipython().system({u}'d:/cygwin/top ')"), - ]]) - ip.system = old_system_cmd - call_idx = CallableIndexable() ip.user_ns['call_idx'] = call_idx diff --git a/IPython/core/tests/test_interactiveshell.py b/IPython/core/tests/test_interactiveshell.py index 954f60c..ee6569c 100644 --- a/IPython/core/tests/test_interactiveshell.py +++ b/IPython/core/tests/test_interactiveshell.py @@ -106,17 +106,6 @@ class InteractiveShellTestCase(unittest.TestCase): ip.run_cell('a = """\n%exit\n"""') self.assertEqual(ip.user_ns['a'], '\n%exit\n') - def test_alias_crash(self): - """Errors in prefilter can't crash IPython""" - ip.run_cell('%alias parts echo first %s second %s') - # capture stderr: - save_err = io.stderr - io.stderr = StringIO() - ip.run_cell('parts 1') - err = io.stderr.getvalue() - io.stderr = save_err - self.assertEqual(err.split(':')[0], 'ERROR') - def test_trailing_newline(self): """test that running !(command) does not raise a SyntaxError""" ip.run_cell('!(true)\n', False) diff --git a/IPython/core/tests/test_magic.py b/IPython/core/tests/test_magic.py index fcf6c8c..d0953dd 100644 --- a/IPython/core/tests/test_magic.py +++ b/IPython/core/tests/test_magic.py @@ -48,16 +48,16 @@ class DummyMagics(magic.Magics): pass def test_rehashx(): # clear up everything _ip = get_ipython() - _ip.alias_manager.alias_table.clear() + _ip.alias_manager.clear_aliases() del _ip.db['syscmdlist'] _ip.magic('rehashx') # Practically ALL ipython development systems will have more than 10 aliases - nt.assert_true(len(_ip.alias_manager.alias_table) > 10) - for key, val in _ip.alias_manager.alias_table.iteritems(): + nt.assert_true(len(_ip.alias_manager.aliases) > 10) + for name, cmd in _ip.alias_manager.aliases: # we must strip dots from alias names - nt.assert_not_in('.', key) + nt.assert_not_in('.', name) # rehashx must fill up syscmdlist scoms = _ip.db['syscmdlist'] diff --git a/IPython/extensions/storemagic.py b/IPython/extensions/storemagic.py index b4c698f..aa7a7c1 100644 --- a/IPython/extensions/storemagic.py +++ b/IPython/extensions/storemagic.py @@ -210,17 +210,17 @@ class StoreMagics(Magics, Configurable): obj = ip.user_ns[args[0]] except KeyError: # it might be an alias - # This needs to be refactored to use the new AliasManager stuff. - if args[0] in ip.alias_manager: - name = args[0] - nargs, cmd = ip.alias_manager.alias_table[ name ] - staliases = db.get('stored_aliases',{}) - staliases[ name ] = cmd - db['stored_aliases'] = staliases - print "Alias stored: %s (%s)" % (name, cmd) - return - else: - raise UsageError("Unknown variable '%s'" % args[0]) + name = args[0] + try: + cmd = ip.alias_manager.retrieve_alias(name) + except ValueError: + raise UsageError("Unknown variable '%s'" % name) + + staliases = db.get('stored_aliases',{}) + staliases[name] = cmd + db['stored_aliases'] = staliases + print "Alias stored: %s (%s)" % (name, cmd) + return else: modname = getattr(inspect.getmodule(obj), '__name__', '') diff --git a/IPython/extensions/tests/test_storemagic.py b/IPython/extensions/tests/test_storemagic.py index 203ffd4..a5a15ba 100644 --- a/IPython/extensions/tests/test_storemagic.py +++ b/IPython/extensions/tests/test_storemagic.py @@ -27,7 +27,7 @@ def test_store_restore(): # Check restoring ip.magic('store -r') nt.assert_equal(ip.user_ns['foo'], 78) - nt.assert_in('bar', ip.alias_manager.alias_table) + assert ip.alias_manager.is_alias('bar') nt.assert_in(os.path.realpath(tmpd), ip.user_ns['_dh']) os.rmdir(tmpd) diff --git a/IPython/terminal/interactiveshell.py b/IPython/terminal/interactiveshell.py index 3a7c93a..5b034d0 100644 --- a/IPython/terminal/interactiveshell.py +++ b/IPython/terminal/interactiveshell.py @@ -369,7 +369,7 @@ class TerminalInteractiveShell(InteractiveShell): for name, cmd in aliases: - self.alias_manager.define_alias(name, cmd) + self.alias_manager.soft_define_alias(name, cmd) #------------------------------------------------------------------------- # Things related to the banner and usage diff --git a/docs/source/whatsnew/pr/incompat-drop-alias.rst b/docs/source/whatsnew/pr/incompat-drop-alias.rst new file mode 100644 index 0000000..570a7cf --- /dev/null +++ b/docs/source/whatsnew/pr/incompat-drop-alias.rst @@ -0,0 +1,3 @@ +- The alias system has been reimplemented to use magic functions. There should be little + visible difference while automagics are enabled, as they are by default, but parts of the + :class:`~IPython.core.alias.AliasManager` API have been removed.