From 0fa2d73aef959ca02629fde52b821e1076cdd4aa Mon Sep 17 00:00:00 2001 From: Teon Brooks Date: Thu, 30 Jul 2026 17:06:35 +0000 Subject: [PATCH] Add optional permissions to step-config writing Thread an optional `permissions` argument through `write_configs` and `ConfigDump.check_and_write` to chmod the uid/full-uid/config yamls after writing (`Path.write_text` is otherwise capped by the umask). Backend passes 0o777 so the config yamls match the permissions applied to the cache data files. Adds unit tests. --- exca/steps/backends.py | 3 ++- exca/steps/identity.py | 8 +++++++- exca/test_utils.py | 31 +++++++++++++++++++++++++++++++ exca/utils.py | 18 +++++++++++++++++- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/exca/steps/backends.py b/exca/steps/backends.py index a308be6c..ad28b6b6 100644 --- a/exca/steps/backends.py +++ b/exca/steps/backends.py @@ -591,7 +591,8 @@ def _prepare(self, step: Step, batch: items.StepItems) -> ComputeBatch: upstream = tuple(batch._upstream) + tuple(step._uid_steps()) paths = step._make_paths(upstream) if paths.step_folder not in self._checked_configs: - identity.write_configs(paths.step_folder, upstream) + # match the 0o777 applied to the cache data files (see _cache_dict) + identity.write_configs(paths.step_folder, upstream, permissions=0o777) self._checked_configs.add(paths.step_folder) cd = self._cache_dict(paths.cache_folder, cache_type=paths.cache_type) mode = _fold_modes(batch._mode, _effective_mode(step)) diff --git a/exca/steps/identity.py b/exca/steps/identity.py index 38c9a934..ba5d8c9c 100644 --- a/exca/steps/identity.py +++ b/exca/steps/identity.py @@ -100,11 +100,17 @@ def write_configs( aligned_steps: tp.Sequence[Step], *, write: bool = True, + permissions: int | None = None, ) -> None: """Idempotent: writes/checks `uid.yaml`, `full-uid.yaml`, `config.yaml`. The config is the full computation path (aligned chain), so a chain and its last step write identical configs when sharing a folder. + + When *permissions* is set, the written config yamls are chmod-ed to it + (best-effort), matching the permissions applied to the cache data files. """ step_folder.mkdir(exist_ok=True, parents=True) - utils.ConfigDump(model=list(aligned_steps)).check_and_write(step_folder, write=write) + utils.ConfigDump(model=list(aligned_steps)).check_and_write( + step_folder, write=write, permissions=permissions + ) diff --git a/exca/test_utils.py b/exca/test_utils.py index 41b6bc62..1fe3492e 100644 --- a/exca/test_utils.py +++ b/exca/test_utils.py @@ -672,3 +672,34 @@ def boom(**_kwargs: tp.Any) -> tp.Any: ex = utils.make_pool_executor("processpool", max_workers=2) assert isinstance(ex, concurrent.futures.ThreadPoolExecutor) ex.shutdown() + + +class _PermModel(BaseModel): + param: int = 12 + + +def test_config_dump_permissions(tmp_path: Path) -> None: + import stat + + folder = tmp_path / "step" + folder.mkdir() + # write=True writes uid/full-uid/config yamls; permissions chmods them + utils.ConfigDump(model=_PermModel(param=7)).check_and_write( + folder, write=True, permissions=0o777 + ) + written = list(folder.glob("*.yaml")) + assert written # at least uid.yaml is written + for fp in written: + assert stat.S_IMODE(fp.stat().st_mode) == 0o777 + + +def test_config_dump_no_permissions_leaves_mode(tmp_path: Path) -> None: + import stat + + folder = tmp_path / "step" + folder.mkdir() + utils.ConfigDump(model=_PermModel(param=7)).check_and_write(folder, write=True) + written = list(folder.glob("*.yaml")) + assert written + # without a permissions arg, files are not force-chmod-ed to 0o777 + assert all(stat.S_IMODE(fp.stat().st_mode) != 0o777 for fp in written) diff --git a/exca/utils.py b/exca/utils.py index 6a02ab9a..37bc1e07 100644 --- a/exca/utils.py +++ b/exca/utils.py @@ -709,11 +709,17 @@ def _to_yaml(self, name: str) -> str: def _error(self, msg: str) -> RuntimeError: return RuntimeError(f"{msg}\n\n(this is for object: {self.model!r})") - def check_and_write(self, folder: Path, *, write: bool = True) -> None: + def check_and_write( + self, folder: Path, *, write: bool = True, permissions: int | None = None + ) -> None: """Check config consistency and optionally write files. Raises RuntimeError if uid.yaml doesn't match (cache collision) or defaults changed incompatibly. + + When *permissions* is set, the config yamls are chmod-ed to it + (best-effort) after writing, since ``Path.write_text`` alone is capped + by the umask (``0o666``). """ from .confdict import ConfDict # avoid circular import @@ -783,3 +789,13 @@ def read_file(name: str) -> str | None: continue with temporary_save_path(fp) as tmp: Path(tmp).write_text(self._to_yaml(name), encoding="utf8") + if permissions is not None: + for name in ("uid", "full-uid", "config"): + fp = folder / f"{name}.yaml" + if not fp.exists(): + continue + try: + fp.chmod(permissions) + except OSError as e: # best-effort: not fatal for a shared dir + msg = "Failed to set permissions %o on '%s': %s" + logger.warning(msg, permissions, fp, e)