From 6725e72c7262b226939f49997d873ba75a8de942 Mon Sep 17 00:00:00 2001 From: Jason Koo Date: Thu, 9 Feb 2023 21:30:11 -0800 Subject: [PATCH 1/7] WIP Simplification update to use arrows only for generator assignment & config --- mock_generators/app.py | 35 +- mock_generators/logic/generate_mapping.py | 121 ++++++ mock_generators/models/generator.py | 2 + mock_generators/named_generators.json | 454 ++++++++++++++++++++++ mock_generators/tabs/design_tab.py | 7 +- mock_generators/tabs/importing_tab.py | 34 +- 6 files changed, 616 insertions(+), 37 deletions(-) create mode 100644 mock_generators/logic/generate_mapping.py create mode 100644 mock_generators/named_generators.json diff --git a/mock_generators/app.py b/mock_generators/app.py index 6caebff..f1391d7 100644 --- a/mock_generators/app.py +++ b/mock_generators/app.py @@ -54,31 +54,24 @@ imported_file = None # Streamlit runs from top-to-bottom from tabs 1 through 8. This is essentially one giant single page app. Earlier attempt to use Streamlit's multi-page app functionality resulted in an inconsistent state between pages. -tab1, tab2, tab3, tab4, tab5, tab6, tab7, tab8, tab9 = st.tabs(["Config >", "Design >", "Import >", "Mapping >", "Search Generators >", "Add New Generator >", "Generate >", "Export >", "Data Importer"]) -with tab1: +t0, t1, t2, t3, t4, t5 = st.tabs([ + "Config >", + "Design >", + "Import >", + "Generate >", + "Export >", + "Data Importer" +]) +with t0: config_tab() - -with tab2: +with t1: design_tab() - -with tab3: +with t2: import_tab() - -with tab4: - mapping_tab() - -with tab5: - generators_tab() - -with tab6: - create_tab() - -with tab7: +with t3: generate_tab() - -with tab8: +with t4: export_tab() - -with tab9: +with t5: data_importer_tab() \ No newline at end of file diff --git a/mock_generators/logic/generate_mapping.py b/mock_generators/logic/generate_mapping.py new file mode 100644 index 0000000..76eaaac --- /dev/null +++ b/mock_generators/logic/generate_mapping.py @@ -0,0 +1,121 @@ +# Builds mapping file from specially formatted arrows.app JSON file + +import json +from models.mapping import Mapping +from models.node_mapping import NodeMapping +from models.relationship_mapping import RelationshipMapping +from models.generator import Generator +import logging + +def generator_for_raw_property( + property_value: str, + generators: dict[str, Generator] + ) -> tuple[Generator, list[any]]: + """Returns a generator and args for a property""" + # Check that the property info is notated for mock data generation use + leading_bracket = property_value[0] + trailing_bracket = property_value[-1] + if leading_bracket != "{" or trailing_bracket != "}": + logging.warning(f'generate_mapping.py: generator_for_raw_property: property_value not wrapped in {{ and }}. Skipping generator assignment for property_value: {property_value}') + return (None, None) + + # The property value should be a JSON string. Convert to a dict obj + json_string = property_value[1:-2] + obj = json.loads(json_string) + + # Should only ever be one + for key, value in obj.items(): + generator_id = key + generator = generators.get(generator_id, None) + if generator is None: + logging.error(f'generate_mapping.py: generator_for_raw_property: generator_id {generator_id} not found in generators. Skipping generator assignment for property_value: {property_value}') + return None + + args = value + return (generator, args) + +def node_mappings_from( + node_dicts: list, + generators: dict[str, Generator] + ) -> list[NodeMapping]: + """Converts node information from JSON file to mapping objects""" + # Sample node_dict + # { + # "id": "n1", + # "position": { + # "x": 284.5, + # "y": -204 + # }, + # "caption": "Company", + # "labels": [], + # "properties": { + # "name": "{{\"company_name\":[]}}", + # "uuid": "{{\"uuid\":[8]}}", + # "{{count}}": "{{\"int\":[1]}}", + # "{{key}}": "uuid" + # }, + # "style": {} + # } + + node_mappings = [] + for node_dict in node_dicts: + + # Check for required properties dict + properties = node_dict.get("properties", None) + if properties is None: + logging.warning(f"generate_mappings: node_mappings_from: dict is missing properties: {node_dict}. Can not configure for data generation. Skipping node.") + continue + + # Determine count generator to use + count_generator_config = properties.get("{{count}}", None) + if count_generator_config is not None: + logging.warning(f"generate_mappings: node_mappings_from: node properties is missing {{count key}}: Skipping {node_dict}") + continue + + key = properties.get("{{key}}", None) + if key is not None: + logging.warning(f"generate_mappings: node_mappings_from: node properties is missing {{key}}: Skipping {node_dict}") + continue + + # Get proper generators for count generator + count_generator, count_args = generator_for_raw_property(count_generator_config, generators) + + # TODO: + # Create property mappings for properties + + # Assign correct property mapping as key property + + # Create node mapping + node_mapping = NodeMapping( + nid = node_dict["id"], + label = node_dict["label"], + properties = node_dict["properties"], + count_generator=count_generator, + count_args=count_args, + ) + # TODO: + # Run generators + node_mappings.append(node_mapping) + return node_mappings + + +def mapping_from_json(json_file: str) -> Mapping: + + # Validate json file + loaded_json = json.loads(json_file) + + # Check required elements needed + node_dicts = loaded_json.get("nodes", None) + if node_dicts is None: + raise Exception(f"generate_mappings: mapping_from_json: No nodes found in JSON file: {json}") + relationship_dicts = loaded_json.get("relationships", None) + if relationship_dicts is None: + raise Exception(f"generate_mappings: mapping_from_json: No relationships found in JSON file: {json}") + + # Convert source information to mapping objects + nodes = node_mappings_from(node_dicts) + + # Create mapping object + + + # return mapping \ No newline at end of file diff --git a/mock_generators/models/generator.py b/mock_generators/models/generator.py index 55ded5c..fa36587 100644 --- a/mock_generators/models/generator.py +++ b/mock_generators/models/generator.py @@ -171,6 +171,8 @@ def __init__( name: str, description: str, code_url: str, + # Information on arguments the generator CAN take. + # Argument values to use during generate are passed in the generate call args: list[GeneratorArg], tags: list[str] ): diff --git a/mock_generators/named_generators.json b/mock_generators/named_generators.json new file mode 100644 index 0000000..4023d76 --- /dev/null +++ b/mock_generators/named_generators.json @@ -0,0 +1,454 @@ +{ + "README":{ + "content": "This is the default list of all generators used by the app. If you add new generators they will be added to this file. The default_generators.json file contains a copy of this from the repo maintainer(s)" + }, + "uri": { + "args": [], + "code_url": "mock_generators/generators/05711cac.py", + "description": "Random URI with Faker library.", + "name": "URL", + "tags": [ + "uri", + "url" + ], + "type": "String" + }, + "email": { + "args": [ + { + "default": "", + "label": "Optional Domain (ie: company.com)", + "type": "String" + } + ], + "code_url": "mock_generators/generators/05add148.py", + "description": "Random email with Faker library.", + "name": "Email", + "tags": [ + "email" + ], + "type": "String" + }, + "float_from_list": { + "args": [ + { + "default": "", + "label": "List of float (ie: 1.0, 2.2, 3.3)", + "type": "String" + } + ], + "code_url": "mock_generators/generators/111d38e0.py", + "description": "Randomly selected float from a comma-seperated list of options.", + "name": "Float from list", + "tags": [ + "float", + "list" + ], + "type": "Float" + }, + "lorem_paragraphs": { + "args": [ + { + "default": 1, + "label": "Minimum Number", + "type": "Integer" + }, + { + "default": 10, + "label": "Maximum Number", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/338d576e.py", + "description": "String generator using the lorem-text package", + "name": "Paragraphs", + "tags": [ + "string", + "lorem", + "ipsum", + "paragraph", + "paragraphs" + ], + "type": "String" + }, + "int_range": { + "args": [ + { + "default": 1, + "label": "Min", + "type": "Integer" + }, + { + "default": 10, + "label": "Max", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/469b37c7.py", + "description": "Random integer from a min and max value argument. Argument values are inclusive.", + "name": "Int Range", + "tags": [ + "int", + "integer", + "number", + "num", + "count" + ], + "type": "Integer" + }, + "country": { + "args": [], + "code_url": "mock_generators/generators/470ff56f.py", + "description": "Country name generator using the Faker library.", + "name": "Country", + "tags": [ + "country", + "from" + ], + "type": "String" + }, + "pure_random": { + "args": [], + "code_url": "mock_generators/generators/4b0db60a.py", + "description": "Randomly assigns to a target node. Duplicates and orphan nodes possible.", + "name": "Pure Random", + "tags": [ + "random" + ], + "type": "Assignment" + }, + "bool": { + "args": [ + { + "default": 50, + "label": "Percent chance of true (out of 100)", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/57f2df99.py", + "description": "Bool generator using the Faker library.", + "name": "Bool", + "tags": [ + "bool", + "boolean" + ], + "type": "Bool" + }, + "first_name": { + "args": [], + "code_url": "mock_generators/generators/58e9ddbb.py", + "description": "First name generator using the Faker library", + "name": "First Name", + "tags": [ + "first", + "name" + ], + "type": "String" + }, + "last_name": { + "args": [], + "code_url": "mock_generators/generators/5929c11b.py", + "description": "Last name generator using the Faker library.", + "name": "Last Name", + "tags": [ + "last", + "name" + ], + "type": "String" + }, + "string_from_list": { + "args": [ + { + "default": "", + "label": "List of words (ie: alpha, brave, charlie)", + "type": "String" + } + ], + "code_url": "mock_generators/generators/5bf1fbd6.py", + "description": "Randomly selected string from a comma-seperated list of options.", + "name": "String from list", + "tags": [ + "string", + "list", + "word", + "words", + "status", + "type" + ], + "type": "String" + }, + "company_name": { + "args": [], + "code_url": "mock_generators/generators/5e30c30b.py", + "description": "Company name generator using the Faker library.", + "name": "Company Name", + "tags": [ + "company", + "name" + ], + "type": "String" + }, + "exhaustive_random": { + "args": [], + "code_url": "mock_generators/generators/73853311.py", + "description": "Assigns each source node to a random target node, until target node records are exhausted. No duplicates, no orphan to nodes.", + "name": "Exhaustive Random", + "tags": [ + "exhaustive" + ], + "type": "Assignment" + }, + "uuid": { + "args": [ + { + "default": 37, + "label": "Limit character length", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/78bc0765.py", + "description": "Random UUID 4 hash using Faker library. 37 Characters Max.", + "name": "UUID", + "tags": [ + "uuid", + "hash", + "unique", + "uid" + ], + "type": "String" + }, + "city": { + "args": [], + "code_url": "mock_generators/generators/92eeddbb.py", + "description": "City name generator using the Faker library.", + "name": "City", + "tags": [ + "city", + "name" + ], + "type": "String" + }, + "date": { + "args": [ + { + "default": "1970-01-01", + "label": "Oldest Date", + "type": "Datetime" + }, + { + "default": "2022-11-24", + "label": "Newest Date", + "type": "Datetime" + } + ], + "code_url": "mock_generators/generators/ab64469b.py", + "description": "Generate a random date between 2 specified dates. Exclusive of days specified.", + "name": "Date", + "tags": [ + "date", + "datetime", + "created", + "updated", + "at" + ], + "type": "Datetime" + }, + "technical_phrase": { + "args": [], + "code_url": "mock_generators/generators/c9a071b5.py", + "description": "Technobabble words all lower-cased. Faker Library", + "name": "Technical BS Phrase", + "tags": [ + "phrase", + "phrases", + "technical", + "jargon", + "task", + "description" + ], + "type": "String" + }, + "catch_phrase": { + "args": [], + "code_url": "mock_generators/generators/d1ebdc1a.py", + "description": "Phrase with first letter capitalized. Faker Library", + "name": "Catch Phrase", + "tags": [ + "phrase", + "phrases", + "catch", + "project", + "description" + ], + "type": "String" + }, + "string_from_csv": { + "args": [ + { + "default": "", + "label": "CSV Filepath", + "type": "String", + "hint": "mock_generators/datasets/tech_companies.csv", + "description":"" + }, + { + "default": "", + "label": "Header Column Field", + "type": "String", + "hint": "Company Name", + "description":"" + } + ], + "code_url": "mock_generators/generators/df2bbd43.py", + "description": "Random string row value from a specified csv file. Be certain field contains string values.", + "name": "String from CSV", + "tags": [ + "csv", + " string", + " random" + ], + "type": "String" + }, + "md5": { + "args": [ + { + "default": 33, + "label": "Limit Character Length", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/e0eb78b0.py", + "description": "Random MD5 hash using Faker library. 33 Characters max", + "name": "MD5", + "tags": [ + "md5", + "hash", + "unique" + ], + "type": "String" + }, + "int_from_list": { + "args": [ + { + "default": "", + "label": "List of integers (ie: 1, 2, 3)", + "type": "String" + } + ], + "code_url": "mock_generators/generators/e56d87a3.py", + "description": "Randomly selected int from a comma-seperated list of options. If no list provided, will return 0", + "name": "Int from list", + "tags": [ + "int", + "integer", + "number", + "num", + "count", + "list", + "salary", + "cost" + ], + "type": "Integer" + }, + "float_range": { + "args": [ + { + "default": 0.0, + "label": "Min", + "type": "Float" + }, + { + "default": 1.0, + "label": "Max", + "type": "Float" + }, + { + "default": 2, + "label": "Decimal Places", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/e8cff8c1.py", + "description": "Random float between a range. Inclusive.", + "name": "Float", + "tags": [ + "float", + "decimal", + "number", + "num", + "count", + "cost", + "price" + ], + "type": "Float" + }, + "int": { + "args": [ + { + "default": 1, + "label": "Value", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/ecdff22c.py", + "description": "Constant integer value", + "name": "Int", + "tags": [ + "int", + "integer", + "num", + "number", + "count" + ], + "type": "Integer" + }, + "lorem_words": { + "args": [ + { + "default": 1, + "label": "Minimum Number", + "type": "Integer" + }, + { + "default": 10, + "label": "Maximum Number", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/loremtext_words.py", + "description": "String generator using the lorem-text package", + "name": "Words", + "tags": [ + "words", + "lorem", + "text", + "description" + ], + "type": "String" + }, + "lorem_sentences": { + "args": [ + { + "default": 1, + "label": "Minimum Number", + "type": "Integer" + }, + { + "default": 10, + "label": "Maximum Number", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/loremtext_sentence.py", + "description": "String generator using the lorem-text package", + "name": "Sentences", + "tags": [ + "sentence", + "sentences", + "lorem", + "text", + "description" + ], + "type": "String" + } +} \ No newline at end of file diff --git a/mock_generators/tabs/design_tab.py b/mock_generators/tabs/design_tab.py index f390db8..7ce1035 100644 --- a/mock_generators/tabs/design_tab.py +++ b/mock_generators/tabs/design_tab.py @@ -12,4 +12,9 @@ def design_tab(): st.write(f"Design Data Model.\n\nUse the [arrows.app](https://arrows.app) then download the .json file to the Import tab.") st.markdown("--------") - components.iframe("https://arrows.app", height=1000, scrolling=False) \ No newline at end of file + c1, c2 = st.columns([8,2]) + with c1: + components.iframe("https://arrows.app", height=1000, scrolling=False) + with c2: + st.write("Generators") + st.markdown("--------") \ No newline at end of file diff --git a/mock_generators/tabs/importing_tab.py b/mock_generators/tabs/importing_tab.py index 59be758..f3c84a2 100644 --- a/mock_generators/tabs/importing_tab.py +++ b/mock_generators/tabs/importing_tab.py @@ -9,6 +9,7 @@ import sys import datetime import os +from logic.generate_mapping import mapping_from_json # Convenience functions def import_file(file) -> bool: @@ -127,19 +128,22 @@ def import_tab(): if current_file is not None: # Verfiy file is valid arrows JSON try: - json_file = json.loads(current_file) - nodes = json_file["nodes"] - relationships = json_file["relationships"] - if nodes is None: - nodes = [] - if relationships is None: - relationships = [] - st.session_state[IMPORTED_NODES] = nodes - st.session_state[IMPORTED_RELATIONSHIPS] = relationships - - st.write(f'Imported Data:') - st.write(f' - {len(nodes)} Nodes') - st.write(f' - {len(relationships)} Relationships') - + # json_file = json.loads(current_file) + # nodes = json_file["nodes"] + # relationships = json_file["relationships"] + # if nodes is None: + # nodes = [] + # if relationships is None: + # relationships = [] + # st.session_state[IMPORTED_NODES] = nodes + # st.session_state[IMPORTED_RELATIONSHIPS] = relationships + + # st.write(f'Imported Data:') + # st.write(f' - {len(nodes)} Nodes') + # st.write(f' - {len(relationships)} Relationships') + mapping = mapping_from_json(current_file) + except json.decoder.JSONDecodeError: - st.error('JSON file is not valid.') \ No newline at end of file + st.error('JSON file is not valid.') + except Exception as e: + st.error(f'Uncaught Error: {e}') \ No newline at end of file From 391e2114b4f21463001e242e570bdc7af66cdc0b Mon Sep 17 00:00:00 2001 From: Jason Koo Date: Mon, 13 Feb 2023 17:33:48 -0800 Subject: [PATCH 2/7] Mappings tabbed replaced with auto generated mappings from specially formatted arrows.app json files --- mock_generators/__init__.py | 2 + mock_generators/app.py | 4 +- mock_generators/constants.py | 2 +- mock_generators/generate_mapping.py | 272 +++++++++++++++++ mock_generators/generators/05add148.py | 11 +- mock_generators/logic/generate_mapping.py | 273 +++++++++++++++--- mock_generators/models/data_import.py | 6 +- mock_generators/models/generator.py | 34 +++ mock_generators/models/node_mapping.py | 54 +++- mock_generators/models/property_mapping.py | 18 +- .../models/relationship_mapping.py | 16 +- mock_generators/tabs/generate_tab.py | 49 ++-- mock_generators/tabs/importing_tab.py | 69 +++-- poetry.lock | 8 +- pyproject.toml | 8 + tests/__init__.py | 2 + tests/test_generate_mappings.py | 188 ++++++++++++ 17 files changed, 882 insertions(+), 134 deletions(-) create mode 100644 mock_generators/generate_mapping.py create mode 100644 tests/test_generate_mappings.py diff --git a/mock_generators/__init__.py b/mock_generators/__init__.py index e69de29..b31ecc4 100644 --- a/mock_generators/__init__.py +++ b/mock_generators/__init__.py @@ -0,0 +1,2 @@ + +__version__ = "0.1.0" \ No newline at end of file diff --git a/mock_generators/app.py b/mock_generators/app.py index f1391d7..6569c93 100644 --- a/mock_generators/app.py +++ b/mock_generators/app.py @@ -42,9 +42,7 @@ if CODE_TEMPLATE_FILE not in st.session_state: st.session_state[CODE_TEMPLATE_FILE] = DEFAULT_CODE_TEMPLATES_FILE if MAPPINGS not in st.session_state: - st.session_state[MAPPINGS] = Mapping( - nodes={}, - relationships={}) + st.session_state[MAPPINGS] = None # UI st.title("Mock Graph Data Generator") diff --git a/mock_generators/constants.py b/mock_generators/constants.py index 1085b0c..28ba3d3 100644 --- a/mock_generators/constants.py +++ b/mock_generators/constants.py @@ -1,7 +1,7 @@ import streamlit as st # Default local filepaths -DEFAULT_GENERATORS_SPEC_FILE = "mock_generators/generators.json" +DEFAULT_GENERATORS_SPEC_FILE = "mock_generators/named_generators.json" DEFAULT_GENERATORS_CODE_PATH = "mock_generators/generators" DEFAULT_ARROWS_SAMPLE_PATH = "mock_generators/samples/arrows.json" DEFAULT_IMPORTS_PATH = "mock_generators/imports" diff --git a/mock_generators/generate_mapping.py b/mock_generators/generate_mapping.py new file mode 100644 index 0000000..9d5b738 --- /dev/null +++ b/mock_generators/generate_mapping.py @@ -0,0 +1,272 @@ +# Builds mapping file from specially formatted arrows.app JSON file + +import json +from models.mapping import Mapping +from models.node_mapping import NodeMapping +from models.relationship_mapping import RelationshipMapping +from models.property_mapping import PropertyMapping +from models.generator import Generator +import logging +import uuid + +def generator_for_raw_property( + property_value: str, + generators: dict[str, Generator] + ) -> tuple[Generator, list[any]]: + """Returns a generator and args for a property""" + # Sample expected string: "{\"company_name\":[]}" + + # Check that the property info is notated for mock data generation use + # leading_bracket = property_value[0] + # trailing_bracket = property_value[-1] + # if leading_bracket != "{" or trailing_bracket != "}": + # logging.warning(f'generate_mapping.py: generator_for_raw_property: property_value not wrapped in {{ and }}. Skipping generator assignment for property_value: {property_value}') + # return (None, None) + + # # The property value should be a JSON string. Convert to a dict obj + # json_string = property_value[1:-1] + + try: + obj = json.loads(property_value) + except Exception as e: + logging.info(f'generate_mapping.py: generator_for_raw_property: Could not parse JSON string: {property_value}. Skipping generator assignment for property_value: {property_value}') + return (None, None) + + # Should only ever be one + for key, value in obj.items(): + generator_id = key + generator = generators.get(generator_id, None) + if generator is None: + logging.error(f'generate_mapping.py: generator_for_raw_property: generator_id {generator_id} not found in generators. Skipping generator assignment for property_value: {property_value}') + return None + + args = value + return (generator, args) + +def propertymappings_for_raw_properties( + raw_properties: dict[str, str], + generators: dict[str, Generator] + ) -> dict[str, PropertyMapping]: + """Returns a list of property mappings for a node or relationship""" + property_mappings = {} + for key, value in raw_properties.items(): + generator, args = generator_for_raw_property(value, generators) + if generator is None: + # TODO: Insert PropertyMapping with no generator? Use literal value? + continue + + pid = str(uuid.uuid4())[:8] + property_mapping = PropertyMapping( + pid = pid, + name=key, + generator=generator, + args=args + ) + property_mappings[pid] = property_mapping + return property_mappings + +def node_mappings_from( + node_dicts: list, + generators: dict[str, Generator] + ) -> dict[str, NodeMapping]: + """Converts node information from JSON file to mapping objects""" + # Sample node_dict + # { + # "id": "n1", + # "position": { + # "x": 284.5, + # "y": -204 + # }, + # "caption": "Company", + # "labels": [], + # "properties": { + # "name": "{{\"company_name\":[]}}", + # "uuid": "{{\"uuid\":[8]}}", + # "{{count}}": "{{\"int\":[1]}}", + # "{{key}}": "uuid" + # }, + # "style": {} + # } + + node_mappings = {} + for node_dict in node_dicts: + + # Check for required data in raw node dict from arrows.app json + position = node_dict.get("position", None) + if position is None: + logging.warning(f"generate_mappings: node_mappings_from: node properties is missing position key from: {node_dict}: Skipping {node_dict}") + continue + + caption = node_dict.get("caption", None) + if caption is None: + logging.warning(f"generate_mappings: node_mappings_from: node properties is missing caption key from: {node_dict}: Skipping {node_dict}") + continue + + + # Check for required properties dict + properties = node_dict.get("properties", None) + if properties is None: + logging.warning(f"generate_mappings: node_mappings_from: dict is missing properties: {node_dict}. Can not configure for data generation. Skipping node.") + continue + + # Determine count generator to use + count_generator_config = properties.get("{count}", None) + if count_generator_config is None: + logging.warning(f"generate_mappings: node_mappings_from: node properties is missing {{count}} key from properties: {properties}: Skipping {node_dict}") + continue + + # Get string name for key property. Value should be an unformatted string + key = properties.get("{key}", None) + if key is None: + logging.warning(f"generate_mappings: node_mappings_from: node properties is missing {{key}}: Skipping {node_dict}") + continue + + # Get proper generators for count generator + count_generator, count_args = generator_for_raw_property(count_generator_config, generators) + + # Create property mappings for properties + property_mappings = propertymappings_for_raw_properties(properties, generators) + + # Assign correct property mapping as key property + # logging.info(f'generate_mappings: node_mappings_from: key_property: {key}, property_mappings: {property_mappings}') + key_property = next((v for k,v in property_mappings.items() if v.name == key), None) + if key_property is None: + logging.warning(f"generate_mappings: node_mappings_from: No key property mapping found for node: {node_dict} - key name: {key}. Skipping node.") + continue + + # Create node mapping + node_mapping = NodeMapping( + nid = node_dict["id"], + labels = node_dict["labels"], + properties = property_mappings, + count_generator=count_generator, + count_args=count_args, + key_property=key_property, + position = position, + caption=caption + ) + # TODO: + # Run generators + node_mappings[node_mapping.nid] = node_mapping + return node_mappings + +def relationshipmappings_from( + relationship_dicts: list[dict], + nodes: dict[str, NodeMapping], + generators: dict[str, Generator] + ) -> dict[str,RelationshipMapping]: + # Sample relationship_dict + # { + # "id": "n0", + # "fromId": "n1", + # "toId": "n0", + # "type": "EMPLOYS", + # "properties": { + # "{count}": "{\"int\":[10]}", + # "{assignment}": "{\"exhaustive_random\":[]}", + # "{filter}": "{string_from_list:[]}" + # }, + # "style": {} + # }, + relationshipmappings = {} + for relationship_dict in relationship_dicts: + # Check for required data in raw node dict from arrows.app json + + rid = relationship_dict.get("id", None) + if rid is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing 'id' key from: {relationship_dict}: Skipping {relationship_dict}") + continue + type = relationship_dict.get("type", None) + if type is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing 'type' key from: {relationship_dict}: Skipping {relationship_dict}") + continue + from_id = relationship_dict.get("fromId", None) + if from_id is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing 'fromId' key from: {relationship_dict}: Skipping {relationship_dict}") + continue + + to_id = relationship_dict.get("toId", None) + if to_id is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing 'toId' key from: {relationship_dict}: Skipping {relationship_dict}") + continue + + # Check for required properties dict + properties = relationship_dict.get("properties", None) + if properties is None: + logging.warning(f"generate_mappings: relationshipmappings_from: dict is missing properties: {relationship_dict}. Can not configure for data generation. Skipping relationship.") + continue + + # Determine count generator to use + count_generator_config = properties.get("{count}", None) + if count_generator_config is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing '{{count}}' key from properties: {properties}: Skipping {relationship_dict}") + continue + + assignment_generator_config = properties.get("{assignment}", None) + # If missing, use ExhaustiveRandom + if assignment_generator_config is None: + assignment_generator_config = "{\"exhaustive_random\":[]}" + + # Get proper generators for count generator + count_generator, count_args = generator_for_raw_property(count_generator_config, generators) + + # Create property mappings for properties + property_mappings = propertymappings_for_raw_properties(properties, generators) + + assignment_generator, assignment_args = generator_for_raw_property(assignment_generator_config, generators) + + from_node = nodes.get(from_id, None) + if from_node is None: + logging.warning(f"generate_mappings: relationshipmappings_from: No node mapping found for relationship: {relationship_dict} - fromId: {from_id}. Skipping relationship.") + continue + + to_node = nodes.get(to_id, None) + if to_node is None: + logging.warning(f"generate_mappings: relationshipmappings_from: No node mapping found for relationship: {relationship_dict} - toId: {to_id}. Skipping relationship.") + continue + + # Create relationship mapping + relationship_mapping = RelationshipMapping( + rid = rid, + type = type, + from_node = from_node, + to_node = to_node , + count_generator=count_generator, + count_args=count_args, + properties=property_mappings, + assignment_generator= assignment_generator, + assignment_args=assignment_args + ) + relationshipmappings[relationship_mapping.rid] = relationship_mapping + + return relationshipmappings + + +def mapping_from_json( + json_file: str, + generators: list[Generator]) -> Mapping: + + try: + # Validate json file + loaded_json = json.loads(json_file) + except Exception as e: + raise Exception(f"generate_mappings: mapping_from_json: Error loading JSON file: {e}") + + # Extract and process nodes + node_dicts = loaded_json.get("nodes", None) + if node_dicts is None: + raise Exception(f"generate_mappings: mapping_from_json: No nodes found in JSON file: {json}") + relationship_dicts = loaded_json.get("relationships", None) + if relationship_dicts is None: + raise Exception(f"generate_mappings: mapping_from_json: No relationships found in JSON file: {json}") + + # Convert source information to mapping objects + nodes = node_mappings_from(node_dicts, generators) + relationships = relationshipmappings_from(relationship_dicts, nodes, generators) + + # Create mapping object + mapping = Mapping( + nodes=nodes, + relationships=relationships + ) + return mapping diff --git a/mock_generators/generators/05add148.py b/mock_generators/generators/05add148.py index 24a7933..9125da6 100644 --- a/mock_generators/generators/05add148.py +++ b/mock_generators/generators/05add148.py @@ -3,9 +3,12 @@ # Do not change function name or arguments def generate(args: list[any]): - domain = args[0] - if domain == "" or domain is None: + result = None + if len(args) != 0: + domain = args[0] + if domain != "" and domain is not None: + result = fake.email(domain=domain) + if result is None: result = fake.email() - else: - result = fake.email(domain=domain) + return result \ No newline at end of file diff --git a/mock_generators/logic/generate_mapping.py b/mock_generators/logic/generate_mapping.py index 76eaaac..f8db29b 100644 --- a/mock_generators/logic/generate_mapping.py +++ b/mock_generators/logic/generate_mapping.py @@ -4,40 +4,91 @@ from models.mapping import Mapping from models.node_mapping import NodeMapping from models.relationship_mapping import RelationshipMapping +from models.property_mapping import PropertyMapping from models.generator import Generator import logging +import uuid def generator_for_raw_property( property_value: str, generators: dict[str, Generator] ) -> tuple[Generator, list[any]]: - """Returns a generator and args for a property""" - # Check that the property info is notated for mock data generation use - leading_bracket = property_value[0] - trailing_bracket = property_value[-1] - if leading_bracket != "{" or trailing_bracket != "}": - logging.warning(f'generate_mapping.py: generator_for_raw_property: property_value not wrapped in {{ and }}. Skipping generator assignment for property_value: {property_value}') - return (None, None) - - # The property value should be a JSON string. Convert to a dict obj - json_string = property_value[1:-2] - obj = json.loads(json_string) - - # Should only ever be one + """Returns a generator and args for specially formatted property values from the arrows.app JSON file""" + # Sample expected string: "{\"company_name\":[]}" + + # Throws an error if a generator can not be found + + obj = json.loads(property_value) + + if len(obj) == 0: + raise Exception(f'generate_mapping.py: generator_for_raw_property: Expected dictionary object from json string not found: {property_value}') + + generator = None + args = None + # Should only be one item, if not take the last for key, value in obj.items(): generator_id = key generator = generators.get(generator_id, None) if generator is None: - logging.error(f'generate_mapping.py: generator_for_raw_property: generator_id {generator_id} not found in generators. Skipping generator assignment for property_value: {property_value}') - return None - + raise Exception(f'generate_mapping.py: generator_for_raw_property: generator_id {generator_id} not found in generators.') args = value - return (generator, args) + return (generator, args) + +def propertymappings_for_raw_properties( + raw_properties: dict[str, str], + generators: dict[str, Generator] + ) -> dict[str, PropertyMapping]: + """Returns a list of property mappings for a node or relationship""" + + # raw_properties is a dict of key value pairs from properties value from an entry from the arrows.app JSON file. Example: + # { + # "name": "{\"company_name\":[]}", + # "uuid": "{\"uuid\":[8]}", + # "{count}": "{\"int\":[1]}", + # "{key}": "uuid" + # }, + + property_mappings = {} + + if raw_properties is None or len(raw_properties) == 0: + raise Exception(f'generate_mapping.py: propertymappings_for_raw_properties: No raw_properties assignment received.') + + if generators is None or len(generators) == 0: + raise Exception(f'generate_mapping.py: propertymappings_for_raw_properties: No generators assignment received.') + + for key, value in raw_properties.items(): + # Skip any keys with { } (brackets) as these are special cases for defining count/assignment/filter generators + if key.startswith('{') and key.endswith('}'): + continue + + # Only process values with string { } (brackets) + if not isinstance(value, str) or not value.startswith('{') or not value.endswith('}'): + property_mappings[key] = value + continue + try: + generator, args = generator_for_raw_property(value, generators) + if generator is None: + # TODO: Insert PropertyMapping with no generator? Use literal value? + logging.warning(f'generate_mapping.py: propertymappings_for_raw_properties: could not find generator for key: {key}, property_value: {value}') + continue + + pid = str(uuid.uuid4())[:8] + property_mapping = PropertyMapping( + pid = pid, + name=key, + generator=generator, + args=args + ) + property_mappings[pid] = property_mapping + except Exception as e: + logging.warning(f'generate_mapping.py: propertymappings_for_raw_properties: could not create property mapping for key: {key}, property_value: {value}: {e}') + continue + return property_mappings def node_mappings_from( node_dicts: list, generators: dict[str, Generator] - ) -> list[NodeMapping]: + ) -> dict[str, NodeMapping]: """Converts node information from JSON file to mapping objects""" # Sample node_dict # { @@ -49,17 +100,29 @@ def node_mappings_from( # "caption": "Company", # "labels": [], # "properties": { - # "name": "{{\"company_name\":[]}}", - # "uuid": "{{\"uuid\":[8]}}", - # "{{count}}": "{{\"int\":[1]}}", - # "{{key}}": "uuid" + # "name": "{\"company_name\":[]}", + # "uuid": "{\"uuid\":[8]}}", + # "{count}": "{{"int\":[1]}", + # "{key}": "uuid" # }, # "style": {} # } - node_mappings = [] + node_mappings = {} for node_dict in node_dicts: + # Check for required data in raw node dict from arrows.app json + position = node_dict.get("position", None) + if position is None: + logging.warning(f"generate_mappings: node_mappings_from: node properties is missing position key from: {node_dict}: Skipping {node_dict}") + continue + + caption = node_dict.get("caption", None) + if caption is None: + logging.warning(f"generate_mappings: node_mappings_from: node properties is missing caption key from: {node_dict}: Skipping {node_dict}") + continue + + # Check for required properties dict properties = node_dict.get("properties", None) if properties is None: @@ -67,44 +130,169 @@ def node_mappings_from( continue # Determine count generator to use - count_generator_config = properties.get("{{count}}", None) - if count_generator_config is not None: - logging.warning(f"generate_mappings: node_mappings_from: node properties is missing {{count key}}: Skipping {node_dict}") + count_generator_config = properties.get("{count}", None) + if count_generator_config is None: + logging.warning(f"generate_mappings: node_mappings_from: node properties is missing {{count}} key from properties: {properties}: Skipping {node_dict}") continue - key = properties.get("{{key}}", None) - if key is not None: + # Get string name for key property. Value should be an unformatted string + key = properties.get("{key}", None) + if key is None: logging.warning(f"generate_mappings: node_mappings_from: node properties is missing {{key}}: Skipping {node_dict}") continue # Get proper generators for count generator - count_generator, count_args = generator_for_raw_property(count_generator_config, generators) + try: + count_generator, count_args = generator_for_raw_property(count_generator_config, generators) + except Exception as e: + logging.warning(f"generate_mappings: node_mappings_from: could not find count generator for node: {node_dict}: {e}") + continue - # TODO: # Create property mappings for properties + try: + property_mappings = propertymappings_for_raw_properties(properties, generators) + except Exception as e: + logging.warning(f"generate_mappings: node_mappings_from: could not create property mappings for node: {node_dict}: {e}") + continue # Assign correct property mapping as key property + # logging.info(f'generate_mappings: node_mappings_from: key_property: {key}, property_mappings: {property_mappings}') + key_property = next((v for k,v in property_mappings.items() if v.name == key), None) + if key_property is None: + logging.warning(f"generate_mappings: node_mappings_from: No key property mapping found for node: {node_dict} - key name: {key}. Skipping node.") + continue # Create node mapping node_mapping = NodeMapping( nid = node_dict["id"], - label = node_dict["label"], - properties = node_dict["properties"], + labels = node_dict["labels"], + properties = property_mappings, count_generator=count_generator, count_args=count_args, + key_property=key_property, + position = position, + caption=caption ) # TODO: # Run generators - node_mappings.append(node_mapping) + node_mappings[node_mapping.nid] = node_mapping return node_mappings +def relationshipmappings_from( + relationship_dicts: list[dict], + nodes: dict[str, NodeMapping], + generators: dict[str, Generator] + ) -> dict[str,RelationshipMapping]: + # Sample relationship_dict + # { + # "id": "n0", + # "fromId": "n1", + # "toId": "n0", + # "type": "EMPLOYS", + # "properties": { + # "{count}": "{\"int\":[10]}", + # "{assignment}": "{\"exhaustive_random\":[]}", + # "{filter}": "{string_from_list:[]}" + # }, + # "style": {} + # }, + relationshipmappings = {} + for relationship_dict in relationship_dicts: + # Check for required data in raw node dict from arrows.app json + + rid = relationship_dict.get("id", None) + if rid is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing 'id' key from: {relationship_dict}: Skipping {relationship_dict}") + continue + type = relationship_dict.get("type", None) + if type is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing 'type' key from: {relationship_dict}: Skipping {relationship_dict}") + continue + from_id = relationship_dict.get("fromId", None) + if from_id is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing 'fromId' key from: {relationship_dict}: Skipping {relationship_dict}") + continue + + to_id = relationship_dict.get("toId", None) + if to_id is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing 'toId' key from: {relationship_dict}: Skipping {relationship_dict}") + continue + + # Check for required properties dict + properties = relationship_dict.get("properties", None) + if properties is None: + logging.warning(f"generate_mappings: relationshipmappings_from: dict is missing properties: {relationship_dict}. Can not configure for data generation. Skipping relationship.") + continue + + # Determine count generator to use + count_generator_config = properties.get("{count}", None) + if count_generator_config is None: + logging.warning(f"generate_mappings: relationshipmappings_from: relationship properties is missing '{{count}}' key from properties: {properties}: Skipping {relationship_dict}") + continue + + assignment_generator_config = properties.get("{assignment}", None) + # If missing, use ExhaustiveRandom + if assignment_generator_config is None: + assignment_generator_config = "{\"exhaustive_random\":[]}" + + # Get proper generators for count generator + try: + count_generator, count_args = generator_for_raw_property(count_generator_config, generators) + except Exception as e: + logging.warning(f"generate_mappings: relationshipmappings_from: could not find count generator for relationship: {relationship_dict}: {e}") + continue + + # Create property mappings for properties + try: + property_mappings = propertymappings_for_raw_properties(properties, generators) + except Exception as e: + logging.warning(f"generate_mappings: relationshipmappings_from: could not create property mappings for relationship: {relationship_dict}: {e}") + continue -def mapping_from_json(json_file: str) -> Mapping: + try: + assignment_generator, assignment_args = generator_for_raw_property(assignment_generator_config, generators) + except Exception as e: + logging.warning(f"generate_mappings: relationshipmappings_from: could not create assignment generator for relationship: {relationship_dict}: {e}") + continue - # Validate json file - loaded_json = json.loads(json_file) + from_node = nodes.get(from_id, None) + if from_node is None: + logging.warning(f"generate_mappings: relationshipmappings_from: No node mapping found for relationship: {relationship_dict} - fromId: {from_id}. Skipping relationship.") + continue - # Check required elements needed + to_node = nodes.get(to_id, None) + if to_node is None: + logging.warning(f"generate_mappings: relationshipmappings_from: No node mapping found for relationship: {relationship_dict} - toId: {to_id}. Skipping relationship.") + continue + + # Create relationship mapping + relationship_mapping = RelationshipMapping( + rid = rid, + type = type, + from_node = from_node, + to_node = to_node , + count_generator=count_generator, + count_args=count_args, + properties=property_mappings, + assignment_generator= assignment_generator, + assignment_args=assignment_args + ) + relationshipmappings[relationship_mapping.rid] = relationship_mapping + + return relationshipmappings + + +def mapping_from_json( + json_file: str, + generators: list[Generator]) -> Mapping: + + try: + # Validate json file + loaded_json = json.loads(json_file) + except Exception as e: + raise Exception(f"generate_mappings: mapping_from_json: Error loading JSON file: {e}") + + # Extract and process nodes node_dicts = loaded_json.get("nodes", None) if node_dicts is None: raise Exception(f"generate_mappings: mapping_from_json: No nodes found in JSON file: {json}") @@ -113,9 +301,12 @@ def mapping_from_json(json_file: str) -> Mapping: raise Exception(f"generate_mappings: mapping_from_json: No relationships found in JSON file: {json}") # Convert source information to mapping objects - nodes = node_mappings_from(node_dicts) + nodes = node_mappings_from(node_dicts, generators) + relationships = relationshipmappings_from(relationship_dicts, nodes, generators) # Create mapping object - - - # return mapping \ No newline at end of file + mapping = Mapping( + nodes=nodes, + relationships=relationships + ) + return mapping diff --git a/mock_generators/models/data_import.py b/mock_generators/models/data_import.py index 40d93fc..dfe3b93 100644 --- a/mock_generators/models/data_import.py +++ b/mock_generators/models/data_import.py @@ -18,9 +18,10 @@ def file_schema_for_property(property: PropertyMapping)-> dict: # if property.type == GeneratorType.DATETIME: # sample_value = sample_value.isoformat() + type = property.generator.type.to_string() result = { "name": property.name, - "type": property.type.to_string().lower(), + "type": type, "sample": sample_value, "include": True } @@ -47,7 +48,7 @@ def file_schema_for_relationship(relationship: RelationshipMapping)-> list[dict] def graph_model_property(property: PropertyMapping)-> dict: result = { "property": property.name, - "type": property.type.to_string().lower(), + "type": property.generator.type.to_string().lower(), "identifier": property.pid } return result @@ -198,6 +199,7 @@ def add_relationship( ): # In case relationshipMapping has not yet generated data + # TODO: Need this here anymore? if relationship.generated_values is None: relationship.generate_values() diff --git a/mock_generators/models/generator.py b/mock_generators/models/generator.py index fa36587..f65b676 100644 --- a/mock_generators/models/generator.py +++ b/mock_generators/models/generator.py @@ -104,6 +104,21 @@ def __str__(self): def __repr__(self): return self.__str__() + def __eq__(self, other): + if isinstance(other, GeneratorArg) == False: + return False + if self.type != other.type: + return False + if self.label != other.label: + return False + if self.default != other.default: + return False + if self.hint != other.hint: + return False + if self.description != other.description: + return False + return True + def to_dict(self): return { "type": self.type.to_string(), @@ -204,6 +219,25 @@ def __str__(self): def __repr__(self): return self.__str__() + def __eq__(self, other): + if isinstance(other, Generator) == False: + return False + if self.id != other.id: + return False + if self.name != other.name: + return False + if self.description != other.description: + return False + if self.code_url != other.code_url: + return False + if self.args != other.args: + return False + if self.type != other.type: + return False + if self.tags != other.tags: + return False + return True + def generate(self, args): # Args are not the same as the generator args, these are the arg inputs from user module = __import__(self.import_url(), fromlist=['generate']) diff --git a/mock_generators/models/node_mapping.py b/mock_generators/models/node_mapping.py index 95aae35..7ef77ff 100644 --- a/mock_generators/models/node_mapping.py +++ b/mock_generators/models/node_mapping.py @@ -46,18 +46,42 @@ def __str__(self): def __repr__(self): return self.__str__() + def __eq__(self, other): + if not isinstance(other, NodeMapping): + return False + if self.nid != other.nid: + return False + if self.caption != other.caption: + return False + if self.labels != other.labels: + return False + if self.properties != other.properties: + return False + if self.count_generator != other.count_generator: + return False + if self.count_args != other.count_args: + return False + if self.key_property != other.key_property: + return False + return True def to_dict(self): - return { - "nid": self.nid, - "caption": self.caption, - "position": self.position, - "labels": self.labels, - "properties": {key: property.to_dict() for (key, property) in self.properties.items() if property.type is not None}, - "count_generator": self.count_generator.to_dict() if self.count_generator is not None else None, - "count_args": clean_list(self.count_args), - "key_property" : self.key_property.to_dict() if self.key_property is not None else None - } + properties = {} + for key, property in self.properties.items(): + if isinstance(property, PropertyMapping): + properties[key] = property.to_dict() + continue + properties[key] = property + return { + "nid": self.nid, + "caption": self.caption, + "position": self.position, + "labels": self.labels, + "properties": properties, + "count_generator": self.count_generator.to_dict() if self.count_generator is not None else None, + "count_args": clean_list(self.count_args), + "key_property" : self.key_property.to_dict() if self.key_property is not None else None + } def filename(self): return f"{self.caption.lower()}_{self.nid.lower()}" @@ -98,9 +122,15 @@ def generate_values(self) -> list[dict]: try: for _ in range(count): node_result = {} - for property_name, property in self.properties.items(): + # logging.info(f'node_mapping.py: NodeMapping.generate_values() generating values for node mapping \'{self.caption}\' with properties {self.properties}') + for property_id, property in self.properties.items(): + # Pass literal values + if isinstance(property, PropertyMapping) == False: + node_result[property_id] = property + continue + # Have PropertyMapping generate a value value = property.generate_value() - node_result[property_name] = value + node_result[property.name] = value # node_result["_uid"] = f"{self.id}_{str(uuid.uuid4())[:8]}" all_results.append(node_result) except: diff --git a/mock_generators/models/property_mapping.py b/mock_generators/models/property_mapping.py index 8d5849e..e98354a 100644 --- a/mock_generators/models/property_mapping.py +++ b/mock_generators/models/property_mapping.py @@ -9,7 +9,7 @@ def empty(): return PropertyMapping( pid = None, name = None, - type = None, + # type = None, generator = None, args = None ) @@ -18,20 +18,20 @@ def __init__( self, pid: str, name: str = None, - type: GeneratorType = None, + # type: GeneratorType = None, generator: Generator = None, # Args to pass into generator during running args: list[any] = []): self.pid = pid self.name = name - self.type = type + # self.type = type self.generator = generator self.args = args def __str__(self): name = self.name if self.name is not None else "" generator = self.generator if self.generator is not None else "" - return f"PropertyMapping(pid={self.pid}, name={name}, type={self.type}, generator={generator}, args={self.args}" + return f"PropertyMapping(pid={self.pid}, name={name}, generator={generator}, args={self.args}" def __repr__(self): return self.__str__() @@ -43,7 +43,7 @@ def to_dict(self): return { "pid": self.pid, "name": self.name, - "type": self.type.to_string() if self.type is not None else None, + # "type": self.type.to_string() if self.type is not None else None, "generator": self.generator.to_dict() if self.generator is not None else None, "args": clean_list(self.args) } @@ -51,15 +51,17 @@ def to_dict(self): def ready_to_generate(self): if self.name is None: return False - if self.type is None: - return False + # if self.type is None: + # return False if self.generator is None: return False return True def generate_value(self): if self.generator == None: - logging.error(f'Generator is not set for property {self.name}') + logging.error(f'property_mapping.py: generate_value: Generator is not set for property {self.name}') + if isinstance(self.args, list) == False: + logging.error(f'property_mapping.py: generate_value: Args for generator is not a list for property {self.name}') result = self.generator.generate(self.args) return result diff --git a/mock_generators/models/relationship_mapping.py b/mock_generators/models/relationship_mapping.py index 0771e13..45c8e3b 100644 --- a/mock_generators/models/relationship_mapping.py +++ b/mock_generators/models/relationship_mapping.py @@ -98,15 +98,6 @@ def generate_values( self, )-> list[dict]: - # Sample incoming all_node_values: - # { - # "":[ - # { - # "first_name":"John", - # "last_name":"Doe" - # } - # ] - # } # Make sure from and to nodes have generated values already if self.from_node.generated_values == None: @@ -151,9 +142,11 @@ def generate_values( # Get the key property name and value for the source node record from_node_key_property_name = self.from_node.key_property.name - from_node_key_property_value = value_dict[from_node_key_property_name] + from_node_key_property_value = value_dict.get(from_node_key_property_name, None) + if from_node_key_property_value is None: + raise Exception(f"Key property '{from_node_key_property_name}' not found in node: {value_dict}") - # If count is zero - no relationship to generate for the curent source node + # If count is zero - no relationship to generate for the current source node # Generate a new relationship for each count for i in range(count): @@ -197,5 +190,6 @@ def generate_values( # Store results for reference self.generated_values = all_results + # logging.info(f'relationship_mapping.py: 1 value: {self.generated_values[0]}') return self.generated_values \ No newline at end of file diff --git a/mock_generators/tabs/generate_tab.py b/mock_generators/tabs/generate_tab.py index e1f2df4..5974fed 100644 --- a/mock_generators/tabs/generate_tab.py +++ b/mock_generators/tabs/generate_tab.py @@ -1,6 +1,6 @@ import streamlit as st from constants import * -# from models.mapping import Mapping +from models.mapping import Mapping from logic.generate_csv import generate_csv from logic.generate_data_import import generate_data_importer_json import os @@ -20,6 +20,8 @@ def generate_tab(): st.markdown("--------") mapping = st.session_state[MAPPINGS] + if mapping is None: + mapping = Mapping.empty() export_folder = st.session_state[EXPORTS_PATH] zips_folder = st.session_state[ZIPS_PATH] imported_filename = st.session_state[IMPORTED_FILENAME] @@ -31,27 +33,27 @@ def generate_tab(): export_zip_filename.replace(" ", "_") export_zip_filename.replace(".", "_") - g1, g2, g3, g4 = st.columns(4) + g2, g3, g4 = st.columns(3) - with g1: - st.write('MAPPING DATA:') - if MAPPINGS not in st.session_state: - # Why hasn't mappings been preloaded by now? - st.error(f'Mappings data was not preloaded') - elif st.session_state[MAPPINGS] is None: - st.error(f'Mappping option not valid for generation. Please configure mapping options below.') - elif st.session_state[MAPPINGS].is_empty() == True: - st.error(f'No data currently mapped. Please configure in Mapping tab.') - elif st.session_state[MAPPINGS].is_valid() == False: - st.error(f'Mappping option not valid for generation. Please configure in Mapping tab.') - else: - st.success(f'Mappping options valid for generation.') + # with g1: + # st.write('MAPPING DATA:') + # if MAPPINGS not in st.session_state: + # # Why hasn't mappings been preloaded by now? + # st.error(f'Mappings data was not preloaded') + # elif st.session_state[MAPPINGS] is None: + # st.error(f'Mappping option not valid for generation. Please configure mapping options below.') + # elif st.session_state[MAPPINGS].is_empty() == True: + # st.error(f'No data currently mapped. Please configure in Mapping tab.') + # elif st.session_state[MAPPINGS].is_valid() == False: + # st.error(f'Mappping option not valid for generation. Please configure in Mapping tab.') + # else: + # st.success(f'Mappping options valid for generation.') - # For the curious - with st.expander("Raw Mapping Data"): - st.json(mapping.to_dict()) + # # For the curious + # with st.expander("Raw Mapping Data"): + # st.json(mapping.to_dict()) - # should_clear = st.checkbox("Delete all files in export folder with each Generate run", value=True) + # # should_clear = st.checkbox("Delete all files in export folder with each Generate run", value=True) with g2: st.write(f'READY FOR GENERATION:') @@ -67,12 +69,17 @@ def generate_tab(): st.stop() return - for key, node in mapping.nodes.items(): + # Generate values from mappings + for _, node in mapping.nodes.items(): # logging.info(f'Generating data for node: {node}') if len(node.properties) == 0: st.error(f'Node {node.caption} has no properties. Add at least one property to generate data.') st.stop() return + node.generate_values() + + for _, rel in mapping.relationships.items(): + rel.generate_values() # Delete all files in export folder first dir = export_folder @@ -130,7 +137,7 @@ def generate_tab(): with g4: st.write(f'GENERATED DATA SUMMARY:') # TODO: Why do these disappear when returning to tab? - nodes = st.session_state[MAPPINGS].nodes + nodes = mapping.nodes for _, node in nodes.items(): values = node.generated_values if values is not None: diff --git a/mock_generators/tabs/importing_tab.py b/mock_generators/tabs/importing_tab.py index f3c84a2..5aa1575 100644 --- a/mock_generators/tabs/importing_tab.py +++ b/mock_generators/tabs/importing_tab.py @@ -16,18 +16,17 @@ def import_file(file) -> bool: if file is None: raise Exception(f'import.py: import_file function called with no file') + # Check if file is a valid JSON file try: json_file = json.loads(file) # Bring in the raw import data for nodes and relationships node_dicts = json_file["nodes"] relationship_dicts = json_file["relationships"] if node_dicts is None: - node_dicts = [] + return False if relationship_dicts is None: - relationship_dicts = [] - - st.session_state[IMPORTED_NODES] = node_dicts - st.session_state[IMPORTED_RELATIONSHIPS] = relationship_dicts + return False + return True except json.decoder.JSONDecodeError: st.error(f'JSON file {file} is not valid.') @@ -51,9 +50,9 @@ def file_selected(path): # Import sucessful_import = import_file(selected_file) if sucessful_import: - st.success(f"Import Complete. Proceed to the Mapping page") + st.success(f"Import Complete.") else: - st.error(f"Import Failed. Please try again.") + st.error(f"Import Failed. Check file format.") def import_tab(): @@ -65,9 +64,11 @@ def import_tab(): st.markdown("--------") - i1, i2 = st.columns([3,1]) + i1, i3 = st.columns([3,3]) with i1: + # File Selection + selected_file = None import_option = st.radio("Select Import Source", ["An Existing File", "Upload"], horizontal=True) @@ -122,28 +123,42 @@ def import_tab(): # file_selected(selected_filepath) - with i2: - # Process uploaded / selected file + # with i2: + + # # Display loaded file + # st.write('IMPORT STATUS:') + + # # Process uploaded / selected file current_file = st.session_state[IMPORTED_FILE] if current_file is not None: # Verfiy file is valid arrows JSON try: - # json_file = json.loads(current_file) - # nodes = json_file["nodes"] - # relationships = json_file["relationships"] - # if nodes is None: - # nodes = [] - # if relationships is None: - # relationships = [] - # st.session_state[IMPORTED_NODES] = nodes - # st.session_state[IMPORTED_RELATIONSHIPS] = relationships - - # st.write(f'Imported Data:') - # st.write(f' - {len(nodes)} Nodes') - # st.write(f' - {len(relationships)} Relationships') - mapping = mapping_from_json(current_file) - + generators = st.session_state[GENERATORS] + mapping = mapping_from_json(current_file, generators) + st.session_state[MAPPINGS] = mapping + st.success(f"Mappings generated from import file.") + except json.decoder.JSONDecodeError: - st.error('JSON file is not valid.') + st.error('Import JSON file is not valid.') except Exception as e: - st.error(f'Uncaught Error: {e}') \ No newline at end of file + st.error(f'Uncaught Error: {e}') + # else: + # st.warning(f'No file selected. Please select a file to import.') + + with i3: + # Display auto-generated Mapping files + st.write('MAPPING DATA:') + mapping = st.session_state[MAPPINGS] + if mapping is None: + st.warning(f'No mapping data available. Import a file.') + elif mapping.is_empty() == True: + st.error(f'No mapping data extracted from imported file. Is the file correctly formatted?') + elif mapping.is_valid() == False: + st.error(f'Mappping invalid. Please check the imported file.') + else: + st.success(f'Mappping options valid for generation. Proceed to Generate Tab.') + + # For the curious + with st.expander("Raw Mapping Data"): + if mapping is not None: + st.json(mapping.to_dict()) \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 7d6be89..cee403b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -614,7 +614,7 @@ python-versions = ">=3.7" [[package]] name = "pytest" -version = "7.2.0" +version = "7.2.1" description = "pytest: simple powerful testing with Python" category = "main" optional = false @@ -1034,7 +1034,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.11" -content-hash = "bc8d3f2168c9cbf29966625ee8b2b50c6deb273211819f880877a985f9b30fb7" +content-hash = "e54338f67cd8efdf16c56db762d08a46337efa7ec811bf640c4dfa206e1905e4" [metadata.files] altair = [ @@ -1694,8 +1694,8 @@ pyrsistent = [ {file = "pyrsistent-0.19.2.tar.gz", hash = "sha256:bfa0351be89c9fcbcb8c9879b826f4353be10f58f8a677efab0c017bf7137ec2"}, ] pytest = [ - {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, - {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, + {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, + {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, ] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, diff --git a/pyproject.toml b/pyproject.toml index 9215d63..f025492 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,14 @@ pytest = "^7.2.0" dataclasses-json = "^0.5.7" +[tool.poetry.group.dev.dependencies] +pytest = "^7.2.1" + +[tool.pytest.ini_options] +pythonpath = [ + ".", "mock_generators" +] + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..b7be57f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +import sys +sys.path.append('.') \ No newline at end of file diff --git a/tests/test_generate_mappings.py b/tests/test_generate_mappings.py new file mode 100644 index 0000000..7a3b427 --- /dev/null +++ b/tests/test_generate_mappings.py @@ -0,0 +1,188 @@ +import pytest +# from mock_generators import __version__ + +from mock_generators.models.generator import Generator, GeneratorType, GeneratorArg + +from mock_generators.models.node_mapping import NodeMapping + +from mock_generators.generate_mapping import generator_for_raw_property, mapping_from_json, propertymappings_for_raw_properties, node_mappings_from + +import json +# For pytest to properly find modules, add following to pyproject.toml: +# [tool.pytest.ini_options] +# pythonpath = [ +# ".", "mock_generators" +# ] + +int_args_test = GeneratorArg( + type=GeneratorType.INT, + label="test_arg", + default=1, + ) + +int_generator_test = Generator( + id="test_int", + name="test_int", + type=GeneratorType.INT, + description="test", + args=[int_args_test], + code_url="mock_generators/generators/ecdff22c.py", + tags=["int, integer, number"] + ) + +float_args_test = [ + GeneratorArg( + type=GeneratorType.FLOAT, + label="min", + default=1.0, + ), + GeneratorArg( + type=GeneratorType.FLOAT, + label="max", + default=2.0, + ), + ] + +float_generator_test = Generator( + id="test_float", + name = "test_float", + type=GeneratorType.FLOAT, + description="test", + args=float_args_test, + code_url="mock_generators/generators/e8cff8c1.py", + tags=["float", "number"] + ) + +test_generators = { + "test_int" : int_generator_test, + "test_float" : float_generator_test +} + +class TestClass: + + # def test_version(self): + # assert __version__ == "0.1.0" + + def test_generator(self): + generator = Generator( + id="test", + name="test", + type=GeneratorType.INT, + description="test", + args=[], + code_url="", + tags = ["test"] + ) + assert generator.id == "test" + assert generator.name == "test" + assert generator.type == GeneratorType.INT + assert generator.description == "test" + assert generator.args == [] + assert generator.code_url == "" + # TODO: Why is this not working + # tag_check = generator.tags("test") + # assert tag_check > 0 + + def test_jsonification(self): + # Parsing json strings critical to this class + json_string = "{\"test_int\":[1]}" + try: + obj = json.loads(json_string) + items = obj.items() + assert items is not None + assert len(items) == 1 + for key, value in items: + assert key == "test_int" + assert value == [1] + + value = obj.get('test_int', None) + assert value is not None + assert value == [1] + except Exception as e: + print(f'Exception: {e}') + assert False + + + def test_generator_for_raw_property(self): + print(f'int_generator_test: {int_generator_test}') + print(f'int_args_test: {int_args_test}') + print(f'test_generators: {test_generators}') + try: + test_string = "{\"test_int\":[1]}" + generator, args = generator_for_raw_property(test_string, test_generators) + except Exception as e: + print(f'Exception: {e}') + print(f'generator: {generator}, args: {args}') + # if status_message is not None: + # print(f'status: {status_message}') + assert int_generator_test is not None + assert int_args_test is not None + assert generator == int_generator_test + assert args == [1] + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value == 1 + + def test_failed_generator_for_raw_property(self): + try: + generator, args = generator_for_raw_property("{\'test_doesnt_exist\':[1]}", test_generators) + except Exception as e: + print(f'Exception: {e}') + assert generator is None + assert args is None + + def test_propertymappings_for_raw_properties(self): + mappings = propertymappings_for_raw_properties( + raw_properties={ + "alpha": "{\'test_int\':[1]", + "bravo": "{\'test_float\':[1.0, 2.0]" + }, + generators= test_generators) + print(f'mappings: {mappings}') + assert len(mappings) == 2 + assert mappings['alpha'].generator == self.int_generator_test() + assert mappings['alpha'].args == self.int_args_test() + assert mappings['bravo'].generator == self.float_generator_test() + assert mappings['bravo'].args == self.float_args_test() + + # def test_node_mappings_from(self): + # nodes = node_mappings_from( + # node_dicts=[{ + # "id": "n1", + # "position": { + # "x": 284.5, + # "y": -204 + # }, + # "caption": "Company", + # "labels": [], + # "properties": { + # "uuid": "{\"test_float\":[8]}", + # "{count}": "{\"test_int\":[1]}", + # "{key}": "uuid" + # }, + # "style": {} + # }], + # generators= test_generators + # ) + # expectedNodeMapping = NodeMapping( + # nid="n1", + # position={ + # "x": 284.5, + # "y": -204 + # }, + # caption="Company", + # labels=[], + # properties={ + # "uuid": "{{\"test_float\":[8]}}", + # "{count}": "{{\"test_int\":[1]}}", + # "{key}": "uuid" + # }, + # count_generator=int_generator_test, + # count_args=int_args_test, + # key_property=int_generator_test + # ) + # assert len(nodes) == 1 + # assert nodes.get("n1") == expectedNodeMapping + + # def test_mapping_from_json(self): + From 81dbafdb46f1ccaaac704264e9d482ed97a91e0d Mon Sep 17 00:00:00 2001 From: Jason Koo Date: Wed, 15 Feb 2023 16:04:32 -0800 Subject: [PATCH 3/7] Generator search and code copy in design tab added --- mock_generators/imports/simple_org_chart.json | 126 ------------------ mock_generators/tabs/design_tab.py | 93 ++++++++++++- poetry.lock | 13 +- pyproject.toml | 1 + 4 files changed, 105 insertions(+), 128 deletions(-) delete mode 100644 mock_generators/imports/simple_org_chart.json diff --git a/mock_generators/imports/simple_org_chart.json b/mock_generators/imports/simple_org_chart.json deleted file mode 100644 index 10ab77a..0000000 --- a/mock_generators/imports/simple_org_chart.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "nodes": [ - { - "id": "n0", - "position": { - "x": -135, - "y": -74 - }, - "caption": "Person", - "labels": [], - "properties": { - "first name": "string", - "email": "string", - "uuid": "string" - }, - "style": {} - }, - { - "id": "n1", - "position": { - "x": 163.93435707302254, - "y": -64.06262614269966 - }, - "caption": "Roles", - "labels": [], - "properties": { - "title": "string", - "uuid": "string" - }, - "style": {} - }, - { - "id": "n2", - "position": { - "x": 304.44455275328244, - "y": -307.43342404235483 - }, - "caption": "Company", - "labels": [], - "properties": { - "name": "string", - "uuid": "string" - }, - "style": {} - } - ], - "relationships": [ - { - "id": "n0", - "fromId": "n0", - "toId": "n1", - "type": "WORKS_AS", - "properties": {}, - "style": {} - }, - { - "id": "n1", - "fromId": "n2", - "toId": "n1", - "type": "EMPLOYS", - "properties": {}, - "style": {} - }, - { - "id": "n2", - "fromId": "n1", - "toId": "n1", - "type": "WORKS_FOR", - "properties": {}, - "style": {} - } - ], - "style": { - "font-family": "sans-serif", - "background-color": "#ffffff", - "background-image": "", - "background-size": "100%", - "node-color": "#ffffff", - "border-width": 4, - "border-color": "#000000", - "radius": 50, - "node-padding": 5, - "node-margin": 2, - "outside-position": "auto", - "node-icon-image": "", - "node-background-image": "", - "icon-position": "inside", - "icon-size": 64, - "caption-position": "inside", - "caption-max-width": 200, - "caption-color": "#000000", - "caption-font-size": 50, - "caption-font-weight": "normal", - "label-position": "inside", - "label-display": "pill", - "label-color": "#000000", - "label-background-color": "#ffffff", - "label-border-color": "#000000", - "label-border-width": 4, - "label-font-size": 40, - "label-padding": 5, - "label-margin": 4, - "directionality": "directed", - "detail-position": "inline", - "detail-orientation": "parallel", - "arrow-width": 5, - "arrow-color": "#000000", - "margin-start": 5, - "margin-end": 5, - "margin-peer": 20, - "attachment-start": "normal", - "attachment-end": "normal", - "relationship-icon-image": "", - "type-color": "#000000", - "type-background-color": "#ffffff", - "type-border-color": "#000000", - "type-border-width": 0, - "type-font-size": 16, - "type-padding": 5, - "property-position": "outside", - "property-alignment": "colon", - "property-color": "#000000", - "property-font-size": 16, - "property-font-weight": "normal" - } -} \ No newline at end of file diff --git a/mock_generators/tabs/design_tab.py b/mock_generators/tabs/design_tab.py index 7ce1035..7a95c6f 100644 --- a/mock_generators/tabs/design_tab.py +++ b/mock_generators/tabs/design_tab.py @@ -2,6 +2,13 @@ import streamlit.components.v1 as components from streamlit_extras.colored_header import colored_header from constants import * +from file_utils import load_string +import sys +import logging +import datetime +from models.generator import GeneratorType +import pyperclip +import json def design_tab(): @@ -17,4 +24,88 @@ def design_tab(): components.iframe("https://arrows.app", height=1000, scrolling=False) with c2: st.write("Generators") - st.markdown("--------") \ No newline at end of file + st.markdown("--------") + # TODO: Put generators search feature here + # TODO: Add copy and paste button to produce specially formatted string values to use in the arrows property app + + generators = st.session_state[GENERATORS] + if generators is None: + st.write("No generators loaded") + st.stop() + + # Search by name or description + search_term = st.text_input("Search by keyword", "") + + # Filter by type + st.write('', unsafe_allow_html=True) + type_filter = st.radio("Filter by type", ["All", "String", "Bool", "Integer", "Float","Datetime", "Assignment"]) + # logging.info(f"Generators: {generators}") + for _, generator in sorted(generators.items(), key=lambda gen:(gen[1].name)): + # generator = generators[key] + # Filtering + if type_filter != "All" and type_filter != generator.type.to_string(): + continue + + if search_term != "" and search_term.lower() not in generator.name.lower() and search_term.lower() not in generator.description.lower(): + continue + + try: + # Check that we can load code first + code_filepath = generator.code_url + code_file = load_string(code_filepath) + except: + logging.error(f"Could not load generator code from {code_filepath}: {sys.exc_info()[0]}") + continue + + with st.expander(generator.name): + st.write(f"\n {generator.description}") + # st.write(f'Generator Code:\n') + # st.markdown(f'```python\n{code_file}\n```') + args = generator.args + arg_inputs = [] + for arg in args: + if arg.type == GeneratorType.STRING: + arg_inputs.append(st.text_input( + label=arg.label, + value = arg.default, + key = f'{generator.id}_{arg.label}', + placeholder = f'{arg.hint}', + help = f'{arg.description}' + )) + if arg.type == GeneratorType.INT or arg.type == GeneratorType.FLOAT: + arg_inputs.append(st.number_input( + label= arg.label, + value= arg.default, + key = f'{generator.id}_{arg.label}' + )) + if arg.type == GeneratorType.BOOL: + arg_inputs.append(st.radio( + label=arg.label, + index=arg.default, + key = f'{generator.id}_{arg.label}' + )) + if arg.type == GeneratorType.DATETIME: + arg_inputs.append(st.date_input( + label=arg.label, + value=datetime.datetime.fromisoformat(arg.default), + key = f'{generator.id}_{arg.label}' + )) + if st.button("Generate Example Output", key=f"run_{generator.id}"): + try: + module = __import__(generator.import_url(), fromlist=['generate']) + # logging.info(f'arg_inputs: {arg_inputs}') + + # TODO: Need a fake list of Node Mappings to test out assignment generators + result = module.generate(arg_inputs) + st.write(f'Output: {result}') + except: + st.error(f"Problem running generator {generator.name}: {sys.exc_info()[0]}") + if st.button("Copy for Arrows", key=f"copy_{generator.id}"): + name = generator.name + args = arg_inputs + obj = { + generator.id: args + } + json_string = json.dumps(obj) + pyperclip.copy(json_string) + st.success(f'Copied to clipboard: {json_string}. Paste into Arrows.app to use.') \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index cee403b..69a7fae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -604,6 +604,14 @@ python-versions = ">=3.6.8" [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pyperclip" +version = "1.8.2" +description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "pyrsistent" version = "0.19.2" @@ -1034,7 +1042,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.11" -content-hash = "e54338f67cd8efdf16c56db762d08a46337efa7ec811bf640c4dfa206e1905e4" +content-hash = "139ef946899dcf744a4d7b18c235d88178911d8cf9842f91893fca672cccce67" [metadata.files] altair = [ @@ -1669,6 +1677,9 @@ pyparsing = [ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] +pyperclip = [ + {file = "pyperclip-1.8.2.tar.gz", hash = "sha256:105254a8b04934f0bc84e9c24eb360a591aaf6535c9def5f29d92af107a9bf57"}, +] pyrsistent = [ {file = "pyrsistent-0.19.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d6982b5a0237e1b7d876b60265564648a69b14017f3b5f908c5be2de3f9abb7a"}, {file = "pyrsistent-0.19.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:187d5730b0507d9285a96fca9716310d572e5464cadd19f22b63a6976254d77a"}, diff --git a/pyproject.toml b/pyproject.toml index f025492..dbd72ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ streamlit-toggle-switch = "^1.0.2" hydralit-components = "^1.0.10" pytest = "^7.2.0" dataclasses-json = "^0.5.7" +pyperclip = "^1.8.2" [tool.poetry.group.dev.dependencies] From 1d1c7b77ce64ed587d3ec5f7c7828e1398083c2a Mon Sep 17 00:00:00 2001 From: Jason Koo Date: Wed, 15 Feb 2023 21:20:07 -0800 Subject: [PATCH 4/7] Config tab removed --- mock_generators/app.py | 12 +++++++----- mock_generators/config.py | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 mock_generators/config.py diff --git a/mock_generators/app.py b/mock_generators/app.py index 6569c93..8abd5d8 100644 --- a/mock_generators/app.py +++ b/mock_generators/app.py @@ -10,7 +10,7 @@ from tabs.design_tab import design_tab from tabs.data_importer import data_importer_tab from models.mapping import Mapping - +from config import load_generators # SETUP st.set_page_config(layout="wide") @@ -44,6 +44,8 @@ if MAPPINGS not in st.session_state: st.session_state[MAPPINGS] = None +load_generators() + # UI st.title("Mock Graph Data Generator") st.markdown("This is a collection of tools to generate mock graph data for [Neo4j](https://neo4j.com) graph databases.") @@ -53,16 +55,16 @@ # Streamlit runs from top-to-bottom from tabs 1 through 8. This is essentially one giant single page app. Earlier attempt to use Streamlit's multi-page app functionality resulted in an inconsistent state between pages. -t0, t1, t2, t3, t4, t5 = st.tabs([ - "Config >", +t1, t2, t3, t4, t5 = st.tabs([ + # "Config >", "Design >", "Import >", "Generate >", "Export >", "Data Importer" ]) -with t0: - config_tab() +# with t0: +# config_tab() with t1: design_tab() with t2: diff --git a/mock_generators/config.py b/mock_generators/config.py new file mode 100644 index 0000000..d814cb4 --- /dev/null +++ b/mock_generators/config.py @@ -0,0 +1,23 @@ +import streamlit as st +from constants import * +from file_utils import load_json, load_string +from models.generator import Generator, generators_from_json +import os +import sys +import logging +from widgets.folder_files import folder_files_expander + +def load_generators(): + + spec_filepath = st.session_state[SPEC_FILE] + generators = st.session_state[GENERATORS] + try: + with open(spec_filepath) as input: + # generators_file = input.read() + generators_json = load_json(spec_filepath) + new_generators = generators_from_json(generators_json) + if generators != new_generators: + st.session_state[GENERATORS] = new_generators + + except FileNotFoundError: + st.error('File not found.') \ No newline at end of file From bfddfa844a1e35c8e3ad8731e331db7467a84d90 Mon Sep 17 00:00:00 2001 From: Jason Koo Date: Wed, 15 Feb 2023 22:25:45 -0800 Subject: [PATCH 5/7] Import, Generate, and Export tabs combined into new Generate tab --- mock_generators/app.py | 14 ++-- mock_generators/generate.py | 94 ++++++++++++++++++++++++++ mock_generators/models/node_mapping.py | 2 +- mock_generators/tabs/importing_tab.py | 72 ++++++++------------ 4 files changed, 129 insertions(+), 53 deletions(-) create mode 100644 mock_generators/generate.py diff --git a/mock_generators/app.py b/mock_generators/app.py index 8abd5d8..e37e18b 100644 --- a/mock_generators/app.py +++ b/mock_generators/app.py @@ -55,12 +55,12 @@ # Streamlit runs from top-to-bottom from tabs 1 through 8. This is essentially one giant single page app. Earlier attempt to use Streamlit's multi-page app functionality resulted in an inconsistent state between pages. -t1, t2, t3, t4, t5 = st.tabs([ +t1, t2, t5 = st.tabs([ # "Config >", "Design >", - "Import >", "Generate >", - "Export >", + # "Generate >", + # "Export >", "Data Importer" ]) # with t0: @@ -69,9 +69,9 @@ design_tab() with t2: import_tab() -with t3: - generate_tab() -with t4: - export_tab() +# with t3: +# generate_tab() +# with t4: +# export_tab() with t5: data_importer_tab() \ No newline at end of file diff --git a/mock_generators/generate.py b/mock_generators/generate.py new file mode 100644 index 0000000..e4aea24 --- /dev/null +++ b/mock_generators/generate.py @@ -0,0 +1,94 @@ +import streamlit as st +from constants import * +from models.mapping import Mapping +from logic.generate_csv import generate_csv +from logic.generate_data_import import generate_data_importer_json +import os +import logging +import sys +import zipfile +from datetime import datetime + +def generate_data(mapping: Mapping): + + export_folder = st.session_state[EXPORTS_PATH] + zips_folder = st.session_state[ZIPS_PATH] + imported_filename = st.session_state[IMPORTED_FILENAME] + + # TODO: Implement better filename cleaning + # TODO: Breaks when using a copy and pasted file + export_zip_filename = f'{imported_filename}'.lower() + export_zip_filename = export_zip_filename.replace(".json", "") + export_zip_filename.replace(" ", "_") + export_zip_filename.replace(".", "_") + + # Stop if no mapping data available + if len(mapping.nodes) == 0: + st.error('No nodes to generate data for. Map at least one noded.') + st.stop() + return + + # Generate values from mappings + for _, node in mapping.nodes.items(): + # logging.info(f'Generating data for node: {node}') + if len(node.properties) == 0: + st.error(f'Node {node.caption} has no properties. Add at least one property to generate data.') + st.stop() + return + node.generate_values() + + for _, rel in mapping.relationships.items(): + rel.generate_values() + + # Delete all files in export folder first + dir = export_folder + for f in os.listdir(dir): + os.remove(os.path.join(dir, f)) + + # Data Importer Options + success = generate_csv( + mapping, + export_folder=export_folder) + + # Check that data was generated + if success == False: + st.error('Error generating data. Check console for details.') + # st.stop() + # return + + success = generate_data_importer_json( + mapping, + export_folder=export_folder, + export_filename=DEFAULT_DATA_IMPORTER_FILENAME) + + # Check that data-import data was generated + if success == False: + st.error('Error generating data-import json. Check console for details.') + # st.stop() + # return + + # Only attempt to zip files if data generation was successful + if success: + try: + # Create zip file, appended with time created + # now = str(datetime.now().isoformat()) + zip_path = f'{zips_folder}/{export_zip_filename}.zip' + logging.info(f'generate_tab: Creating zip file: {zip_path}') + with zipfile.ZipFile(f'{zip_path}', 'w', zipfile.ZIP_DEFLATED) as zipf: + # zipdir(export_folder, zipf) + path = export_folder + for root, dirs, files in os.walk(path): + for file in files: + if file[0] =='.': + # Skip hidden files + continue + zipf.write(os.path.join(root, file), + os.path.relpath(os.path.join(root, file), + os.path.join(path, '..'))) + except: + st.error(f'Error creating zip file: {sys.exc_info()[0]}') + # st.stop() + return + + if success == True: + st.success('Data generated successfully.') diff --git a/mock_generators/models/node_mapping.py b/mock_generators/models/node_mapping.py index 7ef77ff..b8876f2 100644 --- a/mock_generators/models/node_mapping.py +++ b/mock_generators/models/node_mapping.py @@ -138,5 +138,5 @@ def generate_values(self) -> list[dict]: # Store and return all_results self.generated_values = all_results - logging.info(f'node_mapping.py: NodeMapping.generate_values() generated {len(self.generated_values)} values for node mapping {self.caption}') + # logging.info(f'node_mapping.py: NodeMapping.generate_values() generated {len(self.generated_values)} values for node mapping {self.caption}') return self.generated_values \ No newline at end of file diff --git a/mock_generators/tabs/importing_tab.py b/mock_generators/tabs/importing_tab.py index 5aa1575..0a4c150 100644 --- a/mock_generators/tabs/importing_tab.py +++ b/mock_generators/tabs/importing_tab.py @@ -7,9 +7,10 @@ from file_utils import load_string, save_file from models.mapping import Mapping import sys -import datetime import os from logic.generate_mapping import mapping_from_json +from generate import generate_data +from datetime import datetime # Convenience functions def import_file(file) -> bool: @@ -64,16 +65,19 @@ def import_tab(): st.markdown("--------") - i1, i3 = st.columns([3,3]) + i1, i2, i3 = st.columns(3) + + start_generated = datetime.now() + last_generated = start_generated with i1: # File Selection + st.write('IMPORT ARROWS FILE:') + # st.markdown("--------") selected_file = None import_option = st.radio("Select Import Source", ["An Existing File", "Upload"], horizontal=True) - st.markdown("--------") - if import_option == "An Existing File": # Saved options st.write("Select an import file:") @@ -105,60 +109,38 @@ def import_tab(): file_selected(selected_filepath) else: logging.info(f'Copy & Paste Option disabled') - # else: - # # Copy & Paste - # pasted_json = st.text_area("Paste an arrows JSON file here", height=300) - # # logging.info(f'pasted_json: {pasted_json}') - # if pasted_json is not None and pasted_json != "": - # temp_filename = f'pasted_file.json' - # selected_filepath = f"{st.session_state[IMPORTS_PATH]}/{temp_filename}" - # # data = json.dumps(pasted_json, indent=4) - # try: - # save_file( - # filepath=selected_filepath, - # data=pasted_json) - # except: - # st.error(f"Error saving file to {st.session_state[IMPORTS_PATH]}") - # logging.error(f'Error saving file: {sys.exc_info()[0]}') - - # file_selected(selected_filepath) - - # with i2: - # # Display loaded file - # st.write('IMPORT STATUS:') - # # Process uploaded / selected file + # Process uploaded / selected file current_file = st.session_state[IMPORTED_FILE] if current_file is not None: # Verfiy file is valid arrows JSON try: generators = st.session_state[GENERATORS] mapping = mapping_from_json(current_file, generators) - st.session_state[MAPPINGS] = mapping - st.success(f"Mappings generated from import file.") + # st.session_state[MAPPINGS] = mapping + generate_data(mapping) + + last_generated = datetime.now() + # st.success(f"New data generated from import file.") except json.decoder.JSONDecodeError: st.error('Import JSON file is not valid.') except Exception as e: st.error(f'Uncaught Error: {e}') - # else: - # st.warning(f'No file selected. Please select a file to import.') + + with i2: + export_folder = st.session_state[EXPORTS_PATH] + st.write(f"RECENTLY GENERATED FILES:") + if start_generated != last_generated: + st.success(f"New data generated from import file.") + folder_files_expander(export_folder, widget_id="export_tab", enable_download=True) with i3: - # Display auto-generated Mapping files - st.write('MAPPING DATA:') - mapping = st.session_state[MAPPINGS] - if mapping is None: - st.warning(f'No mapping data available. Import a file.') - elif mapping.is_empty() == True: - st.error(f'No mapping data extracted from imported file. Is the file correctly formatted?') - elif mapping.is_valid() == False: - st.error(f'Mappping invalid. Please check the imported file.') - else: - st.success(f'Mappping options valid for generation. Proceed to Generate Tab.') - # For the curious - with st.expander("Raw Mapping Data"): - if mapping is not None: - st.json(mapping.to_dict()) \ No newline at end of file + st.write('GENERATED ZIP FILES:') + if start_generated != last_generated: + st.success(f"New zip files generated from import file.") + zips_folder = st.session_state[ZIPS_PATH] + + folder_files_expander(zips_folder, widget_id="export_tab", enable_download=True, enable_delete_button=True) \ No newline at end of file From dffb1a8244622e355f8fc0aee59ac21806ebc14c Mon Sep 17 00:00:00 2001 From: Jason Koo Date: Sat, 18 Feb 2023 09:42:41 -0800 Subject: [PATCH 6/7] Design tab updated with new instructions --- mock_generators/app.py | 6 ++-- mock_generators/media/sample_generator.png | Bin 0 -> 50621 bytes .../media/sample_node_properties.png | Bin 0 -> 42272 bytes .../media/sample_relationship_properties.png | Bin 0 -> 37218 bytes mock_generators/tabs/data_importer.py | 3 +- mock_generators/tabs/design_tab.py | 27 ++++++++++++++---- mock_generators/tabs/importing_tab.py | 3 +- 7 files changed, 28 insertions(+), 11 deletions(-) create mode 100644 mock_generators/media/sample_generator.png create mode 100644 mock_generators/media/sample_node_properties.png create mode 100644 mock_generators/media/sample_relationship_properties.png diff --git a/mock_generators/app.py b/mock_generators/app.py index e37e18b..7116d1b 100644 --- a/mock_generators/app.py +++ b/mock_generators/app.py @@ -57,11 +57,11 @@ t1, t2, t5 = st.tabs([ # "Config >", - "Design >", - "Generate >", + "① Design", + "② Generate", # "Generate >", # "Export >", - "Data Importer" + "③ Data Importer" ]) # with t0: # config_tab() diff --git a/mock_generators/media/sample_generator.png b/mock_generators/media/sample_generator.png new file mode 100644 index 0000000000000000000000000000000000000000..b89abf833b0d856dd42411a2a332ab147658d1f3 GIT binary patch literal 50621 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 literal 0 HcmV?d00001 diff --git a/mock_generators/media/sample_node_properties.png b/mock_generators/media/sample_node_properties.png new file mode 100644 index 0000000000000000000000000000000000000000..420d62c8ce201260f0e21072b8fc8c299af3d0cb GIT binary patch literal 42272 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�r?l==oH+TQ!>oATn@8+|4UX%8%Y+Ipwb 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

<@P~jA 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 VFmoPe&I?GdxfNw+Tl1{0 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 Date: Tue, 21 Feb 2023 22:09:58 -0800 Subject: [PATCH 7/7] Clean up and instructions added --- mock_generators/app.py | 16 ------------ mock_generators/logic/generate_mapping.py | 3 +++ mock_generators/tabs/data_importer.py | 27 +++++++++++++++----- mock_generators/tabs/design_tab.py | 21 ++++++++------- mock_generators/tabs/generate_tab.py | 2 ++ mock_generators/tabs/importing_tab.py | 31 +++++++++++++++++------ 6 files changed, 60 insertions(+), 40 deletions(-) diff --git a/mock_generators/app.py b/mock_generators/app.py index 7116d1b..8ec4a87 100644 --- a/mock_generators/app.py +++ b/mock_generators/app.py @@ -1,15 +1,8 @@ import streamlit as st from constants import * -from tabs.config_tab import config_tab -from tabs.generators_tab import generators_tab -from tabs.new_generator_tab import create_tab -from tabs.mapping_tab import mapping_tab -from tabs.generate_tab import generate_tab -from tabs.export_tab import export_tab from tabs.importing_tab import import_tab from tabs.design_tab import design_tab from tabs.data_importer import data_importer_tab -from models.mapping import Mapping from config import load_generators # SETUP @@ -56,22 +49,13 @@ # Streamlit runs from top-to-bottom from tabs 1 through 8. This is essentially one giant single page app. Earlier attempt to use Streamlit's multi-page app functionality resulted in an inconsistent state between pages. t1, t2, t5 = st.tabs([ - # "Config >", "① Design", "② Generate", - # "Generate >", - # "Export >", "③ Data Importer" ]) -# with t0: -# config_tab() with t1: design_tab() with t2: import_tab() -# with t3: -# generate_tab() -# with t4: -# export_tab() with t5: data_importer_tab() \ No newline at end of file diff --git a/mock_generators/logic/generate_mapping.py b/mock_generators/logic/generate_mapping.py index f8db29b..7025b22 100644 --- a/mock_generators/logic/generate_mapping.py +++ b/mock_generators/logic/generate_mapping.py @@ -300,6 +300,9 @@ def mapping_from_json( if relationship_dicts is None: raise Exception(f"generate_mappings: mapping_from_json: No relationships found in JSON file: {json}") + # TODO: + # Purge orphaned nodes + # Convert source information to mapping objects nodes = node_mappings_from(node_dicts, generators) relationships = relationshipmappings_from(relationship_dicts, nodes, generators) diff --git a/mock_generators/tabs/data_importer.py b/mock_generators/tabs/data_importer.py index 762e6e8..a901101 100644 --- a/mock_generators/tabs/data_importer.py +++ b/mock_generators/tabs/data_importer.py @@ -5,12 +5,25 @@ def data_importer_tab(): - col1, col2 = st.columns([1,11]) - with col1: - st.image("mock_generators/media/export.gif") - # st.image("mock_generators/media/signpost.gif") - with col2: - st.write(f"Data Importer App.\n\nUse the [Data Importer Tool](https://data-importer.graphapp.io/) to upload generated .zip file to for review and ingesetion to a Neo4j database instance.") + # col1, col2 = st.columns([1,11]) + # with col1: + # st.image("mock_generators/media/export.gif") + # # st.image("mock_generators/media/signpost.gif") + # with col2: + # st.write(f"Data Importer App.\n\nUse the [Data Importer Tool](https://data-importer.graphapp.io/) to upload generated .zip file to for review and ingesetion to a Neo4j database instance.") + with st.expander('Instructions'): + st.write(""" + 1. Connect to your Neo4j instance + 2. Click on the '...' options button in the Data Importer header + 3. Select 'Open model (with data)' + 4. Select the .zip file with the generated data + 5. Click the 'Run import' button + """) + with st.expander("Options"): + is_local = st.checkbox("Use HTTP", value=False, help="Select Use HTTP if connecting with a local Neo4j instance.") st.markdown("--------") - components.iframe("https://data-importer.graphapp.io/", height=1000, scrolling=False) \ No newline at end of file + if is_local == True: + components.iframe("http://data-importer.graphapp.io/", height=1000, scrolling=False) + else: + components.iframe("https://data-importer.graphapp.io/", height=1000, scrolling=False) \ No newline at end of file diff --git a/mock_generators/tabs/design_tab.py b/mock_generators/tabs/design_tab.py index 161e471..7940a17 100644 --- a/mock_generators/tabs/design_tab.py +++ b/mock_generators/tabs/design_tab.py @@ -18,8 +18,14 @@ def design_tab(): # with col2: # st.write(f"Design Data Model.\n\nUse the [arrows.app](https://arrows.app) then download the .json file to the Import tab.") # st.markdown("--------") - - st.write("Use the arrows.app to design a graph data model. When selecting Nodes or Relationships, a properties inspector will appear on the right. Configure the mock graph generator by adding properties with specially formatted keys and values. See additional details in the dropdowns below.\n\nOnce completed, click on the 'Download/Export' button in the arrows.app. Make sure to use the 'JSON' export option.") + with st.expander("Instructions"): + st.write(""" + 1. Connect to arrows.app. Optionally login via Google to save your model designs + 2. Select a Node or Relationship to display a properties inspector on the right + 3. Configure the mock graph generator by adding properties with specially formatted keys and value strings. See additional details on how to do this in the dropdowns below + 4. Once completed, click on the 'Download/Export' button in the arrows.app. Make sure to use the 'JSON' export option + 5. Proceed to the '② Generate' tab + """) d1, d2, d3 = st.columns(3) with d1: with st.expander("NODE requirements"): @@ -38,10 +44,8 @@ def design_tab(): with c1: components.iframe("https://arrows.app", height=1000, scrolling=False) with c2: - st.write("Generators") - st.markdown("--------") - # TODO: Put generators search feature here - # TODO: Add copy and paste button to produce specially formatted string values to use in the arrows property app + # st.write("Generators") + # st.markdown("--------") generators = st.session_state[GENERATORS] if generators is None: @@ -49,12 +53,11 @@ def design_tab(): st.stop() # Search by name or description - search_term = st.text_input("Search by keyword", "") + search_term = st.text_input("Search Generators by keyword", "", help="Generators are functions for creating mock data.") # Filter by type st.write('', unsafe_allow_html=True) - type_filter = st.radio("Filter by type", ["All", "String", "Bool", "Integer", "Float","Datetime", "Assignment"]) - # logging.info(f"Generators: {generators}") + type_filter = st.radio("Filter Generator outputs by type", ["All", "String", "Bool", "Integer", "Float","Datetime", "Assignment"]) for _, generator in sorted(generators.items(), key=lambda gen:(gen[1].name)): # generator = generators[key] # Filtering diff --git a/mock_generators/tabs/generate_tab.py b/mock_generators/tabs/generate_tab.py index 5974fed..4d041d0 100644 --- a/mock_generators/tabs/generate_tab.py +++ b/mock_generators/tabs/generate_tab.py @@ -1,3 +1,5 @@ +# Replaced with expanded Import Tab + import streamlit as st from constants import * from models.mapping import Mapping diff --git a/mock_generators/tabs/importing_tab.py b/mock_generators/tabs/importing_tab.py index ea595c3..dc2d6d3 100644 --- a/mock_generators/tabs/importing_tab.py +++ b/mock_generators/tabs/importing_tab.py @@ -1,3 +1,5 @@ +# Now the new Generate Tab + import streamlit as st import json from constants import * @@ -57,12 +59,23 @@ def file_selected(path): def import_tab(): - col1, col2 = st.columns([1,11]) - with col1: - # st.image("mock_generators/media/import.gif") - st.image("mock_generators/media/fireworks.gif") - with col2: - st.markdown("Import JSON files from an [arrows.app](https://arrows.app/#/local/id=A330UT1VEBAjNH1Ykuss) data model. \n\nProceed to the Mapping Tab when complete.") + # col1, col2 = st.columns([1,11]) + # with col1: + # # st.image("mock_generators/media/import.gif") + # st.image("mock_generators/media/fireworks.gif") + # with col2: + # st.markdown("Import JSON files from an [arrows.app](https://arrows.app/#/local/id=A330UT1VEBAjNH1Ykuss) data model. \n\nProceed to the Mapping Tab when complete.") + + with st.expander("Instructions"): + st.write( + """ + 1. Import or select a previously imported JSON file from an arrows.app export + 2. The mock graph data generator will automatically generate a .csv and .zip files + 3. Download the .zip file + 4. Proceed to the '③ Data Importer' tab + """ + ) + st.markdown("--------") @@ -73,7 +86,7 @@ def import_tab(): with i1: # File Selection - st.write('IMPORT ARROWS FILE:') + st.write('SELECT ARROWS FILE:') # st.markdown("--------") selected_file = None @@ -118,7 +131,9 @@ def import_tab(): # Verfiy file is valid arrows JSON try: generators = st.session_state[GENERATORS] - mapping = mapping_from_json(current_file, generators) + mapping = mapping_from_json( + current_file, + generators) # st.session_state[MAPPINGS] = mapping generate_data(mapping)