Skip to content

Commit d59c991

Browse files
Adds directory upload to cli.
1 parent f314678 commit d59c991

File tree

6 files changed

+108
-8
lines changed

6 files changed

+108
-8
lines changed

cli/path.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import re
33
import sys
4+
from pathlib import Path
45

56
from collections import namedtuple
67

@@ -104,22 +105,39 @@ def tree(
104105

105106
@app.command()
106107
def upload(
107-
path: str = typer.Argument(..., help=("Full path of FILE where CONTENTS should be uploaded to.")),
108-
file: typer.FileBinaryRead = typer.Option(
108+
path: str = typer.Argument(..., help="Full path on PythonAnywhere where CONTENTS should be uploaded to."),
109+
contents: str = typer.Option(
109110
...,
110111
"-c",
111112
"--contents",
112-
help="Path to exisitng file or stdin stream that should be uploaded to PATH."
113+
help="Path to existing file, directory (with -r) or '-' for stdin.",
113114
),
115+
recursive: bool = typer.Option(False, "-r", "--recursive", help="Upload a directory recursively."),
114116
quiet: bool = typer.Option(False, "-q", "--quiet", help="Disable additional logging.")
115117
):
116-
"""
117-
Upload CONTENTS to file at PATH.
118+
"""Upload CONTENTS to PATH.
118119
119120
If PATH points to an existing file, it will be overwritten.
121+
Use -r/--recursive to upload a directory.
120122
"""
121123
pa_path = setup(path, quiet)
122-
success = pa_path.upload(file)
124+
125+
if recursive:
126+
if contents == "-":
127+
typer.echo("stdin is not supported with --recursive", err=True)
128+
sys.exit(1)
129+
success = pa_path.upload_directory(contents)
130+
else:
131+
if contents == "-":
132+
content = sys.stdin.buffer.read()
133+
else:
134+
contents_path = Path(contents)
135+
if contents_path.is_dir():
136+
typer.echo(f"{contents} is a directory, use --recursive flag to upload directories", err=True)
137+
sys.exit(1)
138+
content = contents_path.read_bytes()
139+
success = pa_path.upload(content)
140+
123141
sys.exit(0 if success else 1)
124142

125143

pythonanywhere/files.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ def upload(self, content):
131131
logger.info(snakesay(msg))
132132
return True
133133

134+
def upload_directory(self, local_dir_path):
135+
"""Returns `True` when contents of `local_dir_path` successfully
136+
uploaded to `self.path`, `False` otherwise."""
137+
138+
try:
139+
self.api.tree_post(local_dir_path, self.path)
140+
except Exception as e:
141+
logger.warning(snakesay(str(e)))
142+
return False
143+
144+
logger.info(snakesay(f"Contents of {local_dir_path} successfully uploaded to {self.path}!"))
145+
return True
146+
134147
def get_sharing_url(self, quiet=False):
135148
"""Returns PythonAnywhere sharing url for `self.path` if file
136149
is shared, empty string otherwise."""

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ pytest==9.0.2
88
pytest-cov==7.0.0
99
pytest-mock==3.15.1
1010
pytest-mypy==1.0.1
11-
pythonanywhere_core==0.2.9
11+
pythonanywhere_core>=0.3.0
1212
requests==2.32.5
1313
responses==0.25.8
1414
schema==0.7.2

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"docopt",
3737
"packaging",
3838
"python-dateutil",
39-
"pythonanywhere_core==0.2.9",
39+
"pythonanywhere_core>=0.3.0",
4040
"requests",
4141
"schema",
4242
"snakesay==0.10.4",

tests/test_cli_path.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,43 @@ def test_exits_with_error_when_unsuccessful_upload(self, mock_path):
222222
assert result.exit_code == 1
223223

224224

225+
class TestUploadRecursive:
226+
def test_calls_upload_directory_with_recursive_flag(self, mock_path, tmp_path):
227+
local_dir = tmp_path / "mydir"
228+
local_dir.mkdir()
229+
mock_path.return_value.upload_directory.return_value = True
230+
231+
result = runner.invoke(app, ["upload", "~/remote_dir", "-c", str(local_dir), "-r"])
232+
233+
mock_path.return_value.upload_directory.assert_called_once_with(str(local_dir))
234+
assert result.exit_code == 0
235+
236+
def test_errors_when_stdin_used_with_recursive_flag(self, mock_path):
237+
result = runner.invoke(app, ["upload", "~/remote_dir", "-c", "-", "-r"])
238+
239+
assert result.exit_code == 1
240+
assert "stdin" in result.output
241+
assert "--recursive" in result.output
242+
243+
def test_reads_from_stdin_when_contents_is_dash(self, mock_path):
244+
mock_path.return_value.upload.return_value = True
245+
246+
result = runner.invoke(app, ["upload", "~/remote_file", "-c", "-"], input=b"stdin content")
247+
248+
mock_path.return_value.upload.assert_called_once()
249+
assert result.exit_code == 0
250+
251+
def test_errors_when_directory_passed_without_recursive_flag(self, mock_path, tmp_path):
252+
local_dir = tmp_path / "mydir"
253+
local_dir.mkdir()
254+
255+
result = runner.invoke(app, ["upload", "~/remote_dir", "-c", str(local_dir)])
256+
257+
assert result.exit_code == 1
258+
assert "is a directory" in result.output
259+
assert "--recursive" in result.output
260+
261+
225262
class TestDelete:
226263
def test_creates_pa_path_with_provided_path(self, mock_path, home_dir):
227264
runner.invoke(app, ["delete", "~/hello.txt"])

tests/test_files.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,38 @@ def test_warns_when_file_has_not_been_uploaded(self, mocker):
192192
assert result is False
193193

194194

195+
@pytest.mark.files
196+
class TestPAPathUploadDirectory():
197+
def test_informs_about_successful_directory_upload(self, mocker):
198+
mock_tree_post = mocker.patch("pythonanywhere_core.files.Files.tree_post")
199+
mock_snake = mocker.patch("pythonanywhere.files.snakesay")
200+
mock_info = mocker.patch("pythonanywhere.files.logger.info")
201+
local_dir = "/local/dir"
202+
destination_path = "/home/user/remote"
203+
204+
result = PAPath(destination_path).upload_directory(local_dir)
205+
206+
assert mock_tree_post.call_args == call(local_dir, destination_path)
207+
assert mock_snake.call_args == call(f"Contents of {local_dir} successfully uploaded to {destination_path}!")
208+
assert mock_info.call_args == call(mock_snake.return_value)
209+
assert result is True
210+
211+
def test_warns_when_directory_upload_fails(self, mocker):
212+
mock_tree_post = mocker.patch("pythonanywhere_core.files.Files.tree_post")
213+
mock_tree_post.side_effect = Exception("sth went wrong")
214+
mock_snake = mocker.patch("pythonanywhere.files.snakesay")
215+
mock_warning = mocker.patch("pythonanywhere.files.logger.warning")
216+
local_dir = "/local/dir"
217+
destination_path = "/home/user/remote"
218+
219+
result = PAPath(destination_path).upload_directory(local_dir)
220+
221+
assert mock_tree_post.call_args == call(local_dir, destination_path)
222+
assert mock_snake.call_args == call("sth went wrong")
223+
assert mock_warning.call_args == call(mock_snake.return_value)
224+
assert result is False
225+
226+
195227
@pytest.mark.files
196228
class TestPAPathShare():
197229
def test_returns_full_url_for_shared_file(self, mocker):

0 commit comments

Comments
 (0)