Skip to content

Commit

Permalink
Merge pull request #136 from datopian/tickets/DM-42231D
Browse files Browse the repository at this point in the history
mock Google Cloud Storage: tickets/DM-42231D
  • Loading branch information
demenech committed Jan 19, 2024
2 parents fd3925c + 812c519 commit 02eac21
Show file tree
Hide file tree
Showing 10 changed files with 226 additions and 176 deletions.
15 changes: 8 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,18 @@ VERSION := $(shell $(PYTHON) -c "from importlib.metadata import version;print(ve

default: help

## Install packages necessary for make to work
init:
pip install --upgrade pip pre-commit pip-tools tox

## Regenerate requirements files
requirements: requirements/dev.txt requirements/dev.in requirements/main.txt requirements/main.in

## Set up the development environment
dev-setup: $(SENTINELS)/dev-setup

## Run all linting checks
lint: $(SENTINELS)
pre-commit run --all-files

## Run all tests
test: $(SENTINELS)/dev-setup
test: $(SENTINELS)
$(PYTEST) $(PYTEST_EXTRA_ARGS) $(PACKAGE_DIRS) $(TESTS_DIR)

## Build a local Docker image
Expand Down Expand Up @@ -77,13 +77,14 @@ $(SENTINELS):
mkdir $@

$(SENTINELS)/dist-setup: | $(SENTINELS)
$(PIP) install -U pip wheel twine pre-commit
$(PIP) install -U wheel twine
@touch $@

$(SENTINELS)/dist: $(SENTINELS)/dist-setup $(DIST_DIR)/$(PACKAGE_NAME)-$(VERSION).tar.gz $(DIST_DIR)/$(PACKAGE_NAME)-$(VERSION)-py3-none-any.whl | $(SENTINELS)
@touch $@

$(SENTINELS)/dev-setup: requirements/main.txt requirements/dev.txt | $(SENTINELS)
$(SENTINELS)/dev-setup: init requirements/main.txt requirements/dev.txt | $(SENTINELS)
$(PIP) install -U pip pip-tools pre-commit tox
$(PIP) install -r requirements/main.txt
$(PIP) install -e .
$(PIP) install -r requirements/dev.txt
Expand Down
4 changes: 2 additions & 2 deletions giftless/storage/google_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from typing import Any, BinaryIO, cast

import google.auth
import google.cloud
from google.auth import impersonated_credentials
from google.cloud import storage
from google.oauth2 import service_account

from giftless.storage import ExternalStorage, StreamingStorage
Expand Down Expand Up @@ -40,7 +40,7 @@ def __init__(
| impersonated_credentials.Credentials
| None
) = self._load_credentials(account_key_file, account_key_base64)
self.storage_client = storage.Client(
self.storage_client = google.cloud.storage.Client(
project=project_name, credentials=self.credentials
)
if not self.credentials:
Expand Down
2 changes: 2 additions & 0 deletions requirements/dev.in
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ pytest-env
pytest-cov
pytest-vcr

cloud-storage-mocker

pytz
types-pytz
types-jwt
Expand Down
61 changes: 47 additions & 14 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ babel==2.14.0
# via sphinx
beautifulsoup4==4.12.2
# via furo
boto3-stubs==1.34.16
boto3-stubs==1.34.19
# via -r requirements/dev.in
botocore-stubs==1.34.16
botocore-stubs==1.34.19
# via
# -r requirements/dev.in
# boto3-stubs
Expand All @@ -39,6 +39,8 @@ click==8.1.7
# via
# -c requirements/main.txt
# pip-tools
cloud-storage-mocker==0.3.1
# via -r requirements/dev.in
colorama==0.4.6
# via tox
commonmark==0.9.1
Expand All @@ -62,12 +64,41 @@ flake8==7.0.0
# via -r requirements/dev.in
furo==2023.9.10
# via -r requirements/dev.in
google-auth==2.26.1
google-api-core==2.15.0
# via
# -c requirements/main.txt
# google-cloud-core
# google-cloud-storage
google-auth==2.26.2
# via
# -c requirements/main.txt
# google-api-core
# google-auth-stubs
# google-cloud-core
# google-cloud-storage
google-auth-stubs==0.2.0
# via -r requirements/dev.in
google-cloud-core==2.4.1
# via
# -c requirements/main.txt
# google-cloud-storage
google-cloud-storage==2.14.0
# via
# -c requirements/main.txt
# cloud-storage-mocker
google-crc32c==1.5.0
# via
# -c requirements/main.txt
# google-cloud-storage
# google-resumable-media
google-resumable-media==2.7.0
# via
# -c requirements/main.txt
# google-cloud-storage
googleapis-common-protos==1.62.0
# via
# -c requirements/main.txt
# google-api-core
grpc-stubs==1.53.0.5
# via google-auth-stubs
grpcio==1.60.0
Expand Down Expand Up @@ -117,6 +148,11 @@ pluggy==1.3.0
# via
# pytest
# tox
protobuf==4.25.2
# via
# -c requirements/main.txt
# google-api-core
# googleapis-common-protos
pyasn1==0.5.1
# via
# -c requirements/main.txt
Expand Down Expand Up @@ -167,6 +203,8 @@ recommonmark==0.7.1
requests==2.31.0
# via
# -c requirements/main.txt
# google-api-core
# google-cloud-storage
# sphinx
rsa==4.9
# via
Expand All @@ -183,28 +221,23 @@ sphinx==7.2.6
# recommonmark
# sphinx-autodoc-typehints
# sphinx-basic-ng
# sphinxcontrib-applehelp
# sphinxcontrib-devhelp
# sphinxcontrib-htmlhelp
# sphinxcontrib-qthelp
# sphinxcontrib-serializinghtml
sphinx-autodoc-typehints==1.25.2
# via -r requirements/dev.in
sphinx-basic-ng==1.0.0b2
# via furo
sphinxcontrib-applehelp==1.0.7
sphinxcontrib-applehelp==1.0.8
# via sphinx
sphinxcontrib-devhelp==1.0.5
sphinxcontrib-devhelp==1.0.6
# via sphinx
sphinxcontrib-htmlhelp==2.0.4
sphinxcontrib-htmlhelp==2.0.5
# via sphinx
sphinxcontrib-jsmath==1.0.1
# via sphinx
sphinxcontrib-qthelp==1.0.6
sphinxcontrib-qthelp==1.0.7
# via sphinx
sphinxcontrib-serializinghtml==1.1.9
sphinxcontrib-serializinghtml==1.1.10
# via sphinx
tox==4.11.4
tox==4.12.0
# via -r requirements/dev.in
types-awscrt==0.20.0
# via botocore-stubs
Expand Down
6 changes: 3 additions & 3 deletions requirements/main.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ azure-storage-blob==12.19.0
# via -r requirements/main.in
blinker==1.7.0
# via flask
boto3==1.34.16
boto3==1.34.19
# via -r requirements/main.in
botocore==1.34.16
botocore==1.34.19
# via
# boto3
# s3transfer
Expand Down Expand Up @@ -47,7 +47,7 @@ google-api-core==2.15.0
# via
# google-cloud-core
# google-cloud-storage
google-auth==2.26.1
google-auth==2.26.2
# via
# google-api-core
# google-cloud-core
Expand Down
Empty file added tests/mocks/__init__.py
Empty file.
83 changes: 83 additions & 0 deletions tests/mocks/google_cloud_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Mock for google_cloud_storage that just uses a temporary directory
rather than talking to Google. This effectively makes it a LocalStorage
implementation, of course.
"""

import shutil
from pathlib import Path
from typing import Any, BinaryIO

from giftless.storage.exc import ObjectNotFoundError
from giftless.storage.google_cloud import GoogleCloudStorage


class MockGoogleCloudStorage(GoogleCloudStorage):
"""Mocks a GoogleCloudStorage object by simulating it with a local
directory.
"""

def __init__(
self,
project_name: str,
bucket_name: str,
path: Path,
account_key_file: str | None = None,
account_key_base64: str | None = None,
path_prefix: str | None = None,
serviceaccount_email: str | None = None,
**_: Any,
) -> None:
super().__init__(
project_name=project_name,
bucket_name=bucket_name,
account_key_file=account_key_file,
account_key_base64=account_key_base64,
serviceaccount_email=serviceaccount_email,
)
self._path = path

def _get_blob_path(self, prefix: str, oid: str) -> str:
return str(self._get_blob_pathlib_path(prefix, oid))

def _get_blob_pathlib_path(self, prefix: str, oid: str) -> Path:
return Path(self._path / Path(prefix) / oid)

@staticmethod
def _create_path(spath: str) -> None:
path = Path(spath)
if not path.is_dir():
path.mkdir(parents=True)

def _get_signed_url(
self,
prefix: str,
oid: str,
expires_in: int,
http_method: str = "GET",
filename: str | None = None,
disposition: str | None = None,
) -> str:
return f"https://example.com/signed_blob/{prefix}/{oid}"

def get(self, prefix: str, oid: str) -> BinaryIO:
obj = self._get_blob_pathlib_path(prefix, oid)
if not obj.exists():
raise ObjectNotFoundError("Object does not exist")
return obj.open("rb")

def put(self, prefix: str, oid: str, data_stream: BinaryIO) -> int:
path = self._get_blob_pathlib_path(prefix, oid)
directory = path.parent
self._create_path(str(directory))
with path.open("bw") as dest:
shutil.copyfileobj(data_stream, dest)
return dest.tell()

def exists(self, prefix: str, oid: str) -> bool:
return self._get_blob_pathlib_path(prefix, oid).is_file()

def get_size(self, prefix: str, oid: str) -> int:
if not self.exists(prefix, oid):
raise ObjectNotFoundError("Object does not exist")
path = self._get_blob_pathlib_path(prefix, oid)
return path.stat().st_size
30 changes: 7 additions & 23 deletions tests/storage/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import io
from abc import ABC
from typing import Any, cast

import pytest
Expand All @@ -14,14 +15,8 @@
# for storage classes here. That should be refactored sometime.


class _CommonStorageAbstractTests:
"""Common tests for all storage backend types and interfaces.
This should not be used directly, because it is inherited by other
AbstractTest test suites.
Perhaps that means that we should make this an ABC?
"""
class _CommonStorageAbstractTests(ABC): # noqa: B024
"""Common tests for all storage backend types and interfaces."""

def test_get_size(self, storage_backend: StreamingStorage) -> None:
"""Test getting the size of a stored object."""
Expand Down Expand Up @@ -53,14 +48,9 @@ def test_exists_not_exists(
assert not storage_backend.exists("org/repo", ARBITRARY_OID)


class _VerifiableStorageAbstractTests:
class _VerifiableStorageAbstractTests(ABC): # noqa: B024
"""Mixin class for other base storage adapter test classes that implement
VerifiableStorage.
This should not be used directly, because it is inherited by other
AbstractTest test suites.
Perhaps that means this should be an ABC?
"""

def test_verify_object_ok(self, storage_backend: StreamingStorage) -> None:
Expand Down Expand Up @@ -88,16 +78,14 @@ def test_verify_object_not_there(


class StreamingStorageAbstractTests(
_CommonStorageAbstractTests, _VerifiableStorageAbstractTests
_CommonStorageAbstractTests, _VerifiableStorageAbstractTests, ABC
):
"""Mixin for testing the StreamingStorage methods of a backend
that implements StreamingStorage.
To use, create a concrete test class mixing this class in, and
define a fixture named ``storage_backend`` that returns an
appropriate storage backend object.
Again, perhaps this should be defined as an ABC?
"""

def test_put_get_object(self, storage_backend: StreamingStorage) -> None:
Expand Down Expand Up @@ -135,24 +123,20 @@ class ExternalStorageAbstractTests(
Again, perhaps this should be defined as an ABC?
"""

def test_get_upload_action(
self, storage_backend: ExternalStorage
) -> dict[str, Any]:
def test_get_upload_action(self, storage_backend: ExternalStorage) -> None:
action_spec = storage_backend.get_upload_action(
"org/repo", ARBITRARY_OID, 100, 3600
)
upload = cast(dict[str, Any], action_spec["actions"]["upload"])
assert upload["href"][0:4] == "http"
assert upload["expires_in"] == 3600
return upload

def test_get_download_action(
self, storage_backend: ExternalStorage
) -> dict[str, Any]:
) -> None:
action_spec = storage_backend.get_download_action(
"org/repo", ARBITRARY_OID, 100, 7200
)
download = cast(dict[str, Any], action_spec["actions"]["download"])
assert download["href"][0:4] == "http"
assert download["expires_in"] == 7200
return download
Loading

0 comments on commit 02eac21

Please sign in to comment.