Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 111 additions & 7 deletions src/kaggle/api/kaggle_api_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,30 +416,105 @@ def __str__(self):
return self.value


DEFAULT_IGNORE_PATTERNS = [
".git/",
"*/.git/",
".cache/",
".huggingface/",
]


def should_ignore(rel_path: str, is_dir: bool, patterns: List[str]) -> bool:
"""Helper to check if a path should be ignored based on patterns."""
import fnmatch

rel_path = rel_path.replace(os.path.sep, "/")
match_path = rel_path + "/" if is_dir and not rel_path.endswith("/") else rel_path

for pattern in patterns:
pattern = pattern.replace(os.path.sep, "/")
if pattern.endswith("/"):
if not is_dir:
continue
if fnmatch.fnmatch(match_path, pattern):
return True
return False


class DirectoryArchive(object):
"""
Context manager for handling directory archives.
"""Context manager for handling directory archives with filtering.

This class provides a context manager for working with directory archives in various formats.
It manages the lifecycle of the archive, including opening and closing resources as needed.
This class provides a context manager for working with directory archives in
various formats. It manages the lifecycle of the archive, including opening
and closing resources as needed. It supports filtering files based on
ignore patterns.
"""

def __init__(self, fullpath, fmt):
def __init__(self, fullpath, fmt, ignore_patterns=None):
self._fullpath = fullpath
self._format = fmt
self._ignore_patterns = ignore_patterns or []
self.name = None
self.path = None

def __enter__(self):
self._temp_dir = tempfile.mkdtemp()
_, dir_name = os.path.split(self._fullpath)
self.path = shutil.make_archive(os.path.join(self._temp_dir, dir_name), self._format, self._fullpath)
archive_base_path = os.path.join(self._temp_dir, dir_name)

if self._ignore_patterns:
self.path = self._create_archive_with_filters(archive_base_path)
else:
self.path = shutil.make_archive(archive_base_path, self._format, self._fullpath)

_, self.name = os.path.split(self.path)
return self

def __exit__(self, *args):
shutil.rmtree(self._temp_dir)

def _create_archive_with_filters(self, base_name):
if self._format == "zip":
archive_path = base_name + ".zip"
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(self._fullpath):
dirs[:] = [
d
for d in dirs
if not should_ignore(
os.path.relpath(os.path.join(root, d), self._fullpath),
True,
self._ignore_patterns,
)
]
for file in files:
file_path = os.path.join(root, file)
rel_path = os.path.relpath(file_path, self._fullpath)
if not should_ignore(rel_path, False, self._ignore_patterns):
zipf.write(file_path, rel_path)
return archive_path
elif self._format == "tar":
archive_path = base_name + ".tar"
with tarfile.open(archive_path, "w") as tarf:
for root, dirs, files in os.walk(self._fullpath):
dirs[:] = [
d
for d in dirs
if not should_ignore(
os.path.relpath(os.path.join(root, d), self._fullpath),
True,
self._ignore_patterns,
)
]
for file in files:
file_path = os.path.join(root, file)
rel_path = os.path.relpath(file_path, self._fullpath)
if not should_ignore(rel_path, False, self._ignore_patterns):
tarf.add(file_path, rel_path)
return archive_path
else:
raise ValueError(f"Unsupported archive format: {self._format}")


class ResumableUploadContext(object):
"""
Expand Down Expand Up @@ -3029,6 +3104,7 @@ def competition_data_update(
rerun: bool = False,
quiet: bool = False,
include_hidden: bool = False,
ignore_patterns: Optional[List[str]] = None,
) -> ApiCreateCompetitionDataResponse:
"""Update (version) the data files for a competition you host.

Expand All @@ -3054,6 +3130,7 @@ def competition_data_update(
quiet (bool): Suppress per-file upload progress lines.
include_hidden (bool): If True, upload hidden files and traverse
hidden sub-directories. Default False.
ignore_patterns (Optional[List[str]]): Patterns of files/dirs to ignore.

Returns:
ApiCreateCompetitionDataResponse: url, databundle_id,
Expand All @@ -3070,11 +3147,31 @@ def competition_data_update(
if os.path.isfile(path):
uploads.append((os.path.basename(path), path))
else:
patterns = (ignore_patterns or []) if include_hidden else DEFAULT_IGNORE_PATTERNS + (ignore_patterns or [])

for dirpath, dirnames, filenames in os.walk(path):
if not include_hidden:
# Prune hidden sub-directories in place so os.walk skips them.
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
filenames = [n for n in filenames if not n.startswith(".")]

# Prune by ignore_patterns
pruned_dirnames = []
for d in dirnames:
full_dir_path = os.path.join(dirpath, d)
rel_dir_path = os.path.relpath(full_dir_path, path)
if not should_ignore(rel_dir_path, is_dir=True, patterns=patterns):
pruned_dirnames.append(d)
dirnames[:] = pruned_dirnames

pruned_filenames = []
for f in filenames:
full_file_path = os.path.join(dirpath, f)
rel_file_path = os.path.relpath(full_file_path, path)
if not should_ignore(rel_file_path, is_dir=False, patterns=patterns):
pruned_filenames.append(f)
filenames = pruned_filenames

for name in filenames:
full = os.path.join(dirpath, name)
rel = os.path.relpath(full, path).replace(os.sep, "/")
Expand All @@ -3091,7 +3188,12 @@ def competition_data_update(
with ResumableUploadContext() as upload_context:
for rel_name, full_path in uploads:
upload_file = self._upload_file(
rel_name, full_path, ApiBlobType.DATASET, upload_context, quiet, resources=None
rel_name,
full_path,
ApiBlobType.DATASET,
upload_context,
quiet,
resources=None,
)
if upload_file is not None:
f = ApiCompetitionDataFile()
Expand Down Expand Up @@ -3120,6 +3222,7 @@ def competition_data_update_cli(
rerun=False,
quiet=False,
include_hidden=False,
ignore_patterns=None,
):
"""CLI wrapper for competition_data_update."""
competition_name = competition or competition_opt
Expand All @@ -3141,6 +3244,7 @@ def competition_data_update_cli(
rerun=rerun,
quiet=quiet,
include_hidden=include_hidden,
ignore_patterns=ignore_patterns,
)
print(f'New data version created for "{competition_name}": {response.url}')

Expand Down
7 changes: 7 additions & 0 deletions src/kaggle/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,13 @@ def parse_competitions(subparsers) -> None:
parser_competitions_data_update_optional.add_argument(
"-q", "--quiet", dest="quiet", action="store_true", help=Help.param_quiet
)
parser_competitions_data_update_optional.add_argument(
"--ignore-patterns",
dest="ignore_patterns",
action="append",
required=False,
help="Patterns to ignore when uploading files/dirs",
)
parser_competitions_data_update._action_groups.append(parser_competitions_data_update_optional)
parser_competitions_data_update.set_defaults(func=api.competition_data_update_cli)

Expand Down
26 changes: 26 additions & 0 deletions tests/unit/test_cli_competitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,9 +376,35 @@ def test_competitions_data_update_succeeds(parser):
assert kwargs["version_notes"] == "update msg"
assert kwargs["rerun"] is True
assert kwargs["include_hidden"] is True
assert kwargs.get("ignore_patterns") is None


def test_competitions_data_update_with_ignore_patterns_succeeds(parser):
func, kwargs = parser.dispatch(
[
"competitions",
"data",
"update",
"my-comp",
"-p",
"/path/to/data",
"-m",
"update msg",
"--ignore-patterns",
"*.tmp",
"--ignore-patterns",
"temp/",
]
)
assert func.__name__ == "competition_data_update_cli"
assert kwargs["competition"] == "my-comp"
assert kwargs["path"] == "/path/to/data"
assert kwargs["version_notes"] == "update msg"
assert kwargs["ignore_patterns"] == ["*.tmp", "temp/"]


def test_competitions_settings_get_succeeds(parser):

func, kwargs = parser.dispatch(["competitions", "settings", "get", "my-comp", "--json"])
assert func.__name__ == "competition_get_settings_cli"
assert kwargs["competition"] == "my-comp"
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_competition_data_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,30 @@ def test_update_directory_recurses_and_preserves_paths(self, mock_client, mock_u
for call in mock_upload.call_args_list:
self.assertEqual(call.args[2], ApiBlobType.DATASET)

@patch.object(KaggleApi, "_upload_file")
@patch.object(KaggleApi, "build_kaggle_client")
def test_update_directory_respects_ignore_patterns(self, mock_client, mock_upload):
ignore_patterns = ["test.csv", "images/cats/"]

mock_upload.side_effect = [_mock_upload_file(f"tok-{i}") for i in range(10)]
mock_kaggle = self._patch_client(mock_client)

self.api.competition_data_update(
competition_name="my-comp",
path=self.tmp,
version_notes="version with ignores",
ignore_patterns=ignore_patterns,
)

request = mock_kaggle.competitions.competition_api_client.create_competition_data.call_args[0][0]
names = sorted(f.name for f in request.files)
self.assertEqual(
names,
["images/dog.png", "train.csv"],
)
called_names = sorted(call.args[0] for call in mock_upload.call_args_list)
self.assertEqual(called_names, names)

@patch.object(KaggleApi, "_upload_file")
@patch.object(KaggleApi, "build_kaggle_client")
def test_update_single_file_uploads_as_is(self, mock_client, mock_upload):
Expand Down
Loading
Loading