Skip to content

Commit 5faf144

Browse files
fix: resolve file paths relative to scenario file location
Paths in scenario.yaml (population_spec, agents_file, network_file) are now stored relative to the scenario file and resolved correctly when loading from any working directory. - Add resolve_relative_to() and make_relative_to() utilities - Fix scenario command to store relative paths when saving - Fix validate command to resolve paths relative to scenario file - Add tests for path utilities Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9dfff49 commit 5faf144

7 files changed

Lines changed: 211 additions & 9 deletions

File tree

entropy/cli/commands/scenario.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def scenario_command(
4949
"""
5050
from ...core.models import PopulationSpec
5151
from ...scenario import create_scenario
52+
from ...utils import make_relative_to
5253

5354
start_time = time.time()
5455
console.print()
@@ -238,6 +239,11 @@ def run_pipeline():
238239
console.print("[dim]Cancelled.[/dim]")
239240
raise typer.Exit(0)
240241

242+
# Convert paths to be relative to output file before saving
243+
result_spec.meta.population_spec = make_relative_to(population, output_path)
244+
result_spec.meta.agents_file = make_relative_to(agents, output_path)
245+
result_spec.meta.network_file = make_relative_to(network, output_path)
246+
241247
# Save to YAML
242248
result_spec.to_yaml(output_path)
243249

entropy/cli/commands/validate.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,23 +169,25 @@ def _validate_scenario_spec(spec_file: Path, out: Output) -> int:
169169

170170
# Show file references (human mode only)
171171
if not get_json_mode():
172+
from ...utils import resolve_relative_to
173+
172174
out.text("[bold]File References:[/bold]")
173175

174-
pop_path = Path(spec.meta.population_spec)
176+
pop_path = resolve_relative_to(spec.meta.population_spec, spec_file)
175177
if pop_path.exists():
176178
out.text(f" [green]✓[/green] Population: {spec.meta.population_spec}")
177179
else:
178180
out.text(
179181
f" [red]✗[/red] Population: {spec.meta.population_spec} (not found)"
180182
)
181183

182-
agents_path = Path(spec.meta.agents_file)
184+
agents_path = resolve_relative_to(spec.meta.agents_file, spec_file)
183185
if agents_path.exists():
184186
out.text(f" [green]✓[/green] Agents: {spec.meta.agents_file}")
185187
else:
186188
out.text(f" [red]✗[/red] Agents: {spec.meta.agents_file} (not found)")
187189

188-
network_path = Path(spec.meta.network_file)
190+
network_path = resolve_relative_to(spec.meta.network_file, spec_file)
189191
if network_path.exists():
190192
out.text(f" [green]✓[/green] Network: {spec.meta.network_file}")
191193
else:

entropy/scenario/validator.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -525,18 +525,20 @@ def load_and_validate_scenario(
525525
agent_count = None
526526
network = None
527527

528-
pop_path = Path(spec.meta.population_spec)
528+
from ..utils import resolve_relative_to
529+
530+
pop_path = resolve_relative_to(spec.meta.population_spec, scenario_path)
529531
if pop_path.exists():
530532
try:
531533
population_spec = PopulationSpec.from_yaml(pop_path)
532534
except Exception:
533535
pass # Will be caught as validation error
534536

535-
agents_path = Path(spec.meta.agents_file)
537+
agents_path = resolve_relative_to(spec.meta.agents_file, scenario_path)
536538
if agents_path.exists():
537539
agent_count = get_agent_count(agents_path)
538540

539-
network_path = Path(spec.meta.network_file)
541+
network_path = resolve_relative_to(spec.meta.network_file, scenario_path)
540542
if network_path.exists():
541543
try:
542544
with open(network_path) as f:

entropy/utils/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
ConditionError,
3737
SAFE_BUILTINS,
3838
)
39+
from .paths import (
40+
resolve_relative_to,
41+
make_relative_to,
42+
)
3943

4044
__all__ = [
4145
# Graphs
@@ -62,4 +66,7 @@
6266
"FormulaError",
6367
"ConditionError",
6468
"SAFE_BUILTINS",
69+
# Paths
70+
"resolve_relative_to",
71+
"make_relative_to",
6572
]

entropy/utils/paths.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Path resolution utilities for Entropy.
2+
3+
This module provides consistent path handling across all CLI commands,
4+
ensuring relative paths in YAML files are resolved correctly regardless
5+
of the current working directory.
6+
"""
7+
8+
from pathlib import Path
9+
10+
11+
def resolve_relative_to(path: str | Path, base_file: Path) -> Path:
12+
"""
13+
Resolve a path relative to a base file's directory.
14+
15+
If path is absolute, returns it unchanged.
16+
If path is relative, resolves it against base_file's parent directory.
17+
18+
Args:
19+
path: Path string or Path object to resolve
20+
base_file: The file that contains the path reference (e.g., scenario.yaml)
21+
22+
Returns:
23+
Resolved absolute Path
24+
25+
Example:
26+
>>> resolve_relative_to("population.yaml", Path("/project/study/scenario.yaml"))
27+
PosixPath('/project/study/population.yaml')
28+
29+
>>> resolve_relative_to("/abs/path/pop.yaml", Path("/project/scenario.yaml"))
30+
PosixPath('/abs/path/pop.yaml')
31+
"""
32+
path = Path(path)
33+
if path.is_absolute():
34+
return path
35+
return (base_file.parent / path).resolve()
36+
37+
38+
def make_relative_to(path: str | Path, base_file: Path) -> str:
39+
"""
40+
Convert a path to be relative to a base file's directory.
41+
42+
Used when storing file references in YAML files (e.g., scenario.yaml).
43+
If the path cannot be made relative (different drives on Windows, etc.),
44+
returns the absolute path as a string.
45+
46+
Args:
47+
path: Path to convert (can be relative to cwd or absolute)
48+
base_file: The file that will contain the reference (e.g., scenario.yaml)
49+
50+
Returns:
51+
Relative path string if possible, otherwise absolute path string
52+
53+
Example:
54+
>>> make_relative_to("/project/study/population.yaml", Path("/project/study/scenario.yaml"))
55+
'population.yaml'
56+
57+
>>> make_relative_to("studies/netflix/pop.yaml", Path("studies/netflix/scenario.yaml"))
58+
'pop.yaml'
59+
"""
60+
path = Path(path).resolve()
61+
base_dir = base_file.parent.resolve()
62+
63+
try:
64+
return str(path.relative_to(base_dir))
65+
except ValueError:
66+
# Path is not under base_dir, return absolute
67+
return str(path)

studies/netflix-password-sharing/scenario.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ meta:
22
name: netflix_announces_enforcement_of
33
description: 'Netflix announces enforcement of password-sharing rules: users must
44
pay .99/month extra per shared member outside household or stop sharing'
5-
population_spec: studies/netflix-password-sharing/population.yaml
6-
agents_file: studies/netflix-password-sharing/agents.json
7-
network_file: studies/netflix-password-sharing/network.json
5+
population_spec: population.yaml
6+
agents_file: agents.json
7+
network_file: network.json
88
created_at: '2026-02-02T15:05:10.271096'
99
event:
1010
type: announcement

tests/test_paths.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Tests for path resolution utilities."""
2+
3+
from pathlib import Path
4+
import tempfile
5+
import os
6+
7+
from entropy.utils import resolve_relative_to, make_relative_to
8+
9+
10+
class TestResolveRelativeTo:
11+
"""Tests for resolve_relative_to function."""
12+
13+
def test_relative_path_resolved(self):
14+
"""Relative paths should be resolved against base file's directory."""
15+
result = resolve_relative_to(
16+
"population.yaml", Path("/project/study/scenario.yaml")
17+
)
18+
assert result == Path("/project/study/population.yaml")
19+
20+
def test_absolute_path_unchanged(self):
21+
"""Absolute paths should be returned unchanged."""
22+
result = resolve_relative_to(
23+
"/abs/path/pop.yaml", Path("/project/scenario.yaml")
24+
)
25+
assert result == Path("/abs/path/pop.yaml")
26+
27+
def test_nested_relative_path(self):
28+
"""Nested relative paths should resolve correctly."""
29+
result = resolve_relative_to(
30+
"data/agents.json", Path("/project/study/scenario.yaml")
31+
)
32+
assert result == Path("/project/study/data/agents.json")
33+
34+
def test_parent_relative_path(self):
35+
"""Parent-relative paths (../) should resolve correctly."""
36+
result = resolve_relative_to(
37+
"../common/pop.yaml", Path("/project/study/scenario.yaml")
38+
)
39+
assert result == Path("/project/common/pop.yaml")
40+
41+
def test_path_object_input(self):
42+
"""Should accept Path objects as input."""
43+
result = resolve_relative_to(
44+
Path("population.yaml"), Path("/project/study/scenario.yaml")
45+
)
46+
assert result == Path("/project/study/population.yaml")
47+
48+
49+
class TestMakeRelativeTo:
50+
"""Tests for make_relative_to function."""
51+
52+
def test_sibling_file_relative(self):
53+
"""Files in same directory should return just filename."""
54+
result = make_relative_to(
55+
"/project/study/population.yaml", Path("/project/study/scenario.yaml")
56+
)
57+
assert result == "population.yaml"
58+
59+
def test_nested_file_relative(self):
60+
"""Files in subdirectory should return relative path."""
61+
result = make_relative_to(
62+
"/project/study/data/agents.json", Path("/project/study/scenario.yaml")
63+
)
64+
assert result == "data/agents.json"
65+
66+
def test_unrelated_path_absolute(self):
67+
"""Unrelated paths should return absolute path."""
68+
result = make_relative_to(
69+
"/other/path/pop.yaml", Path("/project/study/scenario.yaml")
70+
)
71+
assert result == "/other/path/pop.yaml"
72+
73+
def test_relative_input_resolved(self):
74+
"""Relative input paths should be resolved against cwd first."""
75+
# Use a temp directory to test this
76+
with tempfile.TemporaryDirectory() as tmpdir:
77+
# Create structure: tmpdir/study/scenario.yaml and tmpdir/study/pop.yaml
78+
study_dir = Path(tmpdir) / "study"
79+
study_dir.mkdir()
80+
scenario = study_dir / "scenario.yaml"
81+
pop = study_dir / "population.yaml"
82+
scenario.touch()
83+
pop.touch()
84+
85+
# Save original cwd
86+
orig_cwd = os.getcwd()
87+
try:
88+
os.chdir(tmpdir)
89+
# When cwd is tmpdir, "study/population.yaml" should become "population.yaml"
90+
# relative to "study/scenario.yaml"
91+
result = make_relative_to("study/population.yaml", Path("study/scenario.yaml"))
92+
assert result == "population.yaml"
93+
finally:
94+
os.chdir(orig_cwd)
95+
96+
97+
class TestRoundTrip:
98+
"""Test that make_relative_to and resolve_relative_to are inverses."""
99+
100+
def test_roundtrip_same_directory(self):
101+
"""Roundtrip for files in same directory."""
102+
base = Path("/project/study/scenario.yaml")
103+
original = "/project/study/population.yaml"
104+
105+
relative = make_relative_to(original, base)
106+
resolved = resolve_relative_to(relative, base)
107+
108+
assert str(resolved) == original
109+
110+
def test_roundtrip_nested(self):
111+
"""Roundtrip for nested files."""
112+
base = Path("/project/study/scenario.yaml")
113+
original = "/project/study/data/agents.json"
114+
115+
relative = make_relative_to(original, base)
116+
resolved = resolve_relative_to(relative, base)
117+
118+
assert str(resolved) == original

0 commit comments

Comments
 (0)