Skip to content

Commit 2a1d13d

Browse files
jmclaude
andcommitted
fix(client): fail-safe get_bitstreams + None on failed create_item/bundle
Review follow-ups, each confirmed by the tests: - get_bitstreams: a 404 now returns [] (a gone bundle has no bitstreams), mirroring the get_bundles #16 fix; other errors still surface so a transient 5xx is retried, not swallowed; a 200 with no bitstreams returns [] not None. The consumers (export/_dspace, reposync/_files) iterate the result unguarded. - create_item / create_bundle: return None on a non-2xx response instead of a truthy uuid-less object, so the importer's `if dso is None` / `if not bundle` guards actually fire. Also addresses the Copilot review: the multipart_properties test helper now asserts the 'properties' part exists (fails loudly) instead of returning None. Tests updated to assert the new behavior. 62 tests, no network. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7b6066c commit 2a1d13d

4 files changed

Lines changed: 60 additions & 34 deletions

File tree

dspace_rest_client/client.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,13 @@ def create_bundle(self, parent=None, name='ORIGINAL'):
732732
if parent is None:
733733
return None
734734
url = f'{self.API_ENDPOINT}/core/items/{parent.uuid}/bundles'
735-
return Bundle(api_resource=parse_json(self.api_post(url, params=None, json={'name': name, 'metadata': {}})))
735+
r = self.api_post(url, params=None, json={'name': name, 'metadata': {}})
736+
if r.status_code not in (200, 201):
737+
# return None on failure (not a uuid-less Bundle) so callers'
738+
# `if not bundle` guards actually fire
739+
_logger.error(f'Failed to create bundle: {r.status_code}: {r.text}')
740+
return None
741+
return Bundle(api_resource=parse_json(r))
736742

737743
# PAGINATION
738744
def get_bitstreams(self, uuid=None, bundle=None, page=0, size=20, sort=None):
@@ -762,12 +768,18 @@ def get_bitstreams(self, uuid=None, bundle=None, page=0, size=20, sort=None):
762768
if sort is not None:
763769
params['sort'] = sort
764770
r_json = self.fetch_resource(url, params=params)
765-
if '_embedded' in r_json:
766-
if 'bitstreams' in r_json['_embedded']:
767-
bitstreams = list()
768-
for bitstream_resource in r_json['_embedded']['bitstreams']:
769-
bitstreams.append(Bitstream(bitstream_resource))
770-
return bitstreams
771+
if r_json is None and getattr(self._last_err, 'status_code', None) == 404:
772+
# the bundle (or item) is gone - no bitstreams, a clean empty result
773+
# rather than a crash. Mirrors get_bundles (#16). Any other failure
774+
# (a transient 5xx, say) falls through and still surfaces to the
775+
# caller so it is retried, not silently recorded as "no bitstreams".
776+
_logger.info(f'No bitstreams: resource not found (404) [{url}]')
777+
return list()
778+
bitstreams = list()
779+
if '_embedded' in r_json and 'bitstreams' in r_json['_embedded']:
780+
for bitstream_resource in r_json['_embedded']['bitstreams']:
781+
bitstreams.append(Bitstream(bitstream_resource))
782+
return bitstreams
771783

772784
def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadata=None, retry=False):
773785
"""
@@ -1030,7 +1042,12 @@ def create_item(self, parent, item):
10301042
if not isinstance(item, Item):
10311043
_logger.error('Need a valid item')
10321044
return None
1033-
return Item(api_resource=parse_json(self.create_dso(url, params=params, data=item.as_dict())))
1045+
r = self.create_dso(url, params=params, data=item.as_dict())
1046+
if r is None or r.status_code != 201:
1047+
# return None on failure (not a uuid-less Item) so callers'
1048+
# `if dso is None` guards actually fire
1049+
return None
1050+
return Item(api_resource=parse_json(r))
10341051

10351052
def update_item(self, item):
10361053
"""

tests/_helpers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ def multipart_properties(request) -> dict:
6666
body = body.decode("utf-8", "replace")
6767
m = re.search(r'name="properties"\r?\n\r?\n(.*?);application/json',
6868
body, re.DOTALL)
69-
return json.loads(m.group(1)) if m else None
69+
# fail the test loudly rather than returning None and deferring the error
70+
assert m is not None, \
71+
"create_bitstream multipart body has no JSON 'properties' part"
72+
return json.loads(m.group(1))
7073

7174

7275
# --- response-body builders (shape mirrors the DSpace 7 REST API) --------- #

tests/test_client_read.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,19 @@ def test_no_args_returns_empty_list(self):
171171
self.assertEqual(c.get_bitstreams(), [])
172172
self.assertEqual(m.call_count, 0)
173173

