Skip to content

Commit eafec26

Browse files
authored
bpo-14156: Make argparse.FileType work correctly for binary file modes when argument is '-' (GH-13165)
Also made modes containing 'a' or 'x' act the same as a mode containing 'w' when argument is '-' (so 'a'/'x' return sys.stdout like 'w', and 'ab'/'xb' return sys.stdout.buffer like 'wb').
1 parent 602024e commit eafec26

File tree

3 files changed

+110
-17
lines changed

3 files changed

+110
-17
lines changed

Lib/argparse.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -728,7 +728,7 @@ def _get_action_name(argument):
728728
if argument is None:
729729
return None
730730
elif argument.option_strings:
731-
return '/'.join(argument.option_strings)
731+
return '/'.join(argument.option_strings)
732732
elif argument.metavar not in (None, SUPPRESS):
733733
return argument.metavar
734734
elif argument.dest not in (None, SUPPRESS):
@@ -1259,9 +1259,9 @@ def __call__(self, string):
12591259
# the special argument "-" means sys.std{in,out}
12601260
if string == '-':
12611261
if 'r' in self._mode:
1262-
return _sys.stdin
1263-
elif 'w' in self._mode:
1264-
return _sys.stdout
1262+
return _sys.stdin.buffer if 'b' in self._mode else _sys.stdin
1263+
elif any(c in self._mode for c in 'wax'):
1264+
return _sys.stdout.buffer if 'b' in self._mode else _sys.stdout
12651265
else:
12661266
msg = _('argument "-" with mode %r') % self._mode
12671267
raise ValueError(msg)

Lib/test/test_argparse.py

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Author: Steven J. Bethard <steven.bethard@gmail.com>.
22

33
import inspect
4+
import io
5+
import operator
46
import os
57
import shutil
68
import stat
@@ -11,12 +13,27 @@
1113
import argparse
1214
import warnings
1315

14-
from io import StringIO
15-
1616
from test.support import os_helper
1717
from unittest import mock
18-
class StdIOBuffer(StringIO):
19-
pass
18+
19+
20+
class StdIOBuffer(io.TextIOWrapper):
21+
'''Replacement for writable io.StringIO that behaves more like real file
22+
23+
Unlike StringIO, provides a buffer attribute that holds the underlying
24+
binary data, allowing it to replace sys.stdout/sys.stderr in more
25+
contexts.
26+
'''
27+
28+
def __init__(self, initial_value='', newline='\n'):
29+
initial_value = initial_value.encode('utf-8')
30+
super().__init__(io.BufferedWriter(io.BytesIO(initial_value)),
31+
'utf-8', newline=newline)
32+
33+
def getvalue(self):
34+
self.flush()
35+
return self.buffer.raw.getvalue().decode('utf-8')
36+
2037

2138
class TestCase(unittest.TestCase):
2239

@@ -43,11 +60,14 @@ def tearDown(self):
4360
os.chmod(os.path.join(self.temp_dir, name), stat.S_IWRITE)
4461
shutil.rmtree(self.temp_dir, True)
4562

46-
def create_readonly_file(self, filename):
63+
def create_writable_file(self, filename):
4764
file_path = os.path.join(self.temp_dir, filename)
4865
with open(file_path, 'w', encoding="utf-8") as file:
4966
file.write(filename)
50-
os.chmod(file_path, stat.S_IREAD)
67+
return file_path
68+
69+
def create_readonly_file(self, filename):
70+
os.chmod(self.create_writable_file(filename), stat.S_IREAD)
5171

5272
class Sig(object):
5373

@@ -97,10 +117,15 @@ def stderr_to_parser_error(parse_args, *args, **kwargs):
97117
try:
98118
result = parse_args(*args, **kwargs)
99119
for key in list(vars(result)):
100-
if getattr(result, key) is sys.stdout:
120+
attr = getattr(result, key)
121+
if attr is sys.stdout:
101122
setattr(result, key, old_stdout)
102-
if getattr(result, key) is sys.stderr:
123+
elif attr is sys.stdout.buffer:
124+
setattr(result, key, getattr(old_stdout, 'buffer', BIN_STDOUT_SENTINEL))
125+
elif attr is sys.stderr:
103126
setattr(result, key, old_stderr)
127+
elif attr is sys.stderr.buffer:
128+
setattr(result, key, getattr(old_stderr, 'buffer', BIN_STDERR_SENTINEL))
104129
return result
105130
except SystemExit as e:
106131
code = e.code
@@ -1565,16 +1590,40 @@ def test_r_1_replace(self):
15651590
type = argparse.FileType('r', 1, errors='replace')
15661591
self.assertEqual("FileType('r', 1, errors='replace')", repr(type))
15671592

1593+
1594+
BIN_STDOUT_SENTINEL = object()
1595+
BIN_STDERR_SENTINEL = object()
1596+
1597+
15681598
class StdStreamComparer:
15691599
def __init__(self, attr):
1570-
self.attr = attr
1600+
# We try to use the actual stdXXX.buffer attribute as our
1601+
# marker, but but under some test environments,
1602+
# sys.stdout/err are replaced by io.StringIO which won't have .buffer,
1603+
# so we use a sentinel simply to show that the tests do the right thing
1604+
# for any buffer supporting object
1605+
self.getattr = operator.attrgetter(attr)
1606+
if attr == 'stdout.buffer':
1607+
self.backupattr = BIN_STDOUT_SENTINEL
1608+
elif attr == 'stderr.buffer':
1609+
self.backupattr = BIN_STDERR_SENTINEL
1610+
else:
1611+
self.backupattr = object() # Not equal to anything
15711612

