Skip to content

Commit e0c3980

Browse files
committed
WIP: More s3 testing
1 parent 66629c7 commit e0c3980

File tree

3 files changed

+211
-39
lines changed

3 files changed

+211
-39
lines changed

tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ def generate(cls, root, versions=None, zip_created=None, zip_root=None):
154154
filename = root / f"{name}.zip"
155155
ver = version.version if version.inc_version else None
156156
with ZipFile(filename, "w") as zf:
157+
# Make the .zip file larger than the remotezip initial_buffer_size
158+
# so testing of partial archive reading is forced use multiple requests
159+
zf.writestr("data.txt", "-" * 64 * 1024)
157160
zf.writestr(
158161
".hab.json",
159162
cls.hab_json(version.name, version=ver, distros=version.distros),

tests/test_distro_finder.py

Lines changed: 206 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import glob
22
import logging
33
import os
4+
import sys
45
from pathlib import Path
56

67
import pytest
@@ -75,43 +76,6 @@ def test_glob_path(config_root, glob_str, count):
7576
assert len(result) == count
7677

7778

78-
class TestLoadPath:
79-
"""Test the various `DistroFinder.load_path` implementations."""
80-
81-
def test_distro_finder(self, uncached_resolver):
82-
"""Currently load_path for DistroFinder just returns None."""
83-
finder = distro_finder.DistroFinder("", uncached_resolver.site)
84-
assert finder.load_path(Path(".")) is None
85-
86-
def test_zip_sidecar(self, zip_distro_sidecar):
87-
"""The Zip Sidecar reads a .json file next to the zip distro.
88-
89-
Ensure it's able to read data from the .json file.
90-
"""
91-
finder = zip_sidecar.DistroFinderZipSidecar(zip_distro_sidecar.root)
92-
93-
# This distro hard codes the version inside the .json file
94-
data = finder.load_path(zip_distro_sidecar.root / "dist_a_v0.1.hab.json")
95-
assert data["name"] == "dist_a"
96-
assert "distros" not in data
97-
assert data["version"] == "0.1"
98-
99-
# Test a different distro that doesn't hard code the version
100-
data = finder.load_path(zip_distro_sidecar.root / "dist_b_v0.5.hab.json")
101-
assert data["name"] == "dist_b"
102-
assert "distros" not in data
103-
assert data["version"] == "0.5"
104-
105-
# This distro includes required distros
106-
data = finder.load_path(zip_distro_sidecar.root / "dist_a_v0.2.hab.json")
107-
assert data["name"] == "dist_a"
108-
assert data["distros"] == ["dist_b"]
109-
assert data["version"] == "0.2"
110-
111-
def test_s3(self):
112-
pass
113-
114-
11579
class CheckDistroFinder:
11680
distro_finder_cls = distro_finder.DistroFinder
11781
site_template = "site_distro_finder.json"
@@ -180,19 +144,54 @@ def test_content(self, distro_finder_info):
180144
result = finder.content(path)
181145
assert result == distro_finder_info.root
182146

147+
def test_load_path(self, uncached_resolver):
148+
"""Currently load_path for DistroFinder just returns None."""
149+
finder = distro_finder.DistroFinder("", uncached_resolver.site)
150+
assert finder.load_path(Path(".")) is None
151+
183152
def test_installed(self, distro_finder_info, helpers, tmp_path):
184153
self.check_installed(distro_finder_info, helpers, tmp_path)
185154

186155
def test_install(self, distro_finder_info, helpers, tmp_path):
187156
self.check_install(distro_finder_info, helpers, tmp_path)
188157

158+
def test_clear_cache(self, distro_finder_info):
159+
"""Cover the clear_cache function, which for this class does nothing."""
160+
finder = self.distro_finder_cls(distro_finder_info.root)
161+
finder.clear_cache()
162+
189163

190164
class TestZipSidecar(CheckDistroFinder):
191165
"""Tests specific to `DistroFinderZipSidecar`."""
192166

193167
distro_finder_cls = zip_sidecar.DistroFinderZipSidecar
194168
site_template = "site_distro_zip_sidecar.json"
195169

170+
def test_load_path(self, zip_distro_sidecar):
171+
"""The Zip Sidecar reads a .json file next to the zip distro.
172+
173+
Ensure it's able to read data from the .json file.
174+
"""
175+
finder = self.distro_finder_cls(zip_distro_sidecar.root)
176+
177+
# This distro hard codes the version inside the .json file
178+
data = finder.load_path(zip_distro_sidecar.root / "dist_a_v0.1.hab.json")
179+
assert data["name"] == "dist_a"
180+
assert "distros" not in data
181+
assert data["version"] == "0.1"
182+
183+
# Test a different distro that doesn't hard code the version
184+
data = finder.load_path(zip_distro_sidecar.root / "dist_b_v0.5.hab.json")
185+
assert data["name"] == "dist_b"
186+
assert "distros" not in data
187+
assert data["version"] == "0.5"
188+
189+
# This distro includes required distros
190+
data = finder.load_path(zip_distro_sidecar.root / "dist_a_v0.2.hab.json")
191+
assert data["name"] == "dist_a"
192+
assert data["distros"] == ["dist_b"]
193+
assert data["version"] == "0.2"
194+
196195
def test_installed(self, zip_distro_sidecar, helpers, tmp_path):
197196
self.check_installed(zip_distro_sidecar, helpers, tmp_path)
198197

@@ -238,7 +237,7 @@ def test_load_path(self, zip_distro):
238237
239238
Ensure it's able to read data from the .json file.
240239
"""
241-
finder = df_zip.DistroFinderZip(zip_distro.root)
240+
finder = self.distro_finder_cls(zip_distro.root)
242241

243242
# This distro hard codes the version inside the .json file
244243
data = finder.load_path(zip_distro.root / "dist_a_v0.1.zip")
@@ -293,24 +292,132 @@ def test_installed(self, zip_distro, helpers, tmp_path):
293292
def test_install(self, zip_distro, helpers, tmp_path):
294293
self.check_install(zip_distro, helpers, tmp_path)
295294

295+
def test_clear_cache(self, distro_finder_info):
296+
"""Test the clear_cache function for this class."""
297+
finder = self.distro_finder_cls(distro_finder_info.root)
298+
finder._cache["test"] = "case"
299+
finder.clear_cache()
300+
assert finder._cache == {}
301+
296302

297303
# These tests only work if using the `pyXX-s3` tox testing env
298304
@pytest.mark.skipif(
299305
not os.getenv("VIRTUAL_ENV", "").endswith("-s3"),
300306
reason="not testing optional s3 cloud",
301307
)
302308
class TestS3(CheckDistroFinder):
303-
"""Tests specific to `DistroFinderS3Zip`."""
309+
"""Tests specific to `DistroFinderS3Zip`.
310+
311+
Note: All tests should use the `zip_distro_s3` fixture. This ensures that
312+
any s3 requests are local and also speeds up the test.
313+
"""
304314

305315
site_template = "site_distro_s3.json"
306316

317+
class ServerSimulator:
318+
"""Requests server used for testing downloading a partial zip file.
319+
320+
Based on remotezip test code:
321+
https://github.com/gtsystem/python-remotezip/blob/master/test_remotezip.py
322+
"""
323+
324+
def __init__(self, fname):
325+
self._fname = fname
326+
self.requested_ranges = []
327+
328+
def serve(self, request, context):
329+
import remotezip
330+
331+
from_byte, to_byte = remotezip.RemoteFetcher.parse_range_header(
332+
request.headers["Range"]
333+
)
334+
self.requested_ranges.append((from_byte, to_byte))
335+
336+
with open(self._fname, "rb") as f:
337+
if from_byte < 0:
338+
f.seek(0, 2)
339+
size = f.tell()
340+
f.seek(max(size + from_byte, 0), 0)
341+
init_pos = f.tell()
342+
content = f.read(min(size, -from_byte))
343+
else:
344+
f.seek(from_byte, 0)
345+
init_pos = f.tell()
346+
content = f.read(to_byte - from_byte + 1)
347+
348+
context.headers[
349+
"Content-Range"
350+
] = remotezip.RemoteFetcher.build_range_header(
351+
init_pos, init_pos + len(content)
352+
)
353+
return content
354+
307355
@property
308356
def distro_finder_cls(self):
309357
"""Only import this class if the test is not skipped."""
310358
from hab.distro_finders.s3_zip import DistroFinderS3Zip
311359

312360
return DistroFinderS3Zip
313361

362+
def test_load_path(self, zip_distro_s3, helpers, tmp_path, requests_mock):
363+
"""Simulate reading only part of a remote zip file hosted in an aws s3 bucket.
364+
365+
This doesn't actually connect to an aws s3 bucket, it uses mock libraries
366+
to simulate the process.
367+
"""
368+
import boto3
369+
370+
if sys.version_info.minor <= 7:
371+
# NOTE: boto3 has dropped python 3.7. Moto changed their context name
372+
# when they dropped support for python 3.7.
373+
from moto import mock_s3 as mock_aws
374+
else:
375+
from moto import mock_aws
376+
377+
# Make requests connect to a simulated s3 server that supports the range header
378+
server = self.ServerSimulator(
379+
zip_distro_s3.zip_root / "hab-test-bucket" / "dist_a_v0.1.zip"
380+
)
381+
requests_mock.register_uri(
382+
"GET",
383+
"s3://hab-test-bucket/dist_a_v0.1.zip",
384+
content=server.serve,
385+
status_code=200,
386+
)
387+
388+
# Create a mock aws setup using moto to test the authorization code
389+
resolver = self.create_resolver(zip_distro_s3.zip_root, helpers, tmp_path)
390+
dl_finder = resolver.site.downloads["distros"][0]
391+
392+
with mock_aws():
393+
# The LocalS3Client objects don't have all of the s3 properties we
394+
# require for configuring requests auth. Add them and crate the bucket.
395+
sess = boto3.Session(region_name="us-east-2")
396+
conn = boto3.resource("s3", region_name="us-east-2")
397+
conn.create_bucket(
398+
Bucket="hab-test-bucket",
399+
CreateBucketConfiguration={"LocationConstraint": "us-east-2"},
400+
)
401+
dl_finder.client.sess = sess
402+
dl_finder.client.client = sess.client("s3", region_name="us-east-2")
403+
404+
# Test reading .hab.json from inside a remote .zip file.
405+
dl_finder = resolver.site.downloads["distros"][0]
406+
zip_path = dl_finder.root / "dist_a_v0.1.zip"
407+
archive = dl_finder.archive(zip_path)
408+
409+
# Check that the filename property is always populated
410+
assert str(archive.filename) == str(zip_path)
411+
412+
# Check that we were able to read the data from the archive
413+
data = dl_finder.load_path(zip_path / ".hab.json")
414+
assert data["name"] == "dist_a"
415+
assert data["version"] == "0.1"
416+
417+
# Verify that remotezip had to make more than one request. This is because
418+
# the .zip file is larger than `initial_buffer_size`.
419+
assert len(server.requested_ranges) == 2
420+
314421
def test_installed(self, zip_distro_s3, helpers, tmp_path):
315422
self.check_installed(zip_distro_s3, helpers, tmp_path)
316423

@@ -334,6 +441,66 @@ def test_client(self, zip_distro_s3, helpers, tmp_path):
334441
finder.client = "A custom client"
335442
assert finder.client == "A custom client"
336443

444+
# Test init with a custom client
445+
from cloudpathlib.local import LocalS3Client
446+
447+
client = LocalS3Client()
448+
finder = self.distro_finder_cls("s3://hab-test-bucket", client=client)
449+
assert finder.client == client
450+
451+
def test_as_posix(self, zip_distro_s3):
452+
"""Cloudpathlib doesn't support `as_posix` a simple str is returned."""
453+
# Test that as_posix for CloudPath's returns the CloudPath as a str
454+
finder = self.distro_finder_cls("s3://hab-test-bucket")
455+
assert finder.as_posix() == "s3://hab-test-bucket"
456+
457+
# Otherwise it returns a standard pathlib.Path.as_posix value
458+
finder.root = zip_distro_s3.root
459+
assert finder.as_posix() == zip_distro_s3.root.as_posix()
460+
461+
def test_clear_cache(self, zip_distro_s3):
462+
"""Test the clear_cache function for this class."""
463+
464+
class Archive:
465+
"""Simulated ZipFile class to test that open archives get closed."""
466+
467+
def __init__(self):
468+
self.is_open = True
469+
470+
def close(self):
471+
self.is_open = False
472+
473+
class Client:
474+
"""Simulated S3Client to test calling clear_cache on."""
475+
476+
def __init__(self):
477+
self.cleared = False
478+
479+
def clear_cache(self):
480+
self.cleared = True
481+
482+
finder = self.distro_finder_cls("s3://hab-test-bucket")
483+
# Simulate use of the finder
484+
archive = Archive()
485+
finder._archives["s3://hab-test-bucket/dist_a_v0.1.zip"] = archive
486+
finder._cache["test"] = "case"
487+
finder.client = Client()
488+
assert archive.is_open
489+
assert not finder.client.cleared
490+
491+
# Check that clearing reset the cache variables
492+
finder.clear_cache()
493+
assert finder._archives == {}
494+
assert finder._cache == {}
495+
# Check that any open archives were closed
496+
assert not archive.is_open
497+
# Check that the client was not cleared as persistent is False
498+
assert not finder.client.cleared
499+
500+
# Clearing of persistent caches clears the cache
501+
finder.clear_cache(persistent=True)
502+
assert finder.client.cleared
503+
337504

338505
# TODO: Break this into separate smaller tests of components for each class not this
339506
@pytest.mark.parametrize(

tox.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ deps =
1616
pytest
1717
json5: pyjson5
1818
s3: .[s3]
19+
s3: moto[s3]
20+
s3: requests-mock
1921
commands =
2022
coverage run -m pytest {tty:--color=yes} {posargs:tests/}
2123

0 commit comments

Comments
 (0)