-
Notifications
You must be signed in to change notification settings - Fork 124
Piped input now properly honors the echo setting #220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
if we are on a pipe, we have to echo the prompt only after we read and are not at EOF.
When ‘pytest -n8’ parallelizes the test execution, hacking sys.stdin to some other file descriptor yields unpredictable results
Codecov Report
@@ Coverage Diff @@
## master #220 +/- ##
========================================
+ Coverage 96.81% 97% +0.19%
========================================
Files 1 1
Lines 1194 1204 +10
========================================
+ Hits 1156 1168 +12
+ Misses 38 36 -2
Continue to review full report at Codecov.
|
code coverage went down, I think it's because I don't currently test the stdin.isatty() logic branches. Lemme see if I can find a way to mock that and force the execution path over those lines. |
I don't know how to write a test to cover all the code. There are two parts of cmd2.pseudo_raw_input() that I don't know how to cover. The first part executes if use_raw_input is set:
I can mock up .isatty() to return true, and I can mock up sm.input() to return the expected value. However, a side effect of sm.input() is that the typed characters are output to sys.stdout. I dunno how to make that happen. The mock(side_effect=func) parameter lets you dynamically change the return value, but I need it to do other stuff too. It's the same problem with this chunk, also in cmd2.pseudo_raw_input():
except in this chunk it's stdin.readline() that needs to echo the input characters to self.stdout. Help! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks good to me. If you can get the test coverage back up with a little work great, if not don't kill yourself. Some cases are hard to test.
# Fix those annoying problems that occur with terminal programs like "less" when you pipe to them | ||
proc = subprocess.Popen(shlex.split('stty sane')) | ||
proc.communicate() | ||
if self.stdin.isatty(): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh yeah, good call! Trying to "sane" a stdin which isn't a terminal isn't going to work so well ;-) That should explain those stty errors.
cmd2.py
Outdated
try: | ||
line = sm.input(safe_prompt) | ||
if sys.stdin.isatty(): | ||
sys.stdout.write(safe_prompt) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just curious ... why not just pass safe_prompt directly to sm.input(safe_prompt)
like before? Why call sys.stdout.write(safe_prompt)
first and then sm.input()
with no prompt?
Actually I did some manual testing, it needs to be the other way, very bad things can happen like the user can delete the prompt by backspacing far enough if you do it this way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My (apparently lame) thought was that I can patch or mock the sm.input() but since I don't know how to get the side effect of echoing the characters to stdout, I'd just print them out there first. But if it breaks real-life, who cares whether the tests pass or are easy?
tests/test_cmd2.py
Outdated
# the next helper function and two tests check for piped | ||
# input when use_rawinput is True. | ||
# | ||
# the only way to make this testable is to mock the builtin input() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this is a pain to test, mocking it is the only way I know of.
line = sm.input(safe_prompt) | ||
if sys.stdin.isatty(): | ||
line = sm.input(safe_prompt) | ||
else: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This else seems to nicely handle the case where stdin is a pipe, both when echo is either True or False
sys.stdout.write('{}{}\n'.format(safe_prompt,line)) | ||
except EOFError: | ||
line = 'eof' | ||
else: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This else case is there honestly just because it is there in cmd
and because cmd
defines the raw_input
attribute. It is never used explicitly in cmd2
other than some unit testing "just in case".
try: | ||
line = sm.input(safe_prompt) | ||
if sys.stdin.isatty(): | ||
line = sm.input(safe_prompt) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I committed a small change to restore this line to the way it was due to issues found during manual testing.
You can view visual code coverage analysis in the code coverage tool with covered lines highlighted in green and uncovered lines highlighted in red. But finding the right link to click on can be non-intuitive. For this PR it is here: As you suggested, it is the two isatty branches that start with: if sys.stdin.isatty(): and if self.stdin.isatty(): that are uncovered. I don't think stdin is going to be a tty inside of a unit test. So these are a pain to test. |
Here is one possible unit test approach for the uncovered cases ... Say we have the block which looks like: if sys.stdin.isatty():
line = sm.input(safe_prompt)
else:
line = sm.input()
if self.echo:
sys.stdout.write('{}{}\n'.format(safe_prompt,line)) Sometimes for difficult cases like this I may just shoot for "branch verification" instead of output or side-effect verification. For example, maybe there could be three unit tests for these cases:
For all of these cases, the following functions could be mocked so that they don't do anything but we can see how many times they are called and with what argument(s):
This is a weaker form of unit testing than actually verifying output. But in cases where manipulations are being done to stdin and stdout it is sometimes the best that can be reasonably done. |
The unit tests I created previously for testing the
Though these tests weren't very focused and tested a number of different things at the same time. It looks like your new unit tests you added do a better job of focusing in on testing the We just might need a couple similar ones which mock |
Yup. I'm working on it right now. I know I can mock isatty() to be true, and then I can at least check that input() got called with the correct prompt. Stand by. |
Uh-oh. This code: Lines 1439 to 1457 in c92ac8c
is not deterministic when pytest runs with xdist. Sometimes is passes, sometimes it fails. I expect because we are mocking sm.input() which could be simultaneously used by other tests that are running. |
Mocks of six.moves.input() and sys.stdin.isatty() now use either a context manager or a decorator. These wrappers make sure to put the functions back to their unmocked values when the test is done. This change appears to have solved the undeterministic test results.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for another excellent PR. I learned a decent amount about using mocks in pytest from this one. I wasn't aware you could use them as either context managers or decorators and have the original version of the function automatically restored.
def test_pseudo_raw_input_tty_rawinput_true(): | ||
# use context managers so original functions get put back when we are done | ||
# we dont use decorators because we need m_input for the assertion | ||
with mock.patch('sys.stdin.isatty', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't aware you could use mocks as context managers. This is a convenient feature.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Until yesterday, I wasn't either. It's super convenient.
|
||
# using the decorator puts the original function at six.moves.input | ||
# back when this method returns | ||
@mock.patch('six.moves.input', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't aware you could use mocks as decorators either. This is another great feature.
@kotfu I always delete the branch after merging into master to keep things clean. If you ever want me to not delete it, just say something in the PR. |
When input is piped into cmd2 from something that isn't a tty, we now honor the *echo setting to determine whether we should print the prompt and the command to the output stream.
Also fixed a bug where we tried to execute stty on the input stream, even when it wasn't a tty.
This closes #218