test: CLARIN consumer-contract coverage + main/dtq merge gate (fixes 8 defects) - #22
test: CLARIN consumer-contract coverage + main/dtq merge gate (fixes 8 defects)#22jr-rk wants to merge 37 commits into
Conversation
- Fix 'bistreams' -> 'bitstreams' in docstring - Fix 'sucessfully' -> 'successfully' in log messages (2 instances) These spelling errors were found in comments and log messages and do not affect code behavior.
add group info to resourcePolicy
Backports critical bugfixes and proxy support from upstream the-library-code/dspace-rest-python.
Bugfixes:
- Fix User model trailing commas that turned fields into tuples
- Fix get_items() using wrong embedded key ('collections' -> 'items')
- Fix InProgressSubmission step assigned from lastModified instead of step
- Fix InProgressSubmission type assigned from lastModified instead of type
- Fix EntityType type field overwriting label
- Fix parse_json to handle None response safely
Improvements:
- Add proxy support via PROXY_URL env var and proxies constructor param
- Add proxies to all HTTP methods (GET, POST, PUT, DELETE, PATCH, send)
- Add proxies to authenticate status check GET
- Add params parameter to api_patch method
- Add embedded attribute to HALResource base class
- Add ITER_PAGE_SIZE class variable (preparation for pagination)
- Add upstream_ref/ to .gitignore
…roxy-support fix: upstream bugfixes and proxy support backport
add timeout
get_bundles() subscripted the None that fetch_resource returns on any non-200 response, so an item deleted since the cache was built (404) raised "'NoneType' object is not subscriptable" during a bitstream export - a scary CRITICAL line for what is really just "this item is gone". Record the failing response as _last_err in fetch_resource so callers can tell a gone resource (404) from a transient 5xx, then in get_bundles treat a 404 as a clean empty result. Any other failure still falls through and surfaces to the caller, so it keeps its retry and failure counting. Co-authored-by: jm <jm@maz> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
origin/main and origin/dtq had diverged since 5724696 (2024-10-24) with no ancestry either way. main added +300/-0 lines and dtq +263/-71, both confined to dspace_rest_client/client.py and dspace_rest_client/models.py. main contributes the CLARIN/UFAL surface: License and Label models, clarinlruallowances get/create, submit-group creation, eperson-to-group membership, get_item_by_handle, get_items_from_collection, get_bundle_by_name, get_user_by_email, api_put_uri and the dict-based resource-policy helpers. dtq keeps its hardening: reauth on 401, request timeouts, proxy support, verify_response, _last_err, the ResourcePolicy model and 404-safe bundles. Conflict resolutions (3 hunks, 2 in client.py and 1 in models.py): - get_items: kept dtq's paginated get_items(self, page=0, size=20) and dropped main's no-arg variants. main still carried two duplicate `def get_items` definitions; the shadowing second one tested for 'collections' in _embedded while iterating _embedded['items']. The paginated signature is required by DSpace-ISstag-integration src/ingest/_dspace.py, which calls get_items(page=page, size=page_size). main's additive get_item_by_handle is kept alongside it. - remove_metadata: unified the two signatures into remove_metadata(self, dso, field, place=None). place=None removes every value of the field (dtq behaviour, PATCH /metadata/{field}); an explicit place removes a single value (main behaviour, PATCH /metadata/{field}/{place}). This keeps dtq's 2-arg call sites and dspace-import-clarin's remove_metadata(item, key, 0) working. main's module-level logging.error was replaced by _logger.error to match the rest of the file. - models.py: kept all three new classes - License (with to_dict), Label and ResourcePolicy (with as_dict and __repr__). The conflict was purely positional, both sides having appended at EOF. api_put_uri was added on main against the pre-hardening base, so it was harmonised with its sibling api_put: reset self._last_err on entry, pass proxies=self.proxies, and use _logger instead of module-level logging. Its public signature is unchanged - dspace-rest-test depends on it. dtq-dev is deliberately NOT merged. Its only unique commit, 145635a (2023-06-12, "copied code from dtq main"), is a snapshot of a different project: it relocates dspace_rest_client/{client,models}.py into support/dspace_interface/, deletes setup.py, example.py, example_gets.py, solr_example.py, publish.sh, CHANGELOG.md and MAINTAINING.md, and adds ~8.5k lines of import tooling, test fixtures, license icons and a localization CSV. Merging it would destroy the installable package. Both resource-policy APIs are retained side by side, since main's dict-based set (get_resource_policy, create_resource_policy, update_resource_policy_group) and dtq's model-based set (get_resourcepolicy, create_resourcepolicy) each have live consumers. Unification is left to a separate deprecation change. Verified: compileall clean, package imports, no duplicate method definitions, and the merged API surface is exactly the union of both branches (nothing from either side lost, main's duplicate get_items aside). The DSpace-ISstag-integration suite gives an identical result with and without this merge - 579 passed, 40 failed - where the 40 failures are pre-existing and caused by flask being absent from that environment, not by the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DSpace-ISstag-integration drives this client from src/{ingest,export,reposync}
and mcp/, but nothing here was under test - a library change could silently
break that consumer. Add a pytest suite that mocks only the HTTP transport
(requests_mock) and exercises the real URL-building and response-parsing for
every DSpaceClient method and model the consumer relies on:
- test_models: Item/Community/Collection/Bundle/Bitstream/ResourcePolicy
construction + accessors (incl. the live _embedded.group -> groupUUID lift)
- test_client_auth: constructor + authenticate() True/False semantics
- test_client_read: search_objects, get_items, get_item, get_bundles (incl.
the 404 -> [] contract from #16), get_bitstreams, get_collections,
get_communities, get_resourcepolicy, fetch_resource (last_err on 404)
- test_client_write: create_resourcepolicy, api_delete, create_bundle,
create_item, create_bitstream (multipart upload)
- test_repo_usage_contract: the exact multi-call chains the consumer runs
(bitstream export, resource-policy replacement, MCP bundle walk, group-uuid
resolution)
Adds .github/workflows/tests.yml (pytest on Python 3.10 + 3.12) and
requirements-test.txt. 56 tests, no network.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Acting on a two-reviewer audit (workflow + Opus-5 advisor): Faithfulness fixes (tests that could pass against a broken library): - get_bitstreams embedded-link test used an href byte-identical to the fallback URL, so it could not distinguish the branches; give it a distinct href that the fallback could not produce. - MCP chain fed a link-bearing Bundle to get_bitstreams; the real consumer round-trips through as_dict() (drops _links) and hits the fallback URL - rebuild the Bundle from as_dict() and assert the fallback is used. - export chain fetched policies with action='READ' (raw-client default) but the real exporter goes through a wrapper defaulting to action=None (no filter); call with action=None and assert no action param is sent. - get_resourcepolicy empty test used a no-_embedded body (defensive branch); the live API returns an _embedded envelope even when empty - use that. Coverage / stronger assertions: - get_resourcepolicy action=None omits the filter and returns all actions. - create_item now asserts the POST body (name/metadata/type/flags), not just the uuid; create_bitstream asserts the multipart 'properties' payload (name/bundleName/metadata). - search_objects result now asserts .as_dict() and links['self']['href'], the two accessors every consumer reads. - model tests assert parsed .metadata and checkSum.checkSumAlgorithm (dropped the tautological hard-set .type assertions' reliance). - get_bitstreams non-200, and create_item/create_bundle server-error: characterization tests pinning the current (non-fail-safe) behavior the consumers depend on, flagged in-comment for a future library hardening. 60 tests, still no network. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dtq-dev no longer exists, and we only care about dtq for now. PRs still trigger via the pull_request event. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ndle 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>
test: unit + integration suite for downstream usage, with CI
get_bundles and get_bitstreams already return [] on a 404 (deleted item or bundle). On any other failure fetch_resource returns None and the code then subscripted it, surfacing an opaque 'NoneType is not subscriptable' TypeError with no status or url. Raise an explicit RuntimeError carrying the HTTP status and url instead, so a transient 5xx is a legible, retryable error. Addresses a Copilot review note; tests assert the status/url is in the message. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(client): raise informative error on non-404 fetch failure
… guards
Python-hygiene fixes (no behaviour change on the happy path):
- Remove logging.basicConfig() at import - a library must not configure the
root logger. Attach a NullHandler and route everything through the module
_logger (dropped the stray root logging.* calls).
- Add a configurable per-request timeout (DEFAULT_TIMEOUT=60, `timeout=`
constructor arg) on every session call, so a stalled server cannot hang the
client forever.
- create_bitstream: open the upload file in a `with` block so the handle is
always closed instead of leaked to the GC.
- Bitstream(None) no longer raises TypeError on the __init__ membership checks.
- create_clarinlruallowances: parameterise metadata_payload; drop the leftover
hardcoded {"metadataValue":"Test"} debug data (refuses with no payload).
- models: modernise super(Cls, self) -> super() throughout.
Mutable class-attribute defaults were intentionally left untouched.
68 tests (5 new), no network.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- guard the NullHandler registration so reloads/re-imports don't accumulate duplicate handlers - create_bitstream: copy the session headers before adding Content-Encoding so it doesn't leak onto every subsequent request / across threads - move Collection.as_dict's docstring to the first statement (it was a no-op string literal after code); rename the `dict` builtin-shadow in InProgressSubmission.as_dict Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(client): Python correctness sweep - logging, timeouts, file handle, None guards
There was a problem hiding this comment.
Pull request overview
Adds comprehensive CLARIN/UFAL consumer-contract coverage and CI gating to safely validate future main↔dtq merges. It expands the test suite to characterize real consumer call chains (transport mocked only), pins multiple dtq fixes, and introduces a “differential contract” workflow that runs the CLARIN suite against both the dtq and origin/main implementations.
Changes:
- Add new CLARIN read/write/model/usage-contract test suites plus shared helpers and pytest markers.
- Harden several client methods (null/empty/failed-response handling) and make
Group(None)/User(None)safe. - Update CI to enforce a coverage floor and add a dtq-vs-main differential contract job (plus optional consumer smoke jobs).
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_transport_hardening.py | New dtq-only tests for timeout/proxy plumbing, verify_response, and last_err reset behavior |
| tests/test_repo_usage_contract.py | Adds a contract test for get_owningCollection + 401-driven reauth predicate |
| tests/test_models_clarin.py | New CLARIN model tests for License, Label, Group, User (including None construction) |
| tests/test_client_read.py | Adds dtq-only coverage for the no-arg get_items() pagination defaults |
| tests/test_clarin_write.py | New CLARIN write-surface characterization tests (policies, submit groups, membership, metadata removal) |
| tests/test_clarin_usage_contract.py | New end-to-end consumer call-chain contract tests (CLARIN side) |
| tests/test_clarin_read.py | New CLARIN read-surface characterization tests (policies, bundles, allowances, handle, email, owningCollection) |
| tests/_helpers.py | Adds CLARIN-oriented JSON builders and IDs used across the new suites |
| requirements-test.txt | Adds pytest-cov for CI-enforced coverage floor |
| pytest.ini | Registers clarin, dtq_only, dq markers for suite slicing |
| dspace_rest_client/models.py | Makes Group(None) / User(None) safe by normalizing api_resource |
| dspace_rest_client/client.py | Hardens CLARIN-related client methods (avoid crashes, return clean None/[]) and improves get_owningCollection behavior |
| .gitignore | Ignores coverage artifacts |
| .github/workflows/tests.yml | Adds coverage enforcement, differential-contract job, and secret-gated consumer smoke jobs |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (6)
dspace_rest_client/client.py:453
api_patch()assumes the 403 response body is JSON; if it isn't,parse_json()returns None andif 'message' in r_jsonraisesTypeError, so CSRF retry handling can crash.
_logger.debug(r.text)
r_json = parse_json(r)
if 'message' in r_json and 'CSRF token' in r_json['message']:
if retry:
_logger.warning(f'Too many retries updating token: {r.status_code}: {r.text}')
else:
_logger.debug("Retrying request with updated CSRF token")
return self.api_patch(url, operation, path, value, params, True)
dspace_rest_client/client.py:1085
get_items()can crash withTypeError: argument of type 'NoneType' is not iterablewhen the response body is empty/non-JSON (becauseparse_json()returns None and the code doesif '_embedded' in r_json). Guard for a falsyr_jsonbefore membership tests, as done inget_items_from_collection().
r = self.api_get(url, params=params)
r_json = parse_json(response=r)
if '_embedded' in r_json:
if 'items' in r_json['_embedded']:
dspace_rest_client/client.py:880
create_bitstream()assumesparse_json()always returns a dict on 401/403. If the body is empty or non-JSON,parse_json()returns None andif 'message' in r_jsonraisesTypeError, preventing the intended retry/authenticate path.
This issue also appears on line 1082 of the same file.
if not retry and r.status_code in (401, 403):
r_json = parse_json(r)
if 'message' in r_json and 'CSRF token' in r_json['message']:
_logger.debug("Retrying request with updated CSRF token")
else:
self.authenticate()
return self.create_bitstream(bundle, name, path, mime, metadata, True)
dspace_rest_client/client.py:365
api_put_uri()assumes the 403 response body is JSON; if it isn't,parse_json()returns None andif 'message' in r_jsonraisesTypeError, so CSRF retry handling can crash instead of retrying/returning the response.
This issue also appears on line 446 of the same file.
r_json = parse_json(r)
if 'message' in r_json and 'CSRF token' in r_json['message']:
if retry:
_logger.warning(f'Too many retries updating token: {r.status_code}: {r.text}')
else:
_logger.debug("Retrying request with updated CSRF token")
return self.api_put_uri(url, params=params, uri_list=uri_list, retry=True)
dspace_rest_client/client.py:1041
get_item()constructs and returns anItemeven when the HTTP request fails or the body isn't valid JSON. That can leak a uuid-less/truthy object (or an object missinglinks) and also drops the failing response details. Prefer returningNoneon non-200 / unparseable bodies and recording_last_errlike other hardened methods.
r = self.api_get(url, None, None)
r_json = parse_json(response=r)
return Item(r_json)
dspace_rest_client/client.py:100
__init__(..., proxies=PROXY_DICT, ...)uses a mutable dict as a default argument, so if any instance mutatesself.proxiesin-place it can unintentionally affect future instances. Preferproxies=Noneand copy the class default when initializingself.proxies.
def __init__(self, api_endpoint=API_ENDPOINT, username=USERNAME, password=PASSWORD, solr_endpoint=SOLR_ENDPOINT,
solr_auth=SOLR_AUTH, fake_user_agent=False, proxies=PROXY_DICT, timeout=None):
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (6)
dspace_rest_client/client.py:878
create_bitstream()assumesparse_json(r)returns a dict; if the 401/403 body is non-JSON,parse_json()returns None and the'message' in r_jsoncheck raisesTypeError, preventing the intended retry/authenticate path.
if not retry and r.status_code in (401, 403):
r_json = parse_json(r)
if 'message' in r_json and 'CSRF token' in r_json['message']:
_logger.debug("Retrying request with updated CSRF token")
else:
dspace_rest_client/client.py:438
- In
api_patch(),parse_json(r)can return None for non-JSON 403 responses; the subsequentif 'message' in r_json ...will then raiseTypeErrorand prevent the CSRF retry path from running.
r = self.session.patch(url, json=[data], params=params, headers=self.request_headers,
proxies=self.proxies, timeout=self.timeout)
dspace_rest_client/client.py:380
- In
api_delete(),parse_json(r)can return None (non-JSON 403 bodies happen), and the laterif 'message' in r_json ...will raiseTypeError, skipping the CSRF retry. Since this method was just updated to always pass proxies/timeout, it's a good spot to also make the CSRF branch resilient to non-JSON responses.
r = self.session.delete(url, params=params, headers=self.request_headers,
proxies=self.proxies, timeout=self.timeout)
dspace_rest_client/client.py:1084
get_items()will crash withTypeError: argument of type 'NoneType' is not iterablewhen the response body is non-JSON (e.g., a 5xx HTML/plain-text error), becauseparse_json()returns None and the code doesif '_embedded' in r_json:. It also drops the failing response instead of recordinglast_err.
r = self.api_get(url, params=params)
r_json = parse_json(response=r)
if '_embedded' in r_json:
dspace_rest_client/client.py:1041
get_item()constructs and returns anItemeven on non-200 responses (and even when JSON parsing fails), which can yield a truthy, uuid-lessItemand hides HTTP errors from callers. Given this PR introduceslast_err, returning None on non-200 and recording the failing response is safer and consistent with other methods.
r = self.api_get(url, None, None)
r_json = parse_json(response=r)
return Item(r_json)
dspace_rest_client/client.py:360
api_put_uri()can raiseTypeErroron a 403 with a non-JSON body:parse_json()returns None and the code does'message' in r_json. Guarding for a None parse keeps the CSRF-retry logic robust.
This issue also appears in the following locations of the same file:
- line 379
- line 437
- line 874
if 'message' in r_json and 'CSRF token' in r_json['message']:
Add characterization tests that replay how the three main-lineage consumers (dspace-import-clarin, dspace-rest-test, dspace-item-importer) actually call this library, plus a branch-differential CI job that runs the shared CLARIN contract against BOTH the dtq and main implementations. Only the HTTP transport is mocked; the real client builds URLs and parses responses. This PR is test + CI infrastructure only - no dspace_rest_client/ changes. The shared-surface fixes and dtq behaviour deltas that the tests would otherwise pin live in a separate, stacked PR, so this one stays scoped to validating main's usage surface and proposes nothing for main. Markers (pytest.ini): clarin - shared contract, must hold on both main and dtq dtq_only - existing dtq-only surface/behaviour, deselected on the main leg Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dc2e654 to
0c7411b
Compare
What
The CLARIN/UFAL surface was merged onto
dtq(main→dtq, 2026-08-17) withzero tests, while three
main-lineage consumers depend on it(
dspace-rest-test,dspace-import-clarin,dspace-item-importer). This addsthe CLARIN counterpart to
test_repo_usage_contract.py— replaying thoseconsumers' real call chains with only the HTTP transport mocked — the fixes
those tests pin, and CI that makes a future
main+dtqmerge validatable.68 → 137 tests. No network in any test.
Fixes
Each crashed or mis-answered a real consumer call. All characterization-tested
(test written to the intended behaviour first, then the method fixed).
get_bundle_by_name/get_resource_policy/get_items_from_collectionNoneTypesubscript crash on any non-200None/[]get_resource_policyIndexErroron an empty policy listNoneget_user_by_emailUseron 404 — slips past the consumer'sif user:guardNonecreate_submit_groupGroup(None)crash on a 201 with an empty bodyNoneGroup(None)/User(None)TypeErrorget_owningCollectionCollectionon non-200, so ISstag'ssrc/repo/_audit.pyowning_col is None and last_err.status_code == 401reauth retry was dead codeNone,last_errrecordedNew tests
test_clarin_read.py(21)get_resource_policy(raw-dict contract),get_bundle_by_name,get_items_from_collection,get_item_by_handle,get_user_by_email,get_clarinlruallowances*,get_owningCollectiontest_clarin_write.py(17)create_resource_policy(params/body/truthiness/401-reauth),update_resource_policy_group,create_submit_group,add_member,remove_metadatatest_models_clarin.py(12)License,Label,Group,Userconstruction +to_dict/as_dicttest_clarin_usage_contract.py(9)test_transport_hardening.py(7)verify_response,last_errresetPlus
_helpers.pyCLARIN builders, the no-argget_items()form, aget_owningCollectionreauth contract intest_repo_usage_contract.py, andpytest.inimarkers.CI — the merge gate
tests.ymlnow builds ondtq/main/feat/**/fix/**. A newdifferential-contractjob runs the CLARIN suite against both thedtqimplementation and the
mainimplementation (swapped in fromorigin/main): atest green on both proves the merge preserves that behaviour. Tests that encode
a
dtqfix or behaviour change are marked@pytest.mark.dtq_onlyand skippedon the
mainleg. Adds a 70% coverage floor and secret-gated consumer-smokejobs (skipped until
CONSUMER_READ_TOKENexists).Verified locally: leg A (dtq) 137 pass; leg B (main impl) 45 shared
contracts pass, 14
dtq_onlydeselected — and those 14 fail on themainimplementation, confirming the deltas are real.
Review resolutions
Follow-up commits after review:
6faffa8—add_memberposted a malformed eperson uri-list body(
/epersons/{uuid}, missing/eperson); DSpace rejects it with 422. Fixed tothe canonical
/eperson/epersons/{uuid}; the test that had pinned the wrongvalue is corrected and marked
dtq_only.dc2e654(Copilot 👍, both threads resolved) —get_resource_policyandget_user_by_emailnow recordself._last_erron the non-200 path, so acaller can distinguish an HTTP error from a genuine empty/not-found result;
get_user_by_emailalso no longer logs a plain 404 at error level or emitsthe raw email. Tests assert
last_errcarries the status code.Verified after each: leg A (dtq) 137 pass; leg B (main impl) 44 pass / 15
dtq_onlydeselected.