Coverage for src/tinycta/hyper/_setup.py: 100%
49 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-15 05:23 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-15 05:23 +0000
1"""Experiment setup helpers: logger configuration."""
3import os
4from pathlib import Path
5from typing import Any, NamedTuple
7import yaml
8from loguru import logger
10_FILE_SINKS: dict[str, int] = {}
13class ExperimentConfig(NamedTuple):
14 """Resources bundled for a notebook experiment run.
16 Example:
17 >>> from tinycta.hyper import ExperimentConfig
18 >>> cfg = ExperimentConfig(name="momentum", logger=None, params={"fast": 16, "slow": 64})
19 >>> cfg.name
20 'momentum'
21 >>> cfg.params
22 {'fast': 16, 'slow': 64}
24 The three config sections are optional and default to ``None``, so a
25 config file that omits one is not an error:
27 >>> cfg.optuna is None and cfg.data is None
28 True
30 Being a :class:`~typing.NamedTuple`, it also unpacks positionally:
32 >>> name, _logger, params, _optuna, _data = cfg
33 >>> name, sorted(params)
34 ('momentum', ['fast', 'slow'])
35 """
37 name: str
38 logger: Any
39 params: dict[str, Any] | None = None
40 optuna: dict[str, Any] | None = None
41 data: dict[str, Any] | None = None
44def _load_yaml(path: Path) -> dict[str, Any]:
45 """Load a YAML file, returning an empty dict if the file does not exist."""
46 if not path.exists():
47 return {}
48 with open(path) as f:
49 return yaml.safe_load(f) or {}
52def _resolve_base(config_path: Path) -> Path:
53 """Return the notebooks directory that paths are resolved relative to.
55 This is the grandparent of ``config_path`` when it lives inside a ``config/``
56 subdirectory, otherwise its direct parent.
57 """
58 return config_path.parent.parent if config_path.parent.name == "config" else config_path.parent
61def _merge_sections(
62 cfg: dict[str, Any], sibling: dict[str, Any]
63) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
64 """Merge the shared ``config.yml`` with the experiment-specific sibling.
66 Each of the ``data``, ``params`` and ``optuna`` sections is taken from
67 ``cfg`` when present and truthy, else from ``sibling``, else an empty dict.
69 Returns:
70 tuple: ``(data, params, optuna)`` section dicts.
71 """
73 def pick(section: str) -> dict[str, Any]:
74 """Return ``section`` from ``cfg`` if truthy, else ``sibling``, else an empty dict."""
75 return cfg.get(section) or sibling.get(section) or {}
77 return pick("data"), pick("params"), pick("optuna")
80def _output_dir(base: Path, data: dict[str, Any], name: str) -> Path:
81 """Resolve the experiment output directory and create it.
83 ``NOTEBOOK_OUTPUT_FOLDER`` is a deliberate operator override and is used
84 verbatim. Otherwise the directory is ``base / output_path / name`` and is
85 confined under ``base`` so an untrusted ``output_path`` (e.g. ``../../etc``
86 or an absolute path) cannot escape the notebooks directory.
88 Raises:
89 ValueError: When the config-derived path escapes ``base``.
90 """
91 env_folder = os.environ.get("NOTEBOOK_OUTPUT_FOLDER")
92 if env_folder:
93 output_dir = Path(env_folder)
94 else:
95 folder = data.get("output_path", "output")
96 base_resolved = base.resolve()
97 output_dir = (base_resolved / folder / name).resolve()
98 if not output_dir.is_relative_to(base_resolved):
99 msg = f"output_path {folder!r} escapes the notebooks directory {base_resolved}"
100 raise ValueError(msg)
101 output_dir.mkdir(parents=True, exist_ok=True)
102 return output_dir
105def _ensure_sink(log_path: Path) -> None:
106 """Register a loguru file sink for ``log_path`` once, keyed by its resolved path."""
107 key = str(log_path.resolve())
108 if key not in _FILE_SINKS:
109 _FILE_SINKS[key] = logger.add(log_path)
112def get_config(name: str, config_path: Path | str | None = None) -> ExperimentConfig:
113 """Return logger and config sections for an experiment.
115 Accepts either a shared ``config.yml`` or an experiment-specific
116 ``config/{name}.yml``. Paths in the config are resolved relative to the
117 notebooks directory (one level above any ``config/`` subdirectory).
118 ``NOTEBOOK_OUTPUT_FOLDER`` env var overrides the output directory used for
119 the log file sink; otherwise the config-derived output directory is confined
120 under the notebooks directory.
122 Example:
123 >>> import tempfile
124 >>> from pathlib import Path
125 >>> import yaml
126 >>> from tinycta.hyper import get_config
128 A shared ``config.yml`` supplies the sections directly:
130 >>> with tempfile.TemporaryDirectory() as tmp:
131 ... config_path = Path(tmp) / "config.yml"
132 ... _ = config_path.write_text(
133 ... yaml.safe_dump({"params": {"fast": 16, "slow": 64}, "data": {"output_path": "out"}})
134 ... )
135 ... cfg = get_config("momentum", config_path=config_path)
136 ... output_exists = (Path(tmp) / "out" / "momentum").is_dir()
137 >>> cfg.name
138 'momentum'
139 >>> cfg.params
140 {'fast': 16, 'slow': 64}
142 The output directory is created as a side effect, ready for the run's
143 artefacts and its ``output.log`` sink:
145 >>> output_exists
146 True
148 An ``output_path`` that would escape the notebooks directory is rejected
149 rather than followed:
151 >>> with tempfile.TemporaryDirectory() as tmp:
152 ... config_path = Path(tmp) / "config.yml"
153 ... _ = config_path.write_text(yaml.safe_dump({"data": {"output_path": "../../etc"}}))
154 ... try:
155 ... get_config("momentum", config_path=config_path)
156 ... except ValueError:
157 ... print("escaping output_path rejected")
158 escaping output_path rejected
159 """
160 config_path = Path(config_path) if config_path else Path.cwd() / "config.yml"
161 cfg = _load_yaml(config_path)
162 base = _resolve_base(config_path)
163 sibling = _load_yaml(base / "config" / f"{name}.yml")
165 data, params, optuna_cfg = _merge_sections(cfg, sibling)
167 output_dir = _output_dir(base, data, name)
168 _ensure_sink(output_dir / "output.log")
169 logger.info(f"Writing output to: {output_dir}\nCurrent working directory: {os.getcwd()}")
171 return ExperimentConfig(
172 name=name,
173 logger=logger,
174 params=params,
175 optuna=optuna_cfg,
176 data=data,
177 )