Skip to content

Commit 8e5c203

Browse files
committed
feat(copy): add exclude_from parameter for rsync-based copy in Singularity builds
1 parent 1238f2e commit 8e5c203

File tree

2 files changed

+69
-0
lines changed

2 files changed

+69
-0
lines changed

hpccm/primitives/copy.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ class copy(object):
6666
6767
src: A file, or a list of files, to copy
6868
69+
exclude_from: String or list of strings. One or more filenames
70+
containing rsync-style exclude patterns (e.g., `.apptainerignore`).
71+
Only used when building for Singularity or Apptainer. If specified,
72+
the copy operation is emitted in the `%setup` section using
73+
`rsync --exclude-from=<file>` rather than the standard `%files`
74+
copy directive. This enables selective exclusion of files and
75+
directories during the image build, for example to omit large data
76+
files, caches, or temporary artifacts. Multiple exclusion files may
77+
be provided as a list or tuple. The default is an empty list
78+
(Singularity specific).
79+
6980
# Examples
7081
7182
```python
@@ -80,6 +91,10 @@ class copy(object):
8091
copy(files={'a': '/tmp/a', 'b': '/opt/b'})
8192
```
8293
94+
```python
95+
copy(src='.', dest='/opt/app', exclude_from='.apptainerignore')
96+
```
97+
8398
"""
8499

85100
def __init__(self, **kwargs):
@@ -96,6 +111,14 @@ def __init__(self, **kwargs):
96111
self._post = kwargs.get('_post', '') # Singularity specific
97112
self.__src = kwargs.get('src', '')
98113

114+
ef = kwargs.get('exclude_from', None)
115+
if ef is None:
116+
self.__exclude_from = []
117+
elif isinstance(ef, (list, tuple)):
118+
self.__exclude_from = list(ef)
119+
else:
120+
self.__exclude_from = [ef]
121+
99122
if self._mkdir and self._post:
100123
logging.error('_mkdir and _post are mutually exclusive!')
101124
self._post = False # prefer _mkdir
@@ -179,6 +202,10 @@ def __str__(self):
179202
else:
180203
logging.warning(msg)
181204

205+
# If exclusion list is defined, switch to rsync copy method
206+
if self.__exclude_from:
207+
logging.info('copy: using rsync with exclude-from %s', self.__exclude_from)
208+
182209
# Format:
183210
# %files
184211
# src1 dest
@@ -211,6 +238,13 @@ def __str__(self):
211238
dest = pair['dest']
212239
src = pair['src']
213240

241+
# Use rsync if exclusion file provided and not multi-stage copy
242+
if self.__exclude_from and not self.__from:
243+
excl_opts = ' '.join('--exclude-from={}'.format(x) for x in self.__exclude_from)
244+
pre.append(' mkdir -p ${{SINGULARITY_ROOTFS}}{0}'.format(dest))
245+
pre.append(' rsync -av {0} {1}/ ${{SINGULARITY_ROOTFS}}{2}/'.format(excl_opts, src, dest))
246+
continue
247+
214248
if self._post:
215249
dest = '/'
216250

test/test_copy.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,38 @@ def test_from_temp_staging(self):
255255
"""Singularity files from previous stage in tmp"""
256256
c = copy(_from='base', src='foo', dest='/var/tmp/foo')
257257
self.assertEqual(str(c), '%files from base\n foo /var/tmp/foo')
258+
259+
@singularity
260+
def test_exclude_from_single_singularity(self):
261+
"""rsync-based copy with exclude_from (single source)"""
262+
c = copy(src='.', dest='/opt/app', exclude_from='.apptainerignore')
263+
recipe = str(c)
264+
self.assertIn('%setup', recipe)
265+
self.assertIn('rsync -av', recipe)
266+
self.assertIn('--exclude-from=.apptainerignore', recipe)
267+
# Allow trailing %files section but ensure rsync setup comes first
268+
self.assertTrue(recipe.strip().startswith('%setup'),
269+
"Expected rsync setup section to appear first")
270+
271+
@singularity
272+
def test_exclude_from_multiple_singularity(self):
273+
"""rsync-based copy with multiple exclude_from files"""
274+
c = copy(src='data', dest='/opt/data',
275+
exclude_from=['.ignore1', '.ignore2'])
276+
recipe = str(c)
277+
self.assertIn('%setup', recipe)
278+
self.assertIn('rsync -av', recipe)
279+
self.assertIn('--exclude-from=.ignore1', recipe)
280+
self.assertIn('--exclude-from=.ignore2', recipe)
281+
# Ensure setup section appears before %files
282+
self.assertTrue(recipe.strip().startswith('%setup'),
283+
"Expected rsync setup section to appear first")
284+
285+
@docker
286+
def test_exclude_from_docker_ignored(self):
287+
"""exclude_from ignored in Docker context"""
288+
c = copy(src='.', dest='/opt/app', exclude_from='.apptainerignore')
289+
recipe = str(c)
290+
self.assertIn('COPY', recipe)
291+
self.assertNotIn('rsync', recipe)
292+
self.assertNotIn('%setup', recipe)

0 commit comments

Comments
 (0)