Show More
@@ -11,23 +11,18 b' This module requires Python 2.2 or later.' | |||
|
11 | 11 | |
|
12 | 12 | |
|
13 | 13 | URL: http://www.jorendorff.com/articles/python/path |
|
14 |
Author: Jason Orendorff <jason |
|
|
14 | Author: Jason Orendorff <jason.orendorff\x40gmail\x2ecom> (and others - see the url!) | |
|
15 | 15 | Date: 7 Mar 2004 |
|
16 | 16 | """ |
|
17 | 17 | |
|
18 | # Original license statement: | |
|
19 | #License: You may use path.py for whatever you wish, at your own risk. (For | |
|
20 | #example, you may modify, relicense, and redistribute it.) It is provided | |
|
21 | #without any guarantee or warranty of any kind, not even for merchantability or | |
|
22 | #fitness for any purpose. | |
|
23 | ||
|
24 | # IPython license note: | |
|
25 | # For the sake of convenience, IPython includes this module | |
|
26 | # in its directory structure in unmodified form, apart from | |
|
27 | # these license statements. The same license still applies. | |
|
28 | ||
|
29 | 18 | |
|
30 | 19 | # TODO |
|
20 | # - Tree-walking functions don't avoid symlink loops. Matt Harrison sent me a patch for this. | |
|
21 | # - Tree-walking functions can't ignore errors. Matt Harrison asked for this. | |
|
22 | # | |
|
23 | # - Two people asked for path.chdir(). This just seems wrong to me, | |
|
24 | # I dunno. chdir() is moderately evil anyway. | |
|
25 | # | |
|
31 | 26 | # - Bug in write_text(). It doesn't support Universal newline mode. |
|
32 | 27 | # - Better error message in listdir() when self isn't a |
|
33 | 28 | # directory. (On Windows, the error message really sucks.) |
@@ -36,25 +31,42 b' Date: 7 Mar 2004' | |||
|
36 | 31 | # - guess_content_type() method? |
|
37 | 32 | # - Perhaps support arguments to touch(). |
|
38 | 33 | # - Could add split() and join() methods that generate warnings. |
|
39 | # - Note: __add__() technically has a bug, I think, where | |
|
40 | # it doesn't play nice with other types that implement | |
|
41 | # __radd__(). Test this. | |
|
42 | 34 | |
|
43 | 35 | from __future__ import generators |
|
44 | 36 | |
|
45 | import sys, os, fnmatch, glob, shutil, codecs | |
|
37 | import sys, warnings, os, fnmatch, glob, shutil, codecs, md5 | |
|
46 | 38 | |
|
47 |
__version__ = '2. |
|
|
39 | __version__ = '2.1' | |
|
48 | 40 | __all__ = ['path'] |
|
49 | 41 | |
|
42 | # Platform-specific support for path.owner | |
|
43 | if os.name == 'nt': | |
|
44 | try: | |
|
45 | import win32security | |
|
46 | except ImportError: | |
|
47 | win32security = None | |
|
48 | else: | |
|
49 | try: | |
|
50 | import pwd | |
|
51 | except ImportError: | |
|
52 | pwd = None | |
|
53 | ||
|
50 | 54 | # Pre-2.3 support. Are unicode filenames supported? |
|
51 | 55 | _base = str |
|
56 | _getcwd = os.getcwd | |
|
52 | 57 | try: |
|
53 | 58 | if os.path.supports_unicode_filenames: |
|
54 | 59 | _base = unicode |
|
60 | _getcwd = os.getcwdu | |
|
55 | 61 | except AttributeError: |
|
56 | 62 | pass |
|
57 | 63 | |
|
64 | # Pre-2.3 workaround for booleans | |
|
65 | try: | |
|
66 | True, False | |
|
67 | except NameError: | |
|
68 | True, False = 1, 0 | |
|
69 | ||
|
58 | 70 | # Pre-2.3 workaround for basestring. |
|
59 | 71 | try: |
|
60 | 72 | basestring |
@@ -67,6 +79,9 b" if hasattr(file, 'newlines'):" | |||
|
67 | 79 | _textmode = 'U' |
|
68 | 80 | |
|
69 | 81 | |
|
82 | class TreeWalkWarning(Warning): | |
|
83 | pass | |
|
84 | ||
|
70 | 85 | class path(_base): |
|
71 | 86 | """ Represents a filesystem path. |
|
72 | 87 | |
@@ -81,10 +96,19 b' class path(_base):' | |||
|
81 | 96 | |
|
82 | 97 | # Adding a path and a string yields a path. |
|
83 | 98 | def __add__(self, more): |
|
84 | return path(_base(self) + more) | |
|
99 | try: | |
|
100 | resultStr = _base.__add__(self, more) | |
|
101 | except TypeError: #Python bug | |
|
102 | resultStr = NotImplemented | |
|
103 | if resultStr is NotImplemented: | |
|
104 | return resultStr | |
|
105 | return self.__class__(resultStr) | |
|
85 | 106 | |
|
86 | 107 | def __radd__(self, other): |
|
87 | return path(other + _base(self)) | |
|
108 | if isinstance(other, basestring): | |
|
109 | return self.__class__(other.__add__(self)) | |
|
110 | else: | |
|
111 | return NotImplemented | |
|
88 | 112 | |
|
89 | 113 | # The / operator joins paths. |
|
90 | 114 | def __div__(self, rel): |
@@ -93,26 +117,27 b' class path(_base):' | |||
|
93 | 117 | Join two path components, adding a separator character if |
|
94 | 118 | needed. |
|
95 | 119 | """ |
|
96 |
return |
|
|
120 | return self.__class__(os.path.join(self, rel)) | |
|
97 | 121 | |
|
98 | 122 | # Make the / operator work even when true division is enabled. |
|
99 | 123 | __truediv__ = __div__ |
|
100 | 124 | |
|
101 | def getcwd(): | |
|
125 | def getcwd(cls): | |
|
102 | 126 | """ Return the current working directory as a path object. """ |
|
103 |
return |
|
|
104 |
getcwd = |
|
|
127 | return cls(_getcwd()) | |
|
128 | getcwd = classmethod(getcwd) | |
|
105 | 129 | |
|
106 | 130 | |
|
107 | 131 | # --- Operations on path strings. |
|
108 | 132 | |
|
109 | def abspath(self): return path(os.path.abspath(self)) | |
|
110 |
def |
|
|
111 |
def norm |
|
|
112 |
def |
|
|
113 |
def |
|
|
114 |
def expand |
|
|
115 |
def |
|
|
133 | isabs = os.path.isabs | |
|
134 | def abspath(self): return self.__class__(os.path.abspath(self)) | |
|
135 | def normcase(self): return self.__class__(os.path.normcase(self)) | |
|
136 | def normpath(self): return self.__class__(os.path.normpath(self)) | |
|
137 | def realpath(self): return self.__class__(os.path.realpath(self)) | |
|
138 | def expanduser(self): return self.__class__(os.path.expanduser(self)) | |
|
139 | def expandvars(self): return self.__class__(os.path.expandvars(self)) | |
|
140 | def dirname(self): return self.__class__(os.path.dirname(self)) | |
|
116 | 141 | basename = os.path.basename |
|
117 | 142 | |
|
118 | 143 | def expand(self): |
@@ -134,7 +159,7 b' class path(_base):' | |||
|
134 | 159 | |
|
135 | 160 | def _get_drive(self): |
|
136 | 161 | drive, r = os.path.splitdrive(self) |
|
137 |
return |
|
|
162 | return self.__class__(drive) | |
|
138 | 163 | |
|
139 | 164 | parent = property( |
|
140 | 165 | dirname, None, None, |
@@ -171,7 +196,7 b' class path(_base):' | |||
|
171 | 196 | def splitpath(self): |
|
172 | 197 | """ p.splitpath() -> Return (p.parent, p.name). """ |
|
173 | 198 | parent, child = os.path.split(self) |
|
174 |
return |
|
|
199 | return self.__class__(parent), child | |
|
175 | 200 | |
|
176 | 201 | def splitdrive(self): |
|
177 | 202 | """ p.splitdrive() -> Return (p.drive, <the rest of p>). |
@@ -181,7 +206,7 b' class path(_base):' | |||
|
181 | 206 | is simply (path(''), p). This is always the case on Unix. |
|
182 | 207 | """ |
|
183 | 208 | drive, rel = os.path.splitdrive(self) |
|
184 |
return |
|
|
209 | return self.__class__(drive), rel | |
|
185 | 210 | |
|
186 | 211 | def splitext(self): |
|
187 | 212 | """ p.splitext() -> Return (p.stripext(), p.ext). |
@@ -194,7 +219,7 b' class path(_base):' | |||
|
194 | 219 | (a, b) == p.splitext(), then a + b == p. |
|
195 | 220 | """ |
|
196 | 221 | filename, ext = os.path.splitext(self) |
|
197 |
return |
|
|
222 | return self.__class__(filename), ext | |
|
198 | 223 | |
|
199 | 224 | def stripext(self): |
|
200 | 225 | """ p.stripext() -> Remove one file extension from the path. |
@@ -207,11 +232,11 b' class path(_base):' | |||
|
207 | 232 | if hasattr(os.path, 'splitunc'): |
|
208 | 233 | def splitunc(self): |
|
209 | 234 | unc, rest = os.path.splitunc(self) |
|
210 |
return |
|
|
235 | return self.__class__(unc), rest | |
|
211 | 236 | |
|
212 | 237 | def _get_uncshare(self): |
|
213 | 238 | unc, r = os.path.splitunc(self) |
|
214 |
return |
|
|
239 | return self.__class__(unc) | |
|
215 | 240 | |
|
216 | 241 | uncshare = property( |
|
217 | 242 | _get_uncshare, None, None, |
@@ -223,10 +248,10 b' class path(_base):' | |||
|
223 | 248 | character (os.sep) if needed. Returns a new path |
|
224 | 249 | object. |
|
225 | 250 | """ |
|
226 |
return |
|
|
251 | return self.__class__(os.path.join(self, *args)) | |
|
227 | 252 | |
|
228 | 253 | def splitall(self): |
|
229 | """ Return a list of the path components in this path. | |
|
254 | r""" Return a list of the path components in this path. | |
|
230 | 255 | |
|
231 | 256 | The first item in the list will be a path. Its value will be |
|
232 | 257 | either os.curdir, os.pardir, empty, or the root directory of |
@@ -251,7 +276,7 b' class path(_base):' | |||
|
251 | 276 | """ Return this path as a relative path, |
|
252 | 277 | based from the current working directory. |
|
253 | 278 | """ |
|
254 |
cwd = |
|
|
279 | cwd = self.__class__(os.getcwd()) | |
|
255 | 280 | return cwd.relpathto(self) |
|
256 | 281 | |
|
257 | 282 | def relpathto(self, dest): |
@@ -262,7 +287,7 b' class path(_base):' | |||
|
262 | 287 | dest.abspath(). |
|
263 | 288 | """ |
|
264 | 289 | origin = self.abspath() |
|
265 |
dest = |
|
|
290 | dest = self.__class__(dest).abspath() | |
|
266 | 291 | |
|
267 | 292 | orig_list = origin.normcase().splitall() |
|
268 | 293 | # Don't normcase dest! We want to preserve the case. |
@@ -287,10 +312,10 b' class path(_base):' | |||
|
287 | 312 | segments += dest_list[i:] |
|
288 | 313 | if len(segments) == 0: |
|
289 | 314 | # If they happen to be identical, use os.curdir. |
|
290 |
re |
|
|
315 | relpath = os.curdir | |
|
291 | 316 | else: |
|
292 |
re |
|
|
293 | ||
|
317 | relpath = os.path.join(*segments) | |
|
318 | return self.__class__(relpath) | |
|
294 | 319 | |
|
295 | 320 | # --- Listing, searching, walking, and matching |
|
296 | 321 | |
@@ -336,7 +361,7 b' class path(_base):' | |||
|
336 | 361 | |
|
337 | 362 | return [p for p in self.listdir(pattern) if p.isfile()] |
|
338 | 363 | |
|
339 | def walk(self, pattern=None): | |
|
364 | def walk(self, pattern=None, errors='strict'): | |
|
340 | 365 | """ D.walk() -> iterator over files and subdirs, recursively. |
|
341 | 366 | |
|
342 | 367 | The iterator yields path objects naming each child item of |
@@ -345,29 +370,85 b' class path(_base):' | |||
|
345 | 370 | |
|
346 | 371 | This performs a depth-first traversal of the directory tree. |
|
347 | 372 | Each directory is returned just before all its children. |
|
373 | ||
|
374 | The errors= keyword argument controls behavior when an | |
|
375 | error occurs. The default is 'strict', which causes an | |
|
376 | exception. The other allowed values are 'warn', which | |
|
377 | reports the error via warnings.warn(), and 'ignore'. | |
|
348 | 378 | """ |
|
349 | for child in self.listdir(): | |
|
379 | if errors not in ('strict', 'warn', 'ignore'): | |
|
380 | raise ValueError("invalid errors parameter") | |
|
381 | ||
|
382 | try: | |
|
383 | childList = self.listdir() | |
|
384 | except Exception: | |
|
385 | if errors == 'ignore': | |
|
386 | return | |
|
387 | elif errors == 'warn': | |
|
388 | warnings.warn( | |
|
389 | "Unable to list directory '%s': %s" | |
|
390 | % (self, sys.exc_info()[1]), | |
|
391 | TreeWalkWarning) | |
|
392 | else: | |
|
393 | raise | |
|
394 | ||
|
395 | for child in childList: | |
|
350 | 396 | if pattern is None or child.fnmatch(pattern): |
|
351 | 397 | yield child |
|
352 |
|
|
|
353 |
|
|
|
398 | try: | |
|
399 | isdir = child.isdir() | |
|
400 | except Exception: | |
|
401 | if errors == 'ignore': | |
|
402 | isdir = False | |
|
403 | elif errors == 'warn': | |
|
404 | warnings.warn( | |
|
405 | "Unable to access '%s': %s" | |
|
406 | % (child, sys.exc_info()[1]), | |
|
407 | TreeWalkWarning) | |
|
408 | isdir = False | |
|
409 | else: | |
|
410 | raise | |
|
411 | ||
|
412 | if isdir: | |
|
413 | for item in child.walk(pattern, errors): | |
|
354 | 414 | yield item |
|
355 | 415 | |
|
356 | def walkdirs(self, pattern=None): | |
|
416 | def walkdirs(self, pattern=None, errors='strict'): | |
|
357 | 417 | """ D.walkdirs() -> iterator over subdirs, recursively. |
|
358 | 418 | |
|
359 | 419 | With the optional 'pattern' argument, this yields only |
|
360 | 420 | directories whose names match the given pattern. For |
|
361 | 421 | example, mydir.walkdirs('*test') yields only directories |
|
362 | 422 | with names ending in 'test'. |
|
423 | ||
|
424 | The errors= keyword argument controls behavior when an | |
|
425 | error occurs. The default is 'strict', which causes an | |
|
426 | exception. The other allowed values are 'warn', which | |
|
427 | reports the error via warnings.warn(), and 'ignore'. | |
|
363 | 428 | """ |
|
364 | for child in self.dirs(): | |
|
429 | if errors not in ('strict', 'warn', 'ignore'): | |
|
430 | raise ValueError("invalid errors parameter") | |
|
431 | ||
|
432 | try: | |
|
433 | dirs = self.dirs() | |
|
434 | except Exception: | |
|
435 | if errors == 'ignore': | |
|
436 | return | |
|
437 | elif errors == 'warn': | |
|
438 | warnings.warn( | |
|
439 | "Unable to list directory '%s': %s" | |
|
440 | % (self, sys.exc_info()[1]), | |
|
441 | TreeWalkWarning) | |
|
442 | else: | |
|
443 | raise | |
|
444 | ||
|
445 | for child in dirs: | |
|
365 | 446 | if pattern is None or child.fnmatch(pattern): |
|
366 | 447 | yield child |
|
367 | for subsubdir in child.walkdirs(pattern): | |
|
448 | for subsubdir in child.walkdirs(pattern, errors): | |
|
368 | 449 | yield subsubdir |
|
369 | 450 | |
|
370 | def walkfiles(self, pattern=None): | |
|
451 | def walkfiles(self, pattern=None, errors='strict'): | |
|
371 | 452 | """ D.walkfiles() -> iterator over files in D, recursively. |
|
372 | 453 | |
|
373 | 454 | The optional argument, pattern, limits the results to files |
@@ -375,12 +456,42 b' class path(_base):' | |||
|
375 | 456 | mydir.walkfiles('*.tmp') yields only files with the .tmp |
|
376 | 457 | extension. |
|
377 | 458 | """ |
|
378 | for child in self.listdir(): | |
|
379 | if child.isfile(): | |
|
459 | if errors not in ('strict', 'warn', 'ignore'): | |
|
460 | raise ValueError("invalid errors parameter") | |
|
461 | ||
|
462 | try: | |
|
463 | childList = self.listdir() | |
|
464 | except Exception: | |
|
465 | if errors == 'ignore': | |
|
466 | return | |
|
467 | elif errors == 'warn': | |
|
468 | warnings.warn( | |
|
469 | "Unable to list directory '%s': %s" | |
|
470 | % (self, sys.exc_info()[1]), | |
|
471 | TreeWalkWarning) | |
|
472 | else: | |
|
473 | raise | |
|
474 | ||
|
475 | for child in childList: | |
|
476 | try: | |
|
477 | isfile = child.isfile() | |
|
478 | isdir = not isfile and child.isdir() | |
|
479 | except: | |
|
480 | if errors == 'ignore': | |
|
481 | return | |
|
482 | elif errors == 'warn': | |
|
483 | warnings.warn( | |
|
484 | "Unable to access '%s': %s" | |
|
485 | % (self, sys.exc_info()[1]), | |
|
486 | TreeWalkWarning) | |
|
487 | else: | |
|
488 | raise | |
|
489 | ||
|
490 | if isfile: | |
|
380 | 491 | if pattern is None or child.fnmatch(pattern): |
|
381 | 492 | yield child |
|
382 |
elif |
|
|
383 | for f in child.walkfiles(pattern): | |
|
493 | elif isdir: | |
|
494 | for f in child.walkfiles(pattern, errors): | |
|
384 | 495 | yield f |
|
385 | 496 | |
|
386 | 497 | def fnmatch(self, pattern): |
@@ -399,7 +510,8 b' class path(_base):' | |||
|
399 | 510 | For example, path('/users').glob('*/bin/*') returns a list |
|
400 | 511 | of all the files users have in their bin directories. |
|
401 | 512 | """ |
|
402 | return map(path, glob.glob(_base(self / pattern))) | |
|
513 | cls = self.__class__ | |
|
514 | return [cls(s) for s in glob.glob(_base(self / pattern))] | |
|
403 | 515 | |
|
404 | 516 | |
|
405 | 517 | # --- Reading or writing an entire file at once. |
@@ -420,7 +532,7 b' class path(_base):' | |||
|
420 | 532 | """ Open this file and write the given bytes to it. |
|
421 | 533 | |
|
422 | 534 | Default behavior is to overwrite any existing file. |
|
423 |
Call |
|
|
535 | Call p.write_bytes(bytes, append=True) to append instead. | |
|
424 | 536 | """ |
|
425 | 537 | if append: |
|
426 | 538 | mode = 'ab' |
@@ -433,7 +545,7 b' class path(_base):' | |||
|
433 | 545 | f.close() |
|
434 | 546 | |
|
435 | 547 | def text(self, encoding=None, errors='strict'): |
|
436 | """ Open this file, read it in, return the content as a string. | |
|
548 | r""" Open this file, read it in, return the content as a string. | |
|
437 | 549 | |
|
438 | 550 | This uses 'U' mode in Python 2.3 and later, so '\r\n' and '\r' |
|
439 | 551 | are automatically translated to '\n'. |
@@ -470,7 +582,7 b' class path(_base):' | |||
|
470 | 582 | .replace(u'\u2028', u'\n')) |
|
471 | 583 | |
|
472 | 584 | def write_text(self, text, encoding=None, errors='strict', linesep=os.linesep, append=False): |
|
473 | """ Write the given text to this file. | |
|
585 | r""" Write the given text to this file. | |
|
474 | 586 | |
|
475 | 587 | The default behavior is to overwrite any existing file; |
|
476 | 588 | to append instead, use the 'append=True' keyword argument. |
@@ -559,7 +671,7 b' class path(_base):' | |||
|
559 | 671 | self.write_bytes(bytes, append) |
|
560 | 672 | |
|
561 | 673 | def lines(self, encoding=None, errors='strict', retain=True): |
|
562 | """ Open this file, read all lines, return them in a list. | |
|
674 | r""" Open this file, read all lines, return them in a list. | |
|
563 | 675 | |
|
564 | 676 | Optional arguments: |
|
565 | 677 | encoding - The Unicode encoding (or character set) of |
@@ -586,7 +698,7 b' class path(_base):' | |||
|
586 | 698 | |
|
587 | 699 | def write_lines(self, lines, encoding=None, errors='strict', |
|
588 | 700 | linesep=os.linesep, append=False): |
|
589 | """ Write the given lines of text to this file. | |
|
701 | r""" Write the given lines of text to this file. | |
|
590 | 702 | |
|
591 | 703 | By default this overwrites any existing file at this path. |
|
592 | 704 | |
@@ -649,11 +761,26 b' class path(_base):' | |||
|
649 | 761 | finally: |
|
650 | 762 | f.close() |
|
651 | 763 | |
|
764 | def read_md5(self): | |
|
765 | """ Calculate the md5 hash for this file. | |
|
766 | ||
|
767 | This reads through the entire file. | |
|
768 | """ | |
|
769 | f = self.open('rb') | |
|
770 | try: | |
|
771 | m = md5.new() | |
|
772 | while True: | |
|
773 | d = f.read(8192) | |
|
774 | if not d: | |
|
775 | break | |
|
776 | m.update(d) | |
|
777 | finally: | |
|
778 | f.close() | |
|
779 | return m.digest() | |
|
652 | 780 | |
|
653 | 781 | # --- Methods for querying the filesystem. |
|
654 | 782 | |
|
655 | 783 | exists = os.path.exists |
|
656 | isabs = os.path.isabs | |
|
657 | 784 | isdir = os.path.isdir |
|
658 | 785 | isfile = os.path.isfile |
|
659 | 786 | islink = os.path.islink |
@@ -699,6 +826,32 b' class path(_base):' | |||
|
699 | 826 | """ Like path.stat(), but do not follow symbolic links. """ |
|
700 | 827 | return os.lstat(self) |
|
701 | 828 | |
|
829 | def get_owner(self): | |
|
830 | r""" Return the name of the owner of this file or directory. | |
|
831 | ||
|
832 | This follows symbolic links. | |
|
833 | ||
|
834 | On Windows, this returns a name of the form ur'DOMAIN\User Name'. | |
|
835 | On Windows, a group can own a file or directory. | |
|
836 | """ | |
|
837 | if os.name == 'nt': | |
|
838 | if win32security is None: | |
|
839 | raise Exception("path.owner requires win32all to be installed") | |
|
840 | desc = win32security.GetFileSecurity( | |
|
841 | self, win32security.OWNER_SECURITY_INFORMATION) | |
|
842 | sid = desc.GetSecurityDescriptorOwner() | |
|
843 | account, domain, typecode = win32security.LookupAccountSid(None, sid) | |
|
844 | return domain + u'\\' + account | |
|
845 | else: | |
|
846 | if pwd is None: | |
|
847 | raise NotImplementedError("path.owner is not implemented on this platform.") | |
|
848 | st = self.stat() | |
|
849 | return pwd.getpwuid(st.st_uid).pw_name | |
|
850 | ||
|
851 | owner = property( | |
|
852 | get_owner, None, None, | |
|
853 | """ Name of the owner of this file or directory. """) | |
|
854 | ||
|
702 | 855 | if hasattr(os, 'statvfs'): |
|
703 | 856 | def statvfs(self): |
|
704 | 857 | """ Perform a statvfs() system call on this path. """ |
@@ -779,7 +932,7 b' class path(_base):' | |||
|
779 | 932 | |
|
780 | 933 | The result may be an absolute or a relative path. |
|
781 | 934 | """ |
|
782 |
return |
|
|
935 | return self.__class__(os.readlink(self)) | |
|
783 | 936 | |
|
784 | 937 | def readlinkabs(self): |
|
785 | 938 | """ Return the path to which this symbolic link points. |
@@ -19,6 +19,10 b'' | |||
|
19 | 19 | interesting (stored / manually defined) aliases last |
|
20 | 20 | where they catch the eye w/o scrolling. |
|
21 | 21 | |
|
22 | * Magic.py (%rehashx), ext_rehashdir.py: files with | |
|
23 | 'py' extension are always considered executable, even | |
|
24 | when not in PATHEXT environment variable. | |
|
25 | ||
|
22 | 26 | 2006-10-12 Ville Vainio <vivainio@gmail.com> |
|
23 | 27 | |
|
24 | 28 | * jobctrl.py: Add new "jobctrl" extension for spawning background |
General Comments 0
You need to be logged in to leave comments.
Login now