Skip to content

Commit 0be01ec

Browse files
jmclaude
andcommitted
refactor: move model attribute defaults from class level to __init__
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>
1 parent 8465bb7 commit 0be01ec

3 files changed

Lines changed: 79 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@ Date: Unreleased
1818
5. `get_communities`, `get_collections` and `get_bundle_by_name` return `None` on a
1919
failed or non-JSON response instead of raising `TypeError`.
2020
6. `add_metadata` / `remove_metadata` return `None` (not the client) on invalid input.
21-
7. Model instances no longer share class-level `links`, `embedded`, `checkSum` or
22-
`sections` dicts, `Group()` / `User()` accept a `None` API resource, and
23-
`Item.from_dso` / `DSpaceObject(dso=...)` deep-copy metadata instead of aliasing it.
21+
7. Model attribute defaults moved from the class body into `__init__` as plain
22+
instance attributes, so no instance can share (or mutate) a class-level
23+
`links`, `embedded`, `metadata`, `checkSum` or `sections` dict. `Group()` /
24+
`User()` accept a `None` API resource, and `Item.from_dso` /
25+
`DSpaceObject(dso=...)` deep-copy metadata instead of aliasing it. Side effect:
26+
`id` on a `DSpaceObject` (and its subclasses) and `label` on an `EntityType`
27+
now default to `None` rather than raising `AttributeError` when the API
28+
resource omits them.
2429
8. `models.__all__` exports the full model surface re-exported by the package.
2530
9. Moved direct Solr support to the documented `solr` optional dependency group;
2631
`solr_query()` raises an actionable `RuntimeError` when the extra is missing.

dspace_rest_client/models.py

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,20 @@
2727
class HALResource:
2828
"""
2929
Base class to represent HAL+JSON API resources
30+
31+
Every attribute is a plain instance attribute assigned here in __init__.
32+
Nothing is declared at class level: a class-level default is shared by all
33+
instances, which for a mutable default (links, embedded, metadata,
34+
checkSum, sections) means one instance's mutation leaks into every other.
3035
"""
31-
links: dict[str, Any]
32-
type: str | None = None
3336

3437
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
3538
"""
3639
Default constructor
3740
@param api_resource: optional API resource (JSON) from a GET response or successful POST can populate instance
3841
"""
3942
self._from_d: dict[str, Any] | None = None
43+
self.type: str | None = None
4044
self.links: dict[str, Any] = {}
4145
self.embedded: dict[str, Any] = {}
4246
if api_resource is not None:
@@ -52,10 +56,10 @@ def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
5256

5357

5458
class AddressableHALResource(HALResource):
55-
id: Any = None
5659

5760
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
5861
super().__init__(api_resource)
62+
self.id: Any = None
5963
if api_resource is not None:
6064
if 'id' in api_resource:
6165
self.id = api_resource['id']
@@ -68,11 +72,6 @@ class ExternalDataObject(HALResource):
6872
"""
6973
Generic External Data Object as configured in DSpace's external data providers framework
7074
"""
71-
id: Any = None
72-
display: Any = None
73-
value: Any = None
74-
externalSource: Any = None
75-
metadata: dict[str, Any]
7675

7776
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
7877
"""
@@ -81,6 +80,10 @@ def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
8180
"""
8281
super().__init__(api_resource)
8382

83+
self.id: Any = None
84+
self.display: Any = None
85+
self.value: Any = None
86+
self.externalSource: Any = None
8487
self.metadata: dict[str, Any] = {}
8588

8689
if api_resource is not None:
@@ -114,13 +117,6 @@ class DSpaceObject(HALResource):
114117
operations are included in the dict returned by asDict(). Implements toJSON() as well.
115118
This class can be used on its own but is generally expected to be extended by other types: Item, Bitstream, etc.
116119
"""
117-
uuid: str | None = None
118-
name: str | None = None
119-
handle: str | None = None
120-
metadata: dict[str, Any]
121-
lastModified: Any = None
122-
type: str | None = None
123-
parent: Any = None
124120

125121
def __init__(
126122
self,
@@ -133,6 +129,12 @@ def __init__(
133129
"""
134130
super().__init__(api_resource)
135131
self.type = None
132+
self.id: Any = None
133+
self.uuid: str | None = None
134+
self.name: str | None = None
135+
self.handle: str | None = None
136+
self.lastModified: Any = None
137+
self.parent: Any = None
136138
self.metadata: dict[str, Any] = {}
137139

