diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/pptx/opc/package.py')
-rw-r--r-- | .venv/lib/python3.12/site-packages/pptx/opc/package.py | 762 |
1 files changed, 762 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/pptx/opc/package.py b/.venv/lib/python3.12/site-packages/pptx/opc/package.py new file mode 100644 index 00000000..713759c5 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/pptx/opc/package.py @@ -0,0 +1,762 @@ +"""Fundamental Open Packaging Convention (OPC) objects. + +The :mod:`pptx.packaging` module coheres around the concerns of reading and writing +presentations to and from a .pptx file. +""" + +from __future__ import annotations + +import collections +from typing import IO, TYPE_CHECKING, DefaultDict, Iterator, Mapping, Set, cast + +from pptx.opc.constants import RELATIONSHIP_TARGET_MODE as RTM +from pptx.opc.constants import RELATIONSHIP_TYPE as RT +from pptx.opc.oxml import CT_Relationships, serialize_part_xml +from pptx.opc.packuri import CONTENT_TYPES_URI, PACKAGE_URI, PackURI +from pptx.opc.serialized import PackageReader, PackageWriter +from pptx.opc.shared import CaseInsensitiveDict +from pptx.oxml import parse_xml +from pptx.util import lazyproperty + +if TYPE_CHECKING: + from typing_extensions import Self + + from pptx.opc.oxml import CT_Relationship, CT_Types + from pptx.oxml.xmlchemy import BaseOxmlElement + from pptx.package import Package + from pptx.parts.presentation import PresentationPart + + +class _RelatableMixin: + """Provide relationship methods required by both the package and each part.""" + + def part_related_by(self, reltype: str) -> Part: + """Return (single) part having relationship to this package of `reltype`. + + Raises |KeyError| if no such relationship is found and |ValueError| if more than one such + relationship is found. + """ + return self._rels.part_with_reltype(reltype) + + def relate_to(self, target: Part | str, reltype: str, is_external: bool = False) -> str: + """Return rId key of relationship of `reltype` to `target`. + + If such a relationship already exists, its rId is returned. Otherwise the relationship is + added and its new rId returned. + """ + if isinstance(target, str): + assert is_external + return self._rels.get_or_add_ext_rel(reltype, target) + + return self._rels.get_or_add(reltype, target) + + def related_part(self, rId: str) -> Part: + """Return related |Part| subtype identified by `rId`.""" + return self._rels[rId].target_part + + def target_ref(self, rId: str) -> str: + """Return URL contained in target ref of relationship identified by `rId`.""" + return self._rels[rId].target_ref + + @lazyproperty + def _rels(self) -> _Relationships: + """|_Relationships| object containing relationships from this part to others.""" + raise NotImplementedError( # pragma: no cover + "`%s` must implement `.rels`" % type(self).__name__ + ) + + +class OpcPackage(_RelatableMixin): + """Main API class for |python-opc|. + + A new instance is constructed by calling the :meth:`open` classmethod with a path to a package + file or file-like object containing a package (.pptx file). + """ + + def __init__(self, pkg_file: str | IO[bytes]): + self._pkg_file = pkg_file + + @classmethod + def open(cls, pkg_file: str | IO[bytes]) -> Self: + """Return an |OpcPackage| instance loaded with the contents of `pkg_file`.""" + return cls(pkg_file)._load() + + def drop_rel(self, rId: str) -> None: + """Remove relationship identified by `rId`.""" + self._rels.pop(rId) + + def iter_parts(self) -> Iterator[Part]: + """Generate exactly one reference to each part in the package.""" + visited: Set[Part] = set() + for rel in self.iter_rels(): + if rel.is_external: + continue + part = rel.target_part + if part in visited: + continue + yield part + visited.add(part) + + def iter_rels(self) -> Iterator[_Relationship]: + """Generate exactly one reference to each relationship in package. + + Performs a depth-first traversal of the rels graph. + """ + visited: Set[Part] = set() + + def walk_rels(rels: _Relationships) -> Iterator[_Relationship]: + for rel in rels.values(): + yield rel + # --- external items can have no relationships --- + if rel.is_external: + continue + # -- all relationships other than those for the package belong to a part. Once + # -- that part has been processed, processing it again would lead to the same + # -- relationships appearing more than once. + part = rel.target_part + if part in visited: + continue + visited.add(part) + # --- recurse into relationships of each unvisited target-part --- + yield from walk_rels(part.rels) + + yield from walk_rels(self._rels) + + @property + def main_document_part(self) -> PresentationPart: + """Return |Part| subtype serving as the main document part for this package. + + In this case it will be a |Presentation| part. + """ + return cast("PresentationPart", self.part_related_by(RT.OFFICE_DOCUMENT)) + + def next_partname(self, tmpl: str) -> PackURI: + """Return |PackURI| next available partname matching `tmpl`. + + `tmpl` is a printf (%)-style template string containing a single replacement item, a '%d' + to be used to insert the integer portion of the partname. Example: + '/ppt/slides/slide%d.xml' + """ + # --- expected next partname is tmpl % n where n is one greater than the number + # --- of existing partnames that match tmpl. Speed up finding the next one + # --- (maybe) by searching from the end downward rather than from 1 upward. + prefix = tmpl[: (tmpl % 42).find("42")] + partnames = {p.partname for p in self.iter_parts() if p.partname.startswith(prefix)} + for n in range(len(partnames) + 1, 0, -1): + candidate_partname = tmpl % n + if candidate_partname not in partnames: + return PackURI(candidate_partname) + raise Exception("ProgrammingError: ran out of candidate_partnames") # pragma: no cover + + def save(self, pkg_file: str | IO[bytes]) -> None: + """Save this package to `pkg_file`. + + `file` can be either a path to a file (a string) or a file-like object. + """ + PackageWriter.write(pkg_file, self._rels, tuple(self.iter_parts())) + + def _load(self) -> Self: + """Return the package after loading all parts and relationships.""" + pkg_xml_rels, parts = _PackageLoader.load(self._pkg_file, cast("Package", self)) + self._rels.load_from_xml(PACKAGE_URI, pkg_xml_rels, parts) + return self + + @lazyproperty + def _rels(self) -> _Relationships: + """|Relationships| object containing relationships of this package.""" + return _Relationships(PACKAGE_URI.baseURI) + + +class _PackageLoader: + """Function-object that loads a package from disk (or other store).""" + + def __init__(self, pkg_file: str | IO[bytes], package: Package): + self._pkg_file = pkg_file + self._package = package + + @classmethod + def load( + cls, pkg_file: str | IO[bytes], package: Package + ) -> tuple[CT_Relationships, dict[PackURI, Part]]: + """Return (pkg_xml_rels, parts) pair resulting from loading `pkg_file`. + + The returned `parts` value is a {partname: part} mapping with each part in the package + included and constructed complete with its relationships to other parts in the package. + + The returned `pkg_xml_rels` value is a `CT_Relationships` object containing the parsed + package relationships. It is the caller's responsibility (the package object) to load + those relationships into its |_Relationships| object. + """ + return cls(pkg_file, package)._load() + + def _load(self) -> tuple[CT_Relationships, dict[PackURI, Part]]: + """Return (pkg_xml_rels, parts) pair resulting from loading pkg_file.""" + parts, xml_rels = self._parts, self._xml_rels + + for partname, part in parts.items(): + part.load_rels_from_xml(xml_rels[partname], parts) + + return xml_rels[PACKAGE_URI], parts + + @lazyproperty + def _content_types(self) -> _ContentTypeMap: + """|_ContentTypeMap| object providing content-types for items of this package. + + Provides a content-type (MIME-type) for any given partname. + """ + return _ContentTypeMap.from_xml(self._package_reader[CONTENT_TYPES_URI]) + + @lazyproperty + def _package_reader(self) -> PackageReader: + """|PackageReader| object providing access to package-items in pkg_file.""" + return PackageReader(self._pkg_file) + + @lazyproperty + def _parts(self) -> dict[PackURI, Part]: + """dict {partname: Part} populated with parts loading from package. + + Among other duties, this collection is passed to each relationships collection so each + relationship can resolve a reference to its target part when required. This reference can + only be reliably carried out once the all parts have been loaded. + """ + content_types = self._content_types + package = self._package + package_reader = self._package_reader + + return { + partname: PartFactory( + partname, + content_types[partname], + package, + blob=package_reader[partname], + ) + for partname in (p for p in self._xml_rels if p != "/") + # -- invalid partnames can arise in some packages; ignore those rather than raise an + # -- exception. + if partname in package_reader + } + + @lazyproperty + def _xml_rels(self) -> dict[PackURI, CT_Relationships]: + """dict {partname: xml_rels} for package and all package parts. + + This is used as the basis for other loading operations such as loading parts and + populating their relationships. + """ + xml_rels: dict[PackURI, CT_Relationships] = {} + visited_partnames: Set[PackURI] = set() + + def load_rels(source_partname: PackURI, rels: CT_Relationships): + """Populate `xml_rels` dict by traversing relationships depth-first.""" + xml_rels[source_partname] = rels + visited_partnames.add(source_partname) + base_uri = source_partname.baseURI + + # --- recursion stops when there are no unvisited partnames in rels --- + for rel in rels.relationship_lst: + if rel.targetMode == RTM.EXTERNAL: + continue + target_partname = PackURI.from_rel_ref(base_uri, rel.target_ref) + if target_partname in visited_partnames: + continue + load_rels(target_partname, self._xml_rels_for(target_partname)) + + load_rels(PACKAGE_URI, self._xml_rels_for(PACKAGE_URI)) + return xml_rels + + def _xml_rels_for(self, partname: PackURI) -> CT_Relationships: + """Return CT_Relationships object formed by parsing rels XML for `partname`. + + A CT_Relationships object is returned in all cases. A part that has no relationships + receives an "empty" CT_Relationships object, i.e. containing no `CT_Relationship` objects. + """ + rels_xml = self._package_reader.rels_xml_for(partname) + return ( + CT_Relationships.new() + if rels_xml is None + else cast(CT_Relationships, parse_xml(rels_xml)) + ) + + +class Part(_RelatableMixin): + """Base class for package parts. + + Provides common properties and methods, but intended to be subclassed in client code to + implement specific part behaviors. Also serves as the default class for parts that are not yet + given specific behaviors. + """ + + def __init__( + self, partname: PackURI, content_type: str, package: Package, blob: bytes | None = None + ): + # --- XmlPart subtypes, don't store a blob (the original XML) --- + self._partname = partname + self._content_type = content_type + self._package = package + self._blob = blob + + @classmethod + def load(cls, partname: PackURI, content_type: str, package: Package, blob: bytes) -> Self: + """Return `cls` instance loaded from arguments. + + This one is a straight pass-through, but subtypes may do some pre-processing, see XmlPart + for an example. + """ + return cls(partname, content_type, package, blob) + + @property + def blob(self) -> bytes: + """Contents of this package part as a sequence of bytes. + + Intended to be overridden by subclasses. Default behavior is to return the blob initial + loaded during `Package.open()` operation. + """ + return self._blob or b"" + + @blob.setter + def blob(self, blob: bytes): + """Note that not all subclasses use the part blob as their blob source. + + In particular, the |XmlPart| subclass uses its `self._element` to serialize a blob on + demand. This works fine for binary parts though. + """ + self._blob = blob + + @lazyproperty + def content_type(self) -> str: + """Content-type (MIME-type) of this part.""" + return self._content_type + + def load_rels_from_xml(self, xml_rels: CT_Relationships, parts: dict[PackURI, Part]) -> None: + """load _Relationships for this part from `xml_rels`. + + Part references are resolved using the `parts` dict that maps each partname to the loaded + part with that partname. These relationships are loaded from a serialized package and so + already have assigned rIds. This method is only used during package loading. + """ + self._rels.load_from_xml(self._partname.baseURI, xml_rels, parts) + + @lazyproperty + def package(self) -> Package: + """Package this part belongs to.""" + return self._package + + @property + def partname(self) -> PackURI: + """|PackURI| partname for this part, e.g. "/ppt/slides/slide1.xml".""" + return self._partname + + @partname.setter + def partname(self, partname: PackURI): + if not isinstance(partname, PackURI): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( # pragma: no cover + "partname must be instance of PackURI, got '%s'" % type(partname).__name__ + ) + self._partname = partname + + @lazyproperty + def rels(self) -> _Relationships: + """Collection of relationships from this part to other parts.""" + # --- this must be public to allow the part graph to be traversed --- + return self._rels + + def _blob_from_file(self, file: str | IO[bytes]) -> bytes: + """Return bytes of `file`, which is either a str path or a file-like object.""" + # --- a str `file` is assumed to be a path --- + if isinstance(file, str): + with open(file, "rb") as f: + return f.read() + + # --- otherwise, assume `file` is a file-like object + # --- reposition file cursor if it has one + if callable(getattr(file, "seek")): + file.seek(0) + return file.read() + + @lazyproperty + def _rels(self) -> _Relationships: + """Relationships from this part to others.""" + return _Relationships(self._partname.baseURI) + + +class XmlPart(Part): + """Base class for package parts containing an XML payload, which is most of them. + + Provides additional methods to the |Part| base class that take care of parsing and + reserializing the XML payload and managing relationships to other parts. + """ + + def __init__( + self, partname: PackURI, content_type: str, package: Package, element: BaseOxmlElement + ): + super(XmlPart, self).__init__(partname, content_type, package) + self._element = element + + @classmethod + def load(cls, partname: PackURI, content_type: str, package: Package, blob: bytes): + """Return instance of `cls` loaded with parsed XML from `blob`.""" + return cls( + partname, content_type, package, element=cast("BaseOxmlElement", parse_xml(blob)) + ) + + @property + def blob(self) -> bytes: # pyright: ignore[reportIncompatibleMethodOverride] + """bytes XML serialization of this part.""" + return serialize_part_xml(self._element) + + # -- XmlPart cannot set its blob, which is why pyright complains -- + + def drop_rel(self, rId: str) -> None: + """Remove relationship identified by `rId` if its reference count is under 2. + + Relationships with a reference count of 0 are implicit relationships. Note that only XML + parts can drop relationships. + """ + if self._rel_ref_count(rId) < 2: + self._rels.pop(rId) + + @property + def part(self): + """This part. + + This is part of the parent protocol, "children" of the document will not know the part + that contains them so must ask their parent object. That chain of delegation ends here for + child objects. + """ + return self + + def _rel_ref_count(self, rId: str) -> int: + """Return int count of references in this part's XML to `rId`.""" + return len([r for r in cast("list[str]", self._element.xpath("//@r:id")) if r == rId]) + + +class PartFactory: + """Constructs a registered subtype of |Part|. + + Client code can register a subclass of |Part| to be used for a package blob based on its + content type. + """ + + part_type_for: dict[str, type[Part]] = {} + + def __new__(cls, partname: PackURI, content_type: str, package: Package, blob: bytes) -> Part: + PartClass = cls._part_cls_for(content_type) + return PartClass.load(partname, content_type, package, blob) + + @classmethod + def _part_cls_for(cls, content_type: str) -> type[Part]: + """Return the custom part class registered for `content_type`. + + Returns |Part| if no custom class is registered for `content_type`. + """ + if content_type in cls.part_type_for: + return cls.part_type_for[content_type] + return Part + + +class _ContentTypeMap: + """Value type providing dict semantics for looking up content type by partname.""" + + def __init__(self, overrides: dict[str, str], defaults: dict[str, str]): + self._overrides = overrides + self._defaults = defaults + + def __getitem__(self, partname: PackURI) -> str: + """Return content-type (MIME-type) for part identified by *partname*.""" + if not isinstance(partname, PackURI): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + "_ContentTypeMap key must be <type 'PackURI'>, got %s" % type(partname).__name__ + ) + + if partname in self._overrides: + return self._overrides[partname] + + if partname.ext in self._defaults: + return self._defaults[partname.ext] + + raise KeyError("no content-type for partname '%s' in [Content_Types].xml" % partname) + + @classmethod + def from_xml(cls, content_types_xml: bytes) -> _ContentTypeMap: + """Return |_ContentTypeMap| instance populated from `content_types_xml`.""" + types_elm = cast("CT_Types", parse_xml(content_types_xml)) + # -- note all partnames in [Content_Types].xml are absolute -- + overrides = CaseInsensitiveDict( + (o.partName.lower(), o.contentType) for o in types_elm.override_lst + ) + defaults = CaseInsensitiveDict( + (d.extension.lower(), d.contentType) for d in types_elm.default_lst + ) + return cls(overrides, defaults) + + +class _Relationships(Mapping[str, "_Relationship"]): + """Collection of |_Relationship| instances having `dict` semantics. + + Relationships are keyed by their rId, but may also be found in other ways, such as by their + relationship type. |Relationship| objects are keyed by their rId. + + Iterating this collection has normal mapping semantics, generating the keys (rIds) of the + mapping. `rels.keys()`, `rels.values()`, and `rels.items() can be used as they would be for a + `dict`. + """ + + def __init__(self, base_uri: str): + self._base_uri = base_uri + + def __contains__(self, rId: object) -> bool: + """Implement 'in' operation, like `"rId7" in relationships`.""" + return rId in self._rels + + def __getitem__(self, rId: str) -> _Relationship: + """Implement relationship lookup by rId using indexed access, like rels[rId].""" + try: + return self._rels[rId] + except KeyError: + raise KeyError("no relationship with key '%s'" % rId) + + def __iter__(self) -> Iterator[str]: + """Implement iteration of rIds (iterating a mapping produces its keys).""" + return iter(self._rels) + + def __len__(self) -> int: + """Return count of relationships in collection.""" + return len(self._rels) + + def get_or_add(self, reltype: str, target_part: Part) -> str: + """Return str rId of `reltype` to `target_part`. + + The rId of an existing matching relationship is used if present. Otherwise, a new + relationship is added and that rId is returned. + """ + existing_rId = self._get_matching(reltype, target_part) + return ( + self._add_relationship(reltype, target_part) if existing_rId is None else existing_rId + ) + + def get_or_add_ext_rel(self, reltype: str, target_ref: str) -> str: + """Return str rId of external relationship of `reltype` to `target_ref`. + + The rId of an existing matching relationship is used if present. Otherwise, a new + relationship is added and that rId is returned. + """ + existing_rId = self._get_matching(reltype, target_ref, is_external=True) + return ( + self._add_relationship(reltype, target_ref, is_external=True) + if existing_rId is None + else existing_rId + ) + + def load_from_xml( + self, base_uri: str, xml_rels: CT_Relationships, parts: dict[PackURI, Part] + ) -> None: + """Replace any relationships in this collection with those from `xml_rels`.""" + + def iter_valid_rels(): + """Filter out broken relationships such as those pointing to NULL.""" + for rel_elm in xml_rels.relationship_lst: + # --- Occasionally a PowerPoint plugin or other client will "remove" + # --- a relationship simply by "voiding" its Target value, like making + # --- it "/ppt/slides/NULL". Skip any relationships linking to a + # --- partname that is not present in the package. + if rel_elm.targetMode == RTM.INTERNAL: + partname = PackURI.from_rel_ref(base_uri, rel_elm.target_ref) + if partname not in parts: + continue + yield _Relationship.from_xml(base_uri, rel_elm, parts) + + self._rels.clear() + self._rels.update((rel.rId, rel) for rel in iter_valid_rels()) + + def part_with_reltype(self, reltype: str) -> Part: + """Return target part of relationship with matching `reltype`. + + Raises |KeyError| if not found and |ValueError| if more than one matching relationship is + found. + """ + rels_of_reltype = self._rels_by_reltype[reltype] + + if len(rels_of_reltype) == 0: + raise KeyError("no relationship of type '%s' in collection" % reltype) + + if len(rels_of_reltype) > 1: + raise ValueError("multiple relationships of type '%s' in collection" % reltype) + + return rels_of_reltype[0].target_part + + def pop(self, rId: str) -> _Relationship: + """Return |_Relationship| identified by `rId` after removing it from collection. + + The caller is responsible for ensuring it is no longer required. + """ + return self._rels.pop(rId) + + @property + def xml(self): + """bytes XML serialization of this relationship collection. + + This value is suitable for storage as a .rels file in an OPC package. Includes a `<?xml..` + declaration header with encoding as UTF-8. + """ + rels_elm = CT_Relationships.new() + + # -- Sequence <Relationship> elements deterministically (in numerical order) to + # -- simplify testing and manual inspection. + def iter_rels_in_numerical_order(): + sorted_num_rId_pairs = sorted( + ( + int(rId[3:]) if rId.startswith("rId") and rId[3:].isdigit() else 0, + rId, + ) + for rId in self.keys() + ) + return (self[rId] for _, rId in sorted_num_rId_pairs) + + for rel in iter_rels_in_numerical_order(): + rels_elm.add_rel(rel.rId, rel.reltype, rel.target_ref, rel.is_external) + + return rels_elm.xml_file_bytes + + def _add_relationship(self, reltype: str, target: Part | str, is_external: bool = False) -> str: + """Return str rId of |_Relationship| newly added to spec.""" + rId = self._next_rId + self._rels[rId] = _Relationship( + self._base_uri, + rId, + reltype, + target_mode=RTM.EXTERNAL if is_external else RTM.INTERNAL, + target=target, + ) + return rId + + def _get_matching( + self, reltype: str, target: Part | str, is_external: bool = False + ) -> str | None: + """Return optional str rId of rel of `reltype`, `target`, and `is_external`. + + Returns `None` on no matching relationship + """ + for rel in self._rels_by_reltype[reltype]: + if rel.is_external != is_external: + continue + rel_target = rel.target_ref if rel.is_external else rel.target_part + if rel_target == target: + return rel.rId + + return None + + @property + def _next_rId(self) -> str: + """Next str rId available in collection. + + The next rId is the first unused key starting from "rId1" and making use of any gaps in + numbering, e.g. 'rId2' for rIds ['rId1', 'rId3']. + """ + # --- The common case is where all sequential numbers starting at "rId1" are + # --- used and the next available rId is "rId%d" % (len(rels)+1). So we start + # --- there and count down to produce the best performance. + for n in range(len(self) + 1, 0, -1): + rId_candidate = "rId%d" % n # like 'rId19' + if rId_candidate not in self._rels: + return rId_candidate + raise Exception( + "ProgrammingError: Impossible to have more distinct rIds than relationships" + ) + + @lazyproperty + def _rels(self) -> dict[str, _Relationship]: + """dict {rId: _Relationship} containing relationships of this collection.""" + return {} + + @property + def _rels_by_reltype(self) -> dict[str, list[_Relationship]]: + """defaultdict {reltype: [rels]} for all relationships in collection.""" + D: DefaultDict[str, list[_Relationship]] = collections.defaultdict(list) + for rel in self.values(): + D[rel.reltype].append(rel) + return D + + +class _Relationship: + """Value object describing link from a part or package to another part.""" + + def __init__(self, base_uri: str, rId: str, reltype: str, target_mode: str, target: Part | str): + self._base_uri = base_uri + self._rId = rId + self._reltype = reltype + self._target_mode = target_mode + self._target = target + + @classmethod + def from_xml( + cls, base_uri: str, rel: CT_Relationship, parts: dict[PackURI, Part] + ) -> _Relationship: + """Return |_Relationship| object based on CT_Relationship element `rel`.""" + target = ( + rel.target_ref + if rel.targetMode == RTM.EXTERNAL + else parts[PackURI.from_rel_ref(base_uri, rel.target_ref)] + ) + return cls(base_uri, rel.rId, rel.reltype, rel.targetMode, target) + + @lazyproperty + def is_external(self) -> bool: + """True if target_mode is `RTM.EXTERNAL`. + + An external relationship is a link to a resource outside the package, such as a + web-resource (URL). + """ + return self._target_mode == RTM.EXTERNAL + + @lazyproperty + def reltype(self) -> str: + """Member of RELATIONSHIP_TYPE describing relationship of target to source.""" + return self._reltype + + @lazyproperty + def rId(self) -> str: + """str relationship-id, like 'rId9'. + + Corresponds to the `Id` attribute on the `CT_Relationship` element and uniquely identifies + this relationship within its peers for the source-part or package. + """ + return self._rId + + @lazyproperty + def target_part(self) -> Part: + """|Part| or subtype referred to by this relationship.""" + if self.is_external: + raise ValueError( + "`.target_part` property on _Relationship is undefined when " + "target-mode is external" + ) + assert isinstance(self._target, Part) + return self._target + + @lazyproperty + def target_partname(self) -> PackURI: + """|PackURI| instance containing partname targeted by this relationship. + + Raises `ValueError` on reference if target_mode is external. Use :attr:`target_mode` to + check before referencing. + """ + if self.is_external: + raise ValueError( + "`.target_partname` property on _Relationship is undefined when " + "target-mode is external" + ) + assert isinstance(self._target, Part) + return self._target.partname + + @lazyproperty + def target_ref(self) -> str: + """str reference to relationship target. + + For internal relationships this is the relative partname, suitable for serialization + purposes. For an external relationship it is typically a URL. + """ + if self.is_external: + assert isinstance(self._target, str) + return self._target + + return self.target_partname.relative_ref(self._base_uri) |