Repository: leshchenko1979/perscache (language: python)

Issue:
GCP Storage



Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 09312d2..89d2eab 100644
--- a/README.md
+++ b/README.md
@@ -1,302 +1,328 @@
-# perscache
-
-![](https://github.com/leshchenko1979/perscache/workflows/build/badge.svg)
-[![codecov](https://codecov.io/gh/leshchenko1979/perscache/branch/master/graph/badge.svg?token=IVIIFN8NDU)](https://codecov.io/gh/leshchenko1979/perscache)
-![](https://img.shields.io/pypi/dm/perscache.svg)
-
-
-An easy to use decorator for persistent memoization: like `functools.lrucache`, but results persist between runs and can be stored in any format to any storage.
-
-- [Use cases](#use-cases)
-- [Features](#features)
-- [Getting started](#getting-started)
-- [Make your own serialization and storage back-ends](#make-your-own-serialization-and-storage-backends)
-- [API Reference](#api-reference)
-
-
-## Use cases
-- Cache the results of a function that uses a lot of resources: runs for a long time, consumes a lot of traffic, uses up paid API calls etc.
-- Speed up retreival of data that doesn't change often.
-- Inspect the results of a decorated function while debugging.
-
-## Features
-### Caching
-- Easy to swap out the cache configuration when switching environments.
-
-- Async functions supported.
-
-- Time-to-live (TTL) support - automatically invalidate cache entries after a certain time.
-
-- Automatic cache invalidation when the decorated function arguments or code have been changed.
-
-- You can ignore changes in certain arguments of the decorated function.
-
-### Serialization and storage
-- Various serialization formats: JSON, YAML, pickle, Parquet, CSV etc.
-
-- Various storage backends:
-    - local disk (_implemented_) or
-    - cloud storage (_to be implemented soon_).
-
-- Serialization and storage are separated into two different classes, so that you can mix various serialization formats and storage back-ends as you like - JSON to local storage, Pickle to AWS, Parquet to Google Cloud Storage etc.
-
-- You can set default serialization format and storage backend and then change them on a per-function basis.
-
-- You can easily add new serialization formats and storage back-ends.
-
-- Local storage is file-based, so you can use human-readable serialization (JSON, YAML, CSV) and inspect cached results.
-
-- Automatic cleanup: least recently used results can be removed from storage when the total storage size exceeds a given threshold.
-
-## Getting started
-### Installation
-```bash
-pip install perscache
-```
-
-### Basic usage
-```python
-from perscache import Cache
-
-cache = Cache()
-
-counter = 0
-
-@cache
-def get_data():
-    print("Fetching data...")
-
-    global counter
-    counter += 1
-
-    return "abc"
-
-print(get_data())  # the function is called
-# Fetching data...
-# abc
-
-print(get_data())  # the cache is used
-# abc
-
-print(counter)  # the function was called only once
-# 1
-```
-
-### Changing parameters or the code of the function invalidates the cache
-```python
-@cache
-def get_data(key):
-    print("The function has been called...")
-    return key
-
-print(get_data("abc"))  # the function has been called
-# The function has been called...
-# abc
-
-print(get_data("fgh"))  # the function has been called again
-# The function has been called...
-# fgh
-
-print(get_data("abc"))  # using the cache
-# abc
-
-@cache
-def get_data(key):
-    print("This function has been changed...")
-    return key
-
-print(get_data("abc"))  # the function has been called again
-# This function has been changed...
-# abc
-
-```
-### Setting the expiry time of the cache
-```python
-import datetime as dt
-
-@cache(ttl=dt.timedelta(days=1))
-def get_data():
-    """This function will be cached for 1 day
-    and called again after this period expires."""
-    ...
-```
-### Ignoring certain arguments
-By specifying the arguments that should be ignored, you can still use the cache even in the values of these arguments have changed.
-```python
-@cache(ignore="ignore_this")
-def get_data(key, ignore_this):
-    print("The function has been called...")
-    return key
-
-print(get_data("abc", "ignore_1"))  # the function has been called
-# The function has been called...
-# abc
-
-# using the cache although the the second argument is different
-print(get_data("abc", "ignore_2"))
-# abc
-```
-
-### Changing the default serialization format and storage backend
-```python
-# set up serialization format and storage backend
-cache = Cache(serializer=JSONSerializer(), storage=GCPStorage("bucket"))
-
-...
-
-# change the default serialization format
-@cache(serialization=PickleSerializer())
-def get_data(key):
-    ...
-```
-
-### Alternating cache settings depending on the environment
-```python
-import os
-
-from perscache import Cache, NoCache
-from perscache.storage import LocalFileStorage
-
-if os.environ.get["DEBUG"]:
-    cache = NoCache()
-else:
-    cache_location = (
-        "gs://bucket/folder"
-        if os.environ.get["GOOGLE_PROJECT_NAME"]
-        else cache_location = "/tmp/cache"
-    )
-    cache = LocalFileStorage(location=cache_location)
-
-@cache
-def function():
-    ...
-```
-### Inspecting cached results
-When using `LocalFileStorage(location=...)`, the files are put into the directory specified by the `location` parameter.
-
-The files are named like `<function_name>-<hash>.<serializer_extension>`, e.g. `get_data-9bf10a401d3d785317b2b35bcb5be1f2.json`.
-
-### Automatic cleanup
-When using `LocalFileStorage(max_size=...)`, the least recently used cache entries are automatically removed to keep the total cache size with the `max_size` limit.
-
-## Make your own serialization and storage backends
-### Serializers
-Although you can use the standard `CloudPickleSerializer()` for almost any type of data, sometimes you want to inspect the results of a decorated function by lookin into the cache files. This requires the data to be serialized in a human-readable format. But the included human-readable serializers (`JSONSerializer()`, `YAMLSerializer()`, `CSVSerializer()`) sometimes cannot process complex objects.
-
->To see which serializers are compatible with which data types, see the [compatibility.py](/perscache/compatibility.py) file.
-
-
-That's when making your own serializer comes in handy.
-
-To do this, you should:
-1. Derive your own serialization classe from the abstract `Serializer` class and override the abstract methods. You should also provide the `extension` class variable that specifies the file extension.
-2. Use your class with the `Cache` class.
-
-```python
-class MySerializer(Serializer):
-
-    extension = "data"
-
-    def dumps(self, data: Any) -> bytes:
-        ...
-
-    def loads(self, data: bytes) -> Any:
-        ...
-
-cache = Cache(serializer=MySerializer())
-```
-#### Serializer factory
-You can also use the `perscache.serializers.make_serializer()` function to create a serializer for a given data type.
-```python
-import pyrogram
-from perscache.serializers import make_serializer
-
-PyrogramSerializer = make_serializer(
-    "PyrogramSerializer",
-    "pyro",
-    dumps_fn = lambda data: str(data).encode("utf-8"),
-    loads_fn = lambda data: eval(data.decode("utf-8")),
-)
-
-cache = Cache(serializer=PyrogramSerializer())
-
-@cache
-async def some_pyrogram_func() -> pyrogram.Message:
-    ...
-```
-### Storage back-ends
-Making a custom storage backed is similar:
-```python
-class MyStorage(Storage):
-    def read(self, path, deadline: datetime.datetime) -> bytes:
-        """Read the file at the given path and return its contents as bytes.
-        If the file does not exist, raise FileNotFoundError. If the file is
-        older than the given deadline, raise CacheExpired.
-        """
-        ...
-
-    def write(self, path, data: bytes) -> None:
-        """Write the file at the given path."""
-        ...
-
-cache = Cache(storage=MyStorage())
-```
-
-## API Reference
-### class `Cache()`
-#### Parameters
-- `serializer (perscache.serializers.Serializer)`: a serializer class to use for cinverting stored data. Defaults to `perscache.serlializers.PickleSerializer`.
-
-- `storage (perscache.storage.Storage)`: a storage back-end used to save and load data. Defaults to `perscache.storage.LocalFileStorage`.
-
-#### decorator `Cache().__call__()`
-Tries to find a cached result of the decorated function in persistent storage. Returns the saved result if it was found, or calls the decorated function and caches its result.
-
-The cache will be invalidated if the function code, its argument values or the cache serializer have been changed.
-##### Arguments
-- `ignore (str | Iterable[str])`: arguments of the decorated function that will not be used in making the cache key. In other words, changes in these arguments will not invalidate the cache. Defaults to `None`.
-
-- `serializer (perscache.serializers.Serializer)`: Overrides the default `Cache()` serializer. Defaults to `None`.
-
-- `storage (perscache.storage.Storage)`: Overrides the default `Cache()` storage. Defaults to `None`.
-
-- `ttl (datetime.timedelta)`: The time-to-live of the cache. If `None`, the cache never exprires. Defaults to `None`.
-
-### class `NoCache()`
-This class has no parameters. It is useful to [alternate cache behaviour depending on the environment](#alternating-cache-settings-depending-on-the-environment).
-#### decorator `NoCache().__call__()`
-The underlying function will be called every time the decorated function has been called and no caching will take place.
-
-This decorator will ignore any parameters it has been given.
-### Serializers
-Serializers are imported from the `perscache.serializers` module.
-
-See also [how to make your own serializer](#make-your-own-serialization-and-storage-backends).
-#### class `perscache.serializers.JSONSerializer`
-Uses the `json` module.
-#### class `perscache.serializers.YAMLSerializer`
-Uses the `yaml` module.
-#### class `perscache.serializers.PickleSerializer`
-Uses the `pickle` module.
-#### class `perscache.serializers.CloudPickleSerializer`
-Uses the `cloudpickle` module. It's the most capable serializer of all, able to process most of the data types. It's the default serializer for the `Cache` class.
-#### class `perscache.serializers.CSVSerializer`
-Uses the `pandas` module. Processes `pandas.DataFrame` objects.
-#### class `perscache.serializers.ParquetSerializer`
-Uses the `pyarrow` module. Processes `pandas.DataFrame` objects.
-##### Parameters
-- `compression (str)`: compression used by `pyarrow` to save the data. Defaults to `"brotli"`.
-
-### Storage back-ends
-Storage back-ends are imported from the `perscache.serializers` module.
-
-See also [how to make your own storage back-end](#make-your-own-serialization-and-storage-backends).
-
-#### class `perscache.storage.LocalFileStorage`
-Keeps cache entries in separate files in a file system directory.
-
-This is the default storage class used by `Cache`.
-##### Parameters
-- `location (str)`: a directory to store the cache files. Defaults to `".cache"`.
-
-- `max_size (int)`: the maximum size for the cache. If set, then, before a new cache entry is written, the future size of the directory is calculated and the least recently used cache entries are removed. If `None`, the cache size grows indefinitely. Defaults to `None`.
+# perscache
+
+![](https://github.com/leshchenko1979/perscache/workflows/build/badge.svg)
+[![codecov](https://codecov.io/gh/leshchenko1979/perscache/branch/master/graph/badge.svg?token=IVIIFN8NDU)](https://codecov.io/gh/leshchenko1979/perscache)
+![](https://img.shields.io/pypi/dm/perscache.svg)
+
+
+An easy to use decorator for persistent memoization: like `functools.lrucache`, but results persist between runs and can be stored in any format to any storage.
+
+- [Use cases](#use-cases)
+- [Features](#features)
+- [Getting started](#getting-started)
+- [Make your own serialization and storage back-ends](#make-your-own-serialization-and-storage-backends)
+- [API Reference](#api-reference)
+
+
+## Use cases
+- Cache the results of a function that uses a lot of resources: runs for a long time, consumes a lot of traffic, uses up paid API calls etc.
+- Speed up retreival of data that doesn't change often.
+- Inspect the results of a decorated function while debugging.
+
+## Features
+### Caching
+- Easy to swap out the cache configuration when switching environments.
+
+- Async functions supported.
+
+- Time-to-live (TTL) support - automatically invalidate cache entries after a certain time.
+
+- Automatic cache invalidation when the decorated function arguments or code have been changed.
+
+- You can ignore changes in certain arguments of the decorated function.
+
+### Serialization and storage
+- Various serialization formats: JSON, YAML, pickle, Parquet, CSV etc.
+
+- Various storage backends: local disk, GCS (Google Cloud Storage) and others to be implemented soon (S3 #13, Azure Blob Storage #14).
+
+- Serialization and storage are separated into two different classes, so that you can mix various serialization formats and storage back-ends as you like - JSON to local storage, Pickle to AWS, Parquet to Google Cloud Storage etc.
+
+- You can set default serialization format and storage backend and then change them on a per-function basis.
+
+- You can easily add new serialization formats and storage back-ends.
+
+- Local storage is file-based, so you can use human-readable serialization (JSON, YAML, CSV) and inspect cached results.
+
+- Automatic cleanup: least recently used results can be removed from storage when the total storage size exceeds a given threshold.
+
+## Getting started
+### Installation
+```bash
+pip install perscache
+```
+
+### Basic usage
+```python
+from perscache import Cache
+
+cache = Cache()
+
+counter = 0
+
+@cache
+def get_data():
+    print("Fetching data...")
+
+    global counter
+    counter += 1
+
+    return "abc"
+
+print(get_data())  # the function is called
+# Fetching data...
+# abc
+
+print(get_data())  # the cache is used
+# abc
+
+print(counter)  # the function was called only once
+# 1
+```
+
+### Changing parameters or the code of the function invalidates the cache
+```python
+@cache
+def get_data(key):
+    print("The function has been called...")
+    return key
+
+print(get_data("abc"))  # the function has been called
+# The function has been called...
+# abc
+
+print(get_data("fgh"))  # the function has been called again
+# The function has been called...
+# fgh
+
+print(get_data("abc"))  # using the cache
+# abc
+
+@cache
+def get_data(key):
+    print("This function has been changed...")
+    return key
+
+print(get_data("abc"))  # the function has been called again
+# This function has been changed...
+# abc
+
+```
+### Setting the expiry time of the cache
+```python
+import datetime as dt
+
+@cache(ttl=dt.timedelta(days=1))
+def get_data():
+    """This function will be cached for 1 day
+    and called again after this period expires."""
+    ...
+```
+### Ignoring certain arguments
+By specifying the arguments that should be ignored, you can still use the cache even in the values of these arguments have changed.
+```python
+@cache(ignore="ignore_this")
+def get_data(key, ignore_this):
+    print("The function has been called...")
+    return key
+
+print(get_data("abc", "ignore_1"))  # the function has been called
+# The function has been called...
+# abc
+
+# using the cache although the the second argument is different
+print(get_data("abc", "ignore_2"))
+# abc
+```
+
+### Changing the default serialization format and storage backend
+```python
+# set up serialization format and storage backend
+cache = Cache(serializer=JSONSerializer(), storage=GCPStorage("bucket"))
+
+...
+
+# change the default serialization format
+@cache(serialization=PickleSerializer())
+def get_data(key):
+    ...
+```
+
+### Alternating cache settings depending on the environment
+```python
+import os
+
+from perscache import Cache, NoCache
+from perscache.storage import LocalFileStorage
+
+if os.environ.get["DEBUG"]:
+    cache = NoCache()  # turn off caching in debug mode
+else:
+    cache = (
+        GoogleCloudStorage("/bucket/folder")
+        if os.environ.get["GOOGLE_PROJECT_NAME"]  # if running in the cloud
+        else LocalFileStorage()
+    )
+
+@cache
+def function():
+    ...
+```
+### Inspecting cached results
+Use human-readable serialization (`JSONSerializer`, `YAMLSerializer`, `CSVSerializer`) and a file storage (`LocalFileStorage`, `GoogleCloudStorage`) to inspect cached results.
+
+### Automatic cleanup
+When using `LocalFileStorage(max_size=...)` or `GoogleCloudStorage(max_size=...)` , the least recently used cache entries are automatically removed to keep the total cache size with the `max_size` limit.
+
+## Make your own serialization and storage backends
+### Serializers
+Although you can use the standard `CloudPickleSerializer()` for almost any type of data, sometimes you want to inspect the results of a decorated function by lookin into the cache files. This requires the data to be serialized in a human-readable format. But the included human-readable serializers (`JSONSerializer()`, `YAMLSerializer()`, `CSVSerializer()`) sometimes cannot process complex objects.
+
+>To see which serializers are compatible with which data types, see the [compatibility.py](/perscache/compatibility.py) file.
+
+
+That's when making your own serializer comes in handy.
+
+To do this, you should:
+1. Derive your own serialization classe from the abstract `Serializer` class and override the abstract methods. You should also provide the `extension` class variable that specifies the file extension.
+2. Use your class with the `Cache` class.
+
+```python
+class MySerializer(Serializer):
+
+    extension = "data"
+
+    def dumps(self, data: Any) -> bytes:
+        ...
+
+    def loads(self, data: bytes) -> Any:
+        ...
+
+cache = Cache(serializer=MySerializer())
+```
+#### Serializer factory
+You can also use the `perscache.serializers.make_serializer()` function to create a serializer for a given data type.
+```python
+import pyrogram
+from perscache.serializers import make_serializer
+
+PyrogramSerializer = make_serializer(
+    "PyrogramSerializer",
+    "pyro",
+    dumps_fn = lambda data: str(data).encode("utf-8"),
+    loads_fn = lambda data: eval(data.decode("utf-8")),
+)
+
+cache = Cache(serializer=PyrogramSerializer())
+
+@cache
+async def some_pyrogram_func() -> pyrogram.Message:
+    ...
+```
+### Storage back-ends
+Making a custom storage backed is similar:
+```python
+class MyStorage(Storage):
+    def read(self, path, deadline: datetime.datetime) -> bytes:
+        """Read the file at the given path and return its contents as bytes.
+        If the file does not exist, raise FileNotFoundError. If the file is
+        older than the given deadline, raise CacheExpired.
+        """
+        ...
+
+    def write(self, path, data: bytes) -> None:
+        """Write the file at the given path."""
+        ...
+
+cache = Cache(storage=MyStorage())
+```
+You can also derive your storage class from `perscache.storage.FileStorage` if you are building a filesystem-based storage back-end. Refer to the [storage.py](/perscache/storage.py#FileStorage) file for more information.
+
+## API Reference
+### class `Cache()`
+#### Parameters
+- `serializer (perscache.serializers.Serializer)`: a serializer class to use for cinverting stored data. Defaults to `perscache.serlializers.PickleSerializer`.
+
+- `storage (perscache.storage.Storage)`: a storage back-end used to save and load data. Defaults to `perscache.storage.LocalFileStorage`.
+
+#### decorator `Cache().__call__()`
+Tries to find a cached result of the decorated function in persistent storage. Returns the saved result if it was found, or calls the decorated function and caches its result.
+
+The cache will be invalidated if the function code, its argument values or the cache serializer have been changed.
+##### Arguments
+- `ignore (str | Iterable[str])`: arguments of the decorated function that will not be used in making the cache key. In other words, changes in these arguments will not invalidate the cache. Defaults to `None`.
+
+- `serializer (perscache.serializers.Serializer)`: Overrides the default `Cache()` serializer. Defaults to `None`.
+
+- `storage (perscache.storage.Storage)`: Overrides the default `Cache()` storage. Defaults to `None`.
+
+- `ttl (datetime.timedelta)`: The time-to-live of the cache. If `None`, the cache never exprires. Defaults to `None`.
+
+### class `NoCache()`
+This class has no parameters. It is useful to [alternate cache behaviour depending on the environment](#alternating-cache-settings-depending-on-the-environment).
+#### decorator `NoCache().__call__()`
+The underlying function will be called every time the decorated function has been called and no caching will take place.
+
+This decorator will ignore any parameters it has been given.
+### Serializers
+Serializers are imported from the `perscache.serializers` module.
+
+See also [how to make your own serializer](#make-your-own-serialization-and-storage-backends).
+#### class `perscache.serializers.JSONSerializer`
+Uses the `json` module.
+#### class `perscache.serializers.YAMLSerializer`
+Uses the `yaml` module.
+#### class `perscache.serializers.PickleSerializer`
+Uses the `pickle` module.
+#### class `perscache.serializers.CloudPickleSerializer`
+Uses the `cloudpickle` module. It's the most capable serializer of all, able to process most of the data types. It's the default serializer for the `Cache` class.
+#### class `perscache.serializers.CSVSerializer`
+Uses the `pandas` module. Processes `pandas.DataFrame` objects.
+#### class `perscache.serializers.ParquetSerializer`
+Uses the `pyarrow` module. Processes `pandas.DataFrame` objects.
+##### Parameters
+- `compression (str)`: compression used by `pyarrow` to save the data. Defaults to `"brotli"`.
+
+### Storage back-ends
+Storage back-ends are imported from the `perscache.serializers` module.
+
+See also [how to make your own storage back-end](#make-your-own-serialization-and-storage-backends).
+
+#### class `perscache.storage.LocalFileStorage`
+Keeps cache entries in separate files in a file system directory.
+
+This is the default storage class used by `Cache`.
+##### Parameters
+- `location (str)`: a directory to store the cache files. Defaults to `".cache"`.
+
+- `max_size (int)`: the maximum size for the cache. If set, then, before a new cache entry is written, the future size of the directory is calculated and the least recently used cache entries are removed. If `None`, the cache size grows indefinitely. Defaults to `None`.
+
+#### class `perscache.storage.GoogleCloudStorage`
+Keeps cache entries in separate files in a Google Cloud Storage Bucket.
+
+Relies on the [`gcsfs`](https://pypi.org/project/gcsfs/) module, which is not a part of the project dependencies and needs to to be installed by the user if he is to use this class.
+
+##### Parameters
+- `location (str)`: a directory to store the cache files. Defaults to `".cache"`.
+
+- `max_size (int)`: the maximum size for the cache. If set, then, before a new cache entry is written, the future size of the directory is calculated and the least recently used cache entries are removed. If `None`, the cache size grows indefinitely. Defaults to `None`.
+
+- `storage_options (dict)`: a dictionary of parameters to pass to the constructor of the `GSCFilesystem` class of the [`gcsfs`](https://pypi.org/project/gcsfs/) module (see the module documentation for more information). Defaults to `None`.
+
+```python
+# supposing gcsfs is installed
+
+from perscache import Cache
+from perscache.storage import GoogleCloudStorage
+
+cache = Cache(
+    storage=GoogleCloudStorage(
+        location="my-bucket/cache",
+        storage_options={"token": "my-token.json"}
+    )
+)
+
+@cache
+def some_func():
+    ...
+```
diff --git a/perscache/cache.py b/perscache/cache.py
index 5809841..e411ae5 100644
--- a/perscache/cache.py
+++ b/perscache/cache.py
@@ -1,230 +1,230 @@
-"""An easy to use decorator for persistent memoization.
-
-Like `functools.lrucache`, but results can be saved in any format to any storage.
-"""
-
-import datetime as dt
-import functools
-import hashlib
-import inspect
-from typing import Any, Callable, Iterable, Optional
-
-import cloudpickle
-from beartype import beartype
-from icontract import require
-
-from .serializers import CloudPickleSerializer, Serializer
-from .storage import CacheExpired, LocalFileStorage, Storage
-
-
-def hash_it(*data) -> str:
-    """Pickles and hashes all the data passed to it as args."""
-    result = hashlib.md5()
-
-    for datum in data:
-        result.update(cloudpickle.dumps(datum))
-
-    return result.hexdigest()
-
-
-def is_async(fn):
-    """Checks if the function is async."""
-    return inspect.iscoroutinefunction(fn) and not inspect.isgeneratorfunction(fn)
-
-
-class Cache:
-    """A cache that can be used to memoize functions."""
-
-    @beartype
-    def __init__(self, serializer: Serializer = None, storage: Storage = None):
-        """Initialize the cache.
-
-        Args:
-            serializer: The serializer to use. If not specified, CloudPickleSerializer is used.
-            storage: The storage to use. If not specified, LocalFileStorage is used.
-        """
-
-        self.serializer = serializer or CloudPickleSerializer()
-        self.storage = storage or LocalFileStorage()
-
-    def __repr__(self) -> str:
-        return f"<Cache(serializer={self.serializer}, storage={self.storage})>"
-
-    @beartype
-    @require(
-        lambda ttl: ttl is None or ttl > dt.timedelta(seconds=0),
-        "ttl must be positive.",
-    )
-    def __call__(
-        self,
-        fn: Optional[Callable] = None,
-        *,
-        ignore: Optional[Iterable[str]] = None,
-        serializer: Optional[Serializer] = None,
-        storage: Optional[Storage] = None,
-        ttl: Optional[dt.timedelta] = None,
-    ):
-        """Cache the value of the wrapped function.
-
-        Tries to find a cached result of the decorated function in persistent storage.
-        Returns the saved result if it was found, or calls the decorated function
-        and caches its result.
-
-        The cache will be invalidated if the function code, its argument values or
-        the cache serializer have been changed.
-
-        Args:
-            ignore: A list of argument names to ignore when hashing the function.
-            serializer: The serializer to use. If not specified, the default serializer is used.
-                    Defaults to None.
-            storage: The storage to use. If not specified, the default storage is used.
-                    Defaults to None.
-            ttl: The expiration time of the cache. If None, the cache will never expire.
-                    Defaults to None.
-        """
-
-        if isinstance(ignore, str):
-            ignore = [ignore]
-
-        wrapper = _CachedFunction(
-            self, ignore, serializer or self.serializer, storage or self.storage, ttl
-        )
-
-        # The decorator should work both with and without parentheses
-        return wrapper if fn is None else wrapper(fn)
-
-    cache = __call__  # Alias for backwards compatibility.
-
-    @staticmethod
-    def _get(
-        key: str, serializer: Serializer, storage: Storage, deadline: dt.datetime
-    ) -> Any:
-        data = storage.read(key, deadline)
-        return serializer.loads(data)
-
-    @staticmethod
-    def _set(key: str, value: Any, serializer: Serializer, storage: Storage) -> None:
-        data = serializer.dumps(value)
-        storage.write(key, data)
-
-    @staticmethod
-    def _get_key(
-        fn: Callable,
-        args: tuple,
-        kwargs: dict,
-        serializer: Serializer,
-        ignore: Iterable[str],
-    ) -> str:
-        """Get a cache key."""
-
-        # Remove ignored arguments from the arguments tuple and kwargs dict
-        arg_dict = inspect.signature(fn).bind(*args, **kwargs).arguments
-
-        if ignore is not None:
-            arg_dict = {k: v for k, v in arg_dict.items() if k not in ignore}
-
-        return hash_it(inspect.getsource(fn), type(serializer), arg_dict)
-
-    def _get_filename(self, fn: callable, key: str, serializer: Serializer) -> str:
-        return f"{fn.__name__}-{key}.{serializer.extension}"
-
-
-class NoCache:
-    """A class used to turn off caching.
-
-    Example:
-    ```
-    cache = NoCache() if os.environ["DEBUG"] else Cache()
-
-    @cache.cache
-    def function():
-        ...
-    ```
-    """
-
-    def __repr__(self) -> str:
-        return "<NoCache>"
-
-    @staticmethod
-    def __call__(*decorator_args, **decorator_kwargs):
-        """Will call the decorated function every time and
-        return its result without any caching.
-        """
-
-        def _decorator(fn):
-            @functools.wraps(fn)
-            def _non_async_wrapper(*args, **kwargs):
-                return fn(*args, **kwargs)
-
-            @functools.wraps(fn)
-            async def _async_wrapper(*args, **kwargs):
-                return await fn(*args, **kwargs)
-
-            return _async_wrapper if is_async(fn) else _non_async_wrapper
-
-        return _decorator
-
-    cache = __call__  # Alias for backwards compatibility.
-
-
-class _CachedFunction:
-    """An interal class used as a wrapper."""
-
-    @beartype
-    def __init__(
-        self,
-        cache: Cache,
-        ignore: Optional[Iterable[str]],
-        serializer: Serializer,
-        storage: Storage,
-        ttl: Optional[dt.timedelta],
-    ):
-        self.cache = cache
-        self.ignore = ignore
-        self.serializer = serializer
-        self.storage = storage
-        self.ttl = ttl
-
-    def __repr__(self) -> str:
-        return (
-            f"<CachedFunction(cache={self.cache}, ignore={self.ignore}, "
-            "serializer={self.serializer}, storage={self.storage}, ttl={self.ttl})>"
-        )
-
-    @require(
-        lambda self, fn: self.ignore is None
-        or all(x in inspect.signature(fn).parameters for x in self.ignore),
-        "Ignored parameters not found in the function signature.",
-    )
-    def __call__(self, fn: Callable) -> Callable:
-        """Return the correct wrapper."""
-
-        @functools.wraps(fn)
-        def _non_async_wrapper(*args, **kwargs):
-            key = self.cache._get_key(fn, args, kwargs, self.serializer, self.ignore)
-            key = self.cache._get_filename(fn, key, self.serializer)
-            try:
-                deadline = (
-                    dt.datetime.now(dt.timezone.utc) - self.ttl if self.ttl else None
-                )
-                return self.cache._get(key, self.serializer, self.storage, deadline)
-            except (FileNotFoundError, CacheExpired):
-                value = fn(*args, **kwargs)
-                self.cache._set(key, value, self.serializer, self.storage)
-                return value
-
-        @functools.wraps(fn)
-        async def _async_wrapper(*args, **kwargs):
-            key = self.cache._get_key(fn, args, kwargs, self.serializer, self.ignore)
-            key = self.cache._get_filename(fn, key, self.serializer)
-            try:
-                deadline = (
-                    dt.datetime.now(dt.timezone.utc) - self.ttl if self.ttl else None
-                )
-                return self.cache._get(key, self.serializer, self.storage, deadline)
-            except (FileNotFoundError, CacheExpired):
-                value = await fn(*args, **kwargs)
-                self.cache._set(key, value, self.serializer, self.storage)
-                return value
-
-        return _async_wrapper if is_async(fn) else _non_async_wrapper
+"""An easy to use decorator for persistent memoization.
+
+Like `functools.lrucache`, but results can be saved in any format to any storage.
+"""
+
+import datetime as dt
+import functools
+import hashlib
+import inspect
+from typing import Any, Callable, Iterable, Optional
+
+import cloudpickle
+from beartype import beartype
+from icontract import require
+
+from .serializers import CloudPickleSerializer, Serializer
+from .storage import CacheExpired, LocalFileStorage, Storage
+
+
+def hash_it(*data) -> str:
+    """Pickles and hashes all the data passed to it as args."""
+    result = hashlib.md5()
+
+    for datum in data:
+        result.update(cloudpickle.dumps(datum))
+
+    return result.hexdigest()
+
+
+def is_async(fn):
+    """Checks if the function is async."""
+    return inspect.iscoroutinefunction(fn) and not inspect.isgeneratorfunction(fn)
+
+
+class Cache:
+    """A cache that can be used to memoize functions."""
+
+    @beartype
+    def __init__(self, serializer: Serializer = None, storage: Storage = None):
+        """Initialize the cache.
+
+        Args:
+            serializer: The serializer to use. If not specified, CloudPickleSerializer is used.
+            storage: The storage to use. If not specified, LocalFileStorage is used.
+        """
+
+        self.serializer = serializer or CloudPickleSerializer()
+        self.storage = storage or LocalFileStorage()
+
+    def __repr__(self) -> str:
+        return f"<Cache(serializer={self.serializer}, storage={self.storage})>"
+
+    @beartype
+    @require(
+        lambda ttl: ttl is None or ttl > dt.timedelta(seconds=0),
+        "ttl must be positive.",
+    )
+    def __call__(
+        self,
+        fn: Optional[Callable] = None,
+        *,
+        ignore: Optional[Iterable[str]] = None,
+        serializer: Optional[Serializer] = None,
+        storage: Optional[Storage] = None,
+        ttl: Optional[dt.timedelta] = None,
+    ):
+        """Cache the value of the wrapped function.
+
+        Tries to find a cached result of the decorated function in persistent storage.
+        Returns the saved result if it was found, or calls the decorated function
+        and caches its result.
+
+        The cache will be invalidated if the function code, its argument values or
+        the cache serializer have been changed.
+
+        Args:
+            ignore: A list of argument names to ignore when hashing the function.
+            serializer: The serializer to use. If not specified, the default serializer is used.
+                    Defaults to None.
+            storage: The storage to use. If not specified, the default storage is used.
+                    Defaults to None.
+            ttl: The expiration time of the cache. If None, the cache will never expire.
+                    Defaults to None.
+        """
+
+        if isinstance(ignore, str):
+            ignore = [ignore]
+
+        wrapper = _CachedFunction(
+            self, ignore, serializer or self.serializer, storage or self.storage, ttl
+        )
+
+        # The decorator should work both with and without parentheses
+        return wrapper if fn is None else wrapper(fn)
+
+    cache = __call__  # Alias for backwards compatibility.
+
+    @staticmethod
+    def _get(
+        key: str, serializer: Serializer, storage: Storage, deadline: dt.datetime
+    ) -> Any:
+        data = storage.read(key, deadline)
+        return serializer.loads(data)
+
+    @staticmethod
+    def _set(key: str, value: Any, serializer: Serializer, storage: Storage) -> None:
+        data = serializer.dumps(value)
+        storage.write(key, data)
+
+    @staticmethod
+    def _get_key(
+        fn: Callable,
+        args: tuple,
+        kwargs: dict,
+        serializer: Serializer,
+        ignore: Iterable[str],
+    ) -> str:
+        """Get a cache key."""
+
+        # Remove ignored arguments from the arguments tuple and kwargs dict
+        arg_dict = inspect.signature(fn).bind(*args, **kwargs).arguments
+
+        if ignore is not None:
+            arg_dict = {k: v for k, v in arg_dict.items() if k not in ignore}
+
+        return hash_it(inspect.getsource(fn), type(serializer), arg_dict)
+
+    def _get_filename(self, fn: callable, key: str, serializer: Serializer) -> str:
+        return f"{fn.__name__}-{key}.{serializer.extension}"
+
+
+class NoCache:
+    """A class used to turn off caching.
+
+    Example:
+    ```
+    cache = NoCache() if os.environ["DEBUG"] else Cache()
+
+    @cache.cache
+    def function():
+        ...
+    ```
+    """
+
+    def __repr__(self) -> str:
+        return "<NoCache>"
+
+    @staticmethod
+    def __call__(*decorator_args, **decorator_kwargs):
+        """Will call the decorated function every time and
+        return its result without any caching.
+        """
+
+        def _decorator(fn):
+            @functools.wraps(fn)
+            def _non_async_wrapper(*args, **kwargs):
+                return fn(*args, **kwargs)
+
+            @functools.wraps(fn)
+            async def _async_wrapper(*args, **kwargs):
+                return await fn(*args, **kwargs)
+
+            return _async_wrapper if is_async(fn) else _non_async_wrapper
+
+        return _decorator
+
+    cache = __call__  # Alias for backwards compatibility.
+
+
+class _CachedFunction:
+    """An interal class used as a wrapper."""
+
+    @beartype
+    def __init__(
+        self,
+        cache: Cache,
+        ignore: Optional[Iterable[str]],
+        serializer: Serializer,
+        storage: Storage,
+        ttl: Optional[dt.timedelta],
+    ):
+        self.cache = cache
+        self.ignore = ignore
+        self.serializer = serializer
+        self.storage = storage
+        self.ttl = ttl
+
+    def __repr__(self) -> str:
+        return (
+            f"<CachedFunction(cache={self.cache}, ignore={self.ignore}, "
+            "serializer={self.serializer}, storage={self.storage}, ttl={self.ttl})>"
+        )
+
+    @require(
+        lambda self, fn: self.ignore is None
+        or all(x in inspect.signature(fn).parameters for x in self.ignore),
+        "Ignored parameters not found in the function signature.",
+    )
+    def __call__(self, fn: Callable) -> Callable:
+        """Return the correct wrapper."""
+
+        @functools.wraps(fn)
+        def _non_async_wrapper(*args, **kwargs):
+            key = self.cache._get_key(fn, args, kwargs, self.serializer, self.ignore)
+            key = self.cache._get_filename(fn, key, self.serializer)
+            try:
+                deadline = (
+                    dt.datetime.now(dt.timezone.utc) - self.ttl if self.ttl else None
+                )
+                return self.cache._get(key, self.serializer, self.storage, deadline)
+            except (FileNotFoundError, CacheExpired):
+                value = fn(*args, **kwargs)
+                self.cache._set(key, value, self.serializer, self.storage)
+                return value
+
+        @functools.wraps(fn)
+        async def _async_wrapper(*args, **kwargs):
+            key = self.cache._get_key(fn, args, kwargs, self.serializer, self.ignore)
+            key = self.cache._get_filename(fn, key, self.serializer)
+            try:
+                deadline = (
+                    dt.datetime.now(dt.timezone.utc) - self.ttl if self.ttl else None
+                )
+                return self.cache._get(key, self.serializer, self.storage, deadline)
+            except (FileNotFoundError, CacheExpired):
+                value = await fn(*args, **kwargs)
+                self.cache._set(key, value, self.serializer, self.storage)
+                return value
+
+        return _async_wrapper if is_async(fn) else _non_async_wrapper
diff --git a/perscache/storage.py b/perscache/storage.py
index 2c4e28d..6d2bf1d 100644
--- a/perscache/storage.py
+++ b/perscache/storage.py
@@ -1,7 +1,7 @@
 import datetime as dt
 from abc import ABC, abstractmethod
 from pathlib import Path
-from typing import Optional, Union
+from typing import Iterator, Optional, Union
 
 from beartype import beartype
 
@@ -25,7 +25,7 @@ def write(self, path: Union[str, Path], data: bytes) -> None:
         ...
 
 
-class LocalFileStorage(Storage):
+class FileStorage(Storage):
     @beartype
     def __init__(
         self,
@@ -36,32 +36,27 @@ def __init__(
         self.max_size = max_size
 
     def __repr__(self) -> str:
-        return (
-            f"<LocalFileStorage(location='{self.location}', max_size={self.max_size})>"
-        )
+        return f"<{self.__class__.__name__}(location={self.location}, max_size={self.max_size})>"
 
     def read(self, path: Union[str, Path], deadline: dt.datetime) -> bytes:
         final_path = self.location / path
 
-        if deadline is not None and final_path.stat().st_mtime < deadline.timestamp():
+        if deadline is not None and self.mtime(final_path) < deadline:
             raise CacheExpired
 
-        with open(self.location / path, "rb") as f:
-            return f.read()
+        return self.read_file(self.location / path)
 
     def write(self, path: Union[str, Path], data: bytes) -> None:
         final_path = self.location / path
 
-        if not final_path.parent.exists():
-            final_path.parent.mkdir(parents=True)
+        self.ensure_path(final_path.parent)
 
-        if self.max_size and self.location.stat().st_size + len(data) > self.max_size:
-            self.remove_least_recently_used(target_size=self.max_size)
+        if self.max_size and self.size(self.location) + len(data) > self.max_size:
+            self.delete_least_recently_used(target_size=self.max_size)
 
-        with open(final_path, "wb") as f:
-            f.write(data)
+        self.write_file(final_path, data)
 
-    def remove_least_recently_used(self, target_size: int) -> None:
+    def delete_least_recently_used(self, target_size: int) -> None:
         """Removes the least recently used file from the cache.
         The least recently used file is the one with the smallest last access time.
 
@@ -69,16 +64,135 @@ def remove_least_recently_used(self, target_size: int) -> None:
             target_size: The target size of the cache.
         """
         files = sorted(
-            self.location.iterdir(), key=lambda f: f.stat().st_atime, reverse=True
+            self.iterdir(self.location), key=lambda f: self.atime(f), reverse=True
         )
 
         # find the set of most recently accessed files whose total size
         # is smaller than the target size
         i, size = 0, 0
         while size < target_size and i < len(files):
-            size += files[i].stat().st_size
+            size += self.size(files[i])
             i += 1
 
         # remove remaining files
         for f in files[i - 1 :]:
-            f.unlink()
+            self.delete(f)
+
+    def clear(self) -> None:
+        for f in self.iterdir(self.location):
+            self.delete(f)
+        self.rmdir(self.location)
+
+    @abstractmethod
+    def read_file(self, path: Union[str, Path]) -> bytes:
+        ...
+
+    @abstractmethod
+    def write_file(self, path: Union[str, Path]) -> bytes:
+        ...
+
+    @abstractmethod
+    def ensure_path(self, path: Union[str, Path]) -> None:
+        ...
+
+    @abstractmethod
+    def iterdir(self, path: Union[str, Path]) -> Iterator[Path]:
+        ...
+
+    @abstractmethod
+    def rmdir(self, path: Union[str, Path]) -> None:
+        ...
+
+    @abstractmethod
+    def mtime(self, path: Union[str, Path]) -> dt.datetime:
+        ...
+
+    @abstractmethod
+    def atime(self, path: Union[str, Path]) -> dt.datetime:
+        ...
+
+    @abstractmethod
+    def size(self, path: Union[str, Path]) -> int:
+        ...
+
+    @abstractmethod
+    def delete(self, path: Union[str, Path]) -> None:
+        ...
+
+
+class LocalFileStorage(FileStorage):
+    def read_file(self, path: Union[str, Path]) -> bytes:
+        return path.read_bytes()
+
+    def write_file(self, path: Union[str, Path], data: bytes) -> None:
+        path.write_bytes(data)
+
+    def ensure_path(self, path: Union[str, Path]) -> None:
+        path.mkdir(parents=True, exist_ok=True)
+
+    def iterdir(self, path: Union[str, Path]) -> Iterator[Path]:
+        return path.iterdir()
+
+    def rmdir(self, path: Union[str, Path]) -> None:
+        path.rmdir()
+
+    def mtime(self, path: Union[str, Path]) -> dt.datetime:
+        return dt.datetime.fromtimestamp(path.stat().st_mtime, tz=dt.timezone.utc)
+
+    def atime(self, path: Union[str, Path]) -> dt.datetime:
+        return dt.datetime.fromtimestamp(path.stat().st_atime, tz=dt.timezone.utc)
+
+    def size(self, path: Union[str, Path]) -> int:
+        return path.stat().st_size
+
+    def delete(self, path: Union[str, Path]) -> None:
+        path.unlink()
+
+
+class GoogleCloudStorage(FileStorage):
+    def __init__(
+        self,
+        location: Optional[Union[str, Path]] = ".cache",
+        max_size: Optional[int] = None,
+        storage_options: Optional[dict] = None,
+    ) -> None:
+        super().__init__(location, max_size)
+
+        import gcsfs
+        self.fs = gcsfs.GCSFileSystem(**storage_options)
+
+    def read_file(self, path: Union[str, Path]) -> bytes:
+        with self.fs.open(path, "rb") as f:
+            return f.read()
+
+    def write_file(self, path: Union[str, Path], data: bytes) -> None:
+        with self.fs.open(path, "wb") as f:
+            f.write(data)
+
+    def ensure_path(self, path: Union[str, Path]) -> None:
+        if not self.fs.exists(path):
+            self.fs.makedirs(path, exist_ok=True)
+
+    def iterdir(self, path: Union[str, Path]) -> Iterator[Path]:
+        return self.fs.ls(path)
+
+    def rmdir(self, path: Union[str, Path]) -> None:
+        self.fs.rm(path)
+
+    def mtime(self, path: Union[str, Path]) -> dt.datetime:
+        mtime = self.fs.info(path)["updated"]
+        ts = dt.datetime.strptime(mtime.split(".")[0], "%Y-%m-%dT%H:%M:%S")
+        ts = ts.replace(tzinfo=dt.timezone.utc, microsecond=int(mtime[-4:-1]))
+        return ts
+
+    def atime(self, path: Union[str, Path]) -> dt.datetime:
+        return self.mtime(path)
+
+    def size(self, path: Union[str, Path]) -> int:
+        return self.fs.info(path)["size"]
+
+    def delete(self, path: Union[str, Path]) -> None:
+        self.fs.rm(path)
+
+    def clear(self):
+        self.fs.rm(self.location.as_posix())


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index ec7b793..3ca5a0f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,8 +1,8 @@
-import pytest
-from perscache import Cache
-from perscache.storage import LocalFileStorage
-
-
-@pytest.fixture
-def cache(tmp_path):
-    return Cache(storage=LocalFileStorage(tmp_path))
+import pytest
+from perscache import Cache
+from perscache.storage import LocalFileStorage
+
+
+@pytest.fixture
+def cache(tmp_path):
+    return Cache(storage=LocalFileStorage(tmp_path))
diff --git a/tests/test_change.py b/tests/test_change.py
index e3b6c5a..ad25e50 100644
--- a/tests/test_change.py
+++ b/tests/test_change.py
@@ -1,153 +1,153 @@
-from perscache import Cache
-from perscache.serializers import (
-    CloudPickleSerializer,
-    JSONSerializer,
-    PickleSerializer,
-)
-from perscache.storage import LocalFileStorage
-
-
-def test_arg_change(cache):
-
-    global_key = None
-
-    @cache()
-    def get_data(key):
-        nonlocal global_key
-        global_key = key
-        return key
-
-    get_data("abc")
-    assert global_key == "abc"
-
-    get_data("fgh")  # the function has been called again
-    assert global_key == "fgh"
-
-    get_data("abc")  # using the cache
-    assert global_key == "fgh"
-
-
-def test_body_change(cache: Cache):
-    @cache()
-    def get_data(key):
-        return key
-
-    hash1 = cache._get_key(get_data, (None,), {}, None, None)
-
-    @cache()
-    def get_data(key):
-        print("This function has been changed...")
-        return key
-
-    hash2 = cache._get_key(get_data, (None,), {}, None, None)
-
-    assert hash1 != hash2
-
-
-def test_serializer_change(cache: Cache):
-
-    counter = 0
-
-    @cache(serializer=PickleSerializer())
-    def get_data():
-        nonlocal counter
-        counter += 1
-        return "abc"
-
-    get_data()
-
-    # now, let's change the serializer
-
-    @cache(serializer=JSONSerializer())
-    def get_data():
-        nonlocal counter
-        counter += 1
-        return "abc"
-
-    get_data()  # cache invalidated, the function should be called again
-
-    assert counter == 2
-
-
-def test_ignore_args(cache):
-
-    counter = 0
-
-    @cache(ignore="ignore_this")
-    def get_data(key, ignore_this):
-        nonlocal counter
-        counter += 1
-        return key
-
-    get_data("abc", ignore_this="ignore_1")
-    assert counter == 1
-
-    # using the cache although the `ignore_this` arg has changed
-    get_data("abc", ignore_this="ignore_2")
-    assert counter == 1
-
-
-def test_ignore_positional_args(cache):
-
-    counter = 0
-
-    @cache(ignore="ignore_this")
-    def get_data(key, ignore_this):
-        nonlocal counter
-        counter += 1
-        return key
-
-    get_data("abc", "ignore_1")
-    assert counter == 1
-
-    # using the cache although the `ignore_this` arg has changed
-    get_data("abc", "ignore_2")
-    assert counter == 1
-
-
-def data():
-    SIZE = 100_000
-
-    # long string
-    data1 = ["A"] * SIZE
-    data2 = data1.copy()
-    data2[SIZE // 2] = ["B"]
-
-    yield data1, data2
-
-    # long list
-    data1 = "A" * SIZE
-    middle = SIZE // 2
-    data2 = f"{data1[:middle]}B{data1[middle:]}"
-
-    yield data1, data2
-
-    # object with no repr() function
-    class Data:
-        pass
-
-    data1, data2 = Data(), Data()
-    data1.a = 1
-    data2.b = 2
-
-    # object with a non-changing repr()
-    class Data:
-        def __repr__(self):
-            return "This never changes"
-
-    data1, data2 = Data(), Data()
-    data1.a = 1
-    data2.b = 2
-
-    yield data1, data2
-
-
-def test_hash(tmp_path):
-    cache = Cache(
-        serializer=CloudPickleSerializer(), storage=LocalFileStorage(tmp_path)
-    )
-    for data1, data2 in data():
-        assert data1 != data2
-        assert cache._get_key(
-            lambda x: None, (data1,), {}, CloudPickleSerializer(), None
-        ) != cache._get_key(lambda x: None, (data2,), {}, CloudPickleSerializer(), None)
+from perscache import Cache
+from perscache.serializers import (
+    CloudPickleSerializer,
+    JSONSerializer,
+    PickleSerializer,
+)
+from perscache.storage import LocalFileStorage
+
+
+def test_arg_change(cache):
+
+    global_key = None
+
+    @cache()
+    def get_data(key):
+        nonlocal global_key
+        global_key = key
+        return key
+
+    get_data("abc")
+    assert global_key == "abc"
+
+    get_data("fgh")  # the function has been called again
+    assert global_key == "fgh"
+
+    get_data("abc")  # using the cache
+    assert global_key == "fgh"
+
+
+def test_body_change(cache: Cache):
+    @cache()
+    def get_data(key):
+        return key
+
+    hash1 = cache._get_key(get_data, (None,), {}, None, None)
+
+    @cache()
+    def get_data(key):
+        print("This function has been changed...")
+        return key
+
+    hash2 = cache._get_key(get_data, (None,), {}, None, None)
+
+    assert hash1 != hash2
+
+
+def test_serializer_change(cache: Cache):
+
+    counter = 0
+
+    @cache(serializer=PickleSerializer())
+    def get_data():
+        nonlocal counter
+        counter += 1
+        return "abc"
+
+    get_data()
+
+    # now, let's change the serializer
+
+    @cache(serializer=JSONSerializer())
+    def get_data():
+        nonlocal counter
+        counter += 1
+        return "abc"
+
+    get_data()  # cache invalidated, the function should be called again
+
+    assert counter == 2
+
+
+def test_ignore_args(cache):
+
+    counter = 0
+
+    @cache(ignore="ignore_this")
+    def get_data(key, ignore_this):
+        nonlocal counter
+        counter += 1
+        return key
+
+    get_data("abc", ignore_this="ignore_1")
+    assert counter == 1
+
+    # using the cache although the `ignore_this` arg has changed
+    get_data("abc", ignore_this="ignore_2")
+    assert counter == 1
+
+
+def test_ignore_positional_args(cache):
+
+    counter = 0
+
+    @cache(ignore="ignore_this")
+    def get_data(key, ignore_this):
+        nonlocal counter
+        counter += 1
+        return key
+
+    get_data("abc", "ignore_1")
+    assert counter == 1
+
+    # using the cache although the `ignore_this` arg has changed
+    get_data("abc", "ignore_2")
+    assert counter == 1
+
+
+def data():
+    SIZE = 100_000
+
+    # long string
+    data1 = ["A"] * SIZE
+    data2 = data1.copy()
+    data2[SIZE // 2] = ["B"]
+
+    yield data1, data2
+
+    # long list
+    data1 = "A" * SIZE
+    middle = SIZE // 2
+    data2 = f"{data1[:middle]}B{data1[middle:]}"
+
+    yield data1, data2
+
+    # object with no repr() function
+    class Data:
+        pass
+
+    data1, data2 = Data(), Data()
+    data1.a = 1
+    data2.b = 2
+
+    # object with a non-changing repr()
+    class Data:
+        def __repr__(self):
+            return "This never changes"
+
+    data1, data2 = Data(), Data()
+    data1.a = 1
+    data2.b = 2
+
+    yield data1, data2
+
+
+def test_hash(tmp_path):
+    cache = Cache(
+        serializer=CloudPickleSerializer(), storage=LocalFileStorage(tmp_path)
+    )
+    for data1, data2 in data():
+        assert data1 != data2
+        assert cache._get_key(
+            lambda x: None, (data1,), {}, CloudPickleSerializer(), None
+        ) != cache._get_key(lambda x: None, (data2,), {}, CloudPickleSerializer(), None)
diff --git a/tests/test_main.py b/tests/test_main.py
index 8bb2aaa..c5d0491 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,149 +1,104 @@
-import datetime as dt
-import time
-
-import pytest
-from beartype.roar import BeartypeCallHintParamViolation
-from icontract import ViolationError
-from perscache import Cache
-from perscache.cache import NoCache
-from perscache.storage import LocalFileStorage
-
-
-def test_repr(cache):
-    assert "CloudPickleSerializer" in repr(cache.serializer)
-    assert "LocalFileStorage" in repr(cache.storage)
-    assert "Cache" in repr(cache)
-
-
-def test_basic(cache):
-
-    counter = 0
-
-    @cache()
-    def get_data():
-        nonlocal counter
-        counter += 1
-        return "abc"
-
-    get_data()
-    get_data()
-
-    assert counter == 1
-
-
-def test_alias(cache):
-
-    counter = 0
-
-    @cache.cache()
-    def get_data():
-        nonlocal counter
-        counter += 1
-        return "abc"
-
-    get_data()
-    get_data()
-
-    assert counter == 1
-
-
-def test_no_parens(cache):
-
-    counter = 0
-
-    @cache
-    def get_data():
-        nonlocal counter
-        counter += 1
-        return "abc"
-
-    get_data()
-    get_data()
-
-    assert counter == 1
-
-
-@pytest.mark.asyncio
-async def test_basic_async(cache):
-
-    counter = 0
-
-    @cache()
-    async def get_data():
-        nonlocal counter
-        counter += 1
-        return "abc"
-
-    await get_data()
-    await get_data()
-
-    assert counter == 1
-
-
-def test_no_cache():
-    counter = 0
-
-    cache = NoCache()
-
-    @cache()
-    def get_data():
-        nonlocal counter
-        counter += 1
-        return "abc"
-
-    get_data()
-    get_data()
-
-    assert counter == 2
-
-
-def test_typing(cache):
-    with pytest.raises(BeartypeCallHintParamViolation):
-        Cache("asd")
-
-    with pytest.raises(BeartypeCallHintParamViolation):
-
-        @cache(ttl=10)
-        def get_data():
-            ...
-
-    with pytest.raises(ViolationError):
-
-        @cache(ignore=["abc"])
-        def get_data():
-            ...
-
-    with pytest.raises(ViolationError):
-
-        @cache(ttl=dt.timedelta(days=-1))
-        def get_data():
-            ...
-
-
-def test_ttl(tmp_path):
-    cache = Cache(storage=LocalFileStorage(tmp_path))
-
-    counter = 0
-
-    @cache(ttl=dt.timedelta(seconds=0.1))
-    def get_data(key):
-        nonlocal counter
-        counter += 1
-        return key
-
-    get_data(1)
-    assert counter == 1
-
-    get_data(1)
-    assert counter == 1
-
-    get_data(2)
-    assert counter == 2
-
-    time.sleep(0.2)
-
-    get_data(1)
-    assert counter == 3
-
-    get_data(2)
-    assert counter == 4
+import datetime as dt
+import time
+
+import pytest
+from beartype.roar import BeartypeCallHintParamViolation
+from icontract import ViolationError
+from perscache import Cache
+from perscache.cache import NoCache
+from perscache.storage import LocalFileStorage
+
+
+def test_repr(cache):
+    assert "CloudPickleSerializer" in repr(cache.serializer)
+    assert "LocalFileStorage" in repr(cache.storage)
+    assert "Cache" in repr(cache)
+
+
+def test_alias(cache):
+
+    counter = 0
+
+    @cache.cache()
+    def get_data():
+        nonlocal counter
+        counter += 1
+        return "abc"
+
+    get_data()
+    get_data()
+
+    assert counter == 1
+
+
+def test_no_parens(cache):
+
+    counter = 0
+
+    @cache
+    def get_data():
+        nonlocal counter
+        counter += 1
+        return "abc"
+
+    get_data()
+    get_data()
+
+    assert counter == 1
+
+
+@pytest.mark.asyncio
+async def test_basic_async(cache):
+
+    counter = 0
+
+    @cache()
+    async def get_data():
+        nonlocal counter
+        counter += 1
+        return "abc"
+
+    await get_data()
+    await get_data()
+
+    assert counter == 1
+
+
+def test_no_cache():
+    counter = 0
+
+    cache = NoCache()
+
+    @cache()
+    def get_data():
+        nonlocal counter
+        counter += 1
+        return "abc"
+
+    get_data()
+    get_data()
+
+    assert counter == 2
+
+
+def test_typing(cache):
+    with pytest.raises(BeartypeCallHintParamViolation):
+        Cache("asd")
+
+    with pytest.raises(BeartypeCallHintParamViolation):
+
+        @cache(ttl=10)
+        def get_data():
+            ...
+
+    with pytest.raises(ViolationError):
+
+        @cache(ignore=["abc"])
+        def get_data():
+            ...
+
+    with pytest.raises(ViolationError):
+
+        @cache(ttl=dt.timedelta(days=-1))
+        def get_data():
+            ...
diff --git a/tests/test_storage.py b/tests/test_storage.py
index d46dd8b..93c7fc0 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -1,18 +1,56 @@
+import datetime as dt
+import os
+import time
 from pathlib import Path
 
 import pytest
-
 from perscache import Cache
-from perscache.storage import LocalFileStorage
+from perscache.storage import GoogleCloudStorage, LocalFileStorage
+
+caches = ["local"]
+
+if os.environ.get("GOOGLE_TOKEN") and os.environ.get("GOOGLE_BUCKET"):
+    caches.append("gcs")
+
+
+@pytest.fixture(params=caches)
+def cache(request):
+    if request.param == "local":
+        storage = LocalFileStorage(Path("/tmp/perscache_test_cache"))
+    elif request.param == "gcs":
+        storage = GoogleCloudStorage(
+            Path(os.environ["GOOGLE_BUCKET"]) / "perscache_test_cache",
+            storage_options={"token": os.environ["GOOGLE_TOKEN"]},
+        )
+    yield Cache(storage=storage)
+    storage.clear()
+
+
+def test_basic(cache):
+
+    counter = 0
+
+    @cache()
+    def get_data():
+        nonlocal counter
+        counter += 1
+        return "abc"
+
+    get_data()
+    get_data()
+
+    assert counter == 1
 
 
 @pytest.mark.parametrize("max_size", [0, 1000, 10_000])
-def test_max_size(tmp_path, max_size):
-    path: Path = tmp_path
+def test_max_size(cache, max_size):
+    path: Path = cache.storage.location
 
-    cache = Cache(storage=LocalFileStorage(path, max_size=max_size))
+    cache.storage.max_size = max_size
+
+    cache.storage.ensure_path(path)
+    initial_size = cache.storage.size(path)
 
-    initial_size = path.stat().st_size
     counter = 0
 
     @cache()
@@ -26,15 +64,38 @@ def get_data(key):
     get_data(3)
     get_data(4)
 
-    assert path.stat().st_size <= max(max_size, initial_size)
+    assert cache.storage.size(path) <= max(max_size, initial_size)
 
     assert counter == 4
 
-def test_no_path(tmp_path):
-    path: Path = tmp_path / "new_path"
-    assert not path.exists()
 
-    cache = Cache(storage=LocalFileStorage(path))
-    cache._set("a", 1, cache.serializer, cache.storage)
+def test_ttl(cache):
+    counter = 0
 
-    assert path.exists()
+    if isinstance(cache.storage, LocalFileStorage):
+        ttl_sec, delay = 0.1, 0.2
+    else:
+        ttl_sec, delay = 3, 5  # setting safe timeouts for GCS
+
+    @cache(ttl=dt.timedelta(seconds=ttl_sec))
+    def get_data(key):
+        nonlocal counter
+        counter += 1
+        return key
+
+    get_data(1)
+    assert counter == 1
+
+    get_data(1)
+    assert counter == 1
+
+    get_data(2)
+    assert counter == 2
+
+    time.sleep(delay)
+
+    get_data(1)
+    assert counter == 3
+
+    get_data(2)
+    assert counter == 4