Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/chemgraph/execution/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,20 @@ def resolve_structure_files(
ValueError
If no files are found or if listed files do not exist.
"""
# A bare relative filename (e.g. "water.cif") from a small model refers to
# a file a sibling tool wrote into CHEMGRAPH_LOG_DIR, not the cwd. Resolve
# each listed name against the log dir before checking existence so those
# inputs still resolve; absolute/cwd paths are returned unchanged. The
# directory branch is intentionally left untouched: writer tools emit
# files (not directories) into the log dir, so there is no sibling-written
# directory to fall back to.
from chemgraph.tools.ase_core import _resolve_existing_path

structure_files: list[Path] = []
output_dir: Path = Path.cwd()

if isinstance(input_source, list):
structure_files = [Path(p) for p in input_source]
structure_files = [Path(_resolve_existing_path(str(p))) for p in input_source]
missing = [p for p in structure_files if not p.exists()]
if missing:
raise ValueError(f"The following input files are missing: {missing}")
Expand Down
17 changes: 13 additions & 4 deletions src/chemgraph/mcp/ase_mcp_hpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,23 @@ def _embed_inline_if_local(job: dict) -> None:
if job.get("remote_structure_file") or job.get("inline_structure"):
return
input_file = job.get("input_structure_file")
if not input_file or not os.path.isfile(input_file):
return # remote path -- worker will read it directly
if not input_file:
return

from ase.io import read as ase_read

from chemgraph.tools.ase_core import atoms_to_atomsdata
from chemgraph.tools.ase_core import _resolve_existing_path, atoms_to_atomsdata

# A small model may echo back a bare name ("water.xyz") for a file a
# sibling tool wrote into CHEMGRAPH_LOG_DIR. Resolve it here, on the
# submitting host where the log dir lives, before deciding whether the
# input is local. Absolute/cwd paths are returned unchanged.
resolved = _resolve_existing_path(input_file)
if not os.path.isfile(resolved):
return # remote path -- worker will read it directly

atoms = ase_read(input_file)
job["input_structure_file"] = resolved
atoms = ase_read(resolved)
job["inline_structure"] = atoms_to_atomsdata(atoms).model_dump()


Expand Down
7 changes: 7 additions & 0 deletions src/chemgraph/mcp/data_analysis_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,19 @@ def aggregate_simulation_results(
str
Human-readable success or error message.
"""
from chemgraph.tools.ase_core import _resolve_existing_path

all_data = []

for file_path in file_paths:
if not file_path or not isinstance(file_path, str):
continue

# A small model may pass a bare name for a result file a sibling tool
# wrote into CHEMGRAPH_LOG_DIR. Resolve it against the log dir; an
# absolute or cwd-relative path is returned unchanged.
file_path = _resolve_existing_path(file_path)

try:
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
Expand Down
6 changes: 5 additions & 1 deletion src/chemgraph/mcp/graspa_mcp_parsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,16 @@ async def run_graspa_ensemble(
params : graspa_input_schema_ensemble
Input parameters for the ensemble of gRASPA calculations.
"""
from chemgraph.tools.ase_core import _resolve_existing_path

input_source = params.input_structures
structure_files: list[Path] = []
output_dir: Path = Path.cwd() # Default fallback

if isinstance(input_source, list):
structure_files = [Path(p) for p in input_source]
# Resolve bare names against CHEMGRAPH_LOG_DIR so a file a sibling tool
# wrote there still resolves; absolute/cwd paths are unchanged.
structure_files = [Path(_resolve_existing_path(str(p))) for p in input_source]
missing = [p for p in structure_files if not p.exists()]
if missing:
raise ValueError(f"The following input files are missing: {missing}")
Expand Down
9 changes: 9 additions & 0 deletions src/chemgraph/mcp/hpc_misc_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ def inspect_json(
) -> dict[str, Any]:
"""Inspect JSON artifacts without assuming one fixed output-file layout."""
target = Path(path).expanduser()
# A small model may pass a bare name for a JSON file a sibling tool wrote
# into CHEMGRAPH_LOG_DIR. If the raw path is not a file, resolve it against
# the log dir before falling back to the directory / nearby-files logic.
if not target.is_file():
from chemgraph.tools.ase_core import _resolve_existing_path

resolved = Path(_resolve_existing_path(str(target))).expanduser()
if resolved.is_file():
target = resolved
if target.is_file():
return {
"status": "ok",
Expand Down
17 changes: 13 additions & 4 deletions src/chemgraph/mcp/mace_mcp_hpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,23 @@ def _embed_inline_if_local(job: dict) -> None:
if job.get("remote_structure_file") or job.get("inline_structure"):
return
input_file = job.get("input_structure_file")
if not input_file or not os.path.isfile(input_file):
return # remote path -- worker will read it directly
if not input_file:
return

from ase.io import read as ase_read

from chemgraph.tools.ase_core import atoms_to_atomsdata
from chemgraph.tools.ase_core import _resolve_existing_path, atoms_to_atomsdata

# A small model may echo back a bare name ("water.xyz") for a file a
# sibling tool wrote into CHEMGRAPH_LOG_DIR. Resolve it here, on the
# submitting host where the log dir lives, before deciding whether the
# input is local. Absolute/cwd paths are returned unchanged.
resolved = _resolve_existing_path(input_file)
if not os.path.isfile(resolved):
return # remote path -- worker will read it directly

atoms = ase_read(input_file)
job["input_structure_file"] = resolved
atoms = ase_read(resolved)
job["inline_structure"] = atoms_to_atomsdata(atoms).model_dump()


Expand Down
5 changes: 4 additions & 1 deletion src/chemgraph/mcp/xanes_mcp_parsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,16 @@ async def run_xanes_ensemble(params: xanes_input_schema_ensemble):
write_fdmnes_input,
extract_conv,
)
from chemgraph.tools.ase_core import _resolve_existing_path

input_source = params.input_structures
structure_files: list[Path] = []
output_dir: Path = Path.cwd()

if isinstance(input_source, list):
structure_files = [Path(p) for p in input_source]
# Resolve bare names against CHEMGRAPH_LOG_DIR so a file a sibling tool
# wrote there still resolves; absolute/cwd paths are unchanged.
structure_files = [Path(_resolve_existing_path(str(p))) for p in input_source]
missing = [p for p in structure_files if not p.exists()]
if missing:
raise ValueError(f"The following input files are missing: {missing}")
Expand Down
44 changes: 43 additions & 1 deletion src/chemgraph/tools/ase_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,41 @@ def _resolve_path(path: str) -> str:
return path


def _resolve_existing_path(path: str) -> str:
"""Resolve a path to read that a sibling tool may have written to the log dir.

Tools that *write* files (``smiles_to_coordinate_file``, ``run_ase``'s
result JSON, ``save_atomsdata_to_file`` ...) send relative paths through
:func:`_resolve_path`, so a bare ``"water.xyz"`` lands in
``CHEMGRAPH_LOG_DIR`` rather than the caller's cwd. A tool that later
*reads* that bare name must look in the same place, otherwise it raises
``FileNotFoundError`` even though the file exists.

This helper returns ``path`` unchanged when it already points at an
existing file (absolute paths and genuine cwd-relative paths keep working);
only when the raw path is missing does it fall back to the
``CHEMGRAPH_LOG_DIR``-resolved location. The raw path is returned when
neither exists, so callers still surface a meaningful "not found" error.

Parameters
----------
path : str
Absolute or relative file path to read.

Returns
-------
str
The raw path if it exists, else the log-dir-resolved path if that
exists, else the raw path unchanged.
"""
if os.path.isfile(path):
return path
resolved = _resolve_path(path)
if resolved != path and os.path.isfile(resolved):
return resolved
return path


# ---------------------------------------------------------------------------
# AtomsData <-> ASE Atoms conversions
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -366,7 +401,11 @@ def run_ase_core(params: ASEInputSchema) -> dict:

start_time = time.time()

input_structure_file = params.input_structure_file
# Resolve a relative input path against CHEMGRAPH_LOG_DIR, matching how
# smiles_to_coordinate_file writes it. Without this, a tool that writes
# water.xyz into the session log dir and a later run_ase that reads
# "water.xyz" from cwd disagree -> FileNotFoundError.
input_structure_file = _resolve_existing_path(params.input_structure_file)
output_results_file = _resolve_path(params.output_results_file)
optimizer = params.optimizer
fmax = params.fmax
Expand Down Expand Up @@ -764,6 +803,9 @@ def extract_output_json_core(json_file: str) -> dict:
json.JSONDecodeError
If the file is not valid JSON.
"""
# run_ase writes its result JSON via _resolve_path (into CHEMGRAPH_LOG_DIR),
# so a bare relative name passed here must resolve to the same place.
json_file = _resolve_existing_path(json_file)
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
return data
4 changes: 4 additions & 0 deletions src/chemgraph/tools/ase_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from chemgraph.schemas.ase_input import ASEInputSchema
from chemgraph.tools.ase_core import (
_resolve_path,
_resolve_existing_path,
atoms_to_atomsdata,
extract_output_json_core,
run_ase_core,
Expand Down Expand Up @@ -63,6 +64,9 @@ def file_to_atomsdata(fname: str) -> AtomsData:
"""
from ase.io import read

# A coordinate file written by smiles_to_coordinate_file/save_atomsdata_to_file
# via _resolve_path lands in CHEMGRAPH_LOG_DIR; resolve a bare name to match.
fname = _resolve_existing_path(fname)
try:
atoms = read(fname)
return atoms_to_atomsdata(atoms)
Expand Down
6 changes: 5 additions & 1 deletion src/chemgraph/tools/graspa_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,11 @@ def _calculate_cell_size(

return [uc_x, uc_y, uc_z]

cif_path = Path(params.input_structure_file).resolve()
# Resolve a bare relative name against CHEMGRAPH_LOG_DIR (where a sibling
# tool wrote the file) before falling back to a cwd-relative absolute path.
from chemgraph.tools.ase_core import _resolve_existing_path

cif_path = Path(_resolve_existing_path(params.input_structure_file)).resolve()
if not cif_path.exists():
raise FileNotFoundError(f"CIF file does not exist: {cif_path}")

Expand Down
4 changes: 3 additions & 1 deletion src/chemgraph/tools/parsl_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
mace_input_schema,
mace_output_schema,
)
from chemgraph.tools.ase_core import run_ase_core
from chemgraph.tools.ase_core import run_ase_core, _resolve_existing_path

# Re-export schemas so existing ``from chemgraph.tools.parsl_tools import …``
# statements continue to work.
Expand Down Expand Up @@ -85,6 +85,8 @@ def extract_output_json(json_file: str) -> dict:
"""Load simulation results from a JSON file produced by run_ase."""
import json

# Match run_ase's _resolve_path write location for bare relative names.
json_file = _resolve_existing_path(json_file)
try:
with open(json_file, "r") as f:
ret = json.load(f)
Expand Down
8 changes: 8 additions & 0 deletions src/chemgraph/tools/report_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ase.data import chemical_symbols as _chemical_symbols

from chemgraph.schemas.ase_input import ASEOutputSchema
from chemgraph.tools.ase_core import _resolve_existing_path
from chemgraph.tools.ase_tools import is_linear_molecule


Expand Down Expand Up @@ -341,6 +342,13 @@ def generate_html(
str
Path to the generated HTML file
"""
# run_ase and the coordinate writers emit relative paths into
# CHEMGRAPH_LOG_DIR via _resolve_path; resolve bare names to match so a
# report can be built from files produced earlier in the same session.
results_json_path = _resolve_existing_path(results_json_path)
if xyz_path is not None:
xyz_path = _resolve_existing_path(xyz_path)

# Validate results_json_path exists
if not os.path.isfile(results_json_path):
return (
Expand Down
6 changes: 5 additions & 1 deletion src/chemgraph/tools/xanes_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,11 @@ def run_xanes_core(params: xanes_input_schema) -> dict:
"Set it to the path of the FDMNES executable."
)

input_path = Path(params.input_structure_file).resolve()
# Resolve a bare relative name against CHEMGRAPH_LOG_DIR (where a sibling
# tool wrote the file) before falling back to a cwd-relative absolute path.
from chemgraph.tools.ase_core import _resolve_existing_path

input_path = Path(_resolve_existing_path(params.input_structure_file)).resolve()
if not input_path.exists():
raise FileNotFoundError(f"Input structure file not found: {input_path}")

Expand Down
Loading