Skip to content

Commit 1cdae24

Browse files
committed
Refactor metadata constructors and add factory
This commit better separates the Metadata class model from the Metadata wireline format, by tailoring the constructors towards class-based parameters and adding an additional factory classmethod that creates Metadata objects based on the wireline json/dictionary metadata representation. (pythonic way of constructor overloading). This 'from_dict' factory method recurses into the 'from_dict' methods of each contained complex field/attribute that is also represented by a class. Currently 'signed' is the only such attribute. This commit further: - Changes optional constructor keyword arguments to mandatory positional arguments: Reduces code and simplifies usage by restricting it. For now, users are unlikely to call constructor directly anyway, but the 'from_dict' factory (or its 'from_json_file' wrapper) instead. - Removes Signed.__expiration (datetime) vs. Signed.expires (datestring) dichotomy: Keeping only one representation of the same attribute in memory makes the interface simpler and less ambiguous. We choose the datetime object, because it is more convenient to modify. Transformation from and to the string format required by the tuf wireline format is performed in the corresponding metadata de/serialization methods, i.e. ('to_dict' and 'from_dict'). Signed-off-by: Lukas Puehringer <[email protected]>
1 parent e49ebe1 commit 1cdae24

File tree

2 files changed

+85
-54
lines changed

2 files changed

+85
-54
lines changed

tests/test_api.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import tempfile
1717
import unittest
1818

19-
from datetime import timedelta
19+
from datetime import datetime, timedelta
2020
from dateutil.relativedelta import relativedelta
2121

2222
IS_PY_VERSION_SUPPORTED = sys.version_info >= (3, 6)
@@ -186,11 +186,11 @@ def test_metadata_base(self):
186186
self.assertEqual(md.signed.version, 1)
187187
md.signed.bump_version()
188188
self.assertEqual(md.signed.version, 2)
189-
self.assertEqual(md.signed.expires, '2030-01-01T00:00:00Z')
189+
self.assertEqual(md.signed.expires, datetime(2030, 1, 1, 0, 0))
190190
md.signed.bump_expiration()
191-
self.assertEqual(md.signed.expires, '2030-01-02T00:00:00Z')
191+
self.assertEqual(md.signed.expires, datetime(2030, 1, 2, 0, 0))
192192
md.signed.bump_expiration(timedelta(days=365))
193-
self.assertEqual(md.signed.expires, '2031-01-02T00:00:00Z')
193+
self.assertEqual(md.signed.expires, datetime(2031, 1, 2, 0, 0))
194194

195195

196196
def test_metadata_snapshot(self):
@@ -218,20 +218,20 @@ def test_metadata_timestamp(self):
218218
timestamp.signed.bump_version()
219219
self.assertEqual(timestamp.signed.version, 2)
220220

221-
self.assertEqual(timestamp.signed.expires, '2030-01-01T00:00:00Z')
221+
self.assertEqual(timestamp.signed.expires, datetime(2030, 1, 1, 0, 0))
222222
timestamp.signed.bump_expiration()
223-
self.assertEqual(timestamp.signed.expires, '2030-01-02T00:00:00Z')
223+
self.assertEqual(timestamp.signed.expires, datetime(2030, 1, 2, 0, 0))
224224
timestamp.signed.bump_expiration(timedelta(days=365))
225-
self.assertEqual(timestamp.signed.expires, '2031-01-02T00:00:00Z')
225+
self.assertEqual(timestamp.signed.expires, datetime(2031, 1, 2, 0, 0))
226226

227227
# Test whether dateutil.relativedelta works, this provides a much
228228
# easier to use interface for callers
229229
delta = relativedelta(days=1)
230230
timestamp.signed.bump_expiration(delta)
231-
self.assertEqual(timestamp.signed.expires, '2031-01-03T00:00:00Z')
231+
self.assertEqual(timestamp.signed.expires, datetime(2031, 1, 3, 0, 0))
232232
delta = relativedelta(years=5)
233233
timestamp.signed.bump_expiration(delta)
234-
self.assertEqual(timestamp.signed.expires, '2036-01-03T00:00:00Z')
234+
self.assertEqual(timestamp.signed.expires, datetime(2036, 1, 3, 0, 0))
235235

