diff --git a/src/docx/api.py b/src/docx/api.py index aea876458..53a5d388f 100644 --- a/src/docx/api.py +++ b/src/docx/api.py @@ -16,14 +16,18 @@ from docx.parts.document import DocumentPart -def Document(docx: str | IO[bytes] | None = None) -> DocumentObject: +def Document( + docx: str | os.PathLike[str] | IO[bytes] | None = None, +) -> DocumentObject: """Return a |Document| object loaded from `docx`, where `docx` can be either a path - to a ``.docx`` file (a string) or a file-like object. + to a ``.docx`` file (a string or ``os.PathLike``) or a file-like object. If `docx` is missing or ``None``, the built-in default document "template" is loaded. """ docx = _default_docx_path() if docx is None else docx + if isinstance(docx, os.PathLike): + docx = os.fspath(docx) document_part = cast("DocumentPart", Package.open(docx).main_document_part) if document_part.content_type != CT.WML_DOCUMENT_MAIN: tmpl = "file '%s' is not a Word file, content type is '%s'" diff --git a/src/docx/document.py b/src/docx/document.py index 73757b46d..afc8400e1 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -5,6 +5,7 @@ from __future__ import annotations +import os from typing import IO, TYPE_CHECKING, Iterator, List, Sequence from docx.blkcntnr import BlockItemContainer @@ -195,12 +196,14 @@ def part(self) -> DocumentPart: """The |DocumentPart| object of this document.""" return self._part - def save(self, path_or_stream: str | IO[bytes]): + def save(self, path_or_stream: str | os.PathLike[str] | IO[bytes]): """Save this document to `path_or_stream`. - `path_or_stream` can be either a path to a filesystem location (a string) or a - file-like object. + `path_or_stream` can be either a path to a filesystem location (a string or + ``os.PathLike``) or a file-like object. """ + if isinstance(path_or_stream, os.PathLike): + path_or_stream = os.fspath(path_or_stream) self._part.save(path_or_stream) @property diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index 3c1cdca22..85ff23bc8 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import IO, TYPE_CHECKING, Iterator, cast from docx.opc.constants import RELATIONSHIP_TYPE as RT @@ -121,8 +122,10 @@ def next_partname(self, template: str) -> PackURI: return PackURI(candidate_partname) @classmethod - def open(cls, pkg_file: str | IO[bytes]) -> Self: + def open(cls, pkg_file: str | os.PathLike[str] | IO[bytes]) -> Self: """Return an |OpcPackage| instance loaded with the contents of `pkg_file`.""" + if isinstance(pkg_file, os.PathLike): + pkg_file = os.fspath(pkg_file) pkg_reader = PackageReader.from_file(pkg_file) package = cls() Unmarshaller.unmarshal(pkg_reader, package, PartFactory) @@ -156,11 +159,13 @@ def rels(self): relationships for this package.""" return Relationships(PACKAGE_URI.baseURI) - def save(self, pkg_file: str | IO[bytes]): + def save(self, pkg_file: str | os.PathLike[str] | IO[bytes]): """Save this package to `pkg_file`. `pkg_file` can be either a file-path or a file-like object. """ + if isinstance(pkg_file, os.PathLike): + pkg_file = os.fspath(pkg_file) for part in self.parts: part.before_marshal() PackageWriter.write(pkg_file, self.rels, self.parts) diff --git a/src/docx/opc/phys_pkg.py b/src/docx/opc/phys_pkg.py index 5ec32237c..c6c0b87c0 100644 --- a/src/docx/opc/phys_pkg.py +++ b/src/docx/opc/phys_pkg.py @@ -11,8 +11,8 @@ class PhysPkgReader: """Factory for physical package reader objects.""" def __new__(cls, pkg_file): - # if `pkg_file` is a string, treat it as a path - if isinstance(pkg_file, str): + # if `pkg_file` is a string or path-like object, treat it as a path + if isinstance(pkg_file, (str, os.PathLike)): if os.path.isdir(pkg_file): reader_cls = _DirPkgReader elif is_zipfile(pkg_file): diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 4960264b1..f4e6f75cc 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import IO, TYPE_CHECKING, cast from docx.document import Document @@ -108,9 +109,11 @@ def numbering_part(self) -> NumberingPart: self.relate_to(numbering_part, RT.NUMBERING) return numbering_part - def save(self, path_or_stream: str | IO[bytes]): + def save(self, path_or_stream: str | os.PathLike[str] | IO[bytes]): """Save this document to `path_or_stream`, which can be either a path to a - filesystem location (a string) or a file-like object.""" + filesystem location (a string or ``os.PathLike``) or a file-like object.""" + if isinstance(path_or_stream, os.PathLike): + path_or_stream = os.fspath(path_or_stream) self.package.save(path_or_stream) @property diff --git a/tests/opc/test_phys_pkg.py b/tests/opc/test_phys_pkg.py index 6de0d868b..a17a245bc 100644 --- a/tests/opc/test_phys_pkg.py +++ b/tests/opc/test_phys_pkg.py @@ -68,6 +68,18 @@ def it_raises_when_pkg_path_is_not_a_package(self): with pytest.raises(PackageNotFoundError): PhysPkgReader("foobar") + def it_uses_zip_reader_when_pkg_is_a_pathlib_path(self): + from pathlib import Path + + phys_reader = PhysPkgReader(Path(zip_pkg_path)) + assert isinstance(phys_reader, _ZipPkgReader) + + def it_uses_dir_reader_when_pkg_is_a_pathlib_path(self): + from pathlib import Path + + phys_reader = PhysPkgReader(Path(dir_pkg_path)) + assert isinstance(phys_reader, _DirPkgReader) + class DescribeZipPkgReader: def it_is_used_by_PhysPkgReader_when_pkg_is_a_zip(self): diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index c27990baf..32c07f074 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -111,6 +111,17 @@ def it_can_save_the_package_to_a_file(self, package_: Mock): package_.save.assert_called_once_with("foobar.docx") + def it_can_save_the_package_to_a_pathlib_path(self, package_: Mock): + from pathlib import Path + + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + document_part.save(Path("foobar.docx")) + + package_.save.assert_called_once_with("foobar.docx") + def it_provides_access_to_the_comments_added_to_the_document( self, _comments_part_prop_: Mock, comments_part_: Mock, comments_: Mock, package_: Mock ): diff --git a/tests/test_api.py b/tests/test_api.py index 6b5d3ae07..3c317fdb9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -35,6 +35,21 @@ def it_opens_the_default_docx_if_none_specified( Package_.open.assert_called_once_with("default-document.docx") assert document is document_ + def it_opens_a_docx_file_from_a_pathlib_path( + self, _default_docx_path_: Mock, Package_: Mock, document_: Mock + ): + from pathlib import Path + + _default_docx_path_.return_value = "default-document.docx" + document_part = Package_.open.return_value.main_document_part + document_part.document = document_ + document_part.content_type = CT.WML_DOCUMENT_MAIN + + document = DocumentFactoryFn(Path("foobar.docx")) + + Package_.open.assert_called_once_with("foobar.docx") + assert document is document_ + def it_raises_on_not_a_Word_file(self, Package_: Mock): Package_.open.return_value.main_document_part.content_type = "BOGUS" diff --git a/tests/test_document.py b/tests/test_document.py index 53efacf8d..e8875c6d3 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -185,6 +185,15 @@ def it_can_save_the_document_to_a_file(self, document_part_: Mock): document_part_.save.assert_called_once_with("foobar.docx") + def it_can_save_the_document_to_a_pathlib_path(self, document_part_: Mock): + from pathlib import Path + + document = Document(cast(CT_Document, element("w:document")), document_part_) + + document.save(Path("foobar.docx")) + + document_part_.save.assert_called_once_with("foobar.docx") + def it_provides_access_to_the_comments(self, document_part_: Mock, comments_: Mock): document_part_.comments = comments_ document = Document(cast(CT_Document, element("w:document")), document_part_)