Source code for datalad.cli.tests.test_main

# emacs: -*- mode: python-mode; py-indent-offset: 4; tab-width: 4; indent-tabs-mode: nil -*-
# ex: set sts=4 ts=4 sw=4 et:
# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
#
#   See COPYING file distributed along with the datalad package for the
#   copyright and license terms.
#
# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
"""Test functioning of the datalad main cmdline utility """

import os
import re
from io import StringIO
from unittest.mock import patch

import pytest

import datalad
from datalad import __version__
from datalad.api import (
    Dataset,
    create,
)
from datalad.cmd import StdOutErrCapture
from datalad.cmd import WitlessRunner as Runner
from datalad.interface.base import get_interface_groups
from datalad.tests.utils_pytest import (
    SkipTest,
    assert_equal,
    assert_in,
    assert_not_in,
    assert_raises,
    assert_re_in,
    eq_,
    in_,
    ok_,
    ok_startswith,
    on_windows,
    skip_if_no_module,
    slow,
    with_tempfile,
)
from datalad.ui.utils import (
    get_console_width,
    get_terminal_size,
)
from datalad.utils import chpwd

from ..helpers import get_commands_from_groups
from ..main import main


[docs] def run_main(args, exit_code=0, expect_stderr=False): """Run main() of the datalad, do basic checks and provide outputs Parameters ---------- args : list List of string cmdline arguments to pass exit_code : int Expected exit code. Would raise AssertionError if differs expect_stderr : bool or string Whether to expect stderr output. If string -- match Returns ------- stdout, stderr strings Output produced """ was_mode = datalad.__api try: # we need to catch "stdout" from multiple places: # sys.stdout but also from the UI, which insists on holding # a dedicated handle fakeout = StringIO() fakeerr = StringIO() with patch('sys.stderr', new=fakeerr) as cmerr, \ patch('sys.stdout', new=fakeout) as cmout, \ patch.object(datalad.ui.ui._ui, 'out', new=fakeout): with assert_raises(SystemExit) as cm: main(["datalad"] + list(args)) eq_('cmdline', datalad.get_apimode()) assert_equal(cm.value.code, exit_code) stdout = cmout.getvalue() stderr = cmerr.getvalue() if expect_stderr is False: assert_equal(stderr, "") elif expect_stderr is True: # do nothing -- just return pass else: # must be a string assert_equal(stderr, expect_stderr) finally: # restore what we had datalad.__api = was_mode return stdout, stderr
def get_all_commands() -> list: return list(get_commands_from_groups(get_interface_groups())) def assert_all_commands_present(out): """Helper to reuse to assert that all known commands are present in output """ for cmd in get_all_commands(): assert_re_in(fr"\b{cmd}\b", out, match=False) # TODO: switch to stdout for --version output def test_version(): # we just get a version if not asking for a version of some command stdout, stderr = run_main(['--version'], expect_stderr=True) eq_(stdout.rstrip(), "datalad %s" % datalad.__version__) stdout, stderr = run_main(['clone', '--version'], expect_stderr=True) ok_startswith(stdout, 'datalad %s\n' % datalad.__version__) # since https://github.com/datalad/datalad/pull/2733 no license in --version assert_not_in("Copyright", stdout) assert_not_in("Permission is hereby granted", stdout) try: import datalad_container except ImportError: pass # not installed, cannot test with extension else: stdout, stderr = run_main(['containers-list', '--version'], expect_stderr=True) eq_(stdout, 'datalad_container %s\n' % datalad_container.__version__) def test_help_np(): stdout, stderr = run_main(['--help-np']) # Let's extract section titles: # enough of bin/datalad and .tox/py27/bin/datalad -- guarantee consistency! ;) ok_startswith(stdout, 'Usage: datalad') # Sections start/end with * if ran under DATALAD_HELP2MAN mode sections = [l[1:-1] for l in filter(re.compile(r'^\*.*\*$').match, stdout.split('\n'))] for s in {'Essential', 'Miscellaneous', 'General information', 'Global options', 'Plumbing', }: assert_in(s, sections) # should be present only one time! eq_(stdout.count(f'*{s}*'), 1) # check that we have global options actually listed after "Global options" # ATM -c is the first such option assert re.search(r"Global options\W*-c ", stdout, flags=re.MULTILINE) # and -c should be listed only once - i.e. that we do not duplicate sections # and our USAGE summary has only [global-opts] assert re.match(r"Usage: .*datalad.* \[global-opts\] command \[command-opts\]", stdout) assert stdout.count(' -c ') == 1 assert_all_commands_present(stdout) if not get_terminal_size()[0] or 0: raise SkipTest( "Could not determine terminal size, skipping the rest of the test") # none of the lines must be longer than 80 chars # TODO: decide on create-sibling and possibly # rewrite-urls accepted_width = get_console_width() long_lines = ["%d %s" % (len(l), l) for l in stdout.split('\n') if len(l) > accepted_width and '{' not in l # on nd70 summary line is unsplit ] if long_lines: raise AssertionError( "Following lines in --help output were longer than %s chars:\n%s" % (accepted_width, '\n'.join(long_lines)) ) def test_dashh(): stdout, stderr = run_main(['-h']) # Note: for -h we do not do ad-hoc tune up of Usage: to guarantee having # datalad instead of python -m nose etc, so we can only verify that we have # options listed assert_re_in(r'^Usage: .*\[', stdout.splitlines()[0]) assert_all_commands_present(stdout) assert_re_in('Use .--help. to get more comprehensive information', stdout.splitlines()) def test_dashh_clone(): # test -h on a sample command stdout, stderr = run_main(['clone', '-h']) assert_re_in(r'^Usage: .* clone \[', stdout.splitlines()[0]) assert_re_in('Use .--help. to get more comprehensive information', stdout.splitlines()) def test_usage_on_insufficient_args(): stdout, stderr = run_main(['install'], exit_code=2, expect_stderr=True) ok_startswith(stderr, 'usage:') def test_subcmd_usage_on_unknown_args(): stdout, stderr = run_main(['get', '--murks'], exit_code=1, expect_stderr=True) in_('get', stdout) def test_combined_short_option(): stdout, stderr = run_main(['-fjson'], exit_code=2, expect_stderr=True) assert_not_in("unrecognized argument", stderr) assert_in("too few arguments", stderr) # https://github.com/datalad/datalad/issues/6814 @with_tempfile(mkdir=True) def test_conflicting_short_option(tempdir=None): # datalad -f|--format requires a value. regression made parser ignore command # and its options with chpwd(tempdir): # can't just use -C tempdir since we do "in process" run_main run_main(['create', '-f']) # apparently a bit different if following a good one so let's do both err_invalid = "error: (invalid|too few arguments|unrecognized argument)" err_insufficient = err_invalid # "specify" @pytest.mark.parametrize( "opts,err_str", [ (('--buga',), err_invalid), (('--dbg', '--buga'), err_invalid), (('--dbg',), err_insufficient), (tuple(), err_insufficient), ] ) def test_incorrect_option(opts, err_str): # The first line used to be: # stdout, stderr = run_main((sys.argv[0],) + opts, expect_stderr=True, exit_code=2) # But: what do we expect to be in sys.argv[0] here? # It depends on how we invoke the test. # - nosetests -s -v datalad/cmdline/tests/test_main.py would result in: # sys.argv[0}=='nosetests' # - python -m nose -s -v datalad/cmdline/tests/test_main.py would result in: # sys.argv[0}=='python -m nose' # - python -c "import nose; nose.main()" -s -v datalad/cmdline/tests/test_main.py would result in: # sys.argv[0]=='-c' # This led to failure in case sys.argv[0] contained an option, that was # defined to be a datalad option too, therefore was a 'known_arg' and was # checked to meet its constraints. # But sys.argv[0] actually isn't used by main at all. It simply doesn't # matter what's in there. The only thing important to pass here is `opts`. stdout, stderr = run_main(opts, expect_stderr=True, exit_code=2) out = stdout + stderr assert_in("usage: ", out) assert_re_in(err_str, out, match=False) @pytest.mark.parametrize( "script", [ 'datalad', 'git-annex-remote-datalad-archives', 'git-annex-remote-datalad', ] ) def test_script_shims(script): runner = Runner() if not on_windows: from shutil import which which(script) # and let's check that it is our script out = runner.run([script, '--version'], protocol=StdOutErrCapture) version = out['stdout'].rstrip() mod, version = version.split(' ', 1) assert_equal(mod, 'datalad') # we can get git and non git .dev version... so for now # relax get_numeric_portion = lambda v: [x for x in re.split('[+.]', v) if x.isdigit()] # extract numeric portion assert get_numeric_portion(version), f"Got no numeric portion from {version}" assert_equal(get_numeric_portion(__version__), get_numeric_portion(version)) @with_tempfile(mkdir=True) def test_cfg_override(path=None): with chpwd(path): # use 'wtf' to dump the config # should be rewritten to use `configuration` cmd = ['datalad', 'wtf', '-S', 'configuration', '-s', 'some'] # control out = Runner().run(cmd, protocol=StdOutErrCapture)['stdout'] assert_not_in('datalad.dummy: this', out) # ensure that this is not a dataset's cfg manager assert_not_in('datalad.dataset.id', out) # env var out = Runner(env=dict(os.environ, DATALAD_DUMMY='this')).run( cmd, protocol=StdOutErrCapture)['stdout'] assert_in('datalad.dummy: this', out) # cmdline arg out = Runner().run([cmd[0], '-c', 'datalad.dummy=this'] + cmd[1:], protocol=StdOutErrCapture)['stdout'] assert_in('datalad.dummy: this', out) # now create a dataset in the path. the wtf plugin will switch to # using the dataset's config manager, which must inherit the overrides create(dataset=path, annex=False) # control out = Runner().run(cmd, protocol=StdOutErrCapture)['stdout'] assert_not_in('datalad.dummy: this', out) # ensure that this is a dataset's cfg manager assert_in('datalad.dataset.id', out) # env var out = Runner(env=dict(os.environ, DATALAD_DUMMY='this')).run( cmd, protocol=StdOutErrCapture)['stdout'] assert_in('datalad.dummy: this', out) # cmdline arg out = Runner().run([cmd[0], '-c', 'datalad.dummy=this'] + cmd[1:], protocol=StdOutErrCapture)['stdout'] assert_in('datalad.dummy: this', out) # set a config run_main([ 'configuration', '--scope', 'local', 'set', 'mike.item=some']) # verify it is successfully set assert 'some' == run_main([ 'configuration', 'get', 'mike.item'])[0].strip() # verify that an override can unset the config # we cannot use run_main(), because the "singleton" instance of the # dataset we are in is still around in this session, and with it # also its config managers that we will not be able to post-hoc # overwrite with this method. Instead, we'll execute in a subprocess. assert '' == Runner().run([ 'datalad', '-c', ':mike.item', 'configuration', 'get', 'mike.item'], protocol=StdOutErrCapture)['stdout'].strip() # verify the effect is not permanent assert 'some' == Runner().run([ 'datalad', 'configuration', 'get', 'mike.item'], protocol=StdOutErrCapture)['stdout'].strip() def test_incorrect_cfg_override(): run_main(['-c', 'some', 'wtf'], exit_code=3) run_main(['-c', 'some=', 'wtf'], exit_code=3) run_main(['-c', 'some.var', 'wtf'], exit_code=3) run_main(['-c', 'some.var=', 'wtf'], exit_code=3) @with_tempfile def test_librarymode(path=None): Dataset(path).create() was_mode = datalad.__runtime_mode try: # clean --dry-run is just a no-op command that is cheap # to execute. It has no particular role here, other than # to make the code pass the location where library mode # should be turned on via the cmdline API run_main(['-c', 'datalad.runtime.librarymode=yes', 'clean', '-d', path, '--dry-run']) ok_(datalad.in_librarymode()) finally: # restore pre-test behavior datalad.__runtime_mode = was_mode datalad.cfg.overrides.pop('datalad.runtime.librarymode') @with_tempfile def test_completion(out_fn=None): skip_if_no_module('argcomplete') from datalad.cmd import WitlessRunner runner = WitlessRunner() def get_completions(s: str, expected) -> list: """Run 'datalad' external command and collect completions Parameters ---------- s: str what to append to 'datalad ' invocation expected: iterable of str What entries to expect - would raise AssertionError if any is not present in output exit_code: int, optional If incomplete/malformed we seems to get 2, most frequently used so default Returns ------- list of str Entries output """ if os.path.exists(out_fn): # reuse but ensure it is gone os.unlink(out_fn) comp_line = f'datalad {s}' runner.run( comp_line.split(' '), env=dict(os.environ, _ARGCOMPLETE='1', _ARGCOMPLETE_STDOUT_FILENAME=out_fn, COMP_LINE=comp_line, # without -1 seems to get "finished completion", someone can investigate more COMP_POINT=str(len(comp_line)-1), # always at the end ATM )) with open(out_fn, 'rb') as f: entries = f.read().split(b'\x0b') entries = [e.decode() for e in entries] diff = set(expected).difference(entries) if diff: raise AssertionError( f"Entries {sorted(diff)} were expected but not found in the completion output: {entries}" ) return entries # for extra analyzes if so desired all_commands = get_all_commands() get_completions('i', {'install'}) get_completions(' ', ['--dbg', '-c'] + all_commands) # if command already matches -- we get only that hit ATM, not others which begin with it get_completions('create', ['create ']) get_completions('create -', ['--dataset']) # but for incomplete one we do get all create* commands get_completions('creat', [c for c in all_commands if c.startswith('create')])