236236
hashes = {'sha256': '0ae9664468150a9aa1e7f11feecb32341658eb84292851367fea2da88e8a58dc'}
237237
fileinfo = timestamp.signed.meta['snapshot.json']

tuf/api/metadata.py

Lines changed: 76 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,7 @@ class Metadata():
6969
]
7070
7171
"""
72-
def __init__(
73-
self, signed: 'Signed' = None, signatures: list = None) -> None:
74-
# TODO: How much init magic do we want?
72+
def __init__(self, signed: 'Signed', signatures: list) -> None:
7573
self.signed = signed
7674
self.signatures = signatures
7775

@@ -167,18 +165,37 @@ def from_json_file(
167165
168166
Raises:
169167
securesystemslib.exceptions.StorageError: The file cannot be read.
170-
securesystemslib.exceptions.Error, ValueError: The metadata cannot
171-
be parsed.
168+
securesystemslib.exceptions.Error, ValueError, KeyError: The
169+
metadata cannot be parsed.
172170
173171
Returns:
174172
A TUF Metadata object.
175173
176174
"""
177-
signable = load_json_file(filename, storage_backend)
175+
return cls.from_dict(load_json_file(filename, storage_backend))
176+
177+
@classmethod
178+
def from_dict(cls, metadata: JsonDict) -> 'Metadata':
179+
"""Creates Metadata object from its JSON/dict representation.
180+
181+
Calls 'from_dict' for any complex metadata attribute represented by a
182+
class also that has a 'from_dict' factory method. (Currently this is
183+
only the signed attribute.)
184+
185+
Arguments:
186+
metadata: TUF metadata in JSON/dict representation, as e.g.
187+
returned by 'json.loads'.
188+
189+
Raises:
190+
KeyError: The metadata dict format is invalid.
191+
ValueError: The metadata has an unrecognized signed._type field.
178192
179-
# TODO: Should we use constants?
180-
# And/or maybe a dispatch table? (<-- maybe too much magic)
181-
_type = signable['signed']['_type']
193+
Returns:
194+
A TUF Metadata object.
195+
196+
"""
197+
# Dispatch to contained metadata class on metadata _type field.
198+
_type = metadata['signed']['_type']
182199

183200
if _type == 'targets':
184201
inner_cls = Targets
@@ -192,9 +209,13 @@ def from_json_file(
192209
else:
193210
raise ValueError(f'unrecognized metadata type "{_type}"')
194211

195-
return Metadata(
196-
signed=inner_cls(**signable['signed']),
197-
signatures=signable['signatures'])
212+
# NOTE: If Signature becomes a class, we should iterate over
213+
# metadata['signatures'], call Signature.from_dict for each item, and
214+
# pass a list of Signature objects to the Metadata constructor intead.
215+
return cls(
216+
signed=inner_cls.from_dict(metadata['signed']),
217+
signatures=metadata['signatures'])
218+
198219

199220
def to_json_file(
200221
self, filename: str, compact: bool = False,
@@ -236,41 +257,48 @@ class Signed:
236257
# we keep it to match spec terminology (I often refer to this as "payload",
237258
# or "inner metadata")
238259

239-
# TODO: Re-think default values. It might be better to pass some things
240-
# as args and not es kwargs. Then we'd need to pop those from
241-
# signable["signed"] in read_from_json and pass them explicitly, which
242-
# some say is better than implicit. :)
243260
def __init__(
244-
self, _type: str = None, version: int = 0,
245-
spec_version: str = None, expires: datetime = None
246-
) -> None:
247-
# TODO: How much init magic do we want?
261+
self, _type: str, version: int, spec_version: str,
262+
expires: datetime) -> None:
248263

249264
self._type = _type
265+
self.version = version
250266
self.spec_version = spec_version
267+
self.expires = expires
251268

252-
# We always intend times to be UTC
253-
# NOTE: we could do this with datetime.fromisoformat() but that is not
254-
# available in Python 2.7's datetime
255-
# NOTE: Store as datetime object for convenient handling, use 'expires'
256-
# property to get the TUF metadata format representation
257-
self.__expiration = iso8601.parse_date(expires).replace(tzinfo=None)
258-
269+
# TODO: Should we separate data validation from constructor?
259270
if version < 0:
260271
raise ValueError(f'version must be < 0, got {version}')
261272
self.version = version
262273

274+
@classmethod
275+
def from_dict(cls, signed_dict) -> 'Signed':
276+
"""Creates Signed object from its JSON/dict representation. """
277+
278+
# Convert 'expires' TUF metadata string to a datetime object, which is
279+
# what the constructor expects and what we store. The inverse operation
280+
# is implemented in 'to_dict'.
281+
signed_dict['expires'] = iso8601.parse_date(
282+
signed_dict['expires']).replace(tzinfo=None)
283+
# NOTE: We write the converted 'expires' back into 'signed_dict' above
284+
# so that we can pass it to the constructor as '**signed_dict' below,
285+
# along with other fields that belong to Signed subclasses.
286+
# Any 'from_dict'(-like) conversions of fields that correspond to a
287+
# subclass should be performed in the 'from_dict' method of that
288+
# subclass and also be written back into 'signed_dict' before calling
289+
# super().from_dict.
290+
291+
# NOTE: cls might be a subclass of Signed, if 'from_dict' was called on
292+
# that subclass (see e.g. Metadata.from_dict).
293+
return cls(**signed_dict)
263294

264-
@property
265-
def expires(self) -> str:
266-
return self.__expiration.isoformat() + 'Z'
267295
def to_canonical_bytes(self) -> bytes:
268296
"""Returns the UTF-8 encoded canonical JSON representation of self. """
269297
return encode_canonical(self.to_dict()).encode('UTF-8')
270298

271299
def bump_expiration(self, delta: timedelta = timedelta(days=1)) -> None:
272300
"""Increments the expires attribute by the passed timedelta. """
273-
self.__expiration = self.__expiration + delta
301+
self.expires += delta
274302

275303
def bump_version(self) -> None:
276304
"""Increments the metadata version number by 1."""
@@ -282,7 +310,7 @@ def to_dict(self) -> JsonDict:
282310
'_type': self._type,
283311
'version': self.version,
284312
'spec_version': self.spec_version,
285-
'expires': self.expires
313+
'expires': self.expires.isoformat() + 'Z'
286314
}
287315

