Skip to content

Commit 81d117d

Browse files
committed
Test config
1 parent 8ecf9ba commit 81d117d

3 files changed

Lines changed: 59 additions & 77 deletions

File tree

‎src/dlstbx/services/trigger_xchem.py‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -898,7 +898,7 @@ def trigger_hitidentification(
898898
return {"success": True}
899899
comparator_threshold = config.resolve("comparator_threshold", parameters)
900900
pipedream = config.resolve("pipedream", parameters)
901-
pandda = config.resolve("run_pandda", parameters, "pandda")
901+
pandda = parameters.pandda
902902

903903
# Industrial proposals never run Pipedream, whatever the visit asks for
904904
if (
@@ -1078,9 +1078,8 @@ def trigger_xchem_collate(
10781078
- automatic: boolean passed to ProcessingJob.automatic
10791079
10801080
The visit's config file (see dlstbx.util.xchem_config) supplies
1081-
`pipedream` if the recipe did not, names the `notify` mail recipients in
1082-
place of the visit's ISPyB Team Leader, and can stop the visit with
1083-
`enabled: false`.
1081+
`pipedream` if the recipe did not, its top-level `notify` names the mail
1082+
recipient, and `enabled:false` stops processing.
10841083
Example recipe parameters:
10851084
{ "target": "xchem_collate",
10861085
"dcid": 123456,
@@ -1256,8 +1255,7 @@ def trigger_xchem_collate(
12561255
if notify_email:
12571256
self.log.info(f"Notifying {notify_email} from the config for {visit}")
12581257
else:
1259-
notify_email = [get_visit_team_leader_email(visit, session) or ""]
1260-
notify_email = ["qvu59474@diamond.ac.uk"]
1258+
notify_email = get_visit_team_leader_email(visit, session) or ""
12611259

12621260
analysis_dir = self._resolve_analysis_dir(xchem_visit_dir)
12631261
recipe_parameters = {
@@ -1268,7 +1266,7 @@ def trigger_xchem_collate(
12681266
"scaling_id": scaling_id,
12691267
"pipedream": pipedream,
12701268
"overwrite": overwrite,
1271-
"notify_email": ",".join(notify_email),
1269+
"notify_email": notify_email,
12721270
}
12731271
# Upsert on max dcid
12741272
self.upsert_proc(rw, max(dcids), "XChem-Collate", recipe_parameters)

‎src/dlstbx/util/pandda.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,5 +200,5 @@ def get_pandda_settings(visit_dir, logger=None):
200200
201201
A visit need not have a config file, and one that exists may be empty.
202202
"""
203-
settings = load_visit_config(visit_dir, logger or log).pandda_args
203+
settings = load_visit_config(visit_dir, logger or log).pandda or {}
204204
return " ".join(f"--{k}={v}" for k, v in settings.items())

‎src/dlstbx/util/xchem_config.py‎

Lines changed: 53 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
"""Per-visit settings for the XChem autoprocessing pipelines.
22
3-
A labxchem visit may hold a `.config.yaml` (or the older `.user.yaml`) naming
4-
its target and steering how it is processed::
3+
A labxchem visit may hold a `.user.yaml` naming its target and steering how
4+
it is processed::
55
66
data:
77
acronym: A71EV2A # cached by the pipeline
88
autoprocessing:
99
enabled: true # process this visit at all?
1010
comparator_threshold: 150 # datasets PanDDA2 waits for before starting
1111
pipedream: false # run Pipedream?
12-
pandda: # false to skip PanDDA2, or a mapping of
13-
high_res_lower_limit: 2.5 # extra --key=value args to run it with
14-
notify: # who to mail when collate finishes
15-
- someone@diamond.ac.uk
12+
pandda: # extra PanDDA2 --key=value arguments
13+
high_res_lower_limit: 2.5
14+
notify: someone@diamond.ac.uk # who to mail when collate finishes
1615
1716
Every key is optional, and leaving one out is not the same as setting it false,
1817
so the fields below default to None to mean "the user said nothing".
@@ -22,55 +21,39 @@
2221
pydantic's `model_fields_set`, so a recipe hardcoding a parameter pins it for
2322
every visit it covers -- leave it out of the recipe for the file to have a say.
2423
25-
The files are hand-edited, so nothing here raises on bad input: settings that
26-
fail to parse are logged and dropped, keeping the cached acronym either way.
24+
The files are hand-edited, so nothing here raises on bad input: a setting that
25+
fails to parse is logged and dropped on its own, leaving the rest of the file
26+
in force.
2727
"""
2828

2929
from __future__ import annotations
3030

3131
import logging
32-
import re
3332
from pathlib import Path
3433
from typing import Any
3534

3635
import pydantic
3736
import yaml
3837

39-
CONFIG_FILENAMES = (".config.yaml", ".user.yaml")
38+
CONFIG_FILENAME = ".user.yaml"
4039

4140
log = logging.getLogger("dlstbx.util.xchem_config")
4241

4342

4443
class VisitConfig(pydantic.BaseModel):
4544
"""A visit's config file, flattened: `acronym` comes from the `data`
46-
section, the rest from `autoprocessing`."""
45+
section, `notify` is top level, the rest come from `autoprocessing`."""
4746

48-
model_config = pydantic.ConfigDict(extra="allow")
47+
# extras are forbidden so a misspelled key is reported rather than
48+
# silently ignored; load_visit_config drops it and keeps the rest
49+
model_config = pydantic.ConfigDict(extra="forbid")
4950

5051
acronym: str | None = None
5152
enabled: bool | None = None
5253
comparator_threshold: int | None = pydantic.Field(default=None, gt=0)
5354
pipedream: bool | None = None
54-
pandda: bool | dict[str, Any] | None = None
55-
notify: list[str] = pydantic.Field(default_factory=list)
56-
57-
@pydantic.field_validator("notify", mode="before")
58-
@classmethod
59-
def _addresses(cls, value):
60-
"""Accept one address, or a comma-separated string, as well as a list."""
61-
if isinstance(value, str):
62-
value = value.split(",")
63-
return [str(v).strip() for v in value or [] if str(v).strip()]
64-
65-
@property
66-
def pandda_args(self) -> dict:
67-
"""PanDDA2 arguments, where `pandda` was given as a mapping."""
68-
return self.pandda if isinstance(self.pandda, dict) else {}
69-
70-
@property
71-
def run_pandda(self) -> bool | None:
72-
"""Whether to run PanDDA2. A mapping says how to run it, not whether."""
73-
return self.pandda if isinstance(self.pandda, bool) else None
55+
pandda: dict[str, Any] | None = None
56+
notify: str | None = None
7457

7558
def resolve(self, key: str, parameters, param_key: str | None = None):
7659
"""The value to use for a setting: the recipe's where it set one
@@ -92,56 +75,57 @@ def _mapping(value) -> dict:
9275
return value if isinstance(value, dict) else {}
9376

9477

95-
def config_path(visit_dir) -> Path:
96-
"""A visit's config file: whichever name is already there, else the
97-
preferred one."""
98-
visit_dir = Path(visit_dir)
99-
for name in CONFIG_FILENAMES:
100-
if (visit_dir / name).is_file():
101-
return visit_dir / name
102-
return visit_dir / CONFIG_FILENAMES[0]
103-
104-
105-
def _read(visit_dir, logger) -> tuple[str, dict]:
106-
"""A visit config file's text and its parsed contents."""
107-
path = config_path(visit_dir)
78+
def _read(visit_dir, logger) -> tuple[Path, str, dict | None]:
79+
"""A visit config file's path, text and parsed contents, the last being
80+
None if the file is there but could not be read or parsed."""
81+
path = Path(visit_dir) / CONFIG_FILENAME
10882
try:
10983
text = path.read_text() if path.is_file() else ""
110-
return text, _mapping(yaml.safe_load(text))
111-
except (OSError, yaml.YAMLError) as e:
84+
return path, text, _mapping(yaml.safe_load(text))
85+
except (OSError, UnicodeDecodeError, yaml.YAMLError) as e:
11286
logger.warning(f"Ignoring unreadable visit config {path}: {e}")
113-
return "", {}
87+
return path, "", None
11488

11589

11690
def load_visit_config(visit_dir, logger=log) -> VisitConfig:
117-
"""Read a visit's config file, or an all-defaults config if it has none."""
118-
_, raw = _read(visit_dir, logger)
119-
acronym = _mapping(raw.get("data")).get("acronym")
91+
"""Read a visit's config file, or an all-defaults config if it has none.
92+
93+
A setting that fails validation is dropped on its own and the rest of the
94+
file still applies, so one typo cannot cost a visit its whole config.
95+
"""
96+
path, _, raw = _read(visit_dir, logger)
97+
raw = raw or {}
98+
values = {str(k): v for k, v in _mapping(raw.get("autoprocessing")).items()}
99+
values["acronym"] = _mapping(raw.get("data")).get("acronym")
100+
values["notify"] = raw.get("notify")
120101
try:
121-
return VisitConfig(acronym=acronym, **_mapping(raw.get("autoprocessing")))
102+
return VisitConfig(**values)
122103
except pydantic.ValidationError as e:
123-
# keep the acronym: losing it would make the visit undiscoverable
124-
logger.warning(f"Ignoring invalid settings in {config_path(visit_dir)}: {e}")
125-
return VisitConfig(acronym=acronym)
104+
# every bad field is reported in one pass, so dropping them all leaves
105+
# only settings that validate
106+
bad = {str(err["loc"][0]) for err in e.errors() if err["loc"]}
107+
logger.warning(f"Ignoring invalid {', '.join(sorted(bad))} in {path}: {e}")
108+
return VisitConfig(**{k: v for k, v in values.items() if k not in bad})
126109

127110

128111
def cache_acronym(visit_dir, acronym: str, logger=log) -> None:
129-
"""Add a visit's target acronym to its config file, leaving whatever the
130-
user wrote untouched. Does nothing if one is already recorded."""
131-
text, raw = _read(visit_dir, logger)
112+
"""Append a visit's target acronym to its config file, leaving whatever the
113+
user wrote untouched. Does nothing if one is already recorded.
114+
115+
A file that already carries a `data` section gets a second one; YAML takes
116+
the last, and `acronym` is the only key the pipeline puts there.
117+
"""
118+
path, text, raw = _read(visit_dir, logger)
119+
if raw is None:
120+
# the file is there but unreadable; appending would destroy it
121+
return
132122
if _mapping(raw.get("data")).get("acronym") is not None:
133123
return
134-
135-
entry = f"data:\n acronym: {acronym}"
136-
empty_section = re.compile(r"^data:[ \t]*$", re.MULTILINE)
137-
if empty_section.search(text):
138-
updated = empty_section.sub(entry, text, count=1)
139-
else:
140-
separator = "" if not text or text.endswith("\n") else "\n"
141-
updated = f"{text}{separator}{entry}\n"
142-
143-
path = config_path(visit_dir)
124+
separator = "" if not text or text.endswith("\n") else "\n"
125+
# dump just the new fragment: appending keeps the user's comments and key
126+
# order
127+
entry = yaml.dump({"data": {"acronym": acronym}}, default_flow_style=False)
144128
try:
145-
path.write_text(updated)
129+
path.write_text(f"{text}{separator}{entry}")
146130
except OSError as e:
147131
logger.warning(f"Could not cache acronym {acronym} to {path}: {e}")

0 commit comments

Comments
 (0)