Repository: ipython/ipython

Issue:
Simpler system for running code after InteractiveShell is initialised
A very common need for config is to run code after the InteractiveShell is initialised - e.g. to define extra magics or aliases, or to import modules. Currently, the user has to track down `exec_lines` in the config file, and put their code inside a string (i.e. it doesn't get syntax highlighted etc.), or save a file in a suitable place, and add it to exec_files.

I propose either:
- A file in the profile directory which is automatically run in the shell if it is present, as an addition to the user-specified exec_files. Something like `ipython_startup.py`.
- A directory, similar to the `/etc/rcN.d/` folders on Linux, in which any files found are run in a predictable order (i.e. to allow prefixes 00-99 if desired). This would allow customisations to be easily distributed: download a file, drop it in your `IPYTHONDIR/profile_default/startup/` directory, and an extra magic function will be available.



Discussion:

Resolving patch:
diff --git a/IPython/core/profiledir.py b/IPython/core/profiledir.py
--- a/IPython/core/profiledir.py
+++ b/IPython/core/profiledir.py
@@ -59,9 +59,11 @@ class ProfileDir(LoggingConfigurable):
 
     security_dir_name = Unicode('security')
     log_dir_name = Unicode('log')
+    startup_dir_name = Unicode('startup')
     pid_dir_name = Unicode('pid')
     security_dir = Unicode(u'')
     log_dir = Unicode(u'')
+    startup_dir = Unicode(u'')
     pid_dir = Unicode(u'')
 
     location = Unicode(u'', config=True,
@@ -81,6 +83,7 @@ def _location_changed(self, name, old, new):
         # ensure config files exist:
         self.security_dir = os.path.join(new, self.security_dir_name)
         self.log_dir = os.path.join(new, self.log_dir_name)
+        self.startup_dir = os.path.join(new, self.startup_dir_name)
         self.pid_dir = os.path.join(new, self.pid_dir_name)
         self.check_dirs()
 
@@ -91,6 +94,17 @@ def check_log_dir(self):
         if not os.path.isdir(self.log_dir):
             os.mkdir(self.log_dir)
 
+    def _startup_dir_changed(self, name, old, new):
+        self.check_startup_dir()
+
+    def check_startup_dir(self):
+        if not os.path.isdir(self.startup_dir):
+            os.mkdir(self.startup_dir)
+        readme = os.path.join(self.startup_dir, 'README')
+        src = os.path.join(get_ipython_package_dir(), u'config', u'profile', u'README_STARTUP')
+        if not os.path.exists(readme):
+            shutil.copy(src, readme)
+
     def _security_dir_changed(self, name, old, new):
         self.check_security_dir()
 
@@ -119,6 +133,7 @@ def check_dirs(self):
         self.check_security_dir()
         self.check_log_dir()
         self.check_pid_dir()
+        self.check_startup_dir()
 
     def copy_config_file(self, config_file, path=None, overwrite=False):
         """Copy a default config file into the active profile directory.
diff --git a/IPython/core/shellapp.py b/IPython/core/shellapp.py
--- a/IPython/core/shellapp.py
+++ b/IPython/core/shellapp.py
@@ -22,6 +22,7 @@
 
 from __future__ import absolute_import
 
+import glob
 import os
 import sys
 
@@ -175,6 +176,7 @@ def init_extensions(self):
 
     def init_code(self):
         """run the pre-flight code, specified via exec_lines"""
+        self._run_startup_files()
         self._run_exec_lines()
         self._run_exec_files()
         self._run_cmd_line_code()
@@ -230,6 +232,22 @@ def _exec_file(self, fname):
         finally:
             sys.argv = save_argv
 
+    def _run_startup_files(self):
+        """Run files from profile startup directory"""
+        startup_dir = self.profile_dir.startup_dir
+        startup_files = glob.glob(os.path.join(startup_dir, '*.py'))
+        startup_files += glob.glob(os.path.join(startup_dir, '*.ipy'))
+        if not startup_files:
+            return
+        
+        self.log.debug("Running startup files from %s...", startup_dir)
+        try:
+            for fname in sorted(startup_files):
+                self._exec_file(fname)
+        except:
+            self.log.warn("Unknown error in handling startup files:")
+            self.shell.showtraceback()
+
     def _run_exec_files(self):
         """Run files from IPythonApp.exec_files"""
         if not self.exec_files:
diff --git a/setupbase.py b/setupbase.py
--- a/setupbase.py
+++ b/setupbase.py
@@ -132,7 +132,7 @@ def find_package_data():
             static_data.append(os.path.join(parent, f))
 
     package_data = {
-        'IPython.config.profile' : ['README', '*/*.py'],
+        'IPython.config.profile' : ['README*', '*/*.py'],
         'IPython.testing' : ['*.txt'],
         'IPython.frontend.html.notebook' : ['templates/*'] + static_data,
         'IPython.frontend.qt.console' : ['resources/icon/*.svg'],


Test patch:
diff --git a/IPython/core/tests/test_profile.py b/IPython/core/tests/test_profile.py
new file mode 100644
--- /dev/null
+++ b/IPython/core/tests/test_profile.py
@@ -0,0 +1,103 @@
+"""Tests for profile-related functions.
+
+Currently only the startup-dir functionality is tested, but more tests should
+be added for:
+
+    * ipython profile create
+    * ipython profile list
+    * ipython profile create --parallel
+    * security dir permissions
+
+Authors
+-------
+
+* MinRK
+
+"""
+from __future__ import absolute_import
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+import os
+import shutil
+import sys
+import tempfile
+
+import nose.tools as nt
+from nose import SkipTest
+
+from IPython.core.profiledir import ProfileDir
+
+from IPython.testing import decorators as dec
+from IPython.testing import tools as tt
+from IPython.utils import py3compat
+
+
+#-----------------------------------------------------------------------------
+# Globals
+#-----------------------------------------------------------------------------
+TMP_TEST_DIR = tempfile.mkdtemp()
+HOME_TEST_DIR = os.path.join(TMP_TEST_DIR, "home_test_dir")
+IP_TEST_DIR = os.path.join(HOME_TEST_DIR,'.ipython')
+
+#
+# Setup/teardown functions/decorators
+#
+
+def setup():
+    """Setup test environment for the module:
+
+            - Adds dummy home dir tree
+    """
+    # Do not mask exceptions here.  In particular, catching WindowsError is a
+    # problem because that exception is only defined on Windows...
+    os.makedirs(IP_TEST_DIR)
+
+
+def teardown():
+    """Teardown test environment for the module:
+
+            - Remove dummy home dir tree
+    """
+    # Note: we remove the parent test dir, which is the root of all test
+    # subdirs we may have created.  Use shutil instead of os.removedirs, so
+    # that non-empty directories are all recursively removed.
+    shutil.rmtree(TMP_TEST_DIR)
+
+
+#-----------------------------------------------------------------------------
+# Test functions
+#-----------------------------------------------------------------------------
+
+def test_startup_py():
+    # create profile dir
+    pd = ProfileDir.create_profile_dir_by_name(IP_TEST_DIR, 'test')
+    # write startup python file
+    with open(os.path.join(pd.startup_dir, '00-start.py'), 'w') as f:
+        f.write('zzz=123\n')
+    # write simple test file, to check that the startup file was run
+    fname = os.path.join(TMP_TEST_DIR, 'test.py')
+    with open(fname, 'w') as f:
+        f.write('print zzz\n')
+    # validate output
+    tt.ipexec_validate(fname, '123', '', 
+        options=['--ipython-dir', IP_TEST_DIR, '--profile', 'test'])
+
+def test_startup_ipy():
+    # create profile dir
+    pd = ProfileDir.create_profile_dir_by_name(IP_TEST_DIR, 'test')
+    # write startup ipython file
+    with open(os.path.join(pd.startup_dir, '00-start.ipy'), 'w') as f:
+        f.write('%profile\n')
+    # write empty script, because we don't need anything to happen
+    # after the startup file is run
+    fname = os.path.join(TMP_TEST_DIR, 'test.py')
+    with open(fname, 'w') as f:
+        f.write('')
+    # validate output
+    tt.ipexec_validate(fname, 'test', '', 
+        options=['--ipython-dir', IP_TEST_DIR, '--profile', 'test'])
+    
+    
\ No newline at end of file
diff --git a/IPython/testing/tools.py b/IPython/testing/tools.py
--- a/IPython/testing/tools.py
+++ b/IPython/testing/tools.py
@@ -259,7 +259,7 @@ def ipexec_validate(fname, expected_out, expected_err='',
 
     import nose.tools as nt
 
-    out, err = ipexec(fname)
+    out, err = ipexec(fname, options)
     #print 'OUT', out  # dbg
     #print 'ERR', err  # dbg
     # If there are any errors, we must check those befor stdout, as they may be