Skip to content

Commit 93c8bcf

Browse files
authored
Add python.yml and fix related code issues. (#1)
Co-authored-by: Leonardo Araneda Freccero <araneda@amazon.com>
1 parent 6df006c commit 93c8bcf

6 files changed

Lines changed: 288 additions & 84 deletions

File tree

.github/workflows/python.yml

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
name: Python
2+
3+
on:
4+
push:
5+
pull_request:
6+
workflow_dispatch:
7+
8+
permissions: {}
9+
10+
jobs:
11+
build:
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
job_id: ["Build"]
16+
defaults:
17+
run:
18+
working-directory: .
19+
name: Build AWS MCP Proxy
20+
runs-on: ubuntu-latest
21+
permissions:
22+
contents: read
23+
pull-requests: read
24+
security-events: write
25+
actions: read
26+
steps:
27+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
28+
29+
- name: Install uv
30+
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
31+
32+
- name: Set up Python
33+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
34+
with:
35+
python-version-file: ".python-version"
36+
# cache: uv (not supported)
37+
38+
- name: Install dependencies
39+
run: uv sync --frozen --all-extras --dev
40+
41+
- name: Run tests
42+
run: |
43+
if [ -d "tests" ]; then
44+
uv run --frozen pytest --cov --cov-branch --cov-report=term-missing --cov-report=xml:${{ matrix.package }}-coverage.xml
45+
else
46+
echo "No tests directory found, skipping tests"
47+
fi
48+
49+
# - name: Upload coverage reports to Codecov
50+
# uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 #v5.4.3
51+
# with:
52+
# token: ${{ secrets.CODECOV_TOKEN }}
53+
# files: ${{ matrix.package }}-coverage.xml
54+
55+
- name: Run pyright
56+
run: uv run --frozen pyright
57+
58+
- name: Run ruff format
59+
run: uv run --frozen ruff format .
60+
61+
- name: Run ruff check
62+
run: uv run --frozen ruff check .
63+
64+
- name: Build package
65+
run: uv build
66+
67+
- name: Upload distribution
68+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
69+
with:
70+
path: dist/
71+
72+
- name: Generate Software Bill of Materials (SBOM)
73+
run: |
74+
source .venv/bin/activate
75+
echo "Attempt to convert to proper UTF-8 files https://github.com/CycloneDX/cyclonedx-python/issues/868"
76+
find .venv -type f -path '*/*.dist-info/*' > .venv/FILES
77+
# because grep with xargs returns 123 have to do this the long and hard way...
78+
while IFS= read -r line; do
79+
(grep -s -q -axv '.*' $line &&
80+
if [[ "$(file -b --mime-encoding $line)" != "binary" ]]; then
81+
echo "illegal utf-8 characters in $line...converting...";
82+
iconv -f $(file -b --mime-encoding $line) -t utf-8 $line > $line.utf8;
83+
mv $line.utf8 $line;
84+
fi;
85+
) || echo "good $line"
86+
done < .venv/FILES;
87+
uv tool run --from cyclonedx-bom==6.1.3 cyclonedx-py environment $VIRTUAL_ENV --PEP-639 --gather-license-texts --pyproject pyproject.toml --mc-type library --output-format JSON > sbom.json
88+
- name: Display SBOM
89+
run: |
90+
cat <<EOT |
91+
import re
92+
import json
93+
import importlib.metadata as metadata
94+
95+
def parse_bom(json_file):
96+
# Parse the JSON file
97+
with open(json_file, 'r') as file:
98+
data = json.load(file)
99+
100+
# Extract components
101+
components = []
102+
for component in data['components']:
103+
comp_info = {}
104+
105+
# Get name, version, description, and purl
106+
comp_info['name'] = component.get('name', 'Unknown')
107+
comp_info['version'] = component.get('version', 'Unknown')
108+
comp_info['description'] = component.get('description', 'Unknown')
109+
comp_info['purl'] = component.get('purl', 'Unknown')
110+
111+
# Get licenses
112+
comp_info['licenses'] = []
113+
licenses = component.get('licenses', [])
114+
for license in licenses:
115+
if license.get('license', {}).get('id'):
116+
comp_info['licenses'].append(license.get('license').get('id'))
117+
if len(comp_info['licenses']) == 0:
118+
comp_info['licenses'].append("No licenses")
119+
120+
# Extract additional information (copyright, etc.)
121+
copyright_info = extract_copyright_from_metadata(comp_info['name'])
122+
comp_info['copyright'] = copyright_info if copyright_info else "No copyright information"
123+
124+
components.append(comp_info)
125+
126+
return components
127+
128+
def extract_copyright_from_metadata(package_name):
129+
try:
130+
# Use importlib.metadata to retrieve metadata from the installed package
131+
dist = metadata.distribution(package_name)
132+
metadata_info = dist.metadata
133+
134+
# Extract relevant metadata
135+
copyright_info = []
136+
author = metadata_info.get('Author')
137+
author_email = metadata_info.get('Author-email')
138+
license_info = metadata_info.get('License')
139+
140+
if author:
141+
copyright_info.append(f"Author: {author}")
142+
if author_email:
143+
copyright_info.append(f"Author Email: {author_email}")
144+
if license_info:
145+
copyright_info.append(f"License: {license_info}")
146+
147+
# Check for classifiers or any extra metadata fields
148+
if 'Classifier' in metadata_info:
149+
for classifier in metadata_info.get_all('Classifier'):
150+
if 'copyright' in classifier.lower():
151+
copyright_info.append(classifier)
152+
153+
return ', '.join(copyright_info) if copyright_info else None
154+
155+
except metadata.PackageNotFoundError:
156+
return None
157+
158+
159+
def main():
160+
bom_file = 'sbom.json' # Replace with your BOM file path
161+
components = parse_bom(bom_file)
162+
163+
for component in components:
164+
print(f"Name: {component['name']}")
165+
print(f"Version: {component['version']}")
166+
print(f"Description: {component['description']}")
167+
print(f"PURL: {component['purl']}")
168+
print(f"Licenses: {', '.join(component['licenses'])}")
169+
print(f"Copyright: {component['copyright']}")
170+
print("-" * 40)
171+
172+
if __name__ == "__main__":
173+
main()
174+
EOT
175+
python -
176+
177+
- name: Upload Software Bill of Materials
178+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
179+
with:
180+
name: sbom-aws-mcp-proxy
181+
path: sbom.json

src/aws_mcp_proxy/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@
2424
import argparse
2525
import asyncio
2626
import os
27+
from fastmcp.server.server import FastMCP
28+
from loguru import logger
2729
from src.aws_mcp_proxy.mcp_proxy_manager import McpProxyManager
2830
from src.aws_mcp_proxy.utils import (
2931
create_transport_with_sigv4,
3032
determine_service_name,
3133
normalize_endpoint_url,
3234
)
33-
from fastmcp.server.server import FastMCP
34-
from loguru import logger
3535
from typing import Any
3636

3737

src/aws_mcp_proxy/utils.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
# limitations under the License.
1414

1515
"""Utility functions for the AWS MCP Proxy."""
16-
import logging
1716
import re
18-
from src.aws_mcp_proxy.sigv4_helper import create_sigv4_client
1917
from fastmcp.client.transports import StreamableHttpTransport
20-
from typing import Optional
18+
from httpx import AsyncClient, Auth, Timeout
19+
from src.aws_mcp_proxy.sigv4_helper import create_sigv4_client
20+
from typing import Dict, Optional
2121
from urllib.parse import urlparse
2222

2323

@@ -34,11 +34,22 @@ def create_transport_with_sigv4(
3434
Returns:
3535
StreamableHttpTransport instance with SigV4 authentication
3636
"""
37+
def client_factory(
38+
headers: Optional[Dict[str, str]] = None,
39+
timeout: Optional[Timeout] = None,
40+
auth: Optional[Auth] = None
41+
) -> AsyncClient:
42+
return create_sigv4_client(
43+
service=service,
44+
profile=profile,
45+
headers=headers,
46+
timeout=timeout,
47+
auth=auth
48+
)
49+
3750
return StreamableHttpTransport(
3851
url=url,
39-
httpx_client_factory=lambda **kwargs: create_sigv4_client(
40-
service=service, profile=profile, **kwargs
41-
),
52+
httpx_client_factory=client_factory,
4253
)
4354

4455

tests/test_mcp_proxy_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
"""Tests for mcp_proxy_manager module."""
1616

1717
import pytest
18-
from src.aws_mcp_proxy.mcp_proxy_manager import McpProxyManager
1918
from fastmcp.server.server import FastMCP
19+
from src.aws_mcp_proxy.mcp_proxy_manager import McpProxyManager
2020
from unittest.mock import AsyncMock, MagicMock
2121

2222

tests/test_utils.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
"""Tests for utils module."""
1616

1717
import pytest
18+
from fastmcp.client.transports import StreamableHttpTransport
19+
from httpx import Timeout
1820
from src.aws_mcp_proxy.utils import (
1921
create_transport_with_sigv4,
2022
determine_service_name,
2123
normalize_endpoint_url,
2224
)
23-
from fastmcp.client.transports import StreamableHttpTransport
2425
from unittest.mock import MagicMock, patch
2526

2627

@@ -47,11 +48,16 @@ def test_create_transport_with_sigv4(self, mock_create_sigv4_client):
4748
# We need to access the factory through the transport's internal structure
4849
if hasattr(result, 'httpx_client_factory') and result.httpx_client_factory:
4950
factory = result.httpx_client_factory
50-
test_kwargs = {'timeout': 30}
51-
factory(**test_kwargs)
51+
test_timeout = Timeout(30.0)
52+
test_headers = {'Content-Type': 'application/json'}
53+
factory(headers=test_headers, timeout=test_timeout)
5254

5355
mock_create_sigv4_client.assert_called_once_with(
54-
service=service, profile=profile, timeout=30
56+
service=service,
57+
profile=profile,
58+
headers=test_headers,
59+
timeout=test_timeout,
60+
auth=None
5561
)
5662
else:
5763
# If we can't access the factory directly, just verify the transport was created
@@ -71,7 +77,13 @@ def test_create_transport_with_sigv4_no_profile(self, mock_create_sigv4_client):
7177
factory = result.httpx_client_factory
7278
factory()
7379

74-
mock_create_sigv4_client.assert_called_once_with(service=service, profile=None)
80+
mock_create_sigv4_client.assert_called_once_with(
81+
service=service,
82+
profile=None,
83+
headers=None,
84+
timeout=None,
85+
auth=None
86+
)
7587
else:
7688
# If we can't access the factory directly, just verify the transport was created
7789
assert result is not None

0 commit comments

Comments
 (0)