diff --git a/lab/components/bench/src/mas/lab/benchmark/cli/common.py b/lab/components/bench/src/mas/lab/benchmark/cli/common.py index f69c4887..05ddec1f 100644 --- a/lab/components/bench/src/mas/lab/benchmark/cli/common.py +++ b/lab/components/bench/src/mas/lab/benchmark/cli/common.py @@ -13,23 +13,25 @@ from typing import Optional from mas.lab.benchmark.cache import get_trace_cache_dir as _get_trace_cache_dir +from mas.library_roots import find_ancestor_with_file +from mas.runtime.constants import LAB_CONFIG_FILENAME def _resolve_run_manager_dir(explicit: Optional[Path]) -> Path: """Return the primary benchmark output root from CLI or active config.""" if explicit is not None: return explicit.resolve() - for _d in [Path.cwd().resolve(), *Path.cwd().resolve().parents]: - _cfg = _d / "lab-config.yaml" - if _cfg.exists(): - try: - from mas.runtime.spec.source import load_yaml_file - - _raw = load_yaml_file(_cfg) - _out = _raw.get("lab", {}).get("output_dir", "output") - return (_d / _out).resolve() - except Exception: - logger.debug('suppressed', exc_info=True) - break + _lab_dir = find_ancestor_with_file( + Path.cwd().resolve(), LAB_CONFIG_FILENAME, stop_at_suffix=".lab" + ) + if _lab_dir is not None: + try: + from mas.runtime.spec.source import load_yaml_file + + _raw = load_yaml_file(_lab_dir / LAB_CONFIG_FILENAME) + _out = _raw.get("lab", {}).get("output_dir", "output") + return (_lab_dir / _out).resolve() + except Exception: + logger.debug('suppressed', exc_info=True) from mas.lab import paths as _paths return _paths.labs_root() diff --git a/lab/components/bench/src/mas/lab/benchmark/pipeline/executor.py b/lab/components/bench/src/mas/lab/benchmark/pipeline/executor.py index 3cd8a6fd..bdf47693 100644 --- a/lab/components/bench/src/mas/lab/benchmark/pipeline/executor.py +++ b/lab/components/bench/src/mas/lab/benchmark/pipeline/executor.py @@ -32,6 +32,8 @@ ) from mas.lab.benchmark.pipeline.schema_validation import validate_payload from mas.lab import paths as _paths +from mas.library_roots import find_ancestor_with_file +from mas.runtime.constants import LAB_CONFIG_FILENAME logger = logging.getLogger(__name__) @@ -229,24 +231,24 @@ def _find_lab_meta( if config_path is None: return override_name, None - candidate = config_path.resolve().parent - for _ in range(6): # max 6 levels up - lab_yaml = candidate / "lab-config.yaml" - if lab_yaml.exists(): - name = override_name - if not name and _yaml is not None: - try: - with open(lab_yaml, encoding="utf-8") as fh: - data = _yaml.safe_load(fh) or {} - name = (data.get("lab") or {}).get("name", "") - except Exception as exc: - logger.debug("Could not read lab name from %s: %s", lab_yaml, exc) - if not name: - # Fallback: strip .lab suffix from directory name - name = candidate.name.removesuffix(".lab") - data_dir = candidate / "data" - return name, (data_dir if data_dir.is_dir() else None) - candidate = candidate.parent + candidate = find_ancestor_with_file( + config_path.resolve().parent, LAB_CONFIG_FILENAME, stop_at_suffix=".lab" + ) + if candidate is not None: + lab_yaml = candidate / LAB_CONFIG_FILENAME + name = override_name + if not name and _yaml is not None: + try: + with open(lab_yaml, encoding="utf-8") as fh: + data = _yaml.safe_load(fh) or {} + name = (data.get("lab") or {}).get("name", "") + except Exception as exc: + logger.debug("Could not read lab name from %s: %s", lab_yaml, exc) + if not name: + # Fallback: strip .lab suffix from directory name + name = candidate.name.removesuffix(".lab") + data_dir = candidate / "data" + return name, (data_dir if data_dir.is_dir() else None) # Not found — best-effort from directory name fallback_name = override_name or config_path.parent.parent.name.removesuffix(".lab") diff --git a/lab/components/bench/src/mas/lab/lab/config/lab_context.py b/lab/components/bench/src/mas/lab/lab/config/lab_context.py index 824f33f1..34fb8511 100644 --- a/lab/components/bench/src/mas/lab/lab/config/lab_context.py +++ b/lab/components/bench/src/mas/lab/lab/config/lab_context.py @@ -9,19 +9,26 @@ from pathlib import Path from typing import Any, List, Optional, Union +from mas.library_roots import find_ancestor_with_file +from mas.runtime.constants import LAB_CONFIG_FILENAME + logger = logging.getLogger(__name__) PluginSpec = Union[str, dict[str, Any]] +_LAB_DIR_SUFFIX = ".lab" + def _discover_lab_name(yaml_path: Path) -> Optional[str]: """Infer the lab name for an experiment YAML from its surrounding context.""" - lab_yaml = yaml_path.parent / "lab-config.yaml" - if lab_yaml.exists(): + lab_dir = find_ancestor_with_file( + yaml_path, LAB_CONFIG_FILENAME, stop_at_suffix=_LAB_DIR_SUFFIX + ) + if lab_dir is not None: try: from mas.runtime.spec.source import load_yaml_file - _data = load_yaml_file(lab_yaml) + _data = load_yaml_file(lab_dir / LAB_CONFIG_FILENAME) _lab_section = _data.get("lab", _data) if isinstance(_data, dict) else {} _name = _lab_section.get("name") if _name: @@ -29,13 +36,9 @@ def _discover_lab_name(yaml_path: Path) -> Optional[str]: except Exception: logger.debug('suppressed', exc_info=True) - parent = yaml_path.parent - if parent.name.endswith(".lab"): - return parent.name[:-4] - - for parent in yaml_path.parents: - if parent.name.endswith(".lab"): - return parent.name[:-4] + for parent in [yaml_path.parent, *yaml_path.parents]: + if parent.name.endswith(_LAB_DIR_SUFFIX): + return parent.name[: -len(_LAB_DIR_SUFFIX)] return None @@ -52,9 +55,21 @@ class LabContext: def discover_lab_context(yaml_path: Path) -> LabContext: - """Find sibling ``lab-config.yaml`` and plugin specs for *yaml_path*.""" - ctx = LabContext(lab_dir=yaml_path.parent) - lab_yaml = yaml_path.parent / "lab-config.yaml" + """Find the enclosing ``lab-config.yaml`` (walking up to the lab root, + same as :func:`_discover_lab_name`) and its plugin specs for *yaml_path*. + + Most labs nest experiment YAMLs several directories below the lab root + (e.g. ``.lab/experiments//experiment.yaml``), so checking only + the immediate parent directory -- as this used to do -- never finds the + lab's own ``lab-config.yaml``, and ``libraries:``/``plugins:`` entries + declared there are silently never applied. + """ + lab_dir = ( + find_ancestor_with_file(yaml_path, LAB_CONFIG_FILENAME, stop_at_suffix=_LAB_DIR_SUFFIX) + or yaml_path.parent + ) + ctx = LabContext(lab_dir=lab_dir) + lab_yaml = lab_dir / LAB_CONFIG_FILENAME if lab_yaml.is_file(): ctx.lab_yaml = lab_yaml try: diff --git a/lab/components/bench/tests/test_lab_context.py b/lab/components/bench/tests/test_lab_context.py new file mode 100644 index 00000000..e28533d6 --- /dev/null +++ b/lab/components/bench/tests/test_lab_context.py @@ -0,0 +1,130 @@ +# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates +# SPDX-License-Identifier: Apache-2.0 +"""Tests for mas.lab.lab.config.lab_context -- discover_lab_context() must find +a lab's own lab-config.yaml regardless of how many directories the experiment +YAML is nested below the lab root. It used to check only the experiment's own +directory, so libraries:/plugins: declarations in a root-level lab-config.yaml +were silently never applied for any experiment nested more than one level +deep (the common case: .lab/experiments//experiment.yaml). +""" + +from __future__ import annotations + +import sys + +from mas.lab.lab.config.lab_context import ( + _discover_lab_name, + discover_lab_context, + inject_lab_libraries, +) + + +def _write_lab_config(lab_dir, *, name="my-lab", libraries=None, plugins=None) -> None: + lines = ["lab:", f' name: "{name}"'] + if libraries: + lines.append(" libraries:") + lines += [f" - {lib}" for lib in libraries] + if plugins: + lines.append(" plugins:") + for p in plugins: + lines.append(f" - path: {p['path']}") + lines.append(f" module: {p['module']}") + (lab_dir / "lab-config.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def test_discover_lab_context_finds_lab_config_one_level_up(tmp_path) -> None: + lab_dir = tmp_path / "some.lab" + exp_dir = lab_dir / "experiments" / "01-smoke" + exp_dir.mkdir(parents=True) + _write_lab_config(lab_dir, name="some-lab") + + ctx = discover_lab_context(exp_dir / "experiment.yaml") + + assert ctx.lab_dir == lab_dir.resolve() + assert ctx.lab_yaml == lab_dir.resolve() / "lab-config.yaml" + assert ctx.lab_name == "some-lab" + + +def test_discover_lab_context_finds_lab_config_several_levels_up(tmp_path) -> None: + """The actual regression case: experiments///pipelines/ + is 4 levels below the lab root, not 1.""" + lab_dir = tmp_path / "sre-triage.lab" + exp_dir = lab_dir / "experiments" / "hackathon-experiments" / "centralized-moderator" + pipelines_dir = exp_dir / "pipelines" + pipelines_dir.mkdir(parents=True) + _write_lab_config(lab_dir, name="sre-triage", libraries=["lib/"]) + + ctx = discover_lab_context(exp_dir / "experiment.yaml") + + assert ctx.lab_dir == lab_dir.resolve() + assert ctx.lab_name == "sre-triage" + assert ctx.libraries == ["lib/"] + + +def test_discover_lab_context_does_not_cross_a_lab_boundary(tmp_path) -> None: + """An experiment inside inner.lab must never pick up outer.lab's config.""" + outer_lab = tmp_path / "outer.lab" + inner_lab = outer_lab / "nested" / "inner.lab" + exp_dir = inner_lab / "experiments" / "01-smoke" + exp_dir.mkdir(parents=True) + _write_lab_config(outer_lab, name="outer-lab") + # inner.lab deliberately has no lab-config.yaml of its own. + + ctx = discover_lab_context(exp_dir / "experiment.yaml") + + assert ctx.lab_yaml is None + assert ctx.lab_name == "inner" # falls back to the .lab dir name, not "outer-lab" + + +def test_discover_lab_context_falls_back_to_experiment_dir_when_no_lab_config( + tmp_path, +) -> None: + exp_dir = tmp_path / "experiments" / "01-smoke" + exp_dir.mkdir(parents=True) + + ctx = discover_lab_context(exp_dir / "experiment.yaml") + + assert ctx.lab_dir == exp_dir.resolve() + assert ctx.lab_yaml is None + + +def test_discover_lab_name_matches_discover_lab_context(tmp_path) -> None: + lab_dir = tmp_path / "cognitive-mas" / "sre-triage.lab" + exp_dir = lab_dir / "experiments" / "top1-smoke" + exp_dir.mkdir(parents=True) + _write_lab_config(lab_dir, name="sre-triage") + + assert _discover_lab_name(exp_dir / "experiment.yaml") == "sre-triage" + + +def test_inject_lab_libraries_puts_the_lab_root_on_sys_path_not_the_experiment_dir( + tmp_path, monkeypatch +) -> None: + lab_dir = tmp_path / "some.lab" + exp_dir = lab_dir / "experiments" / "deeply" / "nested" / "example" + exp_dir.mkdir(parents=True) + _write_lab_config(lab_dir) + + monkeypatch.setattr(sys, "path", list(sys.path)) + ctx = discover_lab_context(exp_dir / "experiment.yaml") + inject_lab_libraries(ctx) + + assert str(lab_dir.resolve()) in sys.path + assert str(exp_dir.resolve()) not in sys.path + + +def test_inject_lab_libraries_resolves_libraries_relative_to_the_lab_root( + tmp_path, monkeypatch +) -> None: + lab_dir = tmp_path / "some.lab" + exp_dir = lab_dir / "experiments" / "a" / "b" / "c" + exp_dir.mkdir(parents=True) + lib_dir = lab_dir / "lib" + lib_dir.mkdir() + _write_lab_config(lab_dir, libraries=["lib/"]) + + monkeypatch.setattr(sys, "path", list(sys.path)) + ctx = discover_lab_context(exp_dir / "experiment.yaml") + inject_lab_libraries(ctx) + + assert str(lib_dir.resolve()) in sys.path diff --git a/lab/components/controller/src/mas/lab/controller/lab_registry.py b/lab/components/controller/src/mas/lab/controller/lab_registry.py index 82ae0248..c2ab227c 100644 --- a/lab/components/controller/src/mas/lab/controller/lab_registry.py +++ b/lab/components/controller/src/mas/lab/controller/lab_registry.py @@ -16,14 +16,14 @@ from pathlib import Path from typing import Any, Dict, List, Optional -from mas.runtime.constants import WORKSPACE_CONFIG_FILENAME +from mas.runtime.constants import LAB_CONFIG_FILENAME, WORKSPACE_CONFIG_FILENAME from mas.runtime.spec.source import load_yaml_file logger = logging.getLogger(__name__) def _library_description(path: Path) -> str: - for candidate in (path / "lab-config.yaml", path / "mas.yaml", path / "README.md"): + for candidate in (path / LAB_CONFIG_FILENAME, path / "mas.yaml", path / "README.md"): if candidate.exists(): try: if candidate.suffix == ".yaml": diff --git a/lab/components/controller/src/mas/lab/controller/routes/libraries.py b/lab/components/controller/src/mas/lab/controller/routes/libraries.py index d8d63ca6..a80a0786 100644 --- a/lab/components/controller/src/mas/lab/controller/routes/libraries.py +++ b/lab/components/controller/src/mas/lab/controller/routes/libraries.py @@ -7,6 +7,7 @@ from fastapi import APIRouter from mas.lab.controller.routes._api import deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml +from mas.runtime.constants import LIBRARY_MANIFEST_FILENAME router = APIRouter() @@ -27,7 +28,7 @@ async def list_libraries(): for lib_dir in sorted(libraries_dir.iterdir()): if not lib_dir.is_dir() or not lib_dir.name.startswith("library-"): continue - lib_yaml = lib_dir / "library.yaml" + lib_yaml = lib_dir / LIBRARY_MANIFEST_FILENAME name = lib_dir.name description = "" if lib_yaml.exists(): diff --git a/lab/components/core/src/mas/lab/paths.py b/lab/components/core/src/mas/lab/paths.py index aa94c279..05d9f8c0 100644 --- a/lab/components/core/src/mas/lab/paths.py +++ b/lab/components/core/src/mas/lab/paths.py @@ -38,7 +38,7 @@ from pathlib import Path from typing import Literal -from mas.runtime.constants import WORKSPACE_CONFIG_FILENAME +from mas.runtime.constants import LAB_CONFIG_FILENAME, WORKSPACE_CONFIG_FILENAME from mas.runtime.workspace_config import ( RuntimeWorkspaceConfig, find_workspace_file, @@ -308,7 +308,7 @@ def source_tag( ) -> str: """Return a short human-readable string describing the path source.""" if lab_config: - return "lab-config.yaml" + return LAB_CONFIG_FILENAME if specific_env and os.environ.get(specific_env): return f"${specific_env}" return resolve_path(key).source diff --git a/lab/src/mas/lab/cli/commands/config.py b/lab/src/mas/lab/cli/commands/config.py index d705e8f3..70da1f87 100644 --- a/lab/src/mas/lab/cli/commands/config.py +++ b/lab/src/mas/lab/cli/commands/config.py @@ -10,6 +10,8 @@ import click from mas.lab import paths as _paths +from mas.library_roots import find_ancestor_with_file +from mas.runtime.constants import LAB_CONFIG_FILENAME def _lab_output_label(lab_output: Path, slug: str) -> str: @@ -25,11 +27,8 @@ def _lab_output_label(lab_output: Path, slug: str) -> str: def _find_lab_config(start: Path) -> Path | None: """Walk up from *start* looking for lab-config.yaml.""" - for directory in [start, *start.parents]: - candidate = directory / "lab-config.yaml" - if candidate.exists(): - return candidate - return None + lab_dir = find_ancestor_with_file(start, LAB_CONFIG_FILENAME, stop_at_suffix=".lab") + return (lab_dir / LAB_CONFIG_FILENAME) if lab_dir is not None else None def _resolve_lab_output() -> Path: @@ -72,7 +71,7 @@ def config_cmd(as_json: bool) -> None: lab_slug = _raw_slug.removesuffix(".lab") if lab_section.get("output_dir"): lab_output = (lab_config_path.parent / lab_section["output_dir"]).resolve() - lout_source_override = "lab-config.yaml" + lout_source_override = LAB_CONFIG_FILENAME except Exception: pass # fall back to global default @@ -120,7 +119,7 @@ def config_cmd(as_json: bool) -> None: "lab_output": { "path": str(lab_output), "label": _lab_output_label(lab_output, lab_slug or lab_output.parent.name), - "source": "lab-config.yaml" if lout_source_override == "lab-config.yaml" else "default", + "source": LAB_CONFIG_FILENAME if lout_source_override == LAB_CONFIG_FILENAME else "default", }, "lab_config": str(lab_config_path) if lab_config_path else None, "workspace_config": str(workspace_config_path) if workspace_config_path else None, @@ -158,7 +157,7 @@ def _tag(source: str) -> str: runs_source = summary["runs_dir"].source click.echo(f"\n {'runs root':<22} {summary['runs_dir'].path} {_tag(runs_source)}") - lout_source = "lab-config.yaml" if lout_source_override == "lab-config.yaml" else "default" + lout_source = LAB_CONFIG_FILENAME if lout_source_override == LAB_CONFIG_FILENAME else "default" _lout_label = _lab_output_label(lab_output, lab_slug or lab_output.parent.name) click.echo(f"\n {'lab output':<22} {click.style(_lout_label, bold=True)} {_tag(lout_source)}") click.echo(f" {' path':<22} {click.style(str(lab_output), dim=True)}") diff --git a/runtime/src/mas/library_catalog.py b/runtime/src/mas/library_catalog.py index c195c8d8..c5a092f6 100644 --- a/runtime/src/mas/library_catalog.py +++ b/runtime/src/mas/library_catalog.py @@ -9,13 +9,14 @@ from typing import Any from mas.library_roots import discover_library_roots +from mas.runtime.constants import LIBRARY_MANIFEST_FILENAME from mas.runtime.spec.source import load_yaml_file logger = logging.getLogger(__name__) def _load_library_manifest(root: Path) -> dict[str, Any]: - manifest_path = root / "library.yaml" + manifest_path = root / LIBRARY_MANIFEST_FILENAME if not manifest_path.is_file(): # A genuinely absent library.yaml is a valid, optional case. return {} @@ -301,7 +302,7 @@ def discover_plugin_manifests() -> list[Path]: manifest = _load_library_manifest(root) candidates: list[Path] = [] if _declares_plugins(manifest): - candidates.append((root / "library.yaml").resolve()) + candidates.append((root / LIBRARY_MANIFEST_FILENAME).resolve()) candidates.extend(_discover_plugin_manifests_from_catalog(root, manifest)) candidates.extend(_discover_plugin_manifests_from_scan(root)) for path in candidates: diff --git a/runtime/src/mas/library_roots.py b/runtime/src/mas/library_roots.py index 1c1fe615..742ee1de 100644 --- a/runtime/src/mas/library_roots.py +++ b/runtime/src/mas/library_roots.py @@ -37,16 +37,38 @@ import os from pathlib import Path +from mas.runtime.constants import LIBRARY_MANIFEST_FILENAME -def _find_library_root(start: Path) -> Path | None: - """Walk upward from *start* to find a directory with ``library.yaml``.""" + +def find_ancestor_with_file( + start: Path, filename: str, *, stop_at_suffix: str | None = None +) -> Path | None: + """Walk upward from *start* to find a directory containing *filename*. + + Checks *start* itself (or its parent, if *start* is a file) first, then + each ancestor in turn. If *stop_at_suffix* is given, the ancestor whose + name carries that suffix is still checked (so e.g. a ``.lab`` root is + itself eligible) but the walk does not continue past it -- this avoids + picking up an unrelated outer lab's or checkout's marker file. + + Shared by every "find the enclosing root" discovery in this codebase + (manifest libraries via ``library.yaml``, labs via ``lab-config.yaml``) + so the walk-and-stop semantics stay in exactly one place. + """ here = start if start.is_dir() else start.parent for parent in [here, *here.parents]: - if (parent / "library.yaml").is_file(): + if (parent / filename).is_file(): return parent.resolve() + if stop_at_suffix and parent.name.endswith(stop_at_suffix): + break return None +def _find_library_root(start: Path) -> Path | None: + """Walk upward from *start* to find a directory with ``library.yaml``.""" + return find_ancestor_with_file(start, LIBRARY_MANIFEST_FILENAME) + + def _root_from_spec(module: str) -> Path | None: """Resolve *module*'s on-disk root via :func:`importlib.util.find_spec`. @@ -191,11 +213,11 @@ def _known_library_paths() -> list[Path]: base = Path(entry).expanduser() if not base.is_dir(): continue - if (base / "library.yaml").is_file(): + if (base / LIBRARY_MANIFEST_FILENAME).is_file(): roots.append(base.resolve()) continue for child in sorted(base.iterdir()): - if child.is_dir() and (child / "library.yaml").is_file(): + if child.is_dir() and (child / LIBRARY_MANIFEST_FILENAME).is_file(): roots.append(child.resolve()) return roots @@ -234,10 +256,10 @@ def _add(path: Path) -> None: if not here.is_dir(): here = here.parent for parent in (here, *here.parents): - if (parent / "library.yaml").is_file(): + if (parent / LIBRARY_MANIFEST_FILENAME).is_file(): _add(parent) samples = parent / "library-samples" - if (samples / "library.yaml").is_file(): + if (samples / LIBRARY_MANIFEST_FILENAME).is_file(): _add(samples) if (parent / ".git").is_dir(): break diff --git a/runtime/src/mas/runtime/constants.py b/runtime/src/mas/runtime/constants.py index a6bcd68c..92f51593 100644 --- a/runtime/src/mas/runtime/constants.py +++ b/runtime/src/mas/runtime/constants.py @@ -13,5 +13,11 @@ CONNECTIONS_CONFIG_FILENAME = "connections.yaml" +# A manifest library's root marker (see mas.library_roots). +LIBRARY_MANIFEST_FILENAME = "library.yaml" + +# A lab's root marker (see mas.lab.lab.config.lab_context). +LAB_CONFIG_FILENAME = "lab-config.yaml" + # Kernel backend id (must match component-registry.yaml). DEFAULT_RUNTIME_ID = "mas-runtime-py" diff --git a/runtime/tests/test_library_roots.py b/runtime/tests/test_library_roots.py index 254f3e9c..02c07929 100644 --- a/runtime/tests/test_library_roots.py +++ b/runtime/tests/test_library_roots.py @@ -18,6 +18,7 @@ _root_from_import, _root_from_spec, discover_library_roots, + find_ancestor_with_file, resolve_manifest_library_package, ) @@ -37,6 +38,67 @@ def test_find_library_root_returns_none_when_absent(tmp_path) -> None: assert _find_library_root(nested) is None +def test_find_ancestor_with_file_checks_start_itself(tmp_path) -> None: + (tmp_path / "marker.txt").write_text("x", encoding="utf-8") + assert find_ancestor_with_file(tmp_path, "marker.txt") == tmp_path.resolve() + + +def test_find_ancestor_with_file_walks_multiple_levels_up(tmp_path) -> None: + root = tmp_path / "root" + nested = root / "a" / "b" / "c" / "d" + nested.mkdir(parents=True) + (root / "marker.txt").write_text("x", encoding="utf-8") + + assert find_ancestor_with_file(nested, "marker.txt") == root.resolve() + + +def test_find_ancestor_with_file_accepts_a_file_path_not_just_a_dir(tmp_path) -> None: + root = tmp_path / "root" + nested = root / "a" / "b" + nested.mkdir(parents=True) + (root / "marker.txt").write_text("x", encoding="utf-8") + some_file = nested / "experiment.yaml" + some_file.write_text("x", encoding="utf-8") + + assert find_ancestor_with_file(some_file, "marker.txt") == root.resolve() + + +def test_find_ancestor_with_file_returns_none_when_absent(tmp_path) -> None: + nested = tmp_path / "a" / "b" / "c" + nested.mkdir(parents=True) + assert find_ancestor_with_file(nested, "marker.txt") is None + + +def test_find_ancestor_with_file_stops_at_suffix_boundary(tmp_path) -> None: + """A marker file beyond the ``.lab``-suffixed ancestor must not be found -- + otherwise nesting one lab checkout inside another's tree would let a + deeply-nested experiment pick up the *outer* lab's config.""" + outer_lab = tmp_path / "outer.lab" + inner_lab = outer_lab / "nested" / "inner.lab" + experiment = inner_lab / "experiments" / "01-smoke" + experiment.mkdir(parents=True) + (outer_lab / "marker.txt").write_text("outer", encoding="utf-8") + + assert ( + find_ancestor_with_file(experiment, "marker.txt", stop_at_suffix=".lab") + is None + ) + + +def test_find_ancestor_with_file_finds_marker_at_the_suffix_boundary_itself( + tmp_path, +) -> None: + """The ``.lab`` directory itself must still be checked, not skipped.""" + lab_root = tmp_path / "some.lab" + experiment = lab_root / "experiments" / "01-smoke" + experiment.mkdir(parents=True) + (lab_root / "marker.txt").write_text("x", encoding="utf-8") + + assert find_ancestor_with_file(experiment, "marker.txt", stop_at_suffix=".lab") == ( + lab_root.resolve() + ) + + def test_root_from_spec_resolves_real_importable_module() -> None: # mas.library_roots itself is a real, importable module with a known file. found = _root_from_spec("mas.library_roots")