Repository: merixstudio/simple-storage (language: python)

Issue:
Google Cloud Storage integration
See the GCloud Storage API [docs here][1].

Use the `google-cloud-storage` package - GitHub repo [here][2].

See how Django Storages handles [this integration][3] - it might be helpful.

[1]: https://cloud.google.com/storage/docs/json_api/v1
[2]: https://github.com/googleapis/python-storage
[3]: https://github.com/jschneier/django-storages/blob/master/storages/backends/gcloud.py


Discussion / hints:

Fix patch:
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..29d7132
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Merixstudio
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index f1b89c5..e2379b3 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Data storage made simple.
 
 - File system
 - AWS S3
+- Google Cloud Storage
 
 ## Requirements
 
@@ -43,6 +44,14 @@ Now, depending on the storage you want to use, follow the instructions below.
 
 > In case you encounter any problems with finding your AWS credentials please read [this page][1] as a reference.
 
+## Google Cloud Storage
+
+| Environment variable                | Value                                                               |
+|-------------------------------------|---------------------------------------------------------------------|
+| `STORAGES_BACKEND`                  | `storages.backends.google_cloud.GoogleCloudStorage`                 |
+| `STORAGES_GOOGLE_CLOUD_CREDENTIALS` | Your Google Cloud Storage credentials in JSON encoded using Base64. |
+| `STORAGES_GOOGLE_CLOUD_BUCKET_NAME` | The bucket name that you want to use.                               |
+
 # Documentation
 
 The documentation is hosted on the project's [GitHub Wiki][2].
@@ -140,6 +149,6 @@ We are aiming to maintain the **100% test coverage** of this package.
 
 **Thank you** for your contributions! 😁
 
-[1]: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
+[1]: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html "Understanding and getting your AWS credentials"
 [2]: https://github.com/merixstudio/simple-storage/wiki
-[3]: https://pypi.org/project/pytest-xdist/
\ No newline at end of file
+[3]: https://pypi.org/project/pytest-xdist/
diff --git a/setup.cfg b/setup.cfg
index ccfb140..170a88e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,6 +26,8 @@ python_requires = >=3.6
 [options.extras_require]
 boto3 =
     boto3 >= 1.4.4
+googlecloud =
+    google-cloud-storage >= 1.43.0
 
 [options.packages.find]
 where = storage
diff --git a/storages/backends/google_cloud.py b/storages/backends/google_cloud.py
new file mode 100644
index 0000000..f4dea4d
--- /dev/null
+++ b/storages/backends/google_cloud.py
@@ -0,0 +1,68 @@
+import base64
+import json
+from datetime import datetime
+from typing import AnyStr
+
+from google.cloud import storage, exceptions  # type: ignore
+
+from storages.backends.base import Storage
+from storages.exceptions import ImproperlyConfiguredError
+
+
+class GoogleCloudStorage(Storage):
+    _SERVICE_NAME = "google_cloud"
+
+    def __init__(
+        self,
+        google_cloud_credentials: str,
+        google_cloud_bucket_name: str,
+    ):
+        if not google_cloud_credentials:
+            raise ImproperlyConfiguredError(
+                name="credentials_path", value=google_cloud_credentials
+            )
+        if not google_cloud_bucket_name:
+            raise ImproperlyConfiguredError(
+                name="google_cloud_bucket_name", value=google_cloud_bucket_name
+            )
+
+        self._client = storage.Client.from_service_account_info(
+            json.loads(base64.b64decode(google_cloud_credentials))
+        )
+        self._bucket_name = google_cloud_bucket_name
+        self._bucket = self._client.get_bucket(self._bucket_name)
+
+    def _get_blob(self, name: str) -> storage.Blob:
+        blob = self._bucket.get_blob(name)
+
+        if blob is None:
+            raise exceptions.NotFound(f"File {name} does not exist.")
+
+        return blob
+
+    def read(self, name: str, mode: str = "r") -> AnyStr:
+        blob = self._bucket.blob(name)
+        return blob.download_as_bytes()
+
+    def write(self, name: str, content: AnyStr, mode: str = "a"):
+        self._bucket.blob(name).upload_from_string(content)
+
+    def delete(self, name: str):
+        self._bucket.blob(name).delete()
+
+    def exists(self, name: str) -> bool:
+        return self._bucket.blob(name).exists()
+
+    def size(self, name: str) -> int:
+        return self._get_blob(name).size
+
+    def get_created_time(self, name: str) -> datetime:
+        return self._get_blob(name).time_created
+
+    def get_modified_time(self, name: str) -> datetime:
+        return self._get_blob(name).updated
+
+    def get_access_time(self, name: str) -> datetime:
+        raise NotImplementedError(
+            "Google Cloud Storage does not provide access time info."
+        )


