Skip to content

Commit

Permalink
Replacement for with_testsui
Browse files Browse the repository at this point in the history
This provides two fixtures

- `datalad_interactive_ui`
- `datalad_noninteractive_ui`

that can be used to test user interaction or communication sequences.
Unlike the datalad-core test UI, this is not only provisioning
user input. In addition, it allows for inspecting a detailed UI
call log, and offers a text-rendering to easy debugging:

```
(Pdb) print(iu)
InteractiveTestUI(
  question: (('attr1',), {'title': 'dummyquestion'})
  response: attr1
  question: (('attr2',), {'title': None})
  response: attr2
  question: (('secret',), {'title': None, 'repeat': True, 'hidden': True})
  response: secret
  (unused responses: [])
)
```

Closes #423
  • Loading branch information
mih committed Jun 21, 2023
1 parent 5419a3f commit 74b3fc1
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 53 deletions.
24 changes: 10 additions & 14 deletions datalad_next/commands/tests/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
eq_,
run_main,
swallow_logs,
with_testsui,
)


Expand Down Expand Up @@ -73,13 +72,6 @@ def test_normalize_specs():
assert_raises(ValueError, normalize_specs, error)


def test_credentials(tmp_keyring):
# we want all tests to bypass the actual system keyring
check_credentials_cli()
check_interactive_entry_set()
check_interactive_entry_get()


def test_errorhandling_smoketest():
callcfg = dict(on_failure='ignore', result_renderer='disabled')

Expand All @@ -94,8 +86,7 @@ def test_errorhandling_smoketest():
status='error', name='dummy')



def check_credentials_cli():
def test_credentials_cli(tmp_keyring):
# usable command
cred = Credentials()
# unknown action
Expand Down Expand Up @@ -145,8 +136,10 @@ def check_credentials_cli():
run_main(['credentials', 'query'], exit_code=0)


@with_testsui(responses=['attr1', 'attr2', 'secret'])
def check_interactive_entry_get():
def test_interactive_entry_get(tmp_keyring, datalad_interactive_ui):
ui = datalad_interactive_ui
ui.staged_responses.extend([
'attr1', 'attr2', 'secret'])
# should ask all properties in order and the secret last
cred = Credentials()
assert_in_results(
Expand All @@ -160,10 +153,12 @@ def check_interactive_entry_get():
cred_attr2='attr2',
cred_secret='secret',
)
assert ui.operation_sequence == ['question', 'response'] * 3


@with_testsui(responses=['secretish'])
def check_interactive_entry_set():
def test_interactive_entry_set(tmp_keyring, datalad_interactive_ui):
ui = datalad_interactive_ui
ui.staged_responses.append('secretish')
# should ask all properties in order and the secret last
cred = Credentials()
assert_in_results(
Expand All @@ -173,6 +168,7 @@ def check_interactive_entry_set():
result_renderer='disabled'),
cred_secret='secretish',
)
assert ui.operation_sequence == ['question', 'response']


def test_result_renderer():
Expand Down
33 changes: 19 additions & 14 deletions datalad_next/commands/tests/test_download.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from io import StringIO
import json
from pathlib import Path
import pytest

import datalad
Expand All @@ -11,19 +10,21 @@
from datalad_next.tests.utils import (
assert_result_count,
assert_status,
with_testsui,
)
from datalad_next.utils import chpwd

from datalad_next.utils import CredentialManager


@pytest.fixture
def hbsurl(httpbin):
# shortcut for the standard URL
return httpbin["standard"]


test_cred = ('dltest-my&=http', 'datalad', 'secure')


@pytest.fixture
def hbscred(hbsurl):
return (
Expand Down Expand Up @@ -202,12 +203,14 @@ def test_download_no_credential_leak_to_http(credman, capsys, hbscred, httpbin):
assert_status('error', res)


@with_testsui(responses=[
'token123',
# after download, it asks for a name
'dataladtest_test_download_new_bearer_token',
])
def test_download_new_bearer_token(tmp_keyring, capsys, hbsurl):
def test_download_new_bearer_token(
tmp_keyring, capsys, hbsurl, datalad_interactive_ui):
ui = datalad_interactive_ui
ui.staged_responses.extend([
'token123',
# after download, it asks for a name
'dataladtest_test_download_new_bearer_token',
])
try:
download({f'{hbsurl}/bearer': '-'})
# and it was saved under this name
Expand All @@ -224,12 +227,14 @@ def test_download_new_bearer_token(tmp_keyring, capsys, hbsurl):
)


