Repository: drivendataorg/cloudpathlib (python)

Issue:
Multiple pickle roundtrip serializations cause KeyError
The following code causes a crash, which can arise when passing cloudpathlib objects to/from subprocesses.

```python
    path1 = CloudPath("s3://bucket/key")
    print(path1.__dict__)
    pkl1 = pickle.dumps(path1)

    path2 = pickle.loads(pkl1)
    print(path2.__dict__)
    pkl2 = pickle.dumps(path2)

    path3 = pickle.loads(pkl2)
    print(path3.__dict__)
```

The exception raised is:

```
{'_handle': None, '_client': None, '_str': 's3://bucket/key', '_url': ParseResult(scheme='s3', netloc='bucket', path='/key', params='', query='', fragment=''), '_path': PurePosixPath('/bucket/key'), '_dirty': False}
{'_handle': None, '_str': 's3://bucket/key', '_url': ParseResult(scheme='s3', netloc='bucket', path='/key', params='', query='', fragment=''), '_path': PurePosixPath('/bucket/key'), '_dirty': False}
Traceback (most recent call last):
  File "/path/to/cloudpathlib-pickle-crash.py", line 20, in <module>
    main()
  File "/path/to/cloudpathlib-pickle-crash.py", line 13, in main
    pkl2 = pickle.dumps(path2)
           ^^^^^^^^^^^^^^^^^^^
  File "/path/to/.venv/lib/python3.11/site-packages/cloudpathlib/cloudpath.py", line 266, in __getstate__
    del state["_client"]
        ~~~~~^^^^^^^^^^^
KeyError: '_client'
```

The issue seems to be that these methods are not symmetrical, and the _client field is not restored when the object is unpickled in a way that might mirror this example in the docs https://docs.python.org/3/library/pickle.html#pickle-state at this point in the code: https://github.com/drivendataorg/cloudpathlib/blob/08b018b36f90f89003e0c9e5ebd19030b41c2433/cloudpathlib/cloudpath.py#L262-L272


Pull request:
454 Multiple pickle support
Local PR to wrap #454 by @kujenga


Closes #450


Patch:
diff --git a/HISTORY.md b/HISTORY.md
index 9cf6a66..c408cab 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -2,6 +2,7 @@
 
 ## UNRELEASED
 
+- Allow `CloudPath` objects to be loaded/dumped through pickle format repeatedly. (Issue [#450](https://github.com/drivendataorg/cloudpathlib/issues/450))
 - Fixes typo in `FileCacheMode` where values were being filled by envvar `CLOUPATHLIB_FILE_CACHE_MODE` instead of `CLOUDPATHLIB_FILE_CACHE_MODE`. (PR [#424](https://github.com/drivendataorg/cloudpathlib/pull/424)
 - 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))
diff --git a/cloudpathlib/cloudpath.py b/cloudpathlib/cloudpath.py
index fc90db4..5cd9270 100644
--- a/cloudpathlib/cloudpath.py
+++ b/cloudpathlib/cloudpath.py
@@ -263,7 +263,8 @@ class CloudPath(metaclass=CloudPathMeta):
         state = self.__dict__.copy()
 
         # don't pickle client
-        del state["_client"]
+        if "_client" in state:
+            del state["_client"]
 
         return state
 


Test patch:
diff --git a/tests/test_cloudpath_file_io.py b/tests/test_cloudpath_file_io.py
index e5546f0..34c9c91 100644
--- a/tests/test_cloudpath_file_io.py
+++ b/tests/test_cloudpath_file_io.py
@@ -1,7 +1,6 @@
 from datetime import datetime
 import os
 from pathlib import Path, PurePosixPath
-import pickle
 from shutil import rmtree
 import sys
 from time import sleep
@@ -454,24 +453,6 @@ def test_os_open(rig):
         assert f.readable()
 
 
-def test_pickle(rig, tmpdir):
-    p = rig.create_cloud_path("dir_0/file0_0.txt")
-
-    with (tmpdir / "test.pkl").open("wb") as f:
-        pickle.dump(p, f)
-
-    with (tmpdir / "test.pkl").open("rb") as f:
-        pickled = pickle.load(f)
-
-    # test a call to the network
-    assert pickled.exists()
-
-    # check we unpickled, and that client is the default client
-    assert str(pickled) == str(p)
-    assert pickled.client == p.client
-    assert rig.client_class._default_client == pickled.client
-
-
 def test_drive_exists(rig):
     """Tests the exists call for top level bucket/container"""
     p = rig.create_cloud_path("dir_0/file0_0.txt")
diff --git a/tests/test_cloudpath_serialize.py b/tests/test_cloudpath_serialize.py
new file mode 100644
index 0000000..a3e59ce
--- /dev/null
+++ b/tests/test_cloudpath_serialize.py
@@ -0,0 +1,32 @@
+import pickle
+
+from cloudpathlib import CloudPath
+
+
+def test_pickle(rig, tmpdir):
+    p = rig.create_cloud_path("dir_0/file0_0.txt")
+
+    with (tmpdir / "test.pkl").open("wb") as f:
+        pickle.dump(p, f)
+
+    with (tmpdir / "test.pkl").open("rb") as f:
+        pickled = pickle.load(f)
+
+    # test a call to the network
+    assert pickled.exists()
+
+    # check we unpickled, and that client is the default client
+    assert str(pickled) == str(p)
+    assert pickled.client == p.client
+    assert rig.client_class._default_client == pickled.client
+
+
+def test_pickle_roundtrip():
+    path1 = CloudPath("s3://bucket/key")
+    pkl1 = pickle.dumps(path1)
+
+    path2 = pickle.loads(pkl1)
+    pkl2 = pickle.dumps(path2)
+
+    assert path1 == path2
+    assert pkl1 == pkl2