Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/docx/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
Expand Down
9 changes: 6 additions & 3 deletions src/docx/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import os
from typing import IO, TYPE_CHECKING, Iterator, List, Sequence

from docx.blkcntnr import BlockItemContainer
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/docx/opc/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/docx/opc/phys_pkg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 5 additions & 2 deletions src/docx/parts/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
from typing import IO, TYPE_CHECKING, cast

from docx.document import Document
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tests/opc/test_phys_pkg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 11 additions & 0 deletions tests/parts/test_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
15 changes: 15 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
9 changes: 9 additions & 0 deletions tests/test_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_)
Expand Down