diff --git a/hpccm/primitives/copy.py b/hpccm/primitives/copy.py index 11b6bfc..51bbfea 100644 --- a/hpccm/primitives/copy.py +++ b/hpccm/primitives/copy.py @@ -44,6 +44,17 @@ class copy(object): dest: Path in the container image to copy the file(s) + _exclude_from: String or list of strings. One or more filenames + containing rsync-style exclude patterns (e.g., `.apptainerignore`). + Only used when building for Singularity or Apptainer. If specified, + the copy operation is emitted in the `%setup` section using + `rsync --exclude-from=` rather than the standard `%files` + copy directive. This enables selective exclusion of files and + directories during the image build, for example to omit large data + files, caches, or temporary artifacts. Multiple exclusion files may + be provided as a list or tuple. The default is an empty list + (Singularity specific). + files: A dictionary of file pairs, source and destination, to copy into the container image. If specified, has precedence over `dest` and `src`. @@ -80,6 +91,10 @@ class copy(object): copy(files={'a': '/tmp/a', 'b': '/opt/b'}) ``` + ```python + copy(src='.', dest='/opt/app', _exclude_from='.apptainerignore') + ``` + """ def __init__(self, **kwargs): @@ -96,6 +111,12 @@ def __init__(self, **kwargs): self._post = kwargs.get('_post', '') # Singularity specific self.__src = kwargs.get('src', '') + ef = kwargs.get('_exclude_from', []) + if isinstance(ef, (list, tuple)): + self.__exclude_from = list(ef) + elif ef: + self.__exclude_from = [ef] + if self._mkdir and self._post: logging.error('_mkdir and _post are mutually exclusive!') self._post = False # prefer _mkdir @@ -211,6 +232,13 @@ def __str__(self): dest = pair['dest'] src = pair['src'] + # Use rsync if exclusion file provided and not multi-stage copy + if self.__exclude_from and not self.__from: + excl_opts = ' '.join('--exclude-from={}'.format(x) for x in self.__exclude_from) + pre.append(' mkdir -p ${{SINGULARITY_ROOTFS}}{0}'.format(dest)) + pre.append(' rsync -av {0} {1}/ ${{SINGULARITY_ROOTFS}}{2}/'.format(excl_opts, src, dest)) + continue + if self._post: dest = '/' diff --git a/test/test_copy.py b/test/test_copy.py index 73bb99b..a695bcd 100644 --- a/test/test_copy.py +++ b/test/test_copy.py @@ -255,3 +255,32 @@ def test_from_temp_staging(self): """Singularity files from previous stage in tmp""" c = copy(_from='base', src='foo', dest='/var/tmp/foo') self.assertEqual(str(c), '%files from base\n foo /var/tmp/foo') + + @singularity + def test_exclude_from_single_singularity(self): + """rsync-based copy with _exclude_from (single source)""" + c = copy(src='.', dest='/opt/app', _exclude_from='.apptainerignore') + self.assertEqual(str(c), +r'''%setup + mkdir -p ${SINGULARITY_ROOTFS}/opt/app + rsync -av --exclude-from=.apptainerignore ./ ${SINGULARITY_ROOTFS}/opt/app/ +%files +''') + + @singularity + def test_exclude_from_multiple_singularity(self): + """rsync-based copy with multiple _exclude_from files""" + c = copy(src='data', dest='/opt/data', + _exclude_from=['.ignore1', '.ignore2']) + self.assertEqual(str(c), +r'''%setup + mkdir -p ${SINGULARITY_ROOTFS}/opt/data + rsync -av --exclude-from=.ignore1 --exclude-from=.ignore2 data/ ${SINGULARITY_ROOTFS}/opt/data/ +%files +''') + + @docker + def test_exclude_from_docker_ignored(self): + """_exclude_from ignored in Docker context""" + c = copy(src='.', dest='/opt/app', _exclude_from='.apptainerignore') + self.assertEqual(str(c), 'COPY . /opt/app')