##// END OF EJS Templates
move IPython.lib.kernel to IPython.utils.kernel...
MinRK -
Show More
@@ -0,0 +1,315 b''
1 """Utilities for connecting to kernels
2
3 Authors:
4
5 * Min Ragan-Kelley
6
7 """
8
9 #-----------------------------------------------------------------------------
10 # Copyright (C) 2011 The IPython Development Team
11 #
12 # Distributed under the terms of the BSD License. The full license is in
13 # the file COPYING, distributed as part of this software.
14 #-----------------------------------------------------------------------------
15
16 #-----------------------------------------------------------------------------
17 # Imports
18 #-----------------------------------------------------------------------------
19
20 import glob
21 import json
22 import os
23 import sys
24 from getpass import getpass
25 from subprocess import Popen, PIPE
26
27 # external imports
28 from IPython.external.ssh import tunnel
29
30 # IPython imports
31 from IPython.core.profiledir import ProfileDir
32 from IPython.utils.path import filefind, get_ipython_dir
33 from IPython.utils.py3compat import str_to_bytes
34
35
36 #-----------------------------------------------------------------------------
37 # Functions
38 #-----------------------------------------------------------------------------
39
40 def get_connection_file(app=None):
41 """Return the path to the connection file of an app
42
43 Parameters
44 ----------
45 app : KernelApp instance [optional]
46 If unspecified, the currently running app will be used
47 """
48 if app is None:
49 from IPython.zmq.ipkernel import IPKernelApp
50 if not IPKernelApp.initialized():
51 raise RuntimeError("app not specified, and not in a running Kernel")
52
53 app = IPKernelApp.instance()
54 return filefind(app.connection_file, ['.', app.profile_dir.security_dir])
55
56 def find_connection_file(filename, profile=None):
57 """find a connection file, and return its absolute path.
58
59 The current working directory and the profile's security
60 directory will be searched for the file if it is not given by
61 absolute path.
62
63 If profile is unspecified, then the current running application's
64 profile will be used, or 'default', if not run from IPython.
65
66 If the argument does not match an existing file, it will be interpreted as a
67 fileglob, and the matching file in the profile's security dir with
68 the latest access time will be used.
69
70 Parameters
71 ----------
72 filename : str
73 The connection file or fileglob to search for.
74 profile : str [optional]
75 The name of the profile to use when searching for the connection file,
76 if different from the current IPython session or 'default'.
77
78 Returns
79 -------
80 str : The absolute path of the connection file.
81 """
82 from IPython.core.application import BaseIPythonApplication as IPApp
83 try:
84 # quick check for absolute path, before going through logic
85 return filefind(filename)
86 except IOError:
87 pass
88
89 if profile is None:
90 # profile unspecified, check if running from an IPython app
91 if IPApp.initialized():
92 app = IPApp.instance()
93 profile_dir = app.profile_dir
94 else:
95 # not running in IPython, use default profile
96 profile_dir = ProfileDir.find_profile_dir_by_name(get_ipython_dir(), 'default')
97 else:
98 # find profiledir by profile name:
99 profile_dir = ProfileDir.find_profile_dir_by_name(get_ipython_dir(), profile)
100 security_dir = profile_dir.security_dir
101
102 try:
103 # first, try explicit name
104 return filefind(filename, ['.', security_dir])
105 except IOError:
106 pass
107
108 # not found by full name
109
110 if '*' in filename:
111 # given as a glob already
112 pat = filename
113 else:
114 # accept any substring match
115 pat = '*%s*' % filename
116 matches = glob.glob( os.path.join(security_dir, pat) )
117 if not matches:
118 raise IOError("Could not find %r in %r" % (filename, security_dir))
119 elif len(matches) == 1:
120 return matches[0]
121 else:
122 # get most recent match, by access time:
123 return sorted(matches, key=lambda f: os.stat(f).st_atime)[-1]
124
125 def get_connection_info(connection_file=None, unpack=False, profile=None):
126 """Return the connection information for the current Kernel.
127
128 Parameters
129 ----------
130 connection_file : str [optional]
131 The connection file to be used. Can be given by absolute path, or
132 IPython will search in the security directory of a given profile.
133 If run from IPython,
134
135 If unspecified, the connection file for the currently running
136 IPython Kernel will be used, which is only allowed from inside a kernel.
137 unpack : bool [default: False]
138 if True, return the unpacked dict, otherwise just the string contents
139 of the file.
140 profile : str [optional]
141 The name of the profile to use when searching for the connection file,
142 if different from the current IPython session or 'default'.
143
144
145 Returns
146 -------
147 The connection dictionary of the current kernel, as string or dict,
148 depending on `unpack`.
149 """
150 if connection_file is None:
151 # get connection file from current kernel
152 cf = get_connection_file()
153 else:
154 # connection file specified, allow shortnames:
155 cf = find_connection_file(connection_file, profile=profile)
156
157 with open(cf) as f:
158 info = f.read()
159
160 if unpack:
161 info = json.loads(info)
162 # ensure key is bytes:
163 info['key'] = str_to_bytes(info.get('key', ''))
164 return info
165
166 def connect_qtconsole(connection_file=None, argv=None, profile=None):
167 """Connect a qtconsole to the current kernel.
168
169 This is useful for connecting a second qtconsole to a kernel, or to a
170 local notebook.
171
172 Parameters
173 ----------
174 connection_file : str [optional]
175 The connection file to be used. Can be given by absolute path, or
176 IPython will search in the security directory of a given profile.
177 If run from IPython,
178
179 If unspecified, the connection file for the currently running
180 IPython Kernel will be used, which is only allowed from inside a kernel.
181 argv : list [optional]
182 Any extra args to be passed to the console.
183 profile : str [optional]
184 The name of the profile to use when searching for the connection file,
185 if different from the current IPython session or 'default'.
186
187
188 Returns
189 -------
190 subprocess.Popen instance running the qtconsole frontend
191 """
192 argv = [] if argv is None else argv
193
194 if connection_file is None:
195 # get connection file from current kernel
196 cf = get_connection_file()
197 else:
198 cf = find_connection_file(connection_file, profile=profile)
199
200 cmd = ';'.join([
201 "from IPython.frontend.qt.console import qtconsoleapp",
202 "qtconsoleapp.main()"
203 ])
204
205 return Popen([sys.executable, '-c', cmd, '--existing', cf] + argv, stdout=PIPE, stderr=PIPE)
206
207 def tunnel_to_kernel(connection_info, sshserver, sshkey=None):
208 """tunnel connections to a kernel via ssh
209
210 This will open four SSH tunnels from localhost on this machine to the
211 ports associated with the kernel. They can be either direct
212 localhost-localhost tunnels, or if an intermediate server is necessary,
213 the kernel must be listening on a public IP.
214
215 Parameters
216 ----------
217 connection_info : dict or str (path)
218 Either a connection dict, or the path to a JSON connection file
219 sshserver : str
220 The ssh sever to use to tunnel to the kernel. Can be a full
221 `user@server:port` string. ssh config aliases are respected.
222 sshkey : str [optional]
223 Path to file containing ssh key to use for authentication.
224 Only necessary if your ssh config does not already associate
225 a keyfile with the host.
226
227 Returns
228 -------
229
230 (shell, iopub, stdin, hb) : ints
231 The four ports on localhost that have been forwarded to the kernel.
232 """
233 if isinstance(connection_info, basestring):
234 # it's a path, unpack it
235 with open(connection_info) as f:
236 connection_info = json.loads(f.read())
237
238 cf = connection_info
239
240 lports = tunnel.select_random_ports(4)
241 rports = cf['shell_port'], cf['iopub_port'], cf['stdin_port'], cf['hb_port']
242
243 remote_ip = cf['ip']
244
245 if tunnel.try_passwordless_ssh(sshserver, sshkey):
246 password=False
247 else:
248 password = getpass("SSH Password for %s: "%sshserver)
249
250 for lp,rp in zip(lports, rports):
251 tunnel.ssh_tunnel(lp, rp, sshserver, remote_ip, sshkey, password)
252
253 return tuple(lports)
254
255
256 def swallow_argv(argv, aliases=None, flags=None):
257 """strip frontend-specific aliases and flags from an argument list
258
259 For use primarily in frontend apps that want to pass a subset of command-line
260 arguments through to a subprocess, where frontend-specific flags and aliases
261 should be removed from the list.
262
263 Parameters
264 ----------
265
266 argv : list(str)
267 The starting argv, to be filtered
268 aliases : container of aliases (dict, list, set, etc.)
269 The frontend-specific aliases to be removed
270 flags : container of flags (dict, list, set, etc.)
271 The frontend-specific flags to be removed
272
273 Returns
274 -------
275
276 argv : list(str)
277 The argv list, excluding flags and aliases that have been stripped
278 """
279
280 if aliases is None:
281 aliases = set()
282 if flags is None:
283 flags = set()
284
285 stripped = list(argv) # copy
286
287 swallow_next = False
288 was_flag = False
289 for a in argv:
290 if swallow_next:
291 swallow_next = False
292 # last arg was an alias, remove the next one
293 # *unless* the last alias has a no-arg flag version, in which
294 # case, don't swallow the next arg if it's also a flag:
295 if not (was_flag and a.startswith('-')):
296 stripped.remove(a)
297 continue
298 if a.startswith('-'):
299 split = a.lstrip('-').split('=')
300 alias = split[0]
301 if alias in aliases:
302 stripped.remove(a)
303 if len(split) == 1:
304 # alias passed with arg via space
305 swallow_next = True
306 # could have been a flag that matches an alias, e.g. `existing`
307 # in which case, we might not swallow the next arg
308 was_flag = alias in flags
309 elif alias in flags and len(split) == 1:
310 # strip flag, but don't swallow next, as flags don't take args
311 stripped.remove(a)
312
313 # return shortened list
314 return stripped
315
@@ -34,9 +34,9 b' import uuid'
34 from IPython.config.application import boolean_flag
34 from IPython.config.application import boolean_flag
35 from IPython.config.configurable import Configurable
35 from IPython.config.configurable import Configurable
36 from IPython.core.profiledir import ProfileDir
36 from IPython.core.profiledir import ProfileDir
37 from IPython.lib.kernel import tunnel_to_kernel, find_connection_file, swallow_argv
38 from IPython.zmq.blockingkernelmanager import BlockingKernelManager
37 from IPython.zmq.blockingkernelmanager import BlockingKernelManager
39 from IPython.zmq.kernelmanager import KernelManager
38 from IPython.zmq.kernelmanager import KernelManager
39 from IPython.utils.kernel import tunnel_to_kernel, find_connection_file, swallow_argv
40 from IPython.utils.path import filefind
40 from IPython.utils.path import filefind
41 from IPython.utils.py3compat import str_to_bytes
41 from IPython.utils.py3compat import str_to_bytes
42 from IPython.utils.traitlets import (
42 from IPython.utils.traitlets import (
@@ -61,7 +61,6 b' from IPython.config.application import catch_config_error, boolean_flag'
61 from IPython.core.application import BaseIPythonApplication
61 from IPython.core.application import BaseIPythonApplication
62 from IPython.core.profiledir import ProfileDir
62 from IPython.core.profiledir import ProfileDir
63 from IPython.frontend.consoleapp import IPythonConsoleApp
63 from IPython.frontend.consoleapp import IPythonConsoleApp
64 from IPython.lib.kernel import swallow_argv
65 from IPython.zmq.session import Session, default_secure
64 from IPython.zmq.session import Session, default_secure
66 from IPython.zmq.zmqshell import ZMQInteractiveShell
65 from IPython.zmq.zmqshell import ZMQInteractiveShell
67 from IPython.zmq.ipkernel import (
66 from IPython.zmq.ipkernel import (
@@ -71,6 +70,7 b' from IPython.zmq.ipkernel import ('
71 )
70 )
72 from IPython.utils.importstring import import_item
71 from IPython.utils.importstring import import_item
73 from IPython.utils.localinterfaces import LOCALHOST
72 from IPython.utils.localinterfaces import LOCALHOST
73 from IPython.utils.kernel import swallow_argv
74 from IPython.utils.traitlets import (
74 from IPython.utils.traitlets import (
75 Dict, Unicode, Integer, List, Enum, Bool,
75 Dict, Unicode, Integer, List, Enum, Bool,
76 DottedObjectName
76 DottedObjectName
@@ -59,13 +59,13 b' from IPython.external.qt import QtCore, QtGui'
59 from IPython.config.application import boolean_flag, catch_config_error
59 from IPython.config.application import boolean_flag, catch_config_error
60 from IPython.core.application import BaseIPythonApplication
60 from IPython.core.application import BaseIPythonApplication
61 from IPython.core.profiledir import ProfileDir
61 from IPython.core.profiledir import ProfileDir
62 from IPython.lib.kernel import tunnel_to_kernel, find_connection_file
63 from IPython.frontend.qt.console.frontend_widget import FrontendWidget
62 from IPython.frontend.qt.console.frontend_widget import FrontendWidget
64 from IPython.frontend.qt.console.ipython_widget import IPythonWidget
63 from IPython.frontend.qt.console.ipython_widget import IPythonWidget
65 from IPython.frontend.qt.console.rich_ipython_widget import RichIPythonWidget
64 from IPython.frontend.qt.console.rich_ipython_widget import RichIPythonWidget
66 from IPython.frontend.qt.console import styles
65 from IPython.frontend.qt.console import styles
67 from IPython.frontend.qt.console.mainwindow import MainWindow
66 from IPython.frontend.qt.console.mainwindow import MainWindow
68 from IPython.frontend.qt.kernelmanager import QtKernelManager
67 from IPython.frontend.qt.kernelmanager import QtKernelManager
68 from IPython.utils.kernel import tunnel_to_kernel, find_connection_file
69 from IPython.utils.path import filefind
69 from IPython.utils.path import filefind
70 from IPython.utils.py3compat import str_to_bytes
70 from IPython.utils.py3compat import str_to_bytes
71 from IPython.utils.traitlets import (
71 from IPython.utils.traitlets import (
@@ -1,315 +1,12 b''
1 """Utilities for connecting to kernels
1 """[DEPRECATED] Utilities for connecting to kernels
2
3 Authors:
4
5 * Min Ragan-Kelley
6
2
3 Moved to IPython.utils.kernel, where it always belonged.
7 """
4 """
8
5
9 #-----------------------------------------------------------------------------
6 import warnings
10 # Copyright (C) 2011 The IPython Development Team
7 warnings.warn("IPython.lib.kernel moved to IPython.utils.kernel in IPython 0.14",
11 #
8 DeprecationWarning
12 # Distributed under the terms of the BSD License. The full license is in
9 )
13 # the file COPYING, distributed as part of this software.
14 #-----------------------------------------------------------------------------
15
16 #-----------------------------------------------------------------------------
17 # Imports
18 #-----------------------------------------------------------------------------
19
20 import glob
21 import json
22 import os
23 import sys
24 from getpass import getpass
25 from subprocess import Popen, PIPE
26
27 # external imports
28 from IPython.external.ssh import tunnel
29
30 # IPython imports
31 from IPython.core.profiledir import ProfileDir
32 from IPython.utils.path import filefind, get_ipython_dir
33 from IPython.utils.py3compat import str_to_bytes
34
35
36 #-----------------------------------------------------------------------------
37 # Functions
38 #-----------------------------------------------------------------------------
39
40 def get_connection_file(app=None):
41 """Return the path to the connection file of an app
42
43 Parameters
44 ----------
45 app : KernelApp instance [optional]
46 If unspecified, the currently running app will be used
47 """
48 if app is None:
49 from IPython.zmq.ipkernel import IPKernelApp
50 if not IPKernelApp.initialized():
51 raise RuntimeError("app not specified, and not in a running Kernel")
52
53 app = IPKernelApp.instance()
54 return filefind(app.connection_file, ['.', app.profile_dir.security_dir])
55
56 def find_connection_file(filename, profile=None):
57 """find a connection file, and return its absolute path.
58
59 The current working directory and the profile's security
60 directory will be searched for the file if it is not given by
61 absolute path.
62
63 If profile is unspecified, then the current running application's
64 profile will be used, or 'default', if not run from IPython.
65
66 If the argument does not match an existing file, it will be interpreted as a
67 fileglob, and the matching file in the profile's security dir with
68 the latest access time will be used.
69
70 Parameters
71 ----------
72 filename : str
73 The connection file or fileglob to search for.
74 profile : str [optional]
75 The name of the profile to use when searching for the connection file,
76 if different from the current IPython session or 'default'.
77
78 Returns
79 -------
80 str : The absolute path of the connection file.
81 """
82 from IPython.core.application import BaseIPythonApplication as IPApp
83 try:
84 # quick check for absolute path, before going through logic
85 return filefind(filename)
86 except IOError:
87 pass
88
89 if profile is None:
90 # profile unspecified, check if running from an IPython app
91 if IPApp.initialized():
92 app = IPApp.instance()
93 profile_dir = app.profile_dir
94 else:
95 # not running in IPython, use default profile
96 profile_dir = ProfileDir.find_profile_dir_by_name(get_ipython_dir(), 'default')
97 else:
98 # find profiledir by profile name:
99 profile_dir = ProfileDir.find_profile_dir_by_name(get_ipython_dir(), profile)
100 security_dir = profile_dir.security_dir
101
102 try:
103 # first, try explicit name
104 return filefind(filename, ['.', security_dir])
105 except IOError:
106 pass
107
108 # not found by full name
109
110 if '*' in filename:
111 # given as a glob already
112 pat = filename
113 else:
114 # accept any substring match
115 pat = '*%s*' % filename
116 matches = glob.glob( os.path.join(security_dir, pat) )
117 if not matches:
118 raise IOError("Could not find %r in %r" % (filename, security_dir))
119 elif len(matches) == 1:
120 return matches[0]
121 else:
122 # get most recent match, by access time:
123 return sorted(matches, key=lambda f: os.stat(f).st_atime)[-1]
124
125 def get_connection_info(connection_file=None, unpack=False, profile=None):
126 """Return the connection information for the current Kernel.
127
128 Parameters
129 ----------
130 connection_file : str [optional]
131 The connection file to be used. Can be given by absolute path, or
132 IPython will search in the security directory of a given profile.
133 If run from IPython,
134
135 If unspecified, the connection file for the currently running
136 IPython Kernel will be used, which is only allowed from inside a kernel.
137 unpack : bool [default: False]
138 if True, return the unpacked dict, otherwise just the string contents
139 of the file.
140 profile : str [optional]
141 The name of the profile to use when searching for the connection file,
142 if different from the current IPython session or 'default'.
143
144
145 Returns
146 -------
147 The connection dictionary of the current kernel, as string or dict,
148 depending on `unpack`.
149 """
150 if connection_file is None:
151 # get connection file from current kernel
152 cf = get_connection_file()
153 else:
154 # connection file specified, allow shortnames:
155 cf = find_connection_file(connection_file, profile=profile)
156
157 with open(cf) as f:
158 info = f.read()
159
160 if unpack:
161 info = json.loads(info)
162 # ensure key is bytes:
163 info['key'] = str_to_bytes(info.get('key', ''))
164 return info
165
166 def connect_qtconsole(connection_file=None, argv=None, profile=None):
167 """Connect a qtconsole to the current kernel.
168
169 This is useful for connecting a second qtconsole to a kernel, or to a
170 local notebook.
171
172 Parameters
173 ----------
174 connection_file : str [optional]
175 The connection file to be used. Can be given by absolute path, or
176 IPython will search in the security directory of a given profile.
177 If run from IPython,
178
179 If unspecified, the connection file for the currently running
180 IPython Kernel will be used, which is only allowed from inside a kernel.
181 argv : list [optional]
182 Any extra args to be passed to the console.
183 profile : str [optional]
184 The name of the profile to use when searching for the connection file,
185 if different from the current IPython session or 'default'.
186
187
188 Returns
189 -------
190 subprocess.Popen instance running the qtconsole frontend
191 """
192 argv = [] if argv is None else argv
193
194 if connection_file is None:
195 # get connection file from current kernel
196 cf = get_connection_file()
197 else:
198 cf = find_connection_file(connection_file, profile=profile)
199
200 cmd = ';'.join([
201 "from IPython.frontend.qt.console import qtconsoleapp",
202 "qtconsoleapp.main()"
203 ])
204
205 return Popen([sys.executable, '-c', cmd, '--existing', cf] + argv, stdout=PIPE, stderr=PIPE)
206
207 def tunnel_to_kernel(connection_info, sshserver, sshkey=None):
208 """tunnel connections to a kernel via ssh
209
210 This will open four SSH tunnels from localhost on this machine to the
211 ports associated with the kernel. They can be either direct
212 localhost-localhost tunnels, or if an intermediate server is necessary,
213 the kernel must be listening on a public IP.
214
215 Parameters
216 ----------
217 connection_info : dict or str (path)
218 Either a connection dict, or the path to a JSON connection file
219 sshserver : str
220 The ssh sever to use to tunnel to the kernel. Can be a full
221 `user@server:port` string. ssh config aliases are respected.
222 sshkey : str [optional]
223 Path to file containing ssh key to use for authentication.
224 Only necessary if your ssh config does not already associate
225 a keyfile with the host.
226
227 Returns
228 -------
229
230 (shell, iopub, stdin, hb) : ints
231 The four ports on localhost that have been forwarded to the kernel.
232 """
233 if isinstance(connection_info, basestring):
234 # it's a path, unpack it
235 with open(connection_info) as f:
236 connection_info = json.loads(f.read())
237
238 cf = connection_info
239
240 lports = tunnel.select_random_ports(4)
241 rports = cf['shell_port'], cf['iopub_port'], cf['stdin_port'], cf['hb_port']
242
243 remote_ip = cf['ip']
244
245 if tunnel.try_passwordless_ssh(sshserver, sshkey):
246 password=False
247 else:
248 password = getpass("SSH Password for %s: "%sshserver)
249
250 for lp,rp in zip(lports, rports):
251 tunnel.ssh_tunnel(lp, rp, sshserver, remote_ip, sshkey, password)
252
253 return tuple(lports)
254
255
10
256 def swallow_argv(argv, aliases=None, flags=None):
11 from IPython.utils.kernel import *
257 """strip frontend-specific aliases and flags from an argument list
258
259 For use primarily in frontend apps that want to pass a subset of command-line
260 arguments through to a subprocess, where frontend-specific flags and aliases
261 should be removed from the list.
262
263 Parameters
264 ----------
265
266 argv : list(str)
267 The starting argv, to be filtered
268 aliases : container of aliases (dict, list, set, etc.)
269 The frontend-specific aliases to be removed
270 flags : container of flags (dict, list, set, etc.)
271 The frontend-specific flags to be removed
272
273 Returns
274 -------
275
276 argv : list(str)
277 The argv list, excluding flags and aliases that have been stripped
278 """
279
280 if aliases is None:
281 aliases = set()
282 if flags is None:
283 flags = set()
284
285 stripped = list(argv) # copy
286
287 swallow_next = False
288 was_flag = False
289 for a in argv:
290 if swallow_next:
291 swallow_next = False
292 # last arg was an alias, remove the next one
293 # *unless* the last alias has a no-arg flag version, in which
294 # case, don't swallow the next arg if it's also a flag:
295 if not (was_flag and a.startswith('-')):
296 stripped.remove(a)
297 continue
298 if a.startswith('-'):
299 split = a.lstrip('-').split('=')
300 alias = split[0]
301 if alias in aliases:
302 stripped.remove(a)
303 if len(split) == 1:
304 # alias passed with arg via space
305 swallow_next = True
306 # could have been a flag that matches an alias, e.g. `existing`
307 # in which case, we might not swallow the next arg
308 was_flag = alias in flags
309 elif alias in flags and len(split) == 1:
310 # strip flag, but don't swallow next, as flags don't take args
311 stripped.remove(a)
312
313 # return shortened list
314 return stripped
315
12
@@ -24,7 +24,7 b' import nose.tools as nt'
24
24
25 # Our own imports
25 # Our own imports
26 from IPython.testing import decorators as dec
26 from IPython.testing import decorators as dec
27 from IPython.lib import kernel
27 from IPython.utils import kernel
28
28
29 #-----------------------------------------------------------------------------
29 #-----------------------------------------------------------------------------
30 # Classes and functions
30 # Classes and functions
@@ -35,7 +35,7 b' from IPython.core.magics import MacroToEdit, CodeMagics'
35 from IPython.core.magic import magics_class, line_magic, Magics
35 from IPython.core.magic import magics_class, line_magic, Magics
36 from IPython.core.payloadpage import install_payload_page
36 from IPython.core.payloadpage import install_payload_page
37 from IPython.inprocess.socket import SocketABC
37 from IPython.inprocess.socket import SocketABC
38 from IPython.lib.kernel import (
38 from IPython.utils.kernel import (
39 get_connection_file, get_connection_info, connect_qtconsole
39 get_connection_file, get_connection_info, connect_qtconsole
40 )
40 )
41 from IPython.testing.skipdoctest import skip_doctest
41 from IPython.testing.skipdoctest import skip_doctest
General Comments 0
You need to be logged in to leave comments. Login now