Skip to content

Commit a2bc31d

Browse files
adds directory upload.
1 parent fd60189 commit a2bc31d

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

pythonanywhere_core/files.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import getpass
2+
from pathlib import Path
23
from typing import Tuple, Union
34
from urllib.parse import urljoin
45

@@ -174,3 +175,23 @@ def tree_get(self, path: str) -> dict:
174175
return result.json()
175176

176177
raise PythonAnywhereApiException(f"GET to {url} failed, got {result}{self._error_msg(result)}")
178+
179+
def tree_post(self, local_dir_path: str, remote_dir_path: str) -> None:
180+
"""Uploads contents of a local directory to remote path on
181+
PythonAnywhere. Walks `local_dir_path` recursively and uploads
182+
each file using :meth:`path_post`, preserving directory structure.
183+
184+
Raises :exc:`PythonAnywhereApiException` on first upload failure."""
185+
186+
local_dir = Path(local_dir_path)
187+
if not local_dir.is_dir():
188+
raise ValueError(f"{local_dir_path} is not a directory")
189+
for path in sorted(local_dir.rglob("*")):
190+
if path.is_file():
191+
relative = path.relative_to(local_dir)
192+
remote_path = f"{remote_dir_path}/{relative}"
193+
self.path_post(remote_path, path.read_bytes())
194+
elif path.is_dir() and not any(path.iterdir()):
195+
placeholder = f"{remote_dir_path}/{path.relative_to(local_dir)}/.empty"
196+
self.path_post(placeholder, b"")
197+
self.path_delete(placeholder)

tests/test_files.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,88 @@ def test_raises_when_path_does_not_exist(
399399
f"GET to {url} failed, got <Response [400]>: {invalid_path} does not exist"
400400
)
401401
assert str(e.value) == expected_error_msg
402+
403+
404+
def test_tree_post_uploads_single_file(
405+
api_token, api_responses, base_url, home_dir_path, tmp_path
406+
):
407+
(tmp_path / "index.html").write_bytes(b"<h1>hello</h1>")
408+
remote_dir = f"{home_dir_path}/myapp"
409+
remote_file_url = f"{base_url}path{remote_dir}/index.html"
410+
api_responses.add(responses.POST, url=remote_file_url, status=201)
411+
412+
Files().tree_post(str(tmp_path), remote_dir)
413+
414+
assert len(api_responses.calls) == 1
415+
assert api_responses.calls[0].request.url == remote_file_url
416+
417+
418+
def test_tree_post_preserves_nested_structure(
419+
api_token, api_responses, base_url, home_dir_path, tmp_path
420+
):
421+
(tmp_path / "index.html").write_bytes(b"<h1>hello</h1>")
422+
(tmp_path / "static" / "css").mkdir(parents=True)
423+
(tmp_path / "static" / "css" / "style.css").write_bytes(b"body {}")
424+
(tmp_path / "static" / "app.js").write_bytes(b"console.log('hi')")
425+
426+
remote_dir = f"{home_dir_path}/myapp"
427+
api_responses.add(responses.POST, url=f"{base_url}path{remote_dir}/index.html", status=201)
428+
api_responses.add(responses.POST, url=f"{base_url}path{remote_dir}/static/app.js", status=201)
429+
api_responses.add(responses.POST, url=f"{base_url}path{remote_dir}/static/css/style.css", status=201)
430+
431+
Files().tree_post(str(tmp_path), remote_dir)
432+
433+
assert len(api_responses.calls) == 3
434+
uploaded_urls = {call.request.url for call in api_responses.calls}
435+
assert uploaded_urls == {
436+
f"{base_url}path{remote_dir}/index.html",
437+
f"{base_url}path{remote_dir}/static/app.js",
438+
f"{base_url}path{remote_dir}/static/css/style.css",
439+
}
440+
441+
442+
def test_tree_post_creates_empty_directories(
443+
api_token, api_responses, base_url, home_dir_path, tmp_path
444+
):
445+
(tmp_path / "empty_dir").mkdir()
446+
remote_dir = f"{home_dir_path}/myapp"
447+
empty_file_url = f"{base_url}path{remote_dir}/empty_dir/.empty"
448+
api_responses.add(responses.POST, url=empty_file_url, status=201)
449+
api_responses.add(responses.DELETE, url=empty_file_url, status=204)
450+
451+
Files().tree_post(str(tmp_path), remote_dir)
452+
453+
assert len(api_responses.calls) == 2
454+
assert api_responses.calls[0].request.url == empty_file_url
455+
assert api_responses.calls[0].request.method == "POST"
456+
assert api_responses.calls[1].request.url == empty_file_url
457+
assert api_responses.calls[1].request.method == "DELETE"
458+
459+
460+
def test_tree_post_raises_when_path_is_not_a_directory(
461+
api_token, tmp_path
462+
):
463+
file_path = tmp_path / "somefile.txt"
464+
file_path.write_bytes(b"content")
465+
466+
with pytest.raises(ValueError) as e:
467+
Files().tree_post(str(file_path), "/home/user/remote")
468+
469+
assert "is not a directory" in str(e.value)
470+
471+
472+
def test_tree_post_fails_fast_on_upload_error(
473+
api_token, api_responses, base_url, home_dir_path, tmp_path
474+
):
475+
(tmp_path / "a.txt").write_bytes(b"aaa")
476+
(tmp_path / "b.txt").write_bytes(b"bbb")
477+
(tmp_path / "c.txt").write_bytes(b"ccc")
478+
479+
remote_dir = f"{home_dir_path}/myapp"
480+
api_responses.add(responses.POST, url=f"{base_url}path{remote_dir}/a.txt", status=201)
481+
api_responses.add(responses.POST, url=f"{base_url}path{remote_dir}/b.txt", status=403)
482+
483+
with pytest.raises(PythonAnywhereApiException):
484+
Files().tree_post(str(tmp_path), remote_dir)
485+
486+
assert len(api_responses.calls) == 2

0 commit comments

Comments
 (0)