|
1 | 1 | """This is the main module.""" |
2 | 2 |
|
| 3 | +import configparser |
3 | 4 | import os |
4 | 5 | import os.path as op |
5 | 6 | import subprocess |
@@ -127,6 +128,7 @@ def __init__(self, project_root): |
127 | 128 | self.job_status_path_rel = 'code/job_status.csv' |
128 | 129 | self.job_status_path_abs = op.join(self.analysis_path, self.job_status_path_rel) |
129 | 130 | self.job_submit_path_abs = op.join(self.analysis_path, 'code/job_submit.csv') |
| 131 | + self._shared_group_enabled_cache = None |
130 | 132 | self._apply_config() |
131 | 133 |
|
132 | 134 | def _apply_config(self) -> None: |
@@ -177,6 +179,7 @@ def _apply_config(self) -> None: |
177 | 179 |
|
178 | 180 | self.input_datasets = InputDatasets(self.processing_level, config_yaml['input_datasets']) |
179 | 181 | self.input_datasets.update_abs_paths(Path(self.project_root) / 'analysis') |
| 182 | + self.ensure_shared_group_git_safe_directories() |
180 | 183 |
|
181 | 184 | def _validate_pipeline_config(self) -> None: |
182 | 185 | """Validate the pipeline configuration if present. |
@@ -323,6 +326,118 @@ def wtf_key_info(self, flag_output_ria_only=False) -> None: |
323 | 326 | proc_analysis_dataset_id.stdout.decode('utf-8').strip().lstrip("'").rstrip("'") |
324 | 327 | ) |
325 | 328 |
|
| 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 | + |
326 | 441 | @property |
327 | 442 | def analysis_datalad_handle(self) -> dlapi.Dataset: |
328 | 443 | """Cached property of `analysis_datalad_handle`.""" |
@@ -444,12 +559,24 @@ def get_latest_submitted_jobs_df(self): |
444 | 559 | 1 sub-0002 1 2 |
445 | 560 |
|
446 | 561 | """ |
| 562 | + expected_columns = get_latest_submitted_jobs_columns(self.processing_level) |
447 | 563 | 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 |
451 | 577 | df[column_name] = df[column_name].astype(status_dtypes[column_name]) |
452 | | - return df |
| 578 | + |
| 579 | + return df[expected_columns] |
453 | 580 |
|
454 | 581 | def get_currently_running_jobs_df(self): |
455 | 582 | """ |
|
0 commit comments