diff --git a/examples/README.md b/examples/README.md index f1fe4fe7a..76834090c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -184,6 +184,8 @@ Putting it altogether, let's prepare and launch a task featuring a form containi mephisto review_app -h 0.0.0.0 -p 8000 -d True -f True ``` +_Note: if a package build was terminated/failed, or related source code was changed, FormComposer needs to be rebuilt with this command: `mephisto scripts form_composer rebuild_all_apps`._ + --- # Your Mephisto project diff --git a/mephisto/client/cli.py b/mephisto/client/cli.py index 0d575050d..d315d30e6 100644 --- a/mephisto/client/cli.py +++ b/mephisto/client/cli.py @@ -28,6 +28,7 @@ import mephisto.scripts.mturk.launch_makeup_hits as launch_makeup_hits_mturk import mephisto.scripts.mturk.print_outstanding_hit_status as print_outstanding_hit_status_mturk import mephisto.scripts.mturk.print_outstanding_hit_status as soft_block_workers_by_mturk_id_mturk +import mephisto.scripts.form_composer.rebuild_all_apps as rebuild_all_apps_form_composer from mephisto.client.cli_commands import get_wut_arguments from mephisto.generators.form_composer.config_validation.task_data_config import ( create_extrapolated_config @@ -223,7 +224,7 @@ def print_non_markdown_list(items: List[str]): res += "\n * " + item return res - VALID_SCRIPT_TYPES = ["local_db", "heroku", "metrics", "mturk"] + VALID_SCRIPT_TYPES = ["local_db", "heroku", "metrics", "mturk", "form_composer"] if script_type is None or script_type.strip() not in VALID_SCRIPT_TYPES: print("") raise click.UsageError( @@ -247,6 +248,9 @@ def print_non_markdown_list(items: List[str]): "print_outstanding_hit_status", "soft_block_workers_by_mturk_id", ] + FORM_COMPOSER_VALID_SCRIPTS_NAMES = [ + "rebuild_all_apps", + ] script_type_to_scripts_data = { "local_db": { "valid_script_names": LOCAL_DB_VALID_SCRIPTS_NAMES, @@ -275,10 +279,16 @@ def print_non_markdown_list(items: List[str]): MTURK_VALID_SCRIPTS_NAMES[0]: cleanup_mturk.main, MTURK_VALID_SCRIPTS_NAMES[1]: identify_broken_units_mturk.main, MTURK_VALID_SCRIPTS_NAMES[2]: launch_makeup_hits_mturk.main, - MTURK_VALID_SCRIPTS_NAMES[3]: print_outstanding_hit_status_mturk.main, + MTURK_VALID_SCRIPTS_NAMES[3]: rebuild_all_apps_form_composer.main, MTURK_VALID_SCRIPTS_NAMES[4]: soft_block_workers_by_mturk_id_mturk.main, }, }, + "form_composer": { + "valid_script_names": FORM_COMPOSER_VALID_SCRIPTS_NAMES, + "scripts": { + FORM_COMPOSER_VALID_SCRIPTS_NAMES[0]: rebuild_all_apps_form_composer.main, + }, + }, } if script_name is None or ( diff --git a/mephisto/generators/form_composer/config_validation/form_config.py b/mephisto/generators/form_composer/config_validation/form_config.py index 2a5eccca0..0f3a252a0 100644 --- a/mephisto/generators/form_composer/config_validation/form_config.py +++ b/mephisto/generators/form_composer/config_validation/form_config.py @@ -54,15 +54,15 @@ def _duplicate_values_exist(unique_names: UniqueAttrsType, errors: List[str]) -> return is_valid -def validate_form_config(config_json: dict) -> Tuple[bool, List[str]]: +def validate_form_config(config_data: dict) -> Tuple[bool, List[str]]: is_valid = True errors = [] - if not isinstance(config_json, dict): + if not isinstance(config_data, dict): is_valid = False errors.append("Form config must be a key/value JSON Object.") - elif config_json.keys() != AVAILABLE_CONFIG_ATTRS.keys(): + elif config_data.keys() != AVAILABLE_CONFIG_ATTRS.keys(): is_valid = False errors.append( f"Form config must contain only these attributes: " @@ -77,10 +77,10 @@ def validate_form_config(config_json: dict) -> Tuple[bool, List[str]]: unique_names: UniqueAttrsType = {} # Add main config level - items_to_validate.append((config_json, "Config", AVAILABLE_CONFIG_ATTRS)) + items_to_validate.append((config_data, "Config", AVAILABLE_CONFIG_ATTRS)) # Add form - form = config_json["form"] + form = config_data["form"] items_to_validate.append((form, "form", AVAILABLE_FORM_ATTRS)) _collect_values_for_unique_attrs_from_item(form, unique_names) diff --git a/mephisto/generators/form_composer/config_validation/separate_token_values_config.py b/mephisto/generators/form_composer/config_validation/separate_token_values_config.py index 227543ac7..3cbd3444a 100644 --- a/mephisto/generators/form_composer/config_validation/separate_token_values_config.py +++ b/mephisto/generators/form_composer/config_validation/separate_token_values_config.py @@ -11,6 +11,7 @@ from botocore.exceptions import BotoCoreError from botocore.exceptions import ClientError from botocore.exceptions import NoCredentialsError +from rich import print from mephisto.generators.form_composer.constants import TOKEN_END_SYMBOLS from mephisto.generators.form_composer.constants import TOKEN_START_SYMBOLS @@ -22,17 +23,17 @@ def validate_separate_token_values_config( - config_json: Dict[str, List[str]], + config_data: Dict[str, List[str]], ) -> Tuple[bool, List[str]]: is_valid = True errors = [] - if not isinstance(config_json, dict): + if not isinstance(config_data, dict): is_valid = False errors.append("Config must be a key/value JSON Object.") return is_valid, errors - for i, token_values in enumerate(config_json.items()): + for i, token_values in enumerate(config_data.items()): token, values = token_values if not values: @@ -48,7 +49,7 @@ def validate_separate_token_values_config( def update_separate_token_values_config_with_file_urls( url: str, separate_token_values_config_path: str, - use_presigned_urls: bool, + use_presigned_urls: bool = False, ): try: files_locations = get_file_urls_from_s3_storage(url) diff --git a/mephisto/generators/form_composer/config_validation/task_data_config.py b/mephisto/generators/form_composer/config_validation/task_data_config.py index 9a12a9fd2..5cce278a7 100644 --- a/mephisto/generators/form_composer/config_validation/task_data_config.py +++ b/mephisto/generators/form_composer/config_validation/task_data_config.py @@ -56,9 +56,15 @@ def _set_tokens_in_form_config_item(item: dict, tokens_values: dict): def _collect_form_config_items_to_extrapolate(config_data: dict) -> List[dict]: items_to_extrapolate = [] + if not isinstance(config_data, dict): + return items_to_extrapolate + form = config_data["form"] items_to_extrapolate.append(form) + submit_button = form["submit_button"] + items_to_extrapolate.append(submit_button) + sections = form["sections"] for section in sections: items_to_extrapolate.append(section) @@ -125,7 +131,7 @@ def _extrapolate_tokens_in_form_config(config_data: dict, tokens_values: dict) - def _validate_tokens_in_both_configs( - form_config_data, token_sets_values_config_data, + form_config_data: dict, token_sets_values_config_data: List[dict], ) -> Tuple[set, set, list]: tokens_from_form_config, tokens_in_unexpected_attrs_errors = ( _collect_tokens_from_form_config(form_config_data) @@ -245,21 +251,21 @@ def create_extrapolated_config( exit() -def validate_task_data_config(config_json: List[dict]) -> Tuple[bool, List[str]]: +def validate_task_data_config(config_data: List[dict]) -> Tuple[bool, List[str]]: is_valid = True errors = [] - if not isinstance(config_json, list): + if not isinstance(config_data, list): is_valid = False errors.append("Config must be a JSON Array.") - if config_json: - if not all(config_json): + if config_data: + if not all(config_data): is_valid = False errors.append("Task data config must contain at least one non-empty item.") # Validate each form version contained in task data config - for item in config_json: + for item in config_data: form_config_is_valid, form_config_errors = validate_form_config(item) if not form_config_is_valid: is_valid = False @@ -375,11 +381,13 @@ def verify_form_composer_configs( print(f"\n[red]Provided Form Composer config files are invalid:[/red] {e}\n") -def prepare_task_config_for_review_app(config: dict) -> dict: - config = deepcopy(config) +def prepare_task_config_for_review_app(config_data: dict) -> dict: + config_data = deepcopy(config_data) procedure_code_regex = r"\s*(.+?)\s*" - tokens_from_inputs, _ = _collect_tokens_from_form_config(config, regex=procedure_code_regex) + tokens_from_inputs, _ = _collect_tokens_from_form_config( + config_data, regex=procedure_code_regex, + ) url_from_rpocedure_code_regex = r"\(\"(.+?)\"\)" token_values = {} @@ -396,5 +404,5 @@ def prepare_task_config_for_review_app(config: dict) -> dict: presigned_url = get_s3_presigned_url(url, S3_URL_EXPIRATION_MINUTES_MAX) token_values[token] = presigned_url - prepared_config = _extrapolate_tokens_in_form_config(config, token_values) + prepared_config = _extrapolate_tokens_in_form_config(config_data, token_values) return prepared_config diff --git a/mephisto/generators/form_composer/constants.py b/mephisto/generators/form_composer/constants.py index 15174e83d..a3b12a2a8 100644 --- a/mephisto/generators/form_composer/constants.py +++ b/mephisto/generators/form_composer/constants.py @@ -7,22 +7,22 @@ CONTENTTYPE_BY_EXTENSION = { # Docs - 'csv': 'text/csv', - 'doc': 'application/msword', - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'pdf': 'application/pdf', + "csv": "text/csv", + "doc": "application/msword", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "pdf": "application/pdf", # Images - 'bmp': 'image/bmp', - 'gif': 'image/gif', - 'heic': 'image/heic', - 'heif': 'image/heif', - 'jpeg': 'image/jpeg', - 'jpg': 'image/jpeg', - 'png': 'image/png', + "bmp": "image/bmp", + "gif": "image/gif", + "heic": "image/heic", + "heif": "image/heif", + "jpeg": "image/jpeg", + "jpg": "image/jpeg", + "png": "image/png", # Videos - 'mkv': 'video/x-matroska', - 'mp4': 'video/mp4', - 'webm': 'video/webm', + "mkv": "video/x-matroska", + "mp4": "video/mp4", + "webm": "video/webm", } JSON_IDENTATION = 2 diff --git a/mephisto/review_app/server/api/views/unit_data_static_view.py b/mephisto/review_app/server/api/views/unit_data_static_view.py index bb4a244a3..095b9c0fd 100644 --- a/mephisto/review_app/server/api/views/unit_data_static_view.py +++ b/mephisto/review_app/server/api/views/unit_data_static_view.py @@ -10,6 +10,7 @@ from flask import Response from flask import send_from_directory from flask.views import MethodView +from werkzeug.exceptions import NotFound from mephisto.data_model.agent import Agent from mephisto.data_model.unit import Unit @@ -55,5 +56,10 @@ def get( if filename_by_original_name: filename = filename_by_original_name - unit_data_folder = unit.get_assigned_agent().get_data_dir() + agent = unit.get_assigned_agent() + if not agent: + app.logger.debug(f"No agent found for {unit}") + raise NotFound("File not found") + + unit_data_folder = agent.get_data_dir() return send_from_directory(unit_data_folder, filename) diff --git a/mephisto/review_app/server/api/views/units_details_view.py b/mephisto/review_app/server/api/views/units_details_view.py index fefc7f7f8..2f4f5096c 100644 --- a/mephisto/review_app/server/api/views/units_details_view.py +++ b/mephisto/review_app/server/api/views/units_details_view.py @@ -55,8 +55,8 @@ def get(self) -> dict: task_run: TaskRun = unit.get_task_run() has_task_source_review = bool(task_run.args.get("blueprint").get("task_source_review")) - inputs = unit_data.get("data", {}).get("inputs") - outputs = unit_data.get("data", {}).get("outputs") + inputs = unit_data.get("data", {}).get("inputs", {}) + outputs = unit_data.get("data", {}).get("outputs", {}) # In case if there is outdated code that returns `final_submission` # under `inputs` and `outputs` keys, we should use the value in side `final_submission` @@ -67,9 +67,12 @@ def get(self) -> dict: # Perform any dynamic action on task config for current unit # to make it the same as it looked like for a worker - prepared_inputs = prepare_task_config_for_review_app(inputs) + prepared_inputs = inputs + if "form" in inputs: + prepared_inputs = prepare_task_config_for_review_app(inputs) - unit_data_folder = unit.get_assigned_agent().get_data_dir() + agent = unit.get_assigned_agent() + unit_data_folder = agent.get_data_dir() if agent else None units.append( { diff --git a/mephisto/scripts/form_composer/__init__.py b/mephisto/scripts/form_composer/__init__.py new file mode 100644 index 000000000..d8a536944 --- /dev/null +++ b/mephisto/scripts/form_composer/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. diff --git a/mephisto/scripts/form_composer/rebuild_all_apps.py b/mephisto/scripts/form_composer/rebuild_all_apps.py new file mode 100644 index 000000000..73722b44f --- /dev/null +++ b/mephisto/scripts/form_composer/rebuild_all_apps.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +""" +Script for an easy rebuild of all FormComposer-related apps/packages (`build` and `node_modules`): + - examples + - generators + - Review App + - react packages + +This is needed due to inter-dependency of Mephisto's rapidly evolving JS components. + +To run this command: + mephisto scripts form_composer rebuild_all_apps +""" + +import os +import shutil + +from rich import print + +from mephisto.tools.scripts import build_custom_bundle + + +def _clean_examples_form_composer_demo(repo_path: str): + webapp_path = os.path.join( + repo_path, "examples", "form_composer_demo", "webapp", + ) + print(f"[blue]Cleaning '{webapp_path}'[/blue]") + build_path = os.path.join(webapp_path, "build") + node_modules_path = os.path.join(webapp_path, "node_modules") + shutil.rmtree(build_path, ignore_errors=True) + shutil.rmtree(node_modules_path, ignore_errors=True) + + +def _clean_generators_form_composer(repo_path: str): + webapp_path = os.path.join( + repo_path, "mephisto", "generators", "form_composer", "webapp", + ) + print(f"[blue]Cleaning '{webapp_path}'[/blue]") + build_path = os.path.join(webapp_path, "build") + node_modules_path = os.path.join(webapp_path, "node_modules") + shutil.rmtree(build_path, ignore_errors=True) + shutil.rmtree(node_modules_path, ignore_errors=True) + + +def _clean_review_app(repo_path: str): + webapp_path = os.path.join( + repo_path, "mephisto", "review_app", "client", + ) + print(f"[blue]Cleaning '{webapp_path}'[/blue]") + build_path = os.path.join(webapp_path, "build") + node_modules_path = os.path.join(webapp_path, "node_modules") + shutil.rmtree(build_path, ignore_errors=True) + shutil.rmtree(node_modules_path, ignore_errors=True) + + +def _clean_packages_mephisto_task_multipart(repo_path: str): + webapp_path = os.path.join( + repo_path, "packages", "mephisto-task-multipart", + ) + print(f"[blue]Cleaning '{webapp_path}'[/blue]") + build_path = os.path.join(webapp_path, "build") + node_modules_path = os.path.join(webapp_path, "node_modules") + shutil.rmtree(build_path, ignore_errors=True) + shutil.rmtree(node_modules_path, ignore_errors=True) + + +def _clean_packages_react_form_composer(repo_path: str): + webapp_path = os.path.join( + repo_path, "packages", "react-form-composer", + ) + print(f"[blue]Cleaning '{webapp_path}'[/blue]") + build_path = os.path.join(webapp_path, "build") + node_modules_path = os.path.join(webapp_path, "node_modules") + shutil.rmtree(build_path, ignore_errors=True) + shutil.rmtree(node_modules_path, ignore_errors=True) + + +def _clean(repo_path: str): + print("[blue]Started cleaning up all `build` and `node_modules` directories[/blue]") + _clean_examples_form_composer_demo(repo_path) + _clean_generators_form_composer(repo_path) + _clean_review_app(repo_path) + _clean_packages_mephisto_task_multipart(repo_path) + _clean_packages_react_form_composer(repo_path) + print( + "[green]" + "Finished cleaning up all `build` and `node_modules` directories successfully!" + "[/green]" + ) + + +def _build_examples_form_composer_demo(repo_path: str): + webapp_path = os.path.join(repo_path, "examples", "form_composer_demo") + print(f"[blue]Building '{webapp_path}'[/blue]") + # Build Review UI for the application + build_custom_bundle( + webapp_path, + force_rebuild=True, + webapp_name="webapp", + build_command="build:review", + ) + + # Build Task UI for the application + build_custom_bundle( + webapp_path, + force_rebuild=True, + webapp_name="webapp", + build_command="dev", + ) + + +def _build_generators_form_composer(repo_path: str): + webapp_path = os.path.join( + repo_path, "mephisto", "generators", "form_composer", + ) + print(f"[blue]Building '{webapp_path}'[/blue]") + # Build Review UI for the application + build_custom_bundle( + webapp_path, + force_rebuild=True, + webapp_name="webapp", + build_command="build:review", + ) + + # Build Task UI for the application + build_custom_bundle( + webapp_path, + force_rebuild=True, + webapp_name="webapp", + build_command="build", + ) + + +def _build_review_app(repo_path: str): + webapp_path = os.path.join(repo_path, "mephisto", "review_app") + print(f"[blue]Building '{webapp_path}'[/blue]") + build_custom_bundle( + webapp_path, + force_rebuild=True, + webapp_name="client", + build_command="build", + ) + + +def _build_packages_mephisto_task_multipart(repo_path: str): + webapp_path = os.path.join(repo_path, "packages") + print(f"[blue]Building '{webapp_path}'[/blue]") + build_custom_bundle( + webapp_path, + force_rebuild=True, + webapp_name="mephisto-task-multipart", + build_command="build", + ) + + +def _build_packages_react_form_composer(repo_path: str): + webapp_path = os.path.join(repo_path, "packages") + print(f"[blue]Building '{webapp_path}'[/blue]") + build_custom_bundle( + webapp_path, + force_rebuild=True, + webapp_name="react-form-composer", + build_command="build", + ) + + +def _build(repo_path: str): + print("[blue]Started building web apps[/blue]") + _build_packages_mephisto_task_multipart(repo_path) + _build_packages_react_form_composer(repo_path) + _build_examples_form_composer_demo(repo_path) + _build_generators_form_composer(repo_path) + _build_review_app(repo_path) + print("[green]Finished building web apps successfully![/green]") + + +def main(): + repo_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__) + )))) + + _clean(repo_path) + _build(repo_path) + + +if __name__ == "__main__": + main() diff --git a/test/generators/__init__.py b/test/generators/__init__.py new file mode 100644 index 000000000..cfaca7562 --- /dev/null +++ b/test/generators/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. diff --git a/test/generators/form_composer/__init__.py b/test/generators/form_composer/__init__.py new file mode 100644 index 000000000..cfaca7562 --- /dev/null +++ b/test/generators/form_composer/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. diff --git a/test/generators/form_composer/config_validation/__init__.py b/test/generators/form_composer/config_validation/__init__.py new file mode 100644 index 000000000..cfaca7562 --- /dev/null +++ b/test/generators/form_composer/config_validation/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. diff --git a/test/generators/form_composer/config_validation/test_common_validation.py b/test/generators/form_composer/config_validation/test_common_validation.py new file mode 100644 index 000000000..52d59f15f --- /dev/null +++ b/test/generators/form_composer/config_validation/test_common_validation.py @@ -0,0 +1,352 @@ +import unittest + +from mephisto.generators.form_composer.config_validation import config_validation_constants +from mephisto.generators.form_composer.config_validation.common_validation import ( + validate_config_dict_item +) + + +class TestCommonValidation(unittest.TestCase): + # --- Config --- + def test_validate_config_dict_item_config_success(self, *args, **kwargs): + errors = [] + + config_item = { + "form": {}, + } + + result = validate_config_dict_item( + item=config_item, + item_log_name="config", + available_attrs=config_validation_constants.AVAILABLE_CONFIG_ATTRS, + errors=errors, + ) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test_validate_config_dict_item_config_error(self, *args, **kwargs): + errors = [] + + config_item = {} + + result = validate_config_dict_item( + item=config_item, + item_log_name="config", + available_attrs=config_validation_constants.AVAILABLE_CONFIG_ATTRS, + errors=errors, + ) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + "Object `config`. Not all required attributes were specified. " + "Required attributes: form. Passed attributes: ." + ), + ], + ) + + # --- Form --- + def test_validate_config_dict_item_form_success(self, *args, **kwargs): + errors = [] + + form_item = { + "title": "Form title", + "instruction": "Form instruction", + "sections": [], + "submit_button": { + "instruction": "Submit instruction", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + } + + result = validate_config_dict_item( + item=form_item, + item_log_name="form", + available_attrs=config_validation_constants.AVAILABLE_FORM_ATTRS, + errors=errors, + ) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test_validate_config_dict_item_form_error(self, *args, **kwargs): + errors = [] + + form_item = { + "title": True, + "instruction": True, + } + + result = validate_config_dict_item( + item=form_item, + item_log_name="form", + available_attrs=config_validation_constants.AVAILABLE_FORM_ATTRS, + errors=errors, + ) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + 'Object `form`. Not all required attributes were specified. ' + 'Required attributes: sections, submit_button, title. ' + 'Passed attributes: title, instruction.' + ), + 'Attribute `title` in object `form` must be `String`.', + 'Attribute `instruction` in object `form` must be `String`.', + ], + ) + + # --- Submit button --- + def test_validate_config_dict_item_submit_button_success(self, *args, **kwargs): + errors = [] + + submit_button_item = { + "instruction": "Submit instruction", + "text": "Submit text", + "tooltip": "Submit tooltip" + } + + result = validate_config_dict_item( + item=submit_button_item, + item_log_name="submit_button", + available_attrs=config_validation_constants.AVAILABLE_SUBMIT_BUTTON_ATTRS, + errors=errors, + ) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test_validate_config_dict_item_submit_button_error(self, *args, **kwargs): + errors = [] + + submit_button_item = {} + + result = validate_config_dict_item( + item=submit_button_item, + item_log_name="submit_button", + available_attrs=config_validation_constants.AVAILABLE_SUBMIT_BUTTON_ATTRS, + errors=errors, + ) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + "Object `submit_button`. Not all required attributes were specified. " + "Required attributes: text. Passed attributes: ." + ), + ], + ) + + # --- Section --- + def test_validate_config_dict_item_section_success(self, *args, **kwargs): + errors = [] + + section_item = { + "name": "section_name", + "title": "Section title", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [], + } + + result = validate_config_dict_item( + item=section_item, + item_log_name="section", + available_attrs=config_validation_constants.AVAILABLE_SECTION_ATTRS, + errors=errors, + ) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test_validate_config_dict_item_section_error(self, *args, **kwargs): + errors = [] + + section_item = { + "name": True, + "title": True, + } + + result = validate_config_dict_item( + item=section_item, + item_log_name="section", + available_attrs=config_validation_constants.AVAILABLE_SECTION_ATTRS, + errors=errors, + ) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + "Object `section` with name `True`. " + "Not all required attributes were specified. " + "Required attributes: fieldsets, title. Passed attributes: name, title." + ), + "Attribute `name` in object `section` must be `String`.", + "Attribute `title` in object `section` must be `String`.", + ], + ) + + # --- Fieldset --- + def test_validate_config_dict_item_fieldset_success(self, *args, **kwargs): + errors = [] + + fieldset_item = { + "title": "Fieldset title", + "instruction": "Fieldset instruction", + "rows": [], + "help": "Fieldset help", + } + + result = validate_config_dict_item( + item=fieldset_item, + item_log_name="fieldset", + available_attrs=config_validation_constants.AVAILABLE_FIELDSET_ATTRS, + errors=errors, + ) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test_validate_config_dict_item_fieldset_error(self, *args, **kwargs): + errors = [] + + fieldset_item = { + "title": True, + "instruction": True, + } + + result = validate_config_dict_item( + item=fieldset_item, + item_log_name="fieldset", + available_attrs=config_validation_constants.AVAILABLE_FIELDSET_ATTRS, + errors=errors, + ) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + "Object `fieldset`. Not all required attributes were specified. " + "Required attributes: rows, title. Passed attributes: title, instruction." + ), + "Attribute `title` in object `fieldset` must be `String`.", + "Attribute `instruction` in object `fieldset` must be `String`.", + ], + ) + + # --- Row --- + def test_validate_config_dict_item_row_success(self, *args, **kwargs): + errors = [] + + row_item = { + "fields": [], + "help": "Row help", + } + + result = validate_config_dict_item( + item=row_item, + item_log_name="row", + available_attrs=config_validation_constants.AVAILABLE_ROW_ATTRS, + errors=errors, + ) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test_validate_config_dict_item_row_error(self, *args, **kwargs): + errors = [] + + row_item = {} + + result = validate_config_dict_item( + item=row_item, + item_log_name="row", + available_attrs=config_validation_constants.AVAILABLE_ROW_ATTRS, + errors=errors, + ) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + "Object `row`. Not all required attributes were specified. " + "Required attributes: fields. Passed attributes: ." + ), + ], + ) + + # --- Field --- + def test_validate_config_dict_item_field_success(self, *args, **kwargs): + errors = [] + + field_item = { + "help": "Field help", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "validators": { + "required": True, + "minLength": 2, + "maxLength": 20, + "regexp": ["^[a-zA-Z0-9._-]+@mephisto\\.ai$", "ig"], + }, + "value": "", + } + + result = validate_config_dict_item( + item=field_item, + item_log_name="field", + available_attrs=config_validation_constants.COMMON_AVAILABLE_FIELD_ATTRS, + errors=errors, + ) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test_validate_config_dict_item_field_error(self, *args, **kwargs): + errors = [] + + field_item = { + "id": True, + "name": True, + "validators": True, + "value": "", + } + + result = validate_config_dict_item( + item=field_item, + item_log_name="field", + available_attrs=config_validation_constants.COMMON_AVAILABLE_FIELD_ATTRS, + errors=errors, + ) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + "Object `field` with name `True`. Not all required attributes were specified. " + "Required attributes: label, name, type. " + "Passed attributes: id, name, validators, value." + ), + "Attribute `id` in object `field` must be `String`.", + "Attribute `name` in object `field` must be `String`.", + "Attribute `validators` in object `field` must be `Object`." + ], + ) diff --git a/test/generators/form_composer/config_validation/test_form_config.py b/test/generators/form_composer/config_validation/test_form_config.py new file mode 100644 index 000000000..57c632e6c --- /dev/null +++ b/test/generators/form_composer/config_validation/test_form_config.py @@ -0,0 +1,313 @@ +import unittest + +from mephisto.generators.form_composer.config_validation.form_config import ( + _collect_values_for_unique_attrs_from_item +) +from mephisto.generators.form_composer.config_validation.form_config import _duplicate_values_exist +from mephisto.generators.form_composer.config_validation.form_config import validate_form_config + + +class TestFormConfig(unittest.TestCase): + def test__collect_values_for_unique_attrs_from_item(self, *args, **kwargs): + item = { + "help": "Field help", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "validators": { + "required": True, + }, + "value": "", + } + + values_for_unique_attrs = {} + result = _collect_values_for_unique_attrs_from_item( + item=item, values_for_unique_attrs=values_for_unique_attrs, + ) + + self.assertEqual(result, {"id": ["id_field"], "name": ["field_name"]}) + + def test__duplicate_values_exist_no_duplicates(self, *args, **kwargs): + no_duplicates_values_for_unique_attrs = {"id": ["id_field"], "name": ["field_name"]} + errors = [] + + result = _duplicate_values_exist(no_duplicates_values_for_unique_attrs, errors) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test__duplicate_values_exist_with_duplicates(self, *args, **kwargs): + no_duplicates_values_for_unique_attrs = { + "id": ["id_field", "id_field"], + "name": ["field_name", "field_name"], + } + errors = [] + + result = _duplicate_values_exist(no_duplicates_values_for_unique_attrs, errors) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + "Found duplicate names for unique attribute 'id' in your form config: id_field", + "Found duplicate names for unique attribute 'name' in your form config: field_name", + ] + ) + + def test_validate_form_config_not_dict(self, *args, **kwargs): + config_data = [] + + result, errors = validate_form_config(config_data) + + self.assertFalse(result) + self.assertEqual(errors, ["Form config must be a key/value JSON Object."]) + + def test_validate_form_config_wrong_keys(self, *args, **kwargs): + config_data = {} + + result, errors = validate_form_config(config_data) + + self.assertFalse(result) + self.assertEqual(errors, ["Form config must contain only these attributes: form."]) + + def test_validate_form_config_not_all_required_fields(self, *args, **kwargs): + config_data = { + "form": { + "instruction": "Form instruction", + "sections": [ + { + "name": "section_name", + "title": "Section title", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help", + "id": "id_field", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + } + + result, errors = validate_form_config(config_data) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + "Object `form`. Not all required attributes were specified. " + "Required attributes: sections, submit_button, title. " + "Passed attributes: instruction, sections, submit_button." + ), + ( + "Object `field` with name `field_name`. " + "Not all required attributes were specified. " + "Required attributes: label, name, type. " + "Passed attributes: help, id, name, placeholder, tooltip, type, value." + ), + ], + ) + + def test_validate_form_config_with_duplicates(self, *args, **kwargs): + config_data = { + "form": { + "title": "Form title", + "instruction": "Form instruction", + "sections": [ + { + "name": "section_name", + "title": "Section title", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help", + "id": "id_field", + "label": "Field label", + "name": "section_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + }, + { + "help": "Field help 2", + "id": "id_field", + "label": "Field label 2", + "name": "field_name", + "placeholder": "Field placeholder 2", + "tooltip": "Field tooltip 2", + "type": "input", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + } + + result, errors = validate_form_config(config_data) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + "Found duplicate names for unique attribute 'name' " + "in your form config: section_name" + ), + "Found duplicate names for unique attribute 'id' in your form config: id_field", + ], + ) + + def test_validate_form_config_incorrent_field_type(self, *args, **kwargs): + config_data = { + "form": { + "title": "Form title", + "instruction": "Form instruction", + "sections": [ + { + "name": "section_name", + "title": "Section title", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "incorrect_field_type", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + } + + result, errors = validate_form_config(config_data) + + self.assertFalse(result) + self.assertEqual( + errors, + ["Object 'field' has unsupported 'type' attribute value: incorrect_field_type"], + ) + + def test_validate_form_config_success(self, *args, **kwargs): + config_data = { + "form": { + "title": "Form title", + "instruction": "Form instruction", + "sections": [ + { + "name": "section_name", + "title": "Section title", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + } + + result, errors = validate_form_config(config_data) + + self.assertTrue(result) + self.assertEqual(errors, []) diff --git a/test/generators/form_composer/config_validation/test_separate_token_values_config.py b/test/generators/form_composer/config_validation/test_separate_token_values_config.py new file mode 100644 index 000000000..cb0aa7f37 --- /dev/null +++ b/test/generators/form_composer/config_validation/test_separate_token_values_config.py @@ -0,0 +1,219 @@ +import json +import os +import shutil +import tempfile +import unittest +from unittest.mock import patch + +from botocore.exceptions import NoCredentialsError + +from mephisto.client.cli import FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME +from mephisto.generators.form_composer.config_validation.separate_token_values_config import ( + update_separate_token_values_config_with_file_urls +) +from mephisto.generators.form_composer.config_validation.separate_token_values_config import ( + validate_separate_token_values_config +) + + +class TestSeparateTokenValuesConfig(unittest.TestCase): + def setUp(self): + self.data_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.data_dir, ignore_errors=True) + + def test_validate_separate_token_values_config_success(self, *args, **kwargs): + config_data = { + "file_location": [ + "https://example.com/1.jpg", + "https://example.com/2.jpg", + ], + } + result, errors = validate_separate_token_values_config(config_data) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test_validate_separate_token_values_config_not_dict(self, *args, **kwargs): + config_data = [] + result, errors = validate_separate_token_values_config(config_data) + + self.assertFalse(result) + self.assertEqual(errors, ["Config must be a key/value JSON Object."]) + + def test_validate_separate_token_values_config_empty_value_list(self, *args, **kwargs): + config_data = { + "file_location": [], + } + result, errors = validate_separate_token_values_config(config_data) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + ( + "You passed empty array of values for token 'file_location'. " + "It must contain at least one value or just remove it you left it by mistake." + ), + ], + ) + + @patch( + "mephisto.generators.form_composer.config_validation.separate_token_values_config." + "read_config_file" + ) + @patch( + "mephisto.generators.form_composer.config_validation.separate_token_values_config." + "get_file_urls_from_s3_storage" + ) + def test_update_separate_token_values_config_with_file_urls_credentials_error( + self, mock_get_file_urls_from_s3_storage, mock_read_config_file, *args, **kwargs, + ): + url = "https://test-bucket-private.s3.amazonaws.com/path/" + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + + mock_get_file_urls_from_s3_storage.side_effect = NoCredentialsError() + + result = update_separate_token_values_config_with_file_urls( + url, separate_token_values_config_path, + ) + + self.assertIsNone(result) + mock_read_config_file.assert_not_called() + + @patch( + "mephisto.generators.form_composer.config_validation.separate_token_values_config." + "read_config_file" + ) + @patch( + "mephisto.generators.form_composer.config_validation.separate_token_values_config." + "get_file_urls_from_s3_storage" + ) + def test_update_separate_token_values_config_with_file_urls_no_file_locations( + self, mock_get_file_urls_from_s3_storage, mock_read_config_file, *args, **kwargs, + ): + url = "https://test-bucket-private.s3.amazonaws.com/path/" + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + + mock_get_file_urls_from_s3_storage.return_value = [] + + result = update_separate_token_values_config_with_file_urls( + url, separate_token_values_config_path, + ) + + self.assertIsNone(result) + mock_read_config_file.assert_not_called() + + @patch( + "mephisto.generators.form_composer.config_validation.separate_token_values_config." + "get_file_urls_from_s3_storage" + ) + def test_update_separate_token_values_config_with_file_urls_success_new_config_file( + self, mock_get_file_urls_from_s3_storage, *args, **kwargs, + ): + url = "https://test-bucket-private.s3.amazonaws.com/path/" + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + + mock_get_file_urls_from_s3_storage.return_value = [ + "https://example.com/1.jpg", + "https://example.com/2.jpg", + ] + + update_separate_token_values_config_with_file_urls( + url, separate_token_values_config_path, + ) + + f = open(separate_token_values_config_path, "r") + result_config_data = json.loads(f.read()) + + self.assertEqual( + result_config_data, + { + "file_location": [ + "https://example.com/1.jpg", + "https://example.com/2.jpg", + ], + }, + ) + + @patch( + "mephisto.generators.form_composer.config_validation.separate_token_values_config." + "get_file_urls_from_s3_storage" + ) + def test_update_separate_token_values_config_with_file_urls_success_updated_config_file( + self, mock_get_file_urls_from_s3_storage, *args, **kwargs, + ): + url = "https://test-bucket-private.s3.amazonaws.com/path/" + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + + mock_get_file_urls_from_s3_storage.return_value = [ + "https://example.com/1.jpg", + "https://example.com/2.jpg", + ] + + initial_config_data = { + "some_token": ["value 1", "value 2"], + } + f = open(separate_token_values_config_path, "w") + f.write(json.dumps(initial_config_data)) + f.close() + + update_separate_token_values_config_with_file_urls( + url, separate_token_values_config_path, + ) + + f = open(separate_token_values_config_path, "r") + result_config_data = json.loads(f.read()) + + expected_config_data = { + "file_location": [ + "https://example.com/1.jpg", + "https://example.com/2.jpg", + ], + } + expected_config_data.update(initial_config_data) + + self.assertEqual(result_config_data, expected_config_data) + + @patch( + "mephisto.generators.form_composer.config_validation.separate_token_values_config." + "get_file_urls_from_s3_storage" + ) + def test_update_separate_token_values_config_with_file_urls_success_new_config_file_presigned( + self, mock_get_file_urls_from_s3_storage, *args, **kwargs, + ): + url = "https://test-bucket-private.s3.amazonaws.com/path/" + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + + mock_get_file_urls_from_s3_storage.return_value = [ + "https://example.com/1.jpg", + "https://example.com/2.jpg", + ] + + update_separate_token_values_config_with_file_urls( + url, separate_token_values_config_path, use_presigned_urls=True, + ) + + f = open(separate_token_values_config_path, "r") + result_config_data = json.loads(f.read()) + + self.assertEqual( + result_config_data, + { + "file_location": [ + "{{getMultiplePresignedUrls(\"https://example.com/1.jpg\")}}", + "{{getMultiplePresignedUrls(\"https://example.com/2.jpg\")}}", + ], + }, + ) diff --git a/test/generators/form_composer/config_validation/test_task_data_config.py b/test/generators/form_composer/config_validation/test_task_data_config.py new file mode 100644 index 000000000..4c20d9092 --- /dev/null +++ b/test/generators/form_composer/config_validation/test_task_data_config.py @@ -0,0 +1,927 @@ +import io +import json +import os +import shutil +import sys +import tempfile +import unittest +from copy import deepcopy +from unittest.mock import patch + +from mephisto.client.cli import FORM_COMPOSER__DATA_CONFIG_NAME +from mephisto.client.cli import FORM_COMPOSER__FORM_CONFIG_NAME +from mephisto.client.cli import FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME +from mephisto.client.cli import FORM_COMPOSER__TOKEN_SETS_VALUES_CONFIG_NAME +from mephisto.generators.form_composer.config_validation.task_data_config import ( + _collect_form_config_items_to_extrapolate +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + _collect_tokens_from_form_config +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + _combine_extrapolated_form_configs +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + _extrapolate_tokens_in_form_config +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + _extrapolate_tokens_values +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + _set_tokens_in_form_config_item +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + _validate_tokens_in_both_configs +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + create_extrapolated_config +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + prepare_task_config_for_review_app +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + validate_task_data_config +) +from mephisto.generators.form_composer.config_validation.task_data_config import ( + verify_form_composer_configs +) + +CORRECT_CONFIG_DATA_WITH_TOKENS = { + "form": { + "title": "Form title {{token_1}}", + "instruction": "Form instruction {{token_2}}", + "sections": [ + { + "name": "section_name", + "title": "Section title {{token_3}}", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title {{token_4}}", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help {{token_5}}", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction {{token_5}}", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, +} + + +class TestTaskDataConfig(unittest.TestCase): + def setUp(self): + self.data_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.data_dir, ignore_errors=True) + + def test__extrapolate_tokens_values_simple(self, *args, **kwargs): + text = "Test {{token_1}} and {{token_2}}" + tokens_values = { + "token_1": "value 1", + "token_2": "value 2", + } + result = _extrapolate_tokens_values(text, tokens_values) + + self.assertEqual(result, "Test value 1 and value 2") + + def test__extrapolate_tokens_values_with_spaces_around(self, *args, **kwargs): + text = "Test {{ token_1 }} and {{ token_2 }}" + tokens_values = { + "token_1": "value 1", + "token_2": "value 2", + } + result = _extrapolate_tokens_values(text, tokens_values) + + self.assertEqual(result, "Test value 1 and value 2") + + def test__extrapolate_tokens_values_with_new_lines_around(self, *args, **kwargs): + text = "Test {{\ntoken_1\n}} and {{\n\ntoken_2\n\n}}" + tokens_values = { + "token_1": "value 1", + "token_2": "value 2", + } + result = _extrapolate_tokens_values(text, tokens_values) + + self.assertEqual(result, "Test value 1 and value 2") + + def test__extrapolate_tokens_values_with_tabs_around(self, *args, **kwargs): + text = "Test {{\ttoken_1\t}} and {{\t\ttoken_2\t\t}}" + tokens_values = { + "token_1": "value 1", + "token_2": "value 2", + } + result = _extrapolate_tokens_values(text, tokens_values) + + self.assertEqual(result, "Test value 1 and value 2") + + def test__extrapolate_tokens_values_with_bracketed_values(self, *args, **kwargs): + text = "Test {{token_1}} and {{token_2}}" + tokens_values = { + "token_1": "{{value 1}}", + "token_2": "{{value 2}}", + } + result = _extrapolate_tokens_values(text, tokens_values) + + self.assertEqual(result, "Test {{value 1}} and {{value 2}}") + + def test__extrapolate_tokens_values_with_procedure_tokens(self, *args, **kwargs): + text = "Test {{someProcedureWithArguments({\"arg\": 1})}} and {{otherProcedure(True)}}" + tokens_values = { + "someProcedureWithArguments({\"arg\": 1})": "value 1", + "otherProcedure(True)": "value 2", + } + result = _extrapolate_tokens_values(text, tokens_values) + + self.assertEqual(result, "Test value 1 and value 2") + + def test__set_tokens_in_form_config_item(self, *args, **kwargs): + item = { + "title": "Form title {{token_1}} and {{token_2}}", + "instruction": "Form instruction {{token_2}}", + } + tokens_values = { + "token_1": "value 1", + "token_2": "value 2", + } + + _set_tokens_in_form_config_item(item, tokens_values) + + self.assertEqual( + item, + { + "title": "Form title value 1 and value 2", + "instruction": "Form instruction value 2", + }, + ) + + def test__collect_form_config_items_to_extrapolate(self, *args, **kwargs): + config_data = { + "form": { + "title": "Form title", + "instruction": "Form instruction", + "sections": [ + { + "name": "section_name", + "title": "Section title", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + } + + items = _collect_form_config_items_to_extrapolate(config_data) + + self.assertEqual(len(items), 6) + + def test__collect_tokens_from_form_config_success(self, *args, **kwargs): + config_data = deepcopy(CORRECT_CONFIG_DATA_WITH_TOKENS) + + tokens, errors = _collect_tokens_from_form_config(config_data) + + self.assertEqual(tokens, {"token_1", "token_2", "token_3", "token_4", "token_5"}) + self.assertEqual(errors, []) + + def test__collect_tokens_from_form_config_with_errors(self, *args, **kwargs): + config_data = { + "form": { + "title": "Form title {{token_1}}", + "instruction": "Form instruction {{token_2}}", + "sections": [ + { + "name": "section_name {{token_6}}", + "title": "Section title {{token_3}}", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title {{token_4}}", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help {{token_5}}", + "id": "id_field {{token_7}}", + "label": "Field label", + "name": "field_name {{token_8}}", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file {{token_9}}", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction {{token_5}}", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + } + + tokens, errors = _collect_tokens_from_form_config(config_data) + + self.assertEqual(tokens, {"token_1", "token_2", "token_3", "token_4", "token_5"}) + self.assertEqual( + sorted(errors), + sorted([ + ( + "You tried to set tokens 'token_6' in attribute 'name' with value " + "'section_name {{token_6}}'. You can use tokens only in following attributes: " + "help, instruction, label, title, tooltip" + ), + ( + "You tried to set tokens 'token_8' in attribute 'name' with value " + "'field_name {{token_8}}'. You can use tokens only in following attributes: " + "help, instruction, label, title, tooltip" + ), + ( + "You tried to set tokens 'token_7' in attribute 'id' with value " + "'id_field {{token_7}}'. You can use tokens only in following attributes: " + "help, instruction, label, title, tooltip" + ), + ( + "You tried to set tokens 'token_9' in attribute 'type' with value " + "'file {{token_9}}'. You can use tokens only in following attributes: " + "help, instruction, label, title, tooltip" + ), + ]), + ) + + def test__extrapolate_tokens_in_form_config_success(self, *args, **kwargs): + config_data = deepcopy(CORRECT_CONFIG_DATA_WITH_TOKENS) + tokens_values = { + "token_1": "value 1", + "token_2": "value 2", + "token_3": "value 3", + "token_4": "value 4", + "token_5": "value 5", + } + + result = _extrapolate_tokens_in_form_config(config_data, tokens_values) + + self.assertEqual( + result, + { + "form": { + "title": "Form title value 1", + "instruction": "Form instruction value 2", + "sections": [ + { + "name": "section_name", + "title": "Section title value 3", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title value 4", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help value 5", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction value 5", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + }, + ) + + def test__validate_tokens_in_both_configs_success(self, *args, **kwargs): + config_data = deepcopy(CORRECT_CONFIG_DATA_WITH_TOKENS) + token_sets_values_config_data = [ + { + "tokens_values": { + "token_1": "value 1", + "token_2": "value 2", + "token_3": "value 3", + "token_4": "value 4", + "token_5": "value 5", + }, + }, + ] + + ( + overspecified_tokens, underspecified_tokens, tokens_in_unexpected_attrs_errors + ) = _validate_tokens_in_both_configs(config_data, token_sets_values_config_data) + + self.assertEqual(len(overspecified_tokens), 0) + self.assertEqual(len(underspecified_tokens), 0) + self.assertEqual(tokens_in_unexpected_attrs_errors, []) + + def test__validate_tokens_in_both_configs_with_errors(self, *args, **kwargs): + config_data = { + "form": { + "title": "Form title", + "instruction": "Form instruction {{token_2}}", + "sections": [ + { + "name": "section_name", + "title": "Section title {{token_3}}", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title {{token_4}}", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help {{token_5}}", + "id": "id_field {{token_5}}", + "label": "Field label {{token_6}}", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction {{token_5}}", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + } + token_sets_values_config_data = [ + { + "tokens_values": { + "token_1": "value 1", + "token_2": "value 2", + "token_3": "value 3", + "token_4": "value 4", + "token_5": "value 5", + }, + }, + ] + + ( + overspecified_tokens, underspecified_tokens, tokens_in_unexpected_attrs_errors + ) = _validate_tokens_in_both_configs(config_data, token_sets_values_config_data) + + self.assertEqual(overspecified_tokens, {"token_1"}) + self.assertEqual(underspecified_tokens, {"token_6"}) + self.assertEqual( + tokens_in_unexpected_attrs_errors, + [ + ( + "You tried to set tokens 'token_5' in attribute 'id' with value 'id_field " + "{{token_5}}'. You can use tokens only in following attributes: help, " + "instruction, label, title, tooltip" + ), + ], + ) + + def test__combine_extrapolated_form_configs_success(self, *args, **kwargs): + config_data = deepcopy(CORRECT_CONFIG_DATA_WITH_TOKENS) + token_sets_values_config_data = [ + { + "tokens_values": { + "token_1": "value 1", + "token_2": "value 2", + "token_3": "value 3", + "token_4": "value 4", + "token_5": "value 5", + }, + }, + ] + + result = _combine_extrapolated_form_configs(config_data, token_sets_values_config_data) + + self.assertEqual( + result, + [ + { + "form": { + "title": "Form title value 1", + "instruction": "Form instruction value 2", + "sections": [ + { + "collapsable": False, + "fieldsets": [ + { + "help": "Fieldset help", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help value 5", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + }, + ], + "help": "Row help", + }, + ], + "title": "Fieldset title value 4", + }, + ], + "initially_collapsed": True, + "instruction": "Section instruction", + "name": "section_name", + "title": "Section title value 3", + }, + ], + "submit_button": { + "instruction": "Submit instruction value 5", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + }, + ], + ) + + def test_create_extrapolated_config_file_not_found(self, *args, **kwargs): + form_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__FORM_CONFIG_NAME, + ) + token_sets_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__TOKEN_SETS_VALUES_CONFIG_NAME, + ) + task_data_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__DATA_CONFIG_NAME, + ) + + with self.assertRaises(FileNotFoundError) as cm: + create_extrapolated_config( + form_config_path, + token_sets_values_config_path, + task_data_config_path, + ) + + self.assertEqual( + cm.exception.__str__(), + f"Create file '{form_config_path}' and add form configuration", + ) + + def test_create_extrapolated_config_success(self, *args, **kwargs): + form_config_data = deepcopy(CORRECT_CONFIG_DATA_WITH_TOKENS) + token_sets_values_config_data = [ + { + "tokens_values": { + "token_1": "value 1", + "token_2": "value 2", + "token_3": "value 3", + "token_4": "value 4", + "token_5": "value 5", + }, + }, + ] + + form_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__FORM_CONFIG_NAME, + ) + token_sets_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__TOKEN_SETS_VALUES_CONFIG_NAME, + ) + task_data_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__DATA_CONFIG_NAME, + ) + + form_config_f = open(form_config_path, "w") + form_config_f.write(json.dumps(form_config_data)) + form_config_f.close() + + token_sets_values_config_f = open(token_sets_values_config_path, "w") + token_sets_values_config_f.write(json.dumps(token_sets_values_config_data)) + token_sets_values_config_f.close() + + create_extrapolated_config( + form_config_path, + token_sets_values_config_path, + task_data_config_path, + ) + + f = open(task_data_config_path, "r") + task_config_data = json.loads(f.read()) + + self.assertEqual( + task_config_data, + [ + { + "form": { + "title": "Form title value 1", + "instruction": "Form instruction value 2", + "sections": [ + { + "collapsable": False, + "fieldsets": [ + { + "help": "Fieldset help", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help value 5", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + }, + ], + "help": "Row help", + }, + ], + "title": "Fieldset title value 4", + }, + ], + "initially_collapsed": True, + "instruction": "Section instruction", + "name": "section_name", + "title": "Section title value 3", + }, + ], + "submit_button": { + "instruction": "Submit instruction value 5", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + }, + ], + ) + + def test_validate_task_data_config_success(self, *args, **kwargs): + task_config_data = [deepcopy(CORRECT_CONFIG_DATA_WITH_TOKENS)] + + result, errors = validate_task_data_config(task_config_data) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test_validate_task_data_config_not_list(self, *args, **kwargs): + task_config_data = {} + + result, errors = validate_task_data_config(task_config_data) + + self.assertFalse(result) + self.assertEqual(errors, ["Config must be a JSON Array."]) + + def test_validate_task_data_config_errors(self, *args, **kwargs): + task_config_data = [{}] + + result, errors = validate_task_data_config(task_config_data) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + "Task data config must contain at least one non-empty item.", + "Form config must contain only these attributes: form." + ], + ) + + def test_verify_form_composer_configs_errors(self, *args, **kwargs): + task_data_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__DATA_CONFIG_NAME, + ) + form_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__FORM_CONFIG_NAME, + ) + token_sets_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__TOKEN_SETS_VALUES_CONFIG_NAME, + ) + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + + captured_print_output = io.StringIO() + sys.stdout = captured_print_output + verify_form_composer_configs( + task_data_config_path, + form_config_path, + token_sets_values_config_path, + separate_token_values_config_path, + task_data_config_only=False, + ) + sys.stdout = sys.__stdout__ + + self.assertEqual( + captured_print_output.getvalue(), + ( + f"Required file not found: '{self.data_dir}/task_data.json'.\n" + f"Required file not found: '{self.data_dir}/form_config.json'.\n" + f"Required file not found: '{self.data_dir}/token_sets_values_config.json'.\n" + f"Required file not found: '{self.data_dir}/separate_token_values_config.json'.\n" + "\n" + "Provided Form Composer config files are invalid: \n" + " - Separate token values config is invalid. Errors:\n" + " - Config must be a key/value JSON Object.\n" + "\n" + ) + ) + + def test_verify_form_composer_configs_errors_task_data_config_only(self, *args, **kwargs): + task_data_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__DATA_CONFIG_NAME, + ) + form_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__FORM_CONFIG_NAME, + ) + token_sets_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__TOKEN_SETS_VALUES_CONFIG_NAME, + ) + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + + captured_print_output = io.StringIO() + sys.stdout = captured_print_output + verify_form_composer_configs( + task_data_config_path, + form_config_path, + token_sets_values_config_path, + separate_token_values_config_path, + task_data_config_only=True, + ) + sys.stdout = sys.__stdout__ + + self.assertEqual( + captured_print_output.getvalue(), + f"Required file not found: '{self.data_dir}/task_data.json'.\n", + ) + + def test_verify_form_composer_configs_success(self, *args, **kwargs): + task_data_config_data = [deepcopy(CORRECT_CONFIG_DATA_WITH_TOKENS)] + form_config_data = deepcopy(CORRECT_CONFIG_DATA_WITH_TOKENS) + token_sets_values_config_data = [ + { + "tokens_values": { + "token_1": "value 1", + "token_2": "value 2", + "token_3": "value 3", + "token_4": "value 4", + "token_5": "value 5", + }, + }, + ] + + separate_token_values_config_data = { + "token_1": [ + "value 1", + ], + "token_2": [ + "value 2", + ], + "token_3": [ + "value 3", + ], + "token_4": [ + "value 4", + ], + "token_5": [ + "value 5", + ], + } + + task_data_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__DATA_CONFIG_NAME, + ) + form_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__FORM_CONFIG_NAME, + ) + token_sets_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__TOKEN_SETS_VALUES_CONFIG_NAME, + ) + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + + task_data_config_f = open(task_data_config_path, "w") + task_data_config_f.write(json.dumps(task_data_config_data)) + task_data_config_f.close() + + form_config_f = open(form_config_path, "w") + form_config_f.write(json.dumps(form_config_data)) + form_config_f.close() + + token_sets_values_config_f = open(token_sets_values_config_path, "w") + token_sets_values_config_f.write(json.dumps(token_sets_values_config_data)) + token_sets_values_config_f.close() + + separate_token_values_config_f = open(separate_token_values_config_path, "w") + separate_token_values_config_f.write(json.dumps(separate_token_values_config_data)) + separate_token_values_config_f.close() + + captured_print_output = io.StringIO() + sys.stdout = captured_print_output + verify_form_composer_configs( + task_data_config_path, + form_config_path, + token_sets_values_config_path, + separate_token_values_config_path, + ) + sys.stdout = sys.__stdout__ + + self.assertEqual(captured_print_output.getvalue(), "All configs are valid.\n") + + @patch( + "mephisto.generators.form_composer.config_validation.task_data_config.get_s3_presigned_url" + ) + def test_prepare_task_config_for_review_app_success( + self, mock_get_s3_presigned_url, *args, **kwargs, + ): + presigned_url_expected = "presigned_url" + config_data = { + "form": { + "title": "Form title {{getMultiplePresignedUrls(\"https://example.com/1.jpg\")}}", + "instruction": "Form instruction", + "sections": [ + { + "name": "section_name", + "title": "Section title", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + } + + mock_get_s3_presigned_url.return_value = presigned_url_expected + + result = prepare_task_config_for_review_app(config_data) + + self.assertEqual( + result, + { + "form": { + "title": f"Form title {presigned_url_expected}", + "instruction": "Form instruction", + "sections": [ + { + "name": "section_name", + "title": "Section title", + "instruction": "Section instruction", + "collapsable": False, + "initially_collapsed": True, + "fieldsets": [ + { + "title": "Fieldset title", + "instruction": "Fieldset instruction", + "rows": [ + { + "fields": [ + { + "help": "Field help", + "id": "id_field", + "label": "Field label", + "name": "field_name", + "placeholder": "Field placeholder", + "tooltip": "Field tooltip", + "type": "file", + "value": "", + } + ], + "help": "Row help", + }, + ], + "help": "Fieldset help", + }, + ], + }, + ], + "submit_button": { + "instruction": "Submit instruction", + "text": "Submit", + "tooltip": "Submit tooltip", + }, + }, + } + ) diff --git a/test/generators/form_composer/config_validation/test_token_sets_values_config.py b/test/generators/form_composer/config_validation/test_token_sets_values_config.py new file mode 100644 index 000000000..218bd68f2 --- /dev/null +++ b/test/generators/form_composer/config_validation/test_token_sets_values_config.py @@ -0,0 +1,180 @@ +import json +import os +import shutil +import tempfile +import unittest + +from mephisto.client.cli import FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME +from mephisto.client.cli import FORM_COMPOSER__TOKEN_SETS_VALUES_CONFIG_NAME +from mephisto.generators.form_composer.config_validation.token_sets_values_config import ( + _premutate_separate_tokens +) +from mephisto.generators.form_composer.config_validation.token_sets_values_config import ( + update_token_sets_values_config_with_premutated_data +) +from mephisto.generators.form_composer.config_validation.token_sets_values_config import ( + validate_token_sets_values_config +) + + +class TestTokenSetsValuesConfig(unittest.TestCase): + def setUp(self): + self.data_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.data_dir, ignore_errors=True) + + def test_validate_token_sets_values_config_not_list(self, *args, **kwargs): + config_data = {} + result, errors = validate_token_sets_values_config(config_data) + + self.assertFalse(result) + self.assertEqual(errors, ["Config must be a JSON Array."]) + + def test_validate_token_sets_values_config_not_list_with_empty_dicts(self, *args, **kwargs): + config_data = [{}, {}] + result, errors = validate_token_sets_values_config(config_data) + + self.assertFalse(result) + self.assertEqual( + errors, + [ + "Config must contain at least one non-empty item.", + ( + "Object `item_tokens_values`. Not all required attributes were specified. " + "Required attributes: tokens_values. Passed attributes: ." + ), + ( + "Object `item_tokens_values`. Not all required attributes were specified. " + "Required attributes: tokens_values. Passed attributes: ." + ), + ], + ) + + def test_validate_token_sets_values_config_dissimilar_set_of_tokens(self, *args, **kwargs): + config_data = [ + { + "tokens_values": { + "token 1": "value 1", + }, + }, + { + "tokens_values": { + "token 2": "value 2", + }, + }, + ] + result, errors = validate_token_sets_values_config(config_data) + + self.assertFalse(result) + self.assertEqual(errors, ["Some token sets contain dissimilar set of token names."]) + + def test_validate_token_sets_values_config_success(self, *args, **kwargs): + config_data = [ + { + "tokens_values": { + "token 1": "value 1", + }, + }, + { + "tokens_values": { + "token 1": "value 2", + }, + }, + ] + result, errors = validate_token_sets_values_config(config_data) + + self.assertTrue(result) + self.assertEqual(errors, []) + + def test__premutate_separate_tokens_success(self, *args, **kwargs): + config_data = { + "token 1": [ + "value 1", + "value 2", + ], + } + result = _premutate_separate_tokens(config_data) + + self.assertEqual( + result, + [ + { + "tokens_values": { + "token 1": "value 1", + }, + }, + { + "tokens_values": { + "token 1": "value 2", + }, + }, + ], + ) + + def test_update_token_sets_values_config_with_premutated_data_error(self, *args, **kwargs): + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + token_sets_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__TOKEN_SETS_VALUES_CONFIG_NAME, + ) + + initial_config_data = { + "some_token": [], + } + f = open(separate_token_values_config_path, "w") + f.write(json.dumps(initial_config_data)) + f.close() + + with self.assertRaises(ValueError) as cm: + update_token_sets_values_config_with_premutated_data( + separate_token_values_config_path, token_sets_values_config_path, + ) + + self.assertEqual( + cm.exception.__str__(), + ( + "\nSeparate token values config is invalid. Errors:\n" + " - You passed empty array of values for token 'some_token'. It must contain " + "at least one value or just remove it you left it by mistake." + ), + ) + + def test_update_token_sets_values_config_with_premutated_data_success(self, *args, **kwargs): + separate_token_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__SEPARATE_TOKEN_VALUES_CONFIG_NAME, + ) + token_sets_values_config_path = os.path.join( + self.data_dir, FORM_COMPOSER__TOKEN_SETS_VALUES_CONFIG_NAME, + ) + + initial_config_data = { + "some_token": ["value 1", "value 2"], + } + f = open(separate_token_values_config_path, "w") + f.write(json.dumps(initial_config_data)) + f.close() + + update_token_sets_values_config_with_premutated_data( + separate_token_values_config_path, token_sets_values_config_path, + ) + + f = open(token_sets_values_config_path, "r") + token_sets_values_config_data = json.loads(f.read()) + + self.assertEqual( + token_sets_values_config_data, + [ + { + "tokens_values": { + "some_token": "value 1", + }, + }, + { + "tokens_values": { + "some_token": "value 2", + }, + }, + ], + ) diff --git a/test/generators/form_composer/config_validation/test_utils.py b/test/generators/form_composer/config_validation/test_utils.py new file mode 100644 index 000000000..a5e659fba --- /dev/null +++ b/test/generators/form_composer/config_validation/test_utils.py @@ -0,0 +1,247 @@ +import json +import os +import shutil +import tempfile +import unittest +from typing import List +from unittest.mock import patch +from urllib.parse import quote + +from botocore.exceptions import BotoCoreError + +from mephisto.generators.form_composer.config_validation.utils import ( + _get_bucket_and_key_from_S3_url +) +from mephisto.generators.form_composer.config_validation.utils import _run_and_handle_boto_errors +from mephisto.generators.form_composer.config_validation.utils import get_file_ext +from mephisto.generators.form_composer.config_validation.utils import get_file_urls_from_s3_storage +from mephisto.generators.form_composer.config_validation.utils import get_s3_presigned_url +from mephisto.generators.form_composer.config_validation.utils import is_s3_url +from mephisto.generators.form_composer.config_validation.utils import make_error_message +from mephisto.generators.form_composer.config_validation.utils import read_config_file +from mephisto.generators.form_composer.config_validation.utils import write_config_to_file +from mephisto.generators.form_composer.constants import CONTENTTYPE_BY_EXTENSION + + +def get_mock_boto_resource(bucket_object_data: List[dict]): + class FilterObject: + key: str + + def __init__(self, data): + self.key = data["key"] + + class MockResource: + def __init__(self, *args, **kwargs): + pass + + class Bucket: + def __init__(self, *args, **kwargs): + pass + + class objects: + @staticmethod + def filter(*args, **kwargs): + return [FilterObject(d) for d in bucket_object_data] + + return MockResource + + +class TestUtils(unittest.TestCase): + def setUp(self): + self.data_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.data_dir, ignore_errors=True) + + def test_write_config_to_file_success(self, *args, **kwargs): + expected_data = {"test": "value"} + + config_path = os.path.join(self.data_dir, "test.json") + write_config_to_file(expected_data, config_path) + + f = open(config_path, "r") + config_data = json.loads(f.read()) + + self.assertEqual(config_data, expected_data) + + def test_read_config_file_non_existent_config_path_without_exit(self, *args, **kwargs): + non_existent_config_path = os.path.join(self.data_dir, "test.json") + result = read_config_file(non_existent_config_path, exit_if_no_file=False) + + self.assertIsNone(result) + + def test_read_config_file_non_existent_config_path_with_exit(self, *args, **kwargs): + non_existent_config_path = os.path.join(self.data_dir, "test.json") + + with self.assertRaises(SystemExit): + read_config_file(non_existent_config_path, exit_if_no_file=True) + + def test_read_config_file_not_json(self, *args, **kwargs): + config_path = os.path.join(self.data_dir, "test.json") + + f = open(config_path, "w") + f.write("not JSON") + f.close() + + with self.assertRaises(SystemExit): + read_config_file(config_path, exit_if_no_file=True) + + def test_read_config_file_success(self, *args, **kwargs): + expected_data = {"test": "value"} + config_path = os.path.join(self.data_dir, "test.json") + + f = open(config_path, "w") + f.write(json.dumps(expected_data)) + f.close() + + result = read_config_file(config_path, exit_if_no_file=True) + + self.assertEqual(result, expected_data) + + def test_make_error_message_success(self, *args, **kwargs): + main_message = "Main message" + error_list = [ + "error 1", + "error 2", + ] + + result = make_error_message( + main_message=main_message, + error_list=error_list, + indent=4, + ) + + self.assertEqual( + result, + f"{main_message}. Errors:\n - {error_list[0]}\n - {error_list[1]}" + ) + + def test_get_file_ext_success(self, *args, **kwargs): + result = get_file_ext("/path/file.jpg") + + self.assertEqual(result, "jpg") + + def test__run_and_handle_boto_errors_success(self, *args, **kwargs): + def fn(): + raise BotoCoreError() + + error_message = "Test" + + with self.assertRaises(BotoCoreError) as cm: + _run_and_handle_boto_errors(fn, error_message, reraise=True) + + self.assertEqual(cm.exception.__str__(), "An unspecified error occurred") + + def test_is_s3_url_wrong(self, *args, **kwargs): + result = is_s3_url("https://example.com") + + self.assertFalse(result) + + def test_is_s3_url_success(self, *args, **kwargs): + result = is_s3_url("https://test-bucket-private.s3.amazonaws.com/path/test.jpg") + + self.assertTrue(result) + + def test__get_bucket_and_key_from_S3_url_success(self, *args, **kwargs): + expected_bucket_name = "test-bucket-private" + expected_s3_key = "path/test.jpg" + + bucket_name, s3_key = _get_bucket_and_key_from_S3_url( + f"https://{expected_bucket_name}.s3.amazonaws.com/{expected_s3_key}", + ) + + self.assertEqual(bucket_name, expected_bucket_name) + self.assertEqual(s3_key, expected_s3_key) + + def test__get_bucket_and_key_from_S3_url_error(self, *args, **kwargs): + with self.assertRaises(SystemExit): + _get_bucket_and_key_from_S3_url(f"https://test-bucket-private.s3.amazonaws.com") + + @patch("boto3.resource") + def test_get_file_urls_from_s3_storage_success(self, mock_resource, *args, **kwargs): + s3_url = "https://test-bucket-private.s3.amazonaws.com/path/" + + mock_resource.return_value = get_mock_boto_resource( + [ + { + "key": "path/file1.jpg", + }, + { + "key": "path/file2.jpg", + }, + ], + ) + + result = get_file_urls_from_s3_storage(s3_url) + + self.assertEqual( + result, + [ + "https://test-bucket-private.s3.amazonaws.com/path/file1.jpg", + "https://test-bucket-private.s3.amazonaws.com/path/file2.jpg", + ], + ) + + @patch("botocore.signers.RequestSigner.generate_presigned_url") + def test_get_s3_presigned_url_success(self, mock_generate_presigned_url, *args, **kwargs): + presigned_url_expected = "presigned_url" + + mock_generate_presigned_url.return_value = presigned_url_expected + + s3_signing_name = "s3" + s3_key = "path/image.png" + s3_bucket = "test-bucket-private" + s3_url = f"https://{s3_bucket}.{s3_signing_name}.amazonaws.com/{s3_key}" + content_type = CONTENTTYPE_BY_EXTENSION.get("png") + + result = get_s3_presigned_url(s3_url) + + self.assertEqual(result, presigned_url_expected) + + mock_generate_presigned_url.assert_called_with( + request_dict={ + "url_path": f"/{s3_key}", + "query_string": { + "response-content-type": content_type, + }, + "method": "GET", + "headers": {}, + "body": b"", + "auth_path": f"/{s3_bucket}/{s3_key}", + "url": f"{s3_url}?response-content-type={quote(content_type, safe='')}", + "context": { + "is_presign_request": True, + "use_global_endpoint": True, + "s3_redirect": { + "redirected": False, + "bucket": s3_bucket, + "params": { + "Bucket": s3_bucket, + "Key": s3_key, + "ResponseContentType": content_type, + }, + }, + "S3Express": { + "bucket_name": s3_bucket, + }, + "auth_type": "v4", + "signing": { + "signing_name": s3_signing_name, + "disableDoubleEncoding": True, + }, + }, + }, + expires_in=3600, + operation_name="GetObject", + ) + + @patch("botocore.signers.RequestSigner.generate_presigned_url") + def test_get_s3_presigned_url_error(self, mock_generate_presigned_url, *args, **kwargs): + mock_generate_presigned_url.side_effect = BotoCoreError() + + s3_url = f"https://test-bucket-private.s3.amazonaws.com/path/image.png" + + with self.assertRaises(BotoCoreError) as cm: + get_s3_presigned_url(s3_url) + + self.assertEqual(cm.exception.__str__(), "An unspecified error occurred") diff --git a/test/generators/form_composer/test_remote_procedures.py b/test/generators/form_composer/test_remote_procedures.py new file mode 100644 index 000000000..ca23ddffb --- /dev/null +++ b/test/generators/form_composer/test_remote_procedures.py @@ -0,0 +1,133 @@ +import unittest +from unittest.mock import patch +from urllib.parse import quote + +from mephisto.generators.form_composer.constants import CONTENTTYPE_BY_EXTENSION +from mephisto.generators.form_composer.remote_procedures import _get_multiple_presigned_urls +from mephisto.generators.form_composer.remote_procedures import _get_presigned_url +from mephisto.generators.form_composer.remote_procedures import _get_presigned_url_for_thread + + +class TestRemoteProcedures(unittest.TestCase): + @patch("botocore.signers.RequestSigner.generate_presigned_url") + def test__get_presigned_url_success(self, mock_generate_presigned_url, *args, **kwargs): + presigned_url_expected = "presigned_url" + + mock_generate_presigned_url.return_value = presigned_url_expected + + s3_signing_name = "s3" + s3_key = "path/image.png" + s3_bucket = "test-bucket-private" + s3_url = f"https://{s3_bucket}.{s3_signing_name}.amazonaws.com/{s3_key}" + content_type = CONTENTTYPE_BY_EXTENSION.get("png") + + url = _get_presigned_url( + "random-string", + s3_url, + None, + ) + + self.assertEqual(url, presigned_url_expected) + mock_generate_presigned_url.assert_called_with( + request_dict={ + "url_path": f"/{s3_key}", + "query_string": { + "response-content-type": content_type, + }, + "method": "GET", + "headers": {}, + "body": b"", + "auth_path": f"/{s3_bucket}/{s3_key}", + "url": f"{s3_url}?response-content-type={quote(content_type, safe='')}", + "context": { + "is_presign_request": True, + "use_global_endpoint": True, + "s3_redirect": { + "redirected": False, + "bucket": s3_bucket, + "params": { + "Bucket": s3_bucket, + "Key": s3_key, + "ResponseContentType": content_type, + }, + }, + "S3Express": { + "bucket_name": s3_bucket, + }, + "auth_type": "v4", + "signing": { + "signing_name": s3_signing_name, + "disableDoubleEncoding": True, + }, + }, + }, + expires_in=3600, + operation_name="GetObject", + ) + + def test__get_presigned_url_for_thread_not_s3_url_error(self, *args, **kwargs): + test_url = "https://any.other/url" + + url, presigned_url, error = _get_presigned_url_for_thread(test_url) + + self.assertEqual(url, test_url) + self.assertIsNone(presigned_url) + self.assertEqual(error, f"Not a valid S3 URL: '{test_url}'") + + @patch("mephisto.generators.form_composer.remote_procedures.get_s3_presigned_url") + def test__get_presigned_url_for_thread_exception( + self, mock_get_s3_presigned_url, *args, **kwargs, + ): + test_url = "https://test-bucket-private.s3.amazonaws.com/path/image.png" + + mock_get_s3_presigned_url.side_effect = Exception("Error") + + url, presigned_url, error = _get_presigned_url_for_thread(test_url) + + self.assertEqual(url, test_url) + self.assertIsNone(presigned_url) + self.assertEqual(error, "Error") + + @patch("mephisto.generators.form_composer.remote_procedures.get_s3_presigned_url") + def test__get_presigned_url_for_thread_success( + self, mock_get_s3_presigned_url, *args, **kwargs, + ): + presigned_url_expected = "presigned_url" + test_url = "https://test-bucket-private.s3.amazonaws.com/path/image.png" + + mock_get_s3_presigned_url.return_value = presigned_url_expected + + url, presigned_url, error = _get_presigned_url_for_thread(test_url) + + self.assertEqual(url, test_url) + self.assertEqual(presigned_url, presigned_url_expected) + self.assertIsNone(error) + + @patch("mephisto.generators.form_composer.remote_procedures.get_s3_presigned_url") + def test__get_multiple_presigned_urls_errors( + self, mock_get_s3_presigned_url, *args, **kwargs, + ): + test_url = "https://test-bucket-private.s3.amazonaws.com/path/image.png" + + mock_get_s3_presigned_url.side_effect = Exception("Error") + + with self.assertRaises(ValueError) as cm: + _get_multiple_presigned_urls("random-string", [test_url], None) + + self.assertEqual( + cm.exception.__str__(), + f"Could not presign URL '{test_url}' because of error: Error.", + ) + + @patch("mephisto.generators.form_composer.remote_procedures.get_s3_presigned_url") + def test__get_multiple_presigned_urls_success( + self, mock_get_s3_presigned_url, *args, **kwargs, + ): + presigned_url_expected = "presigned_url" + test_url = "https://test-bucket-private.s3.amazonaws.com/path/image.png" + + mock_get_s3_presigned_url.return_value = presigned_url_expected + + result = _get_multiple_presigned_urls("random-string", [test_url], None) + + self.assertEqual(result, [(test_url, presigned_url_expected)]) diff --git a/test/review_app/server/api/base_test_api_view_case.py b/test/review_app/server/api/base_test_api_view_case.py index ccba24639..3b2cf8420 100644 --- a/test/review_app/server/api/base_test_api_view_case.py +++ b/test/review_app/server/api/base_test_api_view_case.py @@ -13,7 +13,7 @@ from mephisto.abstractions.database import MephistoDB from mephisto.abstractions.databases.local_database import LocalMephistoDB -from mephisto.client.review_app.server import create_app +from mephisto.review_app.server import create_app class BaseTestApiViewCase(unittest.TestCase): diff --git a/test/review_app/server/api/test_task_export_results_json_view.py b/test/review_app/server/api/test_task_export_results_json_view.py index c0b77ae85..32bc7c5b3 100644 --- a/test/review_app/server/api/test_task_export_results_json_view.py +++ b/test/review_app/server/api/test_task_export_results_json_view.py @@ -10,15 +10,13 @@ from flask import url_for from mephisto.abstractions.providers.prolific.api import status -from mephisto.client.review_app.server.api.views.task_export_results_view import ( - get_result_file_path, -) +from mephisto.review_app.server.api.views.task_export_results_view import get_result_file_path from test.review_app.server.api.base_test_api_view_case import BaseTestApiViewCase class TestTaskExportResultsJsonView(BaseTestApiViewCase): @patch( - "mephisto.client.review_app.server.api.views.task_export_results_json_view.get_results_dir" + "mephisto.review_app.server.api.views.task_export_results_json_view.get_results_dir" ) def test_task_export_result_json_success(self, mock_get_results_dir, *args, **kwargs): mock_get_results_dir.return_value = self.data_dir diff --git a/test/review_app/server/api/test_task_export_results_view.py b/test/review_app/server/api/test_task_export_results_view.py index 80bdc3778..5d44bc8d2 100644 --- a/test/review_app/server/api/test_task_export_results_view.py +++ b/test/review_app/server/api/test_task_export_results_view.py @@ -10,9 +10,7 @@ from flask import url_for from mephisto.abstractions.providers.prolific.api import status -from mephisto.client.review_app.server.api.views.task_export_results_view import ( - get_result_file_path, -) +from mephisto.review_app.server.api.views.task_export_results_view import get_result_file_path from mephisto.data_model.constants.assignment_state import AssignmentState from mephisto.data_model.unit import Unit from mephisto.utils.testing import get_test_qualification @@ -22,7 +20,7 @@ class TestTaskExportResultsView(BaseTestApiViewCase): - @patch("mephisto.client.review_app.server.api.views.task_export_results_view.get_results_dir") + @patch("mephisto.review_app.server.api.views.task_export_results_view.get_results_dir") def test_task_export_result_success(self, mock_get_results_dir, *args, **kwargs): mock_get_results_dir.return_value = self.data_dir @@ -43,7 +41,7 @@ def test_task_export_result_success(self, mock_get_results_dir, *args, **kwargs) results_file_data = "Test JS" - results_file_path = get_result_file_path(self.data_dir, unit.task_id) + results_file_path = get_result_file_path(self.data_dir, unit.task_id, 1) f = open(results_file_path, "w") f.write(results_file_data) f.close() diff --git a/test/review_app/server/api/test_unit_data_static_view.py b/test/review_app/server/api/test_unit_data_static_view.py new file mode 100644 index 000000000..a24475b5f --- /dev/null +++ b/test/review_app/server/api/test_unit_data_static_view.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 + +# Copyright (c) Meta Platforms and its affiliates. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import unittest +from unittest.mock import patch + +from flask import url_for + +from mephisto.abstractions.providers.prolific.api import status +from mephisto.data_model.agent import Agent +from mephisto.utils.testing import get_test_agent +from mephisto.utils.testing import get_test_unit +from test.review_app.server.api.base_test_api_view_case import BaseTestApiViewCase + + +class TestUnitDataStaticView(BaseTestApiViewCase): + def test_unit_data_static_no_agent_not_found_error(self, *args, **kwargs): + unit_id = get_test_unit(self.db) + with self.app_context: + url = url_for("unit_data_static", unit_id=unit_id, filename="wrong.filename") + response = self.client.get(url) + result = response.json + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(result["error"], "File not found") + + @patch("mephisto.data_model.unit.Unit.get_assigned_agent") + def test_unit_data_static_with_agent_not_found_error( + self, mock_get_assigned_agent, *args, **kwargs, + ): + unit_id = get_test_unit(self.db) + agent_id = get_test_agent(self.db, unit_id=unit_id) + + mock_get_assigned_agent.return_value = Agent.get(self.db, agent_id) + + with self.app_context: + url = url_for("unit_data_static", unit_id=unit_id, filename="wrong.filename") + response = self.client.get(url) + result = response.json + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual( + result["error"], + "The requested URL was not found on the server. " + "If you entered the URL manually please check your spelling and try again.", + ) + + @patch("mephisto.data_model.agent.Agent.get_data_dir") + @patch("mephisto.data_model.unit.Unit.get_assigned_agent") + def test_unit_data_static_success_with_filename_from_fs( + self, mock_get_assigned_agent, mock_get_data_dir, *args, **kwargs, + ): + unit_id = get_test_unit(self.db) + agent_id = get_test_agent(self.db, unit_id=unit_id) + + mock_get_assigned_agent.return_value = Agent.get(self.db, agent_id) + mock_get_data_dir.return_value = self.data_dir + + filename_from_fs = "file.txt" + txt_file = f"{self.data_dir}/{filename_from_fs}" + f = open(txt_file, "w") + f.write("") + f.close() + + with self.app_context: + url = url_for("unit_data_static", unit_id=unit_id, filename=filename_from_fs) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.mimetype, "text/plain") + self.assertEqual(response.data, b"") + + @patch("mephisto.abstractions._subcomponents.agent_state.AgentState.get_parsed_data") + @patch("mephisto.data_model.agent.Agent.get_data_dir") + @patch("mephisto.data_model.unit.Unit.get_assigned_agent") + def test_unit_data_static_success_with_filename_by_original_name( + self, mock_get_assigned_agent, mock_get_data_dir, mock_get_parsed_data, *args, **kwargs, + ): + unit_id = get_test_unit(self.db) + agent_id = get_test_agent(self.db, unit_id=unit_id) + agent = Agent.get(self.db, agent_id) + + mock_get_assigned_agent.return_value = agent + mock_get_data_dir.return_value = self.data_dir + + filename_original_name = "original_file.txt" + filename_from_fs = "file.txt" + txt_file = f"{self.data_dir}/{filename_from_fs}" + f = open(txt_file, "w") + f.write("") + f.close() + + mock_get_parsed_data.return_value = { + "inputs": {}, + "outputs": { + "files": [ + { + "originalname": filename_original_name, + "filename": filename_from_fs, + }, + ], + }, + } + + with self.app_context: + url = url_for("unit_data_static", unit_id=unit_id, filename=filename_original_name) + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.mimetype, "text/plain") + self.assertEqual(response.data, b"") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/review_app/server/api/test_units_details_view.py b/test/review_app/server/api/test_units_details_view.py index fcf257293..79f73a1d6 100644 --- a/test/review_app/server/api/test_units_details_view.py +++ b/test/review_app/server/api/test_units_details_view.py @@ -53,8 +53,17 @@ def test_one_unit_success(self, *args, **kwargs): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(result["units"]), 1) self.assertEqual(first_unit["id"], int(unit_id)) - self.assertTrue("inputs" in first_unit) - self.assertTrue("outputs" in first_unit) + + unit_fields = [ + "has_task_source_review", + "id", + "inputs", + "outputs", + "prepared_inputs", + "unit_data_folder", + ] + for unit_field in unit_fields: + self.assertTrue(unit_field in first_unit) if __name__ == "__main__":