diff --git a/python/src/bind_core.cpp b/python/src/bind_core.cpp index 4a1277764..1f4532248 100644 --- a/python/src/bind_core.cpp +++ b/python/src/bind_core.cpp @@ -118,6 +118,16 @@ void odr_python::bind_functions(py::module_ &m) { m.def("capabilities_by_file_type", &odr::capabilities_by_file_type, py::arg("type"), "What this library can do with the file type."); + // Each of these is bound for `File` as well as for a path: a caller holding + // bytes (`File.from_memory`) must be able to reach everything a caller + // holding a path can. + m.def( + "list_file_types", + [](const odr::File &file, const odr::Logger &logger) { + return odr::list_file_types(file, logger); + }, + py::arg("file"), py::arg("logger") = odr::Logger::null(), + "Determine the possible file types of a file."); m.def( "list_file_types", [](const std::string &path, const odr::Logger &logger) { @@ -125,6 +135,13 @@ void odr_python::bind_functions(py::module_ &m) { }, py::arg("path"), py::arg("logger") = odr::Logger::null(), "Determine the possible file types of a file."); + m.def( + "mimetype", + [](const odr::File &file, const odr::Logger &logger) { + return std::string(odr::mimetype(file, logger)); + }, + py::arg("file"), py::arg("logger") = odr::Logger::null(), + "Determine the MIME type of a file."); m.def( "mimetype", [](const std::string &path, const odr::Logger &logger) { @@ -135,6 +152,31 @@ void odr_python::bind_functions(py::module_ &m) { // Decoding is long-running, so it must not hold the GIL. Re-entry is safe: a // Python `ILogger` re-acquires it in the trampoline. + m.def( + "open", + [](const odr::File &file, const odr::Logger &logger) { + return odr::open(file, logger); + }, + py::arg("file"), py::arg("logger") = odr::Logger::null(), + py::call_guard(), "Decode a file."); + m.def( + "open", + [](const odr::File &file, const odr::FileType as, + const odr::Logger &logger) { return odr::open(file, as, logger); }, + py::arg("file"), py::arg("as_type"), + py::arg("logger") = odr::Logger::null(), + py::call_guard(), + "Decode a file as a specific file type."); + m.def( + "open", + [](const odr::File &file, const odr::DecodePreference &preference, + const odr::Logger &logger) { + return odr::open(file, preference, logger); + }, + py::arg("file"), py::arg("preference"), + py::arg("logger") = odr::Logger::null(), + py::call_guard(), + "Decode a file with a decode preference."); m.def( "open", [](const std::string &path, const odr::Logger &logger) { diff --git a/python/src/bind_file.cpp b/python/src/bind_file.cpp index 1484eef6e..c9159fb9a 100644 --- a/python/src/bind_file.cpp +++ b/python/src/bind_file.cpp @@ -151,6 +151,14 @@ void odr_python::bind_file(py::module_ &m) { py::class_(m, "File") .def(py::init<>()) .def(py::init(), py::arg("path")) + .def_static("from_disk", &odr::File::from_disk, py::arg("path"), + "A file read from `path` on disk.") + .def_static( + "from_memory", + [](const py::bytes &data) { + return odr::File::from_memory(std::string(data)); + }, + py::arg("data"), "A file held in memory; `data` is its bytes.") .def("__bool__", [](const odr::File &file) { return file.impl() != nullptr; }) .def("location", &odr::File::location) @@ -169,6 +177,13 @@ void odr_python::bind_file(py::module_ &m) { py::class_(m, "DecodedFile") .def(py::init(), py::arg("file"), py::arg("logger") = odr::Logger::null()) + .def(py::init(), + py::arg("file"), py::arg("as_type"), + py::arg("logger") = odr::Logger::null()) + .def(py::init(), + py::arg("file"), py::arg("preference"), + py::arg("logger") = odr::Logger::null()) .def(py::init(), py::arg("path"), py::arg("logger") = odr::Logger::null()) .def(py::init(), @@ -214,9 +229,42 @@ void odr_python::bind_file(py::module_ &m) { .def("archive", &odr::ArchiveFile::archive); py::class_(m, "DocumentFile") + .def(py::init(), py::arg("file"), + py::arg("logger") = odr::Logger::null()) .def(py::init(), py::arg("path")) - .def_static("type_by_path", &odr::DocumentFile::type, py::arg("path")) - .def_static("meta_by_path", &odr::DocumentFile::meta, py::arg("path")) + .def_static("from_disk", &odr::DocumentFile::from_disk, py::arg("path"), + py::arg("logger") = odr::Logger::null(), + py::call_guard(), + "Decode the document file at `path` on disk.") + .def_static( + "from_memory", + [](const py::bytes &data, const odr::Logger &logger) { + // the bytes have to be copied out under the GIL; only the decode + // that follows is long-running + std::string bytes(data); + const py::gil_scoped_release release; + return odr::DocumentFile::from_memory(std::move(bytes), logger); + }, + py::arg("data"), py::arg("logger") = odr::Logger::null(), + "Decode a document file held in memory; `data` is its bytes.") + // `type`/`meta` are overloaded on `File` and path, so the address of + // either is ambiguous; name the signature. + .def_static( + "type_by_file", + py::overload_cast(&odr::DocumentFile::type), + py::arg("file")) + .def_static( + "type_by_path", + py::overload_cast(&odr::DocumentFile::type), + py::arg("path")) + .def_static( + "meta_by_file", + py::overload_cast(&odr::DocumentFile::meta), + py::arg("file")) + .def_static( + "meta_by_path", + py::overload_cast(&odr::DocumentFile::meta), + py::arg("path")) .def("document_type", &odr::DocumentFile::document_type) .def("decrypt", &odr::DocumentFile::decrypt, py::arg("password"), py::call_guard()) diff --git a/python/tests/test_file.py b/python/tests/test_file.py index 5d412fb5c..b6b3978aa 100644 --- a/python/tests/test_file.py +++ b/python/tests/test_file.py @@ -12,6 +12,28 @@ def test_file(txt_path): assert file.read() == txt_path.read_bytes() +def test_file_from_disk(txt_path): + file = pyodr.File.from_disk(str(txt_path)) + assert file.location() == pyodr.FileLocation.disk + assert file.disk_path() == str(txt_path) + assert file.read() == txt_path.read_bytes() + + +def test_file_from_memory(txt_path): + data = txt_path.read_bytes() + file = pyodr.File.from_memory(data) + assert file.location() == pyodr.FileLocation.memory + assert file.disk_path() is None + assert file.size() == len(data) + assert file.read() == data + + +def test_file_from_memory_keeps_bytes_verbatim(): + # not text, and not valid utf-8 - the bytes must survive the round trip + data = bytes(range(256)) + assert pyodr.File.from_memory(data).read() == data + + def test_open_missing_file(tmp_path): with pytest.raises(FileNotFoundError): pyodr.open(str(tmp_path / "missing.txt")) @@ -75,3 +97,83 @@ def test_open_zip_archive(odt_path): mimetype = filesystem.open("/mimetype").read() assert mimetype == b"application/vnd.oasis.opendocument.text" + + +def test_open_from_memory(odt_path): + file = pyodr.File.from_memory(odt_path.read_bytes()) + + assert pyodr.mimetype(file) == "application/vnd.oasis.opendocument.text" + assert pyodr.FileType.opendocument_text in pyodr.list_file_types(file) + + decoded = pyodr.open(file) + assert decoded.file_type() == pyodr.FileType.opendocument_text + assert decoded.is_document_file() + # the bytes are the only copy there is, so decoding has to have kept them + document = decoded.as_document_file().document() + assert document.document_type() == pyodr.DocumentType.text + + +def test_open_from_memory_as_type(odt_path): + file = pyodr.File.from_memory(odt_path.read_bytes()) + + assert pyodr.open(file, pyodr.FileType.zip).is_archive_file() + + preference = pyodr.DecodePreference() + preference.as_file_type = pyodr.FileType.zip + assert pyodr.open(file, preference).is_archive_file() + + +def test_decoded_file_from_file(odt_path): + file = pyodr.File.from_memory(odt_path.read_bytes()) + + assert pyodr.DecodedFile(file).file_type() == pyodr.FileType.opendocument_text + assert pyodr.DecodedFile(file, pyodr.FileType.zip).is_archive_file() + + preference = pyodr.DecodePreference() + preference.as_file_type = pyodr.FileType.zip + assert pyodr.DecodedFile(file, preference).is_archive_file() + + +def test_document_file_from_file(odt_path): + file = pyodr.File.from_memory(odt_path.read_bytes()) + + assert pyodr.DocumentFile.type_by_file(file) == pyodr.FileType.opendocument_text + assert ( + pyodr.DocumentFile.meta_by_file(file).type == pyodr.FileType.opendocument_text + ) + + document_file = pyodr.DocumentFile(file) + assert document_file.document_type() == pyodr.DocumentType.text + + +def test_document_file_from_disk_and_from_memory(odt_path): + from_disk = pyodr.DocumentFile.from_disk(str(odt_path)) + from_memory = pyodr.DocumentFile.from_memory(odt_path.read_bytes()) + + assert from_disk.file_type() == pyodr.FileType.opendocument_text + assert from_memory.file_type() == from_disk.file_type() + assert from_memory.document_type() == from_disk.document_type() + assert ( + from_memory.document().document_type() == from_disk.document().document_type() + ) + + +def test_document_file_from_memory_rejects_a_non_document(): + with pytest.raises(pyodr.Error): + pyodr.DocumentFile.from_memory(b"not a document") + + +def test_file_and_path_entry_points_agree(odt_path): + path = str(odt_path) + file = pyodr.File.from_disk(path) + + assert pyodr.mimetype(file) == pyodr.mimetype(path) + assert pyodr.list_file_types(file) == pyodr.list_file_types(path) + assert pyodr.open(file).file_type() == pyodr.open(path).file_type() + assert pyodr.DocumentFile.type_by_file(file) == pyodr.DocumentFile.type_by_path( + path + ) + assert ( + pyodr.DocumentFile.meta_by_file(file).type + == pyodr.DocumentFile.meta_by_path(path).type + ) diff --git a/src/odr/file.cpp b/src/odr/file.cpp index 73158568d..d14d3c482 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -30,6 +30,14 @@ deref(const std::shared_ptr &impl) { } // namespace +File File::from_disk(const std::string &path) { + return File(std::make_shared(path)); +} + +File File::from_memory(std::string data) { + return File(std::make_shared(std::move(data))); +} + File::File() = default; File::File(std::shared_ptr impl) @@ -74,15 +82,23 @@ void File::copy(const std::string &path) const { std::shared_ptr File::impl() const { return m_impl; } +std::vector DecodedFile::list_file_types(const File &file, + const Logger &logger) { + return internal::open_strategy::list_file_types(file.impl(), logger); +} + std::vector DecodedFile::list_file_types(const std::string &path, const Logger &logger) { - return internal::open_strategy::list_file_types( - std::make_shared(path), logger); + return list_file_types(File::from_disk(path), logger); +} + +std::string_view DecodedFile::mimetype(const File &file, const Logger &logger) { + return internal::magic::mimetype(file.impl(), logger); } std::string_view DecodedFile::mimetype(const std::string &path, const Logger &logger) { - return internal::magic::mimetype(path, logger); + return mimetype(File::from_disk(path), logger); } DecodedFile::DecodedFile(std::shared_ptr impl) @@ -100,20 +116,22 @@ DecodedFile::DecodedFile(const File &file, const FileType as, : DecodedFile(internal::open_strategy::open_file(file.impl(), as, logger)) { } +DecodedFile::DecodedFile(const File &file, const DecodePreference &preference, + const Logger &logger) + : DecodedFile(internal::open_strategy::open_file(file.impl(), preference, + logger)) {} + DecodedFile::DecodedFile(const std::string &path, const Logger &logger) - : DecodedFile(internal::open_strategy::open_file( - std::make_shared(path), logger)) {} + : DecodedFile(File::from_disk(path), logger) {} DecodedFile::DecodedFile(const std::string &path, const FileType as, const Logger &logger) - : DecodedFile(internal::open_strategy::open_file( - std::make_shared(path), as, logger)) {} + : DecodedFile(File::from_disk(path), as, logger) {} DecodedFile::DecodedFile(const std::string &path, const DecodePreference &preference, const Logger &logger) - : DecodedFile(internal::open_strategy::open_file( - std::make_shared(path), preference, logger)) {} + : DecodedFile(File::from_disk(path), preference, logger) {} File DecodedFile::file() const { return File(m_impl->file()); } @@ -271,21 +289,41 @@ ArchiveFile::ArchiveFile(std::shared_ptr impl) Archive ArchiveFile::archive() const { return Archive(m_impl->archive()); } +DocumentFile DocumentFile::from_disk(const std::string &path, + const Logger &logger) { + return DocumentFile(File::from_disk(path), logger); +} + +DocumentFile DocumentFile::from_memory(std::string data, const Logger &logger) { + return DocumentFile(File::from_memory(std::move(data)), logger); +} + +FileType DocumentFile::type(const File &file) { + return DocumentFile(file).file_type(); +} + FileType DocumentFile::type(const std::string &path) { - return DocumentFile(path).file_type(); + return type(File::from_disk(path)); +} + +FileMeta DocumentFile::meta(const File &file) { + return DocumentFile(file).file_meta(); } FileMeta DocumentFile::meta(const std::string &path) { - return DocumentFile(path).file_meta(); + return meta(File::from_disk(path)); } DocumentFile::DocumentFile( std::shared_ptr impl) : DecodedFile(impl), m_impl{std::move(impl)} {} +DocumentFile::DocumentFile(const File &file, const Logger &logger) + : DocumentFile( + internal::open_strategy::open_document_file(file.impl(), logger)) {} + DocumentFile::DocumentFile(const std::string &path, const Logger &logger) - : DocumentFile(internal::open_strategy::open_document_file( - std::make_shared(path), logger)) {} + : DocumentFile(File::from_disk(path), logger) {} DocumentType DocumentFile::document_type() const { return m_impl->document_type(); diff --git a/src/odr/file.hpp b/src/odr/file.hpp index d4f3ad3f9..0b2f872fd 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -235,11 +235,20 @@ struct FileMeta final { /// @brief Represents a file. class File final { public: + /// @brief A file read from @p path on disk. + [[nodiscard]] static File from_disk(const std::string &path); + /// @brief A file held in memory; @p data is its bytes, moved in. + /// + /// The only way to hand the library a file that has no path — a download, a + /// browser upload, a decrypted payload. + [[nodiscard]] static File from_memory(std::string data); + /// Constructs the null file — every accessor but @ref location throws @ref /// NullPointerError on it, so assign a real one before use. File(); /// @throws NullPointerError if the impl is null. explicit File(std::shared_ptr); + /// @brief Equivalent to @ref from_disk. explicit File(const std::string &path); [[nodiscard]] FileLocation location() const noexcept; @@ -262,16 +271,22 @@ class File final { /// @brief Represents a decoded file. class DecodedFile { public: + [[nodiscard]] static std::vector + list_file_types(const File &file, const Logger &logger = Logger::null()); [[nodiscard]] static std::vector list_file_types(const std::string &path, const Logger &logger = Logger::null()); [[nodiscard]] static std::string_view + mimetype(const File &file, const Logger &logger = Logger::null()); + [[nodiscard]] static std::string_view mimetype(const std::string &path, const Logger &logger = Logger::null()); explicit DecodedFile(std::shared_ptr impl); explicit DecodedFile(const File &file, const Logger &logger = Logger::null()); DecodedFile(const File &file, FileType as, const Logger &logger = Logger::null()); + DecodedFile(const File &file, const DecodePreference &preference, + const Logger &logger = Logger::null()); explicit DecodedFile(const std::string &path, const Logger &logger = Logger::null()); DecodedFile(const std::string &path, FileType as, @@ -355,10 +370,22 @@ class ArchiveFile final : public DecodedFile { /// @brief Represents a document file. class DocumentFile final : public DecodedFile { public: + /// @brief Decodes the document file at @p path on disk. + [[nodiscard]] static DocumentFile + from_disk(const std::string &path, const Logger &logger = Logger::null()); + /// @brief Decodes a document file held in memory; @p data is its bytes, + /// moved in. + [[nodiscard]] static DocumentFile + from_memory(std::string data, const Logger &logger = Logger::null()); + + static FileType type(const File &file); static FileType type(const std::string &path); + static FileMeta meta(const File &file); static FileMeta meta(const std::string &path); explicit DocumentFile(std::shared_ptr); + explicit DocumentFile(const File &file, + const Logger &logger = Logger::null()); explicit DocumentFile(const std::string &path, const Logger &logger = Logger::null()); diff --git a/src/odr/internal/magic.cpp b/src/odr/internal/magic.cpp index 902e6ac78..fcf13e685 100644 --- a/src/odr/internal/magic.cpp +++ b/src/odr/internal/magic.cpp @@ -212,12 +212,12 @@ FileType magic::file_type(const File &file) { return file_type(*file.stream()); } -std::string_view magic::mimetype(const std::string &path, +std::string_view magic::mimetype(const std::shared_ptr &file, const Logger &logger) { // a zip or compound file binary only names its document once opened; // `list_file_types` reports the container first and the decoded type after const std::vector file_types = - open_strategy::list_file_types(std::make_shared(path), logger); + open_strategy::list_file_types(file, logger); if (file_types.empty()) { throw UnknownFileType(); } @@ -225,4 +225,9 @@ std::string_view magic::mimetype(const std::string &path, return odr::mimetype_by_file_type(file_types.back()); } +std::string_view magic::mimetype(const std::string &path, + const Logger &logger) { + return mimetype(std::make_shared(path), logger); +} + } // namespace odr::internal diff --git a/src/odr/internal/magic.hpp b/src/odr/internal/magic.hpp index 14e23f58e..bd0a81d60 100644 --- a/src/odr/internal/magic.hpp +++ b/src/odr/internal/magic.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace odr { @@ -21,5 +22,7 @@ FileType file_type(const File &file); /// Opens the file to tell what a container holds, so it needs a logger like the /// rest of the open strategy. +std::string_view mimetype(const std::shared_ptr &file, + const Logger &logger); std::string_view mimetype(const std::string &path, const Logger &logger); } // namespace odr::internal::magic diff --git a/src/odr/odr.cpp b/src/odr/odr.cpp index 7a19f201e..ff026fd1b 100644 --- a/src/odr/odr.cpp +++ b/src/odr/odr.cpp @@ -145,15 +145,38 @@ odr::capabilities_by_file_type(const FileType type) noexcept { return row == nullptr ? FileTypeCapabilities{} : row->capabilities; } +std::vector odr::list_file_types(const File &file, + const Logger &logger) { + return DecodedFile::list_file_types(file, logger); +} + std::vector odr::list_file_types(const std::string &path, const Logger &logger) { return DecodedFile::list_file_types(path, logger); } +std::string_view odr::mimetype(const File &file, const Logger &logger) { + return DecodedFile::mimetype(file, logger); +} + std::string_view odr::mimetype(const std::string &path, const Logger &logger) { return DecodedFile::mimetype(path, logger); } +odr::DecodedFile odr::open(const File &file, const Logger &logger) { + return DecodedFile(file, logger); +} + +odr::DecodedFile odr::open(const File &file, const FileType as, + const Logger &logger) { + return {file, as, logger}; +} + +odr::DecodedFile odr::open(const File &file, const DecodePreference &preference, + const Logger &logger) { + return {file, preference, logger}; +} + odr::DecodedFile odr::open(const std::string &path, const Logger &logger) { return DecodedFile(path, logger); } diff --git a/src/odr/odr.hpp b/src/odr/odr.hpp index 657b90aed..9b3a62616 100644 --- a/src/odr/odr.hpp +++ b/src/odr/odr.hpp @@ -64,13 +64,29 @@ mimetypes_by_file_type(FileType type) noexcept; [[nodiscard]] FileTypeCapabilities capabilities_by_file_type(FileType type) noexcept; +/// @brief The file types detected for @p file. +[[nodiscard]] std::vector +list_file_types(const File &file, const Logger &logger = Logger::null()); /// @brief The file types detected for the file at @p path. [[nodiscard]] std::vector list_file_types(const std::string &path, const Logger &logger = Logger::null()); +/// @brief The MIME type detected for @p file. +[[nodiscard]] std::string_view mimetype(const File &file, + const Logger &logger = Logger::null()); /// @brief The MIME type detected for the file at @p path. [[nodiscard]] std::string_view mimetype(const std::string &path, const Logger &logger = Logger::null()); +/// @brief Decodes @p file. +[[nodiscard]] DecodedFile open(const File &file, + const Logger &logger = Logger::null()); +/// @brief Decodes @p file as @p as. +[[nodiscard]] DecodedFile open(const File &file, FileType as, + const Logger &logger = Logger::null()); +/// @brief Decodes @p file by @p preference. +[[nodiscard]] DecodedFile open(const File &file, + const DecodePreference &preference, + const Logger &logger = Logger::null()); /// @brief Opens and decodes the file at @p path. [[nodiscard]] DecodedFile open(const std::string &path, const Logger &logger = Logger::null()); diff --git a/test/src/file_test.cpp b/test/src/file_test.cpp index 3251e8b21..e74db7b42 100644 --- a/test/src/file_test.cpp +++ b/test/src/file_test.cpp @@ -1,7 +1,9 @@ +#include #include #include #include +#include #include @@ -16,6 +18,50 @@ using namespace odr::test; TEST(File, open) { EXPECT_THROW(File("/"), FileNotFound); } +TEST(File, from_disk_matches_the_path_constructor) { + const std::string path = TestData::test_file_path("odr-public/odt/about.odt"); + + const File file = File::from_disk(path); + + EXPECT_EQ(file.location(), FileLocation::disk); + EXPECT_EQ(file.disk_path(), File(path).disk_path()); + EXPECT_EQ(file.size(), File(path).size()); +} + +TEST(File, from_memory_holds_its_bytes) { + const File file = File::from_memory("hello"); + + EXPECT_EQ(file.location(), FileLocation::memory); + EXPECT_EQ(file.size(), 5); + EXPECT_FALSE(file.disk_path().has_value()); + ASSERT_TRUE(file.memory_data().has_value()); + EXPECT_EQ(*file.memory_data(), "hello"); +} + +/// The whole point of `from_memory`: a caller with bytes and no path — a +/// download, a browser upload — decodes to exactly what the same bytes on disk +/// would have decoded to. +TEST(File, from_memory_decodes_the_same_as_from_disk) { + const std::string path = TestData::test_file_path("odr-public/odt/about.odt"); + + const DecodedFile from_disk(File::from_disk(path)); + const DecodedFile from_memory( + File::from_memory(internal::util::file::read(path))); + + EXPECT_EQ(from_memory.file_type(), from_disk.file_type()); + EXPECT_EQ(from_memory.file_category(), from_disk.file_category()); + EXPECT_EQ(from_memory.file_meta().type, from_disk.file_meta().type); + EXPECT_EQ(from_memory.file_meta().document_type, + from_disk.file_meta().document_type); + + EXPECT_EQ(DecodedFile::list_file_types( + File::from_memory(internal::util::file::read(path))), + DecodedFile::list_file_types(path)); + EXPECT_EQ(DecodedFile::mimetype( + File::from_memory(internal::util::file::read(path))), + DecodedFile::mimetype(path)); +} + /// `MemoryFile` used to report itself as `disk`, and `memory_data()` handed /// back a bare pointer that only meant something alongside `size()`. TEST(File, memory_file_reports_memory_and_its_bytes) { @@ -48,6 +94,25 @@ TEST(File, disk_file_has_no_memory_data) { TEST(DocumentFile, open) { EXPECT_THROW(DocumentFile("/"), FileNotFound); } +TEST(DocumentFile, from_disk_and_from_memory_agree) { + const std::string path = TestData::test_file_path("odr-public/odt/about.odt"); + + const DocumentFile from_disk = DocumentFile::from_disk(path); + const DocumentFile from_memory = + DocumentFile::from_memory(internal::util::file::read(path)); + + EXPECT_EQ(from_memory.file_type(), from_disk.file_type()); + EXPECT_EQ(from_memory.document_type(), from_disk.document_type()); + EXPECT_EQ(from_memory.document().document_type(), + from_disk.document().document_type()); +} + +/// Not a document, so both factories have to refuse it the same way. +TEST(DocumentFile, from_memory_throws_on_a_non_document) { + EXPECT_THROW(std::ignore = DocumentFile::from_memory("not a document"), + NoDocumentFile); +} + TEST(DecodedFile, wpd) { const auto logger = Logger::create_stdio("odr-test", LogLevel::verbose);