stackport 0.1.2__tar.gz → 0.1.3__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.
Files changed (35) hide show
  1. {stackport-0.1.2/stackport.egg-info → stackport-0.1.3}/PKG-INFO +24 -8
  2. {stackport-0.1.2 → stackport-0.1.3}/README.md +19 -7
  3. {stackport-0.1.2 → stackport-0.1.3}/backend/main.py +2 -1
  4. stackport-0.1.3/backend/routes/dynamodb.py +218 -0
  5. {stackport-0.1.2 → stackport-0.1.3}/pyproject.toml +8 -1
  6. {stackport-0.1.2 → stackport-0.1.3/stackport.egg-info}/PKG-INFO +24 -8
  7. {stackport-0.1.2 → stackport-0.1.3}/stackport.egg-info/SOURCES.txt +11 -4
  8. stackport-0.1.3/stackport.egg-info/requires.txt +8 -0
  9. stackport-0.1.3/tests/test_cache.py +63 -0
  10. stackport-0.1.3/tests/test_client.py +17 -0
  11. stackport-0.1.3/tests/test_config.py +41 -0
  12. stackport-0.1.3/tests/test_dynamodb_routes.py +255 -0
  13. stackport-0.1.3/tests/test_registries.py +69 -0
  14. stackport-0.1.3/tests/test_routes.py +117 -0
  15. stackport-0.1.3/ui/dist/assets/index-CEFYqXru.css +1 -0
  16. stackport-0.1.3/ui/dist/assets/index-DyyngEdQ.js +370 -0
  17. {stackport-0.1.2 → stackport-0.1.3}/ui/dist/index.html +2 -2
  18. stackport-0.1.2/stackport.egg-info/requires.txt +0 -3
  19. stackport-0.1.2/ui/dist/assets/index-CRqBg0t6.js +0 -360
  20. stackport-0.1.2/ui/dist/assets/index-Is3dIbZM.css +0 -1
  21. {stackport-0.1.2 → stackport-0.1.3}/LICENSE +0 -0
  22. {stackport-0.1.2 → stackport-0.1.3}/MANIFEST.in +0 -0
  23. {stackport-0.1.2 → stackport-0.1.3}/backend/__init__.py +0 -0
  24. {stackport-0.1.2 → stackport-0.1.3}/backend/aws_client.py +0 -0
  25. {stackport-0.1.2 → stackport-0.1.3}/backend/cache.py +0 -0
  26. {stackport-0.1.2 → stackport-0.1.3}/backend/config.py +0 -0
  27. {stackport-0.1.2 → stackport-0.1.3}/backend/routes/__init__.py +0 -0
  28. {stackport-0.1.2 → stackport-0.1.3}/backend/routes/resources.py +0 -0
  29. {stackport-0.1.2 → stackport-0.1.3}/backend/routes/s3.py +0 -0
  30. {stackport-0.1.2 → stackport-0.1.3}/backend/routes/stats.py +0 -0
  31. {stackport-0.1.2 → stackport-0.1.3}/setup.cfg +0 -0
  32. {stackport-0.1.2 → stackport-0.1.3}/stackport.egg-info/dependency_links.txt +0 -0
  33. {stackport-0.1.2 → stackport-0.1.3}/stackport.egg-info/entry_points.txt +0 -0
  34. {stackport-0.1.2 → stackport-0.1.3}/stackport.egg-info/top_level.txt +0 -0
  35. {stackport-0.1.2 → stackport-0.1.3}/ui/dist/favicon.png +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -24,19 +24,33 @@ License-File: LICENSE
24
24
  Requires-Dist: fastapi>=0.115.0
25
25
  Requires-Dist: uvicorn>=0.30.0
26
26
  Requires-Dist: boto3>=1.35.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: httpx>=0.27.0; extra == "dev"
30
+ Requires-Dist: moto[dynamodb,iam,lambda,s3,sqs]>=5.0; extra == "dev"
27
31
  Dynamic: license-file
