diff options
-rw-r--r-- | gitlab/client.py | 4 | ||||
-rw-r--r-- | gitlab/config.py | 8 | ||||
-rw-r--r-- | tests/functional/cli/test_cli.py | 6 | ||||
-rw-r--r-- | tests/unit/helpers.py | 3 | ||||
-rw-r--r-- | tests/unit/mixins/test_mixin_methods.py | 55 | ||||
-rw-r--r-- | tests/unit/test_base.py | 26 | ||||
-rw-r--r-- | tests/unit/test_config.py | 66 | ||||
-rw-r--r-- | tests/unit/test_exceptions.py | 12 | ||||
-rw-r--r-- | tests/unit/test_gitlab.py | 61 | ||||
-rw-r--r-- | tests/unit/test_gitlab_http_methods.py | 44 | ||||
-rw-r--r-- | tests/unit/test_utils.py | 52 | ||||
-rw-r--r-- | tox.ini | 1 |
12 files changed, 304 insertions, 34 deletions
diff --git a/gitlab/client.py b/gitlab/client.py index 2ac5158..bba5c1d 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -208,7 +208,9 @@ class Gitlab: self.__dict__.update(state) # We only support v4 API at this time if self._api_version not in ("4",): - raise ModuleNotFoundError(name=f"gitlab.v{self._api_version}.objects") + raise ModuleNotFoundError( + name=f"gitlab.v{self._api_version}.objects" + ) # pragma: no cover, dead code currently # NOTE: We must delay import of gitlab.v4.objects until now or # otherwise it will cause circular import errors import gitlab.v4.objects diff --git a/gitlab/config.py b/gitlab/config.py index c85d7e5..337a265 100644 --- a/gitlab/config.py +++ b/gitlab/config.py @@ -154,7 +154,7 @@ class GitlabConfigParser: # CA bundle. try: self.ssl_verify = _config.get("global", "ssl_verify") - except Exception: + except Exception: # pragma: no cover pass except Exception: pass @@ -166,7 +166,7 @@ class GitlabConfigParser: # CA bundle. try: self.ssl_verify = _config.get(self.gitlab_id, "ssl_verify") - except Exception: + except Exception: # pragma: no cover pass except Exception: pass @@ -197,7 +197,9 @@ class GitlabConfigParser: try: self.http_username = _config.get(self.gitlab_id, "http_username") - self.http_password = _config.get(self.gitlab_id, "http_password") + self.http_password = _config.get( + self.gitlab_id, "http_password" + ) # pragma: no cover except Exception: pass diff --git a/tests/functional/cli/test_cli.py b/tests/functional/cli/test_cli.py index a889066..0da50e6 100644 --- a/tests/functional/cli/test_cli.py +++ b/tests/functional/cli/test_cli.py @@ -27,6 +27,12 @@ def test_version(script_runner): assert ret.stdout.strip() == __version__ +def test_config_error_with_help_prints_help(script_runner): + ret = script_runner.run("gitlab", "-c", "invalid-file", "--help") + assert ret.stdout.startswith("usage:") + assert ret.returncode == 0 + + @pytest.mark.script_launch_mode("inprocess") @responses.activate def test_defaults_to_gitlab_com(script_runner, resp_get_project, monkeypatch): diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py index 33a7c78..54b2b74 100644 --- a/tests/unit/helpers.py +++ b/tests/unit/helpers.py @@ -4,6 +4,9 @@ import json from typing import Optional import requests +import responses + +MATCH_EMPTY_QUERY_PARAMS = [responses.matchers.query_param_matcher({})] # NOTE: The function `httmock_response` and the class `Headers` is taken from diff --git a/tests/unit/mixins/test_mixin_methods.py b/tests/unit/mixins/test_mixin_methods.py index 241cba3..c0b0a58 100644 --- a/tests/unit/mixins/test_mixin_methods.py +++ b/tests/unit/mixins/test_mixin_methods.py @@ -97,8 +97,17 @@ def test_list_mixin(gl): pass url = "http://localhost/api/v4/tests" + headers = { + "X-Page": "1", + "X-Next-Page": "2", + "X-Per-Page": "1", + "X-Total-Pages": "2", + "X-Total": "2", + "Link": ("<http://localhost/api/v4/tests" ' rel="next"'), + } responses.add( method=responses.GET, + headers=headers, url=url, json=[{"id": 42, "foo": "bar"}, {"id": 43, "foo": "baz"}], status=200, @@ -109,6 +118,14 @@ def test_list_mixin(gl): mgr = M(gl) obj_list = mgr.list(iterator=True) assert isinstance(obj_list, base.RESTObjectList) + assert obj_list.current_page == 1 + assert obj_list.prev_page is None + assert obj_list.next_page == 2 + assert obj_list.per_page == 1 + assert obj_list.total == 2 + assert obj_list.total_pages == 2 + assert len(obj_list) == 2 + for obj in obj_list: assert isinstance(obj, FakeObject) assert obj.id in (42, 43) @@ -255,6 +272,25 @@ def test_update_mixin(gl): @responses.activate +def test_update_mixin_uses_post(gl): + class M(UpdateMixin, FakeManager): + _update_uses_post = True + + url = "http://localhost/api/v4/tests/1" + responses.add( + method=responses.POST, + url=url, + json={}, + status=200, + match=[responses.matchers.query_param_matcher({})], + ) + + mgr = M(gl) + mgr.update(1, {}) + assert responses.assert_call_count(url, 1) is True + + +@responses.activate def test_update_mixin_no_id(gl): class M(UpdateMixin, FakeManager): _create_attrs = base.RequiredOptional( @@ -324,6 +360,25 @@ def test_save_mixin(gl): @responses.activate +def test_save_mixin_without_new_data(gl): + class M(UpdateMixin, FakeManager): + pass + + class TestClass(SaveMixin, base.RESTObject): + pass + + url = "http://localhost/api/v4/tests/1" + responses.add(method=responses.PUT, url=url) + + mgr = M(gl) + obj = TestClass(mgr, {"id": 1, "foo": "bar"}) + obj.save() + + assert obj._attrs["foo"] == "bar" + assert responses.assert_call_count(url, 0) is True + + +@responses.activate def test_set_mixin(gl): class M(SetMixin, FakeManager): pass diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index 0a7f353..9c4b65f 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -78,13 +78,15 @@ class TestRESTManager: class TestRESTObject: def test_instantiate(self, fake_gitlab, fake_manager): - obj = FakeObject(fake_manager, {"foo": "bar"}) + attrs = {"foo": "bar"} + obj = FakeObject(fake_manager, attrs.copy()) - assert {"foo": "bar"} == obj._attrs + assert attrs == obj._attrs assert {} == obj._updated_attrs assert obj._create_managers() is None assert fake_manager == obj.manager assert fake_gitlab == obj.manager.gitlab + assert str(obj) == f"{type(obj)} => {attrs}" def test_instantiate_non_dict(self, fake_gitlab, fake_manager): with pytest.raises(gitlab.exceptions.GitlabParsingError): @@ -201,6 +203,7 @@ class TestRESTObject: obj1 = FakeObject(fake_manager, {"id": "foo"}) obj2 = FakeObject(fake_manager, {"id": "foo", "other_attr": "bar"}) assert obj1 == obj2 + assert len(set((obj1, obj2))) == 1 def test_equality_custom_id(self, fake_manager): class OtherFakeObject(FakeObject): @@ -210,6 +213,11 @@ class TestRESTObject: obj2 = OtherFakeObject(fake_manager, {"foo": "bar", "other_attr": "baz"}) assert obj1 == obj2 + def test_equality_no_id(self, fake_manager): + obj1 = FakeObject(fake_manager, {"attr1": "foo"}) + obj2 = FakeObject(fake_manager, {"attr1": "bar"}) + assert not obj1 == obj2 + def test_inequality(self, fake_manager): obj1 = FakeObject(fake_manager, {"id": "foo"}) obj2 = FakeObject(fake_manager, {"id": "bar"}) @@ -219,6 +227,12 @@ class TestRESTObject: obj1 = FakeObject(fake_manager, {"attr1": "foo"}) obj2 = FakeObject(fake_manager, {"attr1": "bar"}) assert obj1 != obj2 + assert len(set((obj1, obj2))) == 2 + + def test_equality_with_other_objects(self, fake_manager): + obj1 = FakeObject(fake_manager, {"id": "foo"}) + obj2 = None + assert not obj1 == obj2 def test_dunder_str(self, fake_manager): fake_object = FakeObject(fake_manager, {"attr1": "foo"}) @@ -280,3 +294,11 @@ class TestRESTObject: " 'ham': 'eggseggseggseggseggseggseggseggseggseggseggseggseggseggseggs'}\n" ) assert stderr == "" + + def test_repr(self, fake_manager): + attrs = {"attr1": "foo"} + obj = FakeObject(fake_manager, attrs) + assert repr(obj) == "<FakeObject id:None>" + + FakeObject._id_attr = None + assert repr(obj) == "<FakeObject>" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 7ba312b..4a96bf8 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -54,6 +54,15 @@ url = https://four.url oauth_token = STUV """ +ssl_verify_str_config = """[global] +default = one +ssl_verify = /etc/ssl/certs/ca-certificates.crt + +[one] +url = http://one.url +private_token = ABCDEF +""" + custom_user_agent_config = f"""[global] default = one user_agent = {custom_user_agent} @@ -69,7 +78,7 @@ url = http://there.url private_token = ABCDEF """ -missing_attr_config = """[global] +invalid_data_config = """[global] [one] url = http://one.url @@ -83,6 +92,11 @@ meh = hem url = http://four.url private_token = ABCDEF per_page = 200 + +[invalid-api-version] +url = http://invalid-api-version.url +private_token = ABCDEF +api_version = 1 """ @@ -173,7 +187,7 @@ def test_invalid_id(m_open, mock_clean_env, monkeypatch): @mock.patch("builtins.open") def test_invalid_data(m_open, monkeypatch): - fd = io.StringIO(missing_attr_config) + fd = io.StringIO(invalid_data_config) fd.close = mock.Mock(return_value=None, side_effect=lambda: fd.seek(0)) m_open.return_value = fd @@ -185,9 +199,14 @@ def test_invalid_data(m_open, monkeypatch): config.GitlabConfigParser(gitlab_id="two") with pytest.raises(config.GitlabDataError): config.GitlabConfigParser(gitlab_id="three") - with pytest.raises(config.GitlabDataError) as emgr: + + with pytest.raises(config.GitlabDataError) as e: config.GitlabConfigParser("four") - assert "Unsupported per_page number: 200" == emgr.value.args[0] + assert str(e.value) == "Unsupported per_page number: 200" + + with pytest.raises(config.GitlabDataError) as e: + config.GitlabConfigParser("invalid-api-version") + assert str(e.value) == "Unsupported API version: 1" @mock.patch("builtins.open") @@ -249,6 +268,18 @@ def test_valid_data(m_open, monkeypatch): @mock.patch("builtins.open") +def test_ssl_verify_as_str(m_open, monkeypatch): + fd = io.StringIO(ssl_verify_str_config) + fd.close = mock.Mock(return_value=None) + m_open.return_value = fd + + with monkeypatch.context() as m: + m.setattr(Path, "resolve", _mock_existent_file) + cp = config.GitlabConfigParser() + assert cp.ssl_verify == "/etc/ssl/certs/ca-certificates.crt" + + +@mock.patch("builtins.open") @pytest.mark.skipif(sys.platform.startswith("win"), reason="Not supported on Windows") def test_data_from_helper(m_open, monkeypatch, tmp_path): helper = tmp_path / "helper.sh" @@ -287,6 +318,33 @@ def test_data_from_helper(m_open, monkeypatch, tmp_path): @mock.patch("builtins.open") +@pytest.mark.skipif(sys.platform.startswith("win"), reason="Not supported on Windows") +def test_from_helper_subprocess_error_raises_error(m_open, monkeypatch): + # using /usr/bin/false here to force a non-zero return code + fd = io.StringIO( + dedent( + """\ + [global] + default = helper + + [helper] + url = https://helper.url + oauth_token = helper: /usr/bin/false + """ + ) + ) + + fd.close = mock.Mock(return_value=None) + m_open.return_value = fd + with monkeypatch.context() as m: + m.setattr(Path, "resolve", _mock_existent_file) + with pytest.raises(config.GitlabConfigHelperError) as e: + config.GitlabConfigParser(gitlab_id="helper") + + assert "Failed to read oauth_token value from helper" in str(e.value) + + +@mock.patch("builtins.open") @pytest.mark.parametrize( "config_string,expected_agent", [ diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 57b394b..6ef0939 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -3,6 +3,18 @@ import pytest from gitlab import exceptions +@pytest.mark.parametrize( + "kwargs,expected", + [ + ({"error_message": "foo"}, "foo"), + ({"error_message": "foo", "response_code": "400"}, "400: foo"), + ], +) +def test_gitlab_error(kwargs, expected): + error = exceptions.GitlabError(**kwargs) + assert str(error) == expected + + def test_error_raises_from_http_error(): """Methods decorated with @on_http_error should raise from GitlabHttpError.""" diff --git a/tests/unit/test_gitlab.py b/tests/unit/test_gitlab.py index 44abfc1..070f215 100644 --- a/tests/unit/test_gitlab.py +++ b/tests/unit/test_gitlab.py @@ -24,6 +24,7 @@ import pytest import responses import gitlab +from tests.unit import helpers localhost = "http://localhost" token = "abc123" @@ -58,7 +59,7 @@ def resp_page_1(): "headers": headers, "content_type": "application/json", "status": 200, - "match": [responses.matchers.query_param_matcher({})], + "match": helpers.MATCH_EMPTY_QUERY_PARAMS, } @@ -84,6 +85,64 @@ def resp_page_2(): } +def test_gitlab_init_with_valid_api_version(): + gl = gitlab.Gitlab(api_version="4") + assert gl.api_version == "4" + + +def test_gitlab_init_with_invalid_api_version(): + with pytest.raises(ModuleNotFoundError): + gitlab.Gitlab(api_version="1") + + +def test_gitlab_as_context_manager(): + with gitlab.Gitlab() as gl: + assert isinstance(gl, gitlab.Gitlab) + + +@responses.activate +@pytest.mark.parametrize( + "status_code,response_json,expected", + [ + (200, {"version": "0.0.0-pre", "revision": "abcdef"}, ("0.0.0-pre", "abcdef")), + (200, None, ("unknown", "unknown")), + (401, None, ("unknown", "unknown")), + ], +) +def test_gitlab_get_version(gl, status_code, response_json, expected): + responses.add( + method=responses.GET, + url="http://localhost/api/v4/version", + json=response_json, + status=status_code, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, + ) + + version = gl.version() + assert version == expected + + +@responses.activate +@pytest.mark.parametrize( + "response_json,expected", + [ + ({"id": "1", "plan": "premium"}, {"id": "1", "plan": "premium"}), + (None, {}), + ], +) +def test_gitlab_get_license(gl, response_json, expected): + responses.add( + method=responses.GET, + url="http://localhost/api/v4/license", + json=response_json, + status=200, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, + ) + + gitlab_license = gl.get_license() + assert gitlab_license == expected + + @responses.activate def test_gitlab_build_list(gl, resp_page_1, resp_page_2): responses.add(**resp_page_1) diff --git a/tests/unit/test_gitlab_http_methods.py b/tests/unit/test_gitlab_http_methods.py index f3e298f..be5b35f 100644 --- a/tests/unit/test_gitlab_http_methods.py +++ b/tests/unit/test_gitlab_http_methods.py @@ -9,8 +9,6 @@ from gitlab import GitlabHttpError, GitlabList, GitlabParsingError, RedirectErro from gitlab.client import RETRYABLE_TRANSIENT_ERROR_CODES from tests.unit import helpers -MATCH_EMPTY_QUERY_PARAMS = [responses.matchers.query_param_matcher({})] - def test_build_url(gl): r = gl._build_url("http://localhost/api/v4") @@ -29,7 +27,7 @@ def test_http_request(gl): url=url, json=[{"name": "project1"}], status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) http_r = gl.http_request("get", "/projects") @@ -46,7 +44,7 @@ def test_http_request_404(gl): url=url, json={}, status=400, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabHttpError): @@ -63,7 +61,7 @@ def test_http_request_with_only_failures(gl, status_code): url=url, json={}, status=status_code, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabHttpError): @@ -322,7 +320,7 @@ def test_http_request_302_get_does_not_raise(gl): method=responses.GET, url=url, status=302, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) gl.http_request(verb=method, path=api_path) @@ -346,7 +344,7 @@ def test_http_request_302_put_raises_redirect_error(gl): method=responses.PUT, url=url, status=302, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(RedirectError) as exc: gl.http_request(verb=method, path=api_path) @@ -364,7 +362,7 @@ def test_get_request(gl): url=url, json={"name": "project1"}, status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) result = gl.http_get("/projects") @@ -382,7 +380,7 @@ def test_get_request_raw(gl): content_type="application/octet-stream", body="content", status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) result = gl.http_get("/projects") @@ -398,7 +396,7 @@ def test_get_request_404(gl): url=url, json=[], status=404, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabHttpError): @@ -415,7 +413,7 @@ def test_get_request_invalid_data(gl): body='["name": "project1"]', content_type="application/json", status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabParsingError): @@ -434,7 +432,7 @@ def test_list_request(gl): json=[{"name": "project1"}], headers={"X-Total": "1"}, status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with warnings.catch_warnings(record=True) as caught_warnings: @@ -480,7 +478,7 @@ large_list_response = { ], "headers": {"X-Total": "30", "x-per-page": "20"}, "status": 200, - "match": MATCH_EMPTY_QUERY_PARAMS, + "match": helpers.MATCH_EMPTY_QUERY_PARAMS, } @@ -572,7 +570,7 @@ def test_list_request_404(gl): url=url, json=[], status=404, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabHttpError): @@ -589,7 +587,7 @@ def test_list_request_invalid_data(gl): body='["name": "project1"]', content_type="application/json", status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabParsingError): @@ -605,7 +603,7 @@ def test_post_request(gl): url=url, json={"name": "project1"}, status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) result = gl.http_post("/projects") @@ -622,7 +620,7 @@ def test_post_request_404(gl): url=url, json=[], status=404, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabHttpError): @@ -639,7 +637,7 @@ def test_post_request_invalid_data(gl): content_type="application/json", body='["name": "project1"]', status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabParsingError): @@ -655,7 +653,7 @@ def test_put_request(gl): url=url, json={"name": "project1"}, status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) result = gl.http_put("/projects") @@ -672,7 +670,7 @@ def test_put_request_404(gl): url=url, json=[], status=404, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabHttpError): @@ -689,7 +687,7 @@ def test_put_request_invalid_data(gl): body='["name": "project1"]', content_type="application/json", status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabParsingError): @@ -705,7 +703,7 @@ def test_delete_request(gl): url=url, json=True, status=200, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) result = gl.http_delete("/projects") @@ -722,7 +720,7 @@ def test_delete_request_404(gl): url=url, json=[], status=404, - match=MATCH_EMPTY_QUERY_PARAMS, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, ) with pytest.raises(GitlabHttpError): diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 3a92604..bd425f8 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -18,9 +18,30 @@ import json import warnings +import pytest +import requests +import responses + from gitlab import types, utils +@responses.activate +def test_response_content(capsys): + responses.add( + method="GET", + url="https://example.com", + status=200, + body="test", + content_type="application/octet-stream", + ) + + resp = requests.get("https://example.com", stream=True) + utils.response_content(resp, streamed=True, action=None, chunk_size=1024) + + captured = capsys.readouterr() + assert "test" in captured.out + + class TestEncodedId: def test_init_str(self): obj = utils.EncodedId("Hello") @@ -38,6 +59,10 @@ class TestEncodedId: assert "23" == str(obj) assert "23" == f"{obj}" + def test_init_invalid_type_raises(self): + with pytest.raises(TypeError): + utils.EncodedId(None) + def test_init_encodeid_str(self): value = "Goodbye" obj_init = utils.EncodedId(value) @@ -97,6 +122,33 @@ class TestWarningsWrapper: assert warn_source == warning.source +@pytest.mark.parametrize( + "source,expected", + [ + ({"a": "", "b": "spam", "c": None}, {"a": "", "b": "spam", "c": None}), + ({"a": "", "b": {"c": "spam"}}, {"a": "", "b[c]": "spam"}), + ], +) +def test_copy_dict(source, expected): + dest = {} + + utils.copy_dict(src=source, dest=dest) + assert dest == expected + + +@pytest.mark.parametrize( + "dictionary,expected", + [ + ({"a": None, "b": "spam"}, {"b": "spam"}), + ({"a": "", "b": "spam"}, {"a": "", "b": "spam"}), + ({"a": None, "b": None}, {}), + ], +) +def test_remove_none_from_dict(dictionary, expected): + result = utils.remove_none_from_dict(dictionary) + assert result == expected + + def test_transform_types_copies_data_with_empty_files(): data = {"attr": "spam"} new_data, files = utils._transform_types(data, {}) @@ -99,6 +99,7 @@ exclude_lines = pragma: no cover if TYPE_CHECKING: if debug: + return NotImplemented [testenv:cli_func_v4] deps = -r{toxinidir}/requirements-docker.txt |