From 54d843c9badcb3cf458b17d29c50c6307cf9b5f3 Mon Sep 17 00:00:00 2001 From: jalakoo Date: Sat, 6 May 2023 09:28:36 -0700 Subject: [PATCH 1/5] Literals (#8) * Pytest config updated * Basic literal support added - ints, floats, ranges, lists * Datetime generator fix + keywords generator support added * Default count and key assignments added + missing generator updates --- Pipfile | 1 + Pipfile.lock | 67 +-- mock_generators/__init__.py | 3 +- mock_generators/app.py | 5 +- mock_generators/config.py | 22 +- mock_generators/constants.py | 2 - mock_generators/default_generators.json | 455 ------------------ mock_generators/file_utils.py | 1 - mock_generators/generate_mapping.py | 272 ----------- mock_generators/generators/ab64469b.py | 15 - mock_generators/generators/date.py | 19 + mock_generators/generators/e56d87a3.py | 11 - .../generators/{ecdff22c.py => float.py} | 6 +- .../{111d38e0.py => float_from_list.py} | 0 .../{e8cff8c1.py => float_range.py} | 0 mock_generators/generators/int.py | 6 + mock_generators/generators/int_from_list.py | 21 + .../generators/{469b37c7.py => int_range.py} | 0 mock_generators/generators/literal.py | 4 + ...remtext_sentence.py => lorem_sentences.py} | 0 .../{loremtext_words.py => lorem_words.py} | 0 .../generators/{78bc0765.py => short_uuid.py} | 1 - .../{5bf1fbd6.py => string_from_list.py} | 0 mock_generators/generators/uuid.py | 5 + mock_generators/logic/generate_mapping.py | 111 ++--- mock_generators/logic/generate_values.py | 239 +++++++++ mock_generators/logic/mapping_conversions.py | 99 ++++ mock_generators/mappings/README.md | 1 + .../mappings/simple_org_chart.json | Bin 0 -> 921 bytes mock_generators/models/data_import.py | 9 +- mock_generators/models/generator.py | 4 +- mock_generators/{ => models}/list_utils.py | 0 mock_generators/models/mapping.py | 2 + mock_generators/models/node_mapping.py | 20 +- mock_generators/models/property_mapping.py | 10 +- .../models/relationship_mapping.py | 12 +- mock_generators/named_generators.json | 74 ++- pyproject.toml | 5 + tests/README.md | 1 + tests/test_generate_mappings.py | 165 +++---- tests/test_generate_values.py | 318 ++++++++++++ tests/test_generators.py | 42 ++ 42 files changed, 1032 insertions(+), 996 deletions(-) delete mode 100644 mock_generators/default_generators.json delete mode 100644 mock_generators/generate_mapping.py delete mode 100644 mock_generators/generators/ab64469b.py create mode 100644 mock_generators/generators/date.py delete mode 100644 mock_generators/generators/e56d87a3.py rename mock_generators/generators/{ecdff22c.py => float.py} (57%) rename mock_generators/generators/{111d38e0.py => float_from_list.py} (100%) rename mock_generators/generators/{e8cff8c1.py => float_range.py} (100%) create mode 100644 mock_generators/generators/int.py create mode 100644 mock_generators/generators/int_from_list.py rename mock_generators/generators/{469b37c7.py => int_range.py} (100%) create mode 100644 mock_generators/generators/literal.py rename mock_generators/generators/{loremtext_sentence.py => lorem_sentences.py} (100%) rename mock_generators/generators/{loremtext_words.py => lorem_words.py} (100%) rename mock_generators/generators/{78bc0765.py => short_uuid.py} (93%) rename mock_generators/generators/{5bf1fbd6.py => string_from_list.py} (100%) create mode 100644 mock_generators/generators/uuid.py create mode 100644 mock_generators/logic/generate_values.py create mode 100644 mock_generators/logic/mapping_conversions.py create mode 100644 mock_generators/mappings/README.md create mode 100644 mock_generators/mappings/simple_org_chart.json rename mock_generators/{ => models}/list_utils.py (100%) create mode 100644 pyproject.toml create mode 100644 tests/README.md create mode 100644 tests/test_generate_values.py create mode 100644 tests/test_generators.py diff --git a/Pipfile b/Pipfile index 78b00a2..3e5b3bb 100644 --- a/Pipfile +++ b/Pipfile @@ -14,6 +14,7 @@ faker = "*" streamlit-player = "*" [dev-packages] +mock-generators = {editable = true, path = "."} [requires] python_version = "3.11" diff --git a/Pipfile.lock b/Pipfile.lock index 9380fbd..3eb7842 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "9ea97856f4fe481f4177b7fba99320f4a4db7a871e6d685ad2e20e1b4929186f" + "sha256": "8541ff792fc307a32203cc04960e717db21fa70f46d6bb2d145becbb9aac37ef" }, "pipfile-spec": 6, "requires": { @@ -240,11 +240,11 @@ }, "faker": { "hashes": [ - "sha256:49060d40e6659e116f53353c5771ad2f2cbcd12b15771f49e3000a3a451f13ec", - "sha256:ac903ba8cb5adbce2cdd15e5536118d484bbe01126f3c774dd9f6df77b61232d" + "sha256:3aabe5b8c4ab50103a0c05c15e31a24b22045968d1ac0b7fb48d1c6244400495", + "sha256:6f882f5a3303c0690b3fb2dd3418839935a2dca155d38f1b2931b0079f3ce622" ], "index": "pypi", - "version": "==18.6.0" + "version": "==18.6.1" }, "favicon": { "hashes": [ @@ -830,34 +830,34 @@ }, "pyarrow": { "hashes": [ - "sha256:1cbcfcbb0e74b4d94f0b7dde447b835a01bc1d16510edb8bb7d6224b9bf5bafc", - "sha256:25aa11c443b934078bfd60ed63e4e2d42461682b5ac10f67275ea21e60e6042c", - "sha256:2d53ba72917fdb71e3584ffc23ee4fcc487218f8ff29dd6df3a34c5c48fe8c06", - "sha256:2d942c690ff24a08b07cb3df818f542a90e4d359381fbff71b8f2aea5bf58841", - "sha256:2f51dc7ca940fdf17893227edb46b6784d37522ce08d21afc56466898cb213b2", - "sha256:362a7c881b32dc6b0eccf83411a97acba2774c10edcec715ccaab5ebf3bb0835", - "sha256:3e99be85973592051e46412accea31828da324531a060bd4585046a74ba45854", - "sha256:40bb42afa1053c35c749befbe72f6429b7b5f45710e85059cdd534553ebcf4f2", - "sha256:410624da0708c37e6a27eba321a72f29d277091c8f8d23f72c92bada4092eb5e", - "sha256:41a1451dd895c0b2964b83d91019e46f15b5564c7ecd5dcb812dadd3f05acc97", - "sha256:5461c57dbdb211a632a48facb9b39bbeb8a7905ec95d768078525283caef5f6d", - "sha256:69309be84dcc36422574d19c7d3a30a7ea43804f12552356d1ab2a82a713c418", - "sha256:7c28b5f248e08dea3b3e0c828b91945f431f4202f1a9fe84d1012a761324e1ba", - "sha256:8f40be0d7381112a398b93c45a7e69f60261e7b0269cc324e9f739ce272f4f70", - "sha256:a37bc81f6c9435da3c9c1e767324ac3064ffbe110c4e460660c43e144be4ed85", - "sha256:aaee8f79d2a120bf3e032d6d64ad20b3af6f56241b0ffc38d201aebfee879d00", - "sha256:ad42bb24fc44c48f74f0d8c72a9af16ba9a01a2ccda5739a517aa860fa7e3d56", - "sha256:ad7c53def8dbbc810282ad308cc46a523ec81e653e60a91c609c2233ae407689", - "sha256:becc2344be80e5dce4e1b80b7c650d2fc2061b9eb339045035a1baa34d5b8f1c", - "sha256:caad867121f182d0d3e1a0d36f197df604655d0b466f1bc9bafa903aa95083e4", - "sha256:ccbf29a0dadfcdd97632b4f7cca20a966bb552853ba254e874c66934931b9841", - "sha256:da93340fbf6f4e2a62815064383605b7ffa3e9eeb320ec839995b1660d69f89b", - "sha256:e217d001e6389b20a6759392a5ec49d670757af80101ee6b5f2c8ff0172e02ca", - "sha256:f010ce497ca1b0f17a8243df3048055c0d18dcadbcc70895d5baf8921f753de5", - "sha256:f12932e5a6feb5c58192209af1d2607d488cb1d404fbc038ac12ada60327fa34" + "sha256:0846ace49998825eda4722f8d7f83fa05601c832549c9087ea49d6d5397d8cec", + "sha256:0d8b90efc290e99a81d06015f3a46601c259ecc81ffb6d8ce288c91bd1b868c9", + "sha256:0e36425b1c1cbf5447718b3f1751bf86c58f2b3ad299f996cd9b1aa040967656", + "sha256:19c812d303610ab5d664b7b1de4051ae23565f9f94d04cbea9e50569746ae1ee", + "sha256:1b50bb9a82dca38a002d7cbd802a16b1af0f8c50ed2ec94a319f5f2afc047ee9", + "sha256:1d568acfca3faa565d663e53ee34173be8e23a95f78f2abfdad198010ec8f745", + "sha256:23a77d97f4d101ddfe81b9c2ee03a177f0e590a7e68af15eafa06e8f3cf05976", + "sha256:2466be046b81863be24db370dffd30a2e7894b4f9823fb60ef0a733c31ac6256", + "sha256:272f147d4f8387bec95f17bb58dcfc7bc7278bb93e01cb7b08a0e93a8921e18e", + "sha256:280289ebfd4ac3570f6b776515baa01e4dcbf17122c401e4b7170a27c4be63fd", + "sha256:2cc63e746221cddb9001f7281dee95fd658085dd5b717b076950e1ccc607059c", + "sha256:3b97649c8a9a09e1d8dc76513054f1331bd9ece78ee39365e6bf6bc7503c1e94", + "sha256:3d1733b1ea086b3c101427d0e57e2be3eb964686e83c2363862a887bb5c41fa8", + "sha256:5b0810864a593b89877120972d1f7af1d1c9389876dbed92b962ed81492d3ffc", + "sha256:7a7b6a765ee4f88efd7d8348d9a1f804487d60799d0428b6ddf3344eaef37282", + "sha256:7b5b9f60d9ef756db59bec8d90e4576b7df57861e6a3d6a8bf99538f68ca15b3", + "sha256:92fb031e6777847f5c9b01eaa5aa0c9033e853ee80117dce895f116d8b0c3ca3", + "sha256:993287136369aca60005ee7d64130f9466489c4f7425f5c284315b0a5401ccd9", + "sha256:a1c4fce253d5bdc8d62f11cfa3da5b0b34b562c04ce84abb8bd7447e63c2b327", + "sha256:a7cd32fe77f967fe08228bc100433273020e58dd6caced12627bcc0a7675a513", + "sha256:b99e559d27db36ad3a33868a475f03e3129430fc065accc839ef4daa12c6dab6", + "sha256:bc4ea634dacb03936f50fcf59574a8e727f90c17c24527e488d8ceb52ae284de", + "sha256:d8c26912607e26c2991826bbaf3cf2b9c8c3e17566598c193b492f058b40d3a4", + "sha256:e6be4d85707fc8e7a221c8ab86a40449ce62559ce25c94321df7c8500245888f", + "sha256:ea830d9f66bfb82d30b5794642f83dd0e4a718846462d22328981e9eb149cba8" ], "markers": "python_version >= '3.7'", - "version": "==11.0.0" + "version": "==12.0.0" }, "pydeck": { "hashes": [ @@ -1256,5 +1256,10 @@ "version": "==3.15.0" } }, - "develop": {} + "develop": { + "mock-generators": { + "editable": true, + "path": "." + } + } } diff --git a/mock_generators/__init__.py b/mock_generators/__init__.py index b31ecc4..5699188 100644 --- a/mock_generators/__init__.py +++ b/mock_generators/__init__.py @@ -1,2 +1,3 @@ - +import sys +sys.path.append('.') __version__ = "0.1.0" \ No newline at end of file diff --git a/mock_generators/app.py b/mock_generators/app.py index 7756496..f660485 100644 --- a/mock_generators/app.py +++ b/mock_generators/app.py @@ -4,12 +4,13 @@ from tabs.design_tab import design_tab from tabs.data_importer import data_importer_tab from tabs.tutorial import tutorial_tab -from config import preload_state, load_generators +from config import setup_logging, preload_state, load_generators_to_streamlit # SETUP st.set_page_config(layout="wide") +setup_logging() preload_state() -load_generators() +load_generators_to_streamlit() # UI st.title("Mock Graph Data Generator") diff --git a/mock_generators/config.py b/mock_generators/config.py index b12c8b2..bc461c4 100644 --- a/mock_generators/config.py +++ b/mock_generators/config.py @@ -2,7 +2,14 @@ from constants import * from file_utils import load_json from models.generator import generators_from_json +import logging +def setup_logging(): + logger = logging.getLogger(__name__) + FORMAT = "[%(filename)s:%(lineno)s - %(funcName)20s() ] %(message)s" + logging.basicConfig(format=FORMAT) + logger.setLevel(logging.DEBUG) + def preload_state(): if ZIPS_PATH not in st.session_state: st.session_state[ZIPS_PATH] = DEFAULT_ZIPS_PATH @@ -18,7 +25,6 @@ def preload_state(): st.session_state[IMPORTED_FILENAME] = "" if IMPORTS_PATH not in st.session_state: st.session_state[IMPORTS_PATH] = DEFAULT_IMPORTS_PATH - # TODO: Replace with reference to selected import file if IMPORTED_FILE not in st.session_state: st.session_state[IMPORTED_FILE] = None if IMPORTED_NODES not in st.session_state: @@ -32,7 +38,19 @@ def preload_state(): if MAPPINGS not in st.session_state: st.session_state[MAPPINGS] = None -def load_generators(): +def load_generators( + folderpath = str +): + try: + with open(folderpath) as input: + generators_json = load_json(folderpath) + new_generators = generators_from_json(generators_json) + return new_generators + except FileNotFoundError: + raise Exception('Generator JSONs not found.') + + +def load_generators_to_streamlit(): spec_filepath = st.session_state[SPEC_FILE] generators = st.session_state[GENERATORS] try: diff --git a/mock_generators/constants.py b/mock_generators/constants.py index 28ba3d3..6ec2053 100644 --- a/mock_generators/constants.py +++ b/mock_generators/constants.py @@ -24,6 +24,4 @@ CODE_TEMPLATE_FILE = "templates_file" DEFAULT_DATA_IMPORTER_FILENAME = "neo4j_importer_model" NEW_GENERATOR_ARGS = "new_generator_args" - -# TODO: Can Streamlit's st.session hold all the data we'll be generating? MAPPINGS = "mappings" diff --git a/mock_generators/default_generators.json b/mock_generators/default_generators.json deleted file mode 100644 index c2f320c..0000000 --- a/mock_generators/default_generators.json +++ /dev/null @@ -1,455 +0,0 @@ -{ - "README":{ - "content": "This is a list of all generators that initially came with this repo." - } - , - "05711cac": { - "args": [], - "code_url": "mock_generators/generators/05711cac.py", - "description": "Random URI with Faker library.", - "name": "URL", - "tags": [ - "uri", - "url" - ], - "type": "String" - }, - "05add148": { - "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" - }, - "111d38e0": { - "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" - }, - "338d576e": { - "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" - }, - "469b37c7": { - "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" - }, - "470ff56f": { - "args": [], - "code_url": "mock_generators/generators/470ff56f.py", - "description": "Country name generator using the Faker library.", - "name": "Country", - "tags": [ - "country", - "from" - ], - "type": "String" - }, - "4b0db60a": { - "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" - }, - "57f2df99": { - "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" - }, - "58e9ddbb": { - "args": [], - "code_url": "mock_generators/generators/58e9ddbb.py", - "description": "First name generator using the Faker library", - "name": "First Name", - "tags": [ - "first", - "name" - ], - "type": "String" - }, - "5929c11b": { - "args": [], - "code_url": "mock_generators/generators/5929c11b.py", - "description": "Last name generator using the Faker library.", - "name": "Last Name", - "tags": [ - "last", - "name" - ], - "type": "String" - }, - "5bf1fbd6": { - "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" - }, - "5e30c30b": { - "args": [], - "code_url": "mock_generators/generators/5e30c30b.py", - "description": "Company name generator using the Faker library.", - "name": "Company Name", - "tags": [ - "company", - "name" - ], - "type": "String" - }, - "73853311": { - "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" - }, - "78bc0765": { - "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" - }, - "92eeddbb": { - "args": [], - "code_url": "mock_generators/generators/92eeddbb.py", - "description": "City name generator using the Faker library.", - "name": "City", - "tags": [ - "city", - "name" - ], - "type": "String" - }, - "ab64469b": { - "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" - }, - "c9a071b5": { - "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" - }, - "d1ebdc1a": { - "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" - }, - "df2bbd43": { - "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" - }, - "e0eb78b0": { - "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" - }, - "e56d87a3": { - "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" - }, - "e8cff8c1": { - "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" - }, - "ecdff22c": { - "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" - }, - "id1": { - "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" - }, - "id2": { - "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/file_utils.py b/mock_generators/file_utils.py index f2b3ad8..072b5a3 100644 --- a/mock_generators/file_utils.py +++ b/mock_generators/file_utils.py @@ -78,7 +78,6 @@ def save_json(filepath, data): def save_csv(filepath: str, data: list[dict]): with open(filepath, 'w') as csvfile: - # TODO: Fix, will crash if the data is empty if data is None: raise Exception(f'file_utils.py: save_csv: No data to save to {filepath}') if len(data) == 0: diff --git a/mock_generators/generate_mapping.py b/mock_generators/generate_mapping.py deleted file mode 100644 index 9d5b738..0000000 --- a/mock_generators/generate_mapping.py +++ /dev/null @@ -1,272 +0,0 @@ -# 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/ab64469b.py b/mock_generators/generators/ab64469b.py deleted file mode 100644 index 278ee21..0000000 --- a/mock_generators/generators/ab64469b.py +++ /dev/null @@ -1,15 +0,0 @@ -from datetime import timedelta -import random - -# Do not change function name or arguments -def generate(args: list[any]): - # oldest ISO datetime - min = args[0] - # most recent ISO datetime - max = args[1] - between = max - min - days = between.days - random.seed(a=None) - random_days = random.randrange(days) - result = min + timedelta(days=random_days) - return result \ No newline at end of file diff --git a/mock_generators/generators/date.py b/mock_generators/generators/date.py new file mode 100644 index 0000000..6ef31f2 --- /dev/null +++ b/mock_generators/generators/date.py @@ -0,0 +1,19 @@ +from datetime import datetime, timedelta +import random + +# Do not change function name or arguments +def generate(args: list[any]): + # oldest ISO datetime + min = args[0] + # most recent ISO datetime + max = args[1] + + # Convert string args to datetime + min_date = datetime.fromisoformat(min) + max_date = datetime.fromisoformat(max) + + delta = max_date - min_date + result = min_date + timedelta(seconds=random.randint(0, delta.total_seconds())) + + # Data Importer expects a string in ISO format + return result.isoformat() \ No newline at end of file diff --git a/mock_generators/generators/e56d87a3.py b/mock_generators/generators/e56d87a3.py deleted file mode 100644 index 1c3d375..0000000 --- a/mock_generators/generators/e56d87a3.py +++ /dev/null @@ -1,11 +0,0 @@ -import random - -# Do not change function name or arguments -def generate(args: list[any]): - options = args[0] - if options is None or options == "": - return 0 - options = options.replace(' ', '') - options = options.split(",") - result = int(random.choice(options)) - return result \ No newline at end of file diff --git a/mock_generators/generators/ecdff22c.py b/mock_generators/generators/float.py similarity index 57% rename from mock_generators/generators/ecdff22c.py rename to mock_generators/generators/float.py index 0a1ee03..f269384 100644 --- a/mock_generators/generators/ecdff22c.py +++ b/mock_generators/generators/float.py @@ -1,6 +1,6 @@ # Do not change function name or arguments def generate(args: list[any]): value = args[0] - return value - - + # Will raise error if not an int + f = float(value) + return f \ No newline at end of file diff --git a/mock_generators/generators/111d38e0.py b/mock_generators/generators/float_from_list.py similarity index 100% rename from mock_generators/generators/111d38e0.py rename to mock_generators/generators/float_from_list.py diff --git a/mock_generators/generators/e8cff8c1.py b/mock_generators/generators/float_range.py similarity index 100% rename from mock_generators/generators/e8cff8c1.py rename to mock_generators/generators/float_range.py diff --git a/mock_generators/generators/int.py b/mock_generators/generators/int.py new file mode 100644 index 0000000..a69b8cb --- /dev/null +++ b/mock_generators/generators/int.py @@ -0,0 +1,6 @@ +# Do not change function name or arguments +def generate(args: list[any]): + value = args[0] + # Will raise error if not an int + integer = int(value) + return integer \ No newline at end of file diff --git a/mock_generators/generators/int_from_list.py b/mock_generators/generators/int_from_list.py new file mode 100644 index 0000000..ec99917 --- /dev/null +++ b/mock_generators/generators/int_from_list.py @@ -0,0 +1,21 @@ +import random +import logging + +# Do not change function name or arguments +def generate(args: list[any]): + try: + # List should come in wrapped as a string: ie ["1,2,3"] + options = args[0] + if options is None or options == "": + # No args given + return 0 + + # Strip white spaces + # options = options.join(options.split()) + options = options.replace(' ', '') + options = options.split(',') + result = int(random.choice(options)) + return result + except Exception as e: + logging.error(f'Exception: {e}') + return None \ No newline at end of file diff --git a/mock_generators/generators/469b37c7.py b/mock_generators/generators/int_range.py similarity index 100% rename from mock_generators/generators/469b37c7.py rename to mock_generators/generators/int_range.py diff --git a/mock_generators/generators/literal.py b/mock_generators/generators/literal.py new file mode 100644 index 0000000..be0a8c8 --- /dev/null +++ b/mock_generators/generators/literal.py @@ -0,0 +1,4 @@ + +def generate(args: list[any]): + value = args[0] + return value \ No newline at end of file diff --git a/mock_generators/generators/loremtext_sentence.py b/mock_generators/generators/lorem_sentences.py similarity index 100% rename from mock_generators/generators/loremtext_sentence.py rename to mock_generators/generators/lorem_sentences.py diff --git a/mock_generators/generators/loremtext_words.py b/mock_generators/generators/lorem_words.py similarity index 100% rename from mock_generators/generators/loremtext_words.py rename to mock_generators/generators/lorem_words.py diff --git a/mock_generators/generators/78bc0765.py b/mock_generators/generators/short_uuid.py similarity index 93% rename from mock_generators/generators/78bc0765.py rename to mock_generators/generators/short_uuid.py index c42796b..d973677 100644 --- a/mock_generators/generators/78bc0765.py +++ b/mock_generators/generators/short_uuid.py @@ -1,6 +1,5 @@ from faker import Faker fake = Faker() -import logging def generate(args: list[any]=[]): if len(args) == 0: diff --git a/mock_generators/generators/5bf1fbd6.py b/mock_generators/generators/string_from_list.py similarity index 100% rename from mock_generators/generators/5bf1fbd6.py rename to mock_generators/generators/string_from_list.py diff --git a/mock_generators/generators/uuid.py b/mock_generators/generators/uuid.py new file mode 100644 index 0000000..ba034ef --- /dev/null +++ b/mock_generators/generators/uuid.py @@ -0,0 +1,5 @@ +import uuid + +def generate(args: list[any]): + return str(uuid.uuid4()) + \ No newline at end of file diff --git a/mock_generators/logic/generate_mapping.py b/mock_generators/logic/generate_mapping.py index 7025b22..a363a1d 100644 --- a/mock_generators/logic/generate_mapping.py +++ b/mock_generators/logic/generate_mapping.py @@ -6,34 +6,10 @@ from models.relationship_mapping import RelationshipMapping from models.property_mapping import PropertyMapping from models.generator import Generator +from logic.generate_values import generator_for_raw_property 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 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: - raise Exception(f'generate_mapping.py: generator_for_raw_property: generator_id {generator_id} not found in generators.') - args = value - return (generator, args) - def propertymappings_for_raw_properties( raw_properties: dict[str, str], generators: dict[str, Generator] @@ -50,21 +26,19 @@ def propertymappings_for_raw_properties( 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 + # TODO: Skip special COUNT and KEY literals + if key == "COUNT" or key == "KEY": + continue + try: generator, args = generator_for_raw_property(value, generators) if generator is None: @@ -89,7 +63,7 @@ def node_mappings_from( node_dicts: list, generators: dict[str, Generator] ) -> dict[str, NodeMapping]: - """Converts node information from JSON file to mapping objects""" + """Converts node information from arrows JSON file to mapping objects""" # Sample node_dict # { # "id": "n1", @@ -108,58 +82,63 @@ def node_mappings_from( # "style": {} # } + # Prepare a dict to store mappings. Incoming node id to be keys node_mappings = {} + + # Process each incoming node data for node_dict in node_dicts: - # Check for required data in raw node dict from arrows.app json + # Incoming data validation 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}") + logging.warning(f"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}") + logging.warning(f"Node properties is missing caption key from: {node_dict}: Skipping {node_dict}") continue + # Check for optional properties dict + properties = node_dict.get("properties", {}) + # Always add a _uid property to node properties + properties["_uid"] = "{\"uuid\":[]}" - # 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.") + # Create property mappings for properties + try: + property_mappings = propertymappings_for_raw_properties(properties, generators) + except Exception as e: + logging.warning(f"Could not create property mappings for node: {node_dict}: {e}") continue # Determine count generator to use - count_generator_config = properties.get("{count}", None) + # TODO: Support COUNT literal + 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 + count_generator_config = properties.get("{count}", None) + if count_generator_config is None: + count_generator_config = '{"int_range": [1,100]}' + logging.info(f"node properties is missing COUNT or {{count}} key from properties: {properties}: Using defalt int_range generator") # 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: node_mappings_from: could not find count generator for node: {node_dict}: {e}") + logging.warning(f"Could not find count generator for node: {node_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: node_mappings_from: could not create property mappings for node: {node_dict}: {e}") - continue + # Get string name for key property. Value should be an unformatted string + key = properties.get("KEY", None) + if key is None: + key = properties.get("{key}", None) + if key is None: + key = "_uid" + logging.info(f"node properties is missing KEY or {{key}}: Assigning self generated _uid") # 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.") + logging.warning(f"Key property mapping not found for node: {node_dict} - key name: {key}. Skipping node.") continue # Create node mapping @@ -173,8 +152,6 @@ def node_mappings_from( position = position, caption=caption ) - # TODO: - # Run generators node_mappings[node_mapping.nid] = node_mapping return node_mappings @@ -219,16 +196,16 @@ def relationshipmappings_from( 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 + properties = relationship_dict.get("properties", {}) # Determine count generator to use - count_generator_config = properties.get("{count}", None) + # TODO: Support COUNT key type + 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 + count_generator_config = properties.get("{count}", None) + if count_generator_config is None: + count_generator_config = '{"int_range": [1,3]}' + logging.info(f"Relationship properties is missing COUNT or '{{count}}' key from properties: {properties}: Using default int_range generator") assignment_generator_config = properties.get("{assignment}", None) # If missing, use ExhaustiveRandom diff --git a/mock_generators/logic/generate_values.py b/mock_generators/logic/generate_values.py new file mode 100644 index 0000000..66d1435 --- /dev/null +++ b/mock_generators/logic/generate_values.py @@ -0,0 +1,239 @@ + +from models.generator import Generator +import logging +import json + +# ORIGINAL GENERATOR ASSIGNMENT +def actual_generator_for_raw_property( + property_value: str, + generators: dict[str, Generator] + ) -> tuple[Generator, list[any]]: + """Returns a generator and args for a property. Returns None if not found.""" + # Sample property_values: + # "{\"company_name\":[]}" + # {"int_from_list": ["1,2,3"]} + try: + obj = json.loads(property_value) + # 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'Generator_id {generator_id} not found in generators. Skipping generator assignment for property_value: {property_value}') + return (None, None) + args = value + return (generator, args) + except Exception as e: + logging.debug(f'Could not parse JSON string: {property_value}. Returning None from generator assignment for property_value: {property_value}. Error: {e}') + + return (None, None) + +# KEYWORD GENERATOR ASSIGNMENT +def keyword_generator_for_raw_property( + value: str, + generators: dict[str, Generator] + ) -> tuple[Generator, list[any]]: + """Returns a generator and args for a property value using a generic keyword. Returns None if not found.""" + + result = None + if value.lower() == "string": + result = { + "lorem_words": [1,3] + } + elif value.lower() == "int" or value.lower() == "integer": + result = { + "int_range": [1,100] + } + elif value.lower() == "float": + result = { + "float_range": [1.0,100.0, 2] + } + elif value.lower() == "bool" or value.lower() == "boolean": + result = { + "bool": [50] + } + elif value.lower() == "date" or value.lower() == "datetime": + result = { + "date": ["1970-01-01", "2022-11-24"] + } + + # Default + if result is None: + return (None, None) + + # Generator was assigned + g_config = json.dumps(result) + return actual_generator_for_raw_property(g_config, generators) + + +# LITERAL GENERATOR ASSIGNMENT SUPPORT +def all_ints(values: list[str]) -> bool: + for value in values: + if not is_int(value): + return False + return True + +def some_floats(values: list[str]) -> bool: + for value in values: + if is_float(value): + return True + return False + +def all_floats(values: list[str]) -> bool: + for value in values: + if not is_float(value): + return False + return True + +def is_int(value: str) -> bool: + try: + int(value) + return True + except ValueError: + return False + +def is_float(value: str) -> bool: + try: + f = float(value) + return True + except ValueError: + return False + +def literal_generator_from_value( + value: str, + generators: list[Generator] + )-> tuple[Generator, list[any]]: + """ + Attempts to find an actual generator based on more concise literal values from arrows.app JSON + + Support for: + - ints + - floats + - ranges of ints + - ranges of floats + - lists of ints + - lists of floats + - string literals + - list of strings + + TODO: + - bools + - lists of bools + - date + - lists of dates + - datetime + - list of datetimes + - "int" / "integer" -> random int generator + - "float" -> random float generator + - "string" -> random word generator + - "bool" / "boolean" -> random bool generator + - "date" -> random datetime generator + - "datetime" -> random datetime generator + """ + # Sample expected values: + # "1" + # "1-10" + # "[3, 5, 10]" + # "[Yes, No]" + + # Original specificaion took stringified JSON objects to notate generator and args to use. We're going to convert matching literal values to appropriate generators + + # Default is to use the literal generator + result = { + "string": [value] + } + + # Check if value is an int or float + if is_int(value): + integer = int(value) + result = { + "int": [integer] + } + + if is_float(value): + f = float(value) + result = { + "float": [f] + } + + # NOTE: Not currently handling complex literals + + # Check if value is a range of ints or floats + r = value.split("-") + if len(r) == 2: + # Single dash in string, possibly a literal range + values = [r[0], r[1]] + if all_ints(values): + result = { + "int_range": [int(r[0]), int(r[1])] + } + elif some_floats(values): + # Float range function expects 3 args - this one seems more sensible than other functions + result = { + "float_range": [float(r[0]), float(r[1]), 2] + } + + + # Check for literal list of ints, floats, or strings + if value.startswith('[') and value.endswith(']'): + values = value[1:-1].split(',') + # Generators take a strange format where the args are always a string - including # lists of other data, like ints, floats. ie ["1,2,3"] is an expected arg type + # because certain generators could take multiple args from different text fields + # These literals, however, all only take a single generic arg + + # YES - this is terrible + + if all_ints(values): + ints_as_string = ",".join([f'{int(v)}' for v in values]) + result = { + "int_from_list": [f"{ints_as_string}"] + } + elif some_floats(values): + floats_as_string = ",".join([f'{float(v)}' for v in values]) + result = { + "float_from_list": [f"{floats_as_string}"] + } + else: + result = { + "string_from_list": values + } + + # Package and return from legacy process + actual_string = json.dumps(result) + return actual_generator_for_raw_property(actual_string, generators) + +def generator_for_raw_property( + property_value: str, + generators: dict[str, Generator] + ) -> tuple[Generator, list[any]]: + """ + Returns a generator and args for specially formatted property values from the arrows.app JSON file. Attempts to determine if literal or original generator + specification was use. + + Return None if no generator found. + """ + + # Original Sample expected string: "{\"company_name\":[]}" + # New literal examples: + # "{\"company_name\":\"Acme\"}" + # "{\"company_name\":[\"Acme\"]}" + # "{\"company_name\":\"string\"}" + + # Also fupport following options: "string", "bool", "boolean", "float", "integer", "number", "date", "datetime" + + generator, args = None, None + + # Check for Legacy Properties assignments + # Also returns None, None if no matching generator found + if generator is None: + generator, args = keyword_generator_for_raw_property(property_value, generators) + + if generator is None: + generator, args = actual_generator_for_raw_property(property_value, generators) + + # Check for new literal assignments + # Defaults to string literal + if generator is None: + generator, args = literal_generator_from_value(property_value, generators) + + return (generator, args) \ No newline at end of file diff --git a/mock_generators/logic/mapping_conversions.py b/mock_generators/logic/mapping_conversions.py new file mode 100644 index 0000000..e9b73c5 --- /dev/null +++ b/mock_generators/logic/mapping_conversions.py @@ -0,0 +1,99 @@ +# For converting data from various forms to other forms +from streamlit_agraph import agraph, Node, Edge, Config + +def convert_agraph_node_to_arrows_node(node): + # Convert agraph node to arrows node + # TODO: How do we handle position + arrows_node = { + "id": node.id, + "caption": node.label, + "position": { + "x": 0, + "y": 0, + }, + "labels":[], + "style": {}, + "properties": {} + } + return arrows_node + +def convert_agraph_edge_to_arrows_relationship(edge): + arrows_relationship = { + "id": edge.id, + "from": edge.source, + "to": edge.target, + "type": edge.label, + "properties": {}, + "style": {} + } + return arrows_relationship + +def convert_agraph_to_arrows_json(agraph_nodes, agraph_edges): + # Convert agraph to arrows json + arrows_nodes = [], + arrows_relationships = [] + for node in agraph_nodes: + new_node = convert_agraph_node_to_arrows_node(node) + arrows_nodes.append(new_node) + for edge in agraph_edges: + new_relationship = convert_agraph_edge_to_arrows_relationship(edge) + arrows_relationships.append(new_relationship) + arrows_json = { + "nodes": arrows_nodes, + "relationships": arrows_relationships, + "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" + } + } + return arrows_json + \ No newline at end of file diff --git a/mock_generators/mappings/README.md b/mock_generators/mappings/README.md new file mode 100644 index 0000000..be7fa7d --- /dev/null +++ b/mock_generators/mappings/README.md @@ -0,0 +1 @@ +Folder for storing mapping files if user should want to save working versions \ No newline at end of file diff --git a/mock_generators/mappings/simple_org_chart.json b/mock_generators/mappings/simple_org_chart.json new file mode 100644 index 0000000000000000000000000000000000000000..b8279a5fe8d17719176ca73965befcaa5acc4423 GIT binary patch literal 921 zcmah{%Wl&^6s4(Cw@IHgu%RlYbWs*Wzyb*giO`Bhgw}}?B-BM@O`1vW2z$nw2f~X= zuphBu_z*U%`2oHHi5=n-!q{;WH-#!q&bc3)FQ4YGrJr66Ssw?Y8los7bZE4D zl0~1*y^rSH?CT=6Eg}sI^nuZv*+`Q1Cv#=b{$81#r=)Md)r%1mL=r~RHFNJxBOZ-L zdM-ANZ#GccJws7i*9$Gog>BRe0qR+5rfYO5;w-{kl8i@bK)8@Q6ouIK3CUCHMI0g$ zx-Pa^U?-u{HE=VPR1z8FsKeHj zBy4+d8+PE12fJgeM|rIK6D;9w{X8viCs^X~A8%QN36^`v!U)kghvr1LxqQ}p;pNlJ%V%&5HSNAQX&pCD uI|dG*3WqR52aY_bTVgLgXmm}w!df(4@!GCf8b9)=*-n=1{eOv4z3N}1cw;{R literal 0 HcmV?d00001 diff --git a/mock_generators/models/data_import.py b/mock_generators/models/data_import.py index dfe3b93..cbb4174 100644 --- a/mock_generators/models/data_import.py +++ b/mock_generators/models/data_import.py @@ -1,10 +1,13 @@ -from models.mapping import Mapping +# 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 GeneratorType from models.node_mapping import NodeMapping from models.relationship_mapping import RelationshipMapping from models.property_mapping import PropertyMapping -import logging -from models.generator import GeneratorType import datetime +import logging def file_schema_for_property(property: PropertyMapping)-> dict: diff --git a/mock_generators/models/generator.py b/mock_generators/models/generator.py index f65b676..c1a1a22 100644 --- a/mock_generators/models/generator.py +++ b/mock_generators/models/generator.py @@ -2,7 +2,7 @@ import logging import sys import re -import numbers +# import numbers # TODO: replace with dataclasses # from dataclasses import dataclass @@ -245,7 +245,7 @@ def generate(self, args): result = module.generate(args) return result except: - logging.error(f"Error generating data for generator {self.name}, id {self.id}: {sys.exc_info()[0]}") + logging.error(f"Error generating data for generator '{self.name}': id {self.id}: ERROR: {sys.exc_info()[0]}") return None diff --git a/mock_generators/list_utils.py b/mock_generators/models/list_utils.py similarity index 100% rename from mock_generators/list_utils.py rename to mock_generators/models/list_utils.py diff --git a/mock_generators/models/mapping.py b/mock_generators/models/mapping.py index b1d4477..b6d420a 100644 --- a/mock_generators/models/mapping.py +++ b/mock_generators/models/mapping.py @@ -1,5 +1,7 @@ +# from models.node_mapping import NodeMapping from models.node_mapping import NodeMapping + import json import logging import sys diff --git a/mock_generators/models/node_mapping.py b/mock_generators/models/node_mapping.py index b8876f2..24372b3 100644 --- a/mock_generators/models/node_mapping.py +++ b/mock_generators/models/node_mapping.py @@ -1,8 +1,12 @@ +# from models.property_mapping import PropertyMapping +# from models.generator import Generator from models.property_mapping import PropertyMapping from models.generator import Generator +from models.list_utils import clean_list + import sys +import uuid import logging -from list_utils import clean_list # TODO: Should have made these dataclasses class NodeMapping(): @@ -26,7 +30,7 @@ def __init__( position: dict, # ie: {x: 0, y: 0} caption: str, labels: list[str], - properties: dict[str, PropertyMapping], + properties: dict[str, any], count_generator: Generator, count_args: list[any], key_property: PropertyMapping): @@ -102,12 +106,12 @@ def generate_values(self) -> list[dict]: # Example return: # [ # { - # "_uid": "n1_abc", + # "_uid": "abc123", # "first_name": "John", # "last_name": "Doe" # }, # { - # "_uid": "n1_xyz", + # "_uid": "xyz123", # "first_name": "Jane", # "last_name": "Doe" # } @@ -115,14 +119,14 @@ def generate_values(self) -> list[dict]: count = 0 all_results = [] try: - count = self.count_generator.generate(self.count_args) + count = int(self.count_generator.generate(self.count_args)) except: + logging.error(f'Possibly incorrect generator assigned for count generation: {self.count_generator}') raise Exception(f"Node mapping could not generate a number of nodes to continue generation process, error: {str(sys.exc_info()[0])}") try: for _ in range(count): node_result = {} - # 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: @@ -131,12 +135,12 @@ def generate_values(self) -> list[dict]: # Have PropertyMapping generate a value value = property.generate_value() node_result[property.name] = value - # node_result["_uid"] = f"{self.id}_{str(uuid.uuid4())[:8]}" + + # Assign a uuid all_results.append(node_result) except: raise Exception(f"Node mapping could not generate property values, error: {str(sys.exc_info()[0])}") # 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}') return self.generated_values \ No newline at end of file diff --git a/mock_generators/models/property_mapping.py b/mock_generators/models/property_mapping.py index e98354a..8840e55 100644 --- a/mock_generators/models/property_mapping.py +++ b/mock_generators/models/property_mapping.py @@ -1,15 +1,16 @@ from models.generator import Generator, GeneratorType -from list_utils import clean_list +from models.list_utils import clean_list import logging class PropertyMapping(): + # TODO: Handle literals + @staticmethod def empty(): return PropertyMapping( pid = None, name = None, - # type = None, generator = None, args = None ) @@ -18,13 +19,11 @@ def __init__( self, pid: str, name: str = 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.generator = generator self.args = args @@ -43,7 +42,6 @@ def to_dict(self): return { "pid": self.pid, "name": self.name, - # "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,8 +49,6 @@ 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.generator is None: return False return True diff --git a/mock_generators/models/relationship_mapping.py b/mock_generators/models/relationship_mapping.py index 45c8e3b..9aec53e 100644 --- a/mock_generators/models/relationship_mapping.py +++ b/mock_generators/models/relationship_mapping.py @@ -1,12 +1,16 @@ +# from models.node_mapping import NodeMapping +# from models.property_mapping import PropertyMapping +# from models.generator import Generator + from models.node_mapping import NodeMapping from models.property_mapping import PropertyMapping from models.generator import Generator +from models.list_utils import clean_list + import logging -import random import sys from copy import deepcopy -from list_utils import clean_list class RelationshipMapping(): @@ -130,8 +134,10 @@ def generate_values( # Decide on how many of these relationships to generate count = 0 try: - count = self.count_generator.generate(self.count_args) + count = int(self.count_generator.generate(self.count_args)) except: + logging.error(f'Possibly incorrect generator assigned for count generation: {self.count_generator}') + # Generator not found or other code error raise Exception(f"Relationship mapping could not generate a number of relationships to continue generation process, error: {str(sys.exc_info()[0])}") diff --git a/mock_generators/named_generators.json b/mock_generators/named_generators.json index 4023d76..88d12a5 100644 --- a/mock_generators/named_generators.json +++ b/mock_generators/named_generators.json @@ -29,6 +29,23 @@ ], "type": "String" }, + "float": { + "args": [ + { + "default": 1.0, + "label": "Value", + "type": "Float" + } + ], + "code_url": "mock_generators/generators/float.py", + "description": "Constant Float value", + "name": "Float", + "tags": [ + "float", + "number" + ], + "type": "Float" + }, "float_from_list": { "args": [ { @@ -37,7 +54,7 @@ "type": "String" } ], - "code_url": "mock_generators/generators/111d38e0.py", + "code_url": "mock_generators/generators/float_from_list.py", "description": "Randomly selected float from a comma-seperated list of options.", "name": "Float from list", "tags": [ @@ -84,7 +101,7 @@ "type": "Integer" } ], - "code_url": "mock_generators/generators/469b37c7.py", + "code_url": "mock_generators/generators/int_range.py", "description": "Random integer from a min and max value argument. Argument values are inclusive.", "name": "Int Range", "tags": [ @@ -156,6 +173,23 @@ ], "type": "String" }, + "string": { + "args": [ + { + "default": "", + "label": "List of words (ie: alpha, brave, charlie)", + "type": "String" + } + ], + "code_url": "mock_generators/generators/literal.py", + "description": "String literal", + "name": "String", + "tags": [ + "string", + "word" + ], + "type": "String" + }, "string_from_list": { "args": [ { @@ -164,7 +198,7 @@ "type": "String" } ], - "code_url": "mock_generators/generators/5bf1fbd6.py", + "code_url": "mock_generators/generators/string_from_list.py", "description": "Randomly selected string from a comma-seperated list of options.", "name": "String from list", "tags": [ @@ -199,6 +233,20 @@ "type": "Assignment" }, "uuid": { + "args": [ + ], + "code_url": "mock_generators/generators/uuid.py", + "description": "UUID4 Generator", + "name": "UUID", + "tags": [ + "uuid", + "hash", + "unique", + "uid" + ], + "type": "String" + }, + "short_uuid": { "args": [ { "default": 37, @@ -206,14 +254,14 @@ "type": "Integer" } ], - "code_url": "mock_generators/generators/78bc0765.py", + "code_url": "mock_generators/generators/short_uuid.py", "description": "Random UUID 4 hash using Faker library. 37 Characters Max.", - "name": "UUID", + "name": "Short UUID", "tags": [ - "uuid", + "uid", "hash", "unique", - "uid" + "short" ], "type": "String" }, @@ -241,7 +289,7 @@ "type": "Datetime" } ], - "code_url": "mock_generators/generators/ab64469b.py", + "code_url": "mock_generators/generators/date.py", "description": "Generate a random date between 2 specified dates. Exclusive of days specified.", "name": "Date", "tags": [ @@ -335,7 +383,7 @@ "type": "String" } ], - "code_url": "mock_generators/generators/e56d87a3.py", + "code_url": "mock_generators/generators/int_from_list.py", "description": "Randomly selected int from a comma-seperated list of options. If no list provided, will return 0", "name": "Int from list", "tags": [ @@ -368,7 +416,7 @@ "type": "Integer" } ], - "code_url": "mock_generators/generators/e8cff8c1.py", + "code_url": "mock_generators/generators/float_range.py", "description": "Random float between a range. Inclusive.", "name": "Float", "tags": [ @@ -390,7 +438,7 @@ "type": "Integer" } ], - "code_url": "mock_generators/generators/ecdff22c.py", + "code_url": "mock_generators/generators/int.py", "description": "Constant integer value", "name": "Int", "tags": [ @@ -415,7 +463,7 @@ "type": "Integer" } ], - "code_url": "mock_generators/generators/loremtext_words.py", + "code_url": "mock_generators/generators/lorem_words.py", "description": "String generator using the lorem-text package", "name": "Words", "tags": [ @@ -439,7 +487,7 @@ "type": "Integer" } ], - "code_url": "mock_generators/generators/loremtext_sentence.py", + "code_url": "mock_generators/generators/lorem_sentences.py", "description": "String generator using the lorem-text package", "name": "Sentences", "tags": [ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..20c0009 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.pytest.ini_options] +log_cli = true +log_cli_level = "INFO" +log_cli_format = "%(asctime)s %(levelname)8s %(filename)s: %(funcName)20s():%(lineno)s %(message)s" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..6e4a333 --- /dev/null +++ b/tests/README.md @@ -0,0 +1 @@ +# Why didn't I just build a test framework that reads from JSON files with inputs and expected outputs? \ No newline at end of file diff --git a/tests/test_generate_mappings.py b/tests/test_generate_mappings.py index 7a3b427..c685710 100644 --- a/tests/test_generate_mappings.py +++ b/tests/test_generate_mappings.py @@ -1,63 +1,16 @@ +import os 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 +from mock_generators.models.generator import Generator, GeneratorType 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 -} +import logging +# test_generators = load_generators("mock_generators/named_generators.json") + +# TODO: Test propertymappings_for_raw_properties +# TODO: Test node_mappings_from +# TODO: Test relationshipmappings_from +# TODO: Test mapping_from_json class TestClass: # def test_version(self): @@ -101,49 +54,67 @@ def test_jsonification(self): except Exception as e: print(f'Exception: {e}') assert False + + # def test_failed_generator_for_raw_property(self): + # try: + # generator, args = generator_for_raw_property("{\'test_doesnt_exist\':[1]}", test_generators) + # assert generator is None + # assert args is None + # except Exception as e: + # print(f'Exception: {e}') + + + # def test_propertymappings_for_raw_properties_smoke(self): + # raw_props = { + # "alpha": "{\"test_int\":[1]}", + # "bravo": "{\"test_float\":[1.0, 2.0]}" + # } + # mappings = propertymappings_for_raw_properties( + # raw_properties=raw_props, + # generators= test_generators) + # assert len(mappings) == 2 + + # def test_node_mappings_from_literals(self): + # nodes = [ + # { + # "id": "n1", + # "position": { + # "x": 284.5, + # "y": -204 + # }, + # "caption": "Company", + # "labels": [], + # "properties": { + # "string": "name", + # "bool": "bool", + # "int": "int", + # "float": "float", + # "datetime": "2020-01-01T00:00:00Z" + # }, + # "style": {} + # } + # ] + + # def test_propertymappings_for_raw_properties_literals(self): + # raw_props = { + # "string": "test", + # "bool": True, + # "int": 1, + # "float": 1.0, + # "datetime": "2020-01-01T00:00:00Z", + # } + # mappings = propertymappings_for_raw_properties( + # raw_properties=raw_props, + # generators= test_generators) + # assert len(mappings) == 5 + # assert mappings["string"] == "test" + # assert mappings["bool"] == True + # assert mappings["int"] == 1 + # assert mappings["float"] == 1.0 + # assert mappings["datetime"] == "2020-01-01T00:00:00Z" + - 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( diff --git a/tests/test_generate_values.py b/tests/test_generate_values.py new file mode 100644 index 0000000..ca297d8 --- /dev/null +++ b/tests/test_generate_values.py @@ -0,0 +1,318 @@ +import pytest +from mock_generators.config import load_generators +from mock_generators.logic.generate_values import literal_generator_from_value, actual_generator_for_raw_property, generator_for_raw_property, keyword_generator_for_raw_property + + +test_generators = load_generators("mock_generators/named_generators.json") + +# TODO: Probably not the most ideal way to test these + +class TestActualGenerators: + + def test_non_conforming(self): + try: + # String literal + test_string = "invalid" + generator, args = actual_generator_for_raw_property(test_string, test_generators) + assert generator == None + assert args == None + except Exception as e: + assert False, f'Exception: {e}' + + + try: + # Empty + test_string = "{}" + generator, args = actual_generator_for_raw_property(test_string, test_generators) + assert generator == None + assert args == None + except Exception as e: + assert False, f'Exception: {e}' + + + try: + # Number + test_string = "6" + generator, args = actual_generator_for_raw_property(test_string, test_generators) + assert generator == None + assert args == None + except Exception as e: + assert False, f'Exception: {e}' + + + try: + # Empty + test_string = "" + generator, args = actual_generator_for_raw_property(test_string, test_generators) + assert generator == None + assert args == None + except Exception as e: + assert False, f'Exception: {e}' + + + def test_integer(self): + try: + test_string = "{\"int\": [1]}" + generator, args = actual_generator_for_raw_property(test_string, test_generators) + assert args == [1] + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value == 1 + except Exception as e: + assert False, f'Exception: {e}' + + + def test_integer_list_single(self): + try: + test_string = "{\"int_from_list\":[\"1\"]}" + generator, args = actual_generator_for_raw_property(test_string, test_generators) + assert args == ["1"] + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value == 1 + except Exception as e: + assert False, f'Exception: {e}' + + + def test_integer_list_multi(self): + try: + test_string = "{\"int_from_list\": [\"1,2,3\"]}" + generator, args = actual_generator_for_raw_property(test_string, test_generators) + assert args == ["1,2,3"] + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value in [1,2,3] + except Exception as e: + assert False, f'Exception: {e}' + + +class TestLiteralGenerators: + def test_integer(self): + try: + test_string = "1" + # This should be equivalent to the integer generator with arg of [1] + generator, args = literal_generator_from_value(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value == 1 + except Exception as e: + assert False, f'Exception: {e}' + + + def test_float(self): + try: + test_string = "1.0" + # This should be equivalent to the integer generator with arg of [1] + generator, args = literal_generator_from_value(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value == 1.0 + except Exception as e: + assert False, f'Exception: {e}' + + + def test_int_range(self): + try: + test_string = "1-3" + # This should be equivalent to the integer generator with arg of [1] + generator, args = literal_generator_from_value(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value in [1,2,3] + except Exception as e: + assert False, f'Exception: {e}' + + + def test_float_range(self): + try: + test_string = "1.0-2" + # This should be equivalent to the integer generator with arg of [1] + generator, args = literal_generator_from_value(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value <= 2.0 + assert value >= 1.0 + except Exception as e: + assert False, f'Exception: {e}' + + + def test_int_list(self): + try: + test_string = "[1,2,3]" + # This should be equivalent to the integer generator with arg of [1] + generator, args = literal_generator_from_value(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value in [1,2,3] + except Exception as e: + assert False, f'Exception: {e}' + + + def test_float_list(self): + try: + test_string = "[1.0, 2, 3.0]" + # This should be equivalent to the integer generator with arg of [1] + generator, args = literal_generator_from_value(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value in [1.0, 2.0, 3.0], f'generator: {generator}, args: {args}, value: {value}' + except Exception as e: + assert False, f'Exception: {e}' + + + def test_string(self): + try: + test_string = "A string value" + # This should be equivalent to the integer generator with arg of [1] + generator, args = literal_generator_from_value(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value == "A string value" + except Exception as e: + assert False, f'Exception: {e}' + + + def test_string_from_list(self): + try: + test_string = "[Chicken, Peas, Carrots]" + # This should be equivalent to the integer generator with arg of [1] + generator, args = literal_generator_from_value(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value in ["Chicken", "Peas", "Carrots"] + except Exception as e: + assert False, f'Exception: {e}' + + +class TestGeneratorForProperties: + def test_failed_generator_for_raw_property(self): + # Will default to a string literal + try: + generator, args = generator_for_raw_property("{\'test_doesnt_exist\':[1]}", test_generators) + value = generator.generate(args) + assert value == "{\'test_doesnt_exist\':[1]}" + except Exception as e: + assert False, f'Exception: {e}' + + def test_string_routing(self): + try: + test_string = "literal string" + # This should be equivalent to the integer generator with arg of [1] + generator, args = generator_for_raw_property(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value == "literal string" + except Exception as e: + assert False, f'Exception: {e}' + + + try: + # List of strings + test_string = "[Chicken, Peas, Carrots]" + # This should be equivalent to the integer generator with arg of [1] + generator, args = generator_for_raw_property(test_string, test_generators) + # Test generator returned creates acceptable value + value = generator.generate(args) + assert value in ["Chicken", "Peas", "Carrots"] + except Exception as e: + assert False, f'Exception: {e}' + + + +class TestKeywordGeneratorForProperties: + def test_no_keyword_found(self): + try: + generator, args = keyword_generator_for_raw_property("NOT A KEYWORD", test_generators) + assert generator is None + assert args is None + except Exception as e: + assert False, f'Exception: {e}' + + def test_bool_keywords(self): + try: + generator, args = keyword_generator_for_raw_property("bool", test_generators) + value = generator.generate(args) + assert value in [True, False] + except Exception as e: + assert False, f'Exception: {e}' + + try: + generator, args = keyword_generator_for_raw_property("boolean", test_generators) + value = generator.generate(args) + assert value in [True, False] + except Exception as e: + assert False, f'Exception: {e}' + + try: + generator, args = keyword_generator_for_raw_property("Boolean", test_generators) + value = generator.generate(args) + assert value in [True, False] + except Exception as e: + assert False, f'Exception: {e}' + + def test_int_keywords(self): + try: + generator, args = keyword_generator_for_raw_property("int", test_generators) + value = generator.generate(args) + assert value <= 100 + assert value >= 1 + except Exception as e: + assert False, f'Exception: {e}' + + try: + generator, args = keyword_generator_for_raw_property("integer", test_generators) + value = generator.generate(args) + assert value <= 100 + assert value >= 1 + except Exception as e: + assert False, f'Exception: {e}' + + try: + generator, args = keyword_generator_for_raw_property("Integer", test_generators) + value = generator.generate(args) + assert value <= 100 + assert value >= 1 + except Exception as e: + assert False, f'Exception: {e}' + + def test_float_keywords(self): + try: + generator, args = keyword_generator_for_raw_property("float", test_generators) + value = generator.generate(args) + assert value <= 100.0 + assert value >= 1.0 + except Exception as e: + assert False, f'Exception: {e}' + + try: + generator, args = keyword_generator_for_raw_property("Float", test_generators) + value = generator.generate(args) + assert value <= 100.0 + assert value >= 1.0 + except Exception as e: + assert False, f'Exception: {e}' + + def test_date_keywords(self): + from datetime import datetime + + try: + generator, args = keyword_generator_for_raw_property("date", test_generators) + value = generator.generate(args) + lower_bound = datetime.fromisoformat('1970-01-01T00:00:00') + upper_bound = datetime.fromisoformat('2022-11-24T00:00:00') + check_date = datetime.fromisoformat(value) + assert lower_bound <= check_date <= upper_bound + except Exception as e: + assert False, f'Exception: {e}' + + + try: + generator, args = keyword_generator_for_raw_property("Datetime", test_generators) + value = generator.generate(args) + lower_bound = datetime.fromisoformat('1970-01-01T00:00:00') + upper_bound = datetime.fromisoformat('2022-11-24T00:00:00') + check_date = datetime.fromisoformat(value) + print(f'check_date: {check_date}') + assert lower_bound <= check_date <= upper_bound + except Exception as e: + assert False, f'Exception: {e}' diff --git a/tests/test_generators.py b/tests/test_generators.py new file mode 100644 index 0000000..a4d0f93 --- /dev/null +++ b/tests/test_generators.py @@ -0,0 +1,42 @@ +import os +import pytest +from mock_generators.config import load_generators +from mock_generators.logic.generate_values import actual_generator_for_raw_property +from datetime import datetime +test_generators = load_generators("mock_generators/named_generators.json") + +class TestDateGenerator: + def test_date_generator(self): + try: + test_string = '{"date": ["1970-01-01", "2022-11-24"]}' + generator, args = actual_generator_for_raw_property(test_string, test_generators) + value = generator.generate(args) + + lower_bound = datetime.fromisoformat('1970-01-01T00:00:00') + upper_bound = datetime.fromisoformat('2022-11-24T00:00:00') + + # define a datetime object to check + check_date = datetime.fromisoformat(value) + + # check if the check_date is within the bounds + assert lower_bound <= check_date <= upper_bound + except Exception as e: + print(f'Exception: {e}') + assert False + + try: + test_string = '{"date": ["1970-01-01", "1970-01-01"]}' + generator, args = actual_generator_for_raw_property(test_string, test_generators) + value = generator.generate(args) + + lower_bound = datetime.fromisoformat('1970-01-01T00:00:00') + upper_bound = datetime.fromisoformat('1970-01-01T00:00:00') + + # define a datetime object to check + check_date = datetime.fromisoformat(value) + + # check if the check_date is within the bounds + assert lower_bound <= check_date <= upper_bound + except Exception as e: + print(f'Exception: {e}') + assert False \ No newline at end of file From fbe77b5237e5200427e9ee45de3cd791fe0d450d Mon Sep 17 00:00:00 2001 From: Jason Koo Date: Sun, 7 May 2023 10:12:39 -0700 Subject: [PATCH 2/5] Random image url added and string_from_csv fixed --- Pipfile | 2 ++ Pipfile.lock | 32 ++++++++--------- mock_generators/generators/df2bbd43.py | 17 --------- mock_generators/generators/image_url.py | 20 +++++++++++ mock_generators/generators/string_from_csv.py | 27 ++++++++++++++ mock_generators/logic/generate_values.py | 9 ++--- mock_generators/named_generators.json | 36 +++++++++++++++---- mock_generators/tabs/design_tab.py | 12 ++----- 8 files changed, 100 insertions(+), 55 deletions(-) delete mode 100644 mock_generators/generators/df2bbd43.py create mode 100644 mock_generators/generators/image_url.py create mode 100644 mock_generators/generators/string_from_csv.py diff --git a/Pipfile b/Pipfile index 3e5b3bb..1251f9b 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,8 @@ streamlit-agraph = "*" lorem-text = "*" faker = "*" streamlit-player = "*" +requests = "*" +pandas = "*" [dev-packages] mock-generators = {editable = true, path = "."} diff --git a/Pipfile.lock b/Pipfile.lock index 3eb7842..93cf2d3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8541ff792fc307a32203cc04960e717db21fa70f46d6bb2d145becbb9aac37ef" + "sha256": "3ff999e6051e3b1bb0b7187e0b3be6f8ffc877541da36e4f2f06204720b5db8b" }, "pipfile-spec": 6, "requires": { @@ -58,11 +58,11 @@ }, "certifi": { "hashes": [ - "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", - "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18" + "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", + "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" ], "markers": "python_version >= '3.6'", - "version": "==2022.12.7" + "version": "==2023.5.7" }, "charset-normalizer": { "hashes": [ @@ -240,11 +240,11 @@ }, "faker": { "hashes": [ - "sha256:3aabe5b8c4ab50103a0c05c15e31a24b22045968d1ac0b7fb48d1c6244400495", - "sha256:6f882f5a3303c0690b3fb2dd3418839935a2dca155d38f1b2931b0079f3ce622" + "sha256:6385386ba8d5aa255bec72f5392c2b795fcec8bebf975a9953488948d54bce35", + "sha256:ef61bbf266d30819e83bab4a6c74a0f5979ce4d19d4c9305719dd26cb7d8d51c" ], "index": "pypi", - "version": "==18.6.1" + "version": "==18.6.2" }, "favicon": { "hashes": [ @@ -717,7 +717,7 @@ "sha256:f25e23a03f7ad7211ffa30cb181c3e5f6d96a8e4cb22898af462a7333f8a74eb", "sha256:fe7914d8ddb2d54b900cec264c090b88d141a1eed605c9539a187dbc2547f022" ], - "markers": "python_version >= '3.8'", + "index": "pypi", "version": "==2.0.1" }, "pillow": { @@ -1026,11 +1026,11 @@ }, "requests": { "hashes": [ - "sha256:e8f3c9be120d3333921d213eef078af392fba3933ab7ed2d1cba3b56f2568c3b", - "sha256:f2e34a75f4749019bb0e3effb66683630e4ffeaf75819fb51bebef1bf5aef059" + "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294", + "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4" ], - "markers": "python_version >= '3.7'", - "version": "==2.29.0" + "index": "pypi", + "version": "==2.30.0" }, "rich": { "hashes": [ @@ -1234,11 +1234,11 @@ }, "urllib3": { "hashes": [ - "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305", - "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42" + "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc", + "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.26.15" + "markers": "python_version >= '3.7'", + "version": "==2.0.2" }, "validators": { "hashes": [ diff --git a/mock_generators/generators/df2bbd43.py b/mock_generators/generators/df2bbd43.py deleted file mode 100644 index e38b807..0000000 --- a/mock_generators/generators/df2bbd43.py +++ /dev/null @@ -1,17 +0,0 @@ -import csv -import random -import sys - -# Do not change function name or arguments -def generate(args: list[any]): - filepath = args[0] - field = args[1] - result = [] - try: - with open(filepath, mode='r') as csvfile: - reader = csv.DictReader(csvfile) - for row in reader: - result.append(row[field]) - except: - raise Exception(f'Generator df2bbd43: Could not read file {filepath}. {sys.exc_info()[0]}') - return random.choice(result) \ No newline at end of file diff --git a/mock_generators/generators/image_url.py b/mock_generators/generators/image_url.py new file mode 100644 index 0000000..f007da3 --- /dev/null +++ b/mock_generators/generators/image_url.py @@ -0,0 +1,20 @@ +import random + +# Do not change function name or arguments +def generate(args: list[any]): + width = 400 + height = 200 + if len(args) > 1: + try: + height = int(args[1]) + except: + pass + if len(args) > 0: + try: + width = int(args[0]) + except: + pass + + pid = random.randint(1, 237) + url = f'https://picsum.photos/id/{pid}/{width}/{height}' + return url \ No newline at end of file diff --git a/mock_generators/generators/string_from_csv.py b/mock_generators/generators/string_from_csv.py new file mode 100644 index 0000000..481c1e8 --- /dev/null +++ b/mock_generators/generators/string_from_csv.py @@ -0,0 +1,27 @@ +import pandas as pd +import io +import requests +import random +import logging +# Using streamlit's cache to avoid reloading the csv each time +import streamlit as st + +@st.cache_data(ttl=60) # 1 minute +def csv_data(url: str): + csv=requests.get(url).content + df=pd.read_csv(io.StringIO(csv.decode('utf-8'))) + records = df.to_dict('records') + return records + +def generate(args: list[any]): + url = args[0] + field = args[1] + try: + data = csv_data(url) + # Return a random item from list + entry = random.choice(data) + value = entry.get(field, None) + return value + except Exception as e: + logging.error(f'Exception: {e}') + return None diff --git a/mock_generators/logic/generate_values.py b/mock_generators/logic/generate_values.py index 66d1435..c5dfc52 100644 --- a/mock_generators/logic/generate_values.py +++ b/mock_generators/logic/generate_values.py @@ -121,20 +121,15 @@ def literal_generator_from_value( - lists of bools - date - lists of dates - - datetime + - datetime (ISO 8601) - list of datetimes - - "int" / "integer" -> random int generator - - "float" -> random float generator - - "string" -> random word generator - - "bool" / "boolean" -> random bool generator - - "date" -> random datetime generator - - "datetime" -> random datetime generator """ # Sample expected values: # "1" # "1-10" # "[3, 5, 10]" # "[Yes, No]" + # "[True, False, False]" # Original specificaion took stringified JSON objects to notate generator and args to use. We're going to convert matching literal values to appropriate generators diff --git a/mock_generators/named_generators.json b/mock_generators/named_generators.json index 88d12a5..0fbcc4e 100644 --- a/mock_generators/named_generators.json +++ b/mock_generators/named_generators.json @@ -336,24 +336,23 @@ "default": "", "label": "CSV Filepath", "type": "String", - "hint": "mock_generators/datasets/tech_companies.csv", + "hint": "http://some.website.com/tech_companies.csv", "description":"" }, { "default": "", "label": "Header Column Field", "type": "String", - "hint": "Company Name", + "hint": "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.", + "code_url": "mock_generators/generators/string_from_csv.py", + "description": "Random string row value from a csv file located at a given url. Be certain field contains string values.", "name": "String from CSV", "tags": [ "csv", - " string", - " random" + "string" ], "type": "String" }, @@ -449,6 +448,31 @@ "count" ], "type": "Integer" + }, + "image_url":{ + "args": [ + { + "default": 400, + "label": "Image Width", + "type": "Integer" + }, + { + "default": 200, + "label": "Image Height", + "type": "Integer" + } + ], + "code_url": "mock_generators/generators/image_url.py", + "description": "Random image url generator using Lorem Picsum", + "name": "Image URL", + "tags": [ + "image", + "lorem", + "images", + "url", + "link" + ], + "type": "String" }, "lorem_words": { "args": [ diff --git a/mock_generators/tabs/design_tab.py b/mock_generators/tabs/design_tab.py index 95e7063..6c42498 100644 --- a/mock_generators/tabs/design_tab.py +++ b/mock_generators/tabs/design_tab.py @@ -12,12 +12,6 @@ def design_tab(): - # col1, col2 = st.columns([1,11]) - # with col1: - # st.image("mock_generators/media/abstract.gif") - # 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("--------") with st.expander("Instructions"): st.write(""" 1. Connect to arrows.app. Optionally login via Google to save your model designs @@ -40,13 +34,13 @@ def design_tab(): st.write("Properties needing mock generated data should be a stringified JSON object. The unique generator name should be used as a key, followed by a list/array or argument values.\n\nSee the NODE and RELATIONSHIP properties dropdown for examples.\n\nThe right hand Generators preview lists all the available mock data generators. Arguments can be set and example output data can be previewed by clicking on the 'Generate Example Output' button. Use the 'Copy for Arrows' button to copy the required formatted JSON string to your clipboard, to paste into the arrows.app") st.image("mock_generators/media/sample_generator.png") + c1, c2 = st.columns([8,2]) with c1: + # Arrows interface components.iframe("https://arrows.app", height=1000, scrolling=False) with c2: - # st.write("Generators") - # st.markdown("--------") - + # Generators List generators = st.session_state[GENERATORS] if generators is None: st.write("No generators loaded") From 3e78eb9601cf26d7c862d94057e38ca8ead57bb2 Mon Sep 17 00:00:00 2001 From: jalakoo Date: Wed, 10 May 2023 13:22:50 -0700 Subject: [PATCH 3/5] Address literal updated (#9) * ADDRESS keyword literal and mock address data generation added * Additional address related generators added --- Pipfile | 1 + Pipfile.lock | 40 ++++--- mock_generators/generators/4b0db60a.py | 11 -- mock_generators/generators/address_usa.py | 10 ++ .../generators/{92eeddbb.py => city.py} | 0 .../generators/{470ff56f.py => country.py} | 0 .../{73853311.py => exhaustive_random.py} | 3 +- mock_generators/generators/postcode.py | 5 + mock_generators/generators/pure_random.py | 9 ++ mock_generators/generators/state.py | 9 ++ mock_generators/generators/street.py | 5 + mock_generators/logic/generate_mapping.py | 109 ++++++++++++++++-- mock_generators/logic/generate_values.py | 13 ++- .../models/relationship_mapping.py | 23 ++-- mock_generators/named_generators.json | 54 ++++++++- tests/test_generators.py | 17 +++ 16 files changed, 257 insertions(+), 52 deletions(-) delete mode 100644 mock_generators/generators/4b0db60a.py create mode 100644 mock_generators/generators/address_usa.py rename mock_generators/generators/{92eeddbb.py => city.py} (100%) rename mock_generators/generators/{470ff56f.py => country.py} (100%) rename mock_generators/generators/{73853311.py => exhaustive_random.py} (75%) create mode 100644 mock_generators/generators/postcode.py create mode 100644 mock_generators/generators/pure_random.py create mode 100644 mock_generators/generators/state.py create mode 100644 mock_generators/generators/street.py diff --git a/Pipfile b/Pipfile index 1251f9b..529959b 100644 --- a/Pipfile +++ b/Pipfile @@ -14,6 +14,7 @@ faker = "*" streamlit-player = "*" requests = "*" pandas = "*" +random-address = "*" [dev-packages] mock-generators = {editable = true, path = "."} diff --git a/Pipfile.lock b/Pipfile.lock index 93cf2d3..2d1108e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3ff999e6051e3b1bb0b7187e0b3be6f8ffc877541da36e4f2f06204720b5db8b" + "sha256": "e68b6c227d7d07ab29f7a02e2848e8cfdb543683331e4fb9def89c446ade3d11" }, "pipfile-spec": 6, "requires": { @@ -37,7 +37,7 @@ "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da", "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==4.12.2" }, "blinker": { @@ -61,7 +61,7 @@ "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==2023.5.7" }, "charset-normalizer": { @@ -219,7 +219,7 @@ "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3", "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==0.11.0" }, "decorator": { @@ -235,7 +235,7 @@ "sha256:b706eddaa9218a19ebcd67b56818f05bb27589b1ca9e8d797b74affad4ccacd4", "sha256:f174b5ff827504fd3cd97cc3f8649f3693f51538c7e4bdf3ef002c8429d42f9f" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==0.4" }, "faker": { @@ -517,7 +517,7 @@ "sha256:553e2db454e2be4567caebef5176c98a40a7e24f7ea9c2fe8a1f05c1d9ea4005", "sha256:b58bb539dcb52e0b040ab2fed32f1f3146cbb2746dc3812940d9dd359c378bb6" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==0.0.7" }, "markupsafe": { @@ -678,7 +678,7 @@ "sha256:ecde0f8adef7dfdec993fd54b0f78183051b6580f606111a6d789cd14c61ea0c", "sha256:f21c442fdd2805e91799fbe044a7b999b8571bb0ab0f7850d0cb9641a687092b" ], - "markers": "python_version >= '3.8'", + "markers": "python_version >= '3.11'", "version": "==1.24.3" }, "packaging": { @@ -797,7 +797,7 @@ "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==1.0.0" }, "protobuf": { @@ -888,7 +888,7 @@ "sha256:993f1a3599ca3f4fcd7160c7545ad06310c9e12f70174ae7ae8d4e25f6c5d3fa", "sha256:d260dda9ae781e1eab6ea15bacb84015849833ba5555f141d2d9b7b7473b307d" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==1.0.1" }, "pyparsing": { @@ -1013,9 +1013,17 @@ "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==6.0" }, + "random-address": { + "hashes": [ + "sha256:52785b8fc661fd5cf5cf7ff0413d7e3f0ea04f502f263df46468b3370cc15f5b", + "sha256:93c16439139417d29812a0e64d48f871bd31ab5529ae74cb9e4d6d3bdfc7c7c5" + ], + "index": "pypi", + "version": "==1.1.1" + }, "rdflib": { "hashes": [ "sha256:36b4e74a32aa1e4fa7b8719876fb192f19ecd45ff932ea5ebbd2e417a0247e63", @@ -1053,7 +1061,7 @@ "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94", "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==5.0.0" }, "soupsieve": { @@ -1108,7 +1116,7 @@ "sha256:22a50eb43407bab3d0ed2d4b58e89819da477cd0592ef87edbd373c286712e3a", "sha256:b3c9520c1b48f2eef3c702b5a967f64c9a8ff2ea8e74ebb26c0e9195965bb923" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==0.1.2" }, "streamlit-extras": { @@ -1124,7 +1132,7 @@ "sha256:3b6625fefb5c2bc759a3b2407c53c97b23cf69924ec31bd2fa7d5313b7691068", "sha256:93d3d910d9be3e60f07c21b28d2ed0487737e071e48ce1faf858e1f68e05b624" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==0.0.2" }, "streamlit-image-coordinates": { @@ -1156,7 +1164,7 @@ "sha256:0081212d80d178bda337acf2432425e2016d757f57834b18645d4c5b928d4c0f", "sha256:991b103cd3448b0f6507f8051777b996a17b4630956d5b6fa13344175b20e572" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==1.0.2" }, "streamlit-vertical-slider": { @@ -1164,7 +1172,7 @@ "sha256:6eaee79a397341eee6ec7862b77d27d548d2bdd126812fd811f831bd4d561f48", "sha256:ab727cd5c1799c1d9a19c6201ff2a9bcda08222c849c5670ad7a0d994c9fdcdc" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==1.0.2" }, "tenacity": { @@ -1172,7 +1180,7 @@ "sha256:2f277afb21b851637e8f52e6a613ff08734c347dc19ade928e519d7d2d8569b0", "sha256:43af037822bd0029025877f3b2d97cc4d7bb0c2991000a3d59d71517c5c969e0" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==8.2.2" }, "toml": { diff --git a/mock_generators/generators/4b0db60a.py b/mock_generators/generators/4b0db60a.py deleted file mode 100644 index 9abffc3..0000000 --- a/mock_generators/generators/4b0db60a.py +++ /dev/null @@ -1,11 +0,0 @@ -import random -import logging -# Do not change function name or arguments -def generate( - args: list[any] - ) -> tuple[dict, list[dict]]: - # TODO: This doesn't support actual args, just a list of values - to_node_values = args - result = random.choice(to_node_values) - logging.info(f'4b0db60a: result: {result}, values_remaining: {len(to_node_values)}') - return (result, to_node_values) \ No newline at end of file diff --git a/mock_generators/generators/address_usa.py b/mock_generators/generators/address_usa.py new file mode 100644 index 0000000..4048354 --- /dev/null +++ b/mock_generators/generators/address_usa.py @@ -0,0 +1,10 @@ +from random_address import real_random_address_by_state +import random + +def generate(args: list[any]): + # Generate a dictionary with valid random address information + states = [ + "AL", "AR", "CA", "CO", "CT", "DC", "FL", "GA", "HI", "KY", "MA" "MD", "TN", "TX", "OK", "VT" + ] + state_code = random.choice(states) + return real_random_address_by_state(state_code) \ No newline at end of file diff --git a/mock_generators/generators/92eeddbb.py b/mock_generators/generators/city.py similarity index 100% rename from mock_generators/generators/92eeddbb.py rename to mock_generators/generators/city.py diff --git a/mock_generators/generators/470ff56f.py b/mock_generators/generators/country.py similarity index 100% rename from mock_generators/generators/470ff56f.py rename to mock_generators/generators/country.py diff --git a/mock_generators/generators/73853311.py b/mock_generators/generators/exhaustive_random.py similarity index 75% rename from mock_generators/generators/73853311.py rename to mock_generators/generators/exhaustive_random.py index ead04f3..bc20b4b 100644 --- a/mock_generators/generators/73853311.py +++ b/mock_generators/generators/exhaustive_random.py @@ -1,5 +1,4 @@ from random import shuffle -import logging # Do not change function name or arguments def generate( @@ -10,5 +9,5 @@ def generate( node_values = args shuffle(node_values) choice = node_values.pop(0) - # logging.info(f'73853311: choice: {choice}, values remaining: {len(node_values)}') + return (choice, node_values) \ No newline at end of file diff --git a/mock_generators/generators/postcode.py b/mock_generators/generators/postcode.py new file mode 100644 index 0000000..abb2d28 --- /dev/null +++ b/mock_generators/generators/postcode.py @@ -0,0 +1,5 @@ +from faker import Faker +fake = Faker() + +def generate(args: list[any]): + return fake.postcode() \ No newline at end of file diff --git a/mock_generators/generators/pure_random.py b/mock_generators/generators/pure_random.py new file mode 100644 index 0000000..68f6ed7 --- /dev/null +++ b/mock_generators/generators/pure_random.py @@ -0,0 +1,9 @@ +import random +# Do not change function name or arguments +def generate( + args: list[any] + ) -> tuple[dict, list[dict]]: + + targets = args[:] + result = random.choice(targets) + return (result, targets) \ No newline at end of file diff --git a/mock_generators/generators/state.py b/mock_generators/generators/state.py new file mode 100644 index 0000000..bd5ae73 --- /dev/null +++ b/mock_generators/generators/state.py @@ -0,0 +1,9 @@ +import random + +def generate(args: list[any]): + states = ["AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DC", "DE", "FL", "GA", + "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", + "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", + "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", + "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY"] + return random.choice(states) \ No newline at end of file diff --git a/mock_generators/generators/street.py b/mock_generators/generators/street.py new file mode 100644 index 0000000..e25babc --- /dev/null +++ b/mock_generators/generators/street.py @@ -0,0 +1,5 @@ +from faker import Faker +fake = Faker() + +def generate(args: list[any]): + return fake.street_address() \ No newline at end of file diff --git a/mock_generators/logic/generate_mapping.py b/mock_generators/logic/generate_mapping.py index a363a1d..b7f86d9 100644 --- a/mock_generators/logic/generate_mapping.py +++ b/mock_generators/logic/generate_mapping.py @@ -6,10 +6,60 @@ from models.relationship_mapping import RelationshipMapping from models.property_mapping import PropertyMapping from models.generator import Generator -from logic.generate_values import generator_for_raw_property +from logic.generate_values import generator_for_raw_property, actual_generator_for_raw_property, assignment_generator_for import logging import uuid + +def generate_addresses_to( + raw_properties: dict[str, str], + generators: dict[str, Generator]): + + # Insert all values - they will be read as literals during the node generation process + raw_properties['street'] = f'{{"street":[]}}' + raw_properties['city'] = f'{{"city":[]}}' + raw_properties['state'] = f'{{"state":[]}}' + raw_properties['zip'] = f'{{"postcode":[]}}' + raw_properties['country'] = f'{{"country":[]}}' + return raw_properties + +def xgenerate_addresses_to( + raw_properties: dict[str, str], + generators: dict[str, Generator]): + + generator, args = actual_generator_for_raw_property('{"address_usa": []}', generators) + value = generator.generate(args) + # Insert all values - they will be read as literals during the node generation process + try: + address1 = value.get('address1', None) + if address1 is not None: + raw_properties['address1'] = f'{{"string":["{address1}"]}}' + address2 = value.get('address2', None) + if address2 is not None: + raw_properties['address2'] = f'{{"string":["{address2}"]}}' + city = value.get('city', None) + if city is not None: + raw_properties['city'] = f'{{"string":["{city}"]}}' + state = value.get('state', None) + if state is not None: + raw_properties['state'] = f'{{"string":["{state}"]}}' + postalCode = value.get('postalCode', None) + if postalCode is not None: + raw_properties['postalCode'] = f'{{"string":["{postalCode}"]}}' + lat = value.get('coordinates', None).get('lat', None) + if lat is not None: + raw_properties['latitude'] = f'{{"string":["{lat}"]}}' + lng = value.get('coordinates', None).get('lng', None) + if lng is not None: + raw_properties['longitude'] = f'{{"string":["{lng}"]}}' + # Add country + raw_properties['country'] = f'{{"string":["USA"]}}' + except Exception as e: + logging.error(f'Problem extracting data from address object: {value}: ERROR: {e}') + + return raw_properties + + def propertymappings_for_raw_properties( raw_properties: dict[str, str], generators: dict[str, Generator] @@ -29,16 +79,57 @@ def propertymappings_for_raw_properties( if generators is None or len(generators) == 0: raise Exception(f'generate_mapping.py: propertymappings_for_raw_properties: No generators assignment received.') + # Special handling for addresses + raw_keys = raw_properties.keys() + # Assign uuid if not key property assignment was made + if "{key}" not in raw_keys and "KEY" not in raw_keys: + raw_properties["KEY"] = "_uid" + raw_properties["_uid"] = f'{{"uuid":[]}}' + if "ADDRESS" in raw_keys: + raw_properties.pop("ADDRESS") + raw_properties = generate_addresses_to(raw_properties, generators) + # Going to an address generator to create following properties: + # address_line_1, address_line_2, city, state, zip, latitude, longitude + # generator, args = actual_generator_for_raw_property('{"address_usa": []}', generators) + # value = generator.generate(args) + # # Insert all values - they will be read as literals during the node generation process + # try: + # address1 = value.get('address1', None) + # if address1 is not None: + # raw_properties['address1'] = f'{{"string":["{address1}"]}}' + # address2 = value.get('address2', None) + # if address2 is not None: + # raw_properties['address2'] = f'{{"string":["{address2}"]}}' + # city = value.get('city', None) + # if city is not None: + # raw_properties['city'] = f'{{"string":["{city}"]}}' + # state = value.get('state', None) + # if state is not None: + # raw_properties['state'] = f'{{"string":["{state}"]}}' + # postalCode = value.get('postalCode', None) + # if postalCode is not None: + # raw_properties['postalCode'] = f'{{"string":["{postalCode}"]}}' + # lat = value.get('coordinates', None).get('lat', None) + # if lat is not None: + # raw_properties['latitude'] = f'{{"string":["{lat}"]}}' + # lng = value.get('coordinates', None).get('lng', None) + # if lng is not None: + # raw_properties['longitude'] = f'{{"string":["{lng}"]}}' + # # Add country + # raw_properties['country'] = f'{{"string":["USA"]}}' + # except Exception as e: + # logging.error(f'Problem extracting data from address object: {value}: ERROR: {e}') + 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 - # TODO: Skip special COUNT and KEY literals + # Skip special COUNT and KEY literals if key == "COUNT" or key == "KEY": continue - + try: generator, args = generator_for_raw_property(value, generators) if generator is None: @@ -207,10 +298,12 @@ def relationshipmappings_from( count_generator_config = '{"int_range": [1,3]}' logging.info(f"Relationship properties is missing COUNT or '{{count}}' key from properties: {properties}: Using default int_range generator") - assignment_generator_config = properties.get("{assignment}", None) - # If missing, use ExhaustiveRandom + assignment_generator_config = properties.get("ASSIGNMENT", None) if assignment_generator_config is None: - assignment_generator_config = "{\"exhaustive_random\":[]}" + 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: @@ -227,9 +320,9 @@ def relationshipmappings_from( continue try: - assignment_generator, assignment_args = generator_for_raw_property(assignment_generator_config, generators) + assignment_generator, assignment_args = assignment_generator_for(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}") + logging.warning(f"generate_mappings: relationshipmappings_from: could not get assignment generator for relationship: {relationship_dict}: {e}") continue from_node = nodes.get(from_id, None) diff --git a/mock_generators/logic/generate_values.py b/mock_generators/logic/generate_values.py index c5dfc52..67d951b 100644 --- a/mock_generators/logic/generate_values.py +++ b/mock_generators/logic/generate_values.py @@ -1,5 +1,5 @@ -from models.generator import Generator +from models.generator import Generator, GeneratorType import logging import json @@ -197,6 +197,17 @@ def literal_generator_from_value( actual_string = json.dumps(result) return actual_generator_for_raw_property(actual_string, generators) +def assignment_generator_for( + config: str, + generators: dict[str, Generator] +) -> tuple[Generator, list[any]]: + + gen, args = actual_generator_for_raw_property(config, generators) + if gen.type != GeneratorType.ASSIGNMENT: + logging.error(f'Generator {gen.name} is not an assignment generator.') + return (None, None) + return gen, args + def generator_for_raw_property( property_value: str, generators: dict[str, Generator] diff --git a/mock_generators/models/relationship_mapping.py b/mock_generators/models/relationship_mapping.py index 9aec53e..6783e81 100644 --- a/mock_generators/models/relationship_mapping.py +++ b/mock_generators/models/relationship_mapping.py @@ -124,11 +124,13 @@ def generate_values( # TODO: Run filter generator here to determine which source nodes to process - # Make a copy of the generated list - values = deepcopy(self.to_node.generated_values) + # Make a copy of source and target dicts + sources = self.from_node.generated_values[:] + targets = self.to_node.generated_values[:] # Iterate through every generated source node - for value_dict in self.from_node.generated_values: + # for value_dict in self.from_node.generated_values: + for value_dict in sources: # dict of property names and generated values # Decide on how many of these relationships to generate @@ -158,17 +160,18 @@ def generate_values( for i in range(count): # Select a random target node - if values is None or len(values) == 0: - # TODO: This appears to break the randomization - logging.info(f'relationship_mapping.py: values exhausted at index {i} before count of {count} reached. Values: {len(values)}') - continue + if targets is None or len(targets) == 0: + # targets exhausted, reset + targets = self.to_node.generated_values[:] # Extract results. Values will be passed back through the next iteration in case the generator returns a modified list - # TODO: values does not change after this call - to_node_value_dict, new_values = self.assignment_generator.generate(values) + # print(f'Attempting to run assignment generator: {self.assignment_generator}. Targets: {len(targets)}') - values = new_values + to_node_value_dict, new_targets = self.assignment_generator.generate(targets) + + # print(f'Assignment generator returned target node: {to_node_value_dict}') + targets = new_targets # Types of randomization generators to consider: # - Pure Random diff --git a/mock_generators/named_generators.json b/mock_generators/named_generators.json index 0fbcc4e..cc02fdc 100644 --- a/mock_generators/named_generators.json +++ b/mock_generators/named_generators.json @@ -2,6 +2,39 @@ "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)" }, + "address_usa": { + "args": [], + "code_url": "mock_generators/generators/address_usa.py", + "description": "Random Address using the random-address package.", + "name": "URL", + "tags": [ + "address", + "location" + ], + "type": "String" + }, + "street": { + "args": [], + "code_url": "mock_generators/generators/street.py", + "description": "Random Street Address using the Faker library.", + "name": "String", + "tags": [ + "address", + "street" + ], + "type": "String" + }, + "postcode": { + "args": [], + "code_url": "mock_generators/generators/postcode.py", + "description": "Random Postal Code using the Faker library.", + "name": "String", + "tags": [ + "address", + "street" + ], + "type": "String" + }, "uri": { "args": [], "code_url": "mock_generators/generators/05711cac.py", @@ -115,7 +148,7 @@ }, "country": { "args": [], - "code_url": "mock_generators/generators/470ff56f.py", + "code_url": "mock_generators/generators/country.py", "description": "Country name generator using the Faker library.", "name": "Country", "tags": [ @@ -126,7 +159,7 @@ }, "pure_random": { "args": [], - "code_url": "mock_generators/generators/4b0db60a.py", + "code_url": "mock_generators/generators/pure_random.py", "description": "Randomly assigns to a target node. Duplicates and orphan nodes possible.", "name": "Pure Random", "tags": [ @@ -224,7 +257,7 @@ }, "exhaustive_random": { "args": [], - "code_url": "mock_generators/generators/73853311.py", + "code_url": "mock_generators/generators/exhaustive_random.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": [ @@ -267,7 +300,7 @@ }, "city": { "args": [], - "code_url": "mock_generators/generators/92eeddbb.py", + "code_url": "mock_generators/generators/city.py", "description": "City name generator using the Faker library.", "name": "City", "tags": [ @@ -276,6 +309,19 @@ ], "type": "String" }, + "state": { + "args": [], + "code_url": "mock_generators/generators/state.py", + "description": "Random US state code", + "name": "State", + "tags": [ + "state", + "state code", + "US", + "name" + ], + "type": "String" + }, "date": { "args": [ { diff --git a/tests/test_generators.py b/tests/test_generators.py index a4d0f93..5a58bfe 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -5,6 +5,23 @@ from datetime import datetime test_generators = load_generators("mock_generators/named_generators.json") +class TestAddressGenerator: + def test_address_generator_elements(self): + try: + test_string = '{"address_usa": []}' + generator, args = actual_generator_for_raw_property(test_string, test_generators) + value = generator.generate(args) + + assert value['address1'], f'address object missing address_line_1: {value}' + assert value['city'], f'address object missing city: {value}' + assert value['state'], f'address object missing state: {value}' + assert value['postalCode'], f'address object missing postalCode: {value}' + assert value['coordinates']['lat'], f'address object missing coordinates.lat: {value}' + assert value['coordinates']['lng'], f'address object missing coordinates.lng: {value}' + + except Exception as e: + assert False, f'Exception: {e}' + class TestDateGenerator: def test_date_generator(self): try: From 0a7ebecc27f01a923b94a4024a1749afbe2ebb76 Mon Sep 17 00:00:00 2001 From: jalakoo Date: Thu, 11 May 2023 08:06:24 -0700 Subject: [PATCH 4/5] Graphgpt (#11) * Basic literal support added - ints, floats, ranges, lists * Datetime generator fix + keywords generator support added * Default count and key assignments added + missing generator updates * Random image url added and string_from_csv fixed * ADDRESS keyword literal and mock address data generation added * graphGPT integration * Cache openai responses to prevent behind the scenes rerun of ideation tab * Video tutorial url made remote configurable --- Pipfile | 2 + Pipfile.lock | 383 +++++++++++++++++- mock_generators/app.py | 23 +- mock_generators/generators/address_usa.py | 13 +- mock_generators/logic/agraph_conversions.py | 201 +++++++++ mock_generators/media/sample_node_0-5-0.png | Bin 0 -> 22984 bytes .../media/sample_properties_0-5-0.png | Bin 0 -> 90737 bytes .../media/sample_relationship_0-5-0.png | Bin 0 -> 19510 bytes mock_generators/tabs/design_tab.py | 20 +- mock_generators/tabs/getting_help.py | 5 + mock_generators/tabs/ideate_tab.py | 124 ++++++ mock_generators/tabs/importing_tab.py | 2 +- mock_generators/tabs/tutorial.py | 4 +- tests/test_generators.py | 25 +- 14 files changed, 754 insertions(+), 48 deletions(-) create mode 100644 mock_generators/logic/agraph_conversions.py create mode 100644 mock_generators/media/sample_node_0-5-0.png create mode 100644 mock_generators/media/sample_properties_0-5-0.png create mode 100644 mock_generators/media/sample_relationship_0-5-0.png create mode 100644 mock_generators/tabs/getting_help.py create mode 100644 mock_generators/tabs/ideate_tab.py diff --git a/Pipfile b/Pipfile index 529959b..d94cce4 100644 --- a/Pipfile +++ b/Pipfile @@ -15,6 +15,8 @@ streamlit-player = "*" requests = "*" pandas = "*" random-address = "*" +openai = "*" +numpy = "*" [dev-packages] mock-generators = {editable = true, path = "."} diff --git a/Pipfile.lock b/Pipfile.lock index 2d1108e..e64c495 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e68b6c227d7d07ab29f7a02e2848e8cfdb543683331e4fb9def89c446ade3d11" + "sha256": "35d4376090e3af2e4790d77b041137555fbc9e93f72f23442f5021fd17dfab67" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,107 @@ ] }, "default": { + "aiohttp": { + "hashes": [ + "sha256:03543dcf98a6619254b409be2d22b51f21ec66272be4ebda7b04e6412e4b2e14", + "sha256:03baa76b730e4e15a45f81dfe29a8d910314143414e528737f8589ec60cf7391", + "sha256:0a63f03189a6fa7c900226e3ef5ba4d3bd047e18f445e69adbd65af433add5a2", + "sha256:10c8cefcff98fd9168cdd86c4da8b84baaa90bf2da2269c6161984e6737bf23e", + "sha256:147ae376f14b55f4f3c2b118b95be50a369b89b38a971e80a17c3fd623f280c9", + "sha256:176a64b24c0935869d5bbc4c96e82f89f643bcdf08ec947701b9dbb3c956b7dd", + "sha256:17b79c2963db82086229012cff93ea55196ed31f6493bb1ccd2c62f1724324e4", + "sha256:1a45865451439eb320784918617ba54b7a377e3501fb70402ab84d38c2cd891b", + "sha256:1b3ea7edd2d24538959c1c1abf97c744d879d4e541d38305f9bd7d9b10c9ec41", + "sha256:22f6eab15b6db242499a16de87939a342f5a950ad0abaf1532038e2ce7d31567", + "sha256:3032dcb1c35bc330134a5b8a5d4f68c1a87252dfc6e1262c65a7e30e62298275", + "sha256:33587f26dcee66efb2fff3c177547bd0449ab7edf1b73a7f5dea1e38609a0c54", + "sha256:34ce9f93a4a68d1272d26030655dd1b58ff727b3ed2a33d80ec433561b03d67a", + "sha256:3a80464982d41b1fbfe3154e440ba4904b71c1a53e9cd584098cd41efdb188ef", + "sha256:3b90467ebc3d9fa5b0f9b6489dfb2c304a1db7b9946fa92aa76a831b9d587e99", + "sha256:3d89efa095ca7d442a6d0cbc755f9e08190ba40069b235c9886a8763b03785da", + "sha256:3d8ef1a630519a26d6760bc695842579cb09e373c5f227a21b67dc3eb16cfea4", + "sha256:3f43255086fe25e36fd5ed8f2ee47477408a73ef00e804cb2b5cba4bf2ac7f5e", + "sha256:40653609b3bf50611356e6b6554e3a331f6879fa7116f3959b20e3528783e699", + "sha256:41a86a69bb63bb2fc3dc9ad5ea9f10f1c9c8e282b471931be0268ddd09430b04", + "sha256:493f5bc2f8307286b7799c6d899d388bbaa7dfa6c4caf4f97ef7521b9cb13719", + "sha256:4a6cadebe132e90cefa77e45f2d2f1a4b2ce5c6b1bfc1656c1ddafcfe4ba8131", + "sha256:4c745b109057e7e5f1848c689ee4fb3a016c8d4d92da52b312f8a509f83aa05e", + "sha256:4d347a172f866cd1d93126d9b239fcbe682acb39b48ee0873c73c933dd23bd0f", + "sha256:4dac314662f4e2aa5009977b652d9b8db7121b46c38f2073bfeed9f4049732cd", + "sha256:4ddaae3f3d32fc2cb4c53fab020b69a05c8ab1f02e0e59665c6f7a0d3a5be54f", + "sha256:5393fb786a9e23e4799fec788e7e735de18052f83682ce2dfcabaf1c00c2c08e", + "sha256:59f029a5f6e2d679296db7bee982bb3d20c088e52a2977e3175faf31d6fb75d1", + "sha256:5a7bdf9e57126dc345b683c3632e8ba317c31d2a41acd5800c10640387d193ed", + "sha256:5b3f2e06a512e94722886c0827bee9807c86a9f698fac6b3aee841fab49bbfb4", + "sha256:5ce45967538fb747370308d3145aa68a074bdecb4f3a300869590f725ced69c1", + "sha256:5e14f25765a578a0a634d5f0cd1e2c3f53964553a00347998dfdf96b8137f777", + "sha256:618c901dd3aad4ace71dfa0f5e82e88b46ef57e3239fc7027773cb6d4ed53531", + "sha256:652b1bff4f15f6287550b4670546a2947f2a4575b6c6dff7760eafb22eacbf0b", + "sha256:6c08e8ed6fa3d477e501ec9db169bfac8140e830aa372d77e4a43084d8dd91ab", + "sha256:6ddb2a2026c3f6a68c3998a6c47ab6795e4127315d2e35a09997da21865757f8", + "sha256:6e601588f2b502c93c30cd5a45bfc665faaf37bbe835b7cfd461753068232074", + "sha256:6e74dd54f7239fcffe07913ff8b964e28b712f09846e20de78676ce2a3dc0bfc", + "sha256:7235604476a76ef249bd64cb8274ed24ccf6995c4a8b51a237005ee7a57e8643", + "sha256:7ab43061a0c81198d88f39aaf90dae9a7744620978f7ef3e3708339b8ed2ef01", + "sha256:7c7837fe8037e96b6dd5cfcf47263c1620a9d332a87ec06a6ca4564e56bd0f36", + "sha256:80575ba9377c5171407a06d0196b2310b679dc752d02a1fcaa2bc20b235dbf24", + "sha256:80a37fe8f7c1e6ce8f2d9c411676e4bc633a8462844e38f46156d07a7d401654", + "sha256:8189c56eb0ddbb95bfadb8f60ea1b22fcfa659396ea36f6adcc521213cd7b44d", + "sha256:854f422ac44af92bfe172d8e73229c270dc09b96535e8a548f99c84f82dde241", + "sha256:880e15bb6dad90549b43f796b391cfffd7af373f4646784795e20d92606b7a51", + "sha256:8b631e26df63e52f7cce0cce6507b7a7f1bc9b0c501fcde69742130b32e8782f", + "sha256:8c29c77cc57e40f84acef9bfb904373a4e89a4e8b74e71aa8075c021ec9078c2", + "sha256:91f6d540163f90bbaef9387e65f18f73ffd7c79f5225ac3d3f61df7b0d01ad15", + "sha256:92c0cea74a2a81c4c76b62ea1cac163ecb20fb3ba3a75c909b9fa71b4ad493cf", + "sha256:9bcb89336efa095ea21b30f9e686763f2be4478f1b0a616969551982c4ee4c3b", + "sha256:a1f4689c9a1462f3df0a1f7e797791cd6b124ddbee2b570d34e7f38ade0e2c71", + "sha256:a3fec6a4cb5551721cdd70473eb009d90935b4063acc5f40905d40ecfea23e05", + "sha256:a5d794d1ae64e7753e405ba58e08fcfa73e3fad93ef9b7e31112ef3c9a0efb52", + "sha256:a86d42d7cba1cec432d47ab13b6637bee393a10f664c425ea7b305d1301ca1a3", + "sha256:adfbc22e87365a6e564c804c58fc44ff7727deea782d175c33602737b7feadb6", + "sha256:aeb29c84bb53a84b1a81c6c09d24cf33bb8432cc5c39979021cc0f98c1292a1a", + "sha256:aede4df4eeb926c8fa70de46c340a1bc2c6079e1c40ccf7b0eae1313ffd33519", + "sha256:b744c33b6f14ca26b7544e8d8aadff6b765a80ad6164fb1a430bbadd593dfb1a", + "sha256:b7a00a9ed8d6e725b55ef98b1b35c88013245f35f68b1b12c5cd4100dddac333", + "sha256:bb96fa6b56bb536c42d6a4a87dfca570ff8e52de2d63cabebfd6fb67049c34b6", + "sha256:bbcf1a76cf6f6dacf2c7f4d2ebd411438c275faa1dc0c68e46eb84eebd05dd7d", + "sha256:bca5f24726e2919de94f047739d0a4fc01372801a3672708260546aa2601bf57", + "sha256:bf2e1a9162c1e441bf805a1fd166e249d574ca04e03b34f97e2928769e91ab5c", + "sha256:c4eb3b82ca349cf6fadcdc7abcc8b3a50ab74a62e9113ab7a8ebc268aad35bb9", + "sha256:c6cc15d58053c76eacac5fa9152d7d84b8d67b3fde92709195cb984cfb3475ea", + "sha256:c6cd05ea06daca6ad6a4ca3ba7fe7dc5b5de063ff4daec6170ec0f9979f6c332", + "sha256:c844fd628851c0bc309f3c801b3a3d58ce430b2ce5b359cd918a5a76d0b20cb5", + "sha256:c9cb1565a7ad52e096a6988e2ee0397f72fe056dadf75d17fa6b5aebaea05622", + "sha256:cab9401de3ea52b4b4c6971db5fb5c999bd4260898af972bf23de1c6b5dd9d71", + "sha256:cd468460eefef601ece4428d3cf4562459157c0f6523db89365202c31b6daebb", + "sha256:d1e6a862b76f34395a985b3cd39a0d949ca80a70b6ebdea37d3ab39ceea6698a", + "sha256:d1f9282c5f2b5e241034a009779e7b2a1aa045f667ff521e7948ea9b56e0c5ff", + "sha256:d265f09a75a79a788237d7f9054f929ced2e69eb0bb79de3798c468d8a90f945", + "sha256:db3fc6120bce9f446d13b1b834ea5b15341ca9ff3f335e4a951a6ead31105480", + "sha256:dbf3a08a06b3f433013c143ebd72c15cac33d2914b8ea4bea7ac2c23578815d6", + "sha256:de04b491d0e5007ee1b63a309956eaed959a49f5bb4e84b26c8f5d49de140fa9", + "sha256:e4b09863aae0dc965c3ef36500d891a3ff495a2ea9ae9171e4519963c12ceefd", + "sha256:e595432ac259af2d4630008bf638873d69346372d38255774c0e286951e8b79f", + "sha256:e75b89ac3bd27d2d043b234aa7b734c38ba1b0e43f07787130a0ecac1e12228a", + "sha256:ea9eb976ffdd79d0e893869cfe179a8f60f152d42cb64622fca418cd9b18dc2a", + "sha256:eafb3e874816ebe2a92f5e155f17260034c8c341dad1df25672fb710627c6949", + "sha256:ee3c36df21b5714d49fc4580247947aa64bcbe2939d1b77b4c8dcb8f6c9faecc", + "sha256:f352b62b45dff37b55ddd7b9c0c8672c4dd2eb9c0f9c11d395075a84e2c40f75", + "sha256:fabb87dd8850ef0f7fe2b366d44b77d7e6fa2ea87861ab3844da99291e81e60f", + "sha256:fe11310ae1e4cd560035598c3f29d86cef39a83d244c7466f95c27ae04850f10", + "sha256:fe7ba4a51f33ab275515f66b0a236bcde4fb5561498fe8f898d4e549b2e4509f" + ], + "markers": "python_version >= '3.6'", + "version": "==3.8.4" + }, + "aiosignal": { + "hashes": [ + "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", + "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.1" + }, "altair": { "hashes": [ "sha256:39399a267c49b30d102c10411e67ab26374156a84b1aeb9fcd15140429ba49c5", @@ -24,6 +125,14 @@ "markers": "python_version >= '3.7'", "version": "==4.2.2" }, + "async-timeout": { + "hashes": [ + "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", + "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" + ], + "markers": "python_version >= '3.6'", + "version": "==4.0.2" + }, "attrs": { "hashes": [ "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", @@ -61,7 +170,7 @@ "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" ], - "markers": "python_full_version >= '3.6.0'", + "markers": "python_version >= '3.6'", "version": "==2023.5.7" }, "charset-normalizer": { @@ -240,11 +349,11 @@ }, "faker": { "hashes": [ - "sha256:6385386ba8d5aa255bec72f5392c2b795fcec8bebf975a9953488948d54bce35", - "sha256:ef61bbf266d30819e83bab4a6c74a0f5979ce4d19d4c9305719dd26cb7d8d51c" + "sha256:38dbc3b80e655d7301e190426ab30f04b6b7f6ca4764c5dd02772ffde0fa6dcd", + "sha256:f02c6d3fdb5bc781f80b440cf2bdec336ed47ecfb8d620b20c3d4188ed051831" ], "index": "pypi", - "version": "==18.6.2" + "version": "==18.7.0" }, "favicon": { "hashes": [ @@ -255,11 +364,91 @@ }, "fonttools": { "hashes": [ - "sha256:64c0c05c337f826183637570ac5ab49ee220eec66cf50248e8df527edfa95aeb", - "sha256:9234b9f57b74e31b192c3fc32ef1a40750a8fbc1cd9837a7b7bfc4ca4a5c51d7" + "sha256:106caf6167c4597556b31a8d9175a3fdc0356fdcd70ab19973c3b0d4c893c461", + "sha256:dba8d7cdb8e2bac1b3da28c5ed5960de09e59a2fe7e63bb73f5a59e57b0430d2" ], "markers": "python_version >= '3.8'", - "version": "==4.39.3" + "version": "==4.39.4" + }, + "frozenlist": { + "hashes": [ + "sha256:008a054b75d77c995ea26629ab3a0c0d7281341f2fa7e1e85fa6153ae29ae99c", + "sha256:02c9ac843e3390826a265e331105efeab489ffaf4dd86384595ee8ce6d35ae7f", + "sha256:034a5c08d36649591be1cbb10e09da9f531034acfe29275fc5454a3b101ce41a", + "sha256:05cdb16d09a0832eedf770cb7bd1fe57d8cf4eaf5aced29c4e41e3f20b30a784", + "sha256:0693c609e9742c66ba4870bcee1ad5ff35462d5ffec18710b4ac89337ff16e27", + "sha256:0771aed7f596c7d73444c847a1c16288937ef988dc04fb9f7be4b2aa91db609d", + "sha256:0af2e7c87d35b38732e810befb9d797a99279cbb85374d42ea61c1e9d23094b3", + "sha256:14143ae966a6229350021384870458e4777d1eae4c28d1a7aa47f24d030e6678", + "sha256:180c00c66bde6146a860cbb81b54ee0df350d2daf13ca85b275123bbf85de18a", + "sha256:1841e200fdafc3d51f974d9d377c079a0694a8f06de2e67b48150328d66d5483", + "sha256:23d16d9f477bb55b6154654e0e74557040575d9d19fe78a161bd33d7d76808e8", + "sha256:2b07ae0c1edaa0a36339ec6cce700f51b14a3fc6545fdd32930d2c83917332cf", + "sha256:2c926450857408e42f0bbc295e84395722ce74bae69a3b2aa2a65fe22cb14b99", + "sha256:2e24900aa13212e75e5b366cb9065e78bbf3893d4baab6052d1aca10d46d944c", + "sha256:303e04d422e9b911a09ad499b0368dc551e8c3cd15293c99160c7f1f07b59a48", + "sha256:352bd4c8c72d508778cf05ab491f6ef36149f4d0cb3c56b1b4302852255d05d5", + "sha256:3843f84a6c465a36559161e6c59dce2f2ac10943040c2fd021cfb70d58c4ad56", + "sha256:394c9c242113bfb4b9aa36e2b80a05ffa163a30691c7b5a29eba82e937895d5e", + "sha256:3bbdf44855ed8f0fbcd102ef05ec3012d6a4fd7c7562403f76ce6a52aeffb2b1", + "sha256:40de71985e9042ca00b7953c4f41eabc3dc514a2d1ff534027f091bc74416401", + "sha256:41fe21dc74ad3a779c3d73a2786bdf622ea81234bdd4faf90b8b03cad0c2c0b4", + "sha256:47df36a9fe24054b950bbc2db630d508cca3aa27ed0566c0baf661225e52c18e", + "sha256:4ea42116ceb6bb16dbb7d526e242cb6747b08b7710d9782aa3d6732bd8d27649", + "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a", + "sha256:5c11e43016b9024240212d2a65043b70ed8dfd3b52678a1271972702d990ac6d", + "sha256:5cf820485f1b4c91e0417ea0afd41ce5cf5965011b3c22c400f6d144296ccbc0", + "sha256:5d8860749e813a6f65bad8285a0520607c9500caa23fea6ee407e63debcdbef6", + "sha256:6327eb8e419f7d9c38f333cde41b9ae348bec26d840927332f17e887a8dcb70d", + "sha256:65a5e4d3aa679610ac6e3569e865425b23b372277f89b5ef06cf2cdaf1ebf22b", + "sha256:66080ec69883597e4d026f2f71a231a1ee9887835902dbe6b6467d5a89216cf6", + "sha256:783263a4eaad7c49983fe4b2e7b53fa9770c136c270d2d4bbb6d2192bf4d9caf", + "sha256:7f44e24fa70f6fbc74aeec3e971f60a14dde85da364aa87f15d1be94ae75aeef", + "sha256:7fdfc24dcfce5b48109867c13b4cb15e4660e7bd7661741a391f821f23dfdca7", + "sha256:810860bb4bdce7557bc0febb84bbd88198b9dbc2022d8eebe5b3590b2ad6c842", + "sha256:841ea19b43d438a80b4de62ac6ab21cfe6827bb8a9dc62b896acc88eaf9cecba", + "sha256:84610c1502b2461255b4c9b7d5e9c48052601a8957cd0aea6ec7a7a1e1fb9420", + "sha256:899c5e1928eec13fd6f6d8dc51be23f0d09c5281e40d9cf4273d188d9feeaf9b", + "sha256:8bae29d60768bfa8fb92244b74502b18fae55a80eac13c88eb0b496d4268fd2d", + "sha256:8df3de3a9ab8325f94f646609a66cbeeede263910c5c0de0101079ad541af332", + "sha256:8fa3c6e3305aa1146b59a09b32b2e04074945ffcfb2f0931836d103a2c38f936", + "sha256:924620eef691990dfb56dc4709f280f40baee568c794b5c1885800c3ecc69816", + "sha256:9309869032abb23d196cb4e4db574232abe8b8be1339026f489eeb34a4acfd91", + "sha256:9545a33965d0d377b0bc823dcabf26980e77f1b6a7caa368a365a9497fb09420", + "sha256:9ac5995f2b408017b0be26d4a1d7c61bce106ff3d9e3324374d66b5964325448", + "sha256:9bbbcedd75acdfecf2159663b87f1bb5cfc80e7cd99f7ddd9d66eb98b14a8411", + "sha256:a4ae8135b11652b08a8baf07631d3ebfe65a4c87909dbef5fa0cdde440444ee4", + "sha256:a6394d7dadd3cfe3f4b3b186e54d5d8504d44f2d58dcc89d693698e8b7132b32", + "sha256:a97b4fe50b5890d36300820abd305694cb865ddb7885049587a5678215782a6b", + "sha256:ae4dc05c465a08a866b7a1baf360747078b362e6a6dbeb0c57f234db0ef88ae0", + "sha256:b1c63e8d377d039ac769cd0926558bb7068a1f7abb0f003e3717ee003ad85530", + "sha256:b1e2c1185858d7e10ff045c496bbf90ae752c28b365fef2c09cf0fa309291669", + "sha256:b4395e2f8d83fbe0c627b2b696acce67868793d7d9750e90e39592b3626691b7", + "sha256:b756072364347cb6aa5b60f9bc18e94b2f79632de3b0190253ad770c5df17db1", + "sha256:ba64dc2b3b7b158c6660d49cdb1d872d1d0bf4e42043ad8d5006099479a194e5", + "sha256:bed331fe18f58d844d39ceb398b77d6ac0b010d571cba8267c2e7165806b00ce", + "sha256:c188512b43542b1e91cadc3c6c915a82a5eb95929134faf7fd109f14f9892ce4", + "sha256:c21b9aa40e08e4f63a2f92ff3748e6b6c84d717d033c7b3438dd3123ee18f70e", + "sha256:ca713d4af15bae6e5d79b15c10c8522859a9a89d3b361a50b817c98c2fb402a2", + "sha256:cd4210baef299717db0a600d7a3cac81d46ef0e007f88c9335db79f8979c0d3d", + "sha256:cfe33efc9cb900a4c46f91a5ceba26d6df370ffddd9ca386eb1d4f0ad97b9ea9", + "sha256:d5cd3ab21acbdb414bb6c31958d7b06b85eeb40f66463c264a9b343a4e238642", + "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0", + "sha256:e235688f42b36be2b6b06fc37ac2126a73b75fb8d6bc66dd632aa35286238703", + "sha256:eb82dbba47a8318e75f679690190c10a5e1f447fbf9df41cbc4c3afd726d88cb", + "sha256:ebb86518203e12e96af765ee89034a1dbb0c3c65052d1b0c19bbbd6af8a145e1", + "sha256:ee78feb9d293c323b59a6f2dd441b63339a30edf35abcb51187d2fc26e696d13", + "sha256:eedab4c310c0299961ac285591acd53dc6723a1ebd90a57207c71f6e0c2153ab", + "sha256:efa568b885bca461f7c7b9e032655c0c143d305bf01c30caf6db2854a4532b38", + "sha256:efce6ae830831ab6a22b9b4091d411698145cb9b8fc869e1397ccf4b4b6455cb", + "sha256:f163d2fd041c630fed01bc48d28c3ed4a3b003c00acd396900e11ee5316b56bb", + "sha256:f20380df709d91525e4bee04746ba612a4df0972c1b8f8e1e8af997e678c7b81", + "sha256:f30f1928162e189091cf4d9da2eac617bfe78ef907a761614ff577ef4edfb3c8", + "sha256:f470c92737afa7d4c3aacc001e335062d582053d4dbe73cda126f2d7031068dd", + "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.3" }, "gitdb": { "hashes": [ @@ -639,6 +828,86 @@ "markers": "python_version >= '3.7'", "version": "==9.1.0" }, + "multidict": { + "hashes": [ + "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9", + "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8", + "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03", + "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710", + "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161", + "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664", + "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569", + "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067", + "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313", + "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706", + "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2", + "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636", + "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49", + "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93", + "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603", + "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0", + "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60", + "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4", + "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e", + "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1", + "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60", + "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951", + "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc", + "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe", + "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95", + "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d", + "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8", + "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed", + "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2", + "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775", + "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87", + "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c", + "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2", + "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98", + "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3", + "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe", + "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78", + "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660", + "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176", + "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e", + "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988", + "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c", + "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c", + "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0", + "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449", + "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f", + "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde", + "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5", + "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d", + "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac", + "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a", + "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9", + "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca", + "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11", + "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35", + "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063", + "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b", + "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982", + "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258", + "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1", + "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52", + "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480", + "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7", + "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461", + "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d", + "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc", + "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779", + "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a", + "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547", + "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0", + "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171", + "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf", + "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d", + "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0.4" + }, "networkx": { "hashes": [ "sha256:4f33f68cb2afcf86f28a45f43efc27a9386b535d567d2127f8f61d51dec58d36", @@ -678,9 +947,17 @@ "sha256:ecde0f8adef7dfdec993fd54b0f78183051b6580f606111a6d789cd14c61ea0c", "sha256:f21c442fdd2805e91799fbe044a7b999b8571bb0ab0f7850d0cb9641a687092b" ], - "markers": "python_version >= '3.11'", + "index": "pypi", "version": "==1.24.3" }, + "openai": { + "hashes": [ + "sha256:1f07ed06f1cfc6c25126107193726fe4cf476edcc4e1485cd9eb708f068f2606", + "sha256:63ca9f6ac619daef8c1ddec6d987fe6aa1c87a9bfdce31ff253204d077222375" + ], + "index": "pypi", + "version": "==0.27.6" + }, "packaging": { "hashes": [ "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", @@ -1216,6 +1493,14 @@ "markers": "python_version >= '3.8'", "version": "==6.3.1" }, + "tqdm": { + "hashes": [ + "sha256:1871fb68a86b8fb3b59ca4cdd3dcccbc7e6d613eeed31f4c332531977b89beb5", + "sha256:c4f53a17fe37e132815abceec022631be8ffe1b9381c2e6e30aa70edc99e9671" + ], + "markers": "python_version >= '3.7'", + "version": "==4.65.0" + }, "typing-extensions": { "hashes": [ "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb", @@ -1255,6 +1540,86 @@ "markers": "python_version >= '3.4'", "version": "==0.20.0" }, + "yarl": { + "hashes": [ + "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571", + "sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3", + "sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3", + "sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c", + "sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7", + "sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04", + "sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191", + "sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea", + "sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4", + "sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4", + "sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095", + "sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e", + "sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74", + "sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef", + "sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33", + "sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde", + "sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45", + "sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf", + "sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b", + "sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac", + "sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0", + "sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528", + "sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716", + "sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb", + "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18", + "sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72", + "sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6", + "sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582", + "sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5", + "sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368", + "sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc", + "sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9", + "sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be", + "sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a", + "sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80", + "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8", + "sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6", + "sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417", + "sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574", + "sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59", + "sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608", + "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82", + "sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1", + "sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3", + "sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d", + "sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8", + "sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc", + "sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac", + "sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8", + "sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955", + "sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0", + "sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367", + "sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb", + "sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a", + "sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623", + "sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2", + "sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6", + "sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7", + "sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4", + "sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051", + "sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938", + "sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8", + "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9", + "sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3", + "sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5", + "sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9", + "sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333", + "sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185", + "sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3", + "sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560", + "sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b", + "sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7", + "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78", + "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7" + ], + "markers": "python_version >= '3.7'", + "version": "==1.9.2" + }, "zipp": { "hashes": [ "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", diff --git a/mock_generators/app.py b/mock_generators/app.py index f660485..bf147c6 100644 --- a/mock_generators/app.py +++ b/mock_generators/app.py @@ -1,9 +1,12 @@ import streamlit as st from constants import * +from tabs.ideate_tab import ideate_tab from tabs.importing_tab import import_tab from tabs.design_tab import design_tab from tabs.data_importer import data_importer_tab from tabs.tutorial import tutorial_tab +from tabs.getting_help import get_help_tab + from config import setup_logging, preload_state, load_generators_to_streamlit # SETUP @@ -22,18 +25,24 @@ # 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, t5 = st.tabs([ - "⓪ Tutorial", - "① Design", - "② Generate", - "③ Data Importer" +t0, t1, t2, t3, t4, t5 = st.tabs([ + "⓪ Getting Started", + "① Ideate", + "② Design", + "③ Generate", + "④ Data Importer", + "Ⓘ Info" ]) with t0: tutorial_tab() with t1: - design_tab() + ideate_tab() with t2: + design_tab() +with t3: import_tab() +with t4: + data_importer_tab() with t5: - data_importer_tab() \ No newline at end of file + get_help_tab() \ No newline at end of file diff --git a/mock_generators/generators/address_usa.py b/mock_generators/generators/address_usa.py index 4048354..08e1d14 100644 --- a/mock_generators/generators/address_usa.py +++ b/mock_generators/generators/address_usa.py @@ -1,10 +1,11 @@ -from random_address import real_random_address_by_state +from random_address import real_random_address import random def generate(args: list[any]): # Generate a dictionary with valid random address information - states = [ - "AL", "AR", "CA", "CO", "CT", "DC", "FL", "GA", "HI", "KY", "MA" "MD", "TN", "TX", "OK", "VT" - ] - state_code = random.choice(states) - return real_random_address_by_state(state_code) \ No newline at end of file + # states = [ + # "AL", "AR", "CA", "CO", "CT", "DC", "FL", "GA", "HI", "KY", "MA" "MD", "TN", "TX", "OK", "VT" + # ] + # state_code = random.choice(states) + # return real_random_address_by_state(state_code) + return real_random_address() diff --git a/mock_generators/logic/agraph_conversions.py b/mock_generators/logic/agraph_conversions.py new file mode 100644 index 0000000..970585b --- /dev/null +++ b/mock_generators/logic/agraph_conversions.py @@ -0,0 +1,201 @@ +# For converting data from various forms to other forms +from streamlit_agraph import agraph, Node, Edge, Config +import logging +import json + +def random_coordinates_for( + number: int): + import numpy as np + + # Generate random coordinates + min_range = 0 + max_width = number * 300 + x_values = np.random.uniform(min_range, max_width, number) + y_values = np.random.uniform(min_range, max_width, number) + + # Create list of (x, y) coordinates + coordinates = list(zip(x_values, y_values)) + return coordinates + + +def agraph_data_from_response(response: str)->tuple[any, any, any]: + # Returns agraph compatible nodes, edges, and config + logging.debug(f'Response: {response}') + # Response will be a list of 3 item tuples + try: + answers = json.loads(response) + except Exception as e: + logging.error(e) + return None, None, None + if isinstance(answers, list) == False: + logging.error(f'Response could not be converted to list, got {type(answers)} instead.') + return None, None, None + nodes = [] + # node_labels = [] + edges = [] + for idx, item in enumerate(answers): + if item is None or len(item) != 3: + continue + n1_label = item[0] + r = item[1] + n2_label = item[2] + + # We only want to add agraph nodes with the same label once in our return + + # Gross but works + add_n1 = True + for node in nodes: + if node.label == n1_label: + add_n1 = False + if add_n1: + nodes.append(Node(id=n1_label, label=n1_label)) + + add_n2 = True + for node in nodes: + if node.label == n2_label: + add_n2 = False + if add_n2: + nodes.append(Node(id=n2_label, label=n2_label)) + + # agraph requires source and target ids to use what we consider labels + edge = Edge(source=n1_label, target=n2_label, label=r) + edges.append(edge) + config = Config( + width=800, + height=800, + backgroundColor="#000000", + directed=True) + return (nodes, edges, config) + + +def convert_agraph_nodes_to_arrows_nodes( + agraph_nodes: list +)-> list[dict]: + # Convert agraph nodes to arrows nodes + arrows_nodes = [] + + # Generate random coordinates to init new arrows nodes with - since we can't extract the location data from agraph + coordinates = random_coordinates_for(len(agraph_nodes)) + + for nidx, node in enumerate(agraph_nodes): + new_node = convert_agraph_node_to_arrows_node( + nidx, node, coordinates[nidx][0], coordinates[nidx][1]) + arrows_nodes.append(new_node) + return arrows_nodes + +def convert_agraph_node_to_arrows_node( + idx, + node, + x, + y): + # Convert agraph node to arrows node + arrows_node = { + "id": f'n{idx+1}', + "caption": node.label, + "position": { + "x": x, + "y": y, + }, + "labels":[], + "style": {}, + "properties": {} + } + return arrows_node + +def convert_agraph_edge_to_arrows_relationship( + idx, + edge, + arrows_nodes: list): + # Example: {'source': 'People', 'from': 'People', 'to': 'Cars', 'color': '#F7A7A6', 'label': 'DRIVE'} + source_node_label = edge.source + target_node_label = edge.to + source_node_id = None + target_node_id = None + + for node in arrows_nodes: + if node['caption'] == source_node_label: + source_node_id = node['id'] + if node['caption'] == target_node_label: + target_node_id = node['id'] + + if source_node_id is None or target_node_id is None: + node_info = [node.__dict__ for node in arrows_nodes] + logging.error(f'Could not find source or target node for edge {edge.__dict__} from nodes: {node_info}') + return None + edge_type = edge.label + arrows_relationship = { + "id": f'n{idx+1}', + "fromId": source_node_id, + "toId": target_node_id, + "type": edge_type, + "properties": {}, + "style": {} + } + return arrows_relationship + +def convert_agraph_to_arrows(agraph_nodes, agraph_edges): + arrows_nodes = convert_agraph_nodes_to_arrows_nodes(agraph_nodes) + + arrows_relationships = [] + for eidx, edge in enumerate(agraph_edges): + new_relationship = convert_agraph_edge_to_arrows_relationship(eidx, edge, arrows_nodes=arrows_nodes) + arrows_relationships.append(new_relationship) + arrows_json = { + "nodes": arrows_nodes, + "relationships": arrows_relationships, + "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" + } + } + return arrows_json + \ No newline at end of file diff --git a/mock_generators/media/sample_node_0-5-0.png b/mock_generators/media/sample_node_0-5-0.png new file mode 100644 index 0000000000000000000000000000000000000000..4610be9c23ef044a7579a734242c5117637c7f52 GIT binary patch literal 22984 zcmeFYWmr{B8#YWzNs1sKumvQfl$72G(j_3BQqo9wDfh+?7>qr<_$VTy|h%frDT;=#ee85+NMC@uzf4sDp5{pPw;)75aU^Qok|J4r95=PZN=r#&E|LW zZNm9Ltc}r5BckW@hhVYDz&ylI8U~!sdzT{-3i9h2*>EUvo=9Du-Ak*1Ux$X4;G$Dc zc30-?JjG_{Pvi?dPHrdCT7Uhvfrq9&k@>x1a|q#wgVXAJ7=Qr>k#H){MW&$p^zeOv zt|tz2_)jUWqUfKtT=SIW0U-v0vsj+t=zf&_NYG=v<@JxVQaP#9*rn;AG&l$;B7T!J z%$6+JKiT{XQ-e_7Trqa^k=8)spgTEC2KSK%jAeS?m(9B9ooaywvqwHqEoPjsM* zmM;_M4R+jr5tzF; zVEv7ySzi0}DHod5$D2(Ce)ZIlTmpmdG=frIlMQmAkI+h=eZxBE`k;#*U;0|Lpz8@q zDU}j&GuO9LSKX3k4RZQk9F*_Rwj>V^{8j_ayYWgWA3ACaD!Kae3aIeJPzI9sBiDZ; zWO?w(_U!z}l90{PJ|e=3O=r^nN3;*EC96q7NS|L*s9f&EDlu_NoL-c?04U&dg z6mq$!<@mDUqIJWU)hZ;_$+en>_lYeo8D0@?9a=Rt+UM^AuZiI6Qr$x-2-gKx5vqOT zifROQFvn6^ihMQ^U1pIlbbmGtmecF2ey+(>O})*yt|q$LW5c)C7iEzk6Fi|*zvY!5 zQyQ~Vc^$V&en=U@?)Gw_U@0m2y4<~sAG=CdK>b}{$@L`B7Lq*{&lh%hZu|SD9o!|9 z^1KaoWIaj~q17s?yj*gkd)OXAvPyA3TsyftwK|y(nhF5!h%iPk5<$1p< z>tePLk!LxhMnH{R|I#o9eACm>MVS#qk(T=^?dLDyITw+ypb%8#HxRN6&w%F_3^o1V zb)J1iTlK<|yBF=lo`PqFq~3y6Lb8P@z4-hkyo?XVB3{kCotCi?L@iJEJuFud9A95K zT;eppN0bEqq(6P17XhA?)jq6dTYh+8r7t_Oy&B_w@+{V;9=;wa1eP-z5=08&>=Nw4ebcQ8FO1i zI|cDkOXLgEUZnEQM~^T9+vH2{OMU69lwq(=fZKvlpDVpFvV#RHNt!#yw z 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 literal 0 HcmV?d00001 diff --git a/mock_generators/media/sample_properties_0-5-0.png b/mock_generators/media/sample_properties_0-5-0.png new file mode 100644 index 0000000000000000000000000000000000000000..8eeddb43cdaaae1e3b87b4b06581ffffa79d57b3 GIT binary patch literal 90737 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 `K|L(s#@Z9UEZllEnC2O*wcVH|)qZ!9XoY4aCA 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,HR-6=@5M1 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 literal 0 HcmV?d00001 diff --git a/mock_generators/media/sample_relationship_0-5-0.png b/mock_generators/media/sample_relationship_0-5-0.png new file mode 100644 index 0000000000000000000000000000000000000000..6418fd6d416ed9fb86cceb48cf79a6a9c1a5e53b GIT binary patch literal 19510 zcmeIZbyQVd7dJ|$q=*92AV^9nodVJg($a@Ux z)FWPFAt6~YAt4f3YfD38GXp57cY!e~i0>7;a8uMHBlscEaNm69;XdQ@izCq=&zt#B z@KZA(dai{t34=mm@cuji$S#i-C9> zqumBr&*_h0@1ldh!$wfiVRj%~jYKKPZ+^>$LQL?4>-6kiS`Yp*G_(X2n|`{tHfQJg zZie<$zQE)3ZsKe6%7zUz|5sA!jWwGi5?&}Mt-dFLs8A&0PG!09WH0@mKmv6RXmu)%b$;$%64FqPPq0@veP<G9sujVbVnWC#e`MnK71G z0}9eZk#uu;$!`#odz*~2Fifr4<6GYIK2?=N!CVct<`}sfu)W@kjHAUn)IrV@k7cJ& z<}~_JmBmSpS2=j zA|l(6w>)um_9F>EW6~h~#VP>JMWm}4@busab_M5kVr!J%lg7dJM@+%Fiv!lJ%uVvz zXOtYslD@ZFbiC^6;kmd5ZBzo1UX%5*5zmlIXj;)PI6ms2B$f!P=68}3lu#(+H*vI< zxat%)X%N%)Vj{NDY)c#+`mYC@cVicmKXKF+P<9RA=6lZ-M;=Vv4`0{%l=-or?fFI5 zl90{P0W8d#O-IUsFY-r}WQ?ycckeIWCy{lBDo?ItUC(}aX^B!I;jWXUOPBH<>ghKP z;sMMjZ##NcT7yeD(uWQm_t!|C7xP1rNWBW<( zU;u?`IHuMLKi2tUGTu&Nq7BBaHE+RA4?@E=6_*IoE&AxtPG}jDepnrnr}Gl}89puZ z*v)#KNViMLu(VIx6H)WZ?-q`PwjKPCU8xYEX5iYWm%`n<~8PeO4TMeNkp{B7swK^*e6)G37D4_reKV z#7E@eY;Noe`AaFOH)Zafycm@_eCoQv#W$09+i>>iTp?`Goc51Q+c}HL<+hL2Z;w|#Lsk^OGRK%aIU$^9+1-Nmt$9_JQUL^12tc zEJCa|TN<_*oca&+VuEd0sl`|9(9+(hi`dl&yFbQ8V6{Bm_t9NNFnxSvvGBk8KO@Ht zAYAr&g-J|-i7cx78HWO`>#a`!Tzp`!Xm>=+JIYeLT;WqP`cCB4=fl3UfII}P^Pk7y>s zUbwQ3XU|ZBf6A9Uk__o<7ca5SN670?Te4~VWT)pgDVh{8V#R5Jp<;!U;%Rg6^4!c7 zSu1ew2f=~;HOej2UPlvF4jk+!!se(SFDM=}BP$?N`dI&b{ZruQ%j!61q`iQ*U(ttx zexaY~qA(@0dsq8Zr%dv5OK+3$lA%Yuqu_}o>`KHDUXDO{NBPa}o8~vYZ$00Lh7lkLg2?7m z&qq6ozcwJs{3I{KAzUNjl#!or_I*43c?O+qN}fh~z34^$A?#W>O#)3N4PDFu4R%Z> z&5Tl5-mJWnP^LV6uKTE>3AEkIidg4J=hRDqn5^Gv-O6jZ{c;8J=86sZm-3L%3rRm;8iGT#@?snKT!vvnV1> zMp;HtOJA+1c^)^71!X=FkMFk#Hwu?-$p{rqZgf*6{=CGqb#!1FM_%-8tqkBUO zk+*yh`n<&166NP6!YSe|G9{818is#@KN8{=0^gn&8ji=8Z8{Qvtl3Kayq{+e_?i#`=@J`Q}Ts5YAJjRS&wc(8(#2P zta7YaEGm5>U8?G)T1i1r{_p%K9XjFRDHbX4DOcQghoxuT2h*D; zX8B|9Zzl`%h@BFhZR+2+I9v_74ZFU+u)Rt+w{|skCBBTmIJmC7;4zqfRTf|Oh4F^) zrtKE-W}%E*wd{QdY_aE}=dfo#f8P(EAKgE$1hNF;1?&WFnhTp*K85>Z3jP#S_L==a zP+GICX77>X@+*Jv^i@o8b#ZsG|5D}B^8PpyD;cY2Yqy&?E4TpQPes+m(DaxgY0mYuic^?{o_|8V!W z-PNJ4vPi~nzp|0!6BNX919NBO5!>Oz)L9CR>bK4#4<@MIOMQ90H2d}@u;y{Nq@yqH*^1ex+ZSu+%y`K6B{65L3gZH{SmA2U2%?0$Q7DU z+X<#7JaL zsky$FeVX*?^yURx1&A0N&!^odSFOgdcF23eR$GzKS8J5T*lz}2s|mFo^|XI$=d-j^OG*X5`2 zBV{cO0u6B~;V$z>g2&4?+Fhym9?DJS?oNx0O--DaQCBWnf!b(|T8AQgeWz6nmNN)n z5qFzzxOka)Q!b*9H#awOwclyeG)}vk-{79^?0%SEObZ}i?{tTM^?oSi z5tNoCqGtjO6k5sv^~&l7pQ^yBkuo049dge=KSFCWlxBj(i*27 zp}yuqwYor!PB~ShCy(iGh#QmswE2$iy2BuLD7I?Bq%s&d?2Cy=e)soFq6;ZojzpvB}V_QCw)#)|K4-%Cky>RFmI=;&MO8ZbDTgKP-=aXWH? zLvsUL9TG=#GYcC|M;@|2cW{E^`_qhMB!6zPHRU0DFC|MNWNB?c@|xil!z(gg6cQ2= zZfku*PWiVY{}~Sc;~|6C+FEfkGCDXoFgUOi8!$-?G8%K{5z zyuZT8#PEvoe{+MO-1lcWWsMyT%v9eRn**MKXYevJz2^RN|NrgEzfXKH^8LRf-*B)# z9QokN|2b00#=u(0(i}X}miOQ7`p@8p7ymPmoAG|-4><7;oBx~zeC9>rX8hlt@uG-t z9?OAkd~W4(n>dV|&ia!t>nx`(2Hq@47DS`@K|v$&L&0ErLcx*TAEx0W zj|xH*$)$om{pZX-7oS7>AZWw=ed{zb3F~*lm+*oQ#zVm%p*6z(Z@T*{{1uP+A&Ok` zC_aD9h>7ot_~()T9qna8Uzx69;ttD)JrTZt$H@Iv_$$Z*|bHCZrPJ~Cr^?@UH-S;vc z{w7wv0rPOtZ1Ma8f_ewbZH9F_m9*c9>vn7NYHm(vq%uT8gSu7F1C9C;48nLXe+`cD zx?O&;8Q^&{y9JZ}+0UEc-?e(3gz$Iy__062XYG7M%xoO8&~oXMOyzA5JX>%*k{T)0 ztV20idoC9+=(yjE*f*RhLHObV^4w*+BnvQQEhMy2=wBA+i^9>+v%Zl0^5ON2Sy_3Z zbUbq##GV)ZY}O`xb1W}TwZXX}{*9%U<7mDr%Z3d}_J+fP+XN5SYJ`wviyxX0zNYI@ z-y6gB5IpHt*OM`oEWM5}!(jT#SIJxs=z>)J#U^7k8O&~zwj)wfEA6407YfES_BMY^haSrGNe+WUJfx=6jaSmm*lDx zX=SaFN?&A2B^oxKFQaB;ek@R{U^bsFXX4r}_BI|&6L{AF|4>qCgwoV}{qRW^yx+__ zl9#XjL}g@!MMSstHb+J#?&sKeKJrx0tMplZm-VdSa(8%D698PJ;z4fsS>tiZu+s(_fJxd~w>w|LcgHWHu-%6cTwkA`h28OGh zpVx1%_QHdaCWQPRF=Z#)tNAAXP%hNioW6gwfBAv$^L`o5O$wWz$W*OE8V`1yKaCn@ zjDdC4B@NGonDss1$*2M?QYfLr>=1;62(j;40z#)++DiXKoHu;ra?AbZ5)T=@slq}Y z0$JRSBe~!{*wAv>SxXvUVMm*%l(mMB$+Inqa+QZ1{~Btk2WpW1Y^VJMEWqb#)wg?e zS01It$B&K*kBb5J2O=S?A5VjEnF&8z9OATE6ZVAyRTe! zYqBMnTu#l(W-RKI-X%*F*PqS1i1hrxi4-JP4Pbxg`0ibs`UFjKK1J!iBUXe2M8kGS zcs<>pNE|oyK#NR9S4QBclyVF^L^ZLxI}#i9ZDu^TQ|XlBO6bb!-&`btiNVF6uB~)B zS}(+=U->+8hRRqktCKtQg-NehS=~HF3DTL(Z)6LZ8a(JAwq#XbC^H%;Z|}i!{xz6x zVW@QrhtDn{d!FWX*u&Ul_IsjeZ+R_;2=PKM$zk59ze)(#yKI1$tFJp<;5M(Q@pmcY zL!tY5nz=JjnCn4A`jf~7KMWO3JEdp!M%eh+)HFi3gr&>Z>u;Jq!a6gCC`vQcNxL0( zQ5mMXogR$U$*9_%&RFu5>9(O9{eZ>zl4w!;lKbgIiCzb%=plh+{SdwA5yk-L7M%?k zQ>RX^q;M58j!2I&qZiI|O`rO3cc{PcXZnhevO%Ekx@n7^aj^HOVO>F}?_Opn>GpFU z^_G8SRVV_oMe#PpWtYQaitIqZyk<=Vn?x)eXRJWIs*&Dat+y;VUb~zhoJ$4NsM*d%r>o9qKdz~4Odd#FE=sw=urX9cusx2 z9Xdl_g+=Q4+60y}40>Ywe`9t?aJLGOQ5C8T0BcWPrJx`WIXBSz=&q?E4AF4ESQFuL zx*#FVO8&aMKhbo{CIxxdD>m9S{SmCZL#(CZt-q`I>AZ_2mAF13*+XxeRthIh#;PyJ zzFWOA8=Kpx11MN~i+}#2#S$H+KVoV zxZ*bBYQG1O0zbpObXU#UtM3mD4pS#D4c6M3KHYHe45F1rLA#*VpRRq)@xJM5w@zwr zzG3C-tlhAV#!QJx-SoG?fE&zZ!AQZC&!R*h-5*WkeV>ewf^>?L-9_l<1P!tuc9Qq0 zTQf>+I!gFeUN)SJtA|#vqpFp@2u6nf*V944v|;Cz4|dvq&@Sx07lWR7`&e-X zv`D_6a-xJv>5vQDPLH369$6w_7lT!Jg1ymOW}G&XSE#9EDPrxXjk7TsKa)U5GMbfk zyX|J*~0VC zD==f_d<4%Q;anX+Ool0huH$1;KbXc>?jQcR-xD1vDAY(|jW99%f|x>zk80v5!U zOdxEP7veyR7O*v5Uy#}we=wrjX+7S^)nV4CQZsw>_8W1atI}#~0f}7ZNaxD_YLpD;hQkRtMTV z0N;G=ds@7kwsMjr`f!)f>GAcv%N`oy^%ELaWADR~& zaS&*xo6A-7^P#(EXRYG|kAzxbHwTF;v;oovF;a6F7scI}+yeXgiwy*0gBN=v^HnJSd^%Kh4L;~Nnp zbPpx#Xv$=|dbVdz0<-mMr(_$!4l8A_&-2-pv;y-Osv1RF=?r(sh0^u}Hs1#RL*2SS z!l7V2d?Meyk*88*&$7>A*!v3gDTA%|b$>D!S8Cv-z-$$1)L_J#Ku?Noaf;yy+xdi9 zlPjk+d>|oOFW=QR3;9AsrTI*4JvM%P<$j-%a;1@4={F%LJFE1!Ucaed(5YAU_9k(R z1cZNz)ee-A|3J~Bm`v<`o~}pRj{W4fN=F#soD*5s@$6s0$uk%>EeOsCUS{75)*yQS z!w@75Qlq^P_kCa!q2Pi@JqM}%aE(bHrZQfikVsfje!eAp zC;|Lw&j3xjkk7aeS&m5^0FomTNs7PIy#H@Zi$l)};!opPI#pfeA}w>}0yTs20(BKx z;s0Z`CPZG-6{d-oCtHcmC%@xCXr6$8hOa0=j*5$)tJqDaR^CacRbQ<}lZE*=N7u0+ zK3{MTj>5M3**iOBx7Z6y06nD6WC3sg&V zeN`*X;#F&Hb4`8dp3|#W+Qx=;SAHi+FR@w@H0p}TfsB2RdHgJ z@wjy*Kaf-E5z$H~O%36&S!c&K2kA*An>yQL`hthM%J1|zV)FcQtBBCYerH-~`uk@U z2;KYQ9~>T*^#|=)!oj#}QpxjwN&X`wM>LX+{l3>T`SLvdw53)CFCG!GF1{tGOANb~|%cE?}iIRF~ag{1)fkcRGXpe@() z96S#do4*T~0P(OdC=X!_S$9Cw+FAc5)5G!N_mG8ktKwhUdPojvYI2Ycf2hQ=)`0v- z@+_E#5*JMjXu4>yaenjw8u5w*xJvYMrYC>-oNHhLy7!Vt^!^&31jt_v$o)6j24mJl z0Zs0ENksQ71=t3V_z*mn_WP8}c6WAaySN1%eyjvCM8;x1&3XhH8%3toY*_(18 zYH8O~*z~}+x!xlM-O(=%`;#~f^9m}h*DM+@*0ZFOIj2gMUb>Ixs~TRPA7lX~G`zbx zdlO3VreA$Nfz7nnDAjF!`U2!!3EJ1oPYpqI%%oXo&xS_ux;If9`SvLF?n|yhb^-?P z`43j}=~3Ig`8nII@jh15@%;N|$9$hPJHf($Mly2*94DCP$`l1y6B=0>^!X=vR7qe@ z`?3-()V$S{-K112Ok(ccZtr~_0H={KS#ZgVg|W5D?kbCgt>2yN7mz- zt_}K^DI`aw@BG=NXTOO(KFa31={nw?VhNM3Q`QvnM`vSfIF?tdZ{h%mM&Dwyck=$h zM_dQeb9=s6Eu?x zb3Y5!hhdbeHy~qt-9hA%AJ6ON>=?bBP4wJx(Q6%KU38P2^53n6$ZLRVbqDb)*#S|@ zZ)C6q?uI({2ilw24b!trrr$PoE$@xAMe$ZT7`@uD0T{mCNd9(caF_dtv^925&w)1A@Ish5a zopgf*8Pc)TH7R@wYk$)y)Y=HsCN^vqviR%&f*4Lkj{TmP}%p zd*QOC_+XT$aLPA1N_=F`eVO4!S~5ZWCl(l_H&lm<6Uf*i_l zncMO?qfb;cu$EuQq$HK(>^0r-W*(;`+g0~}=@0rE{Y0NM+3plPA~F~AZq{bN_k7j& zi4y7D&Dlb#tHx5J;+k?!0208c&5#t85Q}ZU(;ZVM)4hSwb~}klWE*3S1@hH-pI}*Z zq_~|`Pt*mkq(Y_lQ%@Z9(Pv@7Rsu1 zJCK?X!}7CfQ^pKJFKhUn{0!m>2|GwvfT8V<=TJ_HzAR^jlz{oK$&!q^u)7Puji33p zaSWOoT-QhXO{>TGbDvkw^XwtH!S-U)0GgD#xjb=ueU=;VvSa^E)lSotDy@g1PBMyR z*C`m6UJ~r+@Zm3!SeQO5+Riu4j7-*8Y^$lrk6(OgLFekN5NSltPIa%v>d?OVt-`<% zCoIGZ`#a<3(E#6`y{Pq@K#$dqk`ZYx_#S`5Up9T6rn#S!vY&{AJktr|{GIbQkZ-m- zXsPMxVdCeC+%F`e_Np?+gq9;&p+^lv(&~r4`_lUZ+(#iU8(r2)MO9yaaWQU*RFd#T z0(fVdhZo>*$wlF@nod+Usx;*q`2yYuc)yqL^+bJSkw-&52>um+ibWeTCw*L&CgTf?oF|h4$wqo)4>iynAgA>V*)0eAeT^ULF=n;lCQ%3Q3E_7TaE1}%tnvoFiJ;)GiU*0)%+ zi>24oC27TNUR1dglI{QxXPxB+z-XcA4q^{(qiHjYrwkfjvNlY5fU*aiaQZBIs@}_B|onP%ZEduQF-)^)0=1CVU$?STu7FU=WG-2jaKW8#qppZo#IJ|cth4j<(v(5UnJK?_%T;F;H#IYyrGUIl}fRCdtYcI$F zZotSXfSxj;YrxLWr_y^YOa^6MKB9~io;QRZf6A3rwrv*Y7BQ>MZt5lr$Cd@+ z_>xmPFTTC1s`#D&UxJgiwje1kTHO=VMN$t>*JcJ^l+7bmqU+vbN>Z{)$6D85y05{aG&qDe7j_!T`HXZg=R`BFJn(2&z;8 zS$w4BzJTXk@Ade7rI~6wk+|czKzOp4l*D6HH2PdI1Q&j_PSXMu0sV8^k5eA!EyS+2 z-IqZ=uHR?+6a0-1eBnGT6Jy_B$R-Q66xN)zWx1)*%I0v@xaKhnk{9fs{K~>iPv7(N zkO6_S`}};Hx3=l$?gXs`Rqev(IY++l_i<>Tj$(2*e38)VdK4k1bU|e?#HiGP8g-i1 zovA%ovvE&R3Fu9GamAG^DlY@>1=$<8Zti)O1|R+k%zbZ;t=0r(Ejrfi()4npS|?CZ zX<2+{%mRPmk>ko|lgA1?1)m`A*1P1FiNR4&2j(MTLcEIEgPo&{V~>l@s=|IWnw$j= zzH3>!cDQzld_t>TtX}VTM#B_O4}se48IzX3_(_%Oiakv1wECH$CSRfSi^1hf#Sk1a zR>fG$Jo_KrPj%BtQT=(Ocvc72M{weD37cKrfOWCBrrxPlT+34W34$hBdl38?oON%$ zf(s%~E8s35783WyVMK_O?gn87Fj#~LT%dfS*N(7Vf7H(q`g@R)aqotFd$WV1E zWxrdOjfrvtf{E_)gM*Y!N;s4kY0aUr`j8ijWi9TqI3AY*;f&>qf+LrY#pNrny zj{X?SK4ED?3jHE$T!gC1`9z%vSRy9^oGbXXgisZMgR4|72aEpFibwq(D)pIf8<|Q= z^)CbCR@P{$G1EyuE^hT#K29hSf?3EuiH&=Y8TyRrCsp4i9_lOAx23-FF^L7d%cM|V zha?~o2|tr$B$dU4<_K+u^qwpOvXGGwiZT^ zTrAi249M0Pkw;Vg+DrAk@{lf!a+|((S`Rw$l>j%D$fmL`*G!r>su*V=cz3$b$0n|_sxUjn`C)4ud8+MshHFpQ zJ3Rzp%U9Df6>&q)ZGX9PZJ$?ejrhNZOzw!d*-jggZa{JdfH`{4dBT_EixPT%ys6=R z4Kh%8rZk5H)7M(`0W!}A))Bwi(oo@YjbuqF*jD%m`iQuGs>$bcKdX>L3m6DHpmH&N znQ>RR<>~^?c8pu@p}kXR_lJ_P8v7q;YzWKH$M0Xh`sg*AXbNf zup!l{-~LB3C#(DQd4#m-u$$wO?^8k7$~DGQ*RY5eGhS{;XfmudHtpOP{ByQvgDKN6Lx%ltVI&l>EFh zQs*Ivmo?G|D(WlHR#|_lLeJ;3=kvNx1$f_1Q~V%Wj}v#7(fj!Ib%4k7ND0_A9IfPZ z*>tSb+d`>F`VE0Ck(3hh2fK=0XgId9A$N^i3d=9iFH_p+bPoz_r=`^fc9x=zsS;Hx zW~I3D;iiUdK483Mu}`iu^(92YPqv=Tkt5eb zY!n$NtXWSO{J9!9c7Qys*Bai)VwoZB zd@8y8=m5D-tp8(e>g9BQH9?&j6>AH6SRW{v%r;2-5CcToaU9glN5CFi5wj@hr}Db> z?zx|Foy`Ub*?_8hjp>bETPjz>jOe8Kd!ebzLdgWyuF)dx1)j=A4w3B2nYl(V5pfmS z=zS>r%knc1H8c0tP=-G+rJ}ASF_)vr`{T_#t79+M?ay8~c-)tM?;8!cA4mD25!AZe zOl>bTHI)bs&uz7yd%+?mpc8UFOGdC5z+j?L7oZwe*IB+?B)h!}ui&lTYYZO|JN{iX zeFT!dYT~nsZ=p}ZwW($N|9hGCOg-A8-k%)6XukXL1K(`1bk{JnH zq&KUbg!gkki0RN*U*mhMpZ^sH6hr2qESE0Uw<}2jSm(Od_HxSw-kp-Usg6m)^5NxW zfqR@t#Uil3)GACasP%3bLlF9TCBM9ChNSI!SD;sO--aiKhx6%?5_UXwnJY7r zSrImCtRENGtF_(4j&uX)gdwPhwp&Rb*?$DVy1>R!k>yHH}svyM*}o;py-h@NM=xQ6lB}GubTza7>tXpCw2Vs z>b9bX()u}L@NfKeFqTkx7(^gRcH^pr`4Ja)PZI*iJ>KCvnnI;K!spj=n6vBW)QxlW z=}2gc$rOYguC?3pJ3kj>`GudR<`IHv?l8h^FQIsJwQx882%shJn{eJ-xdH#|&w6p0 z=>vKjY}S5pMhV|HhV;whU{JGJMW4K5x#&uZH|%4lrQo0%xs5)v$5R*SQOtThrP&jr z`CVb@bJe>kO3C&n1-AuIucFe6E5}1yjUrty+emRq;M%Pwk8ka))s55VFpc7Y=jNW^ zTJX5LNnWGGWh52&{ItROMEpv6t=j@4RkG|ib%Eyc>f6G6gZbKYN8}KIwTw+FG~9Px zJc(hOG*FOs+;kX`K`A#LX=jRFlU0t?k07b)DbXl*UqD2|kCxP>QLyk847f)WQb0_y z-2?;W$uA6}*2rWz&rQXwR%KxhQgs!kA(>|xYZ{lIeKWsQ8V?q&sj9V)qzQNvS0H{@ z_co?q87gHIQn5#Kv5ZlhE3mHw5AV0+1|uC3!|;c3iFxQM$@`IeX7qefWIug2kz=rr2YQ_?wkQ| zH_VcS@E7n#2!Ok(25Zj0fV<&);4X!U?JwYN`yRO4jTzDTYkUg7l5e{QXg$2Uqj%g# z2knB(_q|~|bBIh$d=kJRq(h?4$kA)w&0(=`FQld+Lm~@uGn=IC zelPOeMWvX8?2}!7#^e)WIB2<`4>^$z9^rdsyPCTUi479gb*`y+)eSR;+*eM5lXpA? z-?}AUh+bZ7r0&cI1qD49EkT8b!{mqh&sQWxgynNt!L-lch!J<;|Gnb%6A2T7)BZV+ z#1r&^{pX9H-;ob z`R`=+(_&(h3?h9f4*d5KP$2Ol8Ke#&R}uIxiGWxyPbe=DC~v6;gkw^NfO9&}Yfv6i z0|$r13iHg2>><%Q;2g?Ir_#TR#^fg<;ZMW*@!|2m3xDj^Ox$!E%bQz7NEWL=}iz*LRt}xbzi>8+DLL8s>w+ z6cRwDRZqI+9`HdH2MiXLL&Jgje~*d$2S~k%Zw9VGGQ19I-7HOaw{@GtnZZ*4+}ugU zrB@sMo(2l5tiW_PG>mUgl}QN;`)-aGBu<&+6-XYf_nFp@_r|mIft1oBd~3d;ZWGkU zLO1Tqr;;h$Q;JGTz=I^jgE%TP>8Kfz_zNf=#e)h?BItzrw%v2`rw-5+Nm)62wOtm@ zbKHW##|jEVeRgW&ZZr!PbvuUB<;GDbU)XmBZ|X09=NSUx{

dxSSP6EsOSv+TET^ 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 str: + # Full prompt string to query openai with and finasse expected response + full_prompt = f""" + Given a prompt, extrapolate as many relationships as possible from it and provide a list of updates. + + If an update is a relationship, provide [ENTITY 1, RELATIONSHIP, ENTITY 2]. The relationship is directed, so the order matters. + + Each entity must be singular. Change any plural nouns to singular. + + Each relationship must have 3 items in the list. + Limit the number of relationships to 12. + + Example: + prompt: Alice is Bob's roommate. Bob is friends with Charlie. + updates: + [["Alice", "ROOMMATE", "Bob"], ["Bob", "FRIEND_OF", "Charlie"]] + + prompt: People who are friends with Alice are also friends with Bob. + updates: + [["Person", "FRIEND_OF", "Alice"], ["Person", "FRIEND_OF", "Bob"]] + + prompt: {prompt} + + updates: + """ + return full_prompt + +def generate_openai_response(prompt)-> str: + response = openai.Completion.create( + model="text-davinci-003", + prompt=prompt, + temperature=0.5, + max_tokens=800, + top_p=1.0, + frequency_penalty=0.8, + presence_penalty=0.0 + ) + # TODO: Validate reponse + print(f'OpenAI Response: {response}') + return response.choices[0].text + +def ideate_tab(): + with st.expander("Instructions"): + st.markdown( + """ + Not sure how to start with data modeling? Use this variation of GraphGPT to generate a graph data model from a prompt. + 1. Add your [OpenAI API Key](https://platform.openai.com/account/api-keys) to the OpenAI API Key field + 2. Enter in a prompt / narrative description of what you'd like modelled + 3. Download data as an arrows.app compatible JSON file + 4. Proceed to the '② Design' tab + """ + ) + + # Configure ChatGPT + if CHATGPT_KEY not in st.session_state: + # For dev only + # st.session_state[CHATGPT_KEY] = st.secrets["OPENAI_API_KEY"] + st.session_state[CHATGPT_KEY] = "" + if LAST_PROMPT_KEY not in st.session_state: + st.session_state[LAST_PROMPT_KEY] = "" + if LAST_OPENAI_RESPONSE not in st.session_state: + st.session_state[LAST_OPENAI_RESPONSE] = "" + + current_api = st.session_state[CHATGPT_KEY] + new_api = st.text_input("OpenAI API Key", value=current_api, type="password") + if new_api != current_api: + st.session_state[CHATGPT_KEY] = new_api + + openai.api_key = st.session_state[CHATGPT_KEY] + + if openai.api_key is None or openai.api_key == "": + st.warning("Please enter your OpenAI API Key") + else: + # Display graph data from prompt + prompt = st.text_input("Prompt") + last_prompt = st.session_state[LAST_PROMPT_KEY] + + # Display Graph + if prompt is not None and prompt != "": + + if prompt != last_prompt: + print(f'New Prompt: {prompt}') + st.session_state[LAST_PROMPT_KEY] = prompt + full_prompt = agraph_data_prompt(prompt) + openai_response = generate_openai_response(full_prompt) + print(f'new open_ai_response: {openai_response}') + st.session_state[LAST_OPENAI_RESPONSE] = openai_response + + oai_r = st.session_state[LAST_OPENAI_RESPONSE] + # Convert openai response to agraph compatible data + nodes, edges, config = agraph_data_from_response(oai_r) + + + + # Button to download graph data in arrows.app compatible JSON + if nodes is not None: + + # Arrows compatible file + arrows_dict = convert_agraph_to_arrows(nodes, edges) + + # Convert dict to file for download + json_data = json.dumps(arrows_dict) + json_file = io.BytesIO(json_data.encode()) + + c1, c2 = st.columns([4,1]) + with c1: + agraph(nodes=nodes, + edges=edges, + config=config) + with c2: + st.write("Download Options") + st.download_button("Arrows.app Compatible File", json_file, file_name="graph_data.json", mime="application/json") \ No newline at end of file diff --git a/mock_generators/tabs/importing_tab.py b/mock_generators/tabs/importing_tab.py index 081decb..ca27e4f 100644 --- a/mock_generators/tabs/importing_tab.py +++ b/mock_generators/tabs/importing_tab.py @@ -15,7 +15,7 @@ def import_tab(): 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 + 4. Proceed to the '④ Data Importer' tab """ ) diff --git a/mock_generators/tabs/tutorial.py b/mock_generators/tabs/tutorial.py index 603c761..bb0afe7 100644 --- a/mock_generators/tabs/tutorial.py +++ b/mock_generators/tabs/tutorial.py @@ -1,4 +1,6 @@ from streamlit_player import st_player +import streamlit as st def tutorial_tab(): - st_player("https://youtu.be/dJMlPYvWdbQ", height=600) \ No newline at end of file + url = st.secrets["VIDEO_TUTORIAL_URL"] + st_player(url, height=600) \ No newline at end of file diff --git a/tests/test_generators.py b/tests/test_generators.py index 5a58bfe..273ce9c 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -7,20 +7,17 @@ class TestAddressGenerator: def test_address_generator_elements(self): - try: - test_string = '{"address_usa": []}' - generator, args = actual_generator_for_raw_property(test_string, test_generators) - value = generator.generate(args) - - assert value['address1'], f'address object missing address_line_1: {value}' - assert value['city'], f'address object missing city: {value}' - assert value['state'], f'address object missing state: {value}' - assert value['postalCode'], f'address object missing postalCode: {value}' - assert value['coordinates']['lat'], f'address object missing coordinates.lat: {value}' - assert value['coordinates']['lng'], f'address object missing coordinates.lng: {value}' - - except Exception as e: - assert False, f'Exception: {e}' + test_string = '{"address_usa": []}' + generator, args = actual_generator_for_raw_property(test_string, test_generators) + value = generator.generate(args) + + print(f'value: {value}') + assert value['address1'], f'address object missing street: {value}' + assert value['city'], f'address object missing city: {value}' + assert value['state'], f'address object missing state: {value}' + assert value['postalCode'], f'address object missing postalCode: {value}' + assert value['coordinates']['lat'], f'address object missing coordinates.lat: {value}' + assert value['coordinates']['lng'], f'address object missing coordinates.lng: {value}' class TestDateGenerator: def test_date_generator(self): From d66f503235ffe36f303ed6ce1d1d337779625a1f Mon Sep 17 00:00:00 2001 From: Jason Koo Date: Tue, 16 May 2023 11:29:09 -0700 Subject: [PATCH 5/5] Support for negative literal numbers added --- mock_generators/logic/generate_values.py | 75 +++++++++++++------ setup.py | 8 ++ tests/test_agraph_conversions.py | 78 +++++++++++++++++++ tests/test_generate_values.py | 95 ++++++++++++++++++++++-- 4 files changed, 229 insertions(+), 27 deletions(-) create mode 100644 setup.py create mode 100644 tests/test_agraph_conversions.py diff --git a/mock_generators/logic/generate_values.py b/mock_generators/logic/generate_values.py index 67d951b..bc969f7 100644 --- a/mock_generators/logic/generate_values.py +++ b/mock_generators/logic/generate_values.py @@ -2,6 +2,7 @@ from models.generator import Generator, GeneratorType import logging import json +import re # ORIGINAL GENERATOR ASSIGNMENT def actual_generator_for_raw_property( @@ -94,11 +95,39 @@ def is_int(value: str) -> bool: def is_float(value: str) -> bool: try: - f = float(value) + _ = float(value) return True except ValueError: return False +def find_longest_float_precision(float_list): + max_precision = 0 + for num in float_list: + num_str = str(num) + if '.' in num_str: + decimal_part = num_str.split('.')[1] + precision = len(decimal_part) + max_precision = max(max_precision, precision) + return max_precision + +def extract_numbers(string): + # Use regex to find all number patterns in the string + number_list = re.findall(r"-?\d+(?:\.\d+)?", string) + + # Convert the extracted strings to appropriate number types + number_list = [int(num) if num.isdigit() else float(num) for num in number_list] + + return number_list + +def numbers_list_from(string): + # numbers = [] + # ranges = string.split('-') + # for r in ranges: + # numbers.extend(extract_numbers(r)) + # return numbers + options = re.split(r'(?<=[^-])-', string) + return options + def literal_generator_from_value( value: str, generators: list[Generator] @@ -134,9 +163,10 @@ def literal_generator_from_value( # Original specificaion took stringified JSON objects to notate generator and args to use. We're going to convert matching literal values to appropriate generators # Default is to use the literal generator - result = { - "string": [value] - } + result = None + # result = { + # "string": [value] + # } # Check if value is an int or float if is_int(value): @@ -145,7 +175,7 @@ def literal_generator_from_value( "int": [integer] } - if is_float(value): + if result is None and is_float(value): f = float(value) result = { "float": [f] @@ -153,24 +183,23 @@ def literal_generator_from_value( # NOTE: Not currently handling complex literals - # Check if value is a range of ints or floats - r = value.split("-") - if len(r) == 2: - # Single dash in string, possibly a literal range - values = [r[0], r[1]] - if all_ints(values): - result = { - "int_range": [int(r[0]), int(r[1])] - } - elif some_floats(values): - # Float range function expects 3 args - this one seems more sensible than other functions - result = { - "float_range": [float(r[0]), float(r[1]), 2] - } - + # Check if value is a range of positive ints or floats + if result is None: + numbers = numbers_list_from(value) + if len(numbers) == 2: + # Check for correctly formatted int or float range string + precision = find_longest_float_precision(numbers) + if precision == 0: + result = { + "int_range": [int(numbers[0]), int(numbers[1])] + } + else: + result = { + "float_range": [float(numbers[0]), float(numbers[1]), precision] + } # Check for literal list of ints, floats, or strings - if value.startswith('[') and value.endswith(']'): + if result is None and value.startswith('[') and value.endswith(']'): values = value[1:-1].split(',') # Generators take a strange format where the args are always a string - including # lists of other data, like ints, floats. ie ["1,2,3"] is an expected arg type # because certain generators could take multiple args from different text fields @@ -193,6 +222,10 @@ def literal_generator_from_value( "string_from_list": values } + if result is None: + result = { + "string": [value] + } # Package and return from legacy process actual_string = json.dumps(result) return actual_generator_for_raw_property(actual_string, generators) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cf4a8d4 --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +# Run `pipenv install -e . --dev` at least once so that pytest can import all files for testing +from setuptools import find_packages, setup + +setup( + name="mock_generators", + package_dir={'': 'mock_generators'}, + packages=find_packages(where='mock_generators'), +) \ No newline at end of file diff --git a/tests/test_agraph_conversions.py b/tests/test_agraph_conversions.py new file mode 100644 index 0000000..27b52c8 --- /dev/null +++ b/tests/test_agraph_conversions.py @@ -0,0 +1,78 @@ +import pytest +from mock_generators.logic.agraph_conversions import convert_agraph_to_arrows, agraph_data_from_response, convert_agraph_nodes_to_arrows_nodes,convert_agraph_node_to_arrows_node, convert_agraph_edge_to_arrows_relationship,random_coordinates_for + +from streamlit_agraph import agraph, Node, Edge, Config + +class TestAgraphConversions: + + def test_agrph_data_from_response(self): + response = """[ + [ + "Alice", + "ROOMMATE", + "Bob" + ], + [ + "Bob", + "FRIEND_OF", + "Charlie" + ] + ] + """ + nodes, edges, config = agraph_data_from_response(response) + print(f'{[node.__dict__ for node in nodes]}') + assert len(nodes) == 3, f'Expected 3 nodes, got {len(nodes)}: nodes: {[node.__dict__ for node in nodes]}' + assert len(edges) == 2, f'Expected 2 edges, got {len(edges)}: edges: {edges}' + + print(f'config: {config.__dict__}') + assert config.width == "800px", f'Expected default config, got {config.__dict__}' + assert config.height == "800px", f'Expected default config, got {config.__dict__}' + assert config.backgroundColor == "#000000", f'Expected default config, got {config.__dict__}' + + def test_convert_agraph_edge_to_arrows_relationship(self): + nodes = set() + nodes.add(Node(id="n1", label="Alice")) + nodes.add(Node(id="n2", label="Bob")) + edge = Edge(source="Alice", target="Bob", label="FRIEND_OF") + + print(f'nodes: {[node.__dict__ for node in nodes]}') + print(f'edge: {edge.__dict__}') + + arrows_nodes = convert_agraph_nodes_to_arrows_nodes(nodes) + arrows_edge = convert_agraph_edge_to_arrows_relationship(0, edge, arrows_nodes) + + # NOTE: The edge may reverse the from and to ids + print(f'arrows edge: {arrows_edge}') + assert arrows_edge == { + "id": "n1", + "type": "FRIEND_OF", + "fromId": "n1", + "toId": "n2", + "properties": {}, + "style": {} + } or { + "id": "n1", + "type": "FRIEND_OF", + "fromId": "n2", + "toId": "n1", + "properties": {}, + "style": {} + } + + def test_convert_agraph_node_to_arrows_node(self): + node = (Node(id="n1", label="Alice")) + print(f'agraph_node: {node.__dict__}') + coordinates = random_coordinates_for(1) + arrows_node = convert_agraph_node_to_arrows_node(0, node, coordinates[0][0], coordinates[0][1]) + print(f'arrows_node: {arrows_node}') + assert arrows_node == { + "id": "n1", + "caption": "Alice", + "position": { + "x": coordinates[0][0], + "y": coordinates[0][1], + }, + "labels": [], + "properties": {}, + "style": {} + } \ No newline at end of file diff --git a/tests/test_generate_values.py b/tests/test_generate_values.py index ca297d8..2ff0a02 100644 --- a/tests/test_generate_values.py +++ b/tests/test_generate_values.py @@ -1,6 +1,6 @@ import pytest from mock_generators.config import load_generators -from mock_generators.logic.generate_values import literal_generator_from_value, actual_generator_for_raw_property, generator_for_raw_property, keyword_generator_for_raw_property +from mock_generators.logic.generate_values import literal_generator_from_value, actual_generator_for_raw_property, generator_for_raw_property, keyword_generator_for_raw_property, find_longest_float_precision test_generators = load_generators("mock_generators/named_generators.json") @@ -85,6 +85,21 @@ def test_integer_list_multi(self): except Exception as e: assert False, f'Exception: {e}' +class TestLiteralSupport: + def test_find_longest_float_percision(self): + try: + test_floats = [1.01, 2.002, 3.004] + precision = find_longest_float_precision(test_floats) + assert precision == 3 + except Exception as e: + assert False, f'Exception: {e}' + + try: + test_floats = [1, -2] + precision = find_longest_float_precision(test_floats) + assert precision == 0 + except Exception as e: + assert False, f'Exception: {e}' class TestLiteralGenerators: def test_integer(self): @@ -102,14 +117,21 @@ def test_integer(self): def test_float(self): try: test_string = "1.0" - # This should be equivalent to the integer generator with arg of [1] generator, args = literal_generator_from_value(test_string, test_generators) - # Test generator returned creates acceptable value value = generator.generate(args) assert value == 1.0 except Exception as e: assert False, f'Exception: {e}' + def test_negative_float(self): + try: + test_string = "-1.0" + generator, args = literal_generator_from_value(test_string, test_generators) + value = generator.generate(args) + assert value == -1.0 + except Exception as e: + assert False, f'Exception: {e}' + def test_int_range(self): try: @@ -126,16 +148,77 @@ def test_int_range(self): def test_float_range(self): try: test_string = "1.0-2" - # This should be equivalent to the integer generator with arg of [1] generator, args = literal_generator_from_value(test_string, test_generators) - # Test generator returned creates acceptable value value = generator.generate(args) assert value <= 2.0 assert value >= 1.0 except Exception as e: assert False, f'Exception: {e}' + try: + test_string = "1-2.0" + generator, args = literal_generator_from_value(test_string, test_generators) + value = generator.generate(args) + assert value <= 2.0 + assert value >= 1.0 + except Exception as e: + assert False, f'Exception: {e}' + try: + test_string = "1.0-2.0" + generator, args = literal_generator_from_value(test_string, test_generators) + value = generator.generate(args) + assert value <= 2.0 + assert value >= 1.0 + except Exception as e: + assert False, f'Exception: {e}' + try: + test_string = "1--2.02" + generator, args = literal_generator_from_value(test_string, test_generators) + value = generator.generate(args) + assert value <= 1.00 + assert value >= -2.01 + except Exception as e: + assert False, f'Exception: {e}' + try: + test_string = "1.01--2.02" + generator, args = literal_generator_from_value(test_string, test_generators) + value = generator.generate(args) + assert value <= 1.01 + assert value >= -2.01 + except Exception as e: + assert False, f'Exception: {e}' - + try: + test_string = "-10.0003-20" + generator, args = literal_generator_from_value(test_string, test_generators) + value = generator.generate(args) + assert value <= 20.0000 + assert value >= -10.0003 + except Exception as e: + assert False, f'Exception: {e}' + try: + test_string = "-10.01-20.02" + generator, args = literal_generator_from_value(test_string, test_generators) + value = generator.generate(args) + assert value <= 20.02 + assert value >= -10.01 + except Exception as e: + assert False, f'Exception: {e}' + try: + test_string = "-73--74.004" + generator, args = literal_generator_from_value(test_string, test_generators) + value = generator.generate(args) + assert value <= -73.000 + assert value >= -74.004 + except Exception as e: + assert False, f'Exception: {e}' + try: + test_string = "-73.979--74.004" + generator, args = literal_generator_from_value(test_string, test_generators) + value = generator.generate(args) + assert value <= -73.979 + assert value >= -74.004 + except Exception as e: + assert False, f'Exception: {e}' def test_int_list(self): try: test_string = "[1,2,3]"