Skip to content

Commit b9b8dc9

Browse files
authored
FileAPI: download_directory_as_zip (#73)
Signed-off-by: Alexander Piskun <[email protected]>
1 parent 1df7370 commit b9b8dc9

File tree

5 files changed

+98
-1
lines changed

5 files changed

+98
-1
lines changed

.github/workflows/analysis-coverage.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,9 +699,12 @@ jobs:
699699
path: apps/app_ecosystem_v2
700700
repository: cloud-py-api/app_ecosystem_v2
701701

702+
- name: Patch base.php
703+
if: ${{ startsWith(matrix.nextcloud, 'stable26') }}
704+
run: patch -p 1 -i apps/app_ecosystem_v2/base_php.patch
705+
702706
- name: Install AppEcosystemV2
703707
run: |
704-
patch -p 1 -i apps/app_ecosystem_v2/base_php.patch
705708
php occ app:enable app_ecosystem_v2
706709
cd nc_py_api
707710
coverage run --data-file=.coverage.ci_install tests/_install.py &

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
77
### Added
88

99
- APIs for enabling\disabling External Applications.
10+
- FileAPI: `download_directory_as_zip` method.
1011

1112
### Changed
1213

nc_py_api/_session.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ def __del__(self):
150150
if hasattr(self, "adapter") and self.adapter:
151151
self.adapter.close()
152152

153+
def get_stream(self, path: str, params: Optional[dict] = None, **kwargs) -> Iterator[Response]:
154+
return self._get_stream(
155+
f"{quote(path)}?{urlencode(params, True)}" if params else quote(path), kwargs.get("headers", {}), **kwargs
156+
)
157+
158+
def _get_stream(self, path_params: str, headers: dict, **kwargs) -> Iterator[Response]:
159+
self.init_adapter()
160+
timeout = kwargs.pop("timeout", self.cfg.options.timeout)
161+
return self.adapter.stream(
162+
"GET", f"{self.cfg.endpoint}{path_params}", headers=headers, timeout=timeout, **kwargs
163+
)
164+
153165
def ocs(
154166
self,
155167
method: str,
@@ -296,6 +308,10 @@ def __init__(self, **kwargs):
296308
self.cfg = AppConfig(**kwargs)
297309
super().__init__(**kwargs)
298310

311+
def _get_stream(self, path_params: str, headers: dict, **kwargs) -> Iterator[Response]:
312+
self.sign_request("GET", path_params, headers, None)
313+
return super()._get_stream(path_params, headers, **kwargs)
314+
299315
def _ocs(self, method: str, path_params: str, headers: dict, data: Optional[bytes], **kwargs):
300316
self.sign_request(method, path_params, headers, data)
301317
return super()._ocs(method, path_params, headers, data, **kwargs)

nc_py_api/files/files.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,31 @@ def download2stream(self, path: Union[str, FsNode], fp, **kwargs) -> None:
147147
else:
148148
raise TypeError("`fp` must be a path to file or an object with `write` method.")
149149

150+
def download_directory_as_zip(
151+
self, path: Union[str, FsNode], local_path: Union[str, Path, None] = None, **kwargs
152+
) -> Path:
153+
"""Downloads a remote directory as zip archive.
154+
155+
:param path: path to directory to download.
156+
:param local_path: relative or absolute file path to save zip file.
157+
:returns: Path to the saved zip archive.
158+
159+
.. note:: This works only for directories, you should not use this to download a file.
160+
"""
161+
path = path.user_path if isinstance(path, FsNode) else path
162+
with self._session.get_stream(
163+
"/index.php/apps/files/ajax/download.php", params={"dir": path}
164+
) as response: # type: ignore
165+
check_error(response.status_code, f"download_directory_as_zip: user={self._session.user}, path={path}")
166+
result_path = local_path if local_path else os.path.basename(path)
167+
with open(
168+
result_path,
169+
"wb",
170+
) as fp:
171+
for data_chunk in response.iter_raw(chunk_size=kwargs.get("chunk_size", 4 * 1024 * 1024)):
172+
fp.write(data_chunk)
173+
return Path(result_path)
174+
150175
def upload(self, path: Union[str, FsNode], content: Union[bytes, str]) -> FsNode:
151176
"""Creates a file with the specified content at the specified path.
152177

tests/files_test.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import math
2+
import os
3+
import zipfile
24
from datetime import datetime
35
from io import BytesIO
46
from random import choice, randbytes
@@ -541,6 +543,56 @@ def test_fs_node_str(nc):
541543
nc.files.delete("test_file_name.txt")
542544

543545

546+
@pytest.mark.parametrize("nc", NC_TO_TEST)
547+
def test_download_as_zip(nc):
548+
nc.files.makedirs("test_root_folder/test_subfolder", exist_ok=True)
549+
try:
550+
nc.files.mkdir("test_root_folder/test_subfolder2")
551+
nc.files.upload("test_root_folder/0.txt", content="")
552+
nc.files.upload("test_root_folder/1.txt", content="123")
553+
nc.files.upload("test_root_folder/test_subfolder/0.txt", content="")
554+
result = nc.files.download_directory_as_zip("test_root_folder")
555+
try:
556+
with zipfile.ZipFile(result, "r") as zip_ref:
557+
assert zip_ref.filelist[0].filename == "test_root_folder/"
558+
assert not zip_ref.filelist[0].file_size
559+
assert zip_ref.filelist[1].filename == "test_root_folder/0.txt"
560+
assert not zip_ref.filelist[1].file_size
561+
assert zip_ref.filelist[2].filename == "test_root_folder/1.txt"
562+
assert zip_ref.filelist[2].file_size == 3
563+
assert zip_ref.filelist[3].filename == "test_root_folder/test_subfolder/"
564+
assert not zip_ref.filelist[3].file_size
565+
assert zip_ref.filelist[4].filename == "test_root_folder/test_subfolder/0.txt"
566+
assert not zip_ref.filelist[4].file_size
567+
assert zip_ref.filelist[5].filename == "test_root_folder/test_subfolder2/"
568+
assert not zip_ref.filelist[5].file_size
569+
assert len(zip_ref.filelist) == 6
570+
finally:
571+
os.remove(result)
572+
result = nc.files.download_directory_as_zip("test_root_folder/test_subfolder", "2.zip")
573+
try:
574+
assert str(result) == "2.zip"
575+
with zipfile.ZipFile(result, "r") as zip_ref:
576+
assert zip_ref.filelist[0].filename == "test_subfolder/"
577+
assert not zip_ref.filelist[0].file_size
578+
assert zip_ref.filelist[1].filename == "test_subfolder/0.txt"
579+
assert not zip_ref.filelist[1].file_size
580+
assert len(zip_ref.filelist) == 2
581+
finally:
582+
os.remove("2.zip")
583+
result = nc.files.download_directory_as_zip("test_root_folder/test_subfolder2", "empty_folder.zip")
584+
try:
585+
assert str(result) == "empty_folder.zip"
586+
with zipfile.ZipFile(result, "r") as zip_ref:
587+
assert zip_ref.filelist[0].filename == "test_subfolder2/"
588+
assert not zip_ref.filelist[0].file_size
589+
assert len(zip_ref.filelist) == 1
590+
finally:
591+
os.remove("empty_folder.zip")
592+
finally:
593+
nc.files.delete("test_root_folder")
594+
595+
544596
@pytest.mark.parametrize("nc", NC_TO_TEST[:1])
545597
def test_fs_node_is_xx(nc):
546598
nc.files.delete("test_root_folder", not_fail=True)

0 commit comments

Comments
 (0)