Skip to content

Commit 19f314b

Browse files
pjbulljayqi
andauthored
Use list_objects_v2 API for S3 (#302)
* Initial work * Fix drive-level exists checks * perf measurement * implement listing with list_objects_v2 * formatting * make tests pass * update makefile documentation * revert rig skipping * Update Makefile Co-authored-by: Jay Qi <2721979+jayqi@users.noreply.github.com> * extract out default containers and buckets * remove dotenv from runner * Move google import to avoid import issues * Add pip caching and test independent installs * missing run keys * skip slow oses to test new steps * add notification dependency * install build tools * activate env * Instantiate properties first * Allow azure to instantiate * oses back in and debug commands removed Co-authored-by: Jay Qi <2721979+jayqi@users.noreply.github.com>
1 parent 289baeb commit 19f314b

18 files changed

Lines changed: 524 additions & 54 deletions

.github/workflows/tests.yml

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ jobs:
1818
- uses: actions/checkout@v2
1919

2020
- name: Set up Python
21-
uses: actions/setup-python@v2
21+
uses: actions/setup-python@v4
2222
with:
2323
python-version: 3.8
24+
cache: 'pip' # caching pip dependencies
2425

2526
- name: Install dependencies
2627
run: |
@@ -44,9 +45,10 @@ jobs:
4445
- uses: actions/checkout@v2
4546

4647
- name: Set up Python ${{ matrix.python-version }}
47-
uses: actions/setup-python@v2
48+
uses: actions/setup-python@v4
4849
with:
4950
python-version: ${{ matrix.python-version }}
51+
cache: 'pip' # caching pip dependencies
5052

5153
- name: Install dependencies
5254
run: |
@@ -82,9 +84,10 @@ jobs:
8284
- uses: actions/checkout@v2
8385

8486
- name: Set up Python
85-
uses: actions/setup-python@v2
87+
uses: actions/setup-python@v4
8688
with:
8789
python-version: 3.8
90+
cache: 'pip' # caching pip dependencies
8891

8992
- name: Install dependencies
9093
run: |
@@ -119,9 +122,54 @@ jobs:
119122
file: ./coverage.xml
120123
fail_ci_if_error: true
121124

125+
extras-test:
126+
name: Test independent installs of clients
127+
needs: tests
128+
runs-on: ubuntu-latest
129+
strategy:
130+
matrix:
131+
extra: ["s3", "azure", "gs"]
132+
include:
133+
- prefix: "s3"
134+
extra: "s3"
135+
- prefix: "az"
136+
extra: "azure"
137+
- prefix: "gs"
138+
extra: "gs"
139+
140+
steps:
141+
- uses: actions/checkout@v2
142+
143+
- name: Set up Python
144+
uses: actions/setup-python@v4
145+
with:
146+
python-version: 3.8
147+
cache: "pip"
148+
149+
- name: Build cloudpathlib
150+
run: |
151+
pip install --upgrade pip wheel setuptools
152+
make dist # build cloudpathlib wheel
153+
154+
- name: Create empty venv
155+
run: |
156+
python -m venv ${{ matrix.extra }}-env
157+
158+
- name: Install cloudpathlib[${{ matrix.extra }}]
159+
run: |
160+
source ${{ matrix.extra }}-env/bin/activate
161+
pip install "$(find dist -name 'cloudpathlib*.whl')[${{ matrix.extra }}]"
162+
163+
- name: Test ${{ matrix.extra }} usage
164+
run: |
165+
source ${{ matrix.extra }}-env/bin/activate
166+
python -c 'from cloudpathlib import CloudPath; CloudPath("${{ matrix.prefix }}://bucket/test")'
167+
env:
168+
AZURE_STORAGE_CONNECTION_STRING: ${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}
169+
122170
notify:
123171
name: Notify failed build
124-
needs: [code-quality, tests, live-tests]
172+
needs: [code-quality, tests, live-tests, extras-test]
125173
if: failure() && github.event.pull_request == null
126174
runs-on: ubuntu-latest
127175
steps:

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ docs/docs/command-reference/*.md
44
docs/docs/index.md
55
docs/docs/changelog.md
66

7+
# perf output
8+
perf-results.csv
9+
710
## GitHub Python .gitignore ##
811
# https://github.com/github/gitignore/blob/master/Python.gitignore
912

@@ -76,6 +79,7 @@ target/
7679

7780
# Jupyter Notebook
7881
.ipynb_checkpoints
82+
scratchpad.ipynb
7983

8084
# pyenv
8185
.python-version
@@ -88,6 +92,7 @@ celerybeat-schedule
8892

8993
# dotenv
9094
.env
95+
.gscreds.json
9196

9297
# virtualenv
9398
.venv

Makefile

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ docs: clean-docs docs-setup ## build the static version of the docs
5151
docs-serve: clean-docs docs-setup ## serve documentation to livereload while you work
5252
cd docs && mkdocs serve
5353

54-
format:
54+
format: ## run black to format codebase
5555
black cloudpathlib tests docs
5656

5757
help:
@@ -60,7 +60,7 @@ help:
6060
install: clean ## install the package to the active Python's site-packages
6161
python setup.py install
6262

63-
lint: ## check style with flake8
63+
lint: ## check style with black, flake8, and mypy
6464
black --check cloudpathlib tests docs
6565
flake8 cloudpathlib tests docs
6666
mypy cloudpathlib
@@ -71,11 +71,17 @@ release: dist ## package and upload a release
7171
release-test: dist
7272
twine upload --repository pypitest dist/*
7373

74-
reqs:
74+
reqs: ## install development requirements
7575
pip install -U -r requirements-dev.txt
7676

7777
test: ## run tests with mocked cloud SDKs
7878
python -m pytest -vv
7979

80+
test-debug: ## rerun tests that failed in last run and stop with pdb at failures
81+
python -m pytest -n=0 -vv --lf --pdb
82+
8083
test-live-cloud: ## run tests on live cloud backends
8184
USE_LIVE_CLOUD=1 python -m pytest -vv
85+
86+
perf: ## run performance measurement suite for s3 and save results to perf-results.csv
87+
python tests/performance/cli.py s3 --save-csv=perf-results.csv

cloudpathlib/azure/azblobclient.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ def _is_file_or_dir(self, cloud_path: AzureBlobPath) -> Optional[str]:
142142
return None
143143

144144
def _exists(self, cloud_path: AzureBlobPath) -> bool:
145+
# short circuit when only the container
146+
if not cloud_path.blob:
147+
return self.service_client.get_container_client(cloud_path.container).exists()
148+
145149
return self._is_file_or_dir(cloud_path) in ["file", "dir"]
146150

147151
def _list_dir(

cloudpathlib/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ def __init__(
3131
local_cache_dir: Optional[Union[str, os.PathLike]] = None,
3232
content_type_method: Optional[Callable] = mimetypes.guess_type,
3333
):
34+
self._cache_tmp_dir = None
3435
self._cloud_meta.validate_completeness()
3536
# setup caching and local versions of file and track if it is a tmp dir
36-
self._cache_tmp_dir = None
3737
if local_cache_dir is None:
3838
self._cache_tmp_dir = TemporaryDirectory()
3939
local_cache_dir = self._cache_tmp_dir.name

cloudpathlib/gs/gsclient.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path, PurePosixPath
55
from typing import Any, Callable, Dict, Iterable, Optional, TYPE_CHECKING, Tuple, Union
66

7+
78
from ..client import Client, register_client_class
89
from ..cloudpath import implementation_registry
910
from .gspath import GSPath
@@ -12,6 +13,7 @@
1213
if TYPE_CHECKING:
1314
from google.auth.credentials import Credentials
1415

16+
from google.api_core.exceptions import NotFound
1517
from google.auth.exceptions import DefaultCredentialsError
1618
from google.cloud.storage import Client as StorageClient
1719

@@ -135,6 +137,14 @@ def _is_file_or_dir(self, cloud_path: GSPath) -> Optional[str]:
135137
return None
136138

137139
def _exists(self, cloud_path: GSPath) -> bool:
140+
# short-circuit the root-level bucket
141+
if not cloud_path.blob:
142+
try:
143+
next(self.client.bucket(cloud_path.bucket).list_blobs())
144+
return True
145+
except NotFound:
146+
return False
147+
138148
return self._is_file_or_dir(cloud_path) in ["file", "dir"]
139149

140150
def _list_dir(self, cloud_path: GSPath, recursive=False) -> Iterable[Tuple[GSPath, bool]]:

cloudpathlib/s3/s3client.py

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,19 @@ def _is_file_or_dir(self, cloud_path: S3Path) -> Optional[str]:
143143
return "dir"
144144

145145
def _exists(self, cloud_path: S3Path) -> bool:
146+
# check if this is a bucket
147+
if not cloud_path.key:
148+
try:
149+
self.client.head_bucket(Bucket=cloud_path.bucket)
150+
return True
151+
except ClientError:
152+
return False
153+
146154
return self._s3_file_query(cloud_path) is not None
147155

148156
def _s3_file_query(self, cloud_path: S3Path):
149157
"""Boto3 query used for quick checks of existence and if path is file/dir"""
150-
# first check if this is an object that we can access directly
151-
158+
# check if this is an object that we can access directly
152159
try:
153160
obj = self.s3.Object(cloud_path.bucket, cloud_path.key)
154161
obj.load()
@@ -169,40 +176,55 @@ def _s3_file_query(self, cloud_path: S3Path):
169176
)
170177

171178
def _list_dir(self, cloud_path: S3Path, recursive=False) -> Iterable[Tuple[S3Path, bool]]:
172-
bucket = self.s3.Bucket(cloud_path.bucket)
173-
174179
prefix = cloud_path.key
175180
if prefix and not prefix.endswith("/"):
176181
prefix += "/"
177182

178183
yielded_dirs = set()
179184

180-
if recursive:
181-
for o in bucket.objects.filter(Prefix=prefix):
182-
# get directory from this path
183-
for parent in PurePosixPath(o.key[len(prefix) :]).parents:
184-
# if we haven't surfaced their directory already
185-
if parent not in yielded_dirs and str(parent) != ".":
186-
yield (self.CloudPath(f"s3://{cloud_path.bucket}/{prefix}{parent}"), True)
187-
yielded_dirs.add(parent)
185+
paginator = self.client.get_paginator("list_objects_v2")
188186

189-
yield (self.CloudPath(f"s3://{o.bucket_name}/{o.key}"), False)
190-
else:
191-
# non recursive is best done with old client API rather than resource
192-
paginator = self.client.get_paginator("list_objects")
193-
194-
for result in paginator.paginate(
195-
Bucket=cloud_path.bucket, Prefix=prefix, Delimiter="/"
196-
):
197-
# sub directory names
198-
for result_prefix in result.get("CommonPrefixes", []):
187+
for result in paginator.paginate(
188+
Bucket=cloud_path.bucket, Prefix=prefix, Delimiter=("" if recursive else "/")
189+
):
190+
# yield everything in common prefixes as directories
191+
for result_prefix in result.get("CommonPrefixes", []):
192+
canonical = result_prefix.get("Prefix").rstrip("/") # keep a canonical form
193+
if canonical not in yielded_dirs:
194+
yield (
195+
self.CloudPath(f"s3://{cloud_path.bucket}/{canonical}"),
196+
True,
197+
)
198+
yielded_dirs.add(canonical)
199+
200+
# check all the keys
201+
for result_key in result.get("Contents", []):
202+
# yield all the parents of any key that have not been yielded already
203+
o_relative_path = result_key.get("Key")[len(prefix) :]
204+
for parent in PurePosixPath(o_relative_path).parents:
205+
parent_canonical = prefix + str(parent).rstrip("/")
206+
if parent_canonical not in yielded_dirs and str(parent) != ".":
207+
yield (
208+
self.CloudPath(f"s3://{cloud_path.bucket}/{parent_canonical}"),
209+
True,
210+
)
211+
yielded_dirs.add(parent_canonical)
212+
213+
# if we already yielded this dir, go to next item in contents
214+
canonical = result_key.get("Key").rstrip("/")
215+
if canonical in yielded_dirs:
216+
continue
217+
218+
# s3 fake directories have 0 size and end with "/"
219+
if result_key.get("Key").endswith("/") and result_key.get("Size") == 0:
199220
yield (
200-
self.CloudPath(f"s3://{cloud_path.bucket}/{result_prefix.get('Prefix')}"),
221+
self.CloudPath(f"s3://{cloud_path.bucket}/{canonical}"),
201222
True,
202223
)
224+
yielded_dirs.add(canonical)
203225

204-
# files in the directory
205-
for result_key in result.get("Contents", []):
226+
# yield object as file
227+
else:
206228
yield (
207229
self.CloudPath(f"s3://{cloud_path.bucket}/{result_key.get('Key')}"),
208230
False,

requirements-dev.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ flake8
55
ipytest
66
ipython
77
jupyter
8+
loguru
89
matplotlib
910
mike
1011
mkdocs>=1.2.2
@@ -19,8 +20,12 @@ pydantic
1920
pytest
2021
pytest-cases
2122
pytest-cov
23+
pytest-xdist
2224
python-dotenv
2325
pywin32; sys_platform == 'win32'
26+
rich
2427
shortuuid
2528
tabulate
29+
tqdm
30+
typer
2631
wheel

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ ignore_errors = True
2121

2222
[tool:pytest]
2323
testpaths = tests/
24-
addopts = --cov=cloudpathlib --cov-report=term --cov-report=html --cov-report=xml
24+
addopts = --cov=cloudpathlib --cov-report=term --cov-report=html --cov-report=xml -n=auto
2525

2626
[coverage:report]
2727
include = cloudpathlib/**.py

0 commit comments

Comments
 (0)