[med-svn] [Git][med-team/python-xopen][master] 3 commits: New upstream version 0.9.0
Nilesh Patra
gitlab at salsa.debian.org
Wed Jul 22 21:09:40 BST 2020
Nilesh Patra pushed to branch master at Debian Med / python-xopen
Commits:
a2294648 by Nilesh Patra at 2020-07-23T01:30:36+05:30
New upstream version 0.9.0
- - - - -
b8d8296a by Nilesh Patra at 2020-07-23T01:30:36+05:30
Update upstream source from tag 'upstream/0.9.0'
Update to upstream version '0.9.0'
with Debian dir 34a66173a86c7f159d2dafc8f244c6008aeaf6b7
- - - - -
51dcafd5 by Nilesh Patra at 2020-07-23T01:37:46+05:30
Add autopkgtests
- - - - -
18 changed files:
- + .codecov.yml
- .travis.yml
- PKG-INFO
- README.rst
- + debian/tests/control
- + debian/tests/run-unit-test
- setup.cfg
- setup.py
- src/xopen.egg-info/PKG-INFO
- src/xopen.egg-info/SOURCES.txt
- src/xopen.egg-info/requires.txt
- src/xopen/__init__.py
- src/xopen/_version.py
- + tests/file.txt.bz2.test
- + tests/file.txt.gz.test
- + tests/file.txt.xz.test
- tests/test_xopen.py
- tox.ini
Changes:
=====================================
.codecov.yml
=====================================
@@ -0,0 +1,16 @@
+comment: off
+
+codecov:
+ require_ci_to_pass: no
+
+coverage:
+ precision: 1
+ round: down
+ range: "70...100"
+
+ status:
+ project: yes
+ patch: no
+ changes: no
+
+comment: off
=====================================
.travis.yml
=====================================
@@ -7,20 +7,24 @@ cache:
- $HOME/.cache/pip
python:
- - "2.7"
- - "3.4"
- "3.5"
- "3.6"
- "3.7"
- - "3.8-dev"
+ - "3.8"
+ - "pypy3"
install:
+ - sudo apt-get update && sudo apt-get install -y pigz
+ - pip install --upgrade coverage codecov
- pip install .
script:
- - sudo apt-get update && sudo apt-get install -y pigz
- python setup.py --version # Detect encoding problems
- - python -m pytest
+ - coverage run -m pytest
+
+after_success:
+ - coverage combine
+ - codecov
env:
global:
@@ -37,8 +41,11 @@ jobs:
script:
- |
python3 setup.py sdist
+ python3 -m pip wheel -w dist/ .
ls -l dist/
- python3 -m twine upload dist/*
+ python3 -m twine upload dist/xopen-*
- allowed_failures:
- - python: "3.8-dev"
\ No newline at end of file
+ - name: flake8
+ python: "3.6"
+ install: python3 -m pip install flake8
+ script: flake8 src/ tests/
=====================================
PKG-INFO
=====================================
@@ -1,16 +1,25 @@
Metadata-Version: 2.1
Name: xopen
-Version: 0.7.3
+Version: 0.9.0
Summary: Open compressed files transparently
Home-page: https://github.com/marcelm/xopen/
Author: Marcel Martin
Author-email: mail at marcelm.net
License: MIT
Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
- :target: https://travis-ci.org/marcelm/xopen
-
+ :target: https://travis-ci.org/marcelm/xopen
+ :alt:
+
.. image:: https://img.shields.io/pypi/v/xopen.svg?branch=master
- :target: https://pypi.python.org/pypi/xopen
+ :target: https://pypi.python.org/pypi/xopen
+
+ .. image:: https://img.shields.io/conda/v/conda-forge/xopen.svg
+ :target: https://anaconda.org/conda-forge/xopen
+ :alt:
+
+ .. image:: https://codecov.io/gh/marcelm/xopen/branch/master/graph/badge.svg
+ :target: https://codecov.io/gh/marcelm/xopen
+ :alt:
=====
xopen
@@ -28,12 +37,12 @@ Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
when reading ``.gz`` files, so it is used both for reading and writing if it is
available.
- This module has originally been developed as part of the `cutadapt
+ This module has originally been developed as part of the `Cutadapt
tool <https://cutadapt.readthedocs.io/>`_ that is used in bioinformatics to
manipulate sequencing data. It has been in successful use within that software
for a few years.
- ``xopen`` is compatible with Python versions 2.7 and 3.4 to 3.7.
+ ``xopen`` is compatible with Python versions 3.5 to 3.8.
Usage
@@ -75,6 +84,9 @@ Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
Ruben Vorderman <https://github.com/rhpvorderman/> contributed improvements to
make reading gzipped files faster.
+ Benjamin Vaisvil <https://github.com/bvaisvil> contributed support for
+ format detection from content.
+
Some ideas were taken from the `canopener project <https://github.com/selassid/canopener>`_.
If you also want to open S3 files, you may want to use that module instead.
@@ -82,11 +94,40 @@ Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
Changes
-------
+ v0.9.0
+ ~~~~~~
+
+ * When the file name extension of a file to be opened for reading is not
+ available, the content is inspected (if possible) and used to determine
+ which compression format applies.
+ * This release drops Python 2.7 and 3.4 support. Python 3.5 or later is
+ now required.
+
+ v0.8.4
+ ~~~~~~
+ * When reading gzipped files, force ``pigz`` to use only a single process.
+ ``pigz`` cannot use multiple cores anyway when decompressing. By default,
+ it would use extra I/O processes, which slightly reduces wall-clock time,
+ but increases CPU time. Single-core decompression with ``pigz`` is still
+ about twice as fast as regular ``gzip``.
+ * Allow ``threads=0`` for specifying that no external ``pigz``/``gzip``
+ process should be used (then regular ``gzip.open()`` is used instead).
+
+ v0.8.3
+ ~~~~~~
+ * When reading gzipped files, let ``pigz`` use at most four threads by default.
+ This limit previously only applied when writing to a file.
+ * Support Python 3.8
+
+ v0.8.0
+ ~~~~~~
+ * Speed improvements when iterating over gzipped files.
+
v0.6.0
~~~~~~
* For reading from gzipped files, xopen will now use a ``pigz`` subprocess.
This is faster than using ``gzip.open``.
- * Python 2 supported will be dropped in one of the next releases..
+ * Python 2 support will be dropped in one of the next releases.
v0.5.0
~~~~~~
@@ -108,13 +149,12 @@ Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
* `Project page on PyPI (Python package index) <https://pypi.python.org/pypi/xopen/>`_
Platform: UNKNOWN
-Classifier: Development Status :: 4 - Beta
+Classifier: Development Status :: 5 - Production/Stable
Classifier: License :: OSI Approved :: MIT License
-Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
-Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4
+Classifier: Programming Language :: Python :: 3.8
+Requires-Python: >=3.5
Provides-Extra: dev
=====================================
README.rst
=====================================
@@ -1,8 +1,17 @@
.. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
- :target: https://travis-ci.org/marcelm/xopen
-
+ :target: https://travis-ci.org/marcelm/xopen
+ :alt:
+
.. image:: https://img.shields.io/pypi/v/xopen.svg?branch=master
- :target: https://pypi.python.org/pypi/xopen
+ :target: https://pypi.python.org/pypi/xopen
+
+.. image:: https://img.shields.io/conda/v/conda-forge/xopen.svg
+ :target: https://anaconda.org/conda-forge/xopen
+ :alt:
+
+.. image:: https://codecov.io/gh/marcelm/xopen/branch/master/graph/badge.svg
+ :target: https://codecov.io/gh/marcelm/xopen
+ :alt:
=====
xopen
@@ -20,12 +29,12 @@ function. ``pigz`` can use multiple threads when compressing, but is also faster
when reading ``.gz`` files, so it is used both for reading and writing if it is
available.
-This module has originally been developed as part of the `cutadapt
+This module has originally been developed as part of the `Cutadapt
tool <https://cutadapt.readthedocs.io/>`_ that is used in bioinformatics to
manipulate sequencing data. It has been in successful use within that software
for a few years.
-``xopen`` is compatible with Python versions 2.7 and 3.4 to 3.7.
+``xopen`` is compatible with Python versions 3.5 to 3.8.
Usage
@@ -67,6 +76,9 @@ appending to files.
Ruben Vorderman <https://github.com/rhpvorderman/> contributed improvements to
make reading gzipped files faster.
+Benjamin Vaisvil <https://github.com/bvaisvil> contributed support for
+format detection from content.
+
Some ideas were taken from the `canopener project <https://github.com/selassid/canopener>`_.
If you also want to open S3 files, you may want to use that module instead.
@@ -74,11 +86,40 @@ If you also want to open S3 files, you may want to use that module instead.
Changes
-------
+v0.9.0
+~~~~~~
+
+* When the file name extension of a file to be opened for reading is not
+ available, the content is inspected (if possible) and used to determine
+ which compression format applies.
+* This release drops Python 2.7 and 3.4 support. Python 3.5 or later is
+ now required.
+
+v0.8.4
+~~~~~~
+* When reading gzipped files, force ``pigz`` to use only a single process.
+ ``pigz`` cannot use multiple cores anyway when decompressing. By default,
+ it would use extra I/O processes, which slightly reduces wall-clock time,
+ but increases CPU time. Single-core decompression with ``pigz`` is still
+ about twice as fast as regular ``gzip``.
+* Allow ``threads=0`` for specifying that no external ``pigz``/``gzip``
+ process should be used (then regular ``gzip.open()`` is used instead).
+
+v0.8.3
+~~~~~~
+* When reading gzipped files, let ``pigz`` use at most four threads by default.
+ This limit previously only applied when writing to a file.
+* Support Python 3.8
+
+v0.8.0
+~~~~~~
+* Speed improvements when iterating over gzipped files.
+
v0.6.0
~~~~~~
* For reading from gzipped files, xopen will now use a ``pigz`` subprocess.
This is faster than using ``gzip.open``.
-* Python 2 supported will be dropped in one of the next releases.
+* Python 2 support will be dropped in one of the next releases.
v0.5.0
~~~~~~
=====================================
debian/tests/control
=====================================
@@ -0,0 +1,3 @@
+Tests: run-unit-test
+Depends: @, python3-all, python3-pytest, python3-freezegun, python3-pytest-mock
+Restrictions: allow-stderr
=====================================
debian/tests/run-unit-test
=====================================
@@ -0,0 +1,8 @@
+#!/bin/bash
+set -e
+
+for py in $(py3versions -r 2> /dev/null)
+do
+ $py -m pytest -v
+done
+
=====================================
setup.cfg
=====================================
@@ -1,6 +1,17 @@
[bdist_wheel]
universal = 1
+[coverage:run]
+parallel = True
+include =
+ */site-packages/xopen/*
+ tests/*
+
+[coverage:paths]
+source =
+ src/
+ **/site-packages/
+
[egg_info]
tag_build =
tag_date = 0
=====================================
setup.py
=====================================
@@ -1,8 +1,8 @@
import sys
from setuptools import setup, find_packages
-if sys.version_info < (2, 7):
- sys.stdout.write("At least Python 2.7 is required.\n")
+if sys.version_info < (3, 5):
+ sys.stdout.write("At least Python 3.5 is required.\n")
sys.exit(1)
with open('README.rst') as f:
@@ -20,21 +20,17 @@ setup(
license='MIT',
package_dir={'': 'src'},
packages=find_packages('src'),
- install_requires=[
- 'bz2file; python_version=="2.7"',
- ],
extras_require={
'dev': ['pytest'],
},
- python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4',
+ python_requires='>=3.5',
classifiers=[
- "Development Status :: 4 - Beta",
+ "Development Status :: 5 - Production/Stable",
"License :: OSI Approved :: MIT License",
- "Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
+ "Programming Language :: Python :: 3.8",
]
)
=====================================
src/xopen.egg-info/PKG-INFO
=====================================
@@ -1,16 +1,25 @@
Metadata-Version: 2.1
Name: xopen
-Version: 0.7.3
+Version: 0.9.0
Summary: Open compressed files transparently
Home-page: https://github.com/marcelm/xopen/
Author: Marcel Martin
Author-email: mail at marcelm.net
License: MIT
Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
- :target: https://travis-ci.org/marcelm/xopen
-
+ :target: https://travis-ci.org/marcelm/xopen
+ :alt:
+
.. image:: https://img.shields.io/pypi/v/xopen.svg?branch=master
- :target: https://pypi.python.org/pypi/xopen
+ :target: https://pypi.python.org/pypi/xopen
+
+ .. image:: https://img.shields.io/conda/v/conda-forge/xopen.svg
+ :target: https://anaconda.org/conda-forge/xopen
+ :alt:
+
+ .. image:: https://codecov.io/gh/marcelm/xopen/branch/master/graph/badge.svg
+ :target: https://codecov.io/gh/marcelm/xopen
+ :alt:
=====
xopen
@@ -28,12 +37,12 @@ Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
when reading ``.gz`` files, so it is used both for reading and writing if it is
available.
- This module has originally been developed as part of the `cutadapt
+ This module has originally been developed as part of the `Cutadapt
tool <https://cutadapt.readthedocs.io/>`_ that is used in bioinformatics to
manipulate sequencing data. It has been in successful use within that software
for a few years.
- ``xopen`` is compatible with Python versions 2.7 and 3.4 to 3.7.
+ ``xopen`` is compatible with Python versions 3.5 to 3.8.
Usage
@@ -75,6 +84,9 @@ Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
Ruben Vorderman <https://github.com/rhpvorderman/> contributed improvements to
make reading gzipped files faster.
+ Benjamin Vaisvil <https://github.com/bvaisvil> contributed support for
+ format detection from content.
+
Some ideas were taken from the `canopener project <https://github.com/selassid/canopener>`_.
If you also want to open S3 files, you may want to use that module instead.
@@ -82,11 +94,40 @@ Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
Changes
-------
+ v0.9.0
+ ~~~~~~
+
+ * When the file name extension of a file to be opened for reading is not
+ available, the content is inspected (if possible) and used to determine
+ which compression format applies.
+ * This release drops Python 2.7 and 3.4 support. Python 3.5 or later is
+ now required.
+
+ v0.8.4
+ ~~~~~~
+ * When reading gzipped files, force ``pigz`` to use only a single process.
+ ``pigz`` cannot use multiple cores anyway when decompressing. By default,
+ it would use extra I/O processes, which slightly reduces wall-clock time,
+ but increases CPU time. Single-core decompression with ``pigz`` is still
+ about twice as fast as regular ``gzip``.
+ * Allow ``threads=0`` for specifying that no external ``pigz``/``gzip``
+ process should be used (then regular ``gzip.open()`` is used instead).
+
+ v0.8.3
+ ~~~~~~
+ * When reading gzipped files, let ``pigz`` use at most four threads by default.
+ This limit previously only applied when writing to a file.
+ * Support Python 3.8
+
+ v0.8.0
+ ~~~~~~
+ * Speed improvements when iterating over gzipped files.
+
v0.6.0
~~~~~~
* For reading from gzipped files, xopen will now use a ``pigz`` subprocess.
This is faster than using ``gzip.open``.
- * Python 2 supported will be dropped in one of the next releases..
+ * Python 2 support will be dropped in one of the next releases.
v0.5.0
~~~~~~
@@ -108,13 +149,12 @@ Description: .. image:: https://travis-ci.org/marcelm/xopen.svg?branch=master
* `Project page on PyPI (Python package index) <https://pypi.python.org/pypi/xopen/>`_
Platform: UNKNOWN
-Classifier: Development Status :: 4 - Beta
+Classifier: Development Status :: 5 - Production/Stable
Classifier: License :: OSI Approved :: MIT License
-Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
-Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4
+Classifier: Programming Language :: Python :: 3.8
+Requires-Python: >=3.5
Provides-Extra: dev
=====================================
src/xopen.egg-info/SOURCES.txt
=====================================
@@ -1,3 +1,4 @@
+.codecov.yml
.editorconfig
.gitignore
.travis.yml
@@ -16,7 +17,10 @@ src/xopen.egg-info/requires.txt
src/xopen.egg-info/top_level.txt
tests/file.txt
tests/file.txt.bz2
+tests/file.txt.bz2.test
tests/file.txt.gz
+tests/file.txt.gz.test
tests/file.txt.xz
+tests/file.txt.xz.test
tests/hello.gz
tests/test_xopen.py
\ No newline at end of file
=====================================
src/xopen.egg-info/requires.txt
=====================================
@@ -1,6 +1,3 @@
-[:python_version == "2.7"]
-bz2file
-
[dev]
pytest
=====================================
src/xopen/__init__.py
=====================================
@@ -1,51 +1,41 @@
"""
Open compressed files transparently.
"""
-from __future__ import print_function, division, absolute_import
+
+__all__ = ["xopen", "PipedGzipWriter", "PipedGzipReader", "__version__"]
import gzip
import sys
import io
import os
+import bz2
import time
+import stat
+import signal
+import pathlib
from subprocess import Popen, PIPE
from ._version import version as __version__
-_PY3 = sys.version > '3'
-
-if not _PY3:
- import bz2file as bz2
-else:
- try:
- import bz2
- except ImportError:
- bz2 = None
-
try:
import lzma
except ImportError:
lzma = None
-if _PY3:
- basestring = str
-
-try:
- import pathlib # Exists in Python 3.4+
-except ImportError:
- pathlib = None
-
try:
from os import fspath # Exists in Python 3.6+
except ImportError:
def fspath(path):
if hasattr(path, "__fspath__"):
return path.__fspath__()
- # Python 3.4 and 3.5 do not support the file system path protocol
+ # Python 3.4 and 3.5 have pathlib, but do not support the file system
+ # path protocol
if pathlib is not None and isinstance(path, pathlib.Path):
return str(path)
+ if not isinstance(path, str):
+ raise TypeError("path must be a string")
return path
@@ -67,7 +57,7 @@ def _available_cpu_count():
res = bin(int(m.group(1).replace(',', ''), 16)).count('1')
if res > 0:
return res
- except IOError:
+ except OSError:
pass
try:
import multiprocessing
@@ -76,11 +66,12 @@ def _available_cpu_count():
return 1
-class Closing(object):
+class Closing:
"""
Inherit from this class and implement a close() method to offer context
manager functionality.
"""
+
def __enter__(self):
return self
@@ -90,16 +81,20 @@ class Closing(object):
def __del__(self):
try:
self.close()
- except:
+ except Exception:
pass
class PipedGzipWriter(Closing):
"""
Write gzip-compressed files by running an external gzip or pigz process and
- piping into it. On Python 2, this is faster than using gzip.open(). On
- Python 3, it allows to run the compression in a separate process and can
- therefore also be faster.
+ piping into it. pigz is tried first. It is fast because it can compress using
+ multiple cores.
+
+ If pigz is not available, a gzip subprocess is used. On Python 2, this saves
+ CPU time because gzip.GzipFile is slower. On Python 3, gzip.GzipFile is on
+ par with gzip itself, but running an external gzip can still reduce wall-clock
+ time because the compression happens in a separate process.
"""
def __init__(self, path, mode='wt', compresslevel=6, threads=None):
@@ -111,7 +106,8 @@ class PipedGzipWriter(Closing):
at four to avoid creating too many threads. Use 0 to let pigz use all available cores.
"""
if mode not in ('w', 'wt', 'wb', 'a', 'at', 'ab'):
- raise ValueError("Mode is '{0}', but it must be 'w', 'wt', 'wb', 'a', 'at' or 'ab'".format(mode))
+ raise ValueError(
+ "Mode is '{}', but it must be 'w', 'wt', 'wb', 'a', 'at' or 'ab'".format(mode))
# TODO use a context manager
self.outfile = open(path, mode)
@@ -119,56 +115,62 @@ class PipedGzipWriter(Closing):
self.closed = False
self.name = path
- kwargs = dict(stdin=PIPE, stdout=self.outfile, stderr=self.devnull)
- # Setting close_fds to True in the Popen arguments is necessary due to
- # <http://bugs.python.org/issue12786>.
- # However, close_fds is not supported on Windows. See
- # <https://github.com/marcelm/cutadapt/issues/315>.
- if sys.platform != 'win32':
- kwargs['close_fds'] = True
-
- if 'w' in mode and compresslevel != 6:
- extra_args = ['-' + str(compresslevel)]
- else:
- extra_args = []
-
- pigz_args = ['pigz']
if threads is None:
threads = min(_available_cpu_count(), 4)
- if threads != 0:
- pigz_args += ['-p', str(threads)]
try:
- self.process = Popen(pigz_args + extra_args, **kwargs)
- self.program = 'pigz'
+ self.process, self.program = self._open_process(
+ mode, compresslevel, threads, self.outfile, self.devnull)
except OSError:
- # pigz not found, try regular gzip
- try:
- self.process = Popen(['gzip'] + extra_args, **kwargs)
- self.program = 'gzip'
- except (IOError, OSError):
- self.outfile.close()
- self.devnull.close()
- raise
- except IOError: # TODO IOError is the same as OSError on Python 3.3
self.outfile.close()
self.devnull.close()
raise
- if _PY3 and 'b' not in mode:
+
+ if 'b' not in mode:
self._file = io.TextIOWrapper(self.process.stdin)
else:
self._file = self.process.stdin
+ @staticmethod
+ def _open_process(mode, compresslevel, threads, outfile, devnull):
+ pigz_args = ['pigz']
+ if threads != 0:
+ pigz_args += ['-p', str(threads)]
+ extra_args = []
+ if 'w' in mode and compresslevel != 6:
+ extra_args += ['-' + str(compresslevel)]
+
+ kwargs = dict(stdin=PIPE, stdout=outfile, stderr=devnull)
+
+ # Setting close_fds to True in the Popen arguments is necessary due to
+ # <http://bugs.python.org/issue12786>.
+ # However, close_fds is not supported on Windows. See
+ # <https://github.com/marcelm/cutadapt/issues/315>.
+ if sys.platform != 'win32':
+ kwargs['close_fds'] = True
+
+ try:
+ process = Popen(pigz_args + extra_args, **kwargs)
+ program = 'pigz'
+ except OSError: # TODO Use FileNotFound instead (Python 3)
+ # pigz not found, try regular gzip
+ process = Popen(['gzip'] + extra_args, **kwargs)
+ program = 'gzip'
+ return process, program
+
def write(self, arg):
self._file.write(arg)
def close(self):
+ if self.closed:
+ return
self.closed = True
self._file.close()
retcode = self.process.wait()
self.outfile.close()
self.devnull.close()
if retcode != 0:
- raise IOError("Output {0} process terminated with exit code {1}".format(self.program, retcode))
+ raise OSError(
+ "Output {} process terminated with exit code {}".format(self.program, retcode))
def __iter__(self):
return self
@@ -180,26 +182,38 @@ class PipedGzipWriter(Closing):
class PipedGzipReader(Closing):
"""
Open a pipe to pigz for reading a gzipped file. Even though pigz is mostly
- used to speed up writing, when it can use many compression threads, it is
- also faster than gzip when reading (three times faster).
+ used to speed up writing by using many compression threads, it is
+ also faster when reading, even when forced to use a single thread
+ (ca. 2x speedup).
"""
- def __init__(self, path, mode='r'):
+ def __init__(self, path, mode='r', threads=None):
"""
Raise an OSError when pigz could not be found.
"""
if mode not in ('r', 'rt', 'rb'):
- raise ValueError("Mode is '{0}', but it must be 'r', 'rt' or 'rb'".format(mode))
- self.process = Popen(['pigz', '-cd', path], stdout=PIPE, stderr=PIPE)
+ raise ValueError("Mode is '{}', but it must be 'r', 'rt' or 'rb'".format(mode))
+
+ pigz_args = ['pigz', '-cd', path]
+
+ if threads is None:
+ # Single threaded behaviour by default because:
+ # - Using a single thread to read a file is the least unexpected
+ # behaviour. (For users of xopen, who do not know which backend is used.)
+ # - There is quite a substantial overhead (+25% CPU time) when
+ # using multiple threads while there is only a 10% gain in wall
+ # clock time.
+ threads = 1
+
+ pigz_args += ['-p', str(threads)]
+
+ self.process = Popen(pigz_args, stdout=PIPE, stderr=PIPE)
self.name = path
- if _PY3 and 'b' not in mode:
+ if 'b' not in mode:
self._file = io.TextIOWrapper(self.process.stdout)
else:
self._file = self.process.stdout
- if _PY3:
- self._stderr = io.TextIOWrapper(self.process.stderr)
- else:
- self._stderr = self.process.stderr
+ self._stderr = io.TextIOWrapper(self.process.stderr)
self.closed = False
# Give the subprocess a little bit of time to report any errors (such as
# a non-existing file)
@@ -207,48 +221,51 @@ class PipedGzipReader(Closing):
self._raise_if_error()
def close(self):
+ if self.closed:
+ return
self.closed = True
retcode = self.process.poll()
if retcode is None:
# still running
self.process.terminate()
- self._raise_if_error()
+ allow_sigterm = True
+ else:
+ allow_sigterm = False
+ self.process.wait()
+ self._file.close()
+ self._raise_if_error(allow_sigterm=allow_sigterm)
+ self._stderr.close()
def __iter__(self):
- for line in self._file:
- yield line
- self.process.wait()
- self._raise_if_error()
+ return self
+
+ def __next__(self):
+ return self._file.__next__()
- def _raise_if_error(self):
+ def _raise_if_error(self, allow_sigterm=False):
"""
- Raise IOError if process is not running anymore and the
- exit code is nonzero.
+ Raise IOError if process is not running anymore and the exit code is
+ nonzero. If allow_sigterm is set and a SIGTERM exit code is
+ encountered, no error is raised.
"""
retcode = self.process.poll()
- if retcode is not None and retcode != 0:
+ if (
+ retcode is not None and retcode != 0
+ and not (allow_sigterm and retcode == -signal.SIGTERM)
+ ):
message = self._stderr.read().strip()
- raise IOError(message)
+ self._file.close()
+ self._stderr.close()
+ raise OSError("{} (exit code {})".format(message, retcode))
def read(self, *args):
- data = self._file.read(*args)
- if len(args) == 0 or args[0] <= 0:
- # wait for process to terminate until we check the exit code
- self.process.wait()
- self._raise_if_error()
- return data
+ return self._file.read(*args)
def readinto(self, *args):
- data = self._file.readinto(*args)
- return data
+ return self._file.readinto(*args)
def readline(self, *args):
- data = self._file.readline(*args)
- if len(args) == 0 or args[0] <= 0:
- # wait for process to terminate until we check the exit code
- self.process.wait()
- self._raise_if_error()
- return data
+ return self._file.readline(*args)
def seekable(self):
return self._file.seekable()
@@ -256,9 +273,8 @@ class PipedGzipReader(Closing):
def peek(self, n=None):
return self._file.peek(n)
- if _PY3:
- def readable(self):
- return self._file.readable()
+ def readable(self):
+ return self._file.readable()
def writable(self):
return self._file.writable()
@@ -271,23 +287,11 @@ def _open_stdin_or_out(mode):
# Do not return sys.stdin or sys.stdout directly as we want the returned object
# to be closable without closing sys.stdout.
std = dict(r=sys.stdin, w=sys.stdout)[mode[0]]
- if not _PY3:
- # Enforce str type on Python 2
- # Note that io.open is slower than regular open() on Python 2.7, but
- # it appears to be the only API that has a closefd parameter.
- mode = mode[0] + 'b'
- return io.open(std.fileno(), mode=mode, closefd=False)
+ return open(std.fileno(), mode=mode, closefd=False)
def _open_bz2(filename, mode):
- if bz2 is None:
- raise ImportError("Cannot open bz2 files: The bz2 module is not available")
- if _PY3:
- return bz2.open(filename, mode)
- else:
- if mode[0] == 'a':
- raise ValueError("mode '{0}' not supported with BZ2 compression".format(mode))
- return bz2.BZ2File(filename, mode)
+ return bz2.open(filename, mode)
def _open_xz(filename, mode):
@@ -298,44 +302,67 @@ def _open_xz(filename, mode):
def _open_gz(filename, mode, compresslevel, threads):
- if sys.version_info[:2] == (2, 7):
- buffered_reader = io.BufferedReader
- buffered_writer = io.BufferedWriter
- else:
- buffered_reader = lambda x: x
- buffered_writer = lambda x: x
- if _PY3:
- exc = FileNotFoundError # was introduced in Python 3.3
- else:
- exc = OSError
- if 'r' in mode:
+ if threads != 0:
try:
- return PipedGzipReader(filename, mode)
- except exc:
- # pigz is not installed
- return buffered_reader(gzip.open(filename, mode))
+ if 'r' in mode:
+ return PipedGzipReader(filename, mode, threads=threads)
+ else:
+ return PipedGzipWriter(filename, mode, compresslevel, threads=threads)
+ except FileNotFoundError:
+ pass # We try without threads.
+
+ if 'r' in mode:
+ return gzip.open(filename, mode)
else:
- try:
- return PipedGzipWriter(filename, mode, compresslevel, threads=threads)
- except exc:
- return buffered_writer(gzip.open(filename, mode, compresslevel=compresslevel))
+ return gzip.open(filename, mode, compresslevel=compresslevel)
+
+
+def _detect_format_from_content(filename):
+ """
+ Attempts to detect file format from the content by reading the first
+ 6 bytes. Returns None if no format could be detected.
+ """
+ try:
+ if stat.S_ISREG(os.stat(filename).st_mode):
+ with open(filename, "rb") as fh:
+ bs = fh.read(6)
+ if bs[:2] == b'\x1f\x8b':
+ # https://tools.ietf.org/html/rfc1952#page-6
+ return "gz"
+ elif bs[:3] == b'\x42\x5a\x68':
+ # https://en.wikipedia.org/wiki/List_of_file_signatures
+ return "bz2"
+ elif bs[:6] == b'\xfd\x37\x7a\x58\x5a\x00':
+ # https://tukaani.org/xz/xz-file-format.txt
+ return "xz"
+ except OSError:
+ return None
+
+
+def _detect_format_from_extension(filename):
+ """
+ Attempts to detect file format from the filename extension.
+ Returns None if no format could be detected.
+ """
+ if filename.endswith('.bz2'):
+ return "bz2"
+ elif filename.endswith('.xz'):
+ return "xz"
+ elif filename.endswith('.gz'):
+ return "gz"
+ else:
+ return None
def xopen(filename, mode='r', compresslevel=6, threads=None):
"""
A replacement for the "open" function that can also read and write
compressed files transparently. The supported compression formats are gzip,
- bzip2 and xz. If the filename is '-', standard output (mode 'w') or input (mode 'r') is returned.
-
- The file type is determined based on the filename: .gz is gzip, .bz2 is bzip2 and .xz is
- xz/lzma.
+ bzip2 and xz. If the filename is '-', standard output (mode 'w') or
+ standard input (mode 'r') is returned.
- When writing a gzip-compressed file, the following methods are tried in order to get the
- best speed 1) using a pigz (parallel gzip) subprocess; 2) using a gzip subprocess;
- 3) gzip.open. A single gzip subprocess can be faster than gzip.open because it runs in a
- separate process.
-
- Uncompressed files are opened with the regular open().
+ The file type is determined based on the filename: .gz is gzip, .bz2 is bzip2, .xz is
+ xz/lzma and no compression assumed otherwise.
mode can be: 'rt', 'rb', 'at', 'ab', 'wt', or 'wb'. Also, the 't' can be omitted,
so instead of 'rt', 'wt' and 'at', the abbreviations 'r', 'w' and 'a' can be used.
@@ -345,35 +372,36 @@ def xopen(filename, mode='r', compresslevel=6, threads=None):
Append mode ('a', 'at', 'ab') is not available with BZ2 compression and
will raise an error.
- compresslevel is the gzip compression level. It is not used for bz2 and xz.
+ compresslevel is the compression level for writing to gzip files.
+ This parameter is ignored for the other compression formats.
+
+ threads only has a meaning when reading or writing gzip files.
+
+ When threads is None (the default), reading or writing a gzip file is done with a pigz
+ (parallel gzip) subprocess if possible. See PipedGzipWriter and PipedGzipReader.
- threads is the number of threads for pigz. If left at None, then the pigz
- default is used. With pigz 2.4, this is "the number of online processors,
- or 8 if unknown".
+ When threads = 0, no subprocess is used.
"""
if mode in ('r', 'w', 'a'):
mode += 't'
if mode not in ('rt', 'rb', 'wt', 'wb', 'at', 'ab'):
- raise ValueError("mode '{0}' not supported".format(mode))
- if not _PY3:
- mode = mode[0]
+ raise ValueError("Mode '{}' not supported".format(mode))
filename = fspath(filename)
- if not isinstance(filename, basestring):
- raise ValueError("the filename must be a string")
if compresslevel not in range(1, 10):
raise ValueError("compresslevel must be between 1 and 9")
if filename == '-':
return _open_stdin_or_out(mode)
- elif filename.endswith('.bz2'):
- return _open_bz2(filename, mode)
- elif filename.endswith('.xz'):
- return _open_xz(filename, mode)
- elif filename.endswith('.gz'):
+
+ detected_format = _detect_format_from_extension(filename)
+ if detected_format is None and "w" not in mode:
+ detected_format = _detect_format_from_content(filename)
+
+ if detected_format == "gz":
return _open_gz(filename, mode, compresslevel, threads)
+ elif detected_format == "xz":
+ return _open_xz(filename, mode)
+ elif detected_format == "bz2":
+ return _open_bz2(filename, mode)
else:
- # Python 2.6 and 2.7 have io.open, which we could use to make the returned
- # object consistent with the one returned in Python 3, but reading a file
- # with io.open() is 100 times slower (!) on Python 2.6, and still about
- # three times slower on Python 2.7 (tested with "for _ in io.open(path): pass")
return open(filename, mode)
=====================================
src/xopen/_version.py
=====================================
@@ -1,4 +1,4 @@
# coding: utf-8
# file generated by setuptools_scm
# don't change, don't track in version control
-version = '0.7.3'
+version = '0.9.0'
=====================================
tests/file.txt.bz2.test
=====================================
Binary files /dev/null and b/tests/file.txt.bz2.test differ
=====================================
tests/file.txt.gz.test
=====================================
Binary files /dev/null and b/tests/file.txt.gz.test differ
=====================================
tests/file.txt.xz.test
=====================================
Binary files /dev/null and b/tests/file.txt.xz.test differ
=====================================
tests/test_xopen.py
=====================================
@@ -1,13 +1,11 @@
-# coding: utf-8
-from __future__ import print_function, division, absolute_import
-
import io
import os
import random
-import sys
import signal
-from contextlib import contextmanager
+import time
import pytest
+from pathlib import Path
+
from xopen import xopen, PipedGzipReader, PipedGzipWriter
@@ -24,11 +22,6 @@ files = [base + ext for ext in extensions]
CONTENT_LINES = ['Testing, testing ...\n', 'The second line.\n']
CONTENT = ''.join(CONTENT_LINES)
-# File extensions for which appending is supported
-append_extensions = extensions[:]
-if sys.version_info[0] == 2:
- append_extensions.remove(".bz2")
-
@pytest.fixture(params=extensions)
def ext(request):
@@ -40,14 +33,23 @@ def fname(request):
return request.param
- at contextmanager
-def temporary_path(name):
- directory = os.path.join(os.path.dirname(__file__), 'testtmp')
- if not os.path.isdir(directory):
- os.mkdir(directory)
- path = os.path.join(directory, name)
- yield path
- os.remove(path)
+ at pytest.fixture
+def large_gzip(tmpdir):
+ path = str(tmpdir.join("large.gz"))
+ random_text = ''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ\n') for _ in range(1024))
+ # Make the text a lot bigger in order to ensure that it is larger than the
+ # pipe buffer size.
+ random_text *= 1024
+ with xopen(path, 'w') as f:
+ f.write(random_text)
+ return path
+
+
+ at pytest.fixture
+def truncated_gzip(large_gzip):
+ with open(large_gzip, 'a') as f:
+ f.truncate(os.stat(large_gzip).st_size - 10)
+ return large_gzip
def test_xopen_text(fname):
@@ -102,11 +104,20 @@ def test_pipedgzipreader_readinto():
assert b[:length] == content
-if sys.version_info[0] != 2:
- def test_pipedgzipreader_textiowrapper():
- with PipedGzipReader("tests/file.txt.gz", "rb") as f:
- wrapped = io.TextIOWrapper(f)
- assert wrapped.read() == CONTENT
+def test_pipedgzipreader_textiowrapper():
+ with PipedGzipReader("tests/file.txt.gz", "rb") as f:
+ wrapped = io.TextIOWrapper(f)
+ assert wrapped.read() == CONTENT
+
+
+def test_detect_gzip_file_format_from_content():
+ with xopen("tests/file.txt.gz.test", "rb") as fh:
+ assert fh.readline() == CONTENT_LINES[0].encode("utf-8")
+
+
+def test_detect_bz2_file_format_from_content():
+ with xopen("tests/file.txt.bz2.test", "rb") as fh:
+ assert fh.readline() == CONTENT_LINES[0].encode("utf-8")
def test_readline(fname):
@@ -131,6 +142,20 @@ def test_readline_text_pipedgzipreader():
assert f.readline() == CONTENT_LINES[0]
+ at pytest.mark.parametrize("threads", [None, 1, 2])
+def test_pipedgzipreader_iter(threads):
+ with PipedGzipReader("tests/file.txt.gz", mode="r", threads=threads) as f:
+ lines = list(f)
+ assert lines[0] == CONTENT_LINES[0]
+
+
+def test_next(fname):
+ with xopen(fname, "rt") as f:
+ _ = next(f)
+ line2 = next(f)
+ assert line2 == 'The second line.\n', fname
+
+
def test_xopen_has_iter_method(ext, tmpdir):
path = str(tmpdir.join("out" + ext))
with xopen(path, mode='w') as f:
@@ -142,70 +167,99 @@ def test_pipedgzipwriter_has_iter_method(tmpdir):
assert hasattr(f, '__iter__')
+def test_iter_without_with(fname):
+ it = iter(xopen(fname, "rt"))
+ assert CONTENT_LINES[0] == next(it)
+
+
+def test_pipedgzipreader_iter_without_with():
+ it = iter(PipedGzipReader("tests/file.txt.gz"))
+ assert CONTENT_LINES[0] == next(it)
+
+
+ at pytest.mark.parametrize("mode", ["rb", "rt"])
+def test_pipedgzipreader_close(large_gzip, mode):
+ with PipedGzipReader(large_gzip, mode=mode) as f:
+ f.readline()
+ time.sleep(0.2)
+ # The subprocess should be properly terminated now
+
+
+def test_partial_gzip_iteration_closes_correctly(large_gzip):
+ class LineReader:
+ def __init__(self, file):
+ self.file = xopen(file, "rb")
+
+ def __iter__(self):
+ wrapper = io.TextIOWrapper(self.file)
+ yield from wrapper
+
+ f = LineReader(large_gzip)
+ next(iter(f))
+ f.file.close()
+
+
def test_nonexisting_file(ext):
with pytest.raises(IOError):
- with xopen('this-file-does-not-exist' + ext) as f:
- pass
+ with xopen('this-file-does-not-exist' + ext):
+ pass # pragma: no cover
def test_write_to_nonexisting_dir(ext):
with pytest.raises(IOError):
- with xopen('this/path/does/not/exist/file.txt' + ext, 'w') as f:
- pass
+ with xopen('this/path/does/not/exist/file.txt' + ext, 'w'):
+ pass # pragma: no cover
+
+
+def test_invalid_mode():
+ with pytest.raises(ValueError):
+ with xopen("tests/file.txt.gz", mode="hallo"):
+ pass # pragma: no cover
- at pytest.mark.parametrize("aext", append_extensions)
-def test_append(aext):
- text = "AB".encode("utf-8")
+def test_filename_not_a_string():
+ with pytest.raises(TypeError):
+ with xopen(123, mode="r"):
+ pass # pragma: no cover
+
+
+def test_invalid_compression_level(tmpdir):
+ path = str(tmpdir.join("out.gz"))
+ with pytest.raises(ValueError) as e:
+ with xopen(path, mode="w", compresslevel=17) as f:
+ f.write("hello") # pragma: no cover
+ assert "between 1 and 9" in e.value.args[0]
+
+
+ at pytest.mark.parametrize("ext", extensions)
+def test_append(ext, tmpdir):
+ text = b"AB"
reference = text + text
- with temporary_path('truncated.fastq' + aext) as path:
- try:
- os.unlink(path)
- except OSError:
+ path = str(tmpdir.join("the-file" + ext))
+ with xopen(path, "ab") as f:
+ f.write(text)
+ with xopen(path, "ab") as f:
+ f.write(text)
+ with xopen(path, "r") as f:
+ for appended in f:
pass
- with xopen(path, 'ab') as f:
- f.write(text)
- with xopen(path, 'ab') as f:
- f.write(text)
- with xopen(path, 'r') as f:
- for appended in f:
- pass
- try:
- reference = reference.decode("utf-8")
- except AttributeError:
- pass
- assert appended == reference
+ reference = reference.decode("utf-8")
+ assert appended == reference
- at pytest.mark.parametrize("aext", append_extensions)
-def test_append_text(aext):
+ at pytest.mark.parametrize("ext", extensions)
+def test_append_text(ext, tmpdir):
text = "AB"
reference = text + text
- with temporary_path('truncated.fastq' + aext) as path:
- try:
- os.unlink(path)
- except OSError:
+ path = str(tmpdir.join("the-file" + ext))
+ with xopen(path, "at") as f:
+ f.write(text)
+ with xopen(path, "at") as f:
+ f.write(text)
+ with xopen(path, "rt") as f:
+ for appended in f:
pass
- with xopen(path, 'at') as f:
- f.write(text)
- with xopen(path, 'at') as f:
- f.write(text)
- with xopen(path, 'rt') as f:
- for appended in f:
- pass
- assert appended == reference
-
-
-def create_truncated_file(path):
- # Random text
- random_text = ''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ') for _ in range(1024))
- # Make the text a lot bigger in order to ensure that it is larger than the
- # pipe buffer size.
- random_text *= 1024 # 1MB
- with xopen(path, 'w') as f:
- f.write(random_text)
- with open(path, 'a') as f:
- f.truncate(os.stat(path).st_size - 10)
+ assert appended == reference
class TookTooLongError(Exception):
@@ -218,7 +272,7 @@ class timeout:
self.seconds = seconds
def handle_timeout(self, signum, frame):
- raise TookTooLongError()
+ raise TookTooLongError() # pragma: no cover
def __enter__(self):
signal.signal(signal.SIGALRM, self.handle_timeout)
@@ -228,26 +282,36 @@ class timeout:
signal.alarm(0)
-if sys.version_info[:2] != (3, 3):
- def test_truncated_gz():
- with temporary_path('truncated.gz') as path:
- create_truncated_file(path)
- with timeout(seconds=2):
- with pytest.raises((EOFError, IOError)):
- f = xopen(path, 'r')
- f.read()
- f.close()
+def test_truncated_gz(truncated_gzip):
+ with timeout(seconds=2):
+ with pytest.raises((EOFError, IOError)):
+ f = xopen(truncated_gzip, "r")
+ f.read()
+ f.close() # pragma: no cover
+
+
+def test_truncated_gz_iter(truncated_gzip):
+ with timeout(seconds=2):
+ with pytest.raises((EOFError, IOError)):
+ f = xopen(truncated_gzip, 'r')
+ for line in f:
+ pass
+ f.close() # pragma: no cover
+
+
+def test_truncated_gz_with(truncated_gzip):
+ with timeout(seconds=2):
+ with pytest.raises((EOFError, IOError)):
+ with xopen(truncated_gzip, 'r') as f:
+ f.read()
- def test_truncated_gz_iter():
- with temporary_path('truncated.gz') as path:
- create_truncated_file(path)
- with timeout(seconds=2):
- with pytest.raises((EOFError, IOError)):
- f = xopen(path, 'r')
- for line in f:
- pass
- f.close()
+def test_truncated_gz_iter_with(truncated_gzip):
+ with timeout(seconds=2):
+ with pytest.raises((EOFError, IOError)):
+ with xopen(truncated_gzip, 'r') as f:
+ for line in f:
+ pass
def test_bare_read_from_gz():
@@ -268,6 +332,19 @@ def test_write_pigz_threads(tmpdir):
assert f.read() == 'hello'
+def test_read_gzip_no_threads():
+ import gzip
+ with xopen("tests/hello.gz", "rb", threads=0) as f:
+ assert isinstance(f, gzip.GzipFile), f
+
+
+def test_write_gzip_no_threads(tmpdir):
+ import gzip
+ path = str(tmpdir.join("out.gz"))
+ with xopen(path, "wb", threads=0) as f:
+ assert isinstance(f, gzip.GzipFile), f
+
+
def test_write_stdout():
f = xopen('-', mode='w')
print("Hello", file=f)
@@ -284,30 +361,36 @@ def test_write_stdout_contextmanager():
print("Still there?")
-if sys.version_info[:2] >= (3, 4):
- # pathlib was added in Python 3.4
- from pathlib import Path
-
- def test_read_pathlib(fname):
- path = Path(fname)
- with xopen(path, mode='rt') as f:
- assert f.read() == CONTENT
-
- def test_read_pathlib_binary(fname):
- path = Path(fname)
- with xopen(path, mode='rb') as f:
- assert f.read() == bytes(CONTENT, 'ascii')
-
- def test_write_pathlib(ext, tmpdir):
- path = Path(str(tmpdir)) / ('hello.txt' + ext)
- with xopen(path, mode='wt') as f:
- f.write('hello')
- with xopen(path, mode='rt') as f:
- assert f.read() == 'hello'
-
- def test_write_pathlib_binary(ext, tmpdir):
- path = Path(str(tmpdir)) / ('hello.txt' + ext)
- with xopen(path, mode='wb') as f:
- f.write(b'hello')
- with xopen(path, mode='rb') as f:
- assert f.read() == b'hello'
+def test_read_pathlib(fname):
+ path = Path(fname)
+ with xopen(path, mode='rt') as f:
+ assert f.read() == CONTENT
+
+
+def test_read_pathlib_binary(fname):
+ path = Path(fname)
+ with xopen(path, mode='rb') as f:
+ assert f.read() == bytes(CONTENT, 'ascii')
+
+
+def test_write_pathlib(ext, tmpdir):
+ path = Path(str(tmpdir)) / ('hello.txt' + ext)
+ with xopen(path, mode='wt') as f:
+ f.write('hello')
+ with xopen(path, mode='rt') as f:
+ assert f.read() == 'hello'
+
+
+def test_write_pathlib_binary(ext, tmpdir):
+ path = Path(str(tmpdir)) / ('hello.txt' + ext)
+ with xopen(path, mode='wb') as f:
+ f.write(b'hello')
+ with xopen(path, mode='rb') as f:
+ assert f.read() == b'hello'
+
+
+# lzma doesn’t work on PyPy3 at the moment
+if lzma is not None:
+ def test_detect_xz_file_format_from_content():
+ with xopen("tests/file.txt.xz.test", "rb") as fh:
+ assert fh.readline() == CONTENT_LINES[0].encode("utf-8")
=====================================
tox.ini
=====================================
@@ -1,6 +1,17 @@
[tox]
-envlist = py27,py34,py35,py36,py37
+envlist = flake8,py35,py36,py37,py38,pypy3
[testenv]
deps = pytest
+setenv = PYTHONDEVMODE = 1
commands = pytest --doctest-modules --pyargs src/xopen tests
+
+[testenv:flake8]
+basepython = python3.6
+deps = flake8
+commands = flake8 src/ tests/
+
+[flake8]
+max-line-length = 99
+max-complexity = 10
+extend_ignore = E731
View it on GitLab: https://salsa.debian.org/med-team/python-xopen/-/compare/b8263bda55d19170d95dd963505e34cfa883eaa8...51dcafd5882b2a249baf3502addd8a79c73e3c18
--
View it on GitLab: https://salsa.debian.org/med-team/python-xopen/-/compare/b8263bda55d19170d95dd963505e34cfa883eaa8...51dcafd5882b2a249baf3502addd8a79c73e3c18
You're receiving this email because of your account on salsa.debian.org.
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/debian-med-commit/attachments/20200722/9bd7825e/attachment-0001.html>
More information about the debian-med-commit
mailing list