@with_testsui(responses=[
'datalad_uniquetoken123',
# after download, it asks for a name, but skip to save
'skip',
])
def test_download_new_bearer_token_nosave(capsys, hbsurl):
def test_download_new_bearer_token_nosave(
capsys, hbsurl, datalad_interactive_ui):
ui = datalad_interactive_ui
ui.staged_responses.extend([
'datalad_uniquetoken123',
# after download, it asks for a name, but skip to save
'skip',
])
download({f'{hbsurl}/bearer': '-'})
# and it was saved under this name
assert_result_count(
Expand Down
4 changes: 4 additions & 0 deletions datalad_next/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
credman,
# function-scope config manager
datalad_cfg,
# function-scope UI wrapper that can provide staged responses
datalad_interactive_ui,
# function-scope UI wrapper that can will raise when asked for responses
datalad_noninteractive_ui,
# function-scope temporary keyring
tmp_keyring,
# function-scope, Dataset instance
Expand Down
58 changes: 34 additions & 24 deletions datalad_next/credman/tests/test_credman.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
assert_in,
assert_raises,
eq_,
with_testsui,
)
from datalad_next.utils import chpwd


def test_credmanager(tmp_keyring, datalad_cfg):
def test_credmanager(tmp_keyring, datalad_cfg, datalad_interactive_ui):
ui = datalad_interactive_ui
credman = CredentialManager(datalad_cfg)
# doesn't work with thing air
assert_raises(ValueError, credman.get)
Expand Down Expand Up @@ -107,44 +107,49 @@ def test_credmanager(tmp_keyring, datalad_cfg):
eq_(credman.get('mycred'), None)

# test prompting for a secret when none is given
res = with_testsui(responses=['mysecret'])(credman.set)(
'mycred', other='prop')
ui.staged_responses.append('mysecret')
res = credman.set('mycred', other='prop')
assert res == {'other': 'prop', 'secret': 'mysecret'}

# test prompting for a name when None is given
res = with_testsui(responses=['mycustomname'])(credman.set)(
None, secret='dummy', other='prop')
ui.staged_responses.append('mycustomname')
res = credman.set(None, secret='dummy', other='prop')
assert res == {'name': 'mycustomname', 'other': 'prop', 'secret': 'dummy'}

# test name prompt loop in case of a name collision
res = with_testsui(
responses=['mycustomname', 'mycustomname2'])(
credman.set)(
None, secret='dummy2', other='prop2')
ui.staged_responses.extend(['mycustomname', 'mycustomname2'])
res = credman.set(None, secret='dummy2', other='prop2')
assert res == {'name': 'mycustomname2', 'other': 'prop2',
'secret': 'dummy2'}

