fix: resolve all 16 code-review findings (correctness, packaging, lint/CI) - #25
Open
vidiecan wants to merge 6 commits into
Open
fix: resolve all 16 code-review findings (correctness, packaging, lint/CI)#25vidiecan wants to merge 6 commits into
vidiecan wants to merge 6 commits into
Conversation
Carry over the pending working-tree pass so the follow-up review fixes land on a committed baseline: - validate UUIDs through _is_valid_uuid in get_resourcepolicy, create_resourcepolicy, get_dso and get_item - add _response_message_contains so a non-JSON 401/403 body still routes through the CSRF-refresh/retry path instead of raising - return None (not the client) from add_metadata/remove_metadata on invalid input - give every model instance its own links/embedded/checkSum/sections and deep-copy metadata on the DSpaceObject/Item copy paths - guard Group()/User() against a None api_resource - export the full model surface from models.__all__ - move release metadata to pyproject.toml, document the [solr] extra and raise a clear RuntimeError from solr_query without it - run ruff/pylint/mypy through pinned pre-commit hooks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remaining correctness findings from the code review: - get_owningCollection validated nothing; it now short-circuits through _is_valid_uuid like every other UUID-taking method, so a None / non-string argument is logged and returns None instead of raising TypeError. - get_communities, get_collections and get_bundle_by_name tested `'_embedded' in r_json` on a value fetch_resource/parse_json can return as None (404 or non-JSON body), raising TypeError. They now report the failure and return None, matching their documented contract. - create_bitstream carried its own copy of the DSPACE-XSRF-TOKEN header/cookie refresh; it now calls the shared update_token(), so there is one CSRF path. - Dropped the commented-out data.pop(...) block in update_dso. Tests cover the non-string UUID arguments (which used to raise TypeError, not the ValueError the callers caught), the non-JSON/404 list responses, and the token refresh going through update_token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Drop the stale E402/E741 ruff ignores (F403/F405/F841 already went); only E501 remains, and it is now documented. Verified clean with ruff 0.12.7. - Stop disabling broad-exception-caught project-wide. The four deliberate fallback catches in client.py carry an inline disable instead, so a new accidental `except Exception` is a lint failure again. - Remove the dead good-names allowlist: invalid-name stays disabled because the DSpace REST field names are camelCase, so the allowlist never applied. Verified with the CI-pinned pylint 3.3.7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The separate typecheck job spun up a second runner to install the same pre-commit environment. Mypy now runs as a step of the lint job, keeping step-level continue-on-error so incremental typing stays non-blocking, and the mypy version is pinned by bumping the mirrors-mypy rev to v2.3.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The DSpaceObject(dso=...) path currently drops embedded/backing state, and Solr initialization can now fail client construction even when Solr is meant to be optional.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR tightens correctness and resilience of the DSpace REST client (UUID validation, non-JSON error handling, mutable default fixes), while also modernizing packaging/release flow and consolidating lint/typecheck execution via pre-commit.
Changes:
- Hardened client request/response handling (UUID validation, CSRF retry parsing, safer list endpoint behavior, consistent
Nonereturns on invalid inputs). - Fixed model construction and copying pitfalls (no shared mutable class attributes; deep-copy to avoid aliasing; expanded
models.__all__and added public API parity test). - Updated packaging/release + CI hygiene (pyproject version bump to 0.2.0, improved publish script/maintainer docs, lint+mypy via pre-commit in CI).
File summaries
| File | Description |
|---|---|
dspace_rest_client/client.py |
Adds defensive helpers for UUID/message parsing, improves CSRF retry safety, fixes return semantics, and improves Solr extra behavior. |
dspace_rest_client/models.py |
Removes shared mutable class attributes, deep-copies resource structures, expands __all__, and makes constructors tolerant of None. |
tests/test_client_read.py |
Adds regression tests for UUID validation and list endpoints returning None on failed/non-JSON responses. |
tests/test_client_write.py |
Adds regression tests for non-JSON 403 handling, metadata patch invalid input, and bitstream retry/token refresh. |
tests/test_models.py |
Adds coverage for copy-aliasing fixes and mutable-default isolation; asserts Group/User(None) construction. |
tests/test_public_api.py |
Pins parity between package exports and models.__all__. |
tests/test_solr.py |
Verifies actionable error when Solr extra is missing. |
pyproject.toml |
Bumps version to 0.2.0, adjusts license metadata, and tightens ruff/pylint config. |
README.md |
Updates supported Python version and documents Solr extra install. |
CHANGELOG.md |
Adds 0.2.0 entry describing the breaking/support changes and behavior fixes. |
publish.sh |
Switches release build/upload flow to python -m build + twine check + upload dist/*. |
MAINTAINING.md |
Updates release checklist to match pyproject.toml-based publishing and new tooling. |
.pre-commit-config.yaml |
Narrows hook file scopes and adds a pinned mypy hook. |
.github/workflows/tests.yml |
Runs ruff/pylint/mypy via pre-commit in a single lint job (non-blocking mypy). |
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+159
to
+161
| if pysolr is not None: | ||
| self.solr = pysolr.Solr( | ||
| url=solr_endpoint, always_commit=True, timeout=300, auth=solr_auth) |
Comment on lines
138
to
141
| if dso is not None: | ||
| api_resource = dso.as_dict() | ||
| self.links = dso.links.copy() | ||
| self.links = deepcopy(dso.links) | ||
| if api_resource is not None: |
Every model class kept its attribute defaults in the class body and relied on
them as fallbacks until __init__ (or an API resource) overwrote them. That is
what made the shared-mutable-dict bug possible in the first place: a class-level
`links = {}` / `checkSum = {...}` / `sections = {}` is one object shared by
every instance, so one object's mutation leaks into all the others.
All of them are now plain instance attributes assigned in __init__ -
HALResource (type, links, embedded), AddressableHALResource (id),
ExternalDataObject (id, display, value, externalSource, metadata), DSpaceObject
(id, uuid, name, handle, lastModified, parent, metadata, type), Item (inArchive,
discoverable, withdrawn), Community / Collection / Bundle / Bitstream / Group /
User (type and their own fields), InProgressSubmission (lastModified, step,
sections, type) and EntityType (label). No model class has a class-level
attribute left; a new test asserts that, so finding 7 cannot regress.
Verified against both repositories first: nothing reads these attributes off the
class (only `Item.from_dso`, a classmethod), nothing builds a model through
`object.__new__`, and `to_json`/`to_json_pretty` - the only readers of a model's
`__dict__` - have no callers. An exhaustive old-vs-new comparison over every
class, constructor form, `dso=` copy path and `as_dict()` reports two
differences, both deliberate: `DSpaceObject.id` and `EntityType.label` now
default to None instead of raising AttributeError when absent.
Behaviour deliberately preserved: Item still stamps `type = 'item'` only when
built from an API resource. Its class-level `type = 'item'` was dead - DSpaceObject
.__init__ always assigns self.type first, shadowing it on every instance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every model repeated the same two-step per attribute: assign a default, then
`if 'x' in api_resource: self.x = api_resource['x']`. That was 36 membership
blocks across the module, and the shape of it is what let a mutable default
drift back to class level.
Both steps now go through one shared helper on HALResource:
self._init_fields(api_resource, name=None, netid=None, canLogIn=False, ...)
Each field is named once, beside its default. `_init_fields` takes the value
from the resource when the key is present and otherwise installs a per-instance
copy of the default via `_fresh()`, so a declared mutable default can never be
shared between instances. An optional positional-only `copy` argument carries
the per-field copy depth the old code had - `deepcopy` for metadata, a shallow
`_shallow` for checkSum and sections - instead of flattening them to one rule.
Attribute *types* stay in the class body as bare annotations, which declare a
type without creating a class attribute: mypy and pylint keep resolving every
member, and the structural guard test stays absolute (there is deliberately no
class-level spec constant needing an allowlist).
Genuinely special logic stays written out: the HAL `_links`/`_embedded` envelope
and its self-href placeholder, DSpaceObject's `dso=` copy path and its
local-only lastModified/parent, Item's inArchive (whose default depends on
whether an API resource was supplied at all), the `type` literal stamps, and
ResourcePolicy's `_embedded` group fallback.
Effect: 36 membership blocks -> 2 (both genuinely special), 369 -> 336 code
lines. The exhaustive old-vs-new comparison - every class x {no-arg, {}, full
resource, None} x every attribute, plus both `dso=` paths, `Item.from_dso`,
`as_dict()` and `to_dict()` - reports exactly one difference: EntityType(None)
now builds an empty instance instead of raising TypeError, matching every other
model's `api_resource or {}` tolerance. EntityType is never constructed in
either repository; the CHANGELOG records it alongside Group()/User().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes all 16 findings from the code review of this library.
Base branch: this is stacked on
feat/pyproject-tooling-typing(PR #21, still open againstdtq), because the work builds directly on that branch's pyproject/typing migration. Targetingdtqwould have replayed #21's whole diff here. GitHub will retarget this PR todtqautomatically once #21 merges.A. Correctness —
dspace_rest_client/client.py_is_valid_uuidis used byget_resourcepolicy,create_resourcepolicy(bothresource_uuidandgroup_uuid),get_dso,get_itemandget_owningCollection(which previously validated nothing). The oldUUID(uuid)insideexcept ValueErrorletUUID(None)escape as an uncaughtTypeError; every site now logsInvalid ... UUIDand returnsNone._response_message_contains()helper parses the body defensively;api_post,api_post_uri,api_put,api_put_uri,api_delete,api_patchandcreate_bitstreamall go through it. A403with an HTML or empty body now takes the CSRF-refresh/retry path instead of raisingTypeError/JSONDecodeError._embeddedchecks are None-guarded.get_communities,get_collectionsandget_bundle_by_namereturnNoneon a failed or non-JSON response instead of raisingTypeError, matching their documented contracts and theget_bundles/get_bitstreamsidiom.return selfcopy-paste bug fixed.add_metadataandremove_metadatareturned the client on invalid input; they now returnNone, which is what callers expect.create_bitstreamXSRF duplication removed — the inline header/cookie refresh is replaced by the sharedself.update_token(r).B. Correctness —
dspace_rest_client/models.pyGroup()/User()are constructible. Both guardapi_resource = api_resource or {}likeBitstream, soGroup(),User()andcreate_group/create_useron aNoneparse result no longer raiseTypeError.HALResource.links/.embedded,Bitstream.checkSumandInProgressSubmission.sectionsare assigned per instance in__init__, so no instance can mutate class-level state andHALResource().embeddedexists.Item.from_dsoandDSpaceObject(dso=...)deep-copy metadata (and links/embedded), so mutating a copy'smetadata[field]list no longer writes through to the source.models.__all__completed with the 7 missing names (AddressableHALResource,InProgressSubmission,WorkspaceItem,EntityType,RelationshipType,License,Label); a test pins__all__parity with the package re-exports.C. Packaging / release
publish.shno longer runs the deletedsetup.py— it clears artifacts, runspython -m build, validates withtwine checkand uploadsdist/*(no hardcoded0.1.10wheel name).MAINTAINING.mdpoints atpyproject.toml.0.1.10with aCHANGELOG.mdentry for this branch. Bumped to0.2.0rather than0.1.11:requires-pythonmoved 3.8 → 3.10, which is a breaking change for consumers, so a minor bump is the honest signal.pysolrextra documented and enforced. README coverspip install "dspace-rest-client[solr]", andsolr_query()raises an actionableRuntimeErrornaming the extra instead of dying withAttributeErroronNone.D. Lint / CI / config hygiene
import json as _jsonreverted. Grepped both this repo and the consumer repo (src/,mcp/,tests/): nothing outside the library callsapi_post/api_putat all, let alone withjson=as a keyword. Kept the public parameter namejson(zero behaviour change) and switched the module tofrom json import dumps, so nothing is shadowed and no inline lint suppression is needed.F403,F405,F841,E402andE741— only the documentedE501remains (long upstream docstring/log lines).broad-exception-caughtis no longer disabled project-wide; the four deliberate fallback catches carry an inline# pylint: disable=broad-exception-caught, so a new accidentalexcept Exceptionfails lint again.invalid-namestays disabled (the DSpace REST field names are camelCase) and the deadgood-namesallowlist it never applied to is deleted, making the config coherent.data.pop(...)block inupdate_dso.typecheckjob spun up a second runner for the same environment; mypy is now a step of thelintjob with step-levelcontinue-on-errorso incremental typing stays non-blocking, and the version is pinned by bumping themirrors-mypyrev tov2.3.1(current latest).Tests
83 tests pass (was 68 before this branch, 80 at the start of this PR). New regression coverage for the behavioural fixes:
None,int,object()andlistarguments without issuing a request (these raisedTypeError, not theValueErrorthe callers caught).403instead of crashing, andcreate_bitstreamre-authenticates and retries on one.get_communities/get_collections/get_bundle_by_namereturnNonefor both a404and a200with an HTML body.Group(None)/User(None)construct; fresh instances do not sharelinks/checkSum/sections.Item.from_dsoandItem(dso=...)do not write through to the source's metadata.add_metadata(None, ...)/remove_metadata(None, ...)returnNone.solr_query()without the extra raises aRuntimeErrornamingdspace-rest-client[solr].create_bitstreamrefreshes the session CSRF token throughupdate_token.Ruff
0.12.7and pylint3.3.7(the CI-pinned versions) are clean. The consumer repo's full suite (655 tests) also passes against this branch.🤖 Generated with Claude Code