diff --git a/aiven/client/argx.py b/aiven/client/argx.py index 965e3c47..2ecf3444 100644 --- a/aiven/client/argx.py +++ b/aiven/client/argx.py @@ -108,15 +108,23 @@ def add_arguments(self, actions: Iterable[Action]) -> None: def _get_help_string(self, action: Action) -> str: help_text = action.help or "" - if "%(default)" not in help_text and action.default is not argparse.SUPPRESS: - if action.option_strings or action.nargs in [ - argparse.OPTIONAL, - argparse.ZERO_OR_MORE, - ]: - if (not isinstance(action.default, bool) and isinstance(action.default, int)) or ( - isinstance(action.default, str) and action.default - ): - help_text += " (default: %(default)s)" + if ( + "%(default)" not in help_text + and action.default is not argparse.SUPPRESS + and ( + action.option_strings + or action.nargs + in [ + argparse.OPTIONAL, + argparse.ZERO_OR_MORE, + ] + ) + and ( + (not isinstance(action.default, bool) and isinstance(action.default, int)) + or (isinstance(action.default, str) and action.default) + ) + ): + help_text += " (default: %(default)s)" if isinstance(action, ArgumentDeprecationNotice): help_text = ( diff --git a/aiven/client/cli.py b/aiven/client/cli.py index 4a449fff..fe1ed8a2 100644 --- a/aiven/client/cli.py +++ b/aiven/client/cli.py @@ -21,7 +21,7 @@ from datetime import datetime, timedelta, timezone from decimal import Decimal from http import HTTPStatus -from typing import IO, Any, Final, Protocol, TypeVar +from typing import IO, Any, ClassVar, Final, Protocol, TypeVar from urllib.parse import urlparse import errno @@ -350,7 +350,7 @@ def get_project(self, raise_if_none: bool = True, fallback_to_default_project: b def help(self) -> None: """List commands""" output = [] - patterns = [re.compile(p, re.I) for p in self.args.pattern] + patterns = [re.compile(p, re.IGNORECASE) for p in self.args.pattern] for plugin in self._extensions: for prop_name in dir(plugin): if prop_name.startswith("_"): @@ -585,7 +585,7 @@ def service__logs(self) -> None: ) except requests.RequestException as ex: if not self.args.follow: - raise ex + raise consecutive_errors += 1 if consecutive_errors > consecutive_errors_limit: raise argx.UserError("Fetching logs failed repeatedly, aborting.") @@ -743,7 +743,7 @@ def service__plans(self) -> None: entry["service_type"] = service_type output.append(entry) - dformat = Decimal("0") if self.args.monthly else Decimal("0.000") + dformat = Decimal(0) if self.args.monthly else Decimal("0.000") for info in sorted(output, key=lambda s: s["description"]): print("{} Plans:\n".format(info["description"])) for plan in info["service_plans"]: @@ -822,7 +822,7 @@ def service__types(self) -> None: ) ) - SERVICE_LAYOUT = [ + SERVICE_LAYOUT: ClassVar[list[list[str]]] = [ [ "service_name", "service_type", @@ -834,9 +834,9 @@ def service__types(self) -> None: "notifications", ] ] - EXT_SERVICE_LAYOUT = ["service_uri", "disk_space_mb", "user_config.*", "databases", "users"] + EXT_SERVICE_LAYOUT: ClassVar[list[str]] = ["service_uri", "disk_space_mb", "user_config.*", "databases", "users"] - TOPIC_LIST_LAYOUT = [ + TOPIC_LIST_LAYOUT: ClassVar[list[list[str]]] = [ [ "topic_name", "partitions", @@ -2706,7 +2706,7 @@ def service__topic_create(self) -> None: """Create a Kafka topic""" tags = list(map(parse_tag_str, self.args.topic_option_tag or [])) - tag_keys = list(map(lambda d: d.get("key"), tags)) + tag_keys = [tag.get("key") for tag in tags] repeated_keys = [key for key, count in Counter(tag_keys).items() if count > 1 and key is not None] if len(repeated_keys) > 0: raise argx.UserError(f"Duplicate tags detected: {', '.join(repeated_keys)}") @@ -2761,7 +2761,7 @@ def service__topic_update(self) -> None: new_tags = list(map(parse_tag_str, self.args.topic_option_tag or [])) untags = list(map(parse_untag_str, self.args.topic_option_untag or [])) - keys: list = list(map(lambda d: d.get("key"), new_tags)) + keys: list = [tag.get("key") for tag in new_tags] tag_keys = keys + untags repeated_keys = [key for key, count in Counter(tag_keys).items() if count > 1] if len(repeated_keys) > 0: @@ -4851,8 +4851,10 @@ def permissions__set(self) -> None: if not self.args.permissions and not self.args.force: self.print_boxed( [ - f"You are going to remove all permissions for principal " - f"{self.args.principal_id} (principal_type={self.args.principal_type}).", + ( + f"You are going to remove all permissions for principal " + f"{self.args.principal_id} (principal_type={self.args.principal_type})." + ), ] ) if not self.confirm("Do you want to proceed (y/N)?"): @@ -4880,9 +4882,8 @@ def permissions__set(self) -> None: for add_perm in added: print(f"+ {add_perm}") - if added or removed: - if not self.confirm("Do you want to proceed (y/N)?"): - raise argx.UserError("Aborted") + if (added or removed) and not self.confirm("Do you want to proceed (y/N)?"): + raise argx.UserError("Aborted") self.client.update_permissions( organization_id=self.args.organization_id, @@ -6236,17 +6237,41 @@ def byoc__list(self) -> None: "--google-privilege-bearing-service-account-id", help="The privilege-bearing service account that Aiven is authorized to impersonate to operate the cloud (Google)", ) + @arg("--azure-subscription-id", help="The Azure subscription ID where the BYOC infrastructure is deployed (Azure)") + @arg("--azure-client-id", help="The client ID of the operator service principal created by Terraform (Azure)") + @arg("--azure-client-secret", help="The client secret of the operator service principal created by Terraform (Azure)") + @arg("--azure-tenant-id", help="The Azure AD tenant ID where the operator service principal resides (Azure)") def byoc__provision(self) -> None: """Provision resources for a Bring Your Own Cloud cloud.""" - if self.args.aws_iam_role_arn and self.args.google_privilege_bearing_service_account_id: + aws_args = [self.args.aws_iam_role_arn] + google_args = [self.args.google_privilege_bearing_service_account_id] + azure_args = [ + self.args.azure_subscription_id, + self.args.azure_client_id, + self.args.azure_client_secret, + self.args.azure_tenant_id, + ] + active_cloud_identity_groups = sum(any(args) for args in (aws_args, google_args, azure_args)) + if active_cloud_identity_groups > 1: + raise argx.UserError( + "--aws-iam-role-arn, --google-privilege-bearing-service-account-id," + " and the Azure provision arguments (--azure-subscription-id, --azure-client-id," + " --azure-client-secret, --azure-tenant-id) are mutually exclusive." + ) + if any(azure_args) and not all(azure_args): raise argx.UserError( - "--aws-iam-role-arn and --google-privilege-bearing-service-account-id are mutually exclusive." + "--azure-subscription-id, --azure-client-id, --azure-client-secret," + " and --azure-tenant-id must all be provided together." ) output = self.client.byoc_provision( organization_id=self.args.organization_id, byoc_id=self.args.byoc_id, aws_iam_role_arn=self.args.aws_iam_role_arn, google_privilege_bearing_service_account_id=self.args.google_privilege_bearing_service_account_id, + azure_subscription_id=self.args.azure_subscription_id, + azure_client_id=self.args.azure_client_id, + azure_client_secret=self.args.azure_client_secret, + azure_tenant_id=self.args.azure_tenant_id, ) self.print_response(output) diff --git a/aiven/client/cliarg.py b/aiven/client/cliarg.py index 785a4479..cf22f601 100644 --- a/aiven/client/cliarg.py +++ b/aiven/client/cliarg.py @@ -76,11 +76,7 @@ def wrapped(self: CommandLineTool) -> T: # empty config ("{}"), which must parse to an empty dict. if self.args.user_config_json is not None: try: - setattr( - self.args, - "user_config_json", - get_json_config(self.args.user_config_json), - ) + self.args.user_config_json = get_json_config(self.args.user_config_json) except jsonlib.decoder.JSONDecodeError as err: raise UserError(f"Invalid user_config_json: {err!s}") from err return fun(self) diff --git a/aiven/client/client.py b/aiven/client/client.py index d85e90cf..4b1c7cbb 100644 --- a/aiven/client/client.py +++ b/aiven/client/client.py @@ -732,7 +732,7 @@ def update_service_elasticsearch_acl_config( self._del_es_acl_rules( config=acl_config, user=username, - rules=set(rule.strip() for rule in del_rules), + rules={rule.strip() for rule in del_rules}, ) path = self.build_path("project", project, "service", service, "elasticsearch", "acl") @@ -2779,13 +2779,23 @@ def byoc_provision( byoc_id: str, aws_iam_role_arn: str | None = None, google_privilege_bearing_service_account_id: str | None = None, + azure_subscription_id: str | None = None, + azure_client_id: str | None = None, + azure_client_secret: str | None = None, + azure_tenant_id: str | None = None, ) -> Mapping[Any, Any]: - if aws_iam_role_arn is not None: - body = {"aws_iam_role_arn": aws_iam_role_arn} - elif google_privilege_bearing_service_account_id is not None: - body = {"google_privilege_bearing_service_account_id": google_privilege_bearing_service_account_id} - else: - body = {} + body = { + k: v + for k, v in { + "aws_iam_role_arn": aws_iam_role_arn, + "google_privilege_bearing_service_account_id": google_privilege_bearing_service_account_id, + "azure_subscription_id": azure_subscription_id, + "azure_client_id": azure_client_id, + "azure_client_secret": azure_client_secret, + "azure_tenant_id": azure_tenant_id, + }.items() + if v is not None + } return self.verify( self.post, self.build_path("organization", organization_id, "custom-cloud-environments", byoc_id, "provision"), diff --git a/aiven/client/connection_info/common.py b/aiven/client/connection_info/common.py index 9e701676..cdc6bd37 100644 --- a/aiven/client/connection_info/common.py +++ b/aiven/client/connection_info/common.py @@ -18,9 +18,9 @@ def __str__(self) -> str: class Store(Enum): - overwrite = object() - write = object() - skip = object() + overwrite = "overwrite" + write = "write" + skip = "skip" def handle(self, getter: Callable[[], str], path: str) -> None: if self is Store.overwrite: diff --git a/aiven/client/speller.py b/aiven/client/speller.py index 03ddaee4..90be6a77 100644 --- a/aiven/client/speller.py +++ b/aiven/client/speller.py @@ -19,7 +19,7 @@ def get_candidates(word: str) -> Iterable[str]: def get_known(words: Iterable[str]) -> Iterable[str]: """The subset of `words` that appear in the dictionary of WORDS.""" - return set(w for w in words if w in known_words) + return {w for w in words if w in known_words} def get_edits1(word: str) -> Iterable[str]: """All edits that are one edit away from `word`.""" diff --git a/aiven/client/units.py b/aiven/client/units.py index 862c8328..c10371da 100644 --- a/aiven/client/units.py +++ b/aiven/client/units.py @@ -6,5 +6,5 @@ MIB_IN_GIB: Final = 1024 -def convert_mib_to_gib(value: float | int) -> float: +def convert_mib_to_gib(value: float) -> float: return float(value) / MIB_IN_GIB diff --git a/pyproject.toml b/pyproject.toml index 1ae01f71..3995c1be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ extend-select = [ ] ignore = [ "PLR0913", # It is too difficult to avoid "Too many arguments" error in the codebase + "PLR0917", # Legacy client APIs pass many positional arguments "UP032", # Downstream has different rules for f-strings ] diff --git a/tests/test_cli.py b/tests/test_cli.py index f5e7a3b3..509fd1d9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2087,6 +2087,7 @@ def test_byoc_update_contact_emails() -> None: "europe-north1", "projects/aiven-test-byoa/serviceAccounts/aiven-cce4bafaf95155@aiven-test-byoa.iam.gserviceaccount.com", ), + ("azure", "westeurope", "12345678-1234-1234-1234-123456789abc"), ], ) def test_byoc_provision(provider: str, region: str, byoc_account_id: str) -> None: @@ -2105,6 +2106,7 @@ def test_byoc_provision(provider: str, region: str, byoc_account_id: str) -> Non } byoc_account_id_args = { "aws": "--aws-iam-role-arn", + "azure": "--azure-subscription-id", "google": "--google-privilege-bearing-service-account-id", } args = [ @@ -2114,11 +2116,23 @@ def test_byoc_provision(provider: str, region: str, byoc_account_id: str) -> Non "--byoc-id=d6a490ad-f43d-49d8-b3e5-45bc5dbfb387", f"{byoc_account_id_args[provider]}={byoc_account_id}", ] + if provider == "azure": + args.extend( + [ + "--azure-client-id=azure-client-id", + "--azure-client-secret=azure-client-secret", + "--azure-tenant-id=azure-tenant-id", + ] + ) build_aiven_cli(aiven_client).run(args=args) aiven_client.byoc_provision.assert_called_once_with( organization_id="org123456789a", byoc_id="d6a490ad-f43d-49d8-b3e5-45bc5dbfb387", aws_iam_role_arn=byoc_account_id if provider == "aws" else None, + azure_subscription_id=byoc_account_id if provider == "azure" else None, + azure_client_id="azure-client-id" if provider == "azure" else None, + azure_client_secret="azure-client-secret" if provider == "azure" else None, + azure_tenant_id="azure-tenant-id" if provider == "azure" else None, google_privilege_bearing_service_account_id=byoc_account_id if provider == "google" else None, ) @@ -2131,8 +2145,10 @@ def test_byoc_provision_args() -> None: "--organization-id=org123456789a", "--byoc-id=d6a490ad-f43d-49d8-b3e5-45bc5dbfb387", "--aws-iam-role-arn=arn:aws:iam::123456789012:role/role-name", - "--google-privilege-bearing-service-account-id=" - "projects/aiven-test-byoa/serviceAccounts/aiven-cce4bafaf95155@aiven-test-byoa.iam.gserviceaccount.com", + ( + "--google-privilege-bearing-service-account-id=" + "projects/aiven-test-byoa/serviceAccounts/aiven-cce4bafaf95155@aiven-test-byoa.iam.gserviceaccount.com" + ), ] build_aiven_cli(aiven_client).run(args=args) aiven_client.byoc_provision.assert_not_called() diff --git a/tests/test_client.py b/tests/test_client.py index 4bb29f4f..041f8cd6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -150,8 +150,10 @@ def operation() -> MockResponse: "response_text, status, expected_str", [ ( - '{"errors":[{"error_code":"kafka_topic_invalid_config","message":"bad config",' - '"status":400}],"message":"Replication factor for diskless topics cannot be different than 1"}', + ( + '{"errors":[{"error_code":"kafka_topic_invalid_config","message":"bad config",' + '"status":400}],"message":"Replication factor for diskless topics cannot be different than 1"}' + ), 400, "Replication factor for diskless topics cannot be different than 1 (status 400)", ), diff --git a/tests/test_pretty.py b/tests/test_pretty.py index 8593c2f3..ecc94414 100644 --- a/tests/test_pretty.py +++ b/tests/test_pretty.py @@ -15,25 +15,27 @@ import pytest import re +SAMPLE_DATETIME = datetime.datetime(2019, 12, 23, tzinfo=datetime.timezone.utc) + @pytest.mark.parametrize( "value,expected", [ (1, "1"), ("a_string", "a_string"), - (datetime.datetime(year=2019, month=12, day=23), "2019-12-23T00:00:00"), - ([datetime.datetime(year=2019, month=12, day=23)], "2019-12-23T00:00:00"), + (SAMPLE_DATETIME, "2019-12-23T00:00:00+00:00"), + ([SAMPLE_DATETIME], "2019-12-23T00:00:00+00:00"), ( - ["x", datetime.datetime(year=2019, month=12, day=23)], - "x, 2019-12-23T00:00:00", + ["x", SAMPLE_DATETIME], + "x, 2019-12-23T00:00:00+00:00", ), (decimal.Decimal("64.23"), "64.23"), ( { "a": decimal.Decimal("12.34"), - "b": datetime.datetime(year=2019, month=12, day=23), + "b": SAMPLE_DATETIME, }, - '{"a": "12.34", "b": "2019-12-23T00:00:00"}', + '{"a": "12.34", "b": "2019-12-23T00:00:00+00:00"}', ), (ipaddress.IPv4Address("192.168.0.1"), "192.168.0.1"), (ipaddress.IPv6Address("fd00:0000::1:123"), "fd00::1:123"),