288316
class Timestamp(Signed):
@@ -304,10 +332,11 @@ class Timestamp(Signed):
304332
}
305333
306334
"""
307-
def __init__(self, meta: JsonDict = None, **kwargs) -> None:
308-
super().__init__(**kwargs)
309-
# TODO: How much init magic do we want?
310-
# TODO: Is there merit in creating classes for dict fields?
335+
def __init__(
336+
self, _type: str, version: int, spec_version: str,
337+
expires: datetime, meta: JsonDict) -> None:
338+
super().__init__(_type, version, spec_version, expires)
339+
# TODO: Add class for meta
311340
self.meta = meta
312341

313342
def to_dict(self) -> JsonDict:
@@ -353,10 +382,11 @@ class Snapshot(Signed):
353382
}
354383
355384
"""
356-
def __init__(self, meta: JsonDict = None, **kwargs) -> None:
357-
# TODO: How much init magic do we want?
358-
# TODO: Is there merit in creating classes for dict fields?
359-
super().__init__(**kwargs)
385+
def __init__(
386+
self, _type: str, version: int, spec_version: str,
387+
expires: datetime, meta: JsonDict) -> None:
388+
super().__init__(_type, version, spec_version, expires)
389+
# TODO: Add class for meta
360390
self.meta = meta
361391

362392
def to_dict(self) -> JsonDict:
@@ -436,14 +466,15 @@ class Targets(Signed):
436466
437467
"""
438468
def __init__(
439-
self, targets: JsonDict = None, delegations: JsonDict = None,
440-
**kwargs) -> None:
441-
# TODO: How much init magic do we want?
442-
# TODO: Is there merit in creating classes for dict fields?
443-
super().__init__(**kwargs)
469+
self, _type: str, version: int, spec_version: str,
470+
expires: datetime, targets: JsonDict, delegations: JsonDict
471+
) -> None:
472+
super().__init__(_type, version, spec_version, expires)
473+
# TODO: Add class for meta
444474
self.targets = targets
445475
self.delegations = delegations
446476

477+
447478
def to_dict(self) -> JsonDict:
448479
"""Returns the JSON-serializable dictionary representation of self. """
449480
json_dict = super().to_dict()

0 commit comments

Comments
 (0)