# test skipping at prompt, smoke test _context arg
res = with_testsui(responses=['skip'])(credman.set)(
ui.staged_responses.append('skip')
res = credman.set(
None, _context='for me', secret='dummy', other='prop')
assert res is None

# if no name is provided and none _can_ be entered -> raise
with pytest.raises(ValueError):
credman.set(None, secret='dummy', other='prop')

# accept suggested name
res = with_testsui(responses=[''])(credman.set)(
ui.staged_responses.append('')
res = credman.set(
None, _suggested_name='auto1', secret='dummy', other='prop')
assert res == {'name': 'auto1', 'other': 'prop', 'secret': 'dummy'}

# a suggestion conflicting with an existing credential is like
# not making a suggestion at all
res = with_testsui(responses=['', 'auto2'])(credman.set)(
ui.staged_responses.extend(('', 'auto2'))
res = credman.set(
None, _suggested_name='auto1', secret='dummy', other='prop')
assert res == {'name': 'auto2', 'other': 'prop', 'secret': 'dummy'}


def test_credmanager_set_noninteractive(
tmp_keyring, datalad_cfg, datalad_noninteractive_ui):
credman = CredentialManager(datalad_cfg)
# if no name is provided and none _can_ be entered -> raise
with pytest.raises(ValueError):
credman.set(None, secret='dummy', other='prop')


def test_credman_local(existing_dataset):
ds = existing_dataset
credman = CredentialManager(ds.config)
Expand Down Expand Up @@ -193,16 +198,19 @@ def test_query(tmp_keyring, datalad_cfg):
[i[0] for i in slist])


def test_credman_get(datalad_cfg):
def test_credman_get(datalad_cfg, datalad_interactive_ui):
ui = datalad_interactive_ui
# we are not making any writes, any config must work
credman = CredentialManager(datalad_cfg)
# must be prompting for missing properties
res = with_testsui(responses=['myuser'])(credman.get)(
ui.staged_responses.append('myuser')
res = credman.get(
None, _type_hint='user_password', _prompt='myprompt',
secret='dummy')
assert 'myuser' == res['user']
# same for the secret
res = with_testsui(responses=['mysecret'])(credman.get)(
ui.staged_responses.append('mysecret')
res = credman.get(
None, _type_hint='user_password', _prompt='myprompt',
user='dummy')
assert 'mysecret' == res['secret']
Expand All @@ -223,7 +231,8 @@ def test_credman_get_guess_type():
}


def test_credman_obtain(tmp_keyring, datalad_cfg):
def test_credman_obtain(tmp_keyring, datalad_cfg, datalad_interactive_ui):
ui = datalad_interactive_ui
credman = CredentialManager(datalad_cfg)
# senseless, but valid call
# could not possibly report a credential without any info
Expand All @@ -236,8 +245,8 @@ def test_credman_obtain(tmp_keyring, datalad_cfg):
with pytest.raises(ValueError):
credman.obtain(prompt='myprompt')
# minimal condition prompt and type-hint for manual entry
res = with_testsui(responses=['mytoken'])(credman.obtain)(
type_hint='token', prompt='myprompt')
ui.staged_responses.append('mytoken')
res = credman.obtain(type_hint='token', prompt='myprompt')
assert res == (None,
{'type': 'token', 'secret': 'mytoken', '_edited': True})

Expand Down Expand Up @@ -265,7 +274,8 @@ def test_credman_obtain(tmp_keyring, datalad_cfg):
res = credman.obtain(query_props={'realm': 'myrealm'})
# if we are looking for a realm, we get it back even if a credential
# had to be entered
res = with_testsui(responses=['mynewtoken'])(credman.obtain)(
ui.staged_responses.append('mynewtoken')
res = credman.obtain(
type_hint='token', prompt='myprompt',
query_props={'realm': 'mytotallynewrealm'})
assert res == (None,
Expand Down
42 changes: 42 additions & 0 deletions datalad_next/tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,45 @@ def httpbin(httpbin_service):
"docker-deployed instance -- too unreliable"
)
yield httpbin_service


@pytest.fixture(autouse=False, scope="function")
def datalad_interactive_ui(monkeypatch):
"""Yields a UI replacement to query for operations and stage responses
No output will be written to STDOUT/ERR by this UI.
A standard usage pattern is to stage one or more responses, run the
to-be-tested code, and verify that the desired user interaction
took place::
> datalad_interactive_ui.staged_responses.append('skip')
> ...
> assert ... datalad_interactive_ui.log
"""
from datalad_next.uis import ui_switcher
from datalad_next.tests.utils import InteractiveTestUI

with monkeypatch.context() as m:
m.setattr(ui_switcher, '_ui', InteractiveTestUI())
yield ui_switcher.ui


@pytest.fixture(autouse=False, scope="function")
def datalad_noninteractive_ui(monkeypatch):
"""Yields a UI replacement to query for operations
No output will be written to STDOUT/ERR by this UI.
A standard usage pattern is to run the to-be-tested code, and verify that
the desired user messaging took place::
> ...
> assert ... datalad_interactive_ui.log
"""
from datalad_next.uis import ui_switcher
from datalad_next.tests.utils import TestUI

with monkeypatch.context() as m:
m.setattr(ui_switcher, '_ui', TestUI())
yield ui_switcher.ui
Loading

0 comments on commit 74b3fc1

Please sign in to comment.