Skip to content

Commit b25aa2f

Browse files
authored
Merge pull request #89 from ImageMarkup/better-metadata-errors
Ensure metadata output file is writable before download
2 parents 53bc63c + 1bf90c3 commit b25aa2f

File tree

3 files changed

+54
-2
lines changed

3 files changed

+54
-2
lines changed

isic_cli/cli/metadata.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from rich.progress import Progress, track
1919
from rich.table import Table
2020

21-
from isic_cli.cli.types import CommaSeparatedIdentifiers, SearchString
21+
from isic_cli.cli.types import CommaSeparatedIdentifiers, SearchString, WritableFilePath
2222
from isic_cli.cli.utils import _extract_metadata, suggest_guest_login
2323
from isic_cli.io.http import get_images, get_num_images
2424

@@ -164,7 +164,7 @@ def validate(csv_file: io.TextIOWrapper): # noqa: C901, PLR0915, PLR0912
164164
@click.option(
165165
"-o",
166166
"--outfile",
167-
type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
167+
type=WritableFilePath(dir_okay=False, file_okay=True, writable=True, path_type=Path),
168168
help="A filepath to write the output CSV to.",
169169
)
170170
@click.pass_obj

isic_cli/cli/types.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,35 @@ def convert(self, value: str, param, ctx) -> str:
7777
raise
7878

7979
return value
80+
81+
82+
class WritableFilePath(click.Path):
83+
name = "writable_file_path"
84+
85+
def __init__(self, *args, **kwargs):
86+
super().__init__(*args, **kwargs)
87+
88+
if not self.file_okay:
89+
raise ValueError("file_okay must be True")
90+
elif self.dir_okay:
91+
raise ValueError("dir_okay must be False")
92+
93+
def convert(self, value, param, ctx):
94+
value = super().convert(value, param, ctx)
95+
96+
# writeable checks on click.Path only apply to already existing paths, see
97+
# https://github.com/pallets/click/issues/2495.
98+
# check if the final path is writable before going to the effort of downloading the data.
99+
if value is not None and str(value) != "-":
100+
try:
101+
value.parent.mkdir(parents=True, exist_ok=True)
102+
with value.open("w", newline="", encoding="utf8"):
103+
pass
104+
except PermissionError:
105+
self.fail(f"Permission denied - cannot write to '{value}'.", param, ctx)
106+
except OSError as e:
107+
# this is a general catch-all for weirder issues like a read only filesystem,
108+
# filenames that are too long or have invalid chars, etc.
109+
self.fail(f"Cannot write to '{value}'. {e!s}", param, ctx)
110+
111+
return value

tests/test_cli_metadata.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pathlib import Path
44
import re
5+
import sys
56

67
import pytest
78
from pytest_lazy_fixtures import lf
@@ -87,6 +88,25 @@ def test_metadata_download_file(cli_runner):
8788
assert re.search(r"ISIC_0000000.*Foo.*CC-0.*melanoma.*male", output), output
8889

8990

91+
@pytest.mark.skipif(sys.platform == "win32", reason="Windows does not support this test")
92+
@pytest.mark.usefixtures("_mock_image_metadata", "_isolated_filesystem")
93+
def test_metadata_download_file_no_write(cli_run):
94+
result = cli_run(["metadata", "download", "-o", "/metadata.csv"])
95+
# it's important that the exit code is 2 and not 1, because the key constraint of this
96+
# functionality is that the user gets the error message before spending their time
97+
# downloading the data. exit code 2 is for usage errors with click.
98+
assert result.exit_code == 2, result.exception
99+
assert re.search(r"Permission denied", result.output), result.output
100+
101+
102+
@pytest.mark.usefixtures("_mock_image_metadata", "_isolated_filesystem")
103+
def test_metadata_download_file_bad_filename(cli_run):
104+
result = cli_run(["metadata", "download", "-o", f"{'1' * 255}.csv"])
105+
# see comment in test_metadata_download_file_no_write for why exit code is 2
106+
assert result.exit_code == 2, result.exception
107+
assert re.search(r"Cannot write to", result.output), result.output
108+
109+
90110
@pytest.mark.usefixtures("_mock_image_metadata")
91111
@pytest.mark.parametrize(
92112
"cli_runner",

0 commit comments

Comments
 (0)