Skip to content

Commit 809028a

Browse files
committed
Add validation of parsed general input
The existing validation is validating the config dict generated by various steps when parsing the excel file to a config dict. It makes sense to have a validation step earlier in the loop which validates the raw input from the excel file to give a more relevant exception feedback to unchanged values. This validation should not edit any values, only validate the input.
1 parent 5646343 commit 809028a

4 files changed

Lines changed: 510 additions & 51 deletions

File tree

src/semeio/fmudesign/_excel_to_dict.py

Lines changed: 6 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
import yaml
1818

1919
from semeio.fmudesign.design_distributions import read_correlations
20-
from semeio.fmudesign.utils import seeds_from_extern
20+
from semeio.fmudesign.general_input_validation import validate_general_input
21+
from semeio.fmudesign.utils import resolve_path, seeds_from_extern
2122

2223

2324
def excel_to_dict(
@@ -150,35 +151,6 @@ def _check_for_mixed_sensitivities(sens_name: str, sens_group: pd.DataFrame) ->
150151
)
151152

152153

153-
def resolve_path(input_filename: str, reference: str | None) -> str | None:
154-
"""The path `input_filename` is an Excel sheet, and `reference` is a cell
155-
value that *might* be a reference to another file. Resolve the path to
156-
`reference` and return. If no such file exists, return `reference`.
157-
"""
158-
# The reference is None, so just return it back
159-
if reference is None:
160-
return reference
161-
162-
# It's a string but not a reference to another file
163-
if not str(reference).endswith(("xlsx", "csv")):
164-
return reference
165-
166-
# If the reference is e.g. 'C:/Users/USER/files/doe1.xlsx'
167-
reference_path = Path(reference)
168-
if reference_path.is_absolute() and reference_path.exists():
169-
return str(reference_path.resolve())
170-
171-
# If the reference is e.g. 'doe1.xlsx'
172-
full_path = Path(input_filename).parent / reference_path
173-
if full_path.exists():
174-
return str(full_path.resolve())
175-
176-
if reference_path.exists():
177-
return str(reference_path.resolve())
178-
179-
raise ValueError(f"Failed to resolve path for file: {reference}")
180-
181-
182154
def _excel_to_dict_onebyone(
183155
input_filename: str,
184156
*,
@@ -202,7 +174,7 @@ def _excel_to_dict_onebyone(
202174
} # This is the config that we read and return
203175

204176
# Read the general input sheet to a dictionary
205-
generalinput = (
177+
raw_generalinput = (
206178
pd.read_excel(
207179
input_filename,
208180
general_input_sheet,
@@ -223,29 +195,12 @@ def parse_value(value: object) -> object:
223195
return value.strip()
224196
return value
225197

226-
# Convert NaN values to None and strip other values
227198
generalinput = {
228-
str(key).strip(): parse_value(value) for (key, value) in generalinput.items()
199+
str(key).strip(): parse_value(value)
200+
for (key, value) in raw_generalinput.items()
229201
}
230202

231-
# Check that there are no wrong keys or typos, e.g. 'repets'
232-
ALLOWED_KEYS = {
233-
"designtype",
234-
"repeats",
235-
"correlation_iterations",
236-
"distribution_seed",
237-
"seed_strategy",
238-
"rms_seeds",
239-
"background",
240-
}
241-
extra_keys = set(generalinput.keys()) - set(ALLOWED_KEYS)
242-
if extra_keys:
243-
msg = (
244-
"In the general input sheet, the following parameter(s) are not"
245-
f"recognized and cannot be parsed:\n{extra_keys!r}\n"
246-
f"Allowed keys:{ALLOWED_KEYS!r}"
247-
)
248-
raise LookupError(msg)
203+
validate_general_input(generalinput, input_filename)
249204

250205
# Copy keys over if they exist
251206
keys = [
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
from typing import Any
2+
3+
from semeio.fmudesign.config_validation import SeedStrategy
4+
from semeio.fmudesign.utils import resolve_path
5+
6+
7+
class GeneralInputError(Exception):
8+
pass
9+
10+
11+
class MissingGeneralInputKeyError(Exception):
12+
def __init__(self, key: str) -> None:
13+
msg = f"key(s): '{key}' must be specified in general input sheet."
14+
super().__init__(msg)
15+
16+
17+
class ValidationError(Exception):
18+
def __init__(self, val: Any, key: str, type_str: str) -> None: # ruff: ignore[any-type]
19+
super().__init__(
20+
f"Could not validate '{val}' as {type_str} for key '{key}'. "
21+
f"Failed to validate '{val}'."
22+
)
23+
24+
25+
class IntValidationError(ValidationError):
26+
pass
27+
28+
29+
class PositiveIntValidationError(ValidationError):
30+
pass
31+
32+
33+
class StrValidationError(ValidationError):
34+
pass
35+
36+
37+
class NoneValidationError(ValidationError):
38+
pass
39+
40+
41+
class PathValidationError(ValidationError):
42+
pass
43+
44+
45+
def validate_string(maybe_string: Any, key: str) -> str: # ruff: ignore[any-type]
46+
try:
47+
return str(maybe_string)
48+
except Exception as e:
49+
raise StrValidationError(maybe_string, key, "str") from e
50+
51+
52+
def validate_int(maybe_int: Any, key: str) -> int: # ruff: ignore[any-type]
53+
try:
54+
return int(maybe_int)
55+
except (TypeError, ValueError) as e:
56+
raise IntValidationError(maybe_int, key, "int") from e
57+
58+
59+
def validate_positive_int(maybe_int: Any, key: str) -> int: # ruff: ignore[any-type]
60+
try:
61+
int_ = int(maybe_int)
62+
except (TypeError, ValueError) as e:
63+
raise PositiveIntValidationError(maybe_int, key, "positive int") from e
64+
65+
if int_ < 0:
66+
raise PositiveIntValidationError(maybe_int, key, "positive int")
67+
68+
return int_
69+
70+
71+
def validate_file(maybe_file: Any, input_file: str, key: str) -> None: # ruff: ignore[any-type]
72+
try:
73+
resolve_path(maybe_file, input_file)
74+
except (ValueError, TypeError) as e:
75+
raise GeneralInputError(
76+
f"Could not resolve file '{maybe_file}' to an existing "
77+
f"file for key '{key}'. "
78+
f"Failed to validate '{maybe_file}'."
79+
) from e
80+
81+
82+
def validate_none(maybe_none: Any, key: str) -> None: # ruff: ignore[any-type]
83+
if maybe_none is None:
84+
return
85+
if isinstance(maybe_none, str) and maybe_none.lower() == "none":
86+
return
87+
raise NoneValidationError(maybe_none, key, "None")
88+
89+
90+
REQUIRED_KEYS = {"designtype", "repeats", "distribution_seed", "rms_seeds"}
91+
ALLOWED_KEYS = REQUIRED_KEYS | {"correlation_iterations", "seed_strategy", "background"}
92+
93+
94+
def _validate_required_keys(config: dict[str, Any]) -> None:
95+
missing_keys = REQUIRED_KEYS - set(config.keys())
96+
if missing_keys:
97+
raise MissingGeneralInputKeyError(", ".join(missing_keys))
98+
99+
100+
def _validate_no_extra_keys(config: dict[str, Any]) -> None:
101+
extra_keys = set(config.keys()) - set(ALLOWED_KEYS)
102+
if extra_keys:
103+
raise GeneralInputError(
104+
"In the general input sheet, the following parameter(s) are not "
105+
f"recognized and cannot be parsed:\n{extra_keys!r}\n"
106+
f"Allowed keys:{ALLOWED_KEYS!r}"
107+
)
108+
109+
110+
def _validate_designtype(config: dict[str, Any]) -> None:
111+
if config["designtype"] != "onebyone":
112+
raise GeneralInputError(
113+
"Generation of DesignMatrix only implemented for designtype 'onebyone', "
114+
f"not '{config['designtype']}'"
115+
)
116+
117+
118+
def _validate_repeats(config: dict[str, Any]) -> None:
119+
validate_positive_int(config["repeats"], "repeats")
120+
121+
122+
def _validate_correlation_iterations(config: dict[str, Any]) -> None:
123+
key = "correlation_iterations"
124+
if key in config:
125+
validate_positive_int(config["correlation_iterations"], key)
126+
127+
128+
def _validate_distribution_seed(config: dict[str, Any]) -> None:
129+
key = "distribution_seed"
130+
value = config["distribution_seed"]
131+
try:
132+
validate_positive_int(value, key)
133+
except PositiveIntValidationError:
134+
try:
135+
validate_none(value, key)
136+
except NoneValidationError as e:
137+
raise ValidationError(value, key, "positive int or None") from e
138+
139+
140+
def _validate_seed_strategy(config: dict[str, Any]) -> None:
141+
key = "seed_strategy"
142+
if key in config:
143+
value = validate_string(config[key], key).lower()
144+
if value.strip().lower() not in {*SeedStrategy, "none"}:
145+
raise ValidationError(
146+
value, key, f"SeedStrategy ({'/'.join(SeedStrategy)})"
147+
)
148+
149+
150+
def _validate_rms_seeds(config: dict[str, Any], input_file: str) -> None:
151+
key = "rms_seeds"
152+
value = config[key]
153+
try:
154+
validate_file(value, input_file, key)
155+
except (PathValidationError, GeneralInputError):
156+
try:
157+
s = validate_string(value, key)
158+
if s != "default":
159+
raise StrValidationError(s, key, s)
160+
except StrValidationError:
161+
try:
162+
validate_none(value, key)
163+
except NoneValidationError as e:
164+
raise ValidationError(value, key, "str, path or None") from e
165+
166+
167+
def validate_general_input(config: dict[str, Any], input_filename: str) -> None:
168+
_validate_no_extra_keys(config)
169+
_validate_required_keys(config)
170+
_validate_designtype(config)
171+
_validate_repeats(config)
172+
_validate_correlation_iterations(config)
173+
_validate_distribution_seed(config)
174+
_validate_seed_strategy(config)
175+
_validate_rms_seeds(config, input_filename)

src/semeio/fmudesign/utils.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Module for utility functions that do not belong elsewhere.
33
"""
44

5+
from pathlib import Path
56
from typing import Any
67

78
import pandas as pd
@@ -214,3 +215,32 @@ def map_dependencies(
214215
print(f" {from_} => {to_}")
215216

216217
return df
218+
219+
220+
def resolve_path(input_filename: str, reference: str | None) -> str | None:
221+
"""The path `input_filename` is an Excel sheet, and `reference` is a cell
222+
value that *might* be a reference to another file. Resolve the path to
223+
`reference` and return. If no such file exists, return `reference`.
224+
"""
225+
# The reference is None, so just return it back
226+
if reference is None:
227+
return reference
228+
229+
# It's a string but not a reference to another file
230+
if not str(reference).endswith(("xlsx", "csv")):
231+
return reference
232+
233+
# If the reference is e.g. 'C:/Users/USER/files/doe1.xlsx'
234+
reference_path = Path(reference)
235+
if reference_path.is_absolute() and reference_path.exists():
236+
return str(reference_path.resolve())
237+
238+
# If the reference is e.g. 'doe1.xlsx'
239+
full_path = Path(input_filename).parent / reference_path
240+
if full_path.exists():
241+
return str(full_path.resolve())
242+
243+
if reference_path.exists():
244+
return str(reference_path.resolve())
245+
246+
raise ValueError(f"Failed to resolve path for file: {reference}")

0 commit comments

Comments
 (0)