Xqt has submitted this change. (
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1300091?usp=email )
Change subject: Introduce SevenZipFile class for open_archive()
......................................................................
Introduce SevenZipFile class for open_archive()
Replace the raw stdout pipe returned for 7z archives with a
RawIOBase-derived wrapper that encapsulates the underlying
subprocess, ensures proper cleanup when closing the stream,
and supports rewinding the stream.
Bug: T428322
Change-Id: I3debd03bba908103d54e8326f235f6e1913ce287
---
M pywikibot/config.py
M pywikibot/tools/__init__.py
M tests/tools_tests.py
3 files changed, 275 insertions(+), 31 deletions(-)
Approvals:
Xqt: Verified; Looks good to me, approved
diff --git a/pywikibot/config.py b/pywikibot/config.py
index 743e446..c44375e 100644
--- a/pywikibot/config.py
+++ b/pywikibot/config.py
@@ -36,6 +36,8 @@
.. version-changed:: 8.0
Editor settings has been revised. *editor* variable is None by
default. Editor detection functions were moved to :mod:`editor`.
+.. version-added:: 11.4
+ The 7-zip executable variable *cmd_7zip* was added.
"""
from __future__ import annotations
@@ -867,6 +869,14 @@
# Version 5 was added with Python 3.8. It is the default since Pywikibot 11.
pickle_protocol = 5
+# 7-zip executable setting
+# The default for 7-zip executable is '7za' but my also be '7Z'. If The
+# executable is not included in the local PATH environment variable the
+# relative or absolute path can also be given with this cmd line setting
+# like:
+# cmd_7zip = r'C:\Program Files\7-Zip\7z.exe'
+cmd_7zip = '7za'
+
# ============================
# End of configuration section
# ============================
diff --git a/pywikibot/tools/__init__.py b/pywikibot/tools/__init__.py
index 5637d18..940cf96 100644
--- a/pywikibot/tools/__init__.py
+++ b/pywikibot/tools/__init__.py
@@ -9,9 +9,11 @@
import abc
import hashlib
import importlib.metadata
+import io
import ipaddress
import os
import re
+import shutil
import stat
import subprocess
import sys
@@ -19,7 +21,7 @@
from contextlib import suppress
from functools import total_ordering, wraps
from types import TracebackType
-from typing import Any
+from typing import IO, Any, Literal
from warnings import catch_warnings, showwarning, warn
import packaging.version
@@ -81,6 +83,7 @@
'strtobool',
'normalize_username',
'MediaWikiVersion',
+ 'SevenZipFile',
'open_archive',
'merge_unique_dicts',
'file_mode_checker',
@@ -540,7 +543,194 @@
return self._dev_version < other._dev_version
-def open_archive(filename: str, mode: str = 'rb', use_extension: bool = True):
+class SevenZipFile(io.RawIOBase):
+
+ """Read-only file-like wrapper around a 7za/7z subprocess.
+
+ This wrapper waits for the 7-Zip process to terminate when closing.
+ It inherits from RawIOBase and implements its low-level access
+ design. It also provides an interface similar to :class:`io.FileIO`.
+
+ This class is used by :func:`open_archive` but can also be used
+ standalone to open 7zip archives:
+
+ >>> zf = SevenZipFile('tests/data/xml/article-pyrus.xml.7z')
+ ... # doctest: +SKIP
+ >>> content = zf.readline() # doctest: +SKIP
+ >>> content[:43] # doctest: +SKIP
+ b'<mediawiki xmlns="https://www.mediawiki.org'
+ >>> zf.read(43) # doctest: +SKIP
+ b'<mediawiki xmlns="https://www.mediawiki.org'
+ >>> zf.write(b'') # doctest: +SKIP
+ Traceback (most recent call last):
+ ...
+ io.UnsupportedOperation: File or stream is not writable.
+ >>> zf.close() # doctest: +SKIP
+ >>> zf.readlines() # doctest: +SKIP
+ Traceback (most recent call last):
+ ...
+ ValueError: I/O operation on closed file.
+
+ You can use this class as context manager too:
+
+ .. code::
+
+ with SevenZipFile('tests/data/xml/article-pyrus.xml.7z') as zf:
+ content = zf.readall()
+
+ This is equal to:
+
+ .. code::
+
+ with open_archive('tests/data/xml/article-pyrus.xml.7z') as zf:
+ content = zf.readall()
+
+ but it works for 7z-files only.
+
+ The 7-Zip executable is taken from :attr:`pywikibot.config.cmd_7zip`,
+ which defaults to ``'7za'``. If the executable is not available via
+ the local ``PATH`` environment variable, a relative or absolute path
+ may be given there.
+
+ .. version-added:: 11.4
+ """
+
+ def __init__(self, name: str, /) -> None:
+ """Initializer."""
+ self._name = name
+ self._process = None
+ self._stream = None
+ self._open_process()
+
+ def __repr__(self) -> str:
+ """Representation string."""
+ module_name = type(self).__module__.removeprefix('pywikibot.')
+ class_name = f'{module_name}.{type(self).__qualname__}'
+ if self.closed:
+ return f'{class_name}[closed]'
+ return f'{class_name}({self.name!r})'
+
+ @property
+ def name(self) -> str:
+ """The file name passed to initializer."""
+ return self._name
+
+ @property
+ def mode(self) -> str:
+ """The file mode, always 'rb'."""
+ return 'rb'
+
+ def readable(self) -> Literal[True]:
+ """Return True if the stream can be read from."""
+ return True
+
+ def readinto(self, b) -> int | None:
+ """Read bytes into a pre-allocated bytes-like object *b*.
+
+ Returns an int representing the number of bytes read (0 for EOF),
+ or None if the object is set not to block and has no data to
+ read. This is the :class:`io.RawIOBase` implementation of the
+ abstract method.
+
+ :param b: Writable buffer to fill with data.
+ :return: Number of bytes read, 0 for end of file, or None if the
+ object is in non-blocking mode and no data is available.
+ :raises ValueError: I/O operation on closed file.
+ """
+ self._checkClosed()
+ self._checkReadable()
+ return self._stream.readinto(b)
+
+ def write(self, b) -> int:
+ """Write the given buffer *b* to the IO stream.
+
+ Returns the number of bytes written, which may be less than the
+ length of *b* in bytes. This is the :class:`io.RawIOBase`
+ implementation of the abstract method.
+
+ :param b: Bytes to write.
+ :raises ValueError: I/O operation on closed file.
+ :raises io.UnsupportedOperation: The stream is not writable.
+ """
+ self._checkClosed()
+ self._checkWritable()
+
+ def close(self) -> None:
+ """Close the file-like wrapper.
+
+ A closed IO object cannot be used for further I/O operations.
+ :meth:`close` may be called more than once without error.
+ """
+ if self.closed:
+ return
+
+ self._close_process()
+ super().close()
+
+ def _open_process(self) -> None:
+ """Start the 7-Zip process and initialize the stream.
+
+ :raises FileNotFoundError: The archive file or the 7-Zip executable
+ was not found.
+ :raises OSError: 7-Zip returned an error while opening the archive.
+ """
+ if not os.path.exists(self.name):
+ raise FileNotFoundError(f'Compressed file {self.name!r} not found')
+
+ cmd = shutil.which(pywikibot.config.cmd_7zip)
+ if cmd is None:
+ raise FileNotFoundError(
+ '7-Zip executable not found (not installed or not in PATH)')
+
+ self._process = subprocess.Popen(
+ [cmd, 'e', '-bd', '-so', self.name],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ bufsize=65535
+ )
+
+ stderr = self._process.stderr.read()
+ self._process.stderr.close()
+
+ if stderr:
+ self._close_process()
+ raise OSError(
+ f'Unexpected STDERR output from {cmd}:\n{stderr}')
+
+ self._stream = self._process.stdout
+
+ def _close_process(self) -> None:
+ """Close the stream and wait for the 7-Zip process to terminate."""
+ if self._stream is not None:
+ self._stream.close()
+
+ if self._process is not None:
+ self._process.wait()
+
+ self._stream = None
+ self._process = None
+
+ def rewind(self) -> None:
+ """Rewind the stream to the beginning.
+
+ This method closes the current stream, waits for the associated
+ 7-Zip process to terminate and starts a new process to read the
+ archive again from the beginning.
+
+ :raises ValueError: I/O operation on closed file.
+ :raises FileNotFoundError: The archive file or the 7-Zip executable
+ was not found.
+ :raises OSError: 7-Zip returned an error while reopening the archive.
+ """
+ self._checkClosed()
+ self._close_process()
+ self._open_process()
+
+
+@deprecated_signature(since='11.4.0')
+def open_archive(filename: str, /,
+ mode: str = 'rb', *,
+ use_extension: bool = True) -> IO[bytes]:
"""Open a file and uncompress it if needed.
This function supports bzip2, gzip, 7zip, lzma, and xz as
@@ -553,6 +743,9 @@
ending.
.. version-added:: 3.0
+ .. version-changed:: 11.4
+ *filename* parameter is positional only, *use_extension* is
+ keyword only. Uses :class:`SevenZipFile` to open 7zip-files.
:param filename: The filename.
:param mode: The mode in which the file should be opened. It may
@@ -570,11 +763,11 @@
is 7z. It is also raised by bz2 when its content is invalid.
gzip does not immediately raise that error but only on reading
it.
+ :raises NotImplementedError: When trying to write a 7z file.
:raises lzma.LZMAError: When error occurs during compression or
decompression or when initializing the state with lzma or xz.
:return: A file-like object returning the uncompressed data in
binary mode.
- :rtype: file-like object
"""
# extension_map maps magic_number to extension.
# Unfortunately, legacy LZMA container has no magic number
@@ -616,23 +809,7 @@
elif extension == '7z':
if mode != 'rb':
raise NotImplementedError('It is not possible to write a 7z file.')
-
- try:
- process = subprocess.Popen(['7za', 'e', '-bd', '-so', filename],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- bufsize=65535)
- except OSError:
- raise ValueError(
- f'7za is not installed or cannot uncompress "{filename}"')
-
- stderr = process.stderr.read()
- process.stderr.close()
- if stderr != b'':
- process.stdout.close()
- raise OSError(
- f'Unexpected STDERR output from 7za {stderr}')
- binary = process.stdout
+ binary = SevenZipFile(filename)
elif extension in ('lzma', 'xz'):
lzma_fmts = {'lzma': lzma.FORMAT_ALONE, 'xz': lzma.FORMAT_XZ}
diff --git a/tests/tools_tests.py b/tests/tools_tests.py
index 7ed8bb5..4b39979 100755
--- a/tests/tools_tests.py
+++ b/tests/tools_tests.py
@@ -8,8 +8,8 @@
import decimal
import hashlib
+import io
import os
-import subprocess
import tempfile
import unittest
from collections import Counter, OrderedDict
@@ -20,6 +20,7 @@
from pywikibot import config, tools
from pywikibot.tools import (
+ SevenZipFile,
cached,
classproperty,
has_module,
@@ -83,17 +84,73 @@
self._get_content(self.base_file + '.gz'), self.original_content)
def test_open_archive_7z(self) -> None:
- """Test open_archive with 7za if installed."""
- with skipping(OSError, msg='7za not installed'):
- subprocess.Popen(['7za'], stdout=subprocess.PIPE).stdout.close()
-
- self.assertEqual(
- self._get_content(self.base_file + '.7z'), self.original_content)
+ """Test open_archive with 7z if installed."""
+ with skipping(FileNotFoundError):
+ self.assertEqual(
+ self._get_content(self.base_file + '.7z'),
+ self.original_content
+ )
with self.assertRaisesRegex(
OSError,
- 'Unexpected STDERR output from 7za '):
- self._get_content(self.base_file + '_invalid.7z',
- use_extension=True)
+ 'Unexpected STDERR output from .*7z'):
+ self._get_content(self.base_file + '_invalid.7z')
+
+ def test_7_zip_file(self) -> None:
+ """Test SevenZipFile class."""
+ filename = self.base_file + '.7z'
+ with skipping(FileNotFoundError), tools.open_archive(filename) as zf:
+ self.assertIsInstance(zf, SevenZipFile)
+ self.assertIsSubclass(type(zf), io.RawIOBase)
+ self.assertEqual(zf.name, filename)
+ self.assertEqual(zf.mode, 'rb')
+ self.assertFalse(zf.closed)
+ self.assertTrue(zf.readable())
+ self.assertEqual(repr(zf), f'tools.SevenZipFile({filename!r})')
+
+ for method_name in ('isatty', 'seekable', 'writable'):
+ with self.subTest(method=method_name):
+ method = getattr(zf, method_name)
+ self.assertFalse(method())
+
+ for method_name in ('fileno', 'tell', 'truncate'):
+ with (
+ self.subTest(method=method_name),
+ self.assertRaises(io.UnsupportedOperation)
+ ):
+ method = getattr(zf, method_name)
+ method()
+
+ with self.assertRaises(io.UnsupportedOperation):
+ zf.seek(0)
+
+ with self.assertRaises(io.UnsupportedOperation):
+ zf.write(b'RawIOBase.write method is not implemented')
+ with self.assertRaises(io.UnsupportedOperation):
+ zf.writelines([b'foo\n', b'bar\n'])
+
+ self.assertEqual(zf.read(100), self.original_content[:100])
+ zf.rewind()
+ buffer = zf.read(-1)
+ zf.rewind()
+ self.assertEqual(buffer, zf.readall())
+ zf.rewind()
+
+ lines = self.original_content.splitlines(keepends=True)
+ self.assertEqual(zf.readline(), lines[0])
+ zf.rewind()
+ self.assertEqual(zf.readlines(), lines)
+
+ self.assertTrue(zf.closed)
+ self.assertIsNone(zf._stream)
+ self.assertIsNone(zf._process)
+ self.assertEqual(repr(zf), 'tools.SevenZipFile[closed]')
+ with self.assertRaisesRegex(ValueError,
+ r'I/O operation on closed file\.'):
+ zf.read(100)
+ with self.assertRaisesRegex(ValueError,
+ r'I/O operation on closed file\.'):
+ zf.rewind()
+ zf.close() # test that nothing happens here
def test_open_archive_lzma(self) -> None:
"""Test open_archive with lzma compressor in the standard library."""
@@ -153,7 +210,7 @@
ValueError,
'Magic number detection only when reading'):
tools.open_archive('/dev/null', # writing without extension
- 'wb', False)
+ 'wb', use_extension=False)
def test_binary_mode(self) -> None:
"""Test that it uses binary mode."""
--
To view, visit
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1300091?usp=email
To unsubscribe, or for help writing mail filters, visit
https://gerrit.wikimedia.org/r/settings?usp=email
Gerrit-MessageType: merged
Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Change-Id: I3debd03bba908103d54e8326f235f6e1913ce287
Gerrit-Change-Number: 1300091
Gerrit-PatchSet: 4
Gerrit-Owner: Xqt <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]