zae-limiter 0.1.0__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.
zae_limiter/schema.py ADDED
@@ -0,0 +1,163 @@
1
+ """DynamoDB schema definitions and key builders."""
2
+
3
+ from typing import Any
4
+
5
+ # Table and index names
6
+ DEFAULT_TABLE_NAME = "rate_limits"
7
+ GSI1_NAME = "GSI1" # For parent -> children lookups
8
+ GSI2_NAME = "GSI2" # For resource aggregation
9
+
10
+ # Key prefixes
11
+ ENTITY_PREFIX = "ENTITY#"
12
+ PARENT_PREFIX = "PARENT#"
13
+ CHILD_PREFIX = "CHILD#"
14
+ RESOURCE_PREFIX = "RESOURCE#"
15
+ SYSTEM_PREFIX = "SYSTEM#"
16
+
17
+ # Sort key prefixes
18
+ SK_META = "#META"
19
+ SK_BUCKET = "#BUCKET#"
20
+ SK_LIMIT = "#LIMIT#"
21
+ SK_RESOURCE = "#RESOURCE#"
22
+ SK_USAGE = "#USAGE#"
23
+ SK_VERSION = "#VERSION"
24
+
25
+ # Special resource for default limits
26
+ DEFAULT_RESOURCE = "_default_"
27
+
28
+
29
+ def pk_entity(entity_id: str) -> str:
30
+ """Build partition key for an entity."""
31
+ return f"{ENTITY_PREFIX}{entity_id}"
32
+
33
+
34
+ def pk_system() -> str:
35
+ """Build partition key for system records (e.g., version)."""
36
+ return SYSTEM_PREFIX
37
+
38
+
39
+ def sk_version() -> str:
40
+ """Build sort key for version record."""
41
+ return SK_VERSION
42
+
43
+
44
+ def sk_meta() -> str:
45
+ """Build sort key for entity metadata."""
46
+ return SK_META
47
+
48
+
49
+ def sk_bucket(resource: str, limit_name: str) -> str:
50
+ """Build sort key for a bucket."""
51
+ return f"{SK_BUCKET}{resource}#{limit_name}"
52
+
53
+
54
+ def sk_limit(resource: str, limit_name: str) -> str:
55
+ """Build sort key for a limit config."""
56
+ return f"{SK_LIMIT}{resource}#{limit_name}"
57
+
58
+
59
+ def sk_limit_prefix(resource: str) -> str:
60
+ """Build sort key prefix for querying limits by resource."""
61
+ return f"{SK_LIMIT}{resource}#"
62
+
63
+
64
+ def sk_resource(resource: str) -> str:
65
+ """Build sort key for resource access tracking."""
66
+ return f"{SK_RESOURCE}{resource}"
67
+
68
+
69
+ def sk_usage(resource: str, window_key: str) -> str:
70
+ """Build sort key for usage snapshot."""
71
+ return f"{SK_USAGE}{resource}#{window_key}"
72
+
73
+
74
+ def gsi1_pk_parent(parent_id: str) -> str:
75
+ """Build GSI1 partition key for parent lookup."""
76
+ return f"{PARENT_PREFIX}{parent_id}"
77
+
78
+
79
+ def gsi1_sk_child(entity_id: str) -> str:
80
+ """Build GSI1 sort key for child entry."""
81
+ return f"{CHILD_PREFIX}{entity_id}"
82
+
83
+
84
+ def gsi2_pk_resource(resource: str) -> str:
85
+ """Build GSI2 partition key for resource aggregation."""
86
+ return f"{RESOURCE_PREFIX}{resource}"
87
+
88
+
89
+ def gsi2_sk_bucket(entity_id: str, limit_name: str) -> str:
90
+ """Build GSI2 sort key for bucket entry."""
91
+ return f"BUCKET#{entity_id}#{limit_name}"
92
+
93
+
94
+ def gsi2_sk_access(entity_id: str) -> str:
95
+ """Build GSI2 sort key for access tracking entry."""
96
+ return f"ACCESS#{entity_id}"
97
+
98
+
99
+ def gsi2_sk_usage(window_key: str, entity_id: str) -> str:
100
+ """Build GSI2 sort key for usage snapshot entry."""
101
+ return f"USAGE#{window_key}#{entity_id}"
102
+
103
+
104
+ def parse_bucket_sk(sk: str) -> tuple[str, str]:
105
+ """Parse resource and limit_name from bucket sort key."""
106
+ # SK format: #BUCKET#{resource}#{limit_name}
107
+ if not sk.startswith(SK_BUCKET):
108
+ raise ValueError(f"Invalid bucket SK: {sk}")
109
+ parts = sk[len(SK_BUCKET) :].split("#", 1)
110
+ if len(parts) != 2:
111
+ raise ValueError(f"Invalid bucket SK format: {sk}")
112
+ return parts[0], parts[1]
113
+
114
+
115
+ def get_table_definition(table_name: str) -> dict[str, Any]:
116
+ """
117
+ Get the DynamoDB table definition for CreateTable.
118
+
119
+ Returns a dictionary suitable for boto3 create_table().
120
+ """
121
+ return {
122
+ "TableName": table_name,
123
+ "BillingMode": "PAY_PER_REQUEST",
124
+ "AttributeDefinitions": [
125
+ {"AttributeName": "PK", "AttributeType": "S"},
126
+ {"AttributeName": "SK", "AttributeType": "S"},
127
+ {"AttributeName": "GSI1PK", "AttributeType": "S"},
128
+ {"AttributeName": "GSI1SK", "AttributeType": "S"},
129
+ {"AttributeName": "GSI2PK", "AttributeType": "S"},
130
+ {"AttributeName": "GSI2SK", "AttributeType": "S"},
131
+ ],
132
+ "KeySchema": [
133
+ {"AttributeName": "PK", "KeyType": "HASH"},
134
+ {"AttributeName": "SK", "KeyType": "RANGE"},
135
+ ],
136
+ "GlobalSecondaryIndexes": [
137
+ {
138
+ "IndexName": GSI1_NAME,
139
+ "KeySchema": [
140
+ {"AttributeName": "GSI1PK", "KeyType": "HASH"},
141
+ {"AttributeName": "GSI1SK", "KeyType": "RANGE"},
142
+ ],
143
+ "Projection": {"ProjectionType": "ALL"},
144
+ },
145
+ {
146
+ "IndexName": GSI2_NAME,
147
+ "KeySchema": [
148
+ {"AttributeName": "GSI2PK", "KeyType": "HASH"},
149
+ {"AttributeName": "GSI2SK", "KeyType": "RANGE"},
150
+ ],
151
+ "Projection": {"ProjectionType": "ALL"},
152
+ },
153
+ ],
154
+ "StreamSpecification": {
155
+ "StreamEnabled": True,
156
+ "StreamViewType": "NEW_AND_OLD_IMAGES",
157
+ },
158
+ }
159
+
160
+
161
+ def calculate_ttl(now_ms: int, ttl_seconds: int = 86400) -> int:
162
+ """Calculate TTL timestamp (epoch seconds)."""
163
+ return (now_ms // 1000) + ttl_seconds
zae_limiter/version.py ADDED
@@ -0,0 +1,214 @@
1
+ """Version tracking and compatibility checking for zae-limiter infrastructure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ # Current schema version - increment when schema changes
9
+ CURRENT_SCHEMA_VERSION = "1.0.0"
10
+
11
+
12
+ @dataclass(frozen=True, order=False)
13
+ class ParsedVersion:
14
+ """Parsed semantic version components."""
15
+
16
+ major: int
17
+ minor: int
18
+ patch: int
19
+ prerelease: str | None = None
20
+
21
+ def __str__(self) -> str:
22
+ base = f"{self.major}.{self.minor}.{self.patch}"
23
+ if self.prerelease:
24
+ return f"{base}-{self.prerelease}"
25
+ return base
26
+
27
+ def __lt__(self, other: ParsedVersion) -> bool:
28
+ # Compare major.minor.patch first
29
+ if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
30
+ return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
31
+ # Prerelease versions are less than release versions
32
+ if self.prerelease and not other.prerelease:
33
+ return True
34
+ if not self.prerelease and other.prerelease:
35
+ return False
36
+ # Both have prerelease, compare lexically
37
+ return (self.prerelease or "") < (other.prerelease or "")
38
+
39
+ def __le__(self, other: ParsedVersion) -> bool:
40
+ return self == other or self < other
41
+
42
+ def __gt__(self, other: ParsedVersion) -> bool:
43
+ return not self <= other
44
+
45
+ def __ge__(self, other: ParsedVersion) -> bool:
46
+ return not self < other
47
+
48
+
49
+ def parse_version(version_str: str) -> ParsedVersion:
50
+ """
51
+ Parse a semantic version string.
52
+
53
+ Handles formats like:
54
+ - "1.2.3"
55
+ - "1.2.3-dev"
56
+ - "1.2.3.dev123+gabcdef"
57
+ - "0.1.0"
58
+
59
+ Args:
60
+ version_str: Version string to parse
61
+
62
+ Returns:
63
+ ParsedVersion tuple
64
+
65
+ Raises:
66
+ ValueError: If version string is invalid
67
+ """
68
+ # Remove leading 'v' if present
69
+ if version_str.startswith("v"):
70
+ version_str = version_str[1:]
71
+
72
+ # Handle PEP 440 dev versions (e.g., "0.1.0.dev123+gabcdef")
73
+ # Convert to semver-like format
74
+ version_str = re.sub(r"\.dev\d+.*$", "-dev", version_str)
75
+
76
+ # Match standard semver with optional prerelease
77
+ match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", version_str)
78
+ if not match:
79
+ raise ValueError(f"Invalid version string: {version_str}")
80
+
81
+ return ParsedVersion(
82
+ major=int(match.group(1)),
83
+ minor=int(match.group(2)),
84
+ patch=int(match.group(3)),
85
+ prerelease=match.group(4),
86
+ )
87
+
88
+
89
+ @dataclass
90
+ class InfrastructureVersion:
91
+ """Version information for deployed infrastructure."""
92
+
93
+ schema_version: str
94
+ lambda_version: str | None
95
+ template_version: str | None
96
+ client_min_version: str
97
+
98
+ @classmethod
99
+ def from_record(cls, record: dict[str, str | None]) -> InfrastructureVersion:
100
+ """Create from a version record dictionary."""
101
+ return cls(
102
+ schema_version=record.get("schema_version") or "1.0.0",
103
+ lambda_version=record.get("lambda_version"),
104
+ template_version=record.get("template_version"),
105
+ client_min_version=record.get("client_min_version") or "0.0.0",
106
+ )
107
+
108
+
109
+ @dataclass
110
+ class CompatibilityResult:
111
+ """Result of a version compatibility check."""
112
+
113
+ is_compatible: bool
114
+ requires_schema_migration: bool = False
115
+ requires_lambda_update: bool = False
116
+ requires_template_update: bool = False
117
+ message: str = ""
118
+
119
+
120
+ def check_compatibility(
121
+ client_version: str,
122
+ infra_version: InfrastructureVersion,
123
+ ) -> CompatibilityResult:
124
+ """
125
+ Check compatibility between client and infrastructure versions.
126
+
127
+ Rules:
128
+ - Major version mismatch in schema: Always incompatible (requires migration)
129
+ - Client version < client_min_version: Incompatible (client too old)
130
+ - Lambda version < client version: Lambda update available
131
+ - Patch version differences: Always compatible
132
+
133
+ Args:
134
+ client_version: The client library version (e.g., "1.2.3")
135
+ infra_version: The infrastructure version information
136
+
137
+ Returns:
138
+ CompatibilityResult with compatibility status and details
139
+ """
140
+ try:
141
+ client = parse_version(client_version)
142
+ except ValueError:
143
+ return CompatibilityResult(
144
+ is_compatible=False,
145
+ message=f"Invalid client version: {client_version}",
146
+ )
147
+
148
+ try:
149
+ schema = parse_version(infra_version.schema_version)
150
+ except ValueError:
151
+ return CompatibilityResult(
152
+ is_compatible=False,
153
+ message=f"Invalid schema version: {infra_version.schema_version}",
154
+ )
155
+
156
+ try:
157
+ min_version = parse_version(infra_version.client_min_version)
158
+ except ValueError:
159
+ min_version = ParsedVersion(0, 0, 0)
160
+
161
+ # Check if client meets minimum version requirement
162
+ if client < min_version:
163
+ return CompatibilityResult(
164
+ is_compatible=False,
165
+ message=(
166
+ f"Client version {client_version} is below minimum required "
167
+ f"version {infra_version.client_min_version}. Please upgrade."
168
+ ),
169
+ )
170
+
171
+ # Check schema compatibility (major version must match)
172
+ if client.major != schema.major:
173
+ return CompatibilityResult(
174
+ is_compatible=False,
175
+ requires_schema_migration=True,
176
+ message=(
177
+ f"Schema version mismatch: client major version {client.major} "
178
+ f"!= schema major version {schema.major}. "
179
+ "Schema migration required."
180
+ ),
181
+ )
182
+
183
+ # Check if Lambda needs update
184
+ requires_lambda_update = False
185
+ if infra_version.lambda_version:
186
+ try:
187
+ lambda_v = parse_version(infra_version.lambda_version)
188
+ # Lambda update needed if client is newer (ignoring prerelease for comparison)
189
+ client_release = ParsedVersion(client.major, client.minor, client.patch)
190
+ lambda_release = ParsedVersion(lambda_v.major, lambda_v.minor, lambda_v.patch)
191
+ requires_lambda_update = lambda_release < client_release
192
+ except ValueError:
193
+ # If Lambda version is invalid, suggest update
194
+ requires_lambda_update = True
195
+
196
+ if requires_lambda_update:
197
+ return CompatibilityResult(
198
+ is_compatible=True, # Can still work, but update available
199
+ requires_lambda_update=True,
200
+ message=(
201
+ f"Lambda update available: {infra_version.lambda_version} -> {client_version}"
202
+ ),
203
+ )
204
+
205
+ # Fully compatible
206
+ return CompatibilityResult(
207
+ is_compatible=True,
208
+ message="Client and infrastructure versions are compatible.",
209
+ )
210
+
211
+
212
+ def get_schema_version() -> str:
213
+ """Get the current schema version."""
214
+ return CURRENT_SCHEMA_VERSION