Skip to content

test: CLARIN consumer-contract coverage + main/dtq merge gate (fixes 8 defects) - #22

Open
jr-rk wants to merge 37 commits into
mainfrom
test/clarin-usage-coverage
Open

test: CLARIN consumer-contract coverage + main/dtq merge gate (fixes 8 defects)#22
jr-rk wants to merge 37 commits into
mainfrom
test/clarin-usage-coverage

Conversation

@jr-rk

@jr-rk jr-rk commented Aug 18, 2026

Copy link
Copy Markdown

What

The CLARIN/UFAL surface was merged onto dtq (maindtq, 2026-08-17) with
zero tests, while three main-lineage consumers depend on it
(dspace-rest-test, dspace-import-clarin, dspace-item-importer). This adds
the CLARIN counterpart to test_repo_usage_contract.py — replaying those
consumers' real call chains with only the HTTP transport mocked — the fixes
those tests pin, and CI that makes a future main+dtq merge 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).

Method Was Now
get_bundle_by_name / get_resource_policy / get_items_from_collection NoneType subscript crash on any non-200 clean None / []
get_resource_policy IndexError on an empty policy list None
get_user_by_email truthy, uuid-less User on 404 — slips past the consumer's if user: guard None
create_submit_group Group(None) crash on a 201 with an empty body None
Group(None) / User(None) TypeError empty object
get_owningCollection empty truthy Collection on non-200, so ISstag's src/repo/_audit.py owning_col is None and last_err.status_code == 401 reauth retry was dead code None, last_err recorded

New tests

File Covers
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_owningCollection
test_clarin_write.py (17) create_resource_policy (params/body/truthiness/401-reauth), update_resource_policy_group, create_submit_group, add_member, remove_metadata
test_models_clarin.py (12) License, Label, Group, User construction + to_dict/as_dict
test_clarin_usage_contract.py (9) the three consumers' multi-call chains, end-to-end
test_transport_hardening.py (7) timeout/proxies on every verb, verify_response, last_err reset

Plus _helpers.py CLARIN builders, the no-arg get_items() form, a
get_owningCollection reauth contract in test_repo_usage_contract.py, and
pytest.ini markers.

CI — the merge gate

tests.yml now builds on dtq / main / feat/** / fix/**. A new
differential-contract job runs the CLARIN suite against both the dtq
implementation and the main implementation (swapped in from origin/main): a
test green on both proves the merge preserves that behaviour. Tests that encode
a dtq fix or behaviour change are marked @pytest.mark.dtq_only and skipped
on the main leg. Adds a 70% coverage floor and secret-gated consumer-smoke
jobs (skipped until CONSUMER_READ_TOKEN exists).

Verified locally: leg A (dtq) 137 pass; leg B (main impl) 45 shared
contracts pass
, 14 dtq_only deselected — and those 14 fail on the main
implementation, confirming the deltas are real.


Review resolutions

Follow-up commits after review:

  • 6faffa8add_member posted a malformed eperson uri-list body
    (/epersons/{uuid}, missing /eperson); DSpace rejects it with 422. Fixed to
    the canonical /eperson/epersons/{uuid}; the test that had pinned the wrong
    value is corrected and marked dtq_only.
  • dc2e654 (Copilot 👍, both threads resolved) — get_resource_policy and
    get_user_by_email now record self._last_err on the non-200 path, so a
    caller can distinguish an HTTP error from a genuine empty/not-found result;
    get_user_by_email also no longer logs a plain 404 at error level or emits
    the raw email. Tests assert last_err carries the status code.

Verified after each: leg A (dtq) 137 pass; leg B (main impl) 44 pass / 15
dtq_only deselected.

jm and others added 30 commits February 5, 2025 18:29
- 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.
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
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>
vidiecan and others added 6 commits August 17, 2026 19:13
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
@jr-rk jr-rk self-assigned this Aug 18, 2026
@jr-rk jr-rk changed the title test: clarin usage coverage test: CLARIN consumer-contract coverage + main/dtq merge gate (fixes 8 defects) Aug 18, 2026
@jr-rk
jr-rk changed the base branch from main to dtq August 18, 2026 13:32
@jr-rk
jr-rk requested a lite review from Copilot August 18, 2026 13:32
@jr-rk
jr-rk changed the base branch from dtq to main August 18, 2026 13:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds comprehensive CLARIN/UFAL consumer-contract coverage and CI gating to safely validate future maindtq 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.

Comment thread dspace_rest_client/client.py Outdated
Comment thread dspace_rest_client/client.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 and if 'message' in r_json raises TypeError, 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 with TypeError: argument of type 'NoneType' is not iterable when the response body is empty/non-JSON (because parse_json() returns None and the code does if '_embedded' in r_json). Guard for a falsy r_json before membership tests, as done in get_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() assumes parse_json() always returns a dict on 401/403. If the body is empty or non-JSON, parse_json() returns None and if 'message' in r_json raises TypeError, 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 and if 'message' in r_json raises TypeError, 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 an Item even when the HTTP request fails or the body isn't valid JSON. That can leak a uuid-less/truthy object (or an object missing links) and also drops the failing response details. Prefer returning None on non-200 / unparseable bodies and recording _last_err like 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 mutates self.proxies in-place it can unintentionally affect future instances. Prefer proxies=None and copy the class default when initializing self.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):

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() assumes parse_json(r) returns a dict; if the 401/403 body is non-JSON, parse_json() returns None and the 'message' in r_json check raises TypeError, 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 subsequent if 'message' in r_json ... will then raise TypeError and 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 later if 'message' in r_json ... will raise TypeError, 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 with TypeError: argument of type 'NoneType' is not iterable when the response body is non-JSON (e.g., a 5xx HTML/plain-text error), because parse_json() returns None and the code does if '_embedded' in r_json:. It also drops the failing response instead of recording last_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 an Item even on non-200 responses (and even when JSON parsing fails), which can yield a truthy, uuid-less Item and hides HTTP errors from callers. Given this PR introduces last_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 raise TypeError on 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>
@jr-rk
jr-rk force-pushed the test/clarin-usage-coverage branch from dc2e654 to 0c7411b Compare August 20, 2026 08:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants