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
1716Every key is optional, and leaving one out is not the same as setting it false,
1817so the fields below default to None to mean "the user said nothing".
2221pydantic's `model_fields_set`, so a recipe hardcoding a parameter pins it for
2322every 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
2929from __future__ import annotations
3030
3131import logging
32- import re
3332from pathlib import Path
3433from typing import Any
3534
3635import pydantic
3736import yaml
3837
39- CONFIG_FILENAMES = ( ".config.yaml" , ". user.yaml")
38+ CONFIG_FILENAME = ". user.yaml"
4039
4140log = logging .getLogger ("dlstbx.util.xchem_config" )
4241
4342
4443class 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
11690def 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
128111def 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