better-aws-tags 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: astral-sh/setup-uv@v5
18
+
19
+ - name: Build package
20
+ run: uv build
21
+
22
+ - name: Publish to PyPI
23
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -10,7 +10,7 @@ dev: ## Run in development mode
10
10
 
11
11
  .PHONY: run
12
12
  run: ## Run debug script
13
- uv run python debug_bats.py
13
+ uv run python -i debug_bats.py
14
14
 
15
15
  .PHONY: test
16
16
  test: ## Run tests on all Python versions
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: better-aws-tags
3
+ Version: 0.3.1
4
+ Summary: A unified Python interface for managing AWS resource tags
5
+ Author-email: Andrey Gubarev <andrey@andreygubarev.com>
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: boto3~=1.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # better-aws-tags
11
+
12
+ A unified Python interface for managing AWS resource tags.
13
+
14
+ AWS provides multiple tagging APIs across services, including the Resource Groups Tagging API and service-specific implementations such as EC2, S3, and IAM. This library abstracts these differences behind a single API that accepts any valid ARN and handles service routing, tag validation, and error reporting automatically.
15
+
16
+ ## Getting Started
17
+
18
+ ```python
19
+ import better_aws_tags as bats
20
+ ```
@@ -0,0 +1,11 @@
1
+ # better-aws-tags
2
+
3
+ A unified Python interface for managing AWS resource tags.
4
+
5
+ AWS provides multiple tagging APIs across services, including the Resource Groups Tagging API and service-specific implementations such as EC2, S3, and IAM. This library abstracts these differences behind a single API that accepts any valid ARN and handles service routing, tag validation, and error reporting automatically.
6
+
7
+ ## Getting Started
8
+
9
+ ```python
10
+ import better_aws_tags as bats
11
+ ```
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.3.1"
2
2
 
3
3
  from .tags import get_tags as get_tags
4
4
  from .tags import set_tags as set_tags
@@ -0,0 +1,139 @@
1
+ """ARN parsing utilities."""
2
+
3
+ import re
4
+ from typing import NamedTuple
5
+
6
+ REGEX_RESOURCE_TYPE = re.compile(r"^[a-zA-Z0-9-]+$")
7
+
8
+ class ResourceNotFound(Exception):
9
+ def __init__(self, service, resource):
10
+ super().__init__(f"Cannot determine resource format for service {service}: {resource}")
11
+
12
+ class ARN(NamedTuple):
13
+ partition: str
14
+ service: str
15
+ region: str
16
+ account: str
17
+ resource_type: str | None
18
+ resource_id: str
19
+ resource_name: str
20
+
21
+
22
+ class Resource(NamedTuple):
23
+ resource_type: str | None
24
+ resource_id: str
25
+ resource_name: str
26
+
27
+
28
+ def find_resource(service, resource):
29
+ separators = resource.count("/") + resource.count(":")
30
+ segments = [s for segment in resource.split("/") for s in segment.split(":")]
31
+ prefix = segments[0]
32
+ suffix = resource[len(prefix) + 1:]
33
+
34
+ if service == "apigateway":
35
+ if separators == 2:
36
+ return Resource(segments[1], segments[2], segments[2])
37
+ else:
38
+ raise ResourceNotFound(service, resource)
39
+ elif service == "autoscaling":
40
+ if segments[0] == "autoScalingGroup" and segments[2] == "autoScalingGroupName":
41
+ return Resource(segments[0], segments[1], segments[3])
42
+ else:
43
+ raise ResourceNotFound(service, resource)
44
+ elif service == "cloudformation":
45
+ if separators == 2:
46
+ return Resource(segments[0], segments[2], segments[1])
47
+ else:
48
+ raise ResourceNotFound(service, resource)
49
+ elif service == "cloudwatch":
50
+ if prefix == "alarm":
51
+ return Resource(prefix, suffix, suffix)
52
+ else:
53
+ raise ResourceNotFound(service, resource)
54
+ elif service == "elasticloadbalancing":
55
+ if prefix == "loadbalancer":
56
+ if separators == 3:
57
+ return Resource(f"{segments[0]}/{segments[1]}", segments[3], segments[2])
58
+ elif separators == 1:
59
+ return Resource(segments[0], segments[1], segments[1])
60
+ else:
61
+ raise ResourceNotFound(service, resource)
62
+ elif prefix == "targetgroup":
63
+ if separators == 2:
64
+ return Resource(prefix, segments[2], segments[1])
65
+ else:
66
+ raise ResourceNotFound(service, resource)
67
+ else:
68
+ raise ResourceNotFound(service, resource)
69
+ elif service == "logs":
70
+ if prefix == "log-group":
71
+ return Resource(prefix, suffix, suffix)
72
+ else:
73
+ raise ResourceNotFound(service, resource)
74
+ elif service == "secretsmanager":
75
+ if prefix == "secret":
76
+ return Resource(prefix, suffix, suffix)
77
+ else:
78
+ raise ResourceNotFound(service, resource)
79
+ elif service == "ssm":
80
+ if prefix == "parameter":
81
+ return Resource(prefix, "/" + suffix, "/" + suffix)
82
+ else:
83
+ raise ResourceNotFound(service, resource)
84
+ elif service == "wafv2":
85
+ if prefix == "global":
86
+ return Resource(f"{segments[0]}/{segments[1]}", segments[2], segments[2])
87
+ elif prefix == "regional":
88
+ return Resource(f"{segments[0]}/{segments[1]}", segments[3], segments[2])
89
+ else:
90
+ raise ResourceNotFound(service, resource)
91
+ elif separators == 1:
92
+ return Resource(segments[0], segments[1], segments[1])
93
+ elif separators == 0:
94
+ return Resource(None, segments[0], segments[0])
95
+ else:
96
+ raise ResourceNotFound(service, resource)
97
+
98
+
99
+ def arnparse(arn):
100
+ """Parse an ARN string into components."""
101
+ parts = arn.split(":", 5)
102
+ if len(parts) != 6:
103
+ raise ValueError(f"Invalid ARN format: {arn}")
104
+
105
+ if parts[0] != "arn":
106
+ raise ValueError(f"ARN must start with 'arn': {arn}")
107
+
108
+ _, partition, service, region, account, resource = parts
109
+
110
+ # Global services - no region, no account
111
+ if service in {"s3", "route53"}:
112
+ if region or account:
113
+ raise ValueError(f"Service {service} must have empty region and account: {arn}")
114
+
115
+ # Global services - no region, has account
116
+ if service in {"cloudfront", "iam"}:
117
+ if region:
118
+ raise ValueError(f"Service {service} must have empty region: {arn}")
119
+ if not account:
120
+ raise ValueError(f"Service {service} must have account: {arn}")
121
+
122
+ # Regional services - has region, no account
123
+ if service in {"apigateway"}:
124
+ if not region:
125
+ raise ValueError(f"Service {service} must have region: {arn}")
126
+ if account:
127
+ raise ValueError(f"Service {service} must have empty account: {arn}")
128
+
129
+ resource = find_resource(service, resource)
130
+
131
+ return ARN(
132
+ partition=partition,
133
+ service=service,
134
+ region=region,
135
+ account=account,
136
+ resource_type=resource.resource_type,
137
+ resource_id=resource.resource_id,
138
+ resource_name=resource.resource_name,
139
+ )
@@ -0,0 +1,134 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ import boto3
4
+
5
+ from .arnparse import arnparse
6
+
7
+ class Handler(ABC):
8
+ @classmethod
9
+ @abstractmethod
10
+ def get_tags(cls, session, arns):
11
+ pass
12
+
13
+ @classmethod
14
+ @abstractmethod
15
+ def set_tags(cls, session, arns, tags):
16
+ pass
17
+
18
+
19
+ class IAMHandler(Handler):
20
+ """Handler for IAM resources using dedicated IAM tagging API."""
21
+
22
+ @classmethod
23
+ def get_tags(cls, session, arns):
24
+ if not arns:
25
+ raise ValueError("At least one ARN must be provided to get_tags.")
26
+
27
+ client = session.client("iam")
28
+ result = {}
29
+
30
+ for arn in arns:
31
+ arn_data = arnparse(arn)
32
+ resource_type = arn_data.resource_type
33
+ resource_name = arn_data.resource_name
34
+
35
+ if resource_type == "role":
36
+ response = client.list_role_tags(RoleName=resource_name)
37
+ elif resource_type == "user":
38
+ response = client.list_user_tags(UserName=resource_name)
39
+ elif resource_type == "policy":
40
+ response = client.list_policy_tags(PolicyArn=arn)
41
+ elif resource_type == "instance-profile":
42
+ response = client.list_instance_profile_tags(InstanceProfileName=resource_name)
43
+ else:
44
+ response = None
45
+
46
+ if response:
47
+ result[arn] = {t["Key"]: t["Value"] for t in response["Tags"]}
48
+ else:
49
+ result[arn] = None
50
+
51
+ return result
52
+
53
+ @classmethod
54
+ def set_tags(cls, session, arns, tags):
55
+ if not arns:
56
+ raise ValueError("At least one ARN must be provided to set_tags.")
57
+
58
+ client = session.client("iam")
59
+ result = {}
60
+ payload = [{"Key": k, "Value": v} for k, v in tags.items()]
61
+
62
+ for arn in arns:
63
+ arn_data = arnparse(arn)
64
+ resource_type = arn_data.resource_type
65
+ resource_name = arn_data.resource_name
66
+
67
+ try:
68
+ if resource_type == "role":
69
+ response = client.tag_role(RoleName=resource_name, Tags=payload)
70
+ elif resource_type == "user":
71
+ response = client.tag_user(UserName=resource_name, Tags=payload)
72
+ elif resource_type == "policy":
73
+ response = client.tag_policy(PolicyArn=arn, Tags=payload)
74
+ elif resource_type == "instance-profile":
75
+ response = client.tag_instance_profile(InstanceProfileName=resource_name, Tags=payload)
76
+ else:
77
+ result[arn] = None
78
+ continue
79
+ except client.exceptions.NoSuchEntityException:
80
+ result[arn] = None
81
+ continue
82
+
83
+ result[arn] = response["ResponseMetadata"]["HTTPStatusCode"] == 200
84
+
85
+ return result
86
+
87
+
88
+ class TaggingAPIHandler(Handler):
89
+ @classmethod
90
+ def get_tags(cls, session, arns):
91
+ if not arns:
92
+ raise ValueError("At least one ARN must be provided to get_tags.")
93
+
94
+ client = session.client("resourcegroupstaggingapi")
95
+ result = {}
96
+
97
+ # https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/API_GetResources.html
98
+ batch_size = 100
99
+ for i in range(0, len(arns), batch_size):
100
+ batch = arns[i : i + batch_size]
101
+ response = client.get_resources(ResourceARNList=batch)
102
+ response = response["ResourceTagMappingList"]
103
+ for arn in batch:
104
+ if arn in response:
105
+ result[arn] = {
106
+ t["Key"]: t["Value"] for t in response[arn].get("Tags", [])
107
+ }
108
+ else:
109
+ result[arn] = None
110
+ return result
111
+
112
+ @classmethod
113
+ def set_tags(cls, session, arns, tags):
114
+ if not arns:
115
+ raise ValueError("At least one ARN must be provided to set_tags.")
116
+
117
+ if len(tags) == 0:
118
+ raise ValueError("At least one tag must be provided to set_tags.")
119
+
120
+ if len(tags) > 50:
121
+ raise ValueError("A maximum of 50 tags can be set at once.")
122
+
123
+ client = session.client("resourcegroupstaggingapi")
124
+ result = {}
125
+
126
+ # https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/API_TagResources.html
127
+ batch_size = 20
128
+ for i in range(0, len(arns), batch_size):
129
+ batch = arns[i : i + batch_size]
130
+ response = client.tag_resources(ResourceARNList=batch, Tags=tags)
131
+ for arn in batch:
132
+ result[arn] = arn not in response["FailedResourcesMap"]
133
+
134
+ return result
@@ -0,0 +1,45 @@
1
+ """AWS resource tag operations."""
2
+
3
+ import boto3
4
+
5
+ from .arnparse import arnparse
6
+ from .handlers import IAMHandler, TaggingAPIHandler
7
+
8
+
9
+ def get_tags(arns, session=None):
10
+ """Get tags for AWS resources."""
11
+ session = session or boto3.Session()
12
+ handlers = dispatch(arns)
13
+ results = {}
14
+ for handler, handler_arns in handlers.items():
15
+ r = handler.get_tags(session, handler_arns)
16
+ results.update(r)
17
+ return results
18
+
19
+
20
+ def set_tags(arns, tags, session=None):
21
+ """Set tags on AWS resources."""
22
+ session = session or boto3.Session()
23
+ handlers = dispatch(arns)
24
+ results = {}
25
+ for handler, handler_arns in handlers.items():
26
+ r = handler.set_tags(session, handler_arns, tags)
27
+ results.update(r)
28
+ return results
29
+
30
+
31
+ def dispatch(arns):
32
+ """Group ARNs by handler."""
33
+ handler_to_arns = {}
34
+ for arn in arns:
35
+ arn_data = arnparse(arn)
36
+
37
+ if arn_data.service == "iam":
38
+ handler = IAMHandler
39
+ else:
40
+ handler = TaggingAPIHandler
41
+
42
+ if handler not in handler_to_arns:
43
+ handler_to_arns[handler] = []
44
+ handler_to_arns[handler].append(arn)
45
+ return handler_to_arns
@@ -0,0 +1,363 @@
1
+ """Tests for ARN parsing."""
2
+
3
+ from better_aws_tags.arnparse import arnparse
4
+
5
+
6
+ def test_arnparse_acm():
7
+ result = arnparse("arn:aws:acm:us-east-1:012345678901:certificate/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
8
+ assert result.resource_type == "certificate"
9
+ assert result.resource_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
10
+ assert result.resource_name == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
11
+
12
+
13
+ def test_arnparse_apigateway():
14
+ result = arnparse("arn:aws:apigateway:us-east-1::/restapis/abc123def4")
15
+ assert result.resource_type == "restapis"
16
+ assert result.resource_id == "abc123def4"
17
+ assert result.resource_name == "abc123def4"
18
+
19
+
20
+ def test_arnparse_athena():
21
+ result = arnparse("arn:aws:athena:us-east-1:012345678901:workgroup/workgroup1")
22
+ assert result.resource_type == "workgroup"
23
+ assert result.resource_id == "workgroup1"
24
+ assert result.resource_name == "workgroup1"
25
+
26
+
27
+ def test_arnparse_autoscaling():
28
+ result = arnparse("arn:aws:autoscaling:us-east-1:012345678901:autoScalingGroup:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:autoScalingGroupName/asg1")
29
+ assert result.resource_type == "autoScalingGroup"
30
+ assert result.resource_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
31
+ assert result.resource_name == "asg1"
32
+
33
+
34
+ def test_arnparse_backup():
35
+ result = arnparse("arn:aws:backup:us-east-1:012345678901:backup-vault:vault1")
36
+ assert result.resource_type == "backup-vault"
37
+ assert result.resource_id == "vault1"
38
+ assert result.resource_name == "vault1"
39
+
40
+
41
+ def test_arnparse_cloudformation():
42
+ result = arnparse("arn:aws:cloudformation:us-east-1:012345678901:stack/stack1/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
43
+ assert result.resource_type == "stack"
44
+ assert result.resource_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
45
+ assert result.resource_name == "stack1"
46
+
47
+
48
+ def test_arnparse_cloudfront():
49
+ result = arnparse("arn:aws:cloudfront::012345678901:distribution/ABCDEFGHIJKLMN")
50
+ assert result.resource_type == "distribution"
51
+ assert result.resource_id == "ABCDEFGHIJKLMN"
52
+ assert result.resource_name == "ABCDEFGHIJKLMN"
53
+
54
+
55
+ def test_arnparse_cloudtrail():
56
+ result = arnparse("arn:aws:cloudtrail:us-east-1:012345678901:trail/trailname1")
57
+ assert result.resource_type == "trail"
58
+ assert result.resource_id == "trailname1"
59
+ assert result.resource_name == "trailname1"
60
+
61
+
62
+ def test_arnparse_cloudwatch():
63
+ result = arnparse("arn:aws:cloudwatch:us-east-1:012345678901:alarm:CPU Utilization - High Warning - service-production-1")
64
+ assert result.resource_type == "alarm"
65
+ assert result.resource_id == "CPU Utilization - High Warning - service-production-1"
66
+ assert result.resource_name == "CPU Utilization - High Warning - service-production-1"
67
+
68
+
69
+ def test_arnparse_codebuild():
70
+ result = arnparse("arn:aws:codebuild:us-east-1:012345678901:project/build_image")
71
+ assert result.resource_type == "project"
72
+ assert result.resource_id == "build_image"
73
+ assert result.resource_name == "build_image"
74
+
75
+
76
+ def test_arnparse_codecommit():
77
+ result = arnparse("arn:aws:codecommit:us-east-1:012345678901:repository1")
78
+ assert result.resource_type is None
79
+ assert result.resource_id == "repository1"
80
+ assert result.resource_name == "repository1"
81
+
82
+
83
+ def test_arnparse_codepipeline():
84
+ result = arnparse("arn:aws:codepipeline:us-east-1:012345678901:deploy_pipeline")
85
+ assert result.resource_type is None
86
+ assert result.resource_id == "deploy_pipeline"
87
+ assert result.resource_name == "deploy_pipeline"
88
+
89
+
90
+ def test_arnparse_datasync():
91
+ result = arnparse("arn:aws:datasync:us-east-1:012345678901:task/task-0123456789abcdef0")
92
+ assert result.resource_type == "task"
93
+ assert result.resource_id == "task-0123456789abcdef0"
94
+ assert result.resource_name == "task-0123456789abcdef0"
95
+
96
+
97
+ def test_arnparse_dynamodb():
98
+ result = arnparse("arn:aws:dynamodb:us-east-1:012345678901:table/table1")
99
+ assert result.resource_type == "table"
100
+ assert result.resource_id == "table1"
101
+ assert result.resource_name == "table1"
102
+
103
+
104
+ def test_arnparse_ec2():
105
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:instance/i-0123456789abcdef0")
106
+ assert result.resource_type == "instance"
107
+ assert result.resource_id == "i-0123456789abcdef0"
108
+ assert result.resource_name == "i-0123456789abcdef0"
109
+
110
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:volume/vol-0123456789abcdef0")
111
+ assert result.resource_type == "volume"
112
+ assert result.resource_id == "vol-0123456789abcdef0"
113
+
114
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:snapshot/snap-0123456789abcdef0")
115
+ assert result.resource_type == "snapshot"
116
+ assert result.resource_id == "snap-0123456789abcdef0"
117
+
118
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:image/ami-0123456789abcdef0")
119
+ assert result.resource_type == "image"
120
+ assert result.resource_id == "ami-0123456789abcdef0"
121
+
122
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:elastic-ip/eipalloc-0123456789abcdef0")
123
+ assert result.resource_type == "elastic-ip"
124
+ assert result.resource_id == "eipalloc-0123456789abcdef0"
125
+
126
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:natgateway/nat-0123456789abcdef0")
127
+ assert result.resource_type == "natgateway"
128
+ assert result.resource_id == "nat-0123456789abcdef0"
129
+
130
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:vpc-endpoint/vpce-0123456789abcdef0")
131
+ assert result.resource_type == "vpc-endpoint"
132
+ assert result.resource_id == "vpce-0123456789abcdef0"
133
+
134
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:launch-template/lt-0123456789abcdef0")
135
+ assert result.resource_type == "launch-template"
136
+ assert result.resource_id == "lt-0123456789abcdef0"
137
+
138
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:reserved-instances/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
139
+ assert result.resource_type == "reserved-instances"
140
+ assert result.resource_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
141
+
142
+ result = arnparse("arn:aws:ec2:us-east-1:012345678901:spot-instances-request/sir-abcd1234")
143
+ assert result.resource_type == "spot-instances-request"
144
+ assert result.resource_id == "sir-abcd1234"
145
+
146
+
147
+ def test_arnparse_ecr():
148
+ result = arnparse("arn:aws:ecr:us-east-1:012345678901:repository/web-service")
149
+ assert result.resource_type == "repository"
150
+ assert result.resource_id == "web-service"
151
+ assert result.resource_name == "web-service"
152
+
153
+
154
+ def test_arnparse_eks():
155
+ result = arnparse("arn:aws:eks:us-east-1:012345678901:cluster/c1")
156
+ assert result.resource_type == "cluster"
157
+ assert result.resource_id == "c1"
158
+ assert result.resource_name == "c1"
159
+
160
+
161
+ def test_arnparse_elasticache():
162
+ result = arnparse("arn:aws:elasticache:us-east-1:012345678901:cluster:redis-cluster-node-001")
163
+ assert result.resource_type == "cluster"
164
+ assert result.resource_id == "redis-cluster-node-001"
165
+
166
+ result = arnparse("arn:aws:elasticache:us-east-1:012345678901:parametergroup:redis-cluster-params-redis7")
167
+ assert result.resource_type == "parametergroup"
168
+ assert result.resource_id == "redis-cluster-params-redis7"
169
+
170
+ result = arnparse("arn:aws:elasticache:us-east-1:012345678901:replicationgroup:redis-repl-group1")
171
+ assert result.resource_type == "replicationgroup"
172
+ assert result.resource_id == "redis-repl-group1"
173
+
174
+ result = arnparse("arn:aws:elasticache:us-east-1:012345678901:reserved-instance:reserved-node-prod1")
175
+ assert result.resource_type == "reserved-instance"
176
+ assert result.resource_id == "reserved-node-prod1"
177
+
178
+ result = arnparse("arn:aws:elasticache:us-east-1:012345678901:subnetgroup:cache-subnet-group1")
179
+ assert result.resource_type == "subnetgroup"
180
+ assert result.resource_id == "cache-subnet-group1"
181
+
182
+ result = arnparse("arn:aws:elasticache:us-east-1:012345678901:user:default")
183
+ assert result.resource_type == "user"
184
+ assert result.resource_id == "default"
185
+
186
+
187
+ def test_arnparse_elasticfilesystem():
188
+ result = arnparse("arn:aws:elasticfilesystem:us-east-1:012345678901:file-system/fs-01234567")
189
+ assert result.resource_type == "file-system"
190
+ assert result.resource_id == "fs-01234567"
191
+ assert result.resource_name == "fs-01234567"
192
+
193
+
194
+ def test_arnparse_elasticloadbalancing():
195
+ # Classic LB
196
+ result = arnparse("arn:aws:elasticloadbalancing:us-east-1:012345678901:loadbalancer/a0123456789abcdef0123456789abcde")
197
+ assert result.resource_type == "loadbalancer"
198
+ assert result.resource_id == "a0123456789abcdef0123456789abcde"
199
+
200
+ # ALB
201
+ result = arnparse("arn:aws:elasticloadbalancing:us-east-1:012345678901:loadbalancer/app/alb-application-lb-name/0123456789abcdef")
202
+ assert result.resource_type == "loadbalancer/app"
203
+ assert result.resource_id == "0123456789abcdef"
204
+ assert result.resource_name == "alb-application-lb-name"
205
+
206
+ # NLB
207
+ result = arnparse("arn:aws:elasticloadbalancing:us-east-1:012345678901:loadbalancer/net/nlb-network-load-balancer/0123456789abcdef")
208
+ assert result.resource_type == "loadbalancer/net"
209
+ assert result.resource_id == "0123456789abcdef"
210
+ assert result.resource_name == "nlb-network-load-balancer"
211
+
212
+ # Target group
213
+ result = arnparse("arn:aws:elasticloadbalancing:us-east-1:012345678901:targetgroup/target-grp-1/0123456789abcdef")
214
+ assert result.resource_type == "targetgroup"
215
+ assert result.resource_id == "0123456789abcdef"
216
+ assert result.resource_name == "target-grp-1"
217
+
218
+
219
+ def test_arnparse_es():
220
+ result = arnparse("arn:aws:es:us-east-1:012345678901:domain/search-domain-prod")
221
+ assert result.resource_type == "domain"
222
+ assert result.resource_id == "search-domain-prod"
223
+ assert result.resource_name == "search-domain-prod"
224
+
225
+
226
+ def test_arnparse_events():
227
+ result = arnparse("arn:aws:events:us-east-1:012345678901:event-bus/default")
228
+ assert result.resource_type == "event-bus"
229
+ assert result.resource_id == "default"
230
+
231
+ result = arnparse("arn:aws:events:us-east-1:012345678901:rule/ScheduledEventRule01")
232
+ assert result.resource_type == "rule"
233
+ assert result.resource_id == "ScheduledEventRule01"
234
+
235
+
236
+ def test_arnparse_guardduty():
237
+ result = arnparse("arn:aws:guardduty:us-east-1:012345678901:detector/0123456789abcdef0123456789abcdef")
238
+ assert result.resource_type == "detector"
239
+ assert result.resource_id == "0123456789abcdef0123456789abcdef"
240
+ assert result.resource_name == "0123456789abcdef0123456789abcdef"
241
+
242
+
243
+ def test_arnparse_iam():
244
+ result = arnparse("arn:aws:iam::012345678901:user/admin")
245
+ assert result.resource_type == "user"
246
+ assert result.resource_id == "admin"
247
+ assert result.resource_name == "admin"
248
+
249
+ result = arnparse("arn:aws:iam::012345678901:role/lambda-execution-role")
250
+ assert result.resource_type == "role"
251
+ assert result.resource_id == "lambda-execution-role"
252
+
253
+ result = arnparse("arn:aws:iam::012345678901:policy/custom-policy")
254
+ assert result.resource_type == "policy"
255
+ assert result.resource_id == "custom-policy"
256
+
257
+ result = arnparse("arn:aws:iam::012345678901:group/developers")
258
+ assert result.resource_type == "group"
259
+ assert result.resource_id == "developers"
260
+
261
+ result = arnparse("arn:aws:iam::012345678901:instance-profile/ec2-profile")
262
+ assert result.resource_type == "instance-profile"
263
+ assert result.resource_id == "ec2-profile"
264
+
265
+
266
+ def test_arnparse_kms():
267
+ result = arnparse("arn:aws:kms:us-east-1:012345678901:key/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
268
+ assert result.resource_type == "key"
269
+ assert result.resource_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
270
+ assert result.resource_name == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
271
+
272
+
273
+ def test_arnparse_lambda():
274
+ result = arnparse("arn:aws:lambda:us-east-1:012345678901:function:ProcessDataHandler")
275
+ assert result.resource_type == "function"
276
+ assert result.resource_id == "ProcessDataHandler"
277
+ assert result.resource_name == "ProcessDataHandler"
278
+
279
+
280
+ def test_arnparse_logs():
281
+ result = arnparse("arn:aws:logs:us-east-1:012345678901:log-group:/aws/lambda/function-logs")
282
+ assert result.resource_type == "log-group"
283
+ assert result.resource_id == "/aws/lambda/function-logs"
284
+ assert result.resource_name == "/aws/lambda/function-logs"
285
+
286
+
287
+ def test_arnparse_rds():
288
+ result = arnparse("arn:aws:rds:us-east-1:012345678901:cluster:database-cluster-1")
289
+ assert result.resource_type == "cluster"
290
+ assert result.resource_id == "database-cluster-1"
291
+
292
+ result = arnparse("arn:aws:rds:us-east-1:012345678901:cluster-snapshot:snap001")
293
+ assert result.resource_type == "cluster-snapshot"
294
+ assert result.resource_id == "snap001"
295
+
296
+ result = arnparse("arn:aws:rds:us-east-1:012345678901:db:database-instance-1")
297
+ assert result.resource_type == "db"
298
+ assert result.resource_id == "database-instance-1"
299
+
300
+ result = arnparse("arn:aws:rds:us-east-1:012345678901:ri:reserved01")
301
+ assert result.resource_type == "ri"
302
+ assert result.resource_id == "reserved01"
303
+
304
+ result = arnparse("arn:aws:rds:us-east-1:012345678901:snapshot:final-database-backup-01234567")
305
+ assert result.resource_type == "snapshot"
306
+ assert result.resource_id == "final-database-backup-01234567"
307
+
308
+
309
+ def test_arnparse_route53():
310
+ result = arnparse("arn:aws:route53:::healthcheck/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
311
+ assert result.resource_type == "healthcheck"
312
+ assert result.resource_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
313
+
314
+ result = arnparse("arn:aws:route53:::hostedzone/Z0123456789ABCDEFGHIJ")
315
+ assert result.resource_type == "hostedzone"
316
+ assert result.resource_id == "Z0123456789ABCDEFGHIJ"
317
+
318
+
319
+ def test_arnparse_s3():
320
+ result = arnparse("arn:aws:s3:::example-bucket-01")
321
+ assert result.resource_type is None
322
+ assert result.resource_id == "example-bucket-01"
323
+ assert result.resource_name == "example-bucket-01"
324
+
325
+
326
+ def test_arnparse_secretsmanager():
327
+ result = arnparse("arn:aws:secretsmanager:us-east-1:012345678901:secret:/app/secrets/service-name/production-AbCdEf")
328
+ assert result.resource_type == "secret"
329
+ assert result.resource_id == "/app/secrets/service-name/production-AbCdEf"
330
+ assert result.resource_name == "/app/secrets/service-name/production-AbCdEf"
331
+
332
+
333
+ def test_arnparse_sns():
334
+ result = arnparse("arn:aws:sns:us-east-1:012345678901:topic1")
335
+ assert result.resource_type is None
336
+ assert result.resource_id == "topic1"
337
+ assert result.resource_name == "topic1"
338
+
339
+
340
+ def test_arnparse_sqs():
341
+ result = arnparse("arn:aws:sqs:us-east-1:012345678901:processing-queue-1")
342
+ assert result.resource_type is None
343
+ assert result.resource_id == "processing-queue-1"
344
+ assert result.resource_name == "processing-queue-1"
345
+
346
+
347
+ def test_arnparse_ssm():
348
+ result = arnparse("arn:aws:ssm:us-east-1:012345678901:parameter/config_val")
349
+ assert result.resource_type == "parameter"
350
+ assert result.resource_id == "/config_val"
351
+ assert result.resource_name == "/config_val"
352
+
353
+
354
+ def test_arnparse_wafv2():
355
+ result = arnparse("arn:aws:wafv2:us-east-1:012345678901:global/webacl/WebACL-for-CloudFront-01/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
356
+ assert result.resource_type == "global/webacl"
357
+ assert result.resource_id == "WebACL-for-CloudFront-01"
358
+ assert result.resource_name == "WebACL-for-CloudFront-01"
359
+
360
+ result = arnparse("arn:aws:wafv2:us-east-1:012345678901:regional/webacl/webacl-production-acl/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
361
+ assert result.resource_type == "regional/webacl"
362
+ assert result.resource_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
363
+ assert result.resource_name == "webacl-production-acl"
@@ -1,16 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "mcp__plugin_adaptivegears_adaptivegears__search",
5
- "mcp__plugin_adaptivegears_adaptivegears__read",
6
- "Bash(uv init:*)",
7
- "Bash(uv venv:*)",
8
- "Bash(uv add:*)",
9
- "Bash(make)",
10
- "Bash(git branch:*)"
11
- ]
12
- },
13
- "enabledPlugins": {
14
- "adaptivegears@adaptivegears-marketplace": true
15
- }
16
- }
@@ -1,18 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: better-aws-tags
3
- Version: 0.2.0
4
- Summary: A unified Python interface for managing AWS resource tags
5
- Author-email: Andrey Gubarev <andrey@andreygubarev.com>
6
- Requires-Python: >=3.10
7
- Requires-Dist: boto3~=1.0
8
- Description-Content-Type: text/markdown
9
-
10
- # better-aws-tags
11
-
12
- A unified Python interface for managing AWS resource tags. AWS provides multiple tagging APIs across services, including the Resource Groups Tagging API and service-specific implementations such as EC2, S3, and IAM. This library abstracts these differences behind a single API that accepts any valid ARN and handles service routing, tag validation, and error reporting automatically.
13
-
14
- ## Getting Started
15
-
16
- ```python
17
- import better_aws_tags as bats
18
- ```
@@ -1,9 +0,0 @@
1
- # better-aws-tags
2
-
3
- A unified Python interface for managing AWS resource tags. AWS provides multiple tagging APIs across services, including the Resource Groups Tagging API and service-specific implementations such as EC2, S3, and IAM. This library abstracts these differences behind a single API that accepts any valid ARN and handles service routing, tag validation, and error reporting automatically.
4
-
5
- ## Getting Started
6
-
7
- ```python
8
- import better_aws_tags as bats
9
- ```
@@ -1,39 +0,0 @@
1
- """AWS resource tag operations."""
2
-
3
- import boto3
4
-
5
-
6
- def get_tags(
7
- arn: str | list[str],
8
- session: boto3.Session | None = None,
9
- ) -> dict[str, str] | dict[str, dict[str, str]]:
10
- """Get tags for one or more AWS resources.
11
-
12
- Args:
13
- arn: Single ARN or list of ARNs.
14
- session: Optional boto3 session. Uses default if not provided.
15
-
16
- Returns:
17
- Single ARN: {"Key": "Value", ...}
18
- Multiple ARNs: {"arn1": {"Key": "Value"}, "arn2": {...}}
19
- """
20
- raise NotImplementedError
21
-
22
-
23
- def set_tags(
24
- arn: str | list[str],
25
- tags: dict[str, str],
26
- session: boto3.Session | None = None,
27
- ) -> bool | dict[str, bool]:
28
- """Set tags on one or more AWS resources.
29
-
30
- Args:
31
- arn: Single ARN or list of ARNs.
32
- tags: Tags to set (merged with existing).
33
- session: Optional boto3 session. Uses default if not provided.
34
-
35
- Returns:
36
- Single ARN: True if successful.
37
- Multiple ARNs: {"arn1": True, "arn2": False, ...}
38
- """
39
- raise NotImplementedError
File without changes