28
32
 
29
33
  # StackPort
30
34
 
31
- [![CI](https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg)](https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml)
32
- [![PyPI](https://img.shields.io/pypi/v/stackport)](https://pypi.org/project/stackport/)
33
- [![Docker](https://img.shields.io/docker/pulls/davireis/stackport)](https://hub.docker.com/r/davireis/stackport)
34
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+ <p>
36
+ <a href="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml"><img src="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
37
+ <a href="https://pypi.org/project/stackport/"><img src="https://img.shields.io/pypi/v/stackport" alt="PyPI Version"></a>
38
+ <a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/pulls/davireis/stackport" alt="Docker Pulls"></a>
39
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
40
+ </p>
35
41
 
36
42
  Universal AWS resource browser for local emulators. Built to work with [**MiniStack**](https://github.com/Nahuel990/ministack), and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.
37
43
 
38
- <!-- TODO: Add screenshot here -->
39
- <!-- ![StackPort Dashboard](docs/screenshot.png) -->
44
+ ## Screenshots
45
+
46
+ **Dashboard** — Service overview with resource counts and health status
47
+ ![StackPort Dashboard](docs/images/dashboard.jpeg)
48
+
49
+ **DynamoDB Browser** — Generic resource table with search, pagination, and detail view
50
+ ![DynamoDB Resources](docs/images/dynamo.jpeg)
51
+
52
+ **S3 Browser** — File browser with folder navigation and object preview
53
+ ![S3 Browser](docs/images/s3.jpeg)
40
54
 
41
55
  ## Features
42
56
 
@@ -62,8 +76,10 @@ stackport
62
76
 
63
77
  ### Docker Compose (MiniStack + StackPort)
64
78
 
79
+ This example uses [MiniStack](https://github.com/Nahuel990/ministack) as the emulator, but you can swap it for LocalStack, Moto, or any AWS-compatible endpoint — just update `AWS_ENDPOINT_URL`.
80
+
65
81
  ```bash
66
- curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docker-compose.yml
82
+ curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.yml
67
83
  docker compose up -d
68
84
  # Open http://localhost:8080
69
85
  ```
@@ -1,14 +1,24 @@
1
1
  # StackPort
2
2
 
3
- [![CI](https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg)](https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml)
4
- [![PyPI](https://img.shields.io/pypi/v/stackport)](https://pypi.org/project/stackport/)
5
- [![Docker](https://img.shields.io/docker/pulls/davireis/stackport)](https://hub.docker.com/r/davireis/stackport)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
3
+ <p>
4
+ <a href="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml"><img src="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
5
+ <a href="https://pypi.org/project/stackport/"><img src="https://img.shields.io/pypi/v/stackport" alt="PyPI Version"></a>
6
+ <a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/pulls/davireis/stackport" alt="Docker Pulls"></a>
7
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
8
+ </p>
7
9
 
8
10
  Universal AWS resource browser for local emulators. Built to work with [**MiniStack**](https://github.com/Nahuel990/ministack), and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.
9
11
 
10
- <!-- TODO: Add screenshot here -->
11
- <!-- ![StackPort Dashboard](docs/screenshot.png) -->
12
+ ## Screenshots
13
+
14
+ **Dashboard** — Service overview with resource counts and health status
15
+ ![StackPort Dashboard](docs/images/dashboard.jpeg)
16
+
17
+ **DynamoDB Browser** — Generic resource table with search, pagination, and detail view
18
+ ![DynamoDB Resources](docs/images/dynamo.jpeg)
19
+
20
+ **S3 Browser** — File browser with folder navigation and object preview
21
+ ![S3 Browser](docs/images/s3.jpeg)
12
22
 
13
23
  ## Features
14
24
 
@@ -34,8 +44,10 @@ stackport
34
44
 
35
45
  ### Docker Compose (MiniStack + StackPort)
36
46
 
47
+ This example uses [MiniStack](https://github.com/Nahuel990/ministack) as the emulator, but you can swap it for LocalStack, Moto, or any AWS-compatible endpoint — just update `AWS_ENDPOINT_URL`.
48
+
37
49
  ```bash
38
- curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docker-compose.yml
50
+ curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.yml
39
51
  docker compose up -d
40
52
  # Open http://localhost:8080
41
53
  ```
@@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
9
9
  from fastapi.staticfiles import StaticFiles
10
10
 
11
11
  from backend.config import STACKPORT_PORT
12
- from backend.routes import resources, s3, stats
12
+ from backend.routes import dynamodb, resources, s3, stats
13
13
 
14
14
  logging.basicConfig(
15
15
  level=logging.INFO,
@@ -28,6 +28,7 @@ app.add_middleware(
28
28
 
29
29
  app.include_router(stats.router, prefix="/api")
30
30
  app.include_router(s3.router, prefix="/api/s3")
31
+ app.include_router(dynamodb.router, prefix="/api/dynamodb")
31
32
  app.include_router(resources.router, prefix="/api")
32
33
 
33
34
  # Serve UI static files — mount assets under /assets, SPA fallback for everything else
@@ -0,0 +1,218 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from fastapi import APIRouter, Query
5
+ from pydantic import BaseModel
6
+
7
+ from backend.aws_client import get_client
8
+ from backend.cache import cache
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ def _get_table_item_count(table_name: str) -> int:
16
+ """Return item count for a table. Cached 30s."""
17
+ cache_key = f"dynamodb:item_count:{table_name}"
18
+ cached = cache.get(cache_key)
19
+ if cached is not None:
20
+ return cached
21
+
22
+ dynamodb = get_client("dynamodb")
23
+ try:
24
+ resp = dynamodb.describe_table(TableName=table_name)
25
+ item_count = resp["Table"].get("ItemCount", 0)
26
+ cache.set(cache_key, item_count, ttl=30)
27
+ return item_count
28
+ except Exception:
29
+ logger.debug("Failed to get item count for %s", table_name, exc_info=True)
30
+ return 0
31
+
32
+
33
+ @router.get("/tables")
34
+ def list_tables():
35
+ dynamodb = get_client("dynamodb")
36
+ paginator = dynamodb.get_paginator("list_tables")
37
+ table_names = []
38
+
39
+ for page in paginator.paginate():
40
+ table_names.extend(page.get("TableNames", []))
41
+
42
+ tables = []
43
+ for name in table_names:
44
+ try:
45
+ resp = dynamodb.describe_table(TableName=name)
46
+ table = resp["Table"]
47
+ item_count = table.get("ItemCount", 0)
48
+ table_size = table.get("TableSizeBytes", 0)
49
+
50
+ key_schema = table.get("KeySchema", [])
51
+ partition_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "HASH"), None)
52
+ sort_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "RANGE"), None)
53
+
54
+ tables.append(
55
+ {
56
+ "name": name,
57
+ "status": table.get("TableStatus", "UNKNOWN"),
58
+ "item_count": item_count,
59
+ "size_bytes": table_size,
60
+ "partition_key": partition_key,
61
+ "sort_key": sort_key,
62
+ "billing_mode": table.get("BillingModeSummary", {}).get("BillingMode", "PROVISIONED"),
63
+ "created": table.get("CreationDateTime").isoformat() if table.get("CreationDateTime") else None,
64
+ }
65
+ )
66
+ except Exception:
67
+ logger.debug("Failed to describe table %s", name, exc_info=True)
68
+ continue
69
+
70
+ return {"tables": tables}
71
+
72
+
73
+ @router.get("/tables/{name}")
74
+ def get_table_detail(name: str):
75
+ dynamodb = get_client("dynamodb")
76
+
77
+ try:
78
+ resp = dynamodb.describe_table(TableName=name)
79
+ table = resp["Table"]
80
+
81
+ key_schema = table.get("KeySchema", [])
82
+ attribute_defs = {attr["AttributeName"]: attr["AttributeType"] for attr in table.get("AttributeDefinitions", [])}
83
+
84
+ partition_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "HASH"), None)
85
+ sort_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "RANGE"), None)
86
+
87
+ return {
88
+ "name": name,
89
+ "status": table.get("TableStatus", "UNKNOWN"),
90
+ "item_count": table.get("ItemCount", 0),
91
+ "size_bytes": table.get("TableSizeBytes", 0),
92
+ "partition_key": partition_key,
93
+ "partition_key_type": attribute_defs.get(partition_key) if partition_key else None,
94
+ "sort_key": sort_key,
95
+ "sort_key_type": attribute_defs.get(sort_key) if sort_key else None,
96
+ "billing_mode": table.get("BillingModeSummary", {}).get("BillingMode", "PROVISIONED"),
97
+ "created": table.get("CreationDateTime").isoformat() if table.get("CreationDateTime") else None,
98
+ "attribute_definitions": attribute_defs,
99
+ "key_schema": key_schema,
100
+ "global_secondary_indexes": table.get("GlobalSecondaryIndexes", []),
101
+ "local_secondary_indexes": table.get("LocalSecondaryIndexes", []),
102
+ }
103
+ except Exception as e:
104
+ logger.error("Failed to get table detail for %s: %s", name, e, exc_info=True)
105
+ return {"error": str(e)}
106
+
107
+
108
+ @router.get("/tables/{name}/items")
109
+ def scan_table(
110
+ name: str,
111
+ limit: int = Query(default=25, ge=1, le=100, description="Max items per page"),
112
+ exclusive_start_key: str = Query(default=None, description="Base64 encoded last evaluated key for pagination"),
113
+ ):
114
+ dynamodb = get_client("dynamodb")
115
+
116
+ scan_params: dict[str, Any] = {
117
+ "TableName": name,
118
+ "Limit": limit,
119
+ }
120
+
121
+ if exclusive_start_key:
122
+ import base64
123
+ import json
124
+
125
+ try:
126
+ decoded = base64.b64decode(exclusive_start_key).decode("utf-8")
127
+ scan_params["ExclusiveStartKey"] = json.loads(decoded)
128
+ except Exception:
129
+ logger.debug("Invalid exclusive_start_key", exc_info=True)
130
+
131
+ try:
132
+ resp = dynamodb.scan(**scan_params)
133
+ items = resp.get("Items", [])
134
+
135
+ last_evaluated_key = resp.get("LastEvaluatedKey")
136
+ next_token = None
137
+ if last_evaluated_key:
138
+ import base64
139
+ import json
140
+
141
+ next_token = base64.b64encode(json.dumps(last_evaluated_key).encode("utf-8")).decode("utf-8")
142
+
143
+ return {
144
+ "table": name,
145
+ "items": items,
146
+ "count": len(items),
147
+ "scanned_count": resp.get("ScannedCount", len(items)),
148
+ "next_token": next_token,
149
+ }
150
+ except Exception as e:
151
+ logger.error("Failed to scan table %s: %s", name, e, exc_info=True)
152
+ return {"error": str(e), "items": [], "count": 0}
153
+
154
+
155
+ class QueryRequest(BaseModel):
156
+ partition_key_value: str
157
+ sort_key_value: str | None = None
158
+ sort_key_operator: str = "=" # =, <, <=, >, >=, BETWEEN, BEGINS_WITH
159
+ limit: int = 25
160
+
161
+
162
+ @router.post("/tables/{name}/query")
163
+ def query_table(name: str, request: QueryRequest):
164
+ dynamodb = get_client("dynamodb")
165
+
166
+ try:
167
+ # Get table key schema
168
+ table_resp = dynamodb.describe_table(TableName=name)
169
+ table = table_resp["Table"]
170
+ key_schema = table.get("KeySchema", [])
171
+ attribute_defs = {attr["AttributeName"]: attr["AttributeType"] for attr in table.get("AttributeDefinitions", [])}
172
+
173
+ partition_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "HASH"), None)
174
+ sort_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "RANGE"), None)
175
+
176
+ if not partition_key:
177
+ return {"error": "No partition key found in table schema", "items": [], "count": 0}
178
+
179
+ partition_key_type = attribute_defs.get(partition_key, "S")
180
+
181
+ # Build key condition expression
182
+ key_condition = f"#{partition_key} = :pk"
183
+ expression_attr_names = {f"#{partition_key}": partition_key}
184
+ expression_attr_values = {":pk": {partition_key_type: request.partition_key_value}}
185
+
186
+ if sort_key and request.sort_key_value:
187
+ sort_key_type = attribute_defs.get(sort_key, "S")
188
+ if request.sort_key_operator == "=":
189
+ key_condition += f" AND #{sort_key} = :sk"
190
+ expression_attr_values[":sk"] = {sort_key_type: request.sort_key_value}
191
+ elif request.sort_key_operator in ("<", "<=", ">", ">="):
192
+ key_condition += f" AND #{sort_key} {request.sort_key_operator} :sk"
193
+ expression_attr_values[":sk"] = {sort_key_type: request.sort_key_value}
194
+ elif request.sort_key_operator == "BEGINS_WITH":
195
+ key_condition += f" AND begins_with(#{sort_key}, :sk)"
196
+ expression_attr_values[":sk"] = {sort_key_type: request.sort_key_value}
197
+ expression_attr_names[f"#{sort_key}"] = sort_key
198
+
199
+ query_params = {
200
+ "TableName": name,
201
+ "KeyConditionExpression": key_condition,
202
+ "ExpressionAttributeNames": expression_attr_names,
203
+ "ExpressionAttributeValues": expression_attr_values,
204
+ "Limit": request.limit,
205
+ }
206
+
207
+ resp = dynamodb.query(**query_params)
208
+ items = resp.get("Items", [])
209
+
210
+ return {
211
+ "table": name,
212
+ "items": items,
213
+ "count": len(items),
214
+ "scanned_count": resp.get("ScannedCount", len(items)),
215
+ }
216
+ except Exception as e:
217
+ logger.error("Failed to query table %s: %s", name, e, exc_info=True)
218
+ return {"error": str(e), "items": [], "count": 0}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackport"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Universal AWS resource browser for local emulators"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -26,6 +26,13 @@ Homepage = "https://github.com/DaviReisVieira/stackport"
26
26
  Repository = "https://github.com/DaviReisVieira/stackport"
27
27
  Issues = "https://github.com/DaviReisVieira/stackport/issues"
28
28
 
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=8.0", "httpx>=0.27.0", "moto[s3,sqs,lambda,dynamodb,iam]>=5.0"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ pythonpath = ["."]
35
+
29
36
  [build-system]
30
37
  requires = ["setuptools>=68.0"]
31
38
  build-backend = "setuptools.build_meta"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -24,19 +24,33 @@ License-File: LICENSE
24
24
  Requires-Dist: fastapi>=0.115.0
25
25
  Requires-Dist: uvicorn>=0.30.0
26
26
  Requires-Dist: boto3>=1.35.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: httpx>=0.27.0; extra == "dev"
30
+ Requires-Dist: moto[dynamodb,iam,lambda,s3,sqs]>=5.0; extra == "dev"
27
31
  Dynamic: license-file
28
32
 
29
33
  # StackPort
30
34
 
31
- [![CI](https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg)](https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml)
32
- [![PyPI](https://img.shields.io/pypi/v/stackport)](https://pypi.org/project/stackport/)
33
- [![Docker](https://img.shields.io/docker/pulls/davireis/stackport)](https://hub.docker.com/r/davireis/stackport)
34
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+ <p>
36
+ <a href="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml"><img src="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
37
+ <a href="https://pypi.org/project/stackport/"><img src="https://img.shields.io/pypi/v/stackport" alt="PyPI Version"></a>
38
+ <a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/pulls/davireis/stackport" alt="Docker Pulls"></a>
39
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
40
+ </p>
35
41
 
36
42
  Universal AWS resource browser for local emulators. Built to work with [**MiniStack**](https://github.com/Nahuel990/ministack), and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.
37
43
 
38
- <!-- TODO: Add screenshot here -->
39
- <!-- ![StackPort Dashboard](docs/screenshot.png) -->
44
+ ## Screenshots
45
+
46
+ **Dashboard** — Service overview with resource counts and health status
47
+ ![StackPort Dashboard](docs/images/dashboard.jpeg)
48
+
49
+ **DynamoDB Browser** — Generic resource table with search, pagination, and detail view
50
+ ![DynamoDB Resources](docs/images/dynamo.jpeg)
51
+
52
+ **S3 Browser** — File browser with folder navigation and object preview
53
+ ![S3 Browser](docs/images/s3.jpeg)
40
54
 
41
55
  ## Features
42
56
 
@@ -62,8 +76,10 @@ stackport
62
76
 
63
77
  ### Docker Compose (MiniStack + StackPort)
64
78
 
79
+ This example uses [MiniStack](https://github.com/Nahuel990/ministack) as the emulator, but you can swap it for LocalStack, Moto, or any AWS-compatible endpoint — just update `AWS_ENDPOINT_URL`.
80
+
65
81
  ```bash
66
- curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docker-compose.yml
82
+ curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.yml
67
83
  docker compose up -d
68
84
  # Open http://localhost:8080
69
85
  ```
@@ -9,9 +9,10 @@ backend/config.py
9
9
  backend/main.py
10
10
  backend/../ui/dist/favicon.png
11
11
  backend/../ui/dist/index.html
12
- backend/../ui/dist/assets/index-CRqBg0t6.js
13
- backend/../ui/dist/assets/index-Is3dIbZM.css
12
+ backend/../ui/dist/assets/index-CEFYqXru.css
13
+ backend/../ui/dist/assets/index-DyyngEdQ.js
14
14
  backend/routes/__init__.py
15
+ backend/routes/dynamodb.py
15
16
  backend/routes/resources.py
16
17
  backend/routes/s3.py
17
18
  backend/routes/stats.py
@@ -21,7 +22,13 @@ stackport.egg-info/dependency_links.txt
21
22
  stackport.egg-info/entry_points.txt
22
23
  stackport.egg-info/requires.txt
23
24
  stackport.egg-info/top_level.txt
25
+ tests/test_cache.py
26
+ tests/test_client.py
27
+ tests/test_config.py
28
+ tests/test_dynamodb_routes.py
29
+ tests/test_registries.py
30
+ tests/test_routes.py
24
31
  ui/dist/favicon.png
25
32
  ui/dist/index.html
26
- ui/dist/assets/index-CRqBg0t6.js
27
- ui/dist/assets/index-Is3dIbZM.css
33
+ ui/dist/assets/index-CEFYqXru.css
34
+ ui/dist/assets/index-DyyngEdQ.js
@@ -0,0 +1,8 @@
1
+ fastapi>=0.115.0
2
+ uvicorn>=0.30.0
3
+ boto3>=1.35.0
4
+
5
+ [dev]
6
+ pytest>=8.0
7
+ httpx>=0.27.0
8
+ moto[dynamodb,iam,lambda,s3,sqs]>=5.0
@@ -0,0 +1,63 @@
1
+ import threading
2
+ import time
3
+
4
+ from backend.cache import TTLCache
5
+
6
+
7
+ class TestTTLCache:
8
+ def test_set_and_get(self):
9
+ c = TTLCache()
10
+ c.set("k", "v", ttl=10)
11
+ assert c.get("k") == "v"
12
+
13
+ def test_get_missing_key(self):
14
+ c = TTLCache()
15
+ assert c.get("missing") is None
16
+
17
+ def test_expiry(self):
18
+ c = TTLCache()
19
+ c.set("k", "v", ttl=0.1)
20
+ time.sleep(0.15)
21
+ assert c.get("k") is None
22
+
23
+ def test_overwrite(self):
24
+ c = TTLCache()
25
+ c.set("k", "v1", ttl=10)
26
+ c.set("k", "v2", ttl=10)
27
+ assert c.get("k") == "v2"
28
+
29
+ def test_thread_safety(self):
30
+ c = TTLCache()
31
+ errors = []
32
+
33
+ def writer(n: int):
34
+ try:
35
+ for i in range(100):
36
+ c.set(f"key-{n}-{i}", i, ttl=5)
37
+ except Exception as e:
38
+ errors.append(e)
39
+
40
+ def reader():
41
+ try:
42
+ for _ in range(100):
43
+ c.get("key-0-0")
44
+ except Exception as e:
45
+ errors.append(e)
46
+
47
+ threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)]
48
+ threads += [threading.Thread(target=reader) for _ in range(5)]
49
+ for t in threads:
50
+ t.start()
51
+ for t in threads:
52
+ t.join()
53
+
54
+ assert errors == []
55
+
56
+ def test_different_types(self):
57
+ c = TTLCache()
58
+ c.set("dict", {"a": 1}, ttl=10)
59
+ c.set("list", [1, 2, 3], ttl=10)
60
+ c.set("int", 42, ttl=10)
61
+ assert c.get("dict") == {"a": 1}
62
+ assert c.get("list") == [1, 2, 3]
63
+ assert c.get("int") == 42
@@ -0,0 +1,17 @@
1
+ from backend.aws_client import get_client
2
+
3
+
4
+ class TestGetClient:
5
+ def test_returns_boto3_client(self):
6
+ client = get_client("s3")
7
+ assert hasattr(client, "list_buckets")
8
+
9
+ def test_lru_cache_returns_same_instance(self):
10
+ c1 = get_client("s3")
11
+ c2 = get_client("s3")
12
+ assert c1 is c2
13
+
14
+ def test_different_services_return_different_clients(self):
15
+ s3 = get_client("s3")
16
+ sqs = get_client("sqs")
17
+ assert s3 is not sqs
@@ -0,0 +1,41 @@
1
+ import os
2
+
3
+
4
+ class TestConfig:
5
+ def test_defaults(self):
6
+ """Config module provides sensible defaults."""
7
+ from backend.config import (
8
+ AWS_ACCESS_KEY_ID,
9
+ AWS_ENDPOINT_URL,
10
+ AWS_REGION,
11
+ AWS_SECRET_ACCESS_KEY,
12
+ STACKPORT_PORT,
13
+ STACKPORT_SERVICES,
14
+ )
15
+
16
+ assert AWS_ENDPOINT_URL # non-empty
17
+ assert AWS_REGION # non-empty
18
+ assert AWS_ACCESS_KEY_ID # non-empty
19
+ assert AWS_SECRET_ACCESS_KEY # non-empty
20
+ assert isinstance(STACKPORT_PORT, int)
21
+ assert STACKPORT_PORT > 0
22
+ # Services string should contain known services
23
+ services = [s.strip() for s in STACKPORT_SERVICES.split(",")]
24
+ assert "s3" in services
25
+ assert "dynamodb" in services
26
+ assert "lambda" in services
27
+ assert len(services) >= 30 # at least 30 services configured
28
+
29
+ def test_env_override(self, monkeypatch):
30
+ """Config respects environment variable overrides."""
31
+ monkeypatch.setenv("STACKPORT_PORT", "9999")
32
+ # Re-import to pick up env change
33
+ import importlib
34
+
35
+ import backend.config
36
+
37
+ importlib.reload(backend.config)
38
+ assert backend.config.STACKPORT_PORT == 9999
39
+ # Restore
40
+ monkeypatch.delenv("STACKPORT_PORT")
41
+ importlib.reload(backend.config)