diff --git a/Doc/library/mailbox.rst b/Doc/library/mailbox.rst index f82a3b200deb7c4..e784915e012f9d5 100644 --- a/Doc/library/mailbox.rst +++ b/Doc/library/mailbox.rst @@ -65,6 +65,10 @@ Supported mailbox formats are Maildir, mbox, MH, Babyl, and MMDF. :exc:`KeyError` exception if the corresponding message is subsequently removed. + .. versionchanged:: 3.10 + Constructor accepts a :term:`path-like object` for *path* (*dirname* in + Maildir). + .. warning:: Be very cautious when modifying mailboxes that might be simultaneously diff --git a/Lib/mailbox.py b/Lib/mailbox.py index 70da07ed2e9e8bc..8a47298700b823f 100644 --- a/Lib/mailbox.py +++ b/Lib/mailbox.py @@ -17,8 +17,10 @@ import email.message import email.generator import io +import itertools import contextlib from types import GenericAlias +from pathlib import Path try: import fcntl except ImportError: @@ -36,7 +38,7 @@ class Mailbox: def __init__(self, path, factory=None, create=True): """Initialize a Mailbox instance.""" - self._path = os.path.abspath(os.path.expanduser(path)) + self._path = Path(path).expanduser().resolve() self._factory = factory def add(self, message): @@ -157,7 +159,7 @@ def pop(self, key, default=None): def popitem(self): """Delete an arbitrary (key, message) pair and return it.""" for key in self.iterkeys(): - return (key, self.pop(key)) # This is only run once. + return key, self.pop(key) # This is only run once. else: raise KeyError('No messages in mailbox') @@ -273,15 +275,15 @@ def __init__(self, dirname, factory=None, create=True): """Initialize a Maildir instance.""" Mailbox.__init__(self, dirname, factory, create) self._paths = { - 'tmp': os.path.join(self._path, 'tmp'), - 'new': os.path.join(self._path, 'new'), - 'cur': os.path.join(self._path, 'cur'), + 'tmp': self._path / 'tmp', + 'new': self._path / 'new', + 'cur': self._path / 'cur', } - if not os.path.exists(self._path): + if not self._path.exists(): if create: - os.mkdir(self._path, 0o700) + self._path.mkdir(mode=0o700) for path in self._paths.values(): - os.mkdir(path, 0o700) + path.mkdir(mode=0o700) else: raise NoSuchMailboxError(self._path) self._toc = {} @@ -292,11 +294,12 @@ def __init__(self, dirname, factory=None, create=True): def add(self, message): """Add message and return assigned key.""" tmp_file = self._create_tmp() + tmp_path = Path(tmp_file.name) try: self._dump_message(message, tmp_file) except BaseException: tmp_file.close() - os.remove(tmp_file.name) + tmp_path.unlink() raise _sync_close(tmp_file) if isinstance(message, MaildirMessage): @@ -307,23 +310,23 @@ def add(self, message): else: subdir = 'new' suffix = '' - uniq = os.path.basename(tmp_file.name).split(self.colon)[0] - dest = os.path.join(self._path, subdir, uniq + suffix) + uniq = tmp_path.name.split(self.colon)[0] + dest = self._path / subdir / (uniq + suffix) if isinstance(message, MaildirMessage): os.utime(tmp_file.name, - (os.path.getatime(tmp_file.name), message.get_date())) + (tmp_path.stat().st_atime, message.get_date())) # No file modification should be done after the file is moved to its # final position in order to prevent race conditions with changes # from other programs try: try: - os.link(tmp_file.name, dest) + tmp_path.link_to(dest) except (AttributeError, PermissionError): - os.rename(tmp_file.name, dest) + tmp_path.rename(dest) else: - os.remove(tmp_file.name) + tmp_path.unlink() except OSError as e: - os.remove(tmp_file.name) + tmp_path.unlink() if e.errno == errno.EEXIST: raise ExternalClashError('Name clash with existing message: %s' % dest) @@ -333,7 +336,7 @@ def add(self, message): def remove(self, key): """Remove the keyed message; raise KeyError if it doesn't exist.""" - os.remove(os.path.join(self._path, self._lookup(key))) + (self._path / self._lookup(key)).unlink() def discard(self, key): """If the keyed message exists, remove it.""" @@ -355,25 +358,25 @@ def __setitem__(self, key, message): # temp's subdir and suffix were defaults from add(). dominant_subpath = old_subpath subdir = os.path.dirname(dominant_subpath) - if self.colon in dominant_subpath: - suffix = self.colon + dominant_subpath.split(self.colon)[-1] + if self.colon in os.fspath(dominant_subpath): + suffix = self.colon + os.fspath(dominant_subpath).split(self.colon)[-1] else: suffix = '' self.discard(key) - tmp_path = os.path.join(self._path, temp_subpath) - new_path = os.path.join(self._path, subdir, key + suffix) + tmp_path = self._path / temp_subpath + new_path = self._path / subdir / (key + suffix) if isinstance(message, MaildirMessage): os.utime(tmp_path, - (os.path.getatime(tmp_path), message.get_date())) + (tmp_path.stat().st_atime, message.get_date())) # No file modification should be done after the file is moved to its # final position in order to prevent race conditions with changes # from other programs - os.rename(tmp_path, new_path) + tmp_path.rename(new_path) def get_message(self, key): """Return a Message representation or raise a KeyError.""" subpath = self._lookup(key) - with open(os.path.join(self._path, subpath), 'rb') as f: + with open(self._path / subpath, 'rb') as f: if self._factory: msg = self._factory(f) else: @@ -382,17 +385,17 @@ def get_message(self, key): msg.set_subdir(subdir) if self.colon in name: msg.set_info(name.split(self.colon)[-1]) - msg.set_date(os.path.getmtime(os.path.join(self._path, subpath))) + msg.set_date((self._path / subpath).stat().st_mtime) return msg def get_bytes(self, key): """Return a bytes representation or raise a KeyError.""" - with open(os.path.join(self._path, self._lookup(key)), 'rb') as f: + with open(self._path / self._lookup(key), 'rb') as f: return f.read().replace(linesep, b'\n') def get_file(self, key): """Return a file-like representation or raise a KeyError.""" - f = open(os.path.join(self._path, self._lookup(key)), 'rb') + f = open(self._path / self._lookup(key), 'rb') return _ProxyFile(f) def iterkeys(self): @@ -436,54 +439,49 @@ def close(self): def list_folders(self): """Return a list of folder names.""" result = [] - for entry in os.listdir(self._path): - if len(entry) > 1 and entry[0] == '.' and \ - os.path.isdir(os.path.join(self._path, entry)): - result.append(entry[1:]) + for entry in self._path.iterdir(): + if len(entry.name) > 1 and entry.name[0] == '.' and entry.is_dir(): + result.append(entry.name[1:]) return result def get_folder(self, folder): """Return a Maildir instance for the named folder.""" - return Maildir(os.path.join(self._path, '.' + folder), - factory=self._factory, - create=False) + return Maildir(self._path / ('.' + folder), + factory=self._factory, create=False) def add_folder(self, folder): """Create a folder and return a Maildir instance representing it.""" - path = os.path.join(self._path, '.' + folder) + path = self._path / ('.' + folder) result = Maildir(path, factory=self._factory) - maildirfolder_path = os.path.join(path, 'maildirfolder') - if not os.path.exists(maildirfolder_path): - os.close(os.open(maildirfolder_path, os.O_CREAT | os.O_WRONLY, - 0o666)) + maildirfolder_path = path / 'maildirfolder' + if not maildirfolder_path.exists(): + os.close(os.open(maildirfolder_path, os.O_CREAT | os.O_WRONLY, 0o666)) return result def remove_folder(self, folder): """Delete the named folder, which must be empty.""" - path = os.path.join(self._path, '.' + folder) - for entry in os.listdir(os.path.join(path, 'new')) + \ - os.listdir(os.path.join(path, 'cur')): - if len(entry) < 1 or entry[0] != '.': + path = self._path / ('.' + folder) + for entry in itertools.chain((path / 'new').iterdir(), (path / 'cur').iterdir()): + if len(entry.name) < 1 or entry.name[0] != '.': raise NotEmptyError('Folder contains message(s): %s' % folder) - for entry in os.listdir(path): - if entry != 'new' and entry != 'cur' and entry != 'tmp' and \ - os.path.isdir(os.path.join(path, entry)): + for entry in path.iterdir(): + if entry.name not in ('new', 'cur', 'tmp') and entry.is_dir(): raise NotEmptyError("Folder contains subdirectory '%s': %s" % - (folder, entry)) + (folder, entry.name)) for root, dirs, files in os.walk(path, topdown=False): for entry in files: - os.remove(os.path.join(root, entry)) + Path(root, entry).unlink() for entry in dirs: - os.rmdir(os.path.join(root, entry)) - os.rmdir(path) + Path(root, entry).rmdir() + path.rmdir() def clean(self): """Delete old files in "tmp".""" now = time.time() - for entry in os.listdir(os.path.join(self._path, 'tmp')): - path = os.path.join(self._path, 'tmp', entry) - if now - os.path.getatime(path) > 129600: # 60 * 60 * 36 - os.remove(path) + for entry in (self._path / 'tmp').iterdir(): + path = self._path / 'tmp' / entry + if now - path.stat().st_atime > 129600: # 60 * 60 * 36 + path.unlink() _count = 1 # This is used to generate unique file names. @@ -497,9 +495,9 @@ def _create_tmp(self): hostname = hostname.replace(':', r'\072') uniq = "%s.M%sP%sQ%s.%s" % (int(now), int(now % 1 * 1e6), os.getpid(), Maildir._count, hostname) - path = os.path.join(self._path, 'tmp', uniq) + path = self._path / 'tmp' / uniq try: - os.stat(path) + path.stat() except FileNotFoundError: Maildir._count += 1 try: @@ -529,7 +527,7 @@ def _refresh(self): if time.time() - self._last_read > 2 + self._skewfactor: refresh = False for subdir in self._toc_mtimes: - mtime = os.path.getmtime(self._paths[subdir]) + mtime = self._paths[subdir].stat().st_mtime if mtime > self._toc_mtimes[subdir]: refresh = True self._toc_mtimes[subdir] = mtime @@ -540,17 +538,17 @@ def _refresh(self): for subdir in self._toc_mtimes: path = self._paths[subdir] for entry in os.listdir(path): - p = os.path.join(path, entry) - if os.path.isdir(p): + p = path / entry + if p.is_dir(): continue uniq = entry.split(self.colon)[0] - self._toc[uniq] = os.path.join(subdir, entry) + self._toc[uniq] = Path(subdir) / Path(entry) self._last_read = time.time() def _lookup(self, key): - """Use TOC to return subpath for given key, or raise a KeyError.""" + """Use TOC to return subpath for a given key, or raise a KeyError.""" try: - if os.path.exists(os.path.join(self._path, self._toc[key])): + if (self._path / self._toc[key]).exists(): return self._toc[key] except KeyError: pass @@ -693,19 +691,20 @@ def flush(self): self._file_length = new_file.tell() except: new_file.close() - os.remove(new_file.name) + Path(new_file.name).unlink() raise _sync_close(new_file) # self._file is about to get replaced, so no need to sync. self._file.close() # Make sure the new file's mode is the same as the old file's mode = os.stat(self._path).st_mode - os.chmod(new_file.name, mode) + new_path = Path(new_file.name) + new_path.chmod(mode) try: - os.rename(new_file.name, self._path) + new_path.rename(self._path) except FileExistsError: - os.remove(self._path) - os.rename(new_file.name, self._path) + self._path.unlink() + new_path.rename(self._path) self._file = open(self._path, 'rb+') self._toc = new_toc self._pending = False @@ -768,7 +767,6 @@ def _append_message(self, message): return offsets - class _mboxMMDF(_singlefileMailbox): """An mbox or MMDF mailbox.""" @@ -832,7 +830,7 @@ def _install_message(self, message): self._file.write(from_line + linesep) self._dump_message(message, self._file, self._mangle_from_) stop = self._file.tell() - return (start, stop) + return start, stop class mbox(_mboxMMDF): @@ -938,13 +936,13 @@ class MH(Mailbox): def __init__(self, path, factory=None, create=True): """Initialize an MH instance.""" Mailbox.__init__(self, path, factory, create) - if not os.path.exists(self._path): + if not self._path.exists(): if create: - os.mkdir(self._path, 0o700) - os.close(os.open(os.path.join(self._path, '.mh_sequences'), + self._path.mkdir(mode=0o700) + os.close(os.open(self._path / '.mh_sequences', os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)) else: - raise NoSuchMailboxError(self._path) + raise NoSuchMailboxError(os.fspath(self._path)) self._locked = False def add(self, message): @@ -954,7 +952,7 @@ def add(self, message): new_key = 1 else: new_key = max(keys) + 1 - new_path = os.path.join(self._path, str(new_key)) + new_path = self._path / str(new_key) f = _create_carefully(new_path) closed = False try: @@ -969,7 +967,7 @@ def add(self, message): _unlock_file(f) _sync_close(f) closed = True - os.remove(new_path) + new_path.unlink() raise if isinstance(message, MHMessage): self._dump_sequences(message, new_key) @@ -983,7 +981,7 @@ def add(self, message): def remove(self, key): """Remove the keyed message; raise KeyError if it doesn't exist.""" - path = os.path.join(self._path, str(key)) + path = self._path / str(key) try: f = open(path, 'rb+') except OSError as e: @@ -993,11 +991,11 @@ def remove(self, key): raise else: f.close() - os.remove(path) + path.unlink() def __setitem__(self, key, message): """Replace the keyed message; raise KeyError if it doesn't exist.""" - path = os.path.join(self._path, str(key)) + path = self._path / str(key) try: f = open(path, 'rb+') except OSError as e: @@ -1023,9 +1021,9 @@ def get_message(self, key): """Return a Message representation or raise a KeyError.""" try: if self._locked: - f = open(os.path.join(self._path, str(key)), 'rb+') + f = open(self._path / str(key), 'rb+') else: - f = open(os.path.join(self._path, str(key)), 'rb') + f = open(self._path / str(key), 'rb') except OSError as e: if e.errno == errno.ENOENT: raise KeyError('No message with key: %s' % key) @@ -1048,9 +1046,9 @@ def get_bytes(self, key): """Return a bytes representation or raise a KeyError.""" try: if self._locked: - f = open(os.path.join(self._path, str(key)), 'rb+') + f = open(self._path / str(key), 'rb+') else: - f = open(os.path.join(self._path, str(key)), 'rb') + f = open(self._path / str(key), 'rb') except OSError as e: if e.errno == errno.ENOENT: raise KeyError('No message with key: %s' % key) @@ -1068,7 +1066,7 @@ def get_bytes(self, key): def get_file(self, key): """Return a file-like representation or raise a KeyError.""" try: - f = open(os.path.join(self._path, str(key)), 'rb') + f = open(self._path / str(key), 'rb') except OSError as e: if e.errno == errno.ENOENT: raise KeyError('No message with key: %s' % key) @@ -1078,12 +1076,12 @@ def get_file(self, key): def iterkeys(self): """Return an iterator over keys.""" - return iter(sorted(int(entry) for entry in os.listdir(self._path) - if entry.isdigit())) + return iter(sorted(int(entry.name) for entry in self._path.iterdir() + if entry.name.isdigit())) def __contains__(self, key): """Return True if the keyed message exists, False otherwise.""" - return os.path.exists(os.path.join(self._path, str(key))) + return (self._path / str(key)).exists() def __len__(self): """Return a count of messages in the mailbox.""" @@ -1092,7 +1090,7 @@ def __len__(self): def lock(self): """Lock the mailbox.""" if not self._locked: - self._file = open(os.path.join(self._path, '.mh_sequences'), 'rb+') + self._file = open(self._path / '.mh_sequences', 'rb+') _lock_file(self._file) self._locked = True @@ -1116,37 +1114,37 @@ def close(self): def list_folders(self): """Return a list of folder names.""" result = [] - for entry in os.listdir(self._path): - if os.path.isdir(os.path.join(self._path, entry)): - result.append(entry) + for entry in self._path.iterdir(): + if entry.is_dir(): + result.append(entry.resolve().name) return result def get_folder(self, folder): """Return an MH instance for the named folder.""" - return MH(os.path.join(self._path, folder), + return MH(self._path / folder, factory=self._factory, create=False) def add_folder(self, folder): """Create a folder and return an MH instance representing it.""" - return MH(os.path.join(self._path, folder), + return MH(self._path / folder, factory=self._factory) def remove_folder(self, folder): """Delete the named folder, which must be empty.""" - path = os.path.join(self._path, folder) - entries = os.listdir(path) - if entries == ['.mh_sequences']: - os.remove(os.path.join(path, '.mh_sequences')) + path = self._path / folder + entries = list(path.iterdir()) + if entries == [Path(path, '.mh_sequences').resolve()]: + (path / '.mh_sequences').unlink() elif entries == []: pass else: - raise NotEmptyError('Folder not empty: %s' % self._path) - os.rmdir(path) + raise NotEmptyError('Folder not empty: %s' % os.fspath(self._path)) + path.rmdir() def get_sequences(self): """Return a name-to-key-list dictionary to define each sequence.""" results = {} - with open(os.path.join(self._path, '.mh_sequences'), 'r', encoding='ASCII') as f: + with open(self._path / '.mh_sequences', 'r', encoding='ASCII') as f: all_keys = set(self.keys()) for line in f: try: @@ -1169,7 +1167,7 @@ def get_sequences(self): def set_sequences(self, sequences): """Set sequences using the given name-to-key-list dictionary.""" - f = open(os.path.join(self._path, '.mh_sequences'), 'r+', encoding='ASCII') + f = open(self._path / '.mh_sequences', 'r+', encoding='ASCII') try: os.close(os.open(f.name, os.O_WRONLY | os.O_TRUNC)) for name, keys in sequences.items(): @@ -1205,13 +1203,11 @@ def pack(self): if key - 1 != prev: changes.append((key, prev + 1)) try: - os.link(os.path.join(self._path, str(key)), - os.path.join(self._path, str(prev + 1))) + (self._path / str(key)).link_to(self._path / str(prev + 1)) except (AttributeError, PermissionError): - os.rename(os.path.join(self._path, str(key)), - os.path.join(self._path, str(prev + 1))) + (self._path / str(key)).rename(self._path / str(prev + 1)) else: - os.unlink(os.path.join(self._path, str(key))) + (self._path / str(key)).unlink() prev += 1 self._next_key = prev + 1 if len(changes) == 0: @@ -1426,7 +1422,7 @@ def _install_message(self, message): if line == b'\n' or not line: break while True: - buffer = orig_buffer.read(4096) # Buffer size is arbitrary. + buffer = orig_buffer.read(4096) # Buffer size is arbitrary. if not buffer: break self._file.write(buffer.replace(b'\n', linesep)) @@ -2084,30 +2080,31 @@ def _lock_file(f, dotlock=True): raise try: try: - os.link(pre_lock.name, f.name + '.lock') + pre_path = Path(pre_lock.name) + pre_path.link_to(f.name + '.lock') dotlock_done = True except (AttributeError, PermissionError): - os.rename(pre_lock.name, f.name + '.lock') + pre_path.rename(f.name + '.lock') dotlock_done = True else: - os.unlink(pre_lock.name) + pre_path.unlink() except FileExistsError: - os.remove(pre_lock.name) + pre_path.unlink() raise ExternalClashError('dot lock unavailable: %s' % f.name) except: if fcntl: fcntl.lockf(f, fcntl.LOCK_UN) if dotlock_done: - os.remove(f.name + '.lock') + Path(f.name + '.lock').unlink() raise def _unlock_file(f): """Unlock file f using lockf and dot locking.""" if fcntl: fcntl.lockf(f, fcntl.LOCK_UN) - if os.path.exists(f.name + '.lock'): - os.remove(f.name + '.lock') + if Path(f.name + '.lock').exists(): + Path(f.name + '.lock').unlink() def _create_carefully(path): """Create a file if it doesn't exist and open for reading and writing.""" diff --git a/Lib/test/test_mailbox.py b/Lib/test/test_mailbox.py index fdda1d11d3307ef..c1f7bf4b6662c90 100644 --- a/Lib/test/test_mailbox.py +++ b/Lib/test/test_mailbox.py @@ -13,7 +13,7 @@ import textwrap import mailbox import glob - +from pathlib import Path class TestBase: @@ -674,7 +674,7 @@ def test_initialize_existing(self): def _check_basics(self, factory=None): # (Used by test_open_new() and test_open_existing().) - self.assertEqual(self._box._path, os.path.abspath(self._path)) + self.assertEqual(self._box._path, Path(self._path).resolve()) self.assertEqual(self._box._factory, factory) for subdir in '', 'tmp', 'new', 'cur': path = os.path.join(self._path, subdir) @@ -791,15 +791,15 @@ def test_refresh(self): key1 = self._box.add(self._template % 1) self.assertEqual(self._box._toc, {}) self._box._refresh() - self.assertEqual(self._box._toc, {key0: os.path.join('new', key0), - key1: os.path.join('new', key1)}) + self.assertEqual(self._box._toc, {key0: Path('new', key0), + key1: Path('new', key1)}) key2 = self._box.add(self._template % 2) - self.assertEqual(self._box._toc, {key0: os.path.join('new', key0), - key1: os.path.join('new', key1)}) + self.assertEqual(self._box._toc, {key0: Path('new', key0), + key1: Path('new', key1)}) self._box._refresh() - self.assertEqual(self._box._toc, {key0: os.path.join('new', key0), - key1: os.path.join('new', key1), - key2: os.path.join('new', key2)}) + self.assertEqual(self._box._toc, {key0: Path('new', key0), + key1: Path('new', key1), + key2: Path('new', key2)}) def test_refresh_after_safety_period(self): # Issue #13254: Call _refresh after the "file system safety @@ -824,9 +824,9 @@ def test_lookup(self): # Look up message subpaths in the TOC self.assertRaises(KeyError, lambda: self._box._lookup('foo')) key0 = self._box.add(self._template % 0) - self.assertEqual(self._box._lookup(key0), os.path.join('new', key0)) + self.assertEqual(self._box._lookup(key0), Path('new', key0)) os.remove(os.path.join(self._path, 'new', key0)) - self.assertEqual(self._box._toc, {key0: os.path.join('new', key0)}) + self.assertEqual(self._box._toc, {key0: Path('new', key0)}) # Be sure that the TOC is read back from disk (see issue #6896 # about bad mtime behaviour on some systems). self._box.flush() diff --git a/Misc/NEWS.d/next/Library/2020-06-19-10-16-57.bpo-41026.hEXB-B.rst b/Misc/NEWS.d/next/Library/2020-06-19-10-16-57.bpo-41026.hEXB-B.rst new file mode 100644 index 000000000000000..5fbb62f847b09c5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2020-06-19-10-16-57.bpo-41026.hEXB-B.rst @@ -0,0 +1 @@ +:mod:`mailbox` now supports a :term:`path-like object`. \ No newline at end of file