Skip to content

Commit c263a0d

Browse files
committed
upload_assets refactor to something simpler and add initial tests (#5)
1 parent 2440c1c commit c263a0d

File tree

3 files changed

+149
-39
lines changed

3 files changed

+149
-39
lines changed

qa/tools/apex_algorithm_qa_tools/pytest_upload_assets.py

+38-39
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
1616
```python
1717
def test_dummy(upload_assets, tmp_path):
18-
path = tmp_path / "dummy.txt"
19-
path.write_text("dummy content")
18+
path = tmp_path / "hello.txt"
19+
path.write_text("Hello world.")
2020
upload_assets(path)
2121
```
2222
@@ -33,7 +33,7 @@ def test_dummy(upload_assets, tmp_path):
3333
import uuid
3434
import warnings
3535
from pathlib import Path
36-
from typing import Callable, Dict, Union
36+
from typing import Callable, Dict
3737

3838
import boto3
3939
import pytest
@@ -80,6 +80,7 @@ def pytest_configure(config: pytest.Config):
8080

8181

8282
def pytest_report_header(config):
83+
# TODO Move inside S3UploadPlugin
8384
plugin: S3UploadPlugin | None = config.pluginmanager.get_plugin(
8485
_UPLOAD_ASSETS_PLUGIN_NAME
8586
)
@@ -92,56 +93,54 @@ def pytest_unconfigure(config):
9293
config.pluginmanager.unregister(name=_UPLOAD_ASSETS_PLUGIN_NAME)
9394

9495

95-
class _Collector:
96-
"""
97-
Collects test outcomes and files to upload for a single test node.
98-
"""
99-
100-
def __init__(self, nodeid: str) -> None:
101-
self.nodeid = nodeid
102-
self.outcomes: Dict[str, str] = {}
103-
self.assets: Dict[str, Path] = {}
104-
105-
def set_outcome(self, when: str, outcome: str):
106-
self.outcomes[when] = outcome
107-
108-
def collect(self, path: Path, name: str):
109-
self.assets[name] = path
110-
111-
11296
class S3UploadPlugin:
11397
def __init__(self, *, run_id: str | None = None, s3_client, bucket: str) -> None:
11498
self.run_id = run_id or uuid.uuid4().hex
115-
self.collector: Union[_Collector, None] = None
99+
self.collected_assets: Dict[str, Path] | None = None
116100
self.s3_client = s3_client
117101
self.bucket = bucket
102+
self.upload_stats = {"uploaded": 0}
118103

119-
def pytest_runtest_logstart(self, nodeid, location):
120-
self.collector = _Collector(nodeid=nodeid)
121-
122-
def pytest_runtest_logreport(self, report: pytest.TestReport):
123-
self.collector.set_outcome(when=report.when, outcome=report.outcome)
124-
125-
def pytest_runtest_logfinish(self, nodeid, location):
126-
# TODO: option to also upload on success?
127-
if self.collector.outcomes.get("call") == "failed":
128-
self._upload(self.collector)
104+
def collect(self, path: Path, name: str):
105+
"""Collect assets to upload"""
106+
assert self.collected_assets is not None, "No active collection of assets"
107+
self.collected_assets[name] = path
129108

130-
self.collector = None
109+
def pytest_runtest_logstart(self, nodeid):
110+
# Start new collection of assets for current test node
111+
self.collected_assets = {}
131112

132-
def _upload(self, collector: _Collector):
133-
for name, path in collector.assets.items():
134-
nodeid = re.sub(r"[^a-zA-Z0-9_.-]", "_", collector.nodeid)
135-
key = f"{self.run_id}!{nodeid}!{name}"
136-
# TODO: get upload info in report?
137-
_log.info(f"Uploading {path} to {self.bucket}/{key}")
113+
def pytest_runtest_logreport(self, report: pytest.TestReport):
114+
# TODO: option to upload on other outcome as well?
115+
if report.when == "call" and report.outcome == "failed":
116+
# TODO: what to do when upload fails?
117+
uploaded = self._upload(nodeid=report.nodeid)
118+
# TODO: report the uploaded assets somewhere (e.g. in user_properties or JSON report?)
119+
120+
def pytest_runtest_logfinish(self, nodeid):
121+
# Reset collection of assets
122+
self.collected_assets = None
123+
124+
def _upload(self, nodeid: str) -> Dict[str, str]:
125+
assets = {}
126+
for name, path in self.collected_assets.items():
127+
safe_nodeid = re.sub(r"[^a-zA-Z0-9_.-]", "_", nodeid)
128+
key = f"{self.run_id}!{safe_nodeid}!{name}"
129+
# TODO: is this manual URL building correct and isn't there a boto utility for that?
130+
url = f"{self.s3_client.meta.endpoint_url.rstrip('/')}/{self.bucket}/{key}"
131+
_log.info(f"Uploading {path} to {url}")
138132
self.s3_client.upload_file(
139133
Filename=str(path),
140134
Bucket=self.bucket,
141135
Key=key,
142136
# TODO: option to override ACL, or ExtraArgs in general?
143137
ExtraArgs={"ACL": "public-read"},
144138
)
139+
assets[name] = url
140+
self.upload_stats["uploaded"] += 1
141+
142+
def pytest_terminal_summary(self, terminalreporter):
143+
terminalreporter.write_sep("-", f"`upload_assets` stats: {self.upload_stats}")
145144

146145

147146
@pytest.fixture
@@ -163,7 +162,7 @@ def collect(*paths: Path):
163162
# (e.g. when test uses an `actual` folder for actual results)
164163
assert path.is_relative_to(tmp_path)
165164
name = str(path.relative_to(tmp_path))
166-
uploader.collector.collect(path=path, name=name)
165+
uploader.collect(path=path, name=name)
167166
else:
168167
warnings.warn("Fixture `upload_assets` is a no-op (incomplete set up).")
169168

qa/unittests/requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
apex-algorithm-qa-tools
22
pytest>=8.2.0
3+
moto[s3, server]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import uuid
2+
3+
import boto3
4+
import moto.server
5+
import pytest
6+
7+
8+
@pytest.fixture(scope="module")
9+
def moto_server() -> str:
10+
"""Fixture to run a mocked AWS server for testing."""
11+
server = moto.server.ThreadedMotoServer()
12+
server.start()
13+
# TODO: avoid hardcoded port (5000)
14+
yield "http://localhost:5000"
15+
server.stop()
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def aws_credentials(monkeypatch):
20+
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "test123")
21+
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test456")
22+
23+
24+
@pytest.fixture
25+
def s3_client(moto_server):
26+
return boto3.client("s3", endpoint_url=moto_server)
27+
28+
29+
@pytest.fixture
30+
def s3_bucket(s3_client) -> str:
31+
# Unique bucket name for test isolation
32+
bucket = f"test-bucket-{uuid.uuid4().hex}"
33+
s3_client.create_bucket(Bucket=bucket)
34+
return bucket
35+
36+
37+
def test_basic_upload_on_fail(
38+
pytester: pytest.Pytester, moto_server, s3_client, s3_bucket
39+
):
40+
pytester.makeconftest(
41+
"""
42+
pytest_plugins = [
43+
"apex_algorithm_qa_tools.pytest_upload_assets",
44+
]
45+
"""
46+
)
47+
pytester.makepyfile(
48+
test_file_maker="""
49+
def test_fail_and_upload(upload_assets, tmp_path):
50+
path = tmp_path / "hello.txt"
51+
path.write_text("Hello world.")
52+
upload_assets(path)
53+
assert 3 == 5
54+
"""
55+
)
56+
57+
run_result = pytester.runpytest_subprocess(
58+
"--upload-assets-run-id=test-run-123",
59+
f"--upload-assets-endpoint-url={moto_server}",
60+
f"--upload-assets-bucket={s3_bucket}",
61+
)
62+
run_result.stdout.re_match_lines(
63+
[r"Plugin `upload_assets` is active, with upload to 'test-bucket-"]
64+
)
65+
run_result.assert_outcomes(failed=1)
66+
67+
object_listing = s3_client.list_objects(Bucket=s3_bucket)
68+
assert len(object_listing["Contents"])
69+
keys = [obj["Key"] for obj in object_listing["Contents"]]
70+
expected_key = "test-run-123!test_file_maker.py__test_fail_and_upload!hello.txt"
71+
assert keys == [expected_key]
72+
73+
actual = s3_client.get_object(Bucket=s3_bucket, Key=expected_key)
74+
assert actual["Body"].read().decode("utf8") == "Hello world."
75+
76+
run_result.stdout.re_match_lines([r".*`upload_assets` stats: \{'uploaded': 1\}"])
77+
78+
79+
def test_nop_on_success(pytester: pytest.Pytester, moto_server, s3_client, s3_bucket):
80+
pytester.makeconftest(
81+
"""
82+
pytest_plugins = [
83+
"apex_algorithm_qa_tools.pytest_upload_assets",
84+
]
85+
"""
86+
)
87+
pytester.makepyfile(
88+
test_file_maker="""
89+
def test_success(upload_assets, tmp_path):
90+
path = tmp_path / "hello.txt"
91+
path.write_text("Hello world.")
92+
upload_assets(path)
93+
assert 3 == 3
94+
"""
95+
)
96+
97+
run_result = pytester.runpytest_subprocess(
98+
"--upload-assets-run-id=test-run-123",
99+
f"--upload-assets-endpoint-url={moto_server}",
100+
f"--upload-assets-bucket={s3_bucket}",
101+
)
102+
run_result.stdout.re_match_lines(
103+
[r"Plugin `upload_assets` is active, with upload to 'test-bucket-"]
104+
)
105+
run_result.assert_outcomes(passed=1)
106+
107+
object_listing = s3_client.list_objects(Bucket=s3_bucket)
108+
assert object_listing.get("Contents", []) == []
109+
110+
run_result.stdout.re_match_lines([r".*`upload_assets` stats: \{'uploaded': 0\}"])

0 commit comments

Comments
 (0)