Utility functions ================= .. :doctest: We're testing ``utils.py`` here: >>> from zest.releaser import utils >>> from pprint import pprint Log level --------- A ``-v`` on the commandline turns on debug level logging: >>> import sys >>> import logging >>> sys.argv[1:] = [] >>> utils.parse_options() >>> utils.VERBOSE False >>> utils.loglevel() == logging.INFO True >>> sys.argv[1:] = ['-v'] >>> utils.parse_options() >>> utils.VERBOSE True >>> utils.loglevel() == logging.DEBUG True >>> sys.argv[1:] = [] Version numbers --------------- Strip all whitespace in a version: >>> utils.strip_version('1.0') '1.0' >>> utils.strip_version(' 1.0 dev ') '1.0dev' Remove development markers in various common forms: >>> utils.cleanup_version('1.0') '1.0' >>> utils.cleanup_version('1.0 dev') '1.0' >>> utils.cleanup_version('1.0 (svn/devel)') '1.0' >>> utils.cleanup_version('1.0 svn') '1.0' >>> utils.cleanup_version('1.0 devel 13') '1.0' >>> utils.cleanup_version('1.0 beta devel 13') '1.0 beta' >>> utils.cleanup_version('1.0.dev0') '1.0' >>> utils.cleanup_version('1.0.dev42') '1.0' Asking input ------------ Asking input on the prompt is not unittestable unless we use the prepared testing hack in utils.py: >>> utils.TESTMODE = True >>> utils.test_answer_book.set_answers([]) The default is True, so hitting enter (which means no input) returns True >>> utils.test_answer_book.set_answers(['']) >>> utils.ask('Does mocking work?') Question: Does mocking work? (Y/n)? Our reply: True A default of False also changes the Y/n to y/N: >>> utils.test_answer_book.set_answers(['']) >>> utils.ask('Does mocking work?', default=False) Question: Does mocking work? (y/N)? Our reply: False A default of None requires an answer: >>> utils.test_answer_book.set_answers(['', 'y']) >>> utils.ask('Does mocking work?', default=None) Question: Does mocking work? (y/n)? Our reply: Please explicitly answer y/n Question: Does mocking work? (y/n)? Our reply: y True Y and n can be upper or lower case: >>> utils.test_answer_book.set_answers(['y']) >>> utils.ask('Does mocking work?', default=None) Question: Does mocking work? (y/n)? Our reply: y True >>> utils.test_answer_book.set_answers(['Y']) >>> utils.ask('Does mocking work?', default=None) Question: Does mocking work? (y/n)? Our reply: Y True >>> utils.test_answer_book.set_answers(['n']) >>> utils.ask('Does mocking work?', default=None) Question: Does mocking work? (y/n)? Our reply: n False >>> utils.test_answer_book.set_answers(['N']) >>> utils.ask('Does mocking work?', default=None) Question: Does mocking work? (y/n)? Our reply: N False Yes and no are fine: >>> utils.test_answer_book.set_answers(['yes']) >>> utils.ask('Does mocking work?', default=None) Question: Does mocking work? (y/n)? Our reply: yes True >>> utils.test_answer_book.set_answers(['no']) >>> utils.ask('Does mocking work?', default=None) Question: Does mocking work? (y/n)? Our reply: no False The y or n must be the first character, however, to prevent accidental input from causing mishaps: >>> utils.test_answer_book.set_answers(['I reallY do not want it', 'n']) >>> utils.ask('Does mocking work?', default=None) Question: Does mocking work? (y/n)? Our reply: I reallY do not want it Please explicitly answer y/n Question: Does mocking work? (y/n)? Our reply: n False You can also ask for a version number. Pass it as the default value: >>> utils.test_answer_book.set_answers(['']) >>> utils.ask_version('New version', default='72.0') Question: New version [72.0]: Our reply: '72.0' >>> utils.test_answer_book.set_answers(['1.0', '', '']) >>> utils.ask_version('New version', default='72.0') Question: New version [72.0]: Our reply: 1.0 '1.0' Note that ``y`` or ``n`` are not accepted as answer for a version number. I see packages that get version ``y`` in postrelease because someone quickly types ``y`` everywhere without looking at the question. >>> utils.test_answer_book.set_answers(['Y', 'y', 'N', 'n', '']) >>> utils.ask_version('New version', default='72.0') Question: New version [72.0]: Our reply: Y y/n not accepted as version. Question: New version [72.0]: Our reply: y y/n not accepted as version. Question: New version [72.0]: Our reply: N y/n not accepted as version. Question: New version [72.0]: Our reply: n y/n not accepted as version. Question: New version [72.0]: Our reply: '72.0' Not asking input ---------------- For running automatically, the ``--no-input`` option is available. By default it is off: >>> utils.AUTO_RESPONSE False We can switch it on: >>> utils.TESTMODE = False >>> utils.test_answer_book.set_answers([]) >>> import sys >>> sys.argv[1:] = ['--no-input'] >>> utils.parse_options() >>> utils.AUTO_RESPONSE True This way, answers aren't even asked. With a default of yes: >>> utils.test_answer_book.set_answers(['']) >>> utils.ask('Does mocking work?') True A default of False: >>> utils.test_answer_book.set_answers(['']) >>> utils.ask('Does mocking work?', default=False) False A default of None requires an answer, which means we cannot run in ``--no-input`` mode. Return an error in this case: >>> utils.ask('Does mocking work?', default=None) Traceback (most recent call last): ... RuntimeError: The question 'Does...' requires a manual answer... The default for versions is accepted, too: >>> utils.test_answer_book.set_answers([]) >>> utils.ask('What mocking version, my liege?', default='72') '72' Reset the defaults: >>> utils.TESTMODE = True >>> utils.AUTO_RESPONSE = False Output filtering ---------------- Uploading to pypi returns quite some lines of output. Only show the first and last few lines, though we make sure we do not print too much in case there are not 'enough' lines: >>> output = "a\nb\nc\nd\ne\nf\ng\n" >>> utils.show_interesting_lines(output) a b c d e f g >>> import string >>> output = '\n'.join(string.ascii_lowercase) >>> utils.show_interesting_lines(output) Showing first few lines... a b c d e ... Showing last few lines... v w x y z Just one line: no problem: >>> output = "just one line, no newlines" >>> utils.show_interesting_lines(output) just one line, no newlines In case of errors, shown in red, we show all and ask what the user wants to do. >>> output = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn" >>> utils.show_interesting_lines(output) Showing first few lines... a b c d e ... Showing last few lines... j k l m n >>> from colorama import Fore >>> output = Fore.RED + output >>> utils.test_answer_book.set_answers(['']) >>> utils.show_interesting_lines(output) Traceback (most recent call last): ... SystemExit: 1 >>> utils.test_answer_book.set_answers(['y']) >>> utils.show_interesting_lines(output) RED a b c d e f g h i j k l m n Question: There were errors or warnings. Are you sure you want to continue? (y/N)? Our reply: y Running commands ---------------- We can run commands. >>> print(utils.execute_command('echo "E.T. phone home."')) E.T. phone home. We want to discover errors and show them in red. >>> Fore.RED '\x1b[31m' >>> Fore.MAGENTA '\x1b[35m' >>> utils.execute_command('ls some-non-existing-file') '\x1b[31mls...' Warnings may also end up in the error output. That may be unwanted. >>> warning = "warning: no previously-included files matching '*.pyc' found anywhere in distribution." >>> result = utils.execute_command('echo %s > /dev/stderr' % warning) >>> result.startswith(Fore.RED) False >>> result.startswith(Fore.MAGENTA) True >>> print(result) MAGENTA warning: no previously-included files matching *.pyc found anywhere in distribution. One similar harmless warning by distutils does not get the 'warning:' prefixed, so we handle it explicitly: >>> warning = "no previously-included directories found matching devsrc" >>> result = utils.execute_command('echo %s > /dev/stderr' % warning) >>> result.startswith(Fore.RED) False >>> result.startswith(Fore.MAGENTA) True >>> print(result) MAGENTA no previously-included directories found matching devsrc Let's do a combination: >>> message = """ ... Warn: What is the answer to life, the universe and everything? ... ... 41""" >>> result = utils.execute_command('echo "%s" > /dev/stderr' % message) >>> result '\x1b[35mWarn: What is the answer to life, the universe and everything?\n\n\x1b[31m41' >>> print(result) MAGENTA Warn: What is the answer to life, the universe and everything? RED 41 Retrying commands ----------------- Some commands may be retried. For example, upload to PyPI may temporarily fail. Maybe the user has set a wrong password or username in his .pypirc. He can see the error, edit it (outside of the control of zest.releaser) and retry the command. Note that in these tests, the error output might not appear, because the program is exited before the output is printed. Or it may appear twice, once printed and once as return value. Also, the error output of the ``ls`` command we use here, can differ significantly on different systems, so we do not check the exact line. The user can choose to quit: >>> utils.test_answer_book.set_answers(['q']) >>> utils.execute_command('ls some-non-existing-file', allow_retry=True) Traceback (most recent call last): ... zest.releaser.utils.CommandException: Command failed: 'ls some-non-existing-file' The user can choose to not retry and just continue: >>> utils.test_answer_book.set_answers(['n']) >>> result = utils.execute_command('ls some-non-existing-file', allow_retry=True) RED ls... RED There were errors or warnings. Question: Retry this command? [Yes/no/quit/?] Our reply: n >>> print(result) RED ls... And there is the retry. In the end you do have to choose something: >>> utils.test_answer_book.set_answers(['y', 'y', 'n']) >>> result = utils.execute_command('ls some-non-existing-file', allow_retry=True) RED ls... RED There were errors or warnings. Question: Retry this command? [Yes/no/quit/?] Our reply: y RED ls... RED There were errors or warnings. Question: Retry this command? [Yes/no/quit/?] Our reply: y RED ls... RED There were errors or warnings. Question: Retry this command? [Yes/no/quit/?] Our reply: n >>> print(result) RED ls... >>> utils.test_answer_book.set_answers(['y', 'y', 'q']) >>> utils.execute_command('ls some-non-existing-file', allow_retry=True) Traceback (most recent call last): ... zest.releaser.utils.CommandException: Command failed: 'ls some-non-existing-file' Changelog header detection -------------------------- Empty changelog: >>> lines = [] >>> utils.extract_headings_from_history(lines) [] Various forms of version+date lines are recognised. "unreleased" or a date in paretheses: >>> lines = ["1.2 (unreleased)"] >>> pprint(utils.extract_headings_from_history(lines)) [{'date': 'unreleased', 'line': 0, 'version': '1.2'}] >>> lines = ["1.1 (2008-12-25)"] >>> pprint(utils.extract_headings_from_history(lines)) [{'date': '2008-12-25', 'line': 0, 'version': '1.1'}] And dash-separated: >>> lines = ["1.0 - 1972-12-25"] >>> pprint(utils.extract_headings_from_history(lines)) [{'date': '1972-12-25', 'line': 0, 'version': '1.0'}] Versions with beta markers and spaces are fine: >>> lines = ["1.4 beta - unreleased"] >>> pprint(utils.extract_headings_from_history(lines)) [{'date': 'unreleased', 'line': 0, 'version': '1.4 beta'}] Multiple headers: >>> lines = ["1.2 (unreleased)", ... "----------------", ... "", ... "- I did something. [reinout]", ... "", ... "1.1 (2008-12-25)" ... "----------------", ... "", ... "- Played Herodes in church play. [reinout]", ... ""] >>> pprint(utils.extract_headings_from_history(lines)) [{'date': 'unreleased', 'line': 0, 'version': '1.2'}, {'date': '2008-12-25', 'line': 5, 'version': '1.1'}] reST headings ------------- If a second line looks like a reST header line, fix up the length: >>> first = 'Hey, a potential heading' >>> second = '-------' >>> utils.fix_rst_heading(first, second) '------------------------' >>> second = '==' >>> utils.fix_rst_heading(first, second) '========================' >>> second = '``' >>> utils.fix_rst_heading(first, second) '````````````````````````' >>> second = '~~' >>> utils.fix_rst_heading(first, second) '~~~~~~~~~~~~~~~~~~~~~~~~' No header line? Just return the second line as-is: >>> second = 'just some text' >>> utils.fix_rst_heading(first, second) 'just some text' Empty line? Just return it. >>> second = '' >>> utils.fix_rst_heading(first, second) '' The second line must be uniform: >>> second = '- bullet point, no header' >>> utils.fix_rst_heading(first, second) '- bullet point, no header' Safe setup.py running --------------------- ``setup_py()`` returns the ``python setup.py xyz`` command line by using sys.executable. >>> cmd = utils.setup_py('cook a cow') >>> print(cmd.replace(sys.executable, 'python')) # test normalization python setup.py cook a cow When the setup.py arguments include arguments that indicate pypi interaction, the python executable is replaced by ``echo`` for safety reasons. This only happens when TESTMODE is on: >>> utils.TESTMODE = True >>> print(utils.setup_py('upload')) echo MOCK setup.py upload >>> print(utils.setup_py('register')) echo MOCK setup.py register Data dict documentation ----------------------- The releasers have a data dict that is passed to entry points (and used internally). Because of the entry points, good documentation is necessary. So we can check whether all keys have attached documentation: >>> data = {'commit_msg': 'get me some booze'} >>> documentation = {'commit_msg': 'Commit message for svn', ... 'target': 'Some other thingy'} >>> utils.is_data_documented(data, documentation) Checking data dict We print a warning when something is undocumented: >>> data = {'version': '0.1'} >>> utils.is_data_documented(data, documentation) Checking data dict Internal detail: key(s) ['version'] are not documented