Skip to content

Commit 13e20e9

Browse files
author
Jussi Kukkonen
committed
Metadata API: Make Metadata Generic
When we use Metadata, it is helpful if the specific signed type (and all of the signed types attribute types are correctly annotated. Currently this is not possible. Making Metadata Generic with constraint T, where T = TypeVar("T", "Root", "Timestamp", "Snapshot", "Targets") allows these annotations. Using Generic annotations is completely optional so all existing code still works -- the changes in test code are done to make IDE annotations more useful in the test code, not because they are required. Examples: md = Metadata[Root].from_bytes(data) md:Metadata[Root] = Metadata.from_bytes(data) In both examples md.signed is now statically typed as "Root" allowing IDE annotations and static type checking by mypy. Note that it's not possible to validate that "data" actually contains a root metadata at runtime in these examples as the annotations are _not_ visible at runtime at all: new constructors would have to be added for that. from_file() is now a class method like from_bytes() to make sure both have the same definition of "T" when from_file() calls from_bytes(): This makes mypy happy. Partially fixes #1433 Signed-off-by: Jussi Kukkonen <[email protected]>
1 parent 98cc149 commit 13e20e9

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
@@ -26,12 +26,15 @@
2626
BinaryIO,
2727
ClassVar,
2828
Dict,
29+
Generic,
2930
List,
3031
Mapping,
3132
Optional,
3233
Tuple,
3334
Type,
35+
TypeVar,
3436
Union,
37+
cast,
3538
)
3639

3740
from securesystemslib import exceptions as sslib_exceptions
@@ -56,24 +59,40 @@
5659
# files to have the same major version (the first number) as ours.
5760
SPECIFICATION_VERSION = ["1", "0", "19"]
5861

62+
# T is a Generic type constraint for Metadata.signed
63+
T = TypeVar("T", "Root", "Timestamp", "Snapshot", "Targets")
5964

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

73-
def __init__(
74-
self, signed: "Signed", signatures: "OrderedDict[str, Signature]"
75-
):
76-
self.signed = signed
94+
def __init__(self, signed: T, signatures: "OrderedDict[str, Signature]"):
95+
self.signed: T = signed
7796
self.signatures = signatures
7897

7998
@classmethod
@@ -119,7 +138,8 @@ def from_dict(cls, metadata: Dict[str, Any]) -> "Metadata":
119138
signatures[sig.keyid] = sig
120139

121140
return cls(
122-
signed=inner_cls.from_dict(metadata.pop("signed")),
141+
# Specific type T is not known at static type check time: use cast
142+
signed=cast(T, inner_cls.from_dict(metadata.pop("signed"))),
123143
signatures=signatures,
124144
)
125145

@@ -129,7 +149,7 @@ def from_file(
129149
filename: str,
130150
deserializer: Optional[MetadataDeserializer] = None,
131151
storage_backend: Optional[StorageBackendInterface] = None,
132-
) -> "Metadata":
152+
) -> "Metadata[T]":
133153
"""Loads TUF metadata from file storage.
134154
135155
Arguments:
@@ -156,11 +176,12 @@ def from_file(
156176
with storage_backend.get(filename) as file_obj:
157177
return cls.from_bytes(file_obj.read(), deserializer)
158178

159-
@staticmethod
179+
@classmethod
160180
def from_bytes(
181+
cls,
161182
data: bytes,
162183
deserializer: Optional[MetadataDeserializer] = None,
163-
) -> "Metadata":
184+
) -> "Metadata[T]":
164185
"""Loads TUF metadata from raw data.
165186
166187
Arguments:

0 commit comments

Comments
 (0)