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.
@@ -1,4 +1,4 @@
1
- __version__ = "0.1.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
better_aws_tags/tags.py CHANGED
@@ -2,38 +2,44 @@
2
2
 
3
3
  import boto3
4
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
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,,