diff --git a/JSON.md b/JSON.md index acd98a10..1416287e 100644 --- a/JSON.md +++ b/JSON.md @@ -706,6 +706,22 @@ The entered lines are joined with `\n` into a single string response, same as fo } ``` +A `"string-multiline"` can also be used as the `"subtype"` of a `"list"` input, to collect several blocks of text: + +```json + { + "type": "list", + "variable": "notes", + "label": "Notes", + "subtype": { + "type": "string-multiline", + "label": "Note", + "question": "What should the note say?" + }, + "while": "Do you want to write another note?" + } +``` + ### Create multiple files example Sometimes we would like a module to support taking an arbritary number of inputs. @@ -900,18 +916,40 @@ $ cat ./out/masterfiles/def.json ### Referencing a file example The `"file"` input type lets a module ask the user for the path to an existing file (a script, playbook, etc.) instead of a value typed in directly - the file must already exist, `cfbs` doesn't generate its contents. -Two optional attributes are available to the `"file"` input-type: `filetype` restricts which file extension(s) are accepted (a string or list of strings; any extension is accepted if omitted), and `while`, just like for `"list"`, lets the user supply any number of files instead of just one. +One optional attribute is available to the `"file"` input-type: `filetype` restricts which file extension(s) are accepted (a string or list of strings; any extension is accepted if omitted). + +A `"file"` input asks for exactly one file, and its `"response"` is a single path: ```json "input": [ { "type": "file", - "variable": "scripts", + "variable": "script", "namespace": "my_namespace", "bundle": "my_bundle", "label": "Script", "question": "Which script should be run?", - "filetype": ".sh", + "filetype": ".sh" + } + ] +``` + +To ask for several files, use a `"list"` input with a `"file"` subtype, exactly as you would for several strings - the `"while"` prompt of the list is what asks for each file after the first: + +```json + "input": [ + { + "type": "list", + "variable": "scripts", + "namespace": "my_namespace", + "bundle": "my_bundle", + "label": "Scripts", + "subtype": { + "type": "file", + "label": "Script", + "question": "Which script should be run?", + "filetype": ".sh" + }, "while": "Do you want to add another script?" } ] @@ -929,20 +967,63 @@ Do you want to add another script? no $ cat ./run-scripts/input.json [ { - "type": "file", + "type": "list", "variable": "scripts", "namespace": "my_namespace", "bundle": "my_bundle", - "label": "Script", - "question": "Which script should be run?", - "filetype": ".sh", + "label": "Scripts", + "subtype": { + "type": "file", + "label": "Script", + "question": "Which script should be run?", + "filetype": ".sh" + }, "while": "Do you want to add another script?", "response": ["./run-scripts/deploy.sh", "./run-scripts/rollback.sh"] } ] ``` -Without `"while"`, `"response"` is a single path instead of a list. +A `"file"` can also be one key among several in a list `"subtype"`, which is how a module asks for a file together with some options describing it: + +```json + "input": [ + { + "type": "list", + "variable": "scripts", + "namespace": "my_namespace", + "bundle": "my_bundle", + "label": "Scripts", + "subtype": [ + { + "key": "path", + "type": "file", + "label": "Script", + "question": "Which script should be run?", + "filetype": ".sh" + }, + { + "key": "condition", + "type": "string", + "label": "Condition", + "question": "Condition for when to run it", + "default": "any" + } + ], + "while": "Do you want to add another script?" + } + ] +``` + +Each response is then an object, and only the `"file"` keys inside it are treated as paths: + +```json + "response": [ + { "path": "./run-scripts/deploy.sh", "condition": "linux" }, + { "path": "./run-scripts/rollback.sh", "condition": "any" } + ] +``` + A file already inside the project is referred to as-is; a file from outside (as above) is copied into the module's own directory, next to `input.json`. #### Where these files end up in the built policy set @@ -953,7 +1034,7 @@ During `cfbs build`, every `"file"` response is copied into the policy set (so i - Otherwise, the file is copied into `services/cfbs/`, preserving its path *relative to the project root* rather than just its filename, so that files with the same name coming from different sources (e.g. two different modules' file inputs both named `deploy.sh`) don't overwrite each other. - If the file lives inside the referencing module's own directory - as is normal for files placed there by `cfbs input`, like the two scripts above, or for a `"file"` input whose `"default"` points at a file the module ships itself - it's additionally namespaced under `services/cfbs/modules//...`, keeping different modules' same-named files apart from each other. -Continuing the example above, both scripts live inside `./run-scripts/`, next to `input.json`, so `cfbs build` copies them under `modules/run-scripts/`: +Continuing the list-of-files example above, both scripts live inside `./run-scripts/`, next to `input.json`, so `cfbs build` copies them under `modules/run-scripts/`: ``` $ cfbs build diff --git a/cfbs/build.py b/cfbs/build.py index 1f5fbc6a..1df1bc41 100644 --- a/cfbs/build.py +++ b/cfbs/build.py @@ -17,6 +17,7 @@ import subprocess from cfbs.augments import generate_augment from cfbs.cfbs_config import CFBSConfig +from cfbs.module_input import map_file_responses from cfbs.utils import ( CFBSUserError, cli_tool_present, @@ -287,9 +288,6 @@ def _localize_file_inputs(name, input_data, destination, build_modules): build step (e.g. the project author set one up manually), that step's destination is used instead of making a redundant copy. """ - if not isinstance(input_data, list): - return - module_dir_name = name[2:] if name.startswith("./") else name module_dir_name = os.path.basename(module_dir_name.rstrip("/")) @@ -314,14 +312,7 @@ def _localize(rel_path): cp(rel_path, dest) return "$(sys.inputdir)/" + os.path.relpath(dest, destination) - for element in input_data: - if not isinstance(element, dict) or element.get("type") != "file": - continue - response = element.get("response") - if isinstance(response, list): - element["response"] = [_localize(path) for path in response] - else: - element["response"] = _localize(response) + map_file_responses(input_data, _localize) def _perform_input_step(args, name, destination, prefix, build_modules): diff --git a/cfbs/cfbs_config.py b/cfbs/cfbs_config.py index 635f9610..1745bed4 100644 --- a/cfbs/cfbs_config.py +++ b/cfbs/cfbs_config.py @@ -580,49 +580,43 @@ def _input_file(input_data): if filetypes is not None and not isinstance(filetypes, list): filetypes = [filetypes] - def _one_file(): - while True: - response = prompt_user( - self.non_interactive, - input_data["question"], - default=input_data.get("default"), - ) - if self.non_interactive: - return response - if filetypes and not any( - response.endswith(filetype) for filetype in filetypes - ): - print( - "'%s' does not have one of the accepted file extensions (%s), please try again" - % (response, ", ".join(filetypes)) - ) - continue - if not os.path.isfile(response): - print("File '%s' not found, please try again" % response) - continue + while True: + response = prompt_user( + self.non_interactive, + input_data["question"], + default=input_data.get("default"), + ) + if self.non_interactive: return response - - if "while" not in input_data: - return _one_file() - - result = {_one_file()} - - while prompt_user_yesno( - self.non_interactive, input_data["while"], default="no" - ): - result.add(_one_file()) - return list(result) + if filetypes and not any( + response.endswith(filetype) for filetype in filetypes + ): + print( + "'%s' does not have one of the accepted file extensions (%s), please try again" + % (response, ", ".join(filetypes)) + ) + continue + if not os.path.isfile(response): + print("File '%s' not found, please try again" % response) + continue + return response + + def _input_subtype(subtype): + if subtype["type"] == "string": + return _input_string(subtype) + if subtype["type"] == "string-multiline": + return _input_multiline_string(subtype) + if subtype["type"] == "file": + return _input_file(subtype) + raise CFBSExitError( + "Subtype of type '%s' not supported for type list" % subtype["type"] + ) def _input_elements(subtype): result = OrderedDict() for element in subtype: _check_keys(["type", "label", "question", "key"], element) - if element["type"] != "string": - raise CFBSExitError( - "Subtype of type '%s' not supported for type list" - % element["type"] - ) - result[element["key"]] = _input_string(element) + result[element["key"]] = _input_subtype(element) return result def _input_list(input_data): @@ -641,16 +635,11 @@ def _input_list(input_data): elif isinstance(subtype, dict): _check_keys(["type", "label", "question"], subtype) - if subtype["type"] != "string": - raise CFBSExitError( - "Subtype of type '%s' not supported for type list" - % subtype["type"] - ) - result = [_input_string(subtype)] + result = [_input_subtype(subtype)] while prompt_user_yesno( self.non_interactive, input_data["while"], default="no" ): - result.append(_input_string(subtype)) + result.append(_input_subtype(subtype)) return result raise CFBSExitError( "Expected the value of attribute 'subtype' to be a JSON list or object, not: %s" diff --git a/cfbs/commands.py b/cfbs/commands.py index 278c476b..21a66921 100644 --- a/cfbs/commands.py +++ b/cfbs/commands.py @@ -130,6 +130,7 @@ def search_command(terms: List[str]): from cfbs.git_magic import commit_after_command, git_commit_maybe_prompt from cfbs.prompts import prompt_user, prompt_user_yesno from cfbs.module import Module, is_module_absolute, is_module_added_manually +from cfbs.module_input import map_file_responses _MODULES_URL = "https://archive.build.cfengine.com/modules" @@ -1821,9 +1822,7 @@ def _place_file_input(module_name, input_data): A file already inside the project is left where it is and simply referred to. A file from outside the project is copied into the module's directory, next to its input.json, and the response is - updated to point at that copy. A "file" input using "while" to collect - multiple files has a list of paths as its response, each handled the - same way. + updated to point at that copy. Returns the list of paths that were copied into the project, so they can be committed alongside input.json. @@ -1850,14 +1849,7 @@ def _place(path): copied_files.append(dest) return dest - for definition in input_data: - if definition.get("type") != "file": - continue - response = definition.get("response") - if isinstance(response, list): - definition["response"] = [_place(path) for path in response] - else: - definition["response"] = _place(response) + map_file_responses(input_data, _place) return copied_files diff --git a/cfbs/module_input.py b/cfbs/module_input.py new file mode 100644 index 00000000..46453acc --- /dev/null +++ b/cfbs/module_input.py @@ -0,0 +1,55 @@ +""" +Helpers for working with module input definitions and their responses. +""" + + +def _is_file(definition): + return isinstance(definition, dict) and definition.get("type") == "file" + + +def _file_keys(subtype): + """The keys of a keyed list "subtype" which hold file paths""" + return [part["key"] for part in subtype if _is_file(part) and "key" in part] + + +def _transform_keys(entry, keys, transform): + """Transform the given keys of a single response object""" + if not isinstance(entry, dict): + return + for key in keys: + if key in entry: + entry[key] = transform(entry[key]) + + +def map_file_responses(input_data, transform): + """Apply transform to every file path in a module's input responses. + + Covers a top level "file" input and a "list" input with a "file" subtype. + Responses are rewritten in place, and anything which isn't a file path is + left alone. + """ + if not isinstance(input_data, list): + return + + for element in input_data: + if not isinstance(element, dict) or "response" not in element: + # Without a response there is nothing to map + continue + response = element["response"] + + if _is_file(element): + # Single file + element["response"] = transform(response) + + elif element.get("type") == "list" and isinstance(response, list): + subtype = element.get("subtype") + + if _is_file(subtype): + # List of files + element["response"] = [transform(path) for path in response] + + elif isinstance(subtype, list): + # List of objects where one or more keys are files + keys = _file_keys(subtype) + for entry in response: + _transform_keys(entry, keys, transform) diff --git a/cfbs/validate.py b/cfbs/validate.py index 7ff6cc8c..4fedd7aa 100644 --- a/cfbs/validate.py +++ b/cfbs/validate.py @@ -649,6 +649,26 @@ def _validate_module_url_field(name, module, field): raise CFBSValidationError(name, '"%s" must be an HTTPS URL' % field) +def _validate_input_filetype(name, input_element): + if "filetype" not in input_element: + return + filetype = input_element["filetype"] + filetypes = filetype if type(filetype) is list else [filetype] + if not filetypes: + raise CFBSValidationError( + name, + 'The "filetype" field of a "file" input element must be a non-empty file extension, or a non-empty list of them, not "%s"' + % filetype, + ) + for part in filetypes: + if type(part) is not str or not part.strip() or not part.startswith("."): + raise CFBSValidationError( + name, + 'The "filetype" field of a "file" input element must consist of file extensions starting with ".", not "%s"' + % part, + ) + + def _validate_module_input(name, module): assert "input" in module if type(module["input"]) is not list or not module["input"]: @@ -764,42 +784,17 @@ def _validate_module_input(name, module): name, 'When using module input with type list, and subtype includes multiple values, "key" is required to distinguish them', ) - if part["type"] != "string": + if part["type"] not in ("string", "string-multiline", "file"): raise CFBSValidationError( name, - 'Only "string" supported for the "type" of module input list elements, not "%s"' + 'Only "string", "string-multiline" and "file" are supported for the "type" of module input list elements, not "%s"' % part["type"], ) + if part["type"] == "file": + _validate_input_filetype(name, part) if input_element["type"] == "file": - if "filetype" in input_element: - filetype = input_element["filetype"] - filetypes = filetype if type(filetype) is list else [filetype] - if not filetypes: - raise CFBSValidationError( - name, - 'The "filetype" field of a "file" input element must be a non-empty file extension, or a non-empty list of them, not "%s"' - % filetype, - ) - for part in filetypes: - if ( - type(part) is not str - or not part.strip() - or not part.startswith(".") - ): - raise CFBSValidationError( - name, - 'The "filetype" field of a "file" input element must consist of file extensions starting with ".", not "%s"' - % part, - ) - if "while" in input_element and ( - type(input_element["while"]) is not str - or not input_element["while"].strip() - ): - raise CFBSValidationError( - name, - 'The "while" prompt in an input "file" element must be a non-empty / non-whitespace string', - ) + _validate_input_filetype(name, input_element) def _compare_dict(a, b, ignore=None): diff --git a/tests/shell/060_input_file.sh b/tests/shell/060_input_file.sh index f7014ddc..72c0326f 100644 --- a/tests/shell/060_input_file.sh +++ b/tests/shell/060_input_file.sh @@ -33,10 +33,11 @@ grep '"response": "./source.txt"' copy-a-file/input.json cfbs render-input copy-a-file copy-a-file/input.json actual.output diff actual.output ../shell/060_input_file/expected-augment.json -# A "file" input with a "while" prompt must let the user supply multiple -# files. Files from outside the project must be copied into the module's -# directory, next to input.json, and "response" updated to a list of the -# (possibly localized) paths: +# A "list" input with a "file" subtype must let the user supply multiple +# files, the "while" prompt of the list asking for each one after the first. +# Files from outside the project must be copied into the module's directory, +# next to input.json, and "response" updated to a list of the (possibly +# localized) paths: echo "echo one" > /tmp/one.sh echo "echo two" > /tmp/two.sh printf '/tmp/one.sh\nyes\n/tmp/two.sh\nno\n' | cfbs input run-scripts diff --git a/tests/shell/060_input_file/example-cfbs.json b/tests/shell/060_input_file/example-cfbs.json index 7cf647e6..ca447485 100644 --- a/tests/shell/060_input_file/example-cfbs.json +++ b/tests/shell/060_input_file/example-cfbs.json @@ -27,14 +27,18 @@ "steps": ["input ./input.json def.json"], "input": [ { - "type": "file", + "type": "list", "variable": "scripts", "namespace": "cfbs", "bundle": "run_scripts", - "label": "Script", - "question": "Which script should be run?", - "while": "Do you want to add another script?", - "filetype": ".sh" + "label": "Scripts", + "subtype": { + "type": "file", + "label": "Script", + "question": "Which script should be run?", + "filetype": ".sh" + }, + "while": "Do you want to add another script?" } ] }, diff --git a/tests/shell/062_input_file_in_list_with_keys.sh b/tests/shell/062_input_file_in_list_with_keys.sh new file mode 100644 index 00000000..7d32d21d --- /dev/null +++ b/tests/shell/062_input_file_in_list_with_keys.sh @@ -0,0 +1,56 @@ +set -e +set -x +cd tests/ +mkdir -p ./tmp/ +cd ./tmp/ +rm -f cfbs.json +rm -rf .git +rm -rf copy-files +cp ../shell/062_input_file_in_list_with_keys/example-cfbs.json cfbs.json + +# Keep the files outside the project, so that they get copied into it: +srcdir=$(mktemp -d) +cleanup() { + rm -rf "$srcdir" +} +trap cleanup EXIT QUIT TERM + +echo "one" > "$srcdir/one.txt" +echo "two" > "$srcdir/two.txt" +echo "notes" > "$srcdir/notes.md" + +# A "file" among the keys of a list "subtype" must be an acceptable +# input definition: +cfbs validate + +# The "file" key must apply the same checks as a top level "file" input, +# rejecting a path whose extension isn't accepted and a path that doesn't +# exist, while the "string" keys beside it accept whatever is typed. The +# answers below are path, owner and mode for each file, then whether to add +# another - the empty answers accept the defaults from the definition: +cfbs input copy-files > actual.output < actual.augment +diff actual.augment ../shell/062_input_file_in_list_with_keys/expected-augment.json + +rm -rf copy-files actual.output actual.augment diff --git a/tests/shell/062_input_file_in_list_with_keys/example-cfbs.json b/tests/shell/062_input_file_in_list_with_keys/example-cfbs.json new file mode 100644 index 00000000..03bd1ba5 --- /dev/null +++ b/tests/shell/062_input_file_in_list_with_keys/example-cfbs.json @@ -0,0 +1,46 @@ +{ + "name": "Example", + "type": "policy-set", + "description": "Example description", + "git": false, + "build": [ + { + "name": "copy-files", + "description": "Copy files, with options per file.", + "steps": ["input ./input.json def.json"], + "input": [ + { + "type": "list", + "variable": "files", + "namespace": "cfbs", + "bundle": "copy_files", + "label": "Files", + "subtype": [ + { + "key": "path", + "type": "file", + "label": "Path", + "question": "Which file should be copied?", + "filetype": [".txt", ".log"] + }, + { + "key": "owner", + "type": "string", + "label": "Owner", + "question": "Who should own the file?", + "default": "root" + }, + { + "key": "mode", + "type": "string", + "label": "Mode", + "question": "Which mode should the file have?", + "default": "0644" + } + ], + "while": "Do you want to copy another file?" + } + ] + } + ] +} diff --git a/tests/shell/062_input_file_in_list_with_keys/expected-augment.json b/tests/shell/062_input_file_in_list_with_keys/expected-augment.json new file mode 100644 index 00000000..79ea6314 --- /dev/null +++ b/tests/shell/062_input_file_in_list_with_keys/expected-augment.json @@ -0,0 +1,11 @@ +{ + "variables": { + "cfbs:copy_files.files": { + "value": [ + { "path": "./copy-files/one.txt", "owner": "alice", "mode": "0600" }, + { "path": "./copy-files/two.txt", "owner": "root", "mode": "0644" } + ], + "comment": "Added by 'cfbs input'" + } + } +} diff --git a/tests/shell/063_input_string_multiline_in_list.sh b/tests/shell/063_input_string_multiline_in_list.sh new file mode 100644 index 00000000..b594598d --- /dev/null +++ b/tests/shell/063_input_string_multiline_in_list.sh @@ -0,0 +1,31 @@ +set -e +set -x +cd tests/ +mkdir -p ./tmp/ +cd ./tmp/ +rm -f cfbs.json +rm -rf .git +rm -rf write-notes +cp ../shell/063_input_string_multiline_in_list/example-cfbs.json cfbs.json + +# A "string-multiline" must be an acceptable "subtype" of a "list" input: +cfbs validate + +# Each note is read until a double newline, and the "while" prompt of the +# list then asks whether to write another: +cfbs input write-notes > actual.output < actual.augment +diff actual.augment ../shell/063_input_string_multiline_in_list/expected-augment.json + +rm -rf write-notes actual.output actual.augment diff --git a/tests/shell/063_input_string_multiline_in_list/example-cfbs.json b/tests/shell/063_input_string_multiline_in_list/example-cfbs.json new file mode 100644 index 00000000..353c07ce --- /dev/null +++ b/tests/shell/063_input_string_multiline_in_list/example-cfbs.json @@ -0,0 +1,28 @@ +{ + "name": "Example", + "type": "policy-set", + "description": "Example description", + "git": false, + "build": [ + { + "name": "write-notes", + "description": "Write notes.", + "steps": ["input ./input.json def.json"], + "input": [ + { + "type": "list", + "variable": "notes", + "namespace": "cfbs", + "bundle": "write_notes", + "label": "Notes", + "subtype": { + "type": "string-multiline", + "label": "Note", + "question": "What should the note say?" + }, + "while": "Do you want to write another note?" + } + ] + } + ] +} diff --git a/tests/shell/063_input_string_multiline_in_list/expected-augment.json b/tests/shell/063_input_string_multiline_in_list/expected-augment.json new file mode 100644 index 00000000..87987f1b --- /dev/null +++ b/tests/shell/063_input_string_multiline_in_list/expected-augment.json @@ -0,0 +1,8 @@ +{ + "variables": { + "cfbs:write_notes.notes": { + "value": ["Hello CFEngine!\nBye CFEngine!\n", "Just one line\n"], + "comment": "Added by 'cfbs input'" + } + } +} diff --git a/tests/shell/all.sh b/tests/shell/all.sh index b59f7176..7abe3e27 100644 --- a/tests/shell/all.sh +++ b/tests/shell/all.sh @@ -105,6 +105,8 @@ run_test tests/shell/058_render_input_fail.sh run_test tests/shell/059_input_string_multiline.sh run_test tests/shell/060_input_file.sh run_test tests/shell/061_set_input_file.sh +run_test tests/shell/062_input_file_in_list_with_keys.sh +run_test tests/shell/063_input_string_multiline_in_list.sh # Summary _suite_end=$(date +%s) diff --git a/tests/test_build.py b/tests/test_build.py index 56389929..bcefbc6a 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -36,8 +36,14 @@ def test_localize_file_inputs_copies_list_of_files(tmp_path, monkeypatch): input_data = [ { - "type": "file", + "type": "list", "variable": "scripts", + "subtype": { + "type": "file", + "label": "Script", + "question": "Which script should be run?", + }, + "while": "Do you want to add another script?", "response": ["one.sh", "run-scripts-module/two.sh"], } ] diff --git a/tests/test_module_input.py b/tests/test_module_input.py new file mode 100644 index 00000000..585a25ba --- /dev/null +++ b/tests/test_module_input.py @@ -0,0 +1,142 @@ +from cfbs.module_input import map_file_responses + + +def _localize(path): + """Stand-in for what the real callers do to each path they're given""" + return "$(sys.inputdir)/" + path + + +def test_map_file_responses_top_level_file(): + """A "file" input asks for one file, so its response is a single path""" + input_data = [ + { + "type": "file", + "variable": "script", + "label": "Script", + "question": "Which script should be run?", + "response": "./run-scripts/deploy.sh", + } + ] + + map_file_responses(input_data, _localize) + + assert input_data[0]["response"] == "$(sys.inputdir)/./run-scripts/deploy.sh" + + +def test_map_file_responses_list_of_files(): + """A "list" whose "subtype" is a lone "file" has a list of paths""" + input_data = [ + { + "type": "list", + "variable": "scripts", + "label": "Scripts", + "subtype": { + "type": "file", + "label": "Script", + "question": "Which script should be run?", + }, + "while": "Do you want to add another script?", + "response": ["./run-scripts/deploy.sh", "./run-scripts/rollback.sh"], + } + ] + + map_file_responses(input_data, _localize) + + assert input_data[0]["response"] == [ + "$(sys.inputdir)/./run-scripts/deploy.sh", + "$(sys.inputdir)/./run-scripts/rollback.sh", + ] + + +def test_map_file_responses_list_of_objects(): + """With a keyed "subtype", each response is an object, and only the keys + which are files are paths - the rest are values the user typed""" + input_data = [ + { + "type": "list", + "variable": "playbooks", + "label": "Playbooks", + "subtype": [ + { + "key": "path", + "type": "file", + "label": "Path", + "question": "Which playbook should be run?", + }, + { + "key": "condition", + "type": "string", + "label": "Condition", + "question": "Condition for when to run", + }, + ], + "while": "Do you want to specify more playbooks to be run?", + "response": [ + {"path": "./playbooks/one.yaml", "condition": "linux"}, + {"path": "./playbooks/two.yaml", "condition": "any"}, + ], + } + ] + + map_file_responses(input_data, _localize) + + assert input_data[0]["response"] == [ + {"path": "$(sys.inputdir)/./playbooks/one.yaml", "condition": "linux"}, + {"path": "$(sys.inputdir)/./playbooks/two.yaml", "condition": "any"}, + ] + + +def test_map_file_responses_ignores_strings(): + """Responses to the other input types are values, not paths""" + input_data = [ + {"type": "string", "variable": "filename", "response": "/tmp/foo.txt"}, + {"type": "string-multiline", "variable": "content", "response": "line\nline"}, + { + "type": "list", + "variable": "files", + "subtype": {"type": "string", "label": "Path", "question": "Path?"}, + "while": "Another?", + "response": ["/tmp/one.txt", "/tmp/two.txt"], + }, + { + "type": "list", + "variable": "packages", + "subtype": [ + {"key": "name", "type": "string", "label": "Name", "question": "Name?"}, + ], + "while": "Another?", + "response": [{"name": "curl"}], + }, + ] + before = [dict(element) for element in input_data] + + map_file_responses(input_data, _localize) + + assert input_data == before + + +def test_map_file_responses_ignores_malformed_data(): + """During a build the input data is whatever is in the module's input.json, + which nothing checks against the module's input definition""" + map_file_responses(None, _localize) + map_file_responses("not a list", _localize) + map_file_responses([None, "not an element"], _localize) + + input_data = [ + { + "type": "list", + "variable": "playbooks", + "subtype": [{"key": "path", "type": "file"}], + "response": ["not an object"], + } + ] + map_file_responses(input_data, _localize) + assert input_data[0]["response"] == ["not an object"] + + +def test_map_file_responses_leaves_unanswered_input_alone(): + input_data = [{"type": "file", "variable": "script", "label": "Script"}] + + map_file_responses(input_data, _localize) + + assert input_data == [{"type": "file", "variable": "script", "label": "Script"}] diff --git a/tests/test_validate.py b/tests/test_validate.py index c6ce62df..c072eb45 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -1,7 +1,11 @@ import pytest from cfbs.utils import CFBSValidationError -from cfbs.validate import input_data_matches_spec, validate_module_name_content +from cfbs.validate import ( + _validate_module_input, + input_data_matches_spec, + validate_module_name_content, +) def test_validate_module_name_content(): @@ -278,3 +282,120 @@ def test_input_data_matches_spec_not_lists(): # And so does the input definition: assert not input_data_matches_spec(0, spec) assert not input_data_matches_spec({}, spec) + + +def _module_with_subtype(subtype): + return { + "input": [ + { + "type": "list", + "variable": "files", + "namespace": "cfbs", + "bundle": "copy_files", + "label": "Files", + "subtype": subtype, + "while": "Do you want to copy another file?", + } + ] + } + + +def test_validate_module_input_string_multiline_subtype(): + """A "string-multiline" is accepted as a list "subtype" too""" + _validate_module_input( + "write-notes", + _module_with_subtype( + { + "type": "string-multiline", + "label": "Note", + "question": "What should the note say?", + } + ), + ) + _validate_module_input( + "write-notes", + _module_with_subtype( + [ + { + "key": "name", + "type": "string", + "label": "Name", + "question": "What should the note be called?", + }, + { + "key": "content", + "type": "string-multiline", + "label": "Note", + "question": "What should the note say?", + }, + ] + ), + ) + + +def test_validate_module_input_file_subtype(): + """A "file" is accepted as a list "subtype", alone or among other keys""" + _validate_module_input( + "copy-files", + _module_with_subtype( + { + "type": "file", + "label": "Path", + "question": "Which file should be copied?", + "filetype": [".txt", ".log"], + } + ), + ) + _validate_module_input( + "copy-files", + _module_with_subtype( + [ + { + "key": "path", + "type": "file", + "label": "Path", + "question": "Which file should be copied?", + "filetype": ".txt", + }, + { + "key": "owner", + "type": "string", + "label": "Owner", + "question": "Who should own the file?", + }, + ] + ), + ) + + +def test_validate_module_input_subtype_filetype(): + """A nested "file" has its "filetype" checked like a top level one""" + for filetype in ("txt", "", " ", [], [".txt", "log"], [None]): + with pytest.raises(CFBSValidationError, match="filetype"): + _validate_module_input( + "copy-files", + _module_with_subtype( + { + "type": "file", + "label": "Path", + "question": "Which file should be copied?", + "filetype": filetype, + } + ), + ) + + +def test_validate_module_input_unsupported_subtype(): + """A list "subtype" cannot consist of just any input type""" + for subtype_type in ("list", "blah"): + with pytest.raises(CFBSValidationError): + _validate_module_input( + "copy-files", + _module_with_subtype( + { + "type": subtype_type, + "label": "Path", + "question": "Which file should be copied?", + } + ), + )