better-aws-tags 0.1.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
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.
- better_aws_tags/__init__.py +1 -1
- better_aws_tags/arnparse.py +139 -0
- better_aws_tags/handlers.py +134 -0
- better_aws_tags/tags.py +41 -35
- better_aws_tags-0.3.1.dist-info/METADATA +20 -0
- better_aws_tags-0.3.1.dist-info/RECORD +7 -0
- better_aws_tags-0.1.0.dist-info/METADATA +0 -18
- better_aws_tags-0.1.0.dist-info/RECORD +0 -5
- {better_aws_tags-0.1.0.dist-info → better_aws_tags-0.3.1.dist-info}/WHEEL +0 -0
better_aws_tags/__init__.py
CHANGED
|
@@ -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
|
better_aws_tags/tags.py
CHANGED
|
@@ -2,38 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
import boto3
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"""Get tags for
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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,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,7 @@
|
|
|
1
|
+
better_aws_tags/__init__.py,sha256=87A8zTFvL5OLAXjtykxq_4lY3vpk1pDoVH8jDNwIX6E,101
|
|
2
|
+
better_aws_tags/arnparse.py,sha256=QiA9sRBY7QgO52V0truzzHfmlQCgJzWHaZOcIo8M9Ac,4865
|
|
3
|
+
better_aws_tags/handlers.py,sha256=VKQsr0I6Y03myNUlpK-xxqx0sFLRCw31mQwv-kvlyy0,4611
|
|
4
|
+
better_aws_tags/tags.py,sha256=pdgA1J_gMxnhyCNlPPAK4VisVZvPNWZO403yPAVbmWk,1182
|
|
5
|
+
better_aws_tags-0.3.1.dist-info/METADATA,sha256=co22UISTduJp7qCLYfgIhfqZ-W6bxsBhfbHMoQL_geA,743
|
|
6
|
+
better_aws_tags-0.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
7
|
+
better_aws_tags-0.3.1.dist-info/RECORD,,
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: better-aws-tags
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Add your description here
|
|
5
|
-
Author-email: Andrey Gubarev <mylokin@me.com>
|
|
6
|
-
Requires-Python: >=3.13
|
|
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,5 +0,0 @@
|
|
|
1
|
-
better_aws_tags/__init__.py,sha256=MQmqM7lNoABNMF1HjG_aZP5H3zdIqTNCuXOKffs7S1k,101
|
|
2
|
-
better_aws_tags/tags.py,sha256=K8LsVZiTPUhNoCfX4VKCRCuaEl1JD9vH-Ua7ttJDku0,1019
|
|
3
|
-
better_aws_tags-0.1.0.dist-info/METADATA,sha256=fuzHeGAnslOzhf_wWrOsbOYuB3YEdXUsY7gFlHTVLA0,700
|
|
4
|
-
better_aws_tags-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
5
|
-
better_aws_tags-0.1.0.dist-info/RECORD,,
|
|
File without changes
|