Skip to content

Commit 0369cdb

Browse files
add: new range appearance params, replace older tests with xpath tests
1 parent 98954a1 commit 0369cdb

7 files changed

Lines changed: 1058 additions & 171 deletions

File tree

pyxform/errors.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,100 @@ class ErrorCode(Enum):
390390
"'{q}' appears more than once."
391391
),
392392
)
393+
RANGE_001 = Detail(
394+
name="Range type - parameter is not a number",
395+
msg=(
396+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
397+
"For the 'range' question type, the parameter '{name}' must be a number."
398+
),
399+
)
400+
RANGE_002 = Detail(
401+
name="Range type - parameter is zero",
402+
msg=(
403+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
404+
"For the 'range' question type, the parameter '{name}' must not be '0' (zero)."
405+
),
406+
)
407+
RANGE_003 = Detail(
408+
name="Range type - parameter larger than range",
409+
msg=(
410+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
411+
"For the 'range' question type, the parameter '{name}' must not be larger than "
412+
"the range (the difference between 'start' and 'end')."
413+
),
414+
)
415+
RANGE_004 = Detail(
416+
name="Range type - parameter not a multiple of tick",
417+
msg=(
418+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
419+
"For the 'range' question type, the parameter '{name}' must be a multiple of 'step'."
420+
),
421+
)
422+
RANGE_005 = Detail(
423+
name="Range type - parameter outside range",
424+
msg=(
425+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
426+
"For the 'range' question type, the parameter '{name}' must be a within "
427+
"the range (between the 'start' and 'end' values, inclusive)."
428+
),
429+
)
430+
RANGE_006 = Detail(
431+
name="Range type - tick_labelset not found",
432+
msg=(
433+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
434+
"For the 'range' question type, the parameter 'tick_labelset' must be a "
435+
"choice list name from the 'list_name' column on the choices sheet."
436+
),
437+
)
438+
RANGE_007 = Detail(
439+
name="Range type - tick_labelset too many choices with no-ticks",
440+
msg=(
441+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
442+
"For the 'range' question type, the parameter 'tick_labelset' choice list "
443+
"must have only 2 items when the 'appearance' is 'no-ticks'."
444+
),
445+
)
446+
RANGE_008 = Detail(
447+
name="Range type - parameter not compatible with appearance",
448+
msg=(
449+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
450+
"For the 'range' question type, the parameters 'tick_interval', 'placeholder', "
451+
"and 'tick_labelset' are only supported for the appearances 'vertical', 'no-ticks' "
452+
"and the default (empty) horizontal."
453+
),
454+
)
455+
RANGE_009 = Detail(
456+
name="Range type - tick_labelset choice is not a number",
457+
msg=(
458+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
459+
"For the 'range' question type, the parameter '{tick_labelset}' choices must "
460+
"all be numbers."
461+
),
462+
)
463+
RANGE_010 = Detail(
464+
name="Range type - tick_labelset choice outside range",
465+
msg=(
466+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
467+
"For the 'range' question type, the parameter 'tick_labelset' choices must "
468+
"be a within the range (between the 'start' and 'end' values, inclusive)."
469+
),
470+
)
471+
RANGE_011 = Detail(
472+
name="Range type - tick_labelset choice not a multiple of tick",
473+
msg=(
474+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
475+
"For the 'range' question type, the parameter 'tick_labelset' choices' must "
476+
"be a multiple of '{name}'."
477+
),
478+
)
479+
RANGE_012 = Detail(
480+
name="Range type - tick_labelset choices not start/end for no-ticks",
481+
msg=(
482+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
483+
"For the 'range' question type, the parameter 'tick_labelset' choice list "
484+
"match the range 'start' and 'end' values when the 'appearance' is 'no-ticks'."
485+
),
486+
)
393487
SURVEY_001 = Detail(
394488
name="Survey sheet - unmatched group/repeat/loop end",
395489
msg=(

pyxform/question_type_dictionary.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,11 @@
356356
"control": {"tag": "upload", "mediatype": "application/*"},
357357
"bind": {"type": "binary"},
358358
},
359-
"range": {"control": {"tag": "range"}, "bind": {"type": "int"}},
359+
"range": {
360+
"control": {"tag": "range"},
361+
"bind": {"type": "int"},
362+
"parameters": {"start": "1", "end": "10", "step": "1"},
363+
},
360364
"audit": {"bind": {"type": "binary"}},
361365
"xml-external": {
362366
# Only effect is to add an external instance.

pyxform/validators/pyxform/parameters_generic.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ def parse(raw_parameters: str) -> PARAMETERS_TYPE:
1919

2020
params = {}
2121
for param in parts:
22-
if "=" not in param:
22+
kv_split = param.split("=")
23+
if "=" not in param or len(kv_split) != 2:
2324
raise PyXFormError(
2425
"Expecting parameters to be in the form of "
2526
"'parameter1=value parameter2=value'."
2627
)
27-
k, v = param.split("=")[:2]
28+
k, v = kv_split
2829
key = maybe_strip(k.lower())
2930
params[key] = v if key in CASE_SENSITIVE_VALUES else maybe_strip(v.lower())
3031

pyxform/validators/pyxform/question_types.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
"""
44

55
from collections.abc import Collection, Iterable
6+
from math import isinf
7+
from typing import Any
68

79
from pyxform import aliases
810
from pyxform.errors import ErrorCode, PyXFormError
11+
from pyxform.question_type_dictionary import QUESTION_TYPE_DICT
12+
from pyxform.validators.pyxform import parameters_generic
913
from pyxform.validators.pyxform.pyxform_reference import (
1014
is_pyxform_reference_candidate,
1115
parse_pyxform_references,
@@ -100,3 +104,140 @@ def validate_geo_parameter_incremental(value: str) -> None:
100104
raise PyXFormError(
101105
code=ErrorCode.SURVEY_003,
102106
)
107+
108+
109+
def process_range_question_type(
110+
row_number: int,
111+
row: dict[str, Any],
112+
parameters: parameters_generic.PARAMETERS_TYPE,
113+
appearance: str,
114+
choices: dict[str, Any],
115+
) -> dict[str, Any]:
116+
"""
117+
Returns a new row that includes the Range parameters start, end and step.
118+
119+
Raises PyXFormError when invalid range parameters are used.
120+
"""
121+
parameters = parameters_generic.validate(
122+
parameters=parameters,
123+
allowed={"start", "end", "step", "tick_interval", "placeholder", "tick_labelset"},
124+
)
125+
if (
126+
appearance
127+
and appearance not in {"vertical", "no-ticks"}
128+
and any(
129+
k in parameters for k in ("tick_interval", "placeholder", "tick_labelset")
130+
)
131+
):
132+
raise PyXFormError(ErrorCode.RANGE_008.value.format(row=row_number))
133+
no_ticks_appearance = appearance and appearance == "no-ticks"
134+
135+
defaults = QUESTION_TYPE_DICT["range"]["parameters"]
136+
# set defaults
137+
for key in defaults:
138+
if key not in parameters:
139+
parameters[key] = defaults[key]
140+
141+
def process_parameter(name: str) -> float | None:
142+
value = parameters.get(name)
143+
if value is None:
144+
return value
145+
err = False
146+
try:
147+
value = float(value)
148+
except ValueError:
149+
err = True
150+
151+
if err or isinf(value):
152+
raise PyXFormError(
153+
ErrorCode.RANGE_001.value.format(row=row_number, name=name)
154+
)
155+
return value
156+
157+
start = process_parameter(name="start")
158+
end = process_parameter(name="end")
159+
step = process_parameter(name="step")
160+
tick_interval = process_parameter(name="tick_interval")
161+
placeholder = process_parameter(name="placeholder")
162+
tick_labelset = parameters.get("tick_labelset")
163+
range_width = abs(end - start)
164+
165+
if step == 0:
166+
raise PyXFormError(ErrorCode.RANGE_002.value.format(row=row_number, name="step"))
167+
if step > range_width:
168+
raise PyXFormError(ErrorCode.RANGE_003.value.format(row=row_number, name="step"))
169+
170+
if tick_interval is not None:
171+
if tick_interval == 0:
172+
raise PyXFormError(
173+
ErrorCode.RANGE_002.value.format(row=row_number, name="tick_interval")
174+
)
175+
if tick_interval > range_width:
176+
raise PyXFormError(
177+
ErrorCode.RANGE_003.value.format(row=row_number, name="tick_interval")
178+
)
179+
if (tick_interval % step) != 0:
180+
raise PyXFormError(
181+
ErrorCode.RANGE_004.value.format(row=row_number, name="tick_interval")
182+
)
183+
parameters["odk:tick-interval"] = parameters.pop("tick_interval")
184+
185+
if placeholder is not None:
186+
if (placeholder % step) != 0:
187+
raise PyXFormError(
188+
ErrorCode.RANGE_004.value.format(row=row_number, name="placeholder")
189+
)
190+
if placeholder < start or placeholder > end:
191+
raise PyXFormError(
192+
ErrorCode.RANGE_005.value.format(row=row_number, name="placeholder")
193+
)
194+
parameters["odk:placeholder"] = parameters.pop("placeholder")
195+
196+
if tick_labelset:
197+
tick_list = choices.get(tick_labelset)
198+
if tick_list is None:
199+
raise PyXFormError(ErrorCode.RANGE_006.value.format(row=row_number))
200+
201+
no_ticks_labels = set()
202+
for label in tick_list:
203+
errored = False
204+
try:
205+
label = float(label.get("name"))
206+
except ValueError:
207+
errored = True
208+
209+
if errored or isinf(label):
210+
raise PyXFormError(ErrorCode.RANGE_009.value.format(row=row_number))
211+
212+
if label < start or label > end:
213+
raise PyXFormError(ErrorCode.RANGE_010.value.format(row=row_number))
214+
if tick_interval is not None and (label % tick_interval) != 0:
215+
raise PyXFormError(
216+
ErrorCode.RANGE_011.value.format(row=row_number, name="tick_interval")
217+
)
218+
elif (label % step) != 0:
219+
raise PyXFormError(
220+
ErrorCode.RANGE_011.value.format(row=row_number, name="step")
221+
)
222+
if no_ticks_appearance:
223+
no_ticks_labels.add(label)
224+
225+
if no_ticks_appearance:
226+
if len(no_ticks_labels) > 2:
227+
raise PyXFormError(ErrorCode.RANGE_007.value.format(row=row_number))
228+
if no_ticks_labels != {start, end}:
229+
raise PyXFormError(ErrorCode.RANGE_012.value.format(row=row_number))
230+
231+
parameters["odk:tick-labelset"] = parameters.pop("tick_labelset")
232+
233+
# Default is integer, but if the floats have decimals then change the bind type.
234+
if any(
235+
i is not None and not i.is_integer()
236+
for i in (start, end, step, tick_interval, placeholder)
237+
):
238+
row["bind"] = row.get("bind", {})
239+
row["bind"].update({"type": "decimal"})
240+
241+
row["parameters"] = parameters
242+
243+
return row

pyxform/xls2json.py

Lines changed: 10 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -132,47 +132,6 @@ def add_flat_annotations(prompt_list, parent_relevant="", name_prefix=""):
132132
# prompt['name'] = name_prefix + prompt['name']
133133

134134

135-
def process_range_question_type(
136-
row: dict[str, Any], parameters: parameters_generic.PARAMETERS_TYPE
137-
) -> dict[str, Any]:
138-
"""
139-
Returns a new row that includes the Range parameters start, end and step.
140-
141-
Raises PyXFormError when invalid range parameters are used.
142-
"""
143-
new_dict = row.copy()
144-
parameters = parameters_generic.validate(
145-
parameters=parameters, allowed=("start", "end", "step")
146-
)
147-
parameters_map = {"start": "start", "end": "end", "step": "step"}
148-
defaults = {"start": "1", "end": "10", "step": "1"}
149-
150-
# set defaults
151-
for key in parameters_map.values():
152-
if key not in parameters:
153-
parameters[key] = defaults[key]
154-
155-
has_float = False
156-
try:
157-
# Check all parameters.
158-
for x in parameters.values():
159-
if float(x) and "." in str(x):
160-
has_float = True
161-
except ValueError as range_err:
162-
raise PyXFormError(
163-
"Range parameters 'start', 'end' or 'step' must all be numbers."
164-
) from range_err
165-
else:
166-
# is integer by default, convert to decimal if it has any float values
167-
if has_float:
168-
new_dict["bind"] = new_dict.get("bind", {})
169-
new_dict["bind"].update({"type": "decimal"})
170-
171-
new_dict["parameters"] = parameters
172-
173-
return new_dict
174-
175-
176135
def process_image_default(default_value):
177136
# prepend image files with the correct prefix, if they don't have it.
178137
image_jr_prefix = "jr://images/"
@@ -519,6 +478,7 @@ def workbook_to_json(
519478

520479
# Get question type
521480
question_type = row.get(constants.TYPE)
481+
appearance = row.get("control", {}).get("appearance")
522482

523483
if not question_type:
524484
# if name and label are also missing,
@@ -888,10 +848,8 @@ def workbook_to_json(
888848

889849
# Code to deal with table_list appearance flags
890850
# (for groups of selects)
891-
ctrl_ap = new_json_dict.get("control", {}).get("appearance")
892-
893-
if ctrl_ap:
894-
appearance_mods_as_list = ctrl_ap.split()
851+
if appearance:
852+
appearance_mods_as_list = appearance.split()
895853
if constants.TABLE_LIST in appearance_mods_as_list:
896854
# Table List modifier should add field list to the new dict,
897855
# as well as appending other appearance modifiers.
@@ -1195,7 +1153,13 @@ def workbook_to_json(
11951153

11961154
# range question_type
11971155
if question_type == "range":
1198-
new_dict = process_range_question_type(row=row, parameters=parameters)
1156+
new_dict = qt.process_range_question_type(
1157+
row_number=row_number,
1158+
row=row,
1159+
parameters=parameters,
1160+
appearance=appearance,
1161+
choices=choices,
1162+
)
11991163
parent_children_array.append(new_dict)
12001164
continue
12011165

@@ -1247,7 +1211,6 @@ def workbook_to_json(
12471211
)
12481212

12491213
if "app" in parameters:
1250-
appearance = row.get("control", {}).get("appearance")
12511214
if appearance is None or appearance == "annotate":
12521215
app_package_name = str(parameters["app"])
12531216
validation_result = validate_android_package_name(app_package_name)

0 commit comments

Comments
 (0)