File size: 4,036 Bytes
749bffa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d92710f
 
 
 
 
749bffa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
"""Canonical filesystem locations for the CellTriage project.

WHAT: Resolves the project root once, at import time, and exposes every
directory the pipeline writes to as a module-level constant.

WHY: Results must be reproducible from a clean checkout on any machine. A
single hardcoded absolute path anywhere in the codebase silently breaks that
guarantee, and the failure mode is a run that *appears* to succeed while
writing artifacts somewhere nobody looks. Resolving the root by walking up
for a marker file means the pipeline behaves identically whether invoked from
the repository root, from ``notebooks/``, or by pytest.
"""

from __future__ import annotations

from pathlib import Path

# Files/directories that together identify the repository root unambiguously.
# No single marker is enough -- any one of these could plausibly exist in an
# unrelated ancestor directory -- so all three are required. They are also
# exactly the three that ship to the deployed Space, which resolves its root
# the same way.
_ROOT_MARKERS: tuple[str, ...] = ("configs", "src", "README.md")


def find_project_root(start: Path | None = None) -> Path:
    """Walk upward from ``start`` until a directory containing all root markers is found.

    WHY a search rather than ``Path(__file__).parents[2]``: the relative depth
    of this file is an implementation detail. If ``src/utils/`` is ever
    reorganised, an index-based lookup breaks silently and points at the wrong
    directory; a marker search fails loudly instead.

    Args:
        start: Directory to begin the search from. Defaults to this file's directory.

    Returns:
        Absolute path to the project root.

    Raises:
        RuntimeError: If no ancestor directory contains all root markers.
    """
    current = (start or Path(__file__).resolve().parent).resolve()
    for candidate in (current, *current.parents):
        if all((candidate / marker).exists() for marker in _ROOT_MARKERS):
            return candidate
    raise RuntimeError(
        f"Could not locate the CellTriage project root above {current}. "
        f"Expected an ancestor directory containing all of: {', '.join(_ROOT_MARKERS)}."
    )


PROJECT_ROOT: Path = find_project_root()

CONFIG_DIR: Path = PROJECT_ROOT / "configs"
DOCS_DIR: Path = PROJECT_ROOT / "docs"
NOTEBOOK_DIR: Path = PROJECT_ROOT / "notebooks"
APP_DIR: Path = PROJECT_ROOT / "app"
TEST_DIR: Path = PROJECT_ROOT / "tests"

DATA_DIR: Path = PROJECT_ROOT / "data"
RAW_DIR: Path = DATA_DIR / "raw"
INTERIM_DIR: Path = DATA_DIR / "interim"
PROCESSED_DIR: Path = DATA_DIR / "processed"
CELLS_DIR: Path = INTERIM_DIR / "cells"

OUTPUT_DIR: Path = PROJECT_ROOT / "outputs"
FIGURE_DIR: Path = OUTPUT_DIR / "figures"
MODEL_DIR: Path = OUTPUT_DIR / "models"
REPORT_DIR: Path = OUTPUT_DIR / "reports"
LOG_DIR: Path = OUTPUT_DIR / "logs"

#: Directories the pipeline is permitted to create on demand. ``data/raw`` is
#: deliberately absent: raw data is downloaded by an explicit Phase 2 step, and
#: auto-creating it would let a missing-download bug masquerade as an empty
#: dataset.
MANAGED_DIRS: tuple[Path, ...] = (
    INTERIM_DIR,
    PROCESSED_DIR,
    CELLS_DIR,
    FIGURE_DIR,
    MODEL_DIR,
    REPORT_DIR,
    LOG_DIR,
)


def ensure_dirs(dirs: tuple[Path, ...] = MANAGED_DIRS) -> None:
    """Create the managed output directories if they do not already exist.

    WHY: Logging and artifact writing must never fail on a fresh clone merely
    because an output directory is absent (empty directories are not tracked
    by git).
    """
    for directory in dirs:
        directory.mkdir(parents=True, exist_ok=True)


def relative_to_root(path: Path) -> Path:
    """Express ``path`` relative to the project root for stable, portable logging.

    WHY: Absolute paths in logs and reports leak machine-specific detail and
    make artifacts from two machines gratuitously diff-noisy.
    """
    path = Path(path).resolve()
    try:
        return path.relative_to(PROJECT_ROOT)
    except ValueError:
        return path