Test patch:
diff --git a/.github/workflows/test_and_lint.yml b/.github/workflows/test_and_lint.yml
index beff47c..6733ba7 100644
--- a/.github/workflows/test_and_lint.yml
+++ b/.github/workflows/test_and_lint.yml
@@ -13,6 +13,8 @@ jobs:
       STORAGES_BUCKET_NAME: ${{ secrets.STORAGES_BUCKET_NAME }}
       STORAGES_PATH: ${{ secrets.STORAGES_PATH }}
       STORAGES_BACKEND: ${{ secrets.STORAGES_BACKEND }}
+      STORAGES_GOOGLE_CLOUD_CREDENTIALS: ${{ secrets.STORAGES_GOOGLE_CLOUD_CREDENTIALS }}
+      STORAGES_GOOGLE_CLOUD_BUCKET_NAME: ${{ secrets.STORAGES_GOOGLE_CLOUD_BUCKET_NAME }}
     strategy:
       matrix:
         python-version: [3.6, 3.7, 3.8, 3.9]
@@ -25,7 +27,8 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          python -m pip install mypy flake8 pytest pytest-cov pytest-xdist boto3
+          python -m pip install mypy flake8 pytest pytest-cov pytest-xdist boto3 \
+          google-cloud-storage
       - name: Lint with flake8
         run: |
           # stop the build if there are Python syntax errors or undefined names
diff --git a/tests/test_google_cloud_storage.py b/tests/test_google_cloud_storage.py
new file mode 100644
index 0000000..1c4043d
--- /dev/null
+++ b/tests/test_google_cloud_storage.py
@@ -0,0 +1,106 @@
+from datetime import datetime, timedelta
+from os import environ
+from unittest import TestCase
+from uuid import uuid4
+
+import pytest
+from google.api_core import exceptions
+from google.cloud.exceptions import NotFound
+
+from storages.backends.google_cloud import GoogleCloudStorage
+from storages.backends.base import Storage
+from storages.exceptions import ImproperlyConfiguredError
+
+
+class google_cloud_temp_file:
+    CONTENT = "Lorem ipsum dolor sit amet..."
+    CONTENT_BINARY = b"Binary lorem ipsum dolor sit amet..."
+
+    def __init__(self, storage: Storage, binary: bool = False):
+        self._storage = storage
+        self._binary = binary
+        self._file_name = str(uuid4())
+
+    def __enter__(self):
+        self._storage.write(
+            name=self._file_name,
+            content=self.CONTENT_BINARY if self._binary else self.CONTENT,
+            mode="xb" if self._binary else "x",
+        )
+        return self._file_name
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        try:
+            self._storage.delete(self._file_name)
+        except exceptions.NotFound:
+            pass
+
+
+class TestGoogleCloudStorage(TestCase):
+    @pytest.fixture(autouse=True)
+    def init_storage(self, tmpdir):
+        self._storage = GoogleCloudStorage(
+            google_cloud_credentials=environ.get("STORAGES_GOOGLE_CLOUD_CREDENTIALS"),
+            google_cloud_bucket_name=environ.get("STORAGES_GOOGLE_CLOUD_BUCKET_NAME"),
+        )
+
+    def test_improper_initialization(self):
+        with pytest.raises(ImproperlyConfiguredError):
+            GoogleCloudStorage(
+                google_cloud_credentials="", google_cloud_bucket_name=""
+            )
+        with pytest.raises(ImproperlyConfiguredError):
+            GoogleCloudStorage(
+                google_cloud_credentials="base64_data",
+                google_cloud_bucket_name=""
+            )
+        with pytest.raises(ImproperlyConfiguredError):
+            GoogleCloudStorage(
+                google_cloud_credentials="",
+                google_cloud_bucket_name="some_bucket"
+            )
+
+    def test_file_not_exists(self):
+        assert not self._storage.exists("some_non_existent.file")
+
+    def test_file_exists_upon_writing(self):
+        with google_cloud_temp_file(storage=self._storage) as temp_file:
+            assert self._storage.exists(temp_file)
+
+    def test_file_contains_binary_written_data(self):
+        with google_cloud_temp_file(storage=self._storage, binary=True) as temp_file:
+            assert (
+                self._storage.read(temp_file, mode="rb")
+                == google_cloud_temp_file.CONTENT_BINARY
+            )
+
+    def test_file_does_not_exist_upon_writing_and_deletion(self):
+        with google_cloud_temp_file(storage=self._storage) as temp_file:
+            self._storage.delete(temp_file)
+            assert not self._storage.exists(temp_file)
+
+    def test_file_size_matches_content_size(self):
+        with google_cloud_temp_file(storage=self._storage) as temp_file:
+            assert self._storage.size(temp_file) == len(google_cloud_temp_file.CONTENT)
+
+    def test_file_creation_time(self):
+        with google_cloud_temp_file(storage=self._storage) as temp_file:
+            creation_time = self._storage.get_created_time(
+                temp_file
+            ).replace(tzinfo=None)
+            assert creation_time - datetime.now() < timedelta(seconds=10)
+
+    def test_file_creation_exception(self):
+        with pytest.raises(NotFound):
+            self._storage.get_created_time("not_existing_file.txt")
+
+    def test_file_access_time(self):
+        with pytest.raises(NotImplementedError):
+            self._storage.get_access_time("any_name.ext")
+
+    def test_file_modification_time(self):
+        with google_cloud_temp_file(storage=self._storage) as temp_file:
+            modification_time = self._storage.get_modified_time(
+                temp_file
+            ).replace(tzinfo=None)
+            assert modification_time - datetime.now() < timedelta(seconds=10)
diff --git a/tests/test_provider.py b/tests/test_provider.py
index f39ee64..ffe70eb 100644
--- a/tests/test_provider.py
+++ b/tests/test_provider.py
@@ -1,11 +1,11 @@
 import os
 import unittest.mock
-from typing import Type
 from unittest import TestCase
 
 import pytest
 
 from storages.backends.amazon_s3 import AmazonS3Storage
+from storages.backends.google_cloud import GoogleCloudStorage
 from storages.backends.base import Storage
 from storages.backends.file_system import FileSystemStorage
 from storages.exceptions import MissingEnvironmentVariableError
@@ -81,6 +81,7 @@ def test_missing_environment_variable(self):
 
 class TestDynamicStorageLoader(TestCase):
     _EXPECTED_RESULT = {
+        "storages.backends.google_cloud.GoogleCloudStorage": GoogleCloudStorage,
         "storages.backends.amazon_s3.AmazonS3Storage": AmazonS3Storage,
         "storages.backends.file_system.FileSystemStorage": FileSystemStorage,
     }
@@ -93,6 +94,7 @@ def test_loader(self):
 
 class TestStorageProvider(TestCase):
     _EXPECTED_RESULT = {
+        "storages.backends.google_cloud.GoogleCloudStorage": GoogleCloudStorage,
         "storages.backends.amazon_s3.AmazonS3Storage": AmazonS3Storage,
         "storages.backends.file_system.FileSystemStorage": FileSystemStorage,
     }