15721613
def __eq__(self, other):
1573-
return other == getattr(sys, self.attr)
1614+
try:
1615+
return other == self.getattr(sys)
1616+
except AttributeError:
1617+
return other == self.backupattr
1618+
15741619

15751620
eq_stdin = StdStreamComparer('stdin')
15761621
eq_stdout = StdStreamComparer('stdout')
15771622
eq_stderr = StdStreamComparer('stderr')
1623+
eq_bstdin = StdStreamComparer('stdin.buffer')
1624+
eq_bstdout = StdStreamComparer('stdout.buffer')
1625+
eq_bstderr = StdStreamComparer('stderr.buffer')
1626+
15781627

15791628
class RFile(object):
15801629
seen = {}
@@ -1653,7 +1702,7 @@ def setUp(self):
16531702
('foo', NS(x=None, spam=RFile('foo'))),
16541703
('-x foo bar', NS(x=RFile('foo'), spam=RFile('bar'))),
16551704
('bar -x foo', NS(x=RFile('foo'), spam=RFile('bar'))),
1656-
('-x - -', NS(x=eq_stdin, spam=eq_stdin)),
1705+
('-x - -', NS(x=eq_bstdin, spam=eq_bstdin)),
16571706
]
16581707

16591708

@@ -1680,8 +1729,9 @@ class TestFileTypeW(TempDirMixin, ParserTestCase):
16801729
"""Test the FileType option/argument type for writing files"""
16811730

16821731
def setUp(self):
1683-
super(TestFileTypeW, self).setUp()
1732+
super().setUp()
16841733
self.create_readonly_file('readonly')
1734+
self.create_writable_file('writable')
16851735

16861736
argument_signatures = [
16871737
Sig('-x', type=argparse.FileType('w')),
@@ -1690,13 +1740,37 @@ def setUp(self):
16901740
failures = ['-x', '', 'readonly']
16911741
successes = [
16921742
('foo', NS(x=None, spam=WFile('foo'))),
1743+
('writable', NS(x=None, spam=WFile('writable'))),
16931744
('-x foo bar', NS(x=WFile('foo'), spam=WFile('bar'))),
16941745
('bar -x foo', NS(x=WFile('foo'), spam=WFile('bar'))),
16951746
('-x - -', NS(x=eq_stdout, spam=eq_stdout)),
16961747
]
16971748

1749+
@unittest.skipIf(hasattr(os, 'geteuid') and os.geteuid() == 0,
1750+
"non-root user required")
1751+
class TestFileTypeX(TempDirMixin, ParserTestCase):
1752+
"""Test the FileType option/argument type for writing new files only"""
1753+
1754+
def setUp(self):
1755+
super().setUp()
1756+
self.create_readonly_file('readonly')
1757+
self.create_writable_file('writable')
1758+
1759+
argument_signatures = [
1760+
Sig('-x', type=argparse.FileType('x')),
1761+
Sig('spam', type=argparse.FileType('x')),
1762+
]
1763+
failures = ['-x', '', 'readonly', 'writable']
1764+
successes = [
1765+
('-x foo bar', NS(x=WFile('foo'), spam=WFile('bar'))),
1766+
('-x - -', NS(x=eq_stdout, spam=eq_stdout)),
1767+
]
1768+
16981769

1770+
@unittest.skipIf(hasattr(os, 'geteuid') and os.geteuid() == 0,
1771+
"non-root user required")
16991772
class TestFileTypeWB(TempDirMixin, ParserTestCase):
1773+
"""Test the FileType option/argument type for writing binary files"""
17001774

17011775
argument_signatures = [
17021776
Sig('-x', type=argparse.FileType('wb')),
@@ -1707,7 +1781,22 @@ class TestFileTypeWB(TempDirMixin, ParserTestCase):
17071781
('foo', NS(x=None, spam=WFile('foo'))),
17081782
('-x foo bar', NS(x=WFile('foo'), spam=WFile('bar'))),
17091783
('bar -x foo', NS(x=WFile('foo'), spam=WFile('bar'))),
1710-
('-x - -', NS(x=eq_stdout, spam=eq_stdout)),
1784+
('-x - -', NS(x=eq_bstdout, spam=eq_bstdout)),
1785+
]
1786+
1787+
1788+
@unittest.skipIf(hasattr(os, 'geteuid') and os.geteuid() == 0,
1789+
"non-root user required")
1790+
class TestFileTypeXB(TestFileTypeX):
1791+
"Test the FileType option/argument type for writing new binary files only"
1792+
1793+
argument_signatures = [
1794+
Sig('-x', type=argparse.FileType('xb')),
1795+
Sig('spam', type=argparse.FileType('xb')),
1796+
]
1797+
successes = [
1798+
('-x foo bar', NS(x=WFile('foo'), spam=WFile('bar'))),
1799+
('-x - -', NS(x=eq_bstdout, spam=eq_bstdout)),
17111800
]
17121801

17131802

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
argparse.FileType now supports an argument of '-' in binary mode, returning
2+
the .buffer attribute of sys.stdin/sys.stdout as appropriate. Modes
3+
including 'x' and 'a' are treated equivalently to 'w' when argument is '-'.
4+
Patch contributed by Josh Rosenberg

0 commit comments

Comments
 (0)