174-
def test_non_200_currently_raises_no_failsafe(self):
175-
# CHARACTERIZATION of a known sharp edge: unlike get_bundles (which since
176-
# PR #16 returns [] on a 404), get_bitstreams has no fail-safe - a non-200
177-
# makes fetch_resource return None which this method then subscripts, so
178-
# it raises. The consumers (export/_dspace, reposync/_files) iterate the
179-
# result unguarded, so this is a real crash risk. Pinned deliberately: if
180-
# the library is hardened to return [], update this test to assert that.
174+
def test_deleted_bundle_404_returns_empty_list(self):
175+
# 404 -> [] fail-safe, mirroring get_bundles (#16): a gone bundle simply
176+
# has no bitstreams, which is a clean empty result, not a crash.
177+
c = make_client()
178+
bundle = Bundle(bundle_json("bnd2"))
179+
with requests_mock.Mocker() as m:
180+
m.get(f"{API}/core/bundles/bnd2/bitstreams",
181+
status_code=404, json={"timestamp": "2026-01-01"})
182+
self.assertEqual(c.get_bitstreams(bundle=bundle), [])
183+
184+
def test_non_404_error_still_surfaces(self):
185+
# a transient 5xx must NOT masquerade as "no bitstreams"; it surfaces so
186+
# the caller can retry, exactly as get_bundles does for non-404 errors.
181187
c = make_client()
182188
bundle = Bundle(bundle_json("bnd2"))
183189
with requests_mock.Mocker() as m:
@@ -186,6 +192,16 @@ def test_non_200_currently_raises_no_failsafe(self):
186192
with self.assertRaises(Exception):
187193
c.get_bitstreams(bundle=bundle)
188194

195+
def test_200_without_bitstreams_returns_empty_list(self):
196+
# a well-formed response with no bitstreams -> [] (not None), so callers
197+
# can iterate the result unconditionally.
198+
c = make_client()
199+
bundle = Bundle(bundle_json("bnd2"))
200+
with requests_mock.Mocker() as m:
201+
m.get(f"{API}/core/bundles/bnd2/bitstreams",
202+
json={"page": {"totalElements": 0}})
203+
self.assertEqual(c.get_bitstreams(bundle=bundle), [])
204+
189205

190206
class TestGetCollections(unittest.TestCase):
191207

tests/test_client_write.py

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -101,20 +101,15 @@ def test_posts_to_item_bundles_and_returns_bundle(self):
101101
def test_none_parent_returns_none(self):
102102
self.assertIsNone(make_client().create_bundle(parent=None))
103103

104-
def test_server_error_returns_truthy_uuidless_bundle(self):
105-
# CHARACTERIZATION: like create_item, create_bundle wraps the response
106-
# unconditionally -> a truthy Bundle with uuid=None on failure, not None.
107-
# The importer's `if not bundle` guard (reposync/_importer.py:118-120)
108-
# never fires because a Bundle instance is always truthy. Pinned.
104+
def test_server_error_returns_none(self):
105+
# a failed create returns None (not a uuid-less Bundle), so the
106+
# importer's `if not bundle` guard fires correctly.
109107
c = make_client()
110108
parent = Item(item_json(ITEM_UUID))
111109
with requests_mock.Mocker() as m:
112110
m.post(f"{API}/core/items/{ITEM_UUID}/bundles",
113111
status_code=500, text="boom")
114-
out = c.create_bundle(parent=parent)
115-
self.assertIsInstance(out, Bundle)
116-
self.assertIsNone(out.uuid)
117-
self.assertTrue(out)
112+
self.assertIsNone(c.create_bundle(parent=parent))
118113

119114

120115
class TestCreateItem(unittest.TestCase):
@@ -138,19 +133,14 @@ def test_posts_with_owning_collection_param_and_returns_item(self):
138133
self.assertEqual(body["metadata"], {})
139134
self.assertIs(body["inArchive"], True)
140135

141-
def test_server_error_returns_truthy_uuidless_item(self):
142-
# CHARACTERIZATION: create_item wraps the response unconditionally, so a
143-
# failed create yields a truthy Item with uuid=None, NOT None. The
144-
# importer guards with `if dso is None` (reposync/_importer.py:127-129),
145-
# which therefore never fires on failure. Pinned; see the fail-safe note.
136+
def test_server_error_returns_none(self):
137+
# a failed create returns None (not a uuid-less Item), so the importer's
138+
# `if dso is None` guard (reposync/_importer.py:127-129) fires correctly.
146139
c = make_client()
147140
item = Item({"name": "x", "metadata": {}})
148141
with requests_mock.Mocker() as m:
149142
m.post(f"{API}/core/items", status_code=500, text="boom")
150-
out = c.create_item(parent=COLLECTION_UUID, item=item)
151-
self.assertIsInstance(out, Item)
152-
self.assertIsNone(out.uuid)
153-
self.assertTrue(out) # truthy despite the failure
143+
self.assertIsNone(c.create_item(parent=COLLECTION_UUID, item=item))
154144

155145
def test_non_item_returns_none(self):
156146
self.assertIsNone(make_client().create_item(

0 commit comments

Comments
 (0)