138140
if dso is not None:
@@ -247,10 +249,6 @@ class Item(SimpleDSpaceObject):
247249
"""
248250
Extends DSpaceObject to implement specific attributes and functions for items
249251
"""
250-
type = 'item'
251-
inArchive = False
252-
discoverable = False
253-
withdrawn = False
254252

255253
def __init__(
256254
self,
@@ -267,11 +265,20 @@ def __init__(
267265
else:
268266
super().__init__(api_resource)
269267

268+
# defaults for the no-api_resource case; a resource overrides them below.
269+
# NB: unlike the other subclasses, Item only stamps `type` when it is
270+
# built from a resource. DSpaceObject.__init__ has already set
271+
# self.type = None, so the old class-level `type = 'item'` was shadowed
272+
# on every instance and never readable - dropping it changes nothing.
273+
self.inArchive = False
274+
self.discoverable = False
275+
self.withdrawn = False
276+
270277
if api_resource is not None:
271278
self.type = 'item'
272-
self.inArchive = api_resource['inArchive'] if 'inArchive' in api_resource else True
273-
self.discoverable = api_resource['discoverable'] if 'discoverable' in api_resource else False
274-
self.withdrawn = api_resource['withdrawn'] if 'withdrawn' in api_resource else False
279+
self.inArchive = api_resource.get('inArchive', True)
280+
self.discoverable = api_resource.get('discoverable', False)
281+
self.withdrawn = api_resource.get('withdrawn', False)
275282

276283
def get_metadata_values(self, field: str) -> list:
277284
"""
@@ -306,7 +313,6 @@ class Community(SimpleDSpaceObject):
306313
"""
307314
Extends DSpaceObject to implement specific attributes and functions for communities
308315
"""
309-
type = 'community'
310316

311317
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
312318
"""
@@ -321,7 +327,6 @@ class Collection(SimpleDSpaceObject):
321327
"""
322328
Extends DSpaceObject to implement specific attributes and functions for collections
323329
"""
324-
type = 'collection'
325330

326331
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
327332
"""
@@ -336,7 +341,6 @@ class Bundle(DSpaceObject):
336341
"""
337342
Extends DSpaceObject to implement specific attributes and functions for bundles
338343
"""
339-
type = 'bundle'
340344

341345
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
342346
"""
@@ -351,12 +355,6 @@ class Bitstream(DSpaceObject):
351355
"""
352356
Extends DSpaceObject to implement specific attributes and functions for bundles
353357
"""
354-
type = 'bitstream'
355-
# Bitstream has a few extra fields specific to file storage
356-
bundleName: str | None = None
357-
sizeBytes: int | None = None
358-
checkSum: dict[str, Any]
359-
sequenceId: int | None = None
360358

361359
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
362360
"""
@@ -365,10 +363,11 @@ def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
365363
"""
366364
super().__init__(api_resource)
367365
self.type = 'bitstream'
368-
self.bundleName = None
369-
self.sizeBytes = None
370-
self.checkSum = {'checkSumAlgorithm': 'MD5', 'value': None}
371-
self.sequenceId = None
366+
# Bitstream has a few extra fields specific to file storage
367+
self.bundleName: str | None = None
368+
self.sizeBytes: int | None = None
369+
self.checkSum: dict[str, Any] = {'checkSumAlgorithm': 'MD5', 'value': None}
370+
self.sequenceId: int | None = None
372371
api_resource = api_resource or {}
373372
if 'bundleName' in api_resource:
374373
self.bundleName = api_resource['bundleName']
@@ -394,9 +393,6 @@ class Group(DSpaceObject):
394393
"""
395394
Extends DSpaceObject to implement specific attributes and methods for groups (aka. EPersonGroups)
396395
"""
397-
type = 'group'
398-
name = None
399-
permanent = False
400396

401397
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
402398
"""
@@ -427,14 +423,6 @@ class User(SimpleDSpaceObject):
427423
"""
428424
Extends DSpaceObject to implement specific attributes and methods for users (aka. EPersons)
429425
"""
430-
type = 'user'
431-
name = None
432-
netid = None
433-
lastActive = None
434-
canLogIn = False
435-
email = None
436-
requireCertificate = False
437-
selfRegistered = False
438426

439427
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
440428
"""
@@ -479,15 +467,11 @@ def as_dict(self) -> dict[str, Any]:
479467

480468

481469
class InProgressSubmission(AddressableHALResource):
482-
lastModified: Any = None
483-
step: Any = None
484-
sections: dict[str, Any]
485-
type: str | None = None
486470

487471
def __init__(self, api_resource: dict[str, Any] | None = None) -> None:
488472
super().__init__(api_resource)
489-
self.lastModified = None
490-
self.step = None
473+
self.lastModified: Any = None
474+
self.step: Any = None
491475
self.sections: dict[str, Any] = {}
492476
self.type = None
493477
api_resource = api_resource or {}
@@ -524,6 +508,7 @@ class EntityType(AddressableHALResource):
524508

525509
def __init__(self, api_resource: dict[str, Any]) -> None:
526510
super().__init__(api_resource)
511+
self.label: Any = None
527512
if 'label' in api_resource:
528513
self.label = api_resource['label']
529514
if 'type' in api_resource:

tests/test_models.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import unittest
1111

1212
import _helpers # noqa: F401 (bootstraps sys.path for direct runs)
13+
from dspace_rest_client import models as models_module
1314
from dspace_rest_client.models import (
1415
DSpaceObject, HALResource, Item, Community, Collection, Bundle, Bitstream,
1516
Group, User, InProgressSubmission, ResourcePolicy)
@@ -90,6 +91,39 @@ def test_submission_sections_are_isolated_between_instances(self):
9091

9192
self.assertNotIn("license", second.sections)
9293

94+
def test_no_model_declares_attribute_defaults_at_class_level(self):
95+
"""The structural guarantee behind the three tests above.
96+
97+
A class-level default is shared by every instance, so a mutable one
98+
(links, metadata, checkSum, sections) lets one object's mutation leak
99+
into all the others. Every attribute is assigned in __init__ instead;
100+
this test fails if anyone reintroduces a class-body default.
101+
"""
102+
for cls in vars(models_module).values():
103+
if not isinstance(cls, type) or cls.__module__ != models_module.__name__:
104+
continue
105+
declared = sorted(
106+
name for name, value in vars(cls).items()
107+
if not name.startswith("__")
108+
and not isinstance(value, (property, classmethod, staticmethod))
109+
and not callable(value))
110+
with self.subTest(model=cls.__name__):
111+
self.assertEqual(declared, [], (
112+
f"{cls.__name__} declares {declared} at class level; "
113+
"move the default into __init__ as self.<attr> = ..."))
114+
115+
def test_every_model_instance_defines_the_attributes_its_dict_reports(self):
116+
# nothing may fall back to a class attribute: what __init__ assigns is
117+
# the whole public surface of the instance.
118+
for cls, args in ((HALResource, ()), (DSpaceObject, ()), (Item, ()),
119+
(Community, ()), (Collection, ()), (Bundle, ()),
120+
(Bitstream, ()), (Group, ()), (User, ()),
121+
(InProgressSubmission, ({},))):
122+
with self.subTest(model=cls.__name__):
123+
instance = cls(*args)
124+
for name in ("type", "links", "embedded"):
125+
self.assertIn(name, vars(instance))
126+
93127

94128
class TestCommunityCollection(unittest.TestCase):
95129

0 commit comments

Comments
 (0)