Repository: drivendataorg/cloudpathlib (python)

Issue:
Instantiate default client lazily
Hi!

I've refactored my code and moved from [s3path](https://github.com/liormizr/s3path) to `cloudpathlib` to support S3 and GCS backends. Everything works great except that clients are created in constructors, possibly when credentials are not yet available.

Here's an example:

```python
from cloudpathlib import S3Path
from dotenv import load_dotenv

UPLOADS_PATH = S3Path("s3://my-bucket/uploads")


def main():
    load_dotenv()
    UPLOADS_PATH.exists()  # <--- botocore.exceptions.NoCredentialsError: Unable to locate credentials


if __name__ == "__main__":
    main()
```

If I call `load_dotenv` before defining `UPLOADS_PATH` it works, but that's not a good practice.

I worked around this by making them functions:

```python
@cache
def UPLOADS_PATH() -> S3Path:
    return S3Path("s3://my-bucket/uploads")
```

but it is an extra boilerplate and also requires changes to the code.

Would it be possible to delay the instantiation of the client class, e.g. to the moment of the first call that actually requires it? I think this is the way it works in `s3path` since it worked for me for a very long time.

Pull request description:
Instantiate default client lazily
 - Turn `CloudPath.client` into a property
 - Get private `._client` if set
 - If not set, get default client and set that to `._client`
 - Add completeness check for dependencies to `CloudPath.__init__` in addition to client (seems ok since cheap) so those happen upstream

Closes #428


Patch:
diff --git a/HISTORY.md b/HISTORY.md
index c56f815..a21d821 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -6,6 +6,7 @@
 - Fix `CloudPath` cleanup via `CloudPath.__del__` when `Client` encounters an exception during initialization and does not create a `file_cache_mode` attribute. (Issue [#372](https://github.com/drivendataorg/cloudpathlib/issues/372), thanks to [@bryanwweber](https://github.com/bryanwweber)) 
 - Drop support for Python 3.7; pin minimal `boto3` version to Python 3.8+ versions. (PR [#407](https://github.com/drivendataorg/cloudpathlib/pull/407))
 - fix: use native `exists()` method in `GSClient`. (PR [#420](https://github.com/drivendataorg/cloudpathlib/pull/420))
+- Enhancement: lazy instantiation of default client (PR [#432](https://github.com/drivendataorg/cloudpathlib/issues/432), Issue [#428](https://github.com/drivendataorg/cloudpathlib/issues/428))
 
 ## v0.18.1 (2024-02-26)
 
diff --git a/cloudpathlib/cloudpath.py b/cloudpathlib/cloudpath.py
index 0c17a4e..afca5cb 100644
--- a/cloudpathlib/cloudpath.py
+++ b/cloudpathlib/cloudpath.py
@@ -214,8 +214,10 @@ class CloudPath(metaclass=CloudPathMeta):
         # handle if local file gets opened. must be set at the top of the method in case any code
         # below raises an exception, this prevents __del__ from raising an AttributeError
         self._handle: Optional[IO] = None
+        self._client: Optional["Client"] = None
 
         self.is_valid_cloudpath(cloud_path, raise_on_error=True)
+        self._cloud_meta.validate_completeness()
 
         # versions of the raw string that provide useful methods
         self._str = str(cloud_path)
@@ -225,27 +227,34 @@ class CloudPath(metaclass=CloudPathMeta):
         # setup client
         if client is None:
             if isinstance(cloud_path, CloudPath):
-                client = cloud_path.client
-            else:
-                client = self._cloud_meta.client_class.get_default_client()
-        if not isinstance(client, self._cloud_meta.client_class):
+                self._client = cloud_path.client
+        else:
+            self._client = client
+
+        if client is not None and not isinstance(client, self._cloud_meta.client_class):
             raise ClientMismatchError(
                 f"Client of type [{client.__class__}] is not valid for cloud path of type "
                 f"[{self.__class__}]; must be instance of [{self._cloud_meta.client_class}], or "
                 f"None to use default client for this cloud path class."
             )
-        self.client: Client = client
 
         # track if local has been written to, if so it may need to be uploaded
         self._dirty = False
 
+    @property
+    def client(self):
+        if getattr(self, "_client", None) is None:
+            self._client = self._cloud_meta.client_class.get_default_client()
+
+        return self._client
+
     def __del__(self) -> None:
         # make sure that file handle to local path is closed
         if self._handle is not None:
             self._handle.close()
 
         # ensure file removed from cache when cloudpath object deleted
-        client = getattr(self, "client", None)
+        client = getattr(self, "_client", None)
         if getattr(client, "file_cache_mode", None) == FileCacheMode.cloudpath_object:
             self.clear_cache()
 
@@ -253,13 +262,11 @@ class CloudPath(metaclass=CloudPathMeta):
         state = self.__dict__.copy()
 
         # don't pickle client
-        del state["client"]
+        del state["_client"]
 
         return state
 
     def __setstate__(self, state: Dict[str, Any]) -> None:
-        client = self._cloud_meta.client_class.get_default_client()
-        state["client"] = client
         self.__dict__.update(state)
 
     @property


Test patch:
diff --git a/tests/test_cloudpath_instantiation.py b/tests/test_cloudpath_instantiation.py
index fe0ca3f..b625a47 100644
--- a/tests/test_cloudpath_instantiation.py
+++ b/tests/test_cloudpath_instantiation.py
@@ -59,6 +59,13 @@ def test_instantiation(rig, path):
         assert str(p._path) == expected.split(":/", 1)[-1]
 
 
+def test_default_client_lazy(rig):
+    cp = rig.path_class(rig.cloud_prefix + "testing/file.txt")
+    assert cp._client is None
+    assert cp.client is not None
+    assert cp._client is not None
+
+
 def test_instantiation_errors(rig):
     with pytest.raises(TypeError):
         rig.path_class()