From 02f6cb116c6a04d2a973ea7a083f14af915b8858 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 8 Nov 2026 02:01:28 +0100 Subject: [PATCH 01/14] Test an optional output_dir for the standalone output files With [Output] output_dir the profile, the summary, the profile plot, the mantle P-T plot (named with id_mass) and the CLI log go into that folder and nothing into /output or the working directory; without it a library call keeps /output. The key takes an existing folder; other types, an empty value and a missing folder raise ValueError. --- tests/test_output_dir.py | 236 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 tests/test_output_dir.py diff --git a/tests/test_output_dir.py b/tests/test_output_dir.py new file mode 100644 index 00000000..f1d4f1f8 --- /dev/null +++ b/tests/test_output_dir.py @@ -0,0 +1,236 @@ +"""The optional ``[Output] output_dir``: the one folder of the standalone output files. + +``post_processing``, the two plot writers and the CLI log write into ``output_dir`` when it +is given; without it a library call keeps ``/output`` and the CLI sets that +folder itself. +""" + +from __future__ import annotations + +import logging +import os +import runpy +from pathlib import Path +from unittest.mock import patch + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from tests.test_config_validators_extra import _make_minimal_config +from tests.test_output import _base_config, fake_solver, fake_solver_results # noqa: F401 +from zalmoxis import output as output_mod +from zalmoxis.config import validate_config +from zalmoxis.plots import plot_phase_vs_radius, plot_profiles + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +@pytest.fixture +def fake_root(monkeypatch, tmp_path): + """A ZALMOXIS_ROOT with an empty output/ for the writers that fall back to it.""" + root = tmp_path / 'root' + (root / 'output').mkdir(parents=True) + for mod in (output_mod, plot_profiles, plot_phase_vs_radius): + monkeypatch.setattr(mod, 'get_zalmoxis_root', lambda: str(root)) + return root + + +def _plotting_config(**overrides): + """A config that writes data, plots and the mantle P-T plot (a melting-curve mantle).""" + cfg = _base_config(plotting_enabled=True) + cfg['layer_eos_config'] = {'core': 'Seager2007:iron', 'mantle': 'WolfBower2018:MgSiO3'} + cfg.update(overrides) + return cfg + + +@pytest.mark.usefixtures('fake_solver') +def test_post_processing_writes_every_file_into_output_dir(fake_root, tmp_path, monkeypatch): + """Profile, summary, profile plot and P-T plot go to output_dir; root/output and cwd stay empty.""" + run, cwd = tmp_path / 'run', tmp_path / 'cwd' + run.mkdir() + cwd.mkdir() + monkeypatch.chdir(cwd) + output_mod.post_processing(_plotting_config(output_dir=str(run)), id_mass='1.0') + assert sorted(p.name for p in run.iterdir()) == [ + 'calculated_planet_mass_radius.txt', + 'mantle_PT_profile1.0.pdf', + 'planet_profile1.0.png', + 'planet_profile1.0.txt', + ] + assert list((fake_root / 'output').iterdir()) == [] + assert list(cwd.iterdir()) == [] + + +@pytest.mark.usefixtures('fake_solver') +def test_post_processing_without_output_dir_keeps_the_root_output(fake_root): + """A library call without the key writes the same files into /output.""" + output_mod.post_processing(_plotting_config()) + assert sorted(p.name for p in (fake_root / 'output').iterdir()) == [ + 'calculated_planet_mass_radius.txt', + 'mantle_PT_profile.pdf', + 'planet_profile.png', + 'planet_profile.txt', + ] + assert 'output_dir' not in _plotting_config() + + +@pytest.mark.usefixtures('fake_solver') +def test_summary_appends_in_output_dir(fake_root, tmp_path): + """Two calls append 2 rows under one header in /calculated_planet_mass_radius.txt.""" + run = tmp_path / 'run' + run.mkdir() + for _ in range(2): + output_mod.post_processing(_base_config(output_dir=run)) + lines = (run / 'calculated_planet_mass_radius.txt').read_text().splitlines() + assert lines[0] == 'Calculated Mass (kg)\tCalculated Radius (m)' + assert len(lines) == 3 + assert not (fake_root / 'output' / 'calculated_planet_mass_radius.txt').exists() + + +@pytest.mark.parametrize( + 'id_mass, name', [(None, 'mantle_PT_profile.pdf'), ('7', 'mantle_PT_profile7.pdf')] +) +def test_mantle_pt_plot_takes_the_id_mass_and_the_folder(fake_root, tmp_path, id_mass, name): + """The P-T plot is named like the profiles (id_mass suffix) and written into the given folder.""" + r = np.linspace(3.5e6, 6.4e6, 20) + phases = ['solid_mantle'] * 10 + ['melted_mantle'] * 10 + plot_phase_vs_radius.plot_PT_with_phases( + np.linspace(1.3e11, 1e5, 20), + np.linspace(4000, 1500, 20), + r, + phases, + 3.5e6, + id_mass=id_mass, + output_dir=tmp_path, + ) + assert (tmp_path / name).is_file() + assert list((fake_root / 'output').iterdir()) == [] + + +class TestOutputDirValidation: + """The optional output_dir: a folder that exists, or absent.""" + + @pytest.mark.parametrize( + 'value', [123, True, ['x'], b'/tmp'], ids=['int', 'bool', 'list', 'bytes'] + ) + def test_wrong_type_is_refused_by_name(self, value): + """A value that is not a path string raises ValueError naming the key and the value.""" + with pytest.raises(ValueError, match='output_dir') as err: + validate_config(_make_minimal_config(output_dir=value)) + assert repr(value) in str(err.value) + + def test_empty_values_are_refused_by_name(self): + """'' and Path('') (which is '.') are refused as empty; a blank name is no folder.""" + for value in ('', Path('')): + with pytest.raises(ValueError, match='output_dir must not be empty'): + validate_config(_make_minimal_config(output_dir=value)) + with pytest.raises(ValueError, match='is not an existing folder'): + validate_config(_make_minimal_config(output_dir=' ')) + + def test_missing_folder_is_refused_and_not_created(self, tmp_path): + """A path that is not an existing folder raises ValueError naming it; nothing is created.""" + missing = tmp_path / 'not_there' + with pytest.raises(ValueError, match='output_dir.*not_there'): + validate_config(_make_minimal_config(output_dir=str(missing))) + assert not missing.exists() + + def test_existing_folder_and_absent_key_pass(self, tmp_path): + """An existing folder (str or Path), '.' and a config without the key validate.""" + for value in (str(tmp_path), tmp_path, '.'): + assert validate_config(_make_minimal_config(output_dir=value)) is None + assert validate_config(_make_minimal_config()) is None + + +@settings(max_examples=60, deadline=None) +@given( + st.one_of( + st.none(), + st.booleans(), + st.integers(), + st.floats(allow_nan=True, allow_infinity=True), + st.lists(st.text(max_size=3), max_size=2), + st.dictionaries(st.text(max_size=2), st.integers(), max_size=1), + st.binary(max_size=4), + ) +) +def test_any_non_path_output_dir_is_refused(value): + """None, bool, int, float (0, negative, NaN, inf) and containers raise ValueError.""" + with pytest.raises(ValueError, match='output_dir must be a folder path'): + validate_config(_make_minimal_config(output_dir=value)) + + +@settings( + max_examples=60, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture] +) +@given(st.text(alphabet='abcXYZ019_- .', min_size=1, max_size=12)) +def test_any_output_dir_that_is_not_a_folder_is_refused(tmp_path, name): + """A name under a missing folder, and a regular file, raise ValueError; nothing is created.""" + with pytest.raises(ValueError, match='is not an existing folder'): + validate_config(_make_minimal_config(output_dir=os.path.join(tmp_path, 'absent', name))) + afile = tmp_path / 'a_file.txt' + afile.write_text('x') + with pytest.raises(ValueError, match='is not an existing folder'): + validate_config(_make_minimal_config(output_dir=str(afile))) + assert sorted(p.name for p in tmp_path.iterdir()) == ['a_file.txt'] + + +def test_output_dir_set_in_the_toml_reaches_the_config(tmp_path): + """A folder named in [Output] is in the returned dict; without the line the key is absent.""" + from zalmoxis import get_zalmoxis_root + from zalmoxis.config import load_zalmoxis_config + + text = open(os.path.join(get_zalmoxis_root(), 'input', 'default.toml')).read() + assert text.count('[Output]\n') == 1 + run = tmp_path / 'run' + run.mkdir() + for line, name in ((f"output_dir = '{run}'", 'with.toml'), ('', 'without.toml')): + (tmp_path / name).write_text(text.replace('[Output]\n', f'[Output]\n{line}\n')) + assert load_zalmoxis_config(str(tmp_path / 'with.toml'))['output_dir'] == str(run) + assert 'output_dir' not in load_zalmoxis_config(str(tmp_path / 'without.toml')) + + +@pytest.fixture +def reset_logging(): + """Restore the root logger handlers and level after a runpy CLI call.""" + logger = logging.getLogger() + saved, level = list(logger.handlers), logger.level + yield + for h in list(logger.handlers): + logger.removeHandler(h) + h.close() + for h in saved: + logger.addHandler(h) + logger.setLevel(level) + + +def _run_cli(root, config): + """Run ``python -m zalmoxis`` with a loader that logs one line and returns ``config``.""" + + def load(): + logging.getLogger('zalmoxis.config').info('loading the test config') + return config + + with patch('zalmoxis.get_zalmoxis_root', return_value=str(root)): + with patch('zalmoxis.config.load_zalmoxis_config', side_effect=load): + with patch('zalmoxis.output.post_processing') as post: + runpy.run_module('zalmoxis', run_name='__main__') + return post + + +@pytest.mark.parametrize('given_dir', [True, False], ids=['output_dir-given', 'root-default']) +def test_cli_log_and_post_processing_follow_the_output_folder( + tmp_path, reset_logging, given_dir +): + """The CLI log, with the lines of the config load, goes to output_dir or to /output.""" + root, run = tmp_path / 'root', tmp_path / 'run' + (root / 'output').mkdir(parents=True) + run.mkdir() + config = {'output_dir': str(run)} if given_dir else {} + post = _run_cli(root, config) + folder = run if given_dir else root / 'output' + assert post.call_args.args[0]['output_dir'] == str(folder) + assert 'loading the test config' in (folder / 'zalmoxis.log').read_text() + other = root / 'output' if given_dir else run + assert list(other.iterdir()) == [] From 1d66d816d50350c33e1b635794af6cda9798ce44 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 8 Nov 2026 02:21:17 +0100 Subject: [PATCH 02/14] Write the standalone output files into an optional [Output] output_dir post_processing, the profile plot, the mantle P-T plot and the log of python -m zalmoxis write into output_dir when the config names it, an existing folder (the validator refuses other types, an empty value and a missing folder). Without the key a library call keeps /output and the CLI sets that folder. The CLI holds the log lines of the config load in memory until the folder is known. The mantle P-T plot carries id_mass in its file name, as the profiles do. --- AGENTS.md | 2 +- docs/How-to/configuration.md | 3 ++- docs/How-to/usage.md | 8 ++++--- input/default.toml | 1 + src/zalmoxis/__main__.py | 19 ++++++++++----- src/zalmoxis/config.py | 15 ++++++++++++ src/zalmoxis/output.py | 17 +++++++------- src/zalmoxis/plots/plot_phase_vs_radius.py | 11 +++++++-- src/zalmoxis/plots/plot_profiles.py | 9 +++++--- tests/test_main_cli.py | 27 +--------------------- tests/test_output.py | 11 ++++++--- 11 files changed, 69 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9cd6ac0e..1818fdfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ The `[project.scripts]` entry in `pyproject.toml` registers `zalmoxis` as `zalmoxis.solver:main`. This bypasses `load_zalmoxis_config()` and is useful mainly for in-process callers; humans should prefer `python -m zalmoxis`. -Outputs (under `output/`): +Outputs (under `output/`, or the existing folder `[Output] output_dir` names): - `planet_profile.txt`: six-column TSV with r, ρ, M(r), P(r), g(r), T(r). - `calculated_planet_mass_radius.txt`: appended one-line summary per run. diff --git a/docs/How-to/configuration.md b/docs/How-to/configuration.md index 85e52746..7d8f2fdd 100644 --- a/docs/How-to/configuration.md +++ b/docs/How-to/configuration.md @@ -482,7 +482,8 @@ Controls what the model writes after a run. | Parameter | Type | Default | Description | |---|---|---|---| -| `data_enabled` | bool | true | Write the computed radial profiles (radius, density, gravity, pressure, temperature, enclosed mass) to a text file in `output/`. | +| `data_enabled` | bool | true | Write the computed radial profiles (radius, density, gravity, pressure, temperature, enclosed mass) to a text file in the output folder. | | `plots_enabled` | bool | false | Generate profile plots after the run. | +| `output_dir` | string | absent | Output folder: an existing folder for the profiles, the mass-radius summary, the plots and the log of `python -m zalmoxis`. Absent: `output/` of the Zalmoxis root, created on the first run. An empty string or a folder that does not exist raises `ValueError`. | Convergence diagnostics are controlled by the Python logging level. Set `logging.getLogger('fwl.zalmoxis').setLevel(logging.DEBUG)` to see per-iteration convergence info and write iteration pressure/density profiles. diff --git a/docs/How-to/usage.md b/docs/How-to/usage.md index d27e0261..8e5ab6d4 100644 --- a/docs/How-to/usage.md +++ b/docs/How-to/usage.md @@ -14,7 +14,7 @@ Run the default configuration (1 Earth-mass planet with a PALEOS iron core and M python -m zalmoxis -c input/default.toml ``` -Output files appear in `output/`. The PALEOS data files must be downloaded first (see [installation](installation.md)). +Output files appear in `output/`, or in the folder `[Output] output_dir` names. The PALEOS data files must be downloaded first (see [installation](installation.md)). ## What happens when you run Zalmoxis @@ -50,7 +50,7 @@ For the full list of parameters (EOS selection, temperature modes, solver settin ## Output files -All output files are written to the `output/` directory (created automatically on first run). The directory structure looks like: +All output files are written to the output folder: the existing folder that `[Output] output_dir` names, or else `output/` of the Zalmoxis root (created on the first run). The folder looks like: ``` output/ @@ -58,6 +58,8 @@ output/ ├── planet_profile5.0.txt # radial profiles (batch run, mass in filename) ├── calculated_planet_mass_radius.txt # summary: mass and radius per run ├── planet_profile.png # 6-panel structure plot (if plots_enabled) +├── mantle_PT_profile.pdf # mantle P-T plot (if plots_enabled and the mantle EOS uses melting curves) +├── zalmoxis.log # log of python -m zalmoxis ├── grid_results/ # grid runner output (if using run_grid) │ ├── grid_summary.csv │ └── *.json @@ -115,7 +117,7 @@ When `plots_enabled = true` in the `[Output]` section, Zalmoxis automatically ge plots_enabled = true ``` -This produces a six-panel figure showing the radial profiles of density, pressure, temperature, gravity, phase state, and mass enclosed: +With a batch run the plot files carry the mass in their names, as the profiles do (`planet_profile5.0.png`, `mantle_PT_profile5.0.pdf`). This produces a six-panel figure showing the radial profiles of density, pressure, temperature, gravity, phase state, and mass enclosed: ![Example profile plot](../img/example_profile_plot.png) diff --git a/input/default.toml b/input/default.toml index a6cf9f28..706be0d1 100644 --- a/input/default.toml +++ b/input/default.toml @@ -147,3 +147,4 @@ target_surface_pressure = 101325 # Target surface pressure (Pa); default 1 a [Output] data_enabled = true # Write radial profiles to output/ plots_enabled = false # Generate profile plots after the run +# output_dir = '/path/to/folder' # Output folder (an existing folder); default output/ of the Zalmoxis root diff --git a/src/zalmoxis/__main__.py b/src/zalmoxis/__main__.py index 67809646..e7172d8d 100644 --- a/src/zalmoxis/__main__.py +++ b/src/zalmoxis/__main__.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import logging.handlers import os from . import get_zalmoxis_root @@ -10,11 +11,17 @@ if __name__ == '__main__': root = get_zalmoxis_root() - logging.basicConfig( - filename=os.path.join(root, 'output', 'zalmoxis.log'), - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - filemode='w', - ) + # The log goes to the output folder of the config, so the lines of the config load wait in memory. + log = logging.getLogger() + early = logging.handlers.MemoryHandler(capacity=10_000, flushLevel=logging.CRITICAL + 1) + log.addHandler(early) + log.setLevel(logging.INFO) config_params = load_zalmoxis_config() + output_dir = config_params.setdefault('output_dir', os.path.join(root, 'output')) + log_file = logging.FileHandler(os.path.join(output_dir, 'zalmoxis.log'), mode='w') + log_file.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) + early.setTarget(log_file) + early.close() + log.removeHandler(early) + log.addHandler(log_file) post_processing(config_params) diff --git a/src/zalmoxis/config.py b/src/zalmoxis/config.py index 2c6f2401..5059faa1 100644 --- a/src/zalmoxis/config.py +++ b/src/zalmoxis/config.py @@ -712,6 +712,19 @@ def validate_config(config_params): f'fractions may converge but are outside the tested range.' ) + if 'output_dir' in config_params: + _check_folder('output_dir', config_params['output_dir']) + + +def _check_folder(key, folder): + """Raise ValueError unless ``folder`` names an existing folder (a str or os.PathLike).""" + if not isinstance(folder, str | os.PathLike): + raise ValueError(f'{key} must be a folder path, got {folder!r}.') + if not os.fspath(folder) or (not isinstance(folder, str) and os.fspath(folder) == '.'): + raise ValueError(f'{key} must not be empty; leave it out for the default folder.') + if not os.path.isdir(folder): + raise ValueError(f'{key} {os.fspath(folder)!r} is not an existing folder.') + def choose_config_file(temp_config_path=None): """ @@ -835,6 +848,8 @@ def load_zalmoxis_config(temp_config_path=None): 'data_output_enabled': config['Output'].get('data_enabled', True), 'plotting_enabled': config['Output'].get('plots_enabled', False), } + if 'output_dir' in config['Output']: + config_params['output_dir'] = config['Output']['output_dir'] # Numerical solver params are optional in the TOML. If present, include # them so they override the mass-adaptive defaults in solver._solve(). diff --git a/src/zalmoxis/output.py b/src/zalmoxis/output.py index 90981d7b..5d8c2241 100644 --- a/src/zalmoxis/output.py +++ b/src/zalmoxis/output.py @@ -43,6 +43,9 @@ def post_processing(config_params, id_mass=None, output_file=None, model_results data_output_enabled = config_params['data_output_enabled'] plotting_enabled = config_params['plotting_enabled'] + output_dir = config_params.get('output_dir') + if output_dir is None: + output_dir = os.path.join(get_zalmoxis_root(), 'output') if model_results is None: from .config import load_material_dictionaries @@ -160,12 +163,7 @@ def post_processing(config_params, id_mass=None, output_file=None, model_results 'Radius (m)\tDensity (kg/m^3)\tGravity (m/s^2)\t' 'Pressure (Pa)\tTemperature (K)\tMass Enclosed (kg)' ) - # Ensure the output directory exists. Local dev workstations - # typically have it from prior runs, but a fresh CI runner does - # not, and np.savetxt's writability probe fails there with - # FileNotFoundError. Creating it here keeps the writers - # independent of caller setup. - output_dir = os.path.join(get_zalmoxis_root(), 'output') + # A fresh checkout has no /output; np.savetxt needs the folder. os.makedirs(output_dir, exist_ok=True) if id_mass is None: np.savetxt( @@ -180,9 +178,7 @@ def post_processing(config_params, id_mass=None, output_file=None, model_results header=header, ) if output_file is None: - output_file = os.path.join( - get_zalmoxis_root(), 'output', 'calculated_planet_mass_radius.txt' - ) + output_file = os.path.join(output_dir, 'calculated_planet_mass_radius.txt') if not os.path.exists(output_file): header = 'Calculated Mass (kg)\tCalculated Radius (m)' with open(output_file, 'w') as file: @@ -206,6 +202,7 @@ def post_processing(config_params, id_mass=None, output_file=None, model_results mass_enclosed, id_mass, layer_eos_config=layer_eos_config, + output_dir=output_dir, ) if uses_phase_detection: @@ -215,4 +212,6 @@ def post_processing(config_params, id_mass=None, output_file=None, model_results mantle_radii, mantle_phases, radii[cmb_index], + id_mass=id_mass, + output_dir=output_dir, ) diff --git a/src/zalmoxis/plots/plot_phase_vs_radius.py b/src/zalmoxis/plots/plot_phase_vs_radius.py index e5d7b8c7..2dc87e83 100644 --- a/src/zalmoxis/plots/plot_phase_vs_radius.py +++ b/src/zalmoxis/plots/plot_phase_vs_radius.py @@ -9,7 +9,9 @@ from zalmoxis import get_zalmoxis_root -def plot_PT_with_phases(pressure, temperature, radii, mantle_phases, cmb_radius): +def plot_PT_with_phases( + pressure, temperature, radii, mantle_phases, cmb_radius, id_mass=None, output_dir=None +): """ Plot pressure–temperature profile highlighting solid/mixed/melted mantle regions. Parameters: @@ -18,6 +20,8 @@ def plot_PT_with_phases(pressure, temperature, radii, mantle_phases, cmb_radius) radii (array-like): Radial positions corresponding to the pressure and temperature profiles in meters. mantle_phases (array-like): List of mantle phases ("solid_mantle", "mixed_mantle", "melted_mantle") corresponding to each radial position. cmb_radius (float or None): Radius of the core-mantle boundary in meters. If None, CMB line is not plotted. + id_mass (str or None): Planet mass identifier, appended to the file name as for the profiles. + output_dir (str, os.PathLike or None): Folder of the PDF; None writes into /output. """ # Convert pressure to GPa and temperature to kK for readability @@ -48,5 +52,8 @@ def plot_PT_with_phases(pressure, temperature, radii, mantle_phases, cmb_radius) ax.legend() ax.grid(alpha=0.3) plt.tight_layout() - plt.savefig(os.path.join(get_zalmoxis_root(), 'output', 'mantle_PT_profile.pdf')) + if output_dir is None: + output_dir = os.path.join(get_zalmoxis_root(), 'output') + name = 'mantle_PT_profile.pdf' if id_mass is None else f'mantle_PT_profile{id_mass}.pdf' + plt.savefig(os.path.join(output_dir, name)) # plt.show() diff --git a/src/zalmoxis/plots/plot_profiles.py b/src/zalmoxis/plots/plot_profiles.py index 5e442e19..0cce9e3d 100644 --- a/src/zalmoxis/plots/plot_profiles.py +++ b/src/zalmoxis/plots/plot_profiles.py @@ -176,6 +176,7 @@ def plot_planet_profile_single( mass_enclosed, id_mass, layer_eos_config=None, + output_dir=None, ): """Generate a 6-panel interior structure plot. @@ -207,6 +208,8 @@ def plot_planet_profile_single( layer_eos_config : dict or None Per-layer EOS config for phase lookup. If None, phase panel shows "unknown" throughout. + output_dir : str, os.PathLike or None + Folder of the PNG; None writes into ``/output``. """ radii_re = radii / earth_radius cmb_r = cmb_radius / earth_radius @@ -317,7 +320,7 @@ def add_cmb(ax): fig.tight_layout(rect=[0, 0, 1, 0.96]) fname = 'planet_profile.png' if id_mass is None else f'planet_profile{id_mass}.png' - fig.savefig( - os.path.join(get_zalmoxis_root(), 'output', fname), dpi=200, bbox_inches='tight' - ) + if output_dir is None: + output_dir = os.path.join(get_zalmoxis_root(), 'output') + fig.savefig(os.path.join(output_dir, fname), dpi=200, bbox_inches='tight') plt.close(fig) diff --git a/tests/test_main_cli.py b/tests/test_main_cli.py index 917ad0fa..40398b0d 100644 --- a/tests/test_main_cli.py +++ b/tests/test_main_cli.py @@ -10,8 +10,7 @@ several seconds and pull in EOS data), both downstream calls are mocked. Tests verify the CLI plumbing only: -* ``get_zalmoxis_root()`` is consulted for the log path -* logging.basicConfig is configured with the expected file path +* the log file path is tested in ``tests/test_output_dir.py`` * ``load_zalmoxis_config`` is invoked with no arguments * ``post_processing`` is invoked with the loaded config """ @@ -66,30 +65,6 @@ def test_main_invokes_load_then_post_process(self, tmp_path, reset_logging): mock_load.assert_called_once_with() mock_post.assert_called_once_with(sentinel_config) - def test_main_configures_log_file_under_zalmoxis_root(self, tmp_path, reset_logging): - """The log file path must be ``{ZALMOXIS_ROOT}/output/zalmoxis.log``, - constructed via ``os.path.join`` with the resolved root. - - ``logging.basicConfig`` is a no-op once the root logger already - has handlers (which it does in a long pytest session), so the - cleanest assertion is that the CLI passes the expected - ``filename`` keyword to ``basicConfig`` rather than checking the - on-disk file.""" - import os - - with patch('zalmoxis.get_zalmoxis_root', return_value=str(tmp_path)): - with patch('zalmoxis.config.load_zalmoxis_config', return_value={}): - with patch('zalmoxis.output.post_processing'): - with patch('logging.basicConfig') as mock_basicconfig: - runpy.run_module('zalmoxis', run_name='__main__') - - mock_basicconfig.assert_called_once() - kwargs = mock_basicconfig.call_args.kwargs - expected_filename = os.path.join(str(tmp_path), 'output', 'zalmoxis.log') - assert kwargs.get('filename') == expected_filename - assert kwargs.get('level') == logging.INFO - assert kwargs.get('filemode') == 'w' - def test_main_does_not_swallow_load_config_errors(self, tmp_path, reset_logging): """If ``load_zalmoxis_config`` raises, the exception must propagate (no silent catch). Important for CI where a malformed diff --git a/tests/test_output.py b/tests/test_output.py index 6a8fb792..d220e71f 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -457,6 +457,7 @@ def _profile_mock( mass_enclosed, id_mass, layer_eos_config=None, + output_dir=None, ): profile_calls.append( { @@ -465,6 +466,7 @@ def _profile_mock( 'cmb_mass': float(cmb_mass), 'id_mass': id_mass, 'layer_eos_config': layer_eos_config, + 'output_dir': output_dir, } ) @@ -486,6 +488,7 @@ def _phase_mock(*a, **k): c = profile_calls[0] assert c['radii_size'] == 60 assert c['id_mass'] == 'earth' + assert c['output_dir'] == os.path.join(str(zalmoxis_tmp_root), 'output') assert c['cmb_mass'] == pytest.approx(1.97e24, rel=1e-12) # Negative branch: phase plot only fires when uses_phase_detection is True. assert phase_calls == [] @@ -510,8 +513,8 @@ def _fake_tdep(P, T, sol, liq): fake_profiles.plot_planet_profile_single = lambda *a, **k: None fake_phase = types.ModuleType('zalmoxis.plots.plot_phase_vs_radius') - def _phase_mock(P, T, R, phases, cmb_radius): - phase_calls.append({'n_pts': P.size, 'cmb_radius': float(cmb_radius)}) + def _phase_mock(P, T, R, phases, cmb_radius, id_mass=None, output_dir=None): + phase_calls.append({'n_pts': P.size, 'id_mass': id_mass, 'output_dir': output_dir}) fake_phase.plot_PT_with_phases = _phase_mock monkeypatch.setitem(sys.modules, 'zalmoxis.plots.plot_profiles', fake_profiles) @@ -524,10 +527,12 @@ def _phase_mock(P, T, R, phases, cmb_radius): 'mantle': 'PALEOS-2phase:MgSiO3', }, ) - output_mod.post_processing(cfg) + output_mod.post_processing(cfg, id_mass='2Me') assert len(phase_calls) == 1 assert phase_calls[0]['n_pts'] > 0 + assert phase_calls[0]['id_mass'] == '2Me' + assert phase_calls[0]['output_dir'] == os.path.join(str(zalmoxis_tmp_root), 'output') # --------------------------------------------------------------------------- From efe973dae80dc558e178a9f203b39a9e6d23e681 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 8 Nov 2026 02:22:17 +0100 Subject: [PATCH 03/14] Test that the CLI keeps the log lines of a refused config --- tests/test_output_dir.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_output_dir.py b/tests/test_output_dir.py index f1d4f1f8..079e24c5 100644 --- a/tests/test_output_dir.py +++ b/tests/test_output_dir.py @@ -234,3 +234,23 @@ def test_cli_log_and_post_processing_follow_the_output_folder( assert 'loading the test config' in (folder / 'zalmoxis.log').read_text() other = root / 'output' if given_dir else run assert list(other.iterdir()) == [] + + +def test_cli_keeps_the_log_lines_of_a_refused_config(tmp_path, reset_logging): + """A config load that logs and then raises: the error propagates, the lines reach /output.""" + root = tmp_path / 'root' + (root / 'output').mkdir(parents=True) + + def load(): + logging.getLogger('zalmoxis.config').info('reading the refused config') + raise ValueError('output_dir must not be empty') + + with patch('zalmoxis.get_zalmoxis_root', return_value=str(root)): + with patch('zalmoxis.config.load_zalmoxis_config', side_effect=load): + with patch('zalmoxis.output.post_processing') as post: + with pytest.raises(ValueError, match='must not be empty'): + runpy.run_module('zalmoxis', run_name='__main__') + post.assert_not_called() + log = root / 'output' / 'zalmoxis.log' + assert log.is_file() + assert 'reading the refused config' in log.read_text() From 94de85f5073fe2c08bb0912ea7b8e8619b26b3fd Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 8 Nov 2026 02:22:55 +0100 Subject: [PATCH 04/14] Test that the CLI creates the output folder of a fresh checkout for its log --- tests/test_output_dir.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_output_dir.py b/tests/test_output_dir.py index 079e24c5..7393d9b4 100644 --- a/tests/test_output_dir.py +++ b/tests/test_output_dir.py @@ -254,3 +254,15 @@ def load(): log = root / 'output' / 'zalmoxis.log' assert log.is_file() assert 'reading the refused config' in log.read_text() + + +def test_cli_creates_the_root_output_of_a_fresh_checkout(tmp_path, reset_logging): + """A root without output/: the CLI creates it for the log, and a refused config still raises.""" + root = tmp_path / 'root' + root.mkdir() + with patch('zalmoxis.get_zalmoxis_root', return_value=str(root)): + with patch('zalmoxis.config.load_zalmoxis_config', side_effect=ValueError('refused')): + with pytest.raises(ValueError, match='refused'): + runpy.run_module('zalmoxis', run_name='__main__') + assert (root / 'output' / 'zalmoxis.log').is_file() + assert sorted(p.name for p in root.iterdir()) == ['output'] From 2ade1b25a8149ac53386c64996d72881ce34a2ff Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 8 Nov 2026 02:22:55 +0100 Subject: [PATCH 05/14] Keep the CLI log lines of a refused config in /output The buffered log lines of the config load reach /zalmoxis.log also when the load raises; then the folder is /output, which the CLI creates on a fresh checkout so that the config error is not hidden. --- src/zalmoxis/__main__.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/zalmoxis/__main__.py b/src/zalmoxis/__main__.py index e7172d8d..22565c4d 100644 --- a/src/zalmoxis/__main__.py +++ b/src/zalmoxis/__main__.py @@ -16,12 +16,16 @@ early = logging.handlers.MemoryHandler(capacity=10_000, flushLevel=logging.CRITICAL + 1) log.addHandler(early) log.setLevel(logging.INFO) - config_params = load_zalmoxis_config() - output_dir = config_params.setdefault('output_dir', os.path.join(root, 'output')) - log_file = logging.FileHandler(os.path.join(output_dir, 'zalmoxis.log'), mode='w') - log_file.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) - early.setTarget(log_file) - early.close() - log.removeHandler(early) - log.addHandler(log_file) + config_params = {} + try: + config_params = load_zalmoxis_config() + finally: # a refused config logs into /output + output_dir = config_params.setdefault('output_dir', os.path.join(root, 'output')) + os.makedirs(output_dir, exist_ok=True) + log_file = logging.FileHandler(os.path.join(output_dir, 'zalmoxis.log'), mode='w') + log_file.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) + early.setTarget(log_file) + early.close() + log.removeHandler(early) + log.addHandler(log_file) post_processing(config_params) From 737a2efb836df0c4534474603675ad62171e7932 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 8 Nov 2026 02:32:20 +0100 Subject: [PATCH 06/14] Test the edges of output_dir and of the CLI log An unwritable folder is refused; post_processing checks a given folder it gets from a dict; plots alone create the default folder; a refused config keeps its error when the log cannot be opened and logs that error; the log is overwritten, formatted and at INFO; the real loader of a default.toml with output_dir sends the log there. Names at the edges replace the generated names. --- tests/test_output_dir.py | 152 +++++++++++++++++++++++++++++++++++---- 1 file changed, 139 insertions(+), 13 deletions(-) diff --git a/tests/test_output_dir.py b/tests/test_output_dir.py index 7393d9b4..b7ea95f8 100644 --- a/tests/test_output_dir.py +++ b/tests/test_output_dir.py @@ -9,13 +9,14 @@ import logging import os +import re import runpy from pathlib import Path from unittest.mock import patch import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import given, settings from hypothesis import strategies as st from tests.test_config_validators_extra import _make_minimal_config @@ -161,19 +162,70 @@ def test_any_non_path_output_dir_is_refused(value): validate_config(_make_minimal_config(output_dir=value)) -@settings( - max_examples=60, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture] +@pytest.mark.parametrize( + 'name, ok', + [ + ('a\0b', False), + ('trailing ', False), + ('~', False), + ('n' * 300, False), + ('link', True), + ('rel', True), + ], + ids=['NUL', 'trailing-space', 'tilde', 'long-name', 'symlink-to-folder', 'relative'], ) -@given(st.text(alphabet='abcXYZ019_- .', min_size=1, max_size=12)) -def test_any_output_dir_that_is_not_a_folder_is_refused(tmp_path, name): - """A name under a missing folder, and a regular file, raise ValueError; nothing is created.""" - with pytest.raises(ValueError, match='is not an existing folder'): - validate_config(_make_minimal_config(output_dir=os.path.join(tmp_path, 'absent', name))) - afile = tmp_path / 'a_file.txt' - afile.write_text('x') - with pytest.raises(ValueError, match='is not an existing folder'): - validate_config(_make_minimal_config(output_dir=str(afile))) - assert sorted(p.name for p in tmp_path.iterdir()) == ['a_file.txt'] +def test_output_dir_names_at_the_edges(tmp_path, monkeypatch, name, ok): + """NUL, a trailing space, '~' (not expanded) and a long name are no folder; a symlink and a relative path are.""" + (tmp_path / 'rel').mkdir() + (tmp_path / 'link').symlink_to(tmp_path / 'rel') + monkeypatch.chdir(tmp_path) + cfg = _make_minimal_config(output_dir=name) + if ok: + assert validate_config(cfg) is None + else: + with pytest.raises(ValueError, match='is not an existing folder'): + validate_config(cfg) + assert sorted(p.name for p in tmp_path.iterdir()) == ['link', 'rel'] + + +@pytest.mark.skipif(os.geteuid() == 0, reason='root writes into a read-only folder') +def test_output_dir_that_is_not_writable_is_refused(tmp_path): + """An existing folder without write permission raises ValueError naming it.""" + ro = tmp_path / 'ro' + ro.mkdir() + ro.chmod(0o500) + try: + with pytest.raises(ValueError, match="output_dir .*ro' is not writable"): + validate_config(_make_minimal_config(output_dir=str(ro))) + finally: + ro.chmod(0o700) + assert validate_config(_make_minimal_config(output_dir=str(ro))) is None + + +@pytest.mark.usefixtures('fake_solver') +@pytest.mark.parametrize( + 'value, match', [('', 'must not be empty'), ('missing/run', 'is not an existing folder')] +) +def test_post_processing_checks_a_given_output_dir( + fake_root, tmp_path, monkeypatch, value, match +): + """A library dict that skipped validate_config: '' and a missing folder raise ValueError, nothing is made.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match=match): + output_mod.post_processing(_base_config(output_dir=value)) + assert not (tmp_path / 'missing').exists() + assert list((fake_root / 'output').iterdir()) == [] + + +@pytest.mark.usefixtures('fake_solver') +def test_plots_alone_create_the_root_output_of_a_fresh_checkout(tmp_path, monkeypatch): + """data_enabled false, plots on, no /output: the folder is made and holds the PNG.""" + root = tmp_path / 'root' + root.mkdir() + for mod in (output_mod, plot_profiles, plot_phase_vs_radius): + monkeypatch.setattr(mod, 'get_zalmoxis_root', lambda: str(root)) + output_mod.post_processing(_base_config(data_output_enabled=False, plotting_enabled=True)) + assert sorted(p.name for p in (root / 'output').iterdir()) == ['planet_profile.png'] def test_output_dir_set_in_the_toml_reaches_the_config(tmp_path): @@ -266,3 +318,77 @@ def test_cli_creates_the_root_output_of_a_fresh_checkout(tmp_path, reset_logging runpy.run_module('zalmoxis', run_name='__main__') assert (root / 'output' / 'zalmoxis.log').is_file() assert sorted(p.name for p in root.iterdir()) == ['output'] + + +@pytest.mark.skipif(os.geteuid() == 0, reason='root writes into a read-only folder') +def test_cli_keeps_the_config_error_when_the_log_cannot_be_opened(tmp_path, reset_logging): + """A refused config with a read-only /output: the config error escapes, not the log error.""" + root = tmp_path / 'root' + (root / 'output').mkdir(parents=True) + (root / 'output').chmod(0o500) + try: + with patch('zalmoxis.get_zalmoxis_root', return_value=str(root)): + with patch( + 'zalmoxis.config.load_zalmoxis_config', side_effect=ValueError('refused') + ): + with pytest.raises(Exception) as err: + runpy.run_module('zalmoxis', run_name='__main__') + finally: + (root / 'output').chmod(0o700) + assert type(err.value) is ValueError + assert list((root / 'output').iterdir()) == [] + + +def test_cli_log_names_the_error_of_a_refused_config(tmp_path, reset_logging): + """The log of a refused config holds the error that stopped the run.""" + root = tmp_path / 'root' + (root / 'output').mkdir(parents=True) + with patch('zalmoxis.get_zalmoxis_root', return_value=str(root)): + with patch('zalmoxis.config.load_zalmoxis_config', side_effect=KeyError('planet_mass')): + with pytest.raises(KeyError): + runpy.run_module('zalmoxis', run_name='__main__') + text = (root / 'output' / 'zalmoxis.log').read_text() + assert ' - ERROR - ' in text + assert 'planet_mass' in text + + +def test_cli_log_is_overwritten_formatted_and_at_info(tmp_path, reset_logging): + """An old log is replaced; each line reads '