Skip to content

Commit d3441f0

Browse files
author
Jussi Kukkonen
authored
Merge pull request #1457 from jku/use-generics-to-improve-signed-typing
Improve signed typing
2 parents 7901687 + 13e20e9 commit d3441f0

File tree

2 files changed

+42
-21
lines changed

2 files changed

+42
-21
lines changed

tests/test_api.py

+12-12
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def test_to_from_bytes(self):
183183

184184
def test_sign_verify(self):
185185
root_path = os.path.join(self.repo_dir, 'metadata', 'root.json')
186-
root:Root = Metadata.from_file(root_path).signed
186+
root = Metadata[Root].from_file(root_path).signed
187187

188188
# Locate the public keys we need from root
189189
targets_keyid = next(iter(root.roles["targets"].keyids))
@@ -302,7 +302,7 @@ def test_metadata_base(self):
302302
def test_metadata_snapshot(self):
303303
snapshot_path = os.path.join(
304304
self.repo_dir, 'metadata', 'snapshot.json')
305-
snapshot = Metadata.from_file(snapshot_path)
305+
snapshot = Metadata[Snapshot].from_file(snapshot_path)
306306

307307
# Create a MetaFile instance representing what we expect
308308
# the updated data to be.
@@ -321,7 +321,7 @@ def test_metadata_snapshot(self):
321321
def test_metadata_timestamp(self):
322322
timestamp_path = os.path.join(
323323
self.repo_dir, 'metadata', 'timestamp.json')
324-
timestamp = Metadata.from_file(timestamp_path)
324+
timestamp = Metadata[Timestamp].from_file(timestamp_path)
325325

326326
self.assertEqual(timestamp.signed.version, 1)
327327
timestamp.signed.bump_version()
@@ -358,19 +358,19 @@ def test_metadata_timestamp(self):
358358

359359
def test_metadata_verify_delegate(self):
360360
root_path = os.path.join(self.repo_dir, 'metadata', 'root.json')
361-
root = Metadata.from_file(root_path)
361+
root = Metadata[Root].from_file(root_path)
362362
snapshot_path = os.path.join(
363363
self.repo_dir, 'metadata', 'snapshot.json')
364-
snapshot = Metadata.from_file(snapshot_path)
364+
snapshot = Metadata[Snapshot].from_file(snapshot_path)
365365
targets_path = os.path.join(
366366
self.repo_dir, 'metadata', 'targets.json')
367-
targets = Metadata.from_file(targets_path)
367+
targets = Metadata[Targets].from_file(targets_path)
368368
role1_path = os.path.join(
369369
self.repo_dir, 'metadata', 'role1.json')
370-
role1 = Metadata.from_file(role1_path)
370+
role1 = Metadata[Targets].from_file(role1_path)
371371
role2_path = os.path.join(
372372
self.repo_dir, 'metadata', 'role2.json')
373-
role2 = Metadata.from_file(role2_path)
373+
role2 = Metadata[Targets].from_file(role2_path)
374374

375375
# test the expected delegation tree
376376
root.verify_delegate('root', root)
@@ -468,7 +468,7 @@ def test_role_class(self):
468468
def test_metadata_root(self):
469469
root_path = os.path.join(
470470
self.repo_dir, 'metadata', 'root.json')
471-
root = Metadata.from_file(root_path)
471+
root = Metadata[Root].from_file(root_path)
472472

473473
# Add a second key to root role
474474
root_key2 = import_ed25519_publickey_from_file(
@@ -530,7 +530,7 @@ def test_delegation_class(self):
530530
def test_metadata_targets(self):
531531
targets_path = os.path.join(
532532
self.repo_dir, 'metadata', 'targets.json')
533-
targets = Metadata.from_file(targets_path)
533+
targets = Metadata[Targets].from_file(targets_path)
534534

535535
# Create a fileinfo dict representing what we expect the updated data to be
536536
filename = 'file2.txt'
@@ -560,7 +560,7 @@ def test_length_and_hash_validation(self):
560560
# for untrusted metadata file to verify.
561561
timestamp_path = os.path.join(
562562
self.repo_dir, 'metadata', 'timestamp.json')
563-
timestamp = Metadata.from_file(timestamp_path)
563+
timestamp = Metadata[Timestamp].from_file(timestamp_path)
564564
snapshot_metafile = timestamp.signed.meta["snapshot.json"]
565565

566566
snapshot_path = os.path.join(
@@ -603,7 +603,7 @@ def test_length_and_hash_validation(self):
603603
# Test target files' hash and length verification
604604
targets_path = os.path.join(
605605
self.repo_dir, 'metadata', 'targets.json')
606-
targets = Metadata.from_file(targets_path)
606+
targets = Metadata[Targets].from_file(targets_path)
607607
file1_targetfile = targets.signed.targets['file1.txt']
608608
filepath = os.path.join(
609609
self.repo_dir, 'targets', 'file1.txt')

tuf/api/metadata.py

+30-9
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,15 @@
2828
BinaryIO,
2929
ClassVar,
3030
Dict,
31+
Generic,
3132
List,
3233
Mapping,
3334
Optional,
3435
Tuple,
3536
Type,
37+
TypeVar,
3638
Union,
39+
cast,
3740
)
3841

3942
from securesystemslib import exceptions as sslib_exceptions
@@ -58,24 +61,40 @@
5861
# files to have the same major version (the first number) as ours.
5962
SPECIFICATION_VERSION = ["1", "0", "19"]
6063

64+
# T is a Generic type constraint for Metadata.signed
65+
T = TypeVar("T", "Root", "Timestamp", "Snapshot", "Targets")
6166

62-
class Metadata:
67+
68+
class Metadata(Generic[T]):
6369
"""A container for signed TUF metadata.
6470
6571
Provides methods to convert to and from dictionary, read and write to and
6672
from file and to create and verify metadata signatures.
6773
74+
Metadata[T] is a generic container type where T can be any one type of
75+
[Root, Timestamp, Snapshot, Targets]. The purpose of this is to allow
76+
static type checking of the signed attribute in code using Metadata::
77+
78+
root_md = Metadata[Root].from_file("root.json")
79+
# root_md type is now Metadata[Root]. This means signed and its
80+
# attributes like consistent_snapshot are now statically typed and the
81+
# types can be verified by static type checkers and shown by IDEs
82+
print(root_md.signed.consistent_snapshot)
83+
84+
Using a type constraint is not required but not doing so means T is not a
85+
specific type so static typing cannot happen. Note that the type constraint
86+
"[Root]" is not validated at runtime (as pure annotations are not available
87+
then).
88+
6889
Attributes:
6990
signed: A subclass of Signed, which has the actual metadata payload,
7091
i.e. one of Targets, Snapshot, Timestamp or Root.
7192
signatures: An ordered dictionary of keyids to Signature objects, each
7293
signing the canonical serialized representation of 'signed'.
7394
"""
7495

75-
def __init__(
76-
self, signed: "Signed", signatures: "OrderedDict[str, Signature]"
77-
):
78-
self.signed = signed
96+
def __init__(self, signed: T, signatures: "OrderedDict[str, Signature]"):
97+
self.signed: T = signed
7998
self.signatures = signatures
8099

81100
@classmethod
@@ -121,7 +140,8 @@ def from_dict(cls, metadata: Dict[str, Any]) -> "Metadata":
121140
signatures[sig.keyid] = sig
122141

123142
return cls(
124-
signed=inner_cls.from_dict(metadata.pop("signed")),
143+
# Specific type T is not known at static type check time: use cast
144+
signed=cast(T, inner_cls.from_dict(metadata.pop("signed"))),
125145
signatures=signatures,
126146
)
127147

@@ -131,7 +151,7 @@ def from_file(
131151
filename: str,
132152
deserializer: Optional[MetadataDeserializer] = None,
133153
storage_backend: Optional[StorageBackendInterface] = None,
134-
) -> "Metadata":
154+
) -> "Metadata[T]":
135155
"""Loads TUF metadata from file storage.
136156
137157
Arguments:
@@ -158,11 +178,12 @@ def from_file(
158178
with storage_backend.get(filename) as file_obj:
159179
return cls.from_bytes(file_obj.read(), deserializer)
160180

161-
@staticmethod
181+
@classmethod
162182
def from_bytes(
183+
cls,
163184
data: bytes,
164185
deserializer: Optional[MetadataDeserializer] = None,
165-
) -> "Metadata":
186+
) -> "Metadata[T]":
166187
"""Loads TUF metadata from raw data.
167188
168189
Arguments:

0 commit comments

Comments
 (0)