Repository navigation
gh-161452: Fix python -m random with a non-positive integer input - #161453
andresdelfino wants to merge 7 commits into
Conversation
When given a single integer input, main() called randint() inside the same try block that converts the input with int(). The ValueError that randint() raises for an empty range was then taken to mean that the input is not an integer, so "python -m random 0" printed 0.0 and a negative N printed a random negative float, instead of failing like "--integer N" does. Only convert the input inside the try block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| @@ -1076,6 +1075,8 @@ def main(arg_list: list[str] | None = None) -> int | str: | |||
There was a problem hiding this comment.
Don't you want to move this to an (inner) else: block too?
There was a problem hiding this comment.
FWIW, I think it's the right place: uniform() not raises (c.f. randint).
So, here we have only parsing failures for floats. In that case it's reasonable to try choice() as a fallback.
There was a problem hiding this comment.
While Sergey is right, it would be easier to read if the same pattern is followed. Let's to think of. Let me know what you prefer.
There was a problem hiding this comment.
I agree with @gvanrossum, it's better to move return uniform(0, val) to an else: block as well.
There was a problem hiding this comment.
This change is backward incompatible, but it respects the documentation: https://docs.python.org/dev/library/random.html#command-line-usage
If no options are given, the output depends on the input:
Integer: same as --integer.
Hum. Displaying a long traceback is not really user friendly. Can you catch the exception and display a (short) error message instead?
$ ./python -m random -3
Traceback (most recent call last):
File "/home/vstinner/python/main/Lib/runpy.py", line 201, in _run_module_as_main
return _run_code(code, main_globals, None,
"__main__", mod_spec)
File "/home/vstinner/python/main/Lib/runpy.py", line 87, in _run_code
exec(code, run_globals)
~~~~^^^^^^^^^^^^^^^^^^^
File "/home/vstinner/python/main/Lib/random.py", line 1088, in <module>
print(main())
~~~~^^
File "/home/vstinner/python/main/Lib/random.py", line 1079, in main
return randint(1, val)
File "/home/vstinner/python/main/Lib/random.py", line 341, in randint
raise ValueError(f"empty range in randint({a}, {b})")
ValueError: empty range in randint(1, -3)cc @hugovk who authored this feature.
|
Yes, a short error message would be better than the traceback. |
Move the uniform() call to an else block too, matching the integer branch. When run from the command line, catch the ValueError for an empty integer range and exit with a one-line error message instead of a traceback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@vstinner I applied your suggestion, handling the exception in the block that calls main. Changing the main function itself would involve more changes in the tests. Would it be better to go that way? |
|
Some general notes on
There is a surprising behavior when the integer is larger than sys.get_int_max_str_digits(). int() raises By the way, it's possible to pass special float values: It seems like Ok, maybe it can return And |
Co-authored-by: Victor Stinner <vstinner@python.org>
vstinner
left a comment
There was a problem hiding this comment.
LGTM. I just have a last suggestion about adding a "real" random.main() function.
| import sys | ||
| try: | ||
| print(main()) | ||
| except ValueError as exc: | ||
| sys.exit(f"error: {exc}") |
There was a problem hiding this comment.
You might rename main() to _main() and wrap these lines into a new main() function. So for example, you can call the new main() function in tests instead of having to spawn a new process.
There was a problem hiding this comment.
What if I do like gzip instead, and call sys.exit directly from main? So we get the benefit you mention, and follow an existing pattern (personally I always find _main/main confusing).
There was a problem hiding this comment.
What I suggest is to have a main() function which does everything: parse command line arguments, compute a random value, display the random value, call sys.exit() on error:
if __name__ == '__main__':
main()I don't care too much of how main() code would look like :-)
There was a problem hiding this comment.
Got it! We were talking about the same thing :) I'll implement this, as this seems to be the common way of implementing CLIs for modules.
There was a problem hiding this comment.
I ended up following your previous _main suggestion, as a lots of returns + a few try blocks would be necessary for main to handle everything properly by itself.
Split the CLI logic into _main(), which returns the result, and make main() print it and exit with a short error message on failure. Test the error path in-process instead of spawning a subprocess. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| self.assertEqual(str(cm.exception), | ||
| f"empty range in randint(1, {value})") | ||
|
|
||
| def test_main_error_no_traceback(self): |
There was a problem hiding this comment.
If test_empty_integer_range_error() is modified to test directly main(), I think that this test can be simply removed.
| instead of a traceback. :func:`!random.main` now prints the result instead | ||
| of returning it, and exits with the error message on failure. |
There was a problem hiding this comment.
Since we don't document the random.main() function, I don't think that we should document when its behavior changes.
| ]: | ||
| random.seed(0) | ||
| self.assertEqual(random.main(shlex.split(command)), expected) | ||
| self.assertEqual(random._main(shlex.split(command)), expected) |
There was a problem hiding this comment.
Same here, I would prefer to test the public main() function:
| self.assertEqual(random._main(shlex.split(command)), expected) | |
| with support.captured_stdout() as stdout: | |
| random.main(shlex.split(command)) | |
| self.assertEqual(stdout.getvalue().rstrip(), str(expected)) |
| for value in ["0", "-5"]: | ||
| for args in [[value], ["--integer", value]]: | ||
| with self.subTest(args=args): | ||
| with self.assertRaises(ValueError) as cm: | ||
| random._main(args) | ||
| self.assertEqual(str(cm.exception), | ||
| f"empty range in randint(1, {value})") |
There was a problem hiding this comment.
I would prefer testing the public main() function, rather than testing _main() which is more an implementation detail:
| for value in ["0", "-5"]: | |
| for args in [[value], ["--integer", value]]: | |
| with self.subTest(args=args): | |
| with self.assertRaises(ValueError) as cm: | |
| random._main(args) | |
| self.assertEqual(str(cm.exception), | |
| f"empty range in randint(1, {value})") | |
| for value in ["0", "-5"]: | |
| err_msg = f"error: empty range in randint(1, {value})" | |
| for args in [[value], ["--integer", value]]: | |
| with self.subTest(args=args): | |
| with self.assertRaises(SystemExit) as cm: | |
| random.main(args) | |
| self.assertEqual(str(cm.exception), err_msg) |
|
I'm also not keen on The traceback is now replaced with a short error: ❯ ./python.exe -m random -3
error: empty range in randint(1, -3)But that doesn't mean much to me. What's a randint? Before I had a traceback pointing me to where it all came from so I could look it up. So I suggest, let's keep: if __name__ == '__main__':
print(main())Add this validation and call def _randint_cli(parser, n: int) -> int:
if n < 1:
parser.error(f"integer must be 1 or greater, got {n}")
return randint(1, n)This mean we get the native argparse error, which prints usage, and also we get colour (and colour handling): And |
Keep main() returning its result as before, and have the CLI reject a non-positive integer through argparse's parser.error(), so it prints the usage and a short error message and exits with status 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@hugovk I followed your suggestions |
| args, parser = random._parse_args(shlex.split("--choice a b c")) | ||
| self.assertEqual(args.choice, ["a", "b", "c"]) | ||
| self.assertStartsWith(help_text, "usage: ") | ||
| self.assertStartsWith(parser.format_help(), "usage: ") |
There was a problem hiding this comment.
We could now remove these lines, we don't need to test argparse.
There was a problem hiding this comment.
Just pushed the change. Let me know if I understood you correctly.
When
python -m randomgets a single integer input,main()calledrandint()inside thetryblock that converts the input withint(). For an integerN <= 0, theValueErrorthatrandint()raises for an empty range was taken to mean that the input is not an integer, sopython -m random 0printed0.0and a negativeNprinted a random negative float. This keeps only the conversions inside thetryblocks (therandint()anduniform()calls move toelse:blocks), so an integer input now behaves like--integer N, as documented.Run from the command line, both forms now print a one-line error and exit with status 1, instead of a traceback:
main()itself still raises theValueError; only the__main__block catches it.The new tests check that
0and-5raise the sameValueErrorfrommain()with and without--integer, and that the command line reports the short error message.🤖 Generated with Claude Code
python -m random Nprints a float for an integer N <= 0 #161452