python-pages 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ """python-docx-shaped core properties stored as an explicit Pages extension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import plistlib
7
+
8
+ from .pages import PagesDocument
9
+
10
+
11
+ CORE_PROPERTIES_MEMBER = "Metadata/PythonPagesCoreProperties.plist"
12
+ LEGACY_CORE_PROPERTIES_MEMBER = "Metadata/IWALabCoreProperties.plist"
13
+ _STRING_PROPERTIES = (
14
+ "author",
15
+ "category",
16
+ "comments",
17
+ "content_status",
18
+ "identifier",
19
+ "keywords",
20
+ "language",
21
+ "last_modified_by",
22
+ "subject",
23
+ "title",
24
+ "version",
25
+ )
26
+ _DATE_PROPERTIES = ("created", "last_printed", "modified")
27
+
28
+
29
+ class CoreProperties:
30
+ """Dublin Core facade for concepts Pages does not natively archive.
31
+
32
+ Values live in a namespaced package member so native Pages metadata is not
33
+ overloaded with meanings its schema does not define.
34
+ """
35
+
36
+ def __init__(self, package: PagesDocument):
37
+ self._package = package
38
+ raw = package.package_data(CORE_PROPERTIES_MEMBER)
39
+ if raw is None:
40
+ # Read documents produced before the project was renamed. New
41
+ # writes always use the python-pages namespace above.
42
+ raw = package.package_data(LEGACY_CORE_PROPERTIES_MEMBER)
43
+ if raw is None:
44
+ self._values: dict[str, str | int] = {}
45
+ return
46
+ parsed = plistlib.loads(raw)
47
+ if not isinstance(parsed, dict) or parsed.get("formatVersion") != 1:
48
+ raise ValueError("unsupported python-pages core-properties format")
49
+ values = parsed.get("properties", {})
50
+ if not isinstance(values, dict):
51
+ raise ValueError("invalid python-pages core-properties payload")
52
+ self._values = dict(values)
53
+
54
+ def _save(self) -> None:
55
+ payload = {"formatVersion": 1, "properties": self._values}
56
+ self._package.set_package_data(
57
+ CORE_PROPERTIES_MEMBER,
58
+ plistlib.dumps(payload, fmt=plistlib.FMT_XML, sort_keys=True),
59
+ )
60
+
61
+ def _string(self, name: str) -> str:
62
+ value = self._values.get(name, "")
63
+ return value if isinstance(value, str) else ""
64
+
65
+ def _set_string(self, name: str, value: str) -> None:
66
+ if not isinstance(value, str):
67
+ raise TypeError(f"{name} must be a string")
68
+ if len(value) > 255:
69
+ raise ValueError(f"{name} must not exceed 255 characters")
70
+ self._values[name] = value
71
+ self._save()
72
+
73
+ def _date(self, name: str) -> dt.datetime | None:
74
+ value = self._values.get(name)
75
+ if not isinstance(value, str):
76
+ return None
77
+ try:
78
+ return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
79
+ except ValueError:
80
+ return None
81
+
82
+ def _set_date(self, name: str, value: dt.datetime | None) -> None:
83
+ if value is None:
84
+ self._values.pop(name, None)
85
+ self._save()
86
+ return
87
+ if not isinstance(value, dt.datetime):
88
+ raise TypeError(f"{name} must be a datetime or None")
89
+ if value.tzinfo is None:
90
+ value = value.replace(tzinfo=dt.timezone.utc)
91
+ else:
92
+ value = value.astimezone(dt.timezone.utc)
93
+ self._values[name] = value.replace(microsecond=0).isoformat().replace("+00:00", "Z")
94
+ self._save()
95
+
96
+ @property
97
+ def revision(self) -> int:
98
+ value = self._values.get("revision", 0)
99
+ return value if isinstance(value, int) and value >= 0 else 0
100
+
101
+ @revision.setter
102
+ def revision(self, value: int) -> None:
103
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
104
+ raise ValueError(f"revision property requires positive int, got {value!r}")
105
+ self._values["revision"] = value
106
+ self._save()
107
+
108
+
109
+ def _string_property(name: str):
110
+ return property(
111
+ lambda self: self._string(name),
112
+ lambda self, value: self._set_string(name, value),
113
+ )
114
+
115
+
116
+ def _date_property(name: str):
117
+ return property(
118
+ lambda self: self._date(name),
119
+ lambda self, value: self._set_date(name, value),
120
+ )
121
+
122
+
123
+ for _name in _STRING_PROPERTIES:
124
+ setattr(CoreProperties, _name, _string_property(_name))
125
+ for _name in _DATE_PROPERTIES:
126
+ setattr(CoreProperties, _name, _date_property(_name))
Binary file
pages/document.py ADDED
@@ -0,0 +1,215 @@
1
+ """Middle-layer access to TP document geometry and header/footer storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import struct
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .protobuf import Message
10
+ from .text import LocatedObject, parse_attribute_table, parse_reference, unpack_packed_varints
11
+
12
+ if TYPE_CHECKING:
13
+ from .pages import PagesDocument
14
+
15
+
16
+ TYPE_DOCUMENT = 10000
17
+ TYPE_SECTION = 10011
18
+ TYPE_SECTION_TEMPLATE = 10143
19
+
20
+ DOCUMENT_USES_SINGLE_HEADER_FOOTER = 21
21
+ DOCUMENT_PAGE_WIDTH = 30
22
+ DOCUMENT_PAGE_HEIGHT = 31
23
+ DOCUMENT_LEFT_MARGIN = 32
24
+ DOCUMENT_RIGHT_MARGIN = 33
25
+ DOCUMENT_TOP_MARGIN = 34
26
+ DOCUMENT_BOTTOM_MARGIN = 35
27
+ DOCUMENT_HEADER_MARGIN = 36
28
+ DOCUMENT_FOOTER_MARGIN = 37
29
+ DOCUMENT_PAGE_SCALE = 38
30
+ DOCUMENT_ORIENTATION = 42
31
+
32
+ SECTION_FIRST_PAGE_DIFFERENT = 18
33
+ SECTION_EVEN_ODD_DIFFERENT = 19
34
+ SECTION_FIRST_TEMPLATE = 23
35
+ SECTION_EVEN_TEMPLATE = 24
36
+ SECTION_ODD_TEMPLATE = 25
37
+
38
+ TEMPLATE_HEADERS = 1
39
+ TEMPLATE_FOOTERS = 2
40
+ STORAGE_TEXT = 3
41
+
42
+ # These fields have ObjectAttributeTable wire shape in the supplied header and
43
+ # footer storage objects. Nonzero boundaries require Stage-C offset rewriting.
44
+ HEADER_FOOTER_ATTRIBUTE_TABLES = (5, 6, 7, 8, 9, 11, 12, 14, 24)
45
+
46
+
47
+ def _float_bits(value: float) -> int:
48
+ return int.from_bytes(struct.pack("<f", float(value)), "little")
49
+
50
+
51
+ def _bits_float(value: int) -> float:
52
+ return struct.unpack("<f", int(value).to_bytes(4, "little"))[0]
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class PageGeometry:
57
+ page_width: float | None
58
+ page_height: float | None
59
+ left_margin: float | None
60
+ right_margin: float | None
61
+ top_margin: float | None
62
+ bottom_margin: float | None
63
+ header_margin: float | None
64
+ footer_margin: float | None
65
+ orientation: int
66
+
67
+
68
+ class DocumentLayout:
69
+ """Mutation facade for a package's single Pages document/section model."""
70
+
71
+ def __init__(self, document: "PagesDocument", body_storage: LocatedObject):
72
+ self.document = document
73
+ roots = [obj for obj in document.object_messages() if obj.info.type_id == TYPE_DOCUMENT]
74
+ if len(roots) != 1:
75
+ raise ValueError(f"expected one TP.DocumentArchive, found {len(roots)}")
76
+ self.root = roots[0]
77
+ self.section = self._find_section(body_storage)
78
+
79
+ def _find_section(self, storage: LocatedObject) -> LocatedObject:
80
+ references = storage.info.message.one(5)
81
+ identifiers = (
82
+ unpack_packed_varints(bytes(references.value)) if references is not None else []
83
+ )
84
+ sections = [
85
+ self.document.objects()[identifier]
86
+ for identifier in identifiers
87
+ if identifier in self.document.objects()
88
+ and self.document.objects()[identifier].info.type_id == TYPE_SECTION
89
+ ]
90
+ if len(sections) != 1:
91
+ raise ValueError(
92
+ "expected body storage to reference one TP.SectionArchive, "
93
+ f"found {len(sections)}"
94
+ )
95
+ return sections[0]
96
+
97
+ def fixed32(self, number: int) -> float | None:
98
+ field = self.root.message.one(number)
99
+ return None if field is None else _bits_float(int(field.value))
100
+
101
+ def set_fixed32(self, number: int, value: float | None) -> None:
102
+ fields = self.root.message.get(number)
103
+ if value is None:
104
+ self.root.message.fields = [field for field in self.root.message.fields if field.number != number]
105
+ return
106
+ if fields:
107
+ fields[0].set_fixed32(_float_bits(value))
108
+ self.root.message.fields = [
109
+ field for field in self.root.message.fields if field.number != number or field is fields[0]
110
+ ]
111
+ else:
112
+ self.root.message.add_fixed32(number, _float_bits(value))
113
+
114
+ def varint(self, number: int, default: int = 0) -> int:
115
+ field = self.root.message.one(number)
116
+ return default if field is None else int(field.value)
117
+
118
+ def set_varint(self, number: int, value: int | None) -> None:
119
+ fields = self.root.message.get(number)
120
+ if value is None:
121
+ self.root.message.fields = [field for field in self.root.message.fields if field.number != number]
122
+ return
123
+ if fields:
124
+ fields[0].set_varint(value)
125
+ self.root.message.fields = [
126
+ field for field in self.root.message.fields if field.number != number or field is fields[0]
127
+ ]
128
+ else:
129
+ self.root.message.add_varint(number, value)
130
+
131
+ @property
132
+ def geometry(self) -> PageGeometry:
133
+ return PageGeometry(
134
+ self.fixed32(DOCUMENT_PAGE_WIDTH),
135
+ self.fixed32(DOCUMENT_PAGE_HEIGHT),
136
+ self.fixed32(DOCUMENT_LEFT_MARGIN),
137
+ self.fixed32(DOCUMENT_RIGHT_MARGIN),
138
+ self.fixed32(DOCUMENT_TOP_MARGIN),
139
+ self.fixed32(DOCUMENT_BOTTOM_MARGIN),
140
+ self.fixed32(DOCUMENT_HEADER_MARGIN),
141
+ self.fixed32(DOCUMENT_FOOTER_MARGIN),
142
+ self.varint(DOCUMENT_ORIENTATION),
143
+ )
144
+
145
+ def section_flag(self, number: int, default: bool = False) -> bool:
146
+ field = self.section.message.one(number)
147
+ return default if field is None else bool(field.value)
148
+
149
+ def set_section_flag(self, number: int, value: bool) -> None:
150
+ field = self.section.message.one(number)
151
+ if field is None:
152
+ self.section.message.add_varint(number, int(value))
153
+ else:
154
+ field.set_varint(int(value))
155
+
156
+ def template(self, kind: str) -> LocatedObject:
157
+ number = {
158
+ "first": SECTION_FIRST_TEMPLATE,
159
+ "even": SECTION_EVEN_TEMPLATE,
160
+ "primary": SECTION_ODD_TEMPLATE,
161
+ }[kind]
162
+ field = self.section.message.one(number)
163
+ identifier = parse_reference(bytes(field.value)) if field is not None else None
164
+ obj = self.document.objects().get(identifier) if identifier is not None else None
165
+ if obj is None or obj.info.type_id != TYPE_SECTION_TEMPLATE:
166
+ raise ValueError(f"section has no {kind} TP.SectionTemplateArchive")
167
+ return obj
168
+
169
+ def header_footer_storages(self, kind: str, *, footer: bool) -> list[LocatedObject]:
170
+ template = self.template(kind)
171
+ number = TEMPLATE_FOOTERS if footer else TEMPLATE_HEADERS
172
+ result = []
173
+ for field in template.message.get(number):
174
+ identifier = parse_reference(bytes(field.value))
175
+ obj = self.document.objects().get(identifier) if identifier is not None else None
176
+ if obj is None or obj.info.type_id != 2001:
177
+ raise ValueError(f"{kind} header/footer references a missing storage")
178
+ result.append(obj)
179
+ if not result:
180
+ raise ValueError(f"{kind} header/footer has no storage fragments")
181
+ return result
182
+
183
+ @staticmethod
184
+ def storage_text(storage: LocatedObject) -> str:
185
+ return "".join(
186
+ bytes(field.value).decode("utf-8") for field in storage.message.get(STORAGE_TEXT)
187
+ )
188
+
189
+ @staticmethod
190
+ def set_storage_text(storage: LocatedObject, value: str) -> None:
191
+ if not isinstance(value, str):
192
+ raise TypeError("header/footer text must be a string")
193
+ for number in HEADER_FOOTER_ATTRIBUTE_TABLES:
194
+ field = storage.message.one(number)
195
+ if field is None:
196
+ continue
197
+ try:
198
+ runs = parse_attribute_table(bytes(field.value))
199
+ except ValueError:
200
+ continue
201
+ if any(run.character_index != 0 for run in runs):
202
+ raise NotImplementedError(
203
+ "header/footer text with nonzero attribute boundaries requires Stage C"
204
+ )
205
+ text_fields = storage.message.get(STORAGE_TEXT)
206
+ encoded = value.encode("utf-8")
207
+ if text_fields:
208
+ text_fields[0].set_bytes(encoded)
209
+ storage.message.fields = [
210
+ field
211
+ for field in storage.message.fields
212
+ if field.number != STORAGE_TEXT or field is text_fields[0]
213
+ ]
214
+ elif value:
215
+ storage.message.add_bytes(STORAGE_TEXT, encoded)
pages/hyperlinks.py ADDED
@@ -0,0 +1,57 @@
1
+ """Middle-layer access to TSWP smart-field hyperlink runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from .text import LocatedObject, parse_attribute_table
9
+
10
+ if TYPE_CHECKING:
11
+ from .pages import PagesDocument
12
+
13
+
14
+ TYPE_HYPERLINK_FIELD = 2032
15
+ STORAGE_TABLE_SMART_FIELDS = 11
16
+ HYPERLINK_URL = 2
17
+
18
+
19
+ @dataclass
20
+ class HyperlinkStore:
21
+ object: LocatedObject
22
+ start_utf16: int
23
+ end_utf16: int
24
+
25
+ @property
26
+ def url(self) -> str:
27
+ field = self.object.message.one(HYPERLINK_URL)
28
+ return "" if field is None else bytes(field.value).decode("utf-8", "replace")
29
+
30
+ @url.setter
31
+ def url(self, value: str) -> None:
32
+ if not isinstance(value, str):
33
+ raise TypeError("hyperlink URL must be a string")
34
+ field = self.object.message.one(HYPERLINK_URL)
35
+ if field is None:
36
+ self.object.message.add_bytes(HYPERLINK_URL, value.encode("utf-8"))
37
+ else:
38
+ field.set_bytes(value.encode("utf-8"))
39
+
40
+
41
+ def storage_hyperlinks(
42
+ document: "PagesDocument", storage: LocatedObject
43
+ ) -> list[HyperlinkStore]:
44
+ field = storage.message.one(STORAGE_TABLE_SMART_FIELDS)
45
+ if field is None:
46
+ return []
47
+ objects = document.objects()
48
+ runs = parse_attribute_table(bytes(field.value))
49
+ result: list[HyperlinkStore] = []
50
+ for index, run in enumerate(runs):
51
+ obj = objects.get(run.object_id) if run.object_id is not None else None
52
+ if obj is None or obj.info.type_id != TYPE_HYPERLINK_FIELD:
53
+ continue
54
+ end = runs[index + 1].character_index if index + 1 < len(runs) else run.character_index
55
+ if end > run.character_index:
56
+ result.append(HyperlinkStore(obj, run.character_index, end))
57
+ return result
pages/iwa.py ADDED
@@ -0,0 +1,101 @@
1
+ """IWA archive framing on top of raw protobuf wire messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .protobuf import Message, ProtobufError, encode_varint, read_varint
8
+ from .snappy import compress_iwork, decompress_iwork
9
+
10
+
11
+ @dataclass
12
+ class MessageInfo:
13
+ field: object
14
+ message: Message
15
+ type_id: int
16
+ length: int
17
+
18
+ def update_length(self, length: int) -> None:
19
+ length_field = self.message.one(3)
20
+ if length_field is None:
21
+ raise ProtobufError("MessageInfo lacks required length field 3")
22
+ length_field.set_varint(length)
23
+ self.field.children = self.message
24
+ self.field.dirty = True
25
+ self.length = length
26
+
27
+
28
+ @dataclass
29
+ class ArchiveSegment:
30
+ header: Message
31
+ header_original: bytes
32
+ identifier: int
33
+ infos: list[MessageInfo]
34
+ payloads: list[Message]
35
+ payload_originals: list[bytes]
36
+
37
+ def encode(self) -> bytes:
38
+ encoded_payloads = [p.encode() for p in self.payloads]
39
+ for info, payload in zip(self.infos, encoded_payloads):
40
+ if info.length != len(payload):
41
+ info.update_length(len(payload))
42
+ header = self.header.encode()
43
+ return encode_varint(len(header)) + header + b"".join(encoded_payloads)
44
+
45
+
46
+ @dataclass
47
+ class IWAFile:
48
+ segments: list[ArchiveSegment]
49
+ decoded_sizes: list[int]
50
+ compressed_original: bytes = b""
51
+ decoded_original: bytes = b""
52
+
53
+ @classmethod
54
+ def parse(cls, data: bytes) -> "IWAFile":
55
+ decoded, decoded_sizes = decompress_iwork(data)
56
+ segments: list[ArchiveSegment] = []
57
+ pos = 0
58
+ while pos < len(decoded):
59
+ header_length, pos = read_varint(decoded, pos)
60
+ if pos + header_length > len(decoded):
61
+ raise ProtobufError("truncated ArchiveInfo")
62
+ header_bytes = decoded[pos : pos + header_length]
63
+ pos += header_length
64
+ header = Message.parse(header_bytes)
65
+ identifier_field = header.one(1)
66
+ identifier = int(identifier_field.value) if identifier_field else 0
67
+ infos: list[MessageInfo] = []
68
+ for info_field in header.get(2):
69
+ if info_field.wire_type != 2:
70
+ raise ProtobufError("ArchiveInfo.message_infos is not a message")
71
+ info_message = Message.parse(bytes(info_field.value))
72
+ info_field.children = info_message
73
+ type_field, length_field = info_message.one(1), info_message.one(3)
74
+ if type_field is None or length_field is None:
75
+ raise ProtobufError("MessageInfo missing type or length")
76
+ infos.append(MessageInfo(info_field, info_message, int(type_field.value), int(length_field.value)))
77
+ payloads: list[Message] = []
78
+ originals: list[bytes] = []
79
+ for info in infos:
80
+ end = pos + info.length
81
+ if end > len(decoded):
82
+ raise ProtobufError("truncated archive payload")
83
+ raw = decoded[pos:end]
84
+ originals.append(raw)
85
+ payloads.append(Message.parse(raw))
86
+ pos = end
87
+ segments.append(ArchiveSegment(header, header_bytes, identifier, infos, payloads, originals))
88
+ return cls(segments, decoded_sizes, data, decoded)
89
+
90
+ def encode_decoded(self) -> bytes:
91
+ return b"".join(segment.encode() for segment in self.segments)
92
+
93
+ def encode(self) -> bytes:
94
+ decoded = self.encode_decoded()
95
+ if self.compressed_original and decoded == self.decoded_original:
96
+ return self.compressed_original
97
+ sizes = self.decoded_sizes if sum(self.decoded_sizes) == len(decoded) else None
98
+ return compress_iwork(decoded, sizes)
99
+
100
+ def type_ids(self) -> list[int]:
101
+ return [info.type_id for segment in self.segments for info in segment.infos]
pages/metadata.py ADDED
@@ -0,0 +1,117 @@
1
+ """Minimal TSP.PackageMetadata mutation for registering new component objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from .protobuf import Message
8
+
9
+ TYPE_PACKAGE_METADATA = 11006
10
+
11
+ PACKAGE_LAST_OBJECT_IDENTIFIER = 1
12
+ PACKAGE_REVISION = 2
13
+ PACKAGE_COMPONENTS = 3
14
+
15
+ COMPONENT_IDENTIFIER = 1
16
+ COMPONENT_PREFERRED_LOCATOR = 2
17
+ COMPONENT_LOCATOR = 3
18
+ COMPONENT_EXTERNAL_REFERENCES = 6
19
+ COMPONENT_OBJECT_UUID_MAP = 11
20
+
21
+
22
+ def component_name(component: Message) -> str:
23
+ locator = component.one(COMPONENT_LOCATOR) or component.one(COMPONENT_PREFERRED_LOCATOR)
24
+ return bytes(locator.value).decode("utf-8") if locator is not None else ""
25
+
26
+
27
+ def object_uuid_map(component: Message) -> dict[int, tuple[int, int]]:
28
+ result: dict[int, tuple[int, int]] = {}
29
+ for raw in component.get(COMPONENT_OBJECT_UUID_MAP):
30
+ entry = Message.parse(bytes(raw.value))
31
+ identifier = entry.one(1)
32
+ uuid_field = entry.one(2)
33
+ if identifier is None or uuid_field is None:
34
+ continue
35
+ uuid_message = Message.parse(bytes(uuid_field.value))
36
+ first, second = uuid_message.one(1), uuid_message.one(2)
37
+ if first is not None and second is not None:
38
+ result[int(identifier.value)] = (int(first.value), int(second.value))
39
+ return result
40
+
41
+
42
+ def package_revision_identifier(package: Message) -> str:
43
+ field = package.one(PACKAGE_REVISION)
44
+ if field is None:
45
+ return "unknown-document"
46
+ revision = Message.parse(bytes(field.value))
47
+ identifier = revision.one(2)
48
+ return bytes(identifier.value).decode("utf-8", "replace") if identifier else "unknown-document"
49
+
50
+
51
+ def register_component_object(
52
+ package: Message,
53
+ component_locator: str,
54
+ identifier: int,
55
+ *,
56
+ uuid_seed: str,
57
+ ) -> tuple[str, int]:
58
+ """Add an object UUID entry and return its RFC UUID and component ID."""
59
+ selected_field = None
60
+ selected = None
61
+ for field in package.get(PACKAGE_COMPONENTS):
62
+ component = Message.parse(bytes(field.value))
63
+ if component_name(component) == component_locator:
64
+ selected_field, selected = field, component
65
+ break
66
+ if selected_field is None or selected is None:
67
+ raise ValueError(f"PackageMetadata has no component {component_locator!r}")
68
+ if identifier in object_uuid_map(selected):
69
+ raise ValueError(f"object {identifier} is already registered in {component_locator}")
70
+
71
+ generated = uuid.uuid5(uuid.NAMESPACE_URL, uuid_seed)
72
+ uuid_message = Message()
73
+ uuid_message.add_varint(1, generated.int >> 64)
74
+ uuid_message.add_varint(2, generated.int & ((1 << 64) - 1))
75
+ entry = Message()
76
+ entry.add_varint(1, identifier)
77
+ entry.add_bytes(2, uuid_message.encode())
78
+ selected.add_bytes(COMPONENT_OBJECT_UUID_MAP, entry.encode())
79
+ selected_field.set_bytes(selected.encode())
80
+ component_id = selected.one(COMPONENT_IDENTIFIER)
81
+ if component_id is None:
82
+ raise ValueError(f"component {component_locator!r} has no identifier")
83
+ return str(generated), int(component_id.value)
84
+
85
+
86
+ def register_external_reference(
87
+ package: Message,
88
+ source_component_locator: str,
89
+ target_component_identifier: int,
90
+ target_object_identifier: int,
91
+ ) -> None:
92
+ """Register a strong cross-component object reference if it is absent."""
93
+ selected_field = None
94
+ selected = None
95
+ for field in package.get(PACKAGE_COMPONENTS):
96
+ component = Message.parse(bytes(field.value))
97
+ if component_name(component) == source_component_locator:
98
+ selected_field, selected = field, component
99
+ break
100
+ if selected_field is None or selected is None:
101
+ raise ValueError(f"PackageMetadata has no component {source_component_locator!r}")
102
+ for raw in selected.get(COMPONENT_EXTERNAL_REFERENCES):
103
+ reference = Message.parse(bytes(raw.value))
104
+ component = reference.one(1)
105
+ obj = reference.one(2)
106
+ if (
107
+ component is not None
108
+ and obj is not None
109
+ and int(component.value) == target_component_identifier
110
+ and int(obj.value) == target_object_identifier
111
+ ):
112
+ return
113
+ reference = Message()
114
+ reference.add_varint(1, target_component_identifier)
115
+ reference.add_varint(2, target_object_identifier)
116
+ selected.add_bytes(COMPONENT_EXTERNAL_REFERENCES, reference.encode())
117
+ selected_field.set_bytes(selected.encode())