Skip to content

Commit 55e9dd0

Browse files
authored
Add babs init --shared-group <GROUP> (#368)
1 parent 7b7e33f commit 55e9dd0

9 files changed

Lines changed: 366 additions & 25 deletions

File tree

babs/base.py

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""This is the main module."""
22

3+
import configparser
34
import os
45
import os.path as op
56
import subprocess
@@ -127,6 +128,7 @@ def __init__(self, project_root):
127128
self.job_status_path_rel = 'code/job_status.csv'
128129
self.job_status_path_abs = op.join(self.analysis_path, self.job_status_path_rel)
129130
self.job_submit_path_abs = op.join(self.analysis_path, 'code/job_submit.csv')
131+
self._shared_group_enabled_cache = None
130132
self._apply_config()
131133

132134
def _apply_config(self) -> None:
@@ -177,6 +179,7 @@ def _apply_config(self) -> None:
177179

178180
self.input_datasets = InputDatasets(self.processing_level, config_yaml['input_datasets'])
179181
self.input_datasets.update_abs_paths(Path(self.project_root) / 'analysis')
182+
self.ensure_shared_group_git_safe_directories()
180183

181184
def _validate_pipeline_config(self) -> None:
182185
"""Validate the pipeline configuration if present.
@@ -323,6 +326,118 @@ def wtf_key_info(self, flag_output_ria_only=False) -> None:
323326
proc_analysis_dataset_id.stdout.decode('utf-8').strip().lstrip("'").rstrip("'")
324327
)
325328

329+
@staticmethod
330+
def source_to_local_path(source: str) -> str | None:
331+
"""Convert a local dataset source URL/path to a filesystem path."""
332+
if not source:
333+
return None
334+
if source.startswith('ria+file://'):
335+
local_path = source[len('ria+file://') :]
336+
elif source.startswith('file://'):
337+
local_path = source[len('file://') :]
338+
elif '://' not in source:
339+
local_path = source
340+
else:
341+
return None
342+
return local_path.split('#', 1)[0]
343+
344+
def analysis_git_config_path(self) -> str | None:
345+
"""Return absolute path to analysis git config file."""
346+
git_path = op.join(self.analysis_path, '.git')
347+
# Standard repo layout: `.git/` is a directory containing `config`.
348+
if op.isdir(git_path):
349+
config_path = op.join(git_path, 'config')
350+
return config_path if op.exists(config_path) else None
351+
352+
# Alternate layout (e.g., worktree/submodule): `.git` is a file with
353+
# a pointer like `gitdir: /actual/path/to/gitdir`.
354+
if op.isfile(git_path):
355+
with open(git_path) as f:
356+
first_line = f.readline().strip()
357+
if first_line.startswith('gitdir:'):
358+
git_dir = first_line.split(':', 1)[1].strip()
359+
# The gitdir pointer can be relative to `analysis_path`.
360+
if not op.isabs(git_dir):
361+
git_dir = op.normpath(op.join(self.analysis_path, git_dir))
362+
config_path = op.join(git_dir, 'config')
363+
return config_path if op.exists(config_path) else None
364+
# No recognizable git metadata/config path found.
365+
return None
366+
367+
def is_shared_group_project(self) -> bool:
368+
"""Return whether this project is configured for shared-group permissions."""
369+
# During `babs init`, shared mode is known from the CLI flag directly.
370+
if getattr(self, 'shared_group', None) is not None:
371+
return True
372+
# Reuse the computed value to avoid repeatedly reading git config.
373+
if self._shared_group_enabled_cache is not None:
374+
return self._shared_group_enabled_cache
375+
376+
# For existing projects, infer shared mode from git's core setting.
377+
config_path = self.analysis_git_config_path()
378+
if config_path is None:
379+
self._shared_group_enabled_cache = False
380+
return False
381+
382+
parser = configparser.ConfigParser()
383+
parser.read(config_path)
384+
# Git may encode shared mode as text ('group') or truthy values.
385+
shared_mode = parser.get('core', 'sharedrepository', fallback='').strip().lower()
386+
self._shared_group_enabled_cache = shared_mode in {'group', '1', 'true', 'yes'}
387+
return self._shared_group_enabled_cache
388+
389+
def ensure_shared_group_git_safe_directories(self) -> None:
390+
"""Register project repositories in git safe.directory for shared mode."""
391+
if not self.is_shared_group_project():
392+
return
393+
394+
safe_dirs = set()
395+
if op.exists(self.analysis_path):
396+
safe_dirs.add(op.realpath(self.analysis_path))
397+
398+
for ria_root in (self.input_ria_path, self.output_ria_path):
399+
ria_root_path = Path(ria_root)
400+
if not ria_root_path.exists():
401+
continue
402+
for repo_dir in ria_root_path.glob('*/*'):
403+
if repo_dir.is_dir():
404+
safe_dirs.add(str(repo_dir.resolve()))
405+
406+
alias_data = op.join(self.output_ria_path, 'alias', 'data')
407+
if op.exists(alias_data):
408+
safe_dirs.add(op.realpath(alias_data))
409+
410+
if hasattr(self, 'input_datasets'):
411+
for in_ds in self.input_datasets:
412+
local_path = self.source_to_local_path(getattr(in_ds, 'origin_url', ''))
413+
if local_path is not None:
414+
safe_dirs.add(op.realpath(local_path))
415+
416+
proc_existing = subprocess.run(
417+
['git', 'config', '--global', '--get-all', 'safe.directory'],
418+
capture_output=True,
419+
text=True,
420+
)
421+
existing = (
422+
{line.strip() for line in proc_existing.stdout.splitlines() if line.strip()}
423+
if proc_existing.returncode == 0
424+
else set()
425+
)
426+
427+
for repo_path in sorted(safe_dirs):
428+
if not op.exists(repo_path) or repo_path in existing:
429+
continue
430+
subprocess.run(
431+
['git', 'config', '--global', '--add', 'safe.directory', repo_path],
432+
capture_output=True,
433+
text=True,
434+
)
435+
existing.add(repo_path)
436+
437+
def ensure_shared_group_runtime_ready(self) -> None:
438+
"""Apply git-safe-directory safeguards for shared projects."""
439+
self.ensure_shared_group_git_safe_directories()
440+
326441
@property
327442
def analysis_datalad_handle(self) -> dlapi.Dataset:
328443
"""Cached property of `analysis_datalad_handle`."""
@@ -444,12 +559,24 @@ def get_latest_submitted_jobs_df(self):
444559
1 sub-0002 1 2
445560
446561
"""
562+
expected_columns = get_latest_submitted_jobs_columns(self.processing_level)
447563
if not op.exists(self.job_submit_path_abs):
448-
return EMPTY_JOB_STATUS_DF
449-
df = pd.read_csv(self.job_submit_path_abs)
450-
for column_name in get_latest_submitted_jobs_columns(self.processing_level):
564+
return pd.DataFrame(columns=expected_columns)
565+
566+
try:
567+
df = pd.read_csv(self.job_submit_path_abs)
568+
except pd.errors.EmptyDataError:
569+
return pd.DataFrame(columns=expected_columns)
570+
571+
# job_submit.csv is written once before submit (without job_id) and then
572+
# rewritten after submit (with job_id). Tolerate the interim schema so
573+
# follow-up commands do not crash if submit was interrupted.
574+
for column_name in expected_columns:
575+
if column_name not in df.columns:
576+
df[column_name] = pd.NA
451577
df[column_name] = df[column_name].astype(status_dtypes[column_name])
452-
return df
578+
579+
return df[expected_columns]
453580

454581
def get_currently_running_jobs_df(self):
455582
"""

babs/bootstrap.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def babs_bootstrap(
3737
container_config,
3838
initial_inclusion_df=None,
3939
throttle=None,
40+
shared_group=None,
4041
):
4142
"""
4243
Bootstrap a babs project: initialize datalad-tracked RIAs, generate scripts to be used, etc
@@ -62,6 +63,10 @@ def babs_bootstrap(
6263
simultaneously running array tasks. The value will be added to the array
6364
specification as `%<throttle>`. Example: `10` will result in
6465
`--array=1-${max_array}%10`.
66+
shared_group: str or None, optional
67+
Unix group name for shared write access. If provided, `analysis` is
68+
initialized with `git init --shared=group` and RIA siblings are created
69+
with `--shared group --group <GROUP>`.
6570
"""
6671
if op.exists(self.project_root):
6772
raise FileExistsError(
@@ -89,6 +94,7 @@ def babs_bootstrap(
8994

9095
# Store throttle value for job submission template
9196
self.throttle = throttle
97+
self.shared_group = shared_group
9298

9399
# validate `processing_level`:
94100
self.processing_level = validate_processing_level(processing_level)
@@ -111,9 +117,10 @@ def babs_bootstrap(
111117
# Create `analysis` folder: -----------------------------
112118
print('DataLad version: ' + get_datalad_version())
113119
print('\nCreating `analysis` folder (also a datalad dataset)...')
114-
self._analysis_datalad_handle = dlapi.create(
115-
self.analysis_path, cfg_proc='yoda', annex=True
116-
)
120+
create_kwargs = {'cfg_proc': 'yoda', 'annex': True}
121+
if self.shared_group is not None:
122+
create_kwargs['initopts'] = ['--shared=group']
123+
self._analysis_datalad_handle = dlapi.create(self.analysis_path, **create_kwargs)
117124
self.input_datasets.update_abs_paths(Path(self.analysis_path))
118125
self.input_datasets.set_inclusion_dataframe(initial_inclusion_df, processing_level)
119126

@@ -171,8 +178,14 @@ def babs_bootstrap(
171178
)
172179
# Create output RIA sibling: -----------------------------
173180
print('\nCreating output and input RIA...')
181+
sibling_kwargs = {}
182+
if self.shared_group is not None:
183+
sibling_kwargs = {'shared': 'group', 'group': self.shared_group}
174184
self.analysis_datalad_handle.create_sibling_ria(
175-
name='output', url=self.output_ria_url, new_store_ok=True
185+
name='output',
186+
url=self.output_ria_url,
187+
new_store_ok=True,
188+
**sibling_kwargs,
176189
)
177190

178191
self.wtf_key_info()
@@ -183,6 +196,7 @@ def babs_bootstrap(
183196
url=self.input_ria_url,
184197
storage_sibling=False, # False is `off` in CLI of datalad
185198
new_store_ok=True,
199+
**sibling_kwargs,
186200
)
187201

188202
# Register the input dataset(s): -----------------------------
@@ -383,6 +397,7 @@ def babs_bootstrap(
383397

384398
# Initialize the job status csv file:
385399
self._create_initial_job_status_csv()
400+
self.ensure_shared_group_git_safe_directories()
386401

387402
print('\n')
388403
print(
@@ -403,7 +418,13 @@ def _bootstrap_single_app_scripts(
403418
print('\nGenerating a bash script for running container and zipping the outputs...')
404419
print('This bash script will be named as `' + container_name + '_zip.sh`')
405420
bash_path = op.join(self.analysis_path, 'code', container_name + '_zip.sh')
406-
container.generate_bash_run_bidsapp(bash_path, self.input_datasets, self.processing_level)
421+
shared_group_mode = self.shared_group is not None
422+
container.generate_bash_run_bidsapp(
423+
bash_path,
424+
self.input_datasets,
425+
self.processing_level,
426+
shared_group_mode=shared_group_mode,
427+
)
407428
self.datalad_save(
408429
path='code/' + container_name + '_zip.sh',
409430
message='Generate script of running container',
@@ -422,11 +443,16 @@ def _bootstrap_single_app_scripts(
422443
self.processing_level,
423444
system,
424445
project_root=op.dirname(self.analysis_path),
446+
shared_group_mode=shared_group_mode,
425447
)
426448

427449
# also, generate a bash script of a test job used by `babs check-setup`:
428450
path_check_setup = op.join(self.analysis_path, 'code/check_setup')
429-
container.generate_bash_test_job(path_check_setup, system)
451+
container.generate_bash_test_job(
452+
path_check_setup,
453+
system,
454+
shared_group_mode=shared_group_mode,
455+
)
430456

431457
def _bootstrap_pipeline_scripts(self, container_ds, container_config, system):
432458
"""Bootstrap scripts for pipeline configuration."""
@@ -457,7 +483,7 @@ def _bootstrap_pipeline_scripts(self, container_ds, container_config, system):
457483

458484
with open(pipeline_script_path, 'w') as f:
459485
f.write(pipeline_script_content)
460-
os.chmod(pipeline_script_path, 0o700)
486+
os.chmod(pipeline_script_path, 0o770 if self.shared_group is not None else 0o700)
461487

462488
self.datalad_save(
463489
path='code/pipeline_zip.sh',
@@ -496,7 +522,7 @@ def _bootstrap_pipeline_scripts(self, container_ds, container_config, system):
496522

497523
with open(bash_path, 'w') as f:
498524
f.write(participant_job_content)
499-
os.chmod(bash_path, 0o700)
525+
os.chmod(bash_path, 0o770 if self.shared_group is not None else 0o700)
500526

501527
# also, generate bash scripts of test jobs used by `babs check-setup`:
502528
# Generate test jobs for all containers in the pipeline
@@ -511,7 +537,11 @@ def _bootstrap_pipeline_scripts(self, container_ds, container_config, system):
511537
# Create separate test job directories for each container
512538
step_check_setup = op.join(path_check_setup, f'step_{i + 1}_{step_container_name}')
513539
os.makedirs(step_check_setup, exist_ok=True)
514-
step_container.generate_bash_test_job(step_check_setup, system)
540+
step_container.generate_bash_test_job(
541+
step_check_setup,
542+
system,
543+
shared_group_mode=(self.shared_group is not None),
544+
)
515545

516546
def _init_import_files(self, file_list):
517547
"""

babs/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ def _parse_init():
126126
'The value will be added to the array specification as ``%%<throttle>``. '
127127
'Example: ``--throttle 10`` will result in ``--array=1-${max_array}%%10``.',
128128
)
129+
parser.add_argument(
130+
'--shared_group',
131+
'--shared-group',
132+
type=str,
133+
help='Unix group name for shared write access. If provided, '
134+
'`analysis` is initialized with ``git init --shared=group`` and '
135+
'RIA siblings are created with ``--shared group --group <GROUP>``.',
136+
)
129137

130138
return parser
131139

@@ -157,6 +165,7 @@ def babs_init_main(
157165
queue: str,
158166
keep_if_failed: bool,
159167
throttle: int | None = None,
168+
shared_group: str | None = None,
160169
):
161170
"""This is the core function of babs init.
162171
@@ -189,6 +198,10 @@ def babs_init_main(
189198
simultaneously running array tasks. The value will be added to the array
190199
specification as `%<throttle>`. Example: `10` will result in
191200
`--array=1-${max_array}%10`.
201+
shared_group: str or None, optional
202+
Unix group name for shared write access. If provided, `analysis` is
203+
initialized with `git init --shared=group` and RIA siblings are created
204+
with `--shared group --group <GROUP>`.
192205
"""
193206

194207
from babs import BABSBootstrap
@@ -203,6 +216,7 @@ def babs_init_main(
203216
container_config,
204217
list_sub_file,
205218
throttle=throttle,
219+
shared_group=shared_group,
206220
)
207221
except Exception as exc:
208222
print('\n`babs init` failed! Below is the error message:')

0 commit comments

Comments
 (0)