Skip to content

Commit 279e711

Browse files
authored
Merge pull request #845 from lindsay-stevens/pyxform-812
812: Leading and trailing spaces in parameters lead to form error
2 parents 05e1504 + 9fdd143 commit 279e711

18 files changed

Lines changed: 645 additions & 339 deletions

pyxform/constants.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
CONTROL = "control"
5151
APPEARANCE = "appearance"
5252
ITEMSET = "itemset"
53-
RANDOMIZE = "randomize"
5453
CHOICE_FILTER = "choice_filter"
5554
PARAMETERS = "parameters"
5655

@@ -102,13 +101,6 @@
102101
XLSX_EXTENSIONS = {".xlsx", ".xlsm"}
103102
SUPPORTED_FILE_EXTENSIONS = {*XLS_EXTENSIONS, *XLSX_EXTENSIONS}
104103

105-
LOCATION_PRIORITY = "location-priority"
106-
LOCATION_MIN_INTERVAL = "location-min-interval"
107-
LOCATION_MAX_AGE = "location-max-age"
108-
TRACK_CHANGES = "track-changes"
109-
IDENTIFY_USER = "identify-user"
110-
TRACK_CHANGES_REASONS = "track-changes-reasons"
111-
112104
# supported bind keywords for which external instances will be created for pulldata function
113105
EXTERNAL_INSTANCES = {"calculate", "constraint", "readonly", "required", "relevant"}
114106

@@ -177,3 +169,64 @@ class EntityColumns(StrEnum):
177169
SUPPORTED_MEDIA_TYPES = {"image", "big-image", "audio", "video"}
178170
OR_OTHER_CHOICE = {NAME: "other", LABEL: "Other"}
179171
RESERVED_NAMES_SURVEY_SHEET = {META}
172+
173+
174+
##########################################################################################
175+
# Parameters
176+
##########################################################################################
177+
# Name enums by question type, or if shared then use a sensible question type prefix.
178+
# For aliased question types, use the primary documented name e.g. "image" not "photo".
179+
# The module question_type_dictionary.py handles default parameter values, if any.
180+
# Add new enums or keys alphabetical order.
181+
class ParametersAudio(StrEnum):
182+
QUALITY = "quality"
183+
184+
185+
class ParametersAudit(StrEnum):
186+
IDENTIFY_USER = "identify-user"
187+
LOCATION_MAX_AGE = "location-max-age"
188+
LOCATION_MIN_INTERVAL = "location-min-interval"
189+
LOCATION_PRIORITY = "location-priority"
190+
TRACK_CHANGES = "track-changes"
191+
TRACK_CHANGES_REASONS = "track-changes-reasons"
192+
193+
194+
class ParametersGeo(StrEnum):
195+
ALLOW_MOCK_ACCURACY = "allow-mock-accuracy"
196+
INCREMENTAL = "incremental"
197+
198+
199+
class ParametersGeoPoint(StrEnum):
200+
ALLOW_MOCK_ACCURACY = "allow-mock-accuracy"
201+
CAPTURE_ACCURACY = "capture-accuracy"
202+
WARNING_ACCURACY = "warning-accuracy"
203+
204+
205+
class ParametersImage(StrEnum):
206+
APP = "app"
207+
MAX_PIXELS = "max-pixels"
208+
209+
210+
class ParametersRange(StrEnum):
211+
END = "end"
212+
PLACEHOLDER = "placeholder"
213+
START = "start"
214+
STEP = "step"
215+
TICK_INTERVAL = "tick_interval"
216+
TICK_LABELSET = "tick_labelset"
217+
218+
219+
class ParametersSelect(StrEnum):
220+
RANDOMIZE = "randomize"
221+
SEED = "seed"
222+
223+
224+
class ParametersSelectFromFile(StrEnum):
225+
LABEL = "label"
226+
RANDOMIZE = "randomize"
227+
SEED = "seed"
228+
VALUE = "value"
229+
230+
231+
class ParametersText(StrEnum):
232+
ROWS = "rows"

pyxform/errors.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,21 @@ class ErrorCode(Enum):
506506
"be 'true' or not included."
507507
),
508508
)
509+
SURVEY_004: Detail = Detail(
510+
name="Survey sheet - parameters parsing failed",
511+
msg=(
512+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
513+
"Parameters must be in the form of 'key1=value1 key2=value2."
514+
),
515+
)
516+
SURVEY_005: Detail = Detail(
517+
name="Survey sheet - parameters unknown key",
518+
msg=(
519+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
520+
"The accepted parameter keys for this question type are '{accepted}'. "
521+
"The following are invalid parameter key(s): '{rejected}'."
522+
),
523+
)
509524

510525

511526
class PyXFormError(Exception):

pyxform/parsing/parameters.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from lark import Lark, Token, Transformer
2+
from lark.exceptions import LarkError
3+
4+
from pyxform.errors import ErrorCode, PyXFormError
5+
from pyxform.parsing.expression import maybe_strip
6+
7+
# Label and value are used to match against user-specified files so case should be preserved.
8+
CASE_SENSITIVE_VALUES = {"label", "value"}
9+
10+
PARAMETER_GRAMMAR = r"""
11+
start: pair*
12+
pair: TOKEN "=" TOKEN
13+
// Anything that's not a delimiter.
14+
TOKEN: /[^\s=,;]+/
15+
// Delimiters between key-value pairs.
16+
%ignore /[\s,;]+/
17+
"""
18+
19+
_PARAMETER_PARSER = Lark(PARAMETER_GRAMMAR, parser="lalr", start="start")
20+
21+
22+
class ParameterTransformer(Transformer):
23+
@staticmethod
24+
def start(pairs: list[tuple[str, str]]) -> dict[str, str]:
25+
"""Combine (key, value) tuples into a dict"""
26+
return dict(pairs)
27+
28+
@staticmethod
29+
def pair(items: list[Token, Token]) -> tuple[str, str]:
30+
"""Normalise matched (key, value) tokens."""
31+
raw_key, raw_value = items
32+
key = maybe_strip(str(raw_key).lower())
33+
value = maybe_strip(str(raw_value))
34+
35+
if key not in CASE_SENSITIVE_VALUES:
36+
value = value.lower()
37+
38+
return key, value
39+
40+
41+
# No token-specific (ALL_CAPS) methods, so visit_tokens=False.
42+
_PARAMETER_TRANSFORMER = ParameterTransformer(visit_tokens=False)
43+
44+
45+
def parse(
46+
raw_parameters: str,
47+
row_number: int,
48+
) -> dict[str, str]:
49+
if not raw_parameters or not raw_parameters.strip():
50+
return {}
51+
52+
try:
53+
return _PARAMETER_TRANSFORMER.transform(_PARAMETER_PARSER.parse(raw_parameters))
54+
except LarkError as e:
55+
raise PyXFormError(code=ErrorCode.SURVEY_004, context={"row": row_number}) from e

pyxform/question.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,12 @@ def build_xml(self, survey: "Survey"):
401401
itemset_value_ref = DEFAULT_ITEMSET_VALUE_REF
402402
itemset_label_ref = DEFAULT_ITEMSET_LABEL_REF
403403
if self.parameters is not None:
404-
itemset_value_ref = self.parameters.get("value", itemset_value_ref)
405-
itemset_label_ref = self.parameters.get("label", itemset_label_ref)
404+
itemset_value_ref = self.parameters.get(
405+
constants.ParametersSelectFromFile.VALUE, itemset_value_ref
406+
)
407+
itemset_label_ref = self.parameters.get(
408+
constants.ParametersSelectFromFile.LABEL, itemset_label_ref
409+
)
406410

407411
is_previous_question = has_pyxform_reference(self.itemset)
408412

@@ -451,12 +455,17 @@ def build_xml(self, survey: "Survey"):
451455
if self.parameters:
452456
params = self.parameters
453457

454-
if "randomize" in params and params["randomize"] == "true":
458+
if (
459+
constants.ParametersSelect.RANDOMIZE in params
460+
and params[constants.ParametersSelect.RANDOMIZE] == "true"
461+
):
455462
nodeset = f"randomize({nodeset}"
456463

457-
if "seed" in params:
464+
if constants.ParametersSelect.SEED in params:
458465
seed = maybe_strip(
459-
survey.insert_xpaths(text=params["seed"], context=self)
466+
survey.insert_xpaths(
467+
text=params[constants.ParametersSelect.SEED], context=self
468+
)
460469
)
461470
nodeset = f"{nodeset}, {seed}"
462471

pyxform/question_type_dictionary.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from types import MappingProxyType
77
from typing import Any
88

9-
from pyxform import constants as const
9+
from pyxform import constants as co
1010

1111
_QUESTION_TYPE_DICT = {
1212
"q picture": {
@@ -359,7 +359,11 @@
359359
"range": {
360360
"control": {"tag": "range"},
361361
"bind": {"type": "int"},
362-
"parameters": {"start": "1", "end": "10", "step": "1"},
362+
"parameters": {
363+
co.ParametersRange.START.value: "1",
364+
co.ParametersRange.END.value: "10",
365+
co.ParametersRange.STEP.value: "1",
366+
},
363367
},
364368
"audit": {"bind": {"type": "binary"}},
365369
"xml-external": {
@@ -390,8 +394,8 @@ def get_meta_group(children: Sequence[dict[str, Any]]) -> dict[str, Any]:
390394
if children is None:
391395
children = []
392396
return {
393-
const.NAME: "meta",
394-
const.TYPE: const.GROUP,
395-
const.CONTROL: {"bodyless": True},
396-
const.CHILDREN: children,
397+
co.NAME: "meta",
398+
co.TYPE: co.GROUP,
399+
co.CONTROL: {"bodyless": True},
400+
co.CHILDREN: children,
397401
}

pyxform/util/enum.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,13 @@ def __new__(cls, *values):
2828
return member
2929

3030
@classmethod
31-
def value_list(cls):
31+
def value_list(cls) -> list:
3232
return list(cls.__members__.values())
33+
34+
@classmethod
35+
def value_set(cls) -> set:
36+
return set(cls.__members__.values())
37+
38+
@classmethod
39+
def value_str_sorted(cls) -> str:
40+
return ", ".join(sorted(cls.__members__.values()))
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Any
2+
3+
from pyxform.errors import ErrorCode, PyXFormError
4+
from pyxform.util.enum import StrEnum
5+
6+
PARAMETERS_TYPE = dict[str, Any]
7+
8+
9+
def validate(
10+
parameters: PARAMETERS_TYPE,
11+
accepted: type[StrEnum],
12+
row_number: int,
13+
) -> dict[str, str]:
14+
"""
15+
Raise an error if 'parameters' includes any keys not named in 'accepted'.
16+
"""
17+
extras = set(parameters) - accepted.value_set()
18+
if 0 < len(extras):
19+
raise PyXFormError(
20+
ErrorCode.SURVEY_005.value.format(
21+
row=row_number,
22+
accepted=accepted.value_str_sorted(),
23+
rejected=", ".join(sorted(extras)),
24+
)
25+
)
26+
return parameters

pyxform/validators/pyxform/parameters_generic.py

Lines changed: 0 additions & 49 deletions
This file was deleted.

0 commit comments

Comments
 (0)