diff --git a/pyproject.toml b/pyproject.toml index c265102f..7a9b36bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,3 @@ [tool.black] line-length = 100 -target-version = ['py36', 'py37', 'py38'] +target-version = ['py36', 'py37', 'py38', 'py39', 'py310', 'py311'] diff --git a/tests/conftest.py b/tests/conftest.py index 9aae5c1b..aa90aeaa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,3 +18,4 @@ def pytest_addoption(parser): default="/dev/null", help="Sets an optional config file to read from", ) + parser.addoption("--aws-profile", default="pytest", help="Sets the AWS profile name") diff --git a/tests/functional_test.py b/tests/functional_test.py index d51800ac..3950c6ed 100644 --- a/tests/functional_test.py +++ b/tests/functional_test.py @@ -78,6 +78,7 @@ def custom_args(request): "--okta-mfa-response", "--aws-role-arn", "--config-file", + "--aws-profile", ] arg_list = [] # pytest does not have a method for listing options, so we have look them up. @@ -211,6 +212,8 @@ def test_generate_credentials(custom_args): args = [ "--aws-role-arn", f"{config.aws['role_arn']}", + "--aws-profile", + f"{config.aws['profile']}", "--okta-app-url", f"{config.okta['app_url']}", "--okta-mfa-method", @@ -252,8 +255,8 @@ def test_aws_credentials(custom_args): if not config.aws["role_arn"]: pytest.skip("No AWS profile defined, test will be skipped.") - profile = config.aws["role_arn"].split("/")[-1] - runnable = ["aws", "--profile", profile, "sts", "get-caller-identity"] + + runnable = ["aws", "--profile", config.aws["profile"], "sts", "get-caller-identity"] proc = run_process(runnable) assert not proc["stderr"] assert proc["exit_status"] == 0 diff --git a/tests/unit_test.py b/tests/unit_test.py index 963e36a1..203e4411 100644 --- a/tests/unit_test.py +++ b/tests/unit_test.py @@ -90,6 +90,11 @@ def test_setup_logging(): ret = user.setup_logging(args) assert ret == logging.INFO + # test that a default level is set on a bad level + args = {"loglevel": "pytest"} + ret = user.setup_logging(args) + assert ret == logging.INFO + def test_setup_early_logging(monkeypatch, tmpdir): """Test early logging.""" @@ -127,14 +132,14 @@ def test_get_interactive_config(mocker): """Test if interactive configuration is collected correctly.""" from tokendito import user - # test that all values return + # test that all values return correctly ret = user.get_interactive_config( - app_url="https://pytest", org_url="https://pytest", username="pytest" + app_url="https://pytest/pytest", org_url="https://pytest", username="pytest" ) assert ( ret["okta_username"] == "pytest" and ret["okta_org_url"] == "https://pytest" - and ret["okta_app_url"] == "https://pytest" + and ret["okta_app_url"] == "https://pytest/pytest" ) # test that interactive values are handled correctly @@ -287,6 +292,12 @@ def test_display_selected_role(): ret = user.display_selected_role("pytest", {"Credentials": {"Expiration": utcnow}}) assert ret is not None and "pytest" in ret + with pytest.raises(SystemExit) as err: + ret = user.display_selected_role("pytest", {"pytest": {}}) + assert err.value.code == 1 + + assert ret is not None and "pytest" in ret + @pytest.mark.parametrize( "url,expected", @@ -399,6 +410,10 @@ def test_utc_to_local(): assert user.utc_to_local(utc) == local_time + with pytest.raises(SystemExit) as err: + user.utc_to_local("pytest") + assert err.value.code == 1 + def test_set_passcode(mocker): """Check if numerical passcode can handle leading zero values.""" @@ -957,7 +972,8 @@ def test_default_loglevel(): def test_loglevel_collected_from_env(monkeypatch, tmpdir): """Ensure that the loglevel collected from env vars.""" from argparse import Namespace - from tokendito import user, config, Config + import logging + from tokendito import user args = { "okta_username": "pytest_arg", @@ -970,8 +986,192 @@ def test_loglevel_collected_from_env(monkeypatch, tmpdir): monkeypatch.setenv("TOKENDITO_USER_LOGLEVEL", "DEBUG") monkeypatch.setattr(user, "parse_cli_args", lambda *x: Namespace(**args)) - monkeypatch.setattr(user, "process_ini_file", lambda *x: Config()) + ret = user.setup_early_logging(args)["loglevel"] + val = logging.getLevelName(ret) + + assert val == logging.DEBUG + + +def test_create_directory(tmpdir): + """Test dir creation.""" + from tokendito import user + + path = tmpdir.mkdir("pytest") + testdir = f"{path}/pytest" + + ret = user.create_directory(testdir) + assert ret is None + + with pytest.raises(SystemExit) as err: + user.create_directory(__file__) + assert err.value.code == 1 + + +def test_get_submodules_names(): + """Test whether submodules are retrieves correctly.""" + from tokendito import user + + ret = user.get_submodule_names() + assert "__main__" in ret - user.process_options(None) + ret = user.get_submodule_names("") + assert ret == [] + + +def test_process_interactive_input(mocker): + """Test interactive input processor.""" + from tokendito import user, Config + + # Check that a good object retrieves an interactive password + mocker.patch("getpass.getpass", return_value="pytest_password") + config = dict(okta=dict()) + pytest_config = Config(**config) + pytest_config.okta["app_url"] = "https://pytest/appurl" + pytest_config.okta["org"] = "https://pytest/" + pytest_config.okta["username"] = "pytest" + ret = user.process_interactive_input(pytest_config) + assert ret.okta["password"] == "pytest_password" + + # Check that a bad object raises an exception + with pytest.raises(SystemExit) as error: + assert user.process_interactive_input({"pytest": "pytest"}) == error + + +@pytest.mark.parametrize( + "value,submit,expected", + [ + ("pytest", None, "pytest"), + ("pytest", "deadbeef", "pytest"), + ("pytest", 0xDEADBEEF, "pytest"), + (None, None, "default"), + (None, "", "default"), + (None, 0xDEADBEEF, str(0xDEADBEEF)), + ], +) +def test_set_role_name(value, submit, expected): + """Test setting the AWS Role (profile) name.""" + from tokendito import user, Config + + pytest_config = Config(aws=dict(profile=value)) + + ret = user.set_role_name(pytest_config, submit) + assert ret.aws["profile"] == expected + + +@pytest.mark.parametrize( + "config,expected", + [ + ( + {"okta": {"username": "", "password": "", "org": None, "app_url": None}}, + [ + "Username not set.", + "Password not set.", + "Either Okta Org or App URL must be defined.", + ], + ), + ( + { + "okta": { + "username": "pytest", + "password": "pytest", + "org": "https://acme.okta.org", + "app_url": None, + } + }, + [], + ), + ( + { + "okta": { + "username": "pytest", + "password": "pytest", + "org": "https://acme.okta.org", + "app_url": "https://badurl_pytest.org", + } + }, + [ + "Tile URL https://badurl_pytest.org is not valid.", + "Org URL https://acme.okta.org and Tile URL " + "https://badurl_pytest.org must be in the same domain.", + ], + ), + ( + { + "okta": { + "username": "pytest", + "password": "pytest", + "org": "https://acme.okta.org", + "app_url": "https://acme.okta.org/home/amazon_aws/" + "0123456789abcdef0123/456?fromHome=true", + } + }, + [], + ), + ( + { + "okta": { + "username": "pytest", + "password": "pytest", + "org": "https://acme.okta.com/", + "app_url": "https://acme.okta.org/home/amazon_aws/" + "0123456789abcdef0123/456?fromHome=true", + } + }, + [ + "Org URL https://acme.okta.com/ and Tile URL " + "https://acme.okta.org/home/amazon_aws/" + "0123456789abcdef0123/456?fromHome=true must be in the same domain." + ], + ), + ( + { + "okta": { + "username": "pytest", + "password": "pytest", + "org": "pytest_deadbeef", + "app_url": None, + } + }, + ["Org URL pytest_deadbeef is not valid"], + ), + ], +) +def test_validate_configuration(config, expected): + """Test configuration validator.""" + from tokendito import user, Config + + pytest_config = Config(**config) + print(pytest_config) + assert user.validate_configuration(pytest_config) == expected + + +def test_sanitize_config_values(): + """Test configuration sanitizer method.""" + from tokendito import user, Config + + pytest_config = Config( + aws=dict(output="pytest", region="pytest"), + okta=dict(app_url="https://pytest_org", org="https://pytest_bar/"), + ) + ret = user.sanitize_config_values(pytest_config) + assert ret.aws["region"] == pytest_config.get_defaults()["aws"]["region"] + assert ret.aws["output"] == pytest_config.get_defaults()["aws"]["output"] + assert ret.okta["app_url"].startswith(ret.okta["org"]) + + +def test_get_regions(): + """Test retrieval of available AWS regions.""" + from tokendito import aws + + ret = aws.get_regions(profile="pytest") + assert ret == [] + ret = aws.get_regions() + assert "us-east-1" in ret + + +def test_get_output_types(): + """Test getting AWS output types.""" + from tokendito import aws - assert config.user["loglevel"] == "DEBUG" + ret = aws.get_output_types() + assert "json" in ret diff --git a/tokendito/aws.py b/tokendito/aws.py index 820c90b7..8537dab2 100644 --- a/tokendito/aws.py +++ b/tokendito/aws.py @@ -21,6 +21,31 @@ logger = logging.getLogger(__name__) +DEFAULT_OUTPUT_TYPES = ["json", "text", "csv", "yaml", "yaml-stream"] + + +def get_regions(profile=None): + """Get avaliable regions from botocore. + + :return: List of available regions. + """ + regions = [] + try: + session = botocore.session.get_session(env_vars=profile) + regions = session.get_available_regions("sts") + logger.debug(f"Found AWS regions: {regions}") + except Exception: + pass + return regions + + +def get_output_types(): + """Provide available output types. + + Currently, this cannot be done dynamically. + :return: List of available output types. + """ + return DEFAULT_OUTPUT_TYPES def authenticate_to_roles(secret_session_token, urls, cookies=None): @@ -41,29 +66,10 @@ def authenticate_to_roles(secret_session_token, urls, cookies=None): logger.debug(f"Authenticate AWS user with SAML URL [{url}]") response = requests.get(url, params=payload, cookies=cookies) + response.raise_for_status() saml_response_string = response.text - if response.status_code == 400 or response.status_code == 401: - errmsg = "Invalid Credentials." - logger.error(f"{errmsg}\nExiting with code:{response.status_code}") - sys.exit(2) - elif response.status_code == 404: - errmsg = "Invalid Okta application URL. Please verify your configuration." - logger.error(f"{errmsg}") - sys.exit(2) - elif response.status_code >= 500 and response.status_code < 504: - errmsg = ( - "Unable to establish connection with Okta. Verify Okta Org URL and try again." - ) - logger.error(f"{errmsg}\nExiting with code:{response.status_code}") - sys.exit(2) - elif response.status_code != 200: - logger.error(f"Exiting with code:{response.status_code}") - logger.debug(saml_response_string) - sys.exit(2) - - except Exception as error: - errmsg = f"Okta auth failed:\n{error}" - logger.error(errmsg) + except Exception as err: + logger.error(f"There was an error with the call to {url}: {err}") sys.exit(1) saml_xml = user.validate_saml_response(saml_response_string) @@ -163,7 +169,7 @@ def assert_credentials(role_response={}): aws_access_key = role_response["Credentials"]["AccessKeyId"] aws_secret_key = role_response["Credentials"]["SecretAccessKey"] aws_session_token = role_response["Credentials"]["SessionToken"] - except KeyError: + except (KeyError, TypeError): logger.error("SAML Response did not contain credentials") sys.exit(1) @@ -188,8 +194,8 @@ def assert_credentials(role_response={}): def select_assumeable_role(apps): """Select the role to perform the AssumeRoleWithSaml. - # :param apps: apps metadata, list of tuples - # :return: AWS AssumeRoleWithSaml response, role name, tuple + :param apps: apps metadata, list of tuples + :return: tuple with AWS AssumeRoleWithSaml response and role name """ authenticated_aps = {} for url, saml_response, saml, label in apps: @@ -211,4 +217,4 @@ def select_assumeable_role(apps): authenticated_aps[_id]["saml"], ) - return assume_role_response, role_name + return (assume_role_response, role_name) diff --git a/tokendito/duo.py b/tokendito/duo.py index ca9bdabe..a1622eee 100644 --- a/tokendito/duo.py +++ b/tokendito/duo.py @@ -114,7 +114,7 @@ def get_duo_devices(duo_auth): """ soup = BeautifulSoup(duo_auth.content, "html.parser") - device_soup = soup.find("select", {"name": "device"}).findAll("option") + device_soup = soup.find("select", {"name": "device"}).findAll("option") # type: ignore devices = [f"{d['value']} - {d.text}" for d in device_soup] if not devices: logger.error("Please configure devices for your Duo MFA and retry.") @@ -123,7 +123,7 @@ def get_duo_devices(duo_auth): factor_options = [] for device in devices: options = soup.find("fieldset", {"data-device-index": device.split(" - ")[0]}) - factors = options.findAll("input", {"name": "factor"}) + factors = options.findAll("input", {"name": "factor"}) # type: ignore (PEP 561) for factor in factors: factor_option = {"device": device, "factor": factor["value"]} factor_options.append(factor_option) diff --git a/tokendito/okta.py b/tokendito/okta.py index ccca46e0..0e8568e0 100644 --- a/tokendito/okta.py +++ b/tokendito/okta.py @@ -107,19 +107,18 @@ def user_session_token(primary_auth, headers): return session_token -def authenticate_user(url, username, password): +def authenticate_user(config): """Authenticate user with okta credential. - :param url: company specific URL of the okta - :param username: okta username - :param password: okta password - :return: MFA session options + :param config: Config object + :return: MFA session with options """ headers = {"content-type": "application/json", "accept": "application/json"} - payload = {"username": username, "password": password} + payload = {"username": config.okta["username"], "password": config.okta["password"]} - logger.debug(f"Authenticate Okta headers [{headers}] ") - primary_auth = api_wrapper(f"{url}/api/v1/authn", payload, headers) + logger.debug("Authenticate user to Okta") + logger.debug(f"Sending {headers}, {payload} to {config.okta['org']}") + primary_auth = api_wrapper(f"{config.okta['org']}/api/v1/authn", payload, headers) session_token = user_session_token(primary_auth, headers) logger.info("User has been succesfully authenticated.") @@ -311,7 +310,7 @@ def push_approval(headers, mfa_challenge_url, payload): logger.error("Device approval window has expired.") sys.exit(2) - time.sleep(0.5) + time.sleep(1) if mfa_verify is None: logger.error("Unknown error in MFA approval.") diff --git a/tokendito/tool.py b/tokendito/tool.py index b4474fae..31797807 100644 --- a/tokendito/tool.py +++ b/tokendito/tool.py @@ -15,37 +15,36 @@ def cli(args): """Tokendito retrieves AWS credentials after authenticating with Okta.""" + args = user.parse_cli_args(args) + # Early logging, in case the user requests debugging via env/CLI + user.setup_early_logging(args) + # Set some required initial values user.process_options(args) - logger.debug(f"Final configuration is {config}") - user.process_interactive_input(config) + # Late logging (default) + user.setup_logging(config.user) - # Authenticate okta and AWS also use assumerole to assign the role - logger.debug("Authenticate user with Okta and AWS.") + # Validate configuration + message = user.validate_configuration(config) + if message: + logger.error(f"Could not validate configuration: {' '.join(message)}") + sys.exit(1) - secret_session_token = okta.authenticate_user( - config.okta["org"], config.okta["username"], config.okta["password"] - ) + # Authenticate okta and AWS also use assumerole to assign the role + session_token = okta.authenticate_user(config) session_cookies = None if config.okta["app_url"]: - if not user.validate_okta_app_url(config.okta["app_url"]): - logger.error( - "Okta Application URL not found, or invalid. Please check " - "your configuration and try again." - ) - sys.exit(2) - app_label = "" config.okta["app_url"] = (config.okta["app_url"], app_label) else: - session_cookies = user.request_cookies(config.okta["org"], secret_session_token) + session_cookies = user.request_cookies(config.okta["org"], session_token) config.okta["app_url"] = user.discover_app_url(config.okta["org"], session_cookies) auth_apps = aws.authenticate_to_roles( - secret_session_token, config.okta["app_url"], cookies=session_cookies + session_token, config.okta["app_url"], cookies=session_cookies ) (role_response, role_name) = aws.select_assumeable_role(auth_apps) @@ -57,11 +56,13 @@ def cli(args): ) sys.exit(1) + user.set_role_name(config, role_name) + user.set_local_credentials( response=role_response, - role=role_name, + role=config.aws["profile"], region=config.aws["region"], output=config.aws["output"], ) - user.display_selected_role(profile_name=role_name, role_response=role_response) + user.display_selected_role(profile_name=config.aws["profile"], role_response=role_response) diff --git a/tokendito/user.py b/tokendito/user.py index a4744ef9..c3cb38f6 100644 --- a/tokendito/user.py +++ b/tokendito/user.py @@ -21,6 +21,7 @@ from bs4 import BeautifulSoup import requests from tokendito import __version__ +from tokendito import aws from tokendito import Config from tokendito import config as config @@ -171,7 +172,7 @@ def get_submodule_names(location=__file__): submodules = [] try: - package = Path(location).resolve() + package = Path(location).resolve(strict=True) submodules = [x.name for x in iter_modules([str(package.parent)])] except Exception as err: logger.warning(f"Could not resolve modules: {str(err)}") @@ -202,8 +203,7 @@ def setup_logging(conf): """Set logging level. :param conf: dictionary with config - :return: None - + :return: loglevel name """ root_logger = logging.getLogger() formatter = logging.Formatter( @@ -670,22 +670,42 @@ def process_environment(prefix="tokendito"): return config_env -def get_base_url(urlstring): +def process_interactive_input(config): """ - Validate okta app url, and extract okta org url from it. + Request input interactively interactively for elements that are not proesent. - :param config_obj: configuration object - :returns: None + :param config: Config object with some values set + :returns: Config object with necessary values set. """ - if not validate_okta_app_url(urlstring): - logger.error( - "Okta URL not found, or invalid. Please check " "your configuration and try again." + # reuse interactive config. It will only request the portions needed. + try: + details = get_interactive_config( + app_url=config.okta["app_url"], + org_url=config.okta["org"], + username=config.okta["username"], ) - sys.exit(2) + except (AttributeError, KeyError, ValueError) as err: + logger.error(f"Interactive arguments are not correct: {err}") + sys.exit(1) - url = urlparse(urlstring) - baseurl = f"{url.scheme}://{url.netloc}" - return baseurl + # Create a dict that can be passed to Config later + res = dict(okta=dict()) + # Copy the values set by get_interactive_config + if "okta_app_url" in details: + res["okta"]["app_url"] = details["okta_app_url"] + if "okta_org_url" in details: + res["okta"]["org"] = details["okta_org_url"] + if "okta_username" in details: + res["okta"]["username"] = details["okta_username"] + + if "password" not in config.okta: + logger.debug("No password set, will try to get one interactively") + res["okta"]["password"] = get_password() + add_sensitive_value_to_be_masked(res["okta"]["password"]) + + config_int = Config(**res) + logger.debug(f"Interactive configuration is: {config_int}") + return config_int def get_interactive_config(app_url="", org_url="", username=""): @@ -694,7 +714,7 @@ def get_interactive_config(app_url="", org_url="", username=""): :return: dictionary with values """ logger.debug("Obtain user input for the user.") - config_details = {} + details = {} # We need either one of these two: while org_url == "" and app_url == "": @@ -705,13 +725,25 @@ def get_interactive_config(app_url="", org_url="", username=""): username = get_username() if org_url != "": - config_details["okta_org_url"] = org_url + details["okta_org_url"] = org_url if app_url != "": - config_details["okta_app_url"] = app_url - config_details["okta_username"] = username + details["okta_app_url"] = app_url + details["okta_username"] = username - logger.debug(f"Details: {config_details}") - return config_details + logger.debug(f"Details: {details}") + return details + + +def get_base_url(urlstring): + """ + Extract base url from string. + + :param urlstring: url string + :returns: base URL + """ + url = urlparse(urlstring) + baseurl = f"{url.scheme}://{url.netloc}" + return baseurl def get_org_url(): @@ -798,6 +830,21 @@ def get_password(): return res +def set_role_name(config_obj, name): + """Set AWS Role alias name based on user preferences. + + :param config: Config object. + :param name: Role name. Defaults to the string "default" + :return: Config object. + """ + if name is None or name == "": + name = "default" + if config_obj.aws["profile"] is None: + config_obj.aws["profile"] = str(name) + + return config_obj + + def update_configuration(ini_file, profile): """Update configuration file on local system. @@ -931,6 +978,7 @@ def validate_input(value, valid_range): def get_input(prompt="-> "): """Collect user input for TOTP. + :param prompt: optional string with prompt. :return user_input: raw from user. """ user_input = input(prompt) @@ -960,10 +1008,6 @@ def collect_integer(valid_range): def process_options(args): """Collect all user-specific credentials and config params.""" - args = parse_cli_args(args) - # Early logging, in case the user requests debugging via command line - setup_early_logging(args) - if args.version: display_version() sys.exit(0) @@ -984,39 +1028,64 @@ def process_options(args): config.update(config_ini) config.update(config_env) config.update(config_args) + + # 4: Get missing data from the user, if necessary + config_int = process_interactive_input(config) + config.update(config_int) + + sanitize_config_values(config) logger.debug(f"Final configuration is {config}") - # Late logging (default) - setup_logging(config.user) -def process_interactive_input(config_obj): +def validate_configuration(config): + """Ensure that minimum configuration values are sane. + + :param config: Config element with final configuration. + :return: message with validation issues. """ - Request input interactively interactively for elements that are not proesent. + message = [] + if not config.okta["username"] or config.okta["username"] == "": + message.append("Username not set.") + if not config.okta["password"] or config.okta["password"] == "": + message.append("Password not set.") + if not config.okta["org"] and not config.okta["app_url"]: + message.append("Either Okta Org or App URL must be defined.") + if config.okta["app_url"] and not validate_okta_app_url(config.okta["app_url"]): + message.append(f"Tile URL {config.okta['app_url']} is not valid.") + if config.okta["org"] and not validate_okta_org_url(config.okta["org"]): + message.append(f"Org URL {config.okta['org']} is not valid") + if ( + config.okta["org"] + and config.okta["app_url"] + and not config.okta["app_url"].startswith(config.okta["org"]) + ): + message.append( + f"Org URL {config.okta['org']} and Tile URL" + f" {config.okta['app_url']} must be in the same domain." + ) + + return message + + +def sanitize_config_values(config): + """Adjust values that may need to be corrected. - :param config_obj: configuration object - :returns: None + :param config: Config object to adjust + :returns: modified object. """ - # reuse interactive config. It will only request the portions needed. - config_details = get_interactive_config( - app_url=config_obj.okta["app_url"], - org_url=config_obj.okta["org"], - username=config_obj.okta["username"], - ) - if "okta_app_url" in config_details: - config_obj.okta["app_url"] = config_details["okta_app_url"] - if "okta_org_url" in config_details: - config_obj.okta["org"] = config_details["okta_org_url"] - if "okta_username" in config_details: - config_obj.okta["username"] = config_details["okta_username"] - - if config_obj.okta["app_url"] and not config_obj.okta["org"]: - config_obj.okta["org"] = get_base_url(config_obj.okta["app_url"]) - logger.debug(f"Connection string is {config_obj.okta['org']}") - - if config_obj.okta["password"] == "": - config_obj.okta["password"] = get_password() - add_sensitive_value_to_be_masked(config_obj.okta["password"]) - logger.debug(f"Runtime configuration is: {config_obj}") + if config.okta["app_url"]: + base_url = get_base_url(config.okta["app_url"]) + config.okta["org"] = base_url + + if config.aws["output"] not in aws.get_output_types(): + config.aws["output"] = config.get_defaults()["aws"]["output"] + logger.warning(f"AWS Output reset to {config.aws['output']}") + + if config.aws["region"] not in aws.get_regions(): + config.aws["region"] = config.get_defaults()["aws"]["region"] + logger.warning(f"AWS Region reset to {config.aws['region']}") + + return config def request_cookies(url, session_token): diff --git a/tox.ini b/tox.ini index aad5b71b..1c46d0e6 100755 --- a/tox.ini +++ b/tox.ini @@ -5,14 +5,15 @@ envlist = lint, py{36,37,38,39,310}, auth, coverage [testenv] deps = -r requirements-dev.txt commands = - pytest --cov=tokendito --cov-append -v -rA -k 'unit' -s tests/ -- - pytest --cov=tokendito --cov-append -v -rA -k 'functional and not credentials' -s tests/ -- + coverage erase + pytest --cov=tokendito --cov-append -v -ra -k 'unit' -s tests/ -- + pytest --cov=tokendito --cov-append -v -ra -k 'functional and not credentials' -s tests/ -- [testenv:lint] skip_install = true commands = - flake8 - pyroma --min=10 . + flake8 -- + pyroma --min=10 . -- [testenv:auth] skip_install = true @@ -37,7 +38,7 @@ python = [flake8] max-line-length = 100 max-complexity = 8 -exclude = .git, __pycache__, .tox, build/, venv/ +exclude = .git, __pycache__, .tox, build/, .venv/, venv/ extend-ignore = E203, W503 import-order-style = google application-import-names = flake8