From 09e0699a93f9505d1332ce175ff3c939442b152a Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Fri, 13 Dec 2019 14:38:23 +0100 Subject: [PATCH 1/2] Initial implementation Tests only ensure that a full document *can* be parsed. The actual client part is missing still, but parsing to models should work. --- .editorconfig | 23 + .pre-commit-config.yaml | 18 + Pipfile | 15 + Pipfile.lock | 190 ++++++ caluma_client/__init__.py | 0 caluma_client/models.py | 179 +++++ caluma_client/parser.py | 44 ++ caluma_client/tests/__init__.py | 0 .../tests/files/full_form_response.json | 636 ++++++++++++++++++ caluma_client/tests/test_parser.py | 13 + commitlint.config.js | 1 + setup.cfg | 47 ++ setup.py | 15 + 13 files changed, 1181 insertions(+) create mode 100644 .editorconfig create mode 100644 .pre-commit-config.yaml create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 caluma_client/__init__.py create mode 100644 caluma_client/models.py create mode 100644 caluma_client/parser.py create mode 100644 caluma_client/tests/__init__.py create mode 100644 caluma_client/tests/files/full_form_response.json create mode 100644 caluma_client/tests/test_parser.py create mode 100644 commitlint.config.js create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0cca7e1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +root = true + +[*] +insert_final_newline = true +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true + +[*.sql] +indent_style = space +indent_size = 4 + +[*.py] +indent_style = space +indent_size = 4 + +[*.json] +indent_style = space +indent_size = 2 + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..be90fe5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: +- repo: local + hooks: + - id: black + name: black + language: system + entry: black + types: [python] + - id: isort + name: isort + language: system + entry: isort -y + types: [python] + - id: flake8 + name: flake8 + language: system + entry: flake8 + types: [python] diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..3f77124 --- /dev/null +++ b/Pipfile @@ -0,0 +1,15 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +pytest = "*" +pytest-mock = "*" +pytest-cov = "*" + +[packages] +requests = "*" + +[requires] +python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..53551be --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,190 @@ +{ + "_meta": { + "hash": { + "sha256": "696e2b2307263f1eae728c9d57ca824202b19c7b988de8b05435904d19d2103a" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.6" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + ], + "version": "==2019.11.28" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, + "requests": { + "hashes": [ + "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", + "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + ], + "index": "pypi", + "version": "==2.22.0" + }, + "urllib3": { + "hashes": [ + "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", + "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" + ], + "version": "==1.25.7" + } + }, + "develop": { + "attrs": { + "hashes": [ + "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", + "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + ], + "version": "==19.3.0" + }, + "coverage": { + "hashes": [ + "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", + "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", + "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", + "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", + "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", + "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", + "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", + "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", + "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", + "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", + "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", + "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", + "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", + "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", + "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", + "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", + "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", + "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", + "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", + "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", + "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", + "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", + "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", + "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", + "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", + "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", + "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", + "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", + "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", + "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", + "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", + "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" + ], + "version": "==4.5.4" + }, + "importlib-metadata": { + "hashes": [ + "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45", + "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f" + ], + "markers": "python_version < '3.8'", + "version": "==1.3.0" + }, + "more-itertools": { + "hashes": [ + "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d", + "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564" + ], + "version": "==8.0.2" + }, + "packaging": { + "hashes": [ + "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", + "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" + ], + "version": "==19.2" + }, + "pluggy": { + "hashes": [ + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + ], + "version": "==0.13.1" + }, + "py": { + "hashes": [ + "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", + "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" + ], + "version": "==1.8.0" + }, + "pyparsing": { + "hashes": [ + "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", + "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" + ], + "version": "==2.4.5" + }, + "pytest": { + "hashes": [ + "sha256:63344a2e3bce2e4d522fd62b4fdebb647c019f1f9e4ca075debbd13219db4418", + "sha256:f67403f33b2b1d25a6756184077394167fe5e2f9d8bdaab30707d19ccec35427" + ], + "index": "pypi", + "version": "==5.3.1" + }, + "pytest-cov": { + "hashes": [ + "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b", + "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626" + ], + "index": "pypi", + "version": "==2.8.1" + }, + "pytest-mock": { + "hashes": [ + "sha256:67e414b3caef7bff6fc6bd83b22b5bc39147e4493f483c2679bc9d4dc485a94d", + "sha256:e24a911ec96773022ebcc7030059b57cd3480b56d4f5d19b7c370ec635e6aed5" + ], + "index": "pypi", + "version": "==1.13.0" + }, + "six": { + "hashes": [ + "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", + "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" + ], + "version": "==1.13.0" + }, + "wcwidth": { + "hashes": [ + "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", + "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" + ], + "version": "==0.1.7" + }, + "zipp": { + "hashes": [ + "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", + "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335" + ], + "version": "==0.6.0" + } + } +} diff --git a/caluma_client/__init__.py b/caluma_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/caluma_client/models.py b/caluma_client/models.py new file mode 100644 index 0000000..3275ed2 --- /dev/null +++ b/caluma_client/models.py @@ -0,0 +1,179 @@ +import base64 + +from .parser import parse_document + +ANSWER_TYPE_MAP = { + "TextQuestion": "StringAnswer", + "TextareaQuestion": "StringAnswer", + "IntegerQuestion": "IntegerAnswer", + "FloatQuestion": "FloatAnswer", + "MultipleChoiceQuestion": "ListAnswer", + "ChoiceQuestion": "StringAnswer", + "DynamicMultipleChoiceQuestion": "ListAnswer", + "DynamicChoiceQuestion": "StringAnswer", + "TableQuestion": "TableAnswer", + "FormQuestion": "FormAnswer", + "FileQuestion": "FileAnswer", + "StaticQuestion": None, + "DateQuestion": "DateAnswer", +} + + +def decode_id(string): + return base64.b64decode(string).decode("utf-8").split(":")[-1] + + +def answer_value_key(typename): + return typename.split("Answer")[0].lower() + "Value" + + +class Document: + def __init__(self, raw): + assert raw.get("__typename") == "Document", "raw must be a caluma `Document`" + + self.raw = raw + self.uuid = decode_id(self.raw["id"]) + self.pk = f"Document:{self.uuid}" + self.root_form = None + self.fieldsets = [] + + self._create_root_form() + self._create_fieldsets() + + def _create_root_form(self): + self.root_form = Form(self.raw["rootForm"]) + + def _create_fieldsets(self): + self.fieldsets = [ + Fieldset(self, {"form": form, "answers": self.raw.get("answers", [])}) + for form in self.raw.get("forms", []) + ] + + @property + def fields(self): + return [field for fields in self.fieldsets for field in fields] + + @property + def jexl(self): + raise NotImplementedError("JEXL is not supported by this library") + + def find_answer(self, question_slug): + # TODO + raise NotImplementedError + + def find_field(self, slug): + # TODO + raise NotImplementedError + + +class Fieldset: + def __init__(self, document, raw): + assert ( + "form" in raw and "answers" in raw + ), "Raw must contain `form` and `answers`" + + self.document = document + self.raw = raw + self.form = None + self.fields = [] + + self._create_form() + self._create_fields() + + def _create_form(self): + self.form = Form(self.raw.get("form")) + + def _create_fields(self): + def _find_answer(question): + return next( + ( + answer + for answer in self.raw["answers"] + if answer.get("question", {}).get("slug") == question.get("slug") + ), + None, + ) + + self.fields = [ + Field(self, {"question": question, "answer": _find_answer(question)}) + for question in self.raw["form"]["questions"] + ] + + @property + def field(self): + raise NotImplementedError + + +class Field: + def __init__(self, fieldset, raw): + assert "question" in raw, "Raw must contain Question" + + self.fieldset = fieldset + self.raw = raw + + self.question = Question(self.raw["question"]) + self.answer = None + self.options = None # TODO? + self._create_answer() + + def _create_answer(self): + if self.raw.get("answer"): + self.answer = Answer(self.raw["answer"]) + return + + question_type = self.raw["question"]["__typename"] + answer_type = ANSWER_TYPE_MAP.get(question_type) + if answer_type is None: + return + + value_key = answer_value_key(question_type) + raw = dict( + zip( + ("__typename", "question", value_key), + (answer_type, self.raw["question"].get("slug"), None), + ) + ) + self.answer = Answer(raw) + + +class Form: + def __init__(self, raw): + assert raw.get("__typename") == "Form", "raw must be a caluma `Form`" + + self.raw = raw + + @property + def uuid(self): + return decode_id(self.raw["id"]) if "id" in self.raw else None + + +class Question: + def __init__(self, raw): + assert raw.get("__typename").endswith( + "Question" + ), "raw must be a caluma `Question`" + self.raw = raw + # self.is_choice = raw["__typename"]. + + +class Answer: + def __init__(self, raw): + assert raw.get("__typename", "").endswith( + "Answer" + ), "raw must be a caluma `Answer`" + self.raw = raw + self.__typename = raw["__typename"] + self.value_key = answer_value_key(self.raw["__typename"]) + self._create_value() + + def _create_value(self): + value = self.raw.get(self.value_key) + + if self.__typename == "TableAnswer" and value: + self.value = [Document(parse_document(raw)) for raw in value] + else: + self.value = value + + @property + def uuid(self): + return decode_id(self.raw["id"]) if "id" in self.raw else None diff --git a/caluma_client/parser.py b/caluma_client/parser.py new file mode 100644 index 0000000..bc4b661 --- /dev/null +++ b/caluma_client/parser.py @@ -0,0 +1,44 @@ +def _unpack_dict(dictionary, levels): + if not levels: + return dictionary + current = levels[0] + try: + current = int(current) + except ValueError: + pass + return _unpack_dict(dictionary[current], levels[1:]) + + +def parse_document(response, nesting=""): + if nesting: + levels = nesting.split(".") + try: + response = _unpack_dict(response, levels) + except KeyError: + raise ValueError("Nesting does not match the given response") + + return { + **response, + "rootForm": parse_form(response["form"]), + "answers": [edge["node"] for edge in response["answers"]["edges"]], + "forms": parse_form_tree(response["form"]), + } + + +def parse_form(response): + return { + **response, + "questions": [edge["node"] for edge in response["questions"]["edges"]], + } + + +def parse_form_tree(response): + form = parse_form(response) + ret = [form] + + for question in form["questions"]: + subform = question.get("subForm") + if subform: + ret.extend(parse_form_tree(subform)) + + return ret diff --git a/caluma_client/tests/__init__.py b/caluma_client/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/caluma_client/tests/files/full_form_response.json b/caluma_client/tests/files/full_form_response.json new file mode 100644 index 0000000..fee3b8a --- /dev/null +++ b/caluma_client/tests/files/full_form_response.json @@ -0,0 +1,636 @@ +{ + "data": { + "allDocuments": { + "edges": [ + { + "node": { + "__typename": "Document", + "id": "RG9jdW1lbnQ6NDFjMGUxZjctNGM2Zi00YTllLTljMmEtN2RhZmRkYjhlNWEz", + "form": { + "__typename": "Form", + "slug": "test-form", + "name": "test-form", + "meta": {}, + "questions": { + "edges": [ + { + "node": { + "id": "Rm9ybVF1ZXN0aW9uOmYx", + "__typename": "FormQuestion", + "slug": "f1", + "label": "f1", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "subForm": { + "__typename": "Form", + "slug": "top-level-form-1", + "name": "top-level-form-1", + "questions": { + "edges": [ + { + "node": { + "__typename": "TextQuestion", + "slug": "text-question", + "label": "text question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "textMaxLength": null, + "placeholder": "" + } + }, + { + "node": { + "__typename": "TableQuestion", + "slug": "table-question", + "label": "table-question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "rowForm": { + "__typename": "Form", + "slug": "table-form", + "questions": { + "edges": [ + { + "node": { + "__typename": "TextQuestion", + "slug": "text-question", + "label": "text question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "textMaxLength": null, + "placeholder": "" + } + }, + { + "node": { + "__typename": "ChoiceQuestion", + "slug": "choice-question", + "label": "choice question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "choiceOptions": { + "edges": [ + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-1", + "label": "opt 1" + } + }, + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-2", + "label": "opt 2" + } + } + ] + } + } + } + ] + } + } + } + } + ] + } + } + } + }, + { + "node": { + "id": "Rm9ybVF1ZXN0aW9uOmYy", + "__typename": "FormQuestion", + "slug": "f2", + "label": "f2", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "subForm": { + "__typename": "Form", + "slug": "top-level-form-2", + "name": "top-level-form-2", + "questions": { + "edges": [ + { + "node": { + "__typename": "FloatQuestion", + "slug": "float-question", + "label": "float question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "floatMinValue": null, + "floatMaxValue": null, + "placeholder": "" + } + }, + { + "node": { + "__typename": "ChoiceQuestion", + "slug": "choice-question", + "label": "choice question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "choiceOptions": { + "edges": [ + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-1", + "label": "opt 1" + } + }, + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-2", + "label": "opt 2" + } + } + ] + } + } + }, + { + "node": { + "__typename": "MultipleChoiceQuestion", + "slug": "choices-question", + "label": "choices-question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "multipleChoiceOptions": { + "edges": [ + { + "node": { + "__typename": "Option", + "slug": "choices-question-opt1", + "label": "opt1" + } + }, + { + "node": { + "__typename": "Option", + "slug": "choices-question-opt2", + "label": "opt2" + } + } + ] + } + } + }, + { + "node": { + "__typename": "TextareaQuestion", + "slug": "textarea-question", + "label": "textarea question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "textareaMaxLength": null, + "placeholder": "" + } + }, + { + "node": { + "__typename": "FileQuestion", + "slug": "file-question", + "label": "file question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "" + } + }, + { + "node": { + "__typename": "IntegerQuestion", + "slug": "integer-question", + "label": "integer question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "integerMinValue": null, + "integerMaxValue": null, + "placeholder": "" + } + } + ] + } + } + } + } + ] + } + }, + "answers": { + "edges": [ + { + "node": { + "__typename": "StringAnswer", + "id": "U3RyaW5nQW5zd2VyOjI5YzhjNDk3LWQ0MjItNDAwZi1iOTA0LTI2OGUxNDVhMDNmNQ==", + "question": { + "slug": "choice-question", + "label": "choice question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "ChoiceQuestion", + "choiceOptions": { + "edges": [ + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-1", + "label": "opt 1" + } + }, + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-2", + "label": "opt 2" + } + } + ] + } + }, + "stringValue": "choice-question-opt-1" + } + }, + { + "node": { + "__typename": "ListAnswer", + "id": "TGlzdEFuc3dlcjo4NTc4MDA3NC1mMTZkLTRiNjItYmVhYy1kNTMyNWMzYTM0NzY=", + "question": { + "slug": "choices-question", + "label": "choices-question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "MultipleChoiceQuestion", + "multipleChoiceOptions": { + "edges": [ + { + "node": { + "__typename": "Option", + "slug": "choices-question-opt1", + "label": "opt1" + } + }, + { + "node": { + "__typename": "Option", + "slug": "choices-question-opt2", + "label": "opt2" + } + } + ] + } + }, + "listValue": [ + "choices-question-opt1", + "choices-question-opt2" + ] + } + }, + { + "node": { + "__typename": "FloatAnswer", + "id": "RmxvYXRBbnN3ZXI6MWNlZmMyMjAtMjZlMS00OTU0LWE0MTgtZDZmZGIwMTk3NThj", + "question": { + "slug": "float-question", + "label": "float question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "FloatQuestion", + "floatMinValue": null, + "floatMaxValue": null, + "placeholder": "" + }, + "floatValue": 0.123 + } + }, + { + "node": { + "__typename": "TableAnswer", + "id": "VGFibGVBbnN3ZXI6ZDgyZDAxMWMtZDJlMS00NzhmLTlkMGYtNmUwNjUyOWQzNzBi", + "question": { + "slug": "table-question", + "label": "table-question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "TableQuestion" + }, + "tableValue": [ + { + "__typename": "Document", + "id": "RG9jdW1lbnQ6Zjc4YTE3YmItYzQ1My00MjA1LTg4YTYtMzNhYWJmYzYxYjYx", + "form": { + "__typename": "Form", + "slug": "table-form", + "questions": { + "edges": [ + { + "node": { + "__typename": "TextQuestion", + "slug": "text-question", + "label": "text question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "textMaxLength": null, + "placeholder": "" + } + }, + { + "node": { + "__typename": "ChoiceQuestion", + "slug": "choice-question", + "label": "choice question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "choiceOptions": { + "edges": [ + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-1", + "label": "opt 1" + } + }, + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-2", + "label": "opt 2" + } + } + ] + } + } + } + ] + } + }, + "answers": { + "edges": [ + { + "node": { + "__typename": "StringAnswer", + "id": "U3RyaW5nQW5zd2VyOjJjNDlmOGU2LTVjODYtNDZjZC1hYmZmLTUxZjY0MGMyNDgxNQ==", + "question": { + "slug": "text-question", + "label": "text question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "TextQuestion", + "textMaxLength": null, + "placeholder": "" + }, + "stringValue": "fdafdsa" + } + }, + { + "node": { + "__typename": "StringAnswer", + "id": "U3RyaW5nQW5zd2VyOjI5M2E3YzMxLWM3ZDctNDliNS1hM2RhLTIyYzhhZDc5NWZiZA==", + "question": { + "slug": "choice-question", + "label": "choice question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "ChoiceQuestion", + "choiceOptions": { + "edges": [ + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-1", + "label": "opt 1" + } + }, + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-2", + "label": "opt 2" + } + } + ] + } + }, + "stringValue": "choice-question-opt-1" + } + } + ] + } + }, + { + "__typename": "Document", + "id": "RG9jdW1lbnQ6YWVkN2VhNmQtOGZkOC00YjA3LTgwNGUtMDE0ZjVjOGY3N2Ni", + "form": { + "__typename": "Form", + "slug": "table-form", + "questions": { + "edges": [ + { + "node": { + "__typename": "TextQuestion", + "slug": "text-question", + "label": "text question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "textMaxLength": null, + "placeholder": "" + } + }, + { + "node": { + "__typename": "ChoiceQuestion", + "slug": "choice-question", + "label": "choice question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "choiceOptions": { + "edges": [ + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-1", + "label": "opt 1" + } + }, + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-2", + "label": "opt 2" + } + } + ] + } + } + } + ] + } + }, + "answers": { + "edges": [ + { + "node": { + "__typename": "StringAnswer", + "id": "U3RyaW5nQW5zd2VyOmIxZTZjZjdjLTFiNDAtNDMxMS1hYjg0LWJjZmU5ZGZlNmNkZg==", + "question": { + "slug": "text-question", + "label": "text question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "TextQuestion", + "textMaxLength": null, + "placeholder": "" + }, + "stringValue": "feaswe" + } + }, + { + "node": { + "__typename": "StringAnswer", + "id": "U3RyaW5nQW5zd2VyOjI1YTM4MjdlLThkMGQtNDY5Mi1hNzM3LWM1ZDBlNDE1NDczMA==", + "question": { + "slug": "choice-question", + "label": "choice question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "ChoiceQuestion", + "choiceOptions": { + "edges": [ + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-1", + "label": "opt 1" + } + }, + { + "node": { + "__typename": "Option", + "slug": "choice-question-opt-2", + "label": "opt 2" + } + } + ] + } + }, + "stringValue": "choice-question-opt-1" + } + } + ] + } + } + ] + } + }, + { + "node": { + "__typename": "StringAnswer", + "id": "U3RyaW5nQW5zd2VyOmJmYTEzMDNlLWM4ZGItNDFiNC1iMTliLWRmYjk1NzA4MWY4OQ==", + "question": { + "slug": "text-question", + "label": "text question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "TextQuestion", + "textMaxLength": null, + "placeholder": "" + }, + "stringValue": "asdf" + } + }, + { + "node": { + "__typename": "StringAnswer", + "id": "U3RyaW5nQW5zd2VyOjFjODE1ODA5LTFlYzEtNGJkZi04N2ViLWFmYzk3OTkzNGZmYg==", + "question": { + "slug": "textarea-question", + "label": "textarea question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "TextareaQuestion", + "textareaMaxLength": null, + "placeholder": "" + }, + "stringValue": "fabawe wer \nbawed" + } + }, + { + "node": { + "__typename": "IntegerAnswer", + "id": "SW50ZWdlckFuc3dlcjo2M2FkODJlZS1lMzcxLTRhMWYtYWQ0MC05NzMwN2FmZDAxMTk=", + "question": { + "slug": "integer-question", + "label": "integer question", + "isRequired": "false", + "isHidden": "false", + "meta": {}, + "infoText": "", + "__typename": "IntegerQuestion", + "integerMinValue": null, + "integerMaxValue": null, + "placeholder": "" + }, + "integerValue": 123 + } + } + ] + } + } + } + ] + } + } +} diff --git a/caluma_client/tests/test_parser.py b/caluma_client/tests/test_parser.py new file mode 100644 index 0000000..8e0c1cb --- /dev/null +++ b/caluma_client/tests/test_parser.py @@ -0,0 +1,13 @@ +import json +from pathlib import Path + +from caluma_client.models import Document +from caluma_client.parser import parse_document + + +def test_parse_full_document(): + response_file = Path(__file__).parent / "files/full_form_response.json" + data = json.load(response_file.open()) + + raw = parse_document(data, nesting="data.allDocuments.edges.0.node") + Document(raw) diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..3347cb9 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +module.exports = {extends: ['@commitlint/config-conventional']}; diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2db5407 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,47 @@ +[flake8] +ignore = + # whitespace before ':' + E203, + # too many leading ### in a block comment + E266, + # line too long (managed by black) + E501, + # Line break occurred before a binary operator (this is not PEP8 compatible) + W503, + # do not enforce existence of docstrings + D100, + D101, + D102, + D103, + D104, + D105, + D106, + D107, + # needed because of https://github.com/ambv/black/issues/144 + D202, +max-line-length = 88 +max-complexity = 10 +doctests = True + +[tool:isort] +known_first_party=caluma_client +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +combine_as_imports=True +line_length=88 + +[coverage:run] +source=. + +[coverage:report] +fail_under=100 +exclude_lines = + pragma: no cover + pragma: todo cover + def __str__ + def __unicode__ + def __repr__ +omit= + setup.py +show_missing = True diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1afe024 --- /dev/null +++ b/setup.py @@ -0,0 +1,15 @@ +from setuptools import find_packages, setup + +with open("README.md", "r") as fh: + long_description = fh.read() + +setup( + name="caluma-client", + version="0.0.1", + description="Client library for Caluma", + long_description=long_description, + long_description_content_type="text/markup", + url="https://projectcaluma.github.io/", + install_requires=["requests"], + packages=find_packages(), +) From 19e1c127969e9b520508de590b58dc39569af43e Mon Sep 17 00:00:00 2001 From: Stefan Borer Date: Tue, 17 Dec 2019 11:19:41 +0100 Subject: [PATCH 2/2] refactor: address code review feedback Make most fields lazy properties and get rid of the using `self._create_xxx` antipattern in constructors. --- caluma_client/models.py | 190 +++++++++++++++++++--------------------- 1 file changed, 91 insertions(+), 99 deletions(-) diff --git a/caluma_client/models.py b/caluma_client/models.py index 3275ed2..9456e23 100644 --- a/caluma_client/models.py +++ b/caluma_client/models.py @@ -12,7 +12,7 @@ "DynamicMultipleChoiceQuestion": "ListAnswer", "DynamicChoiceQuestion": "StringAnswer", "TableQuestion": "TableAnswer", - "FormQuestion": "FormAnswer", + "FormQuestion": None, "FileQuestion": "FileAnswer", "StaticQuestion": None, "DateQuestion": "DateAnswer", @@ -20,6 +20,8 @@ def decode_id(string): + if not string: + return None return base64.b64decode(string).decode("utf-8").split(":")[-1] @@ -27,81 +29,48 @@ def answer_value_key(typename): return typename.split("Answer")[0].lower() + "Value" -class Document: - def __init__(self, raw): - assert raw.get("__typename") == "Document", "raw must be a caluma `Document`" - - self.raw = raw - self.uuid = decode_id(self.raw["id"]) - self.pk = f"Document:{self.uuid}" - self.root_form = None - self.fieldsets = [] - - self._create_root_form() - self._create_fieldsets() - - def _create_root_form(self): - self.root_form = Form(self.raw["rootForm"]) - - def _create_fieldsets(self): - self.fieldsets = [ - Fieldset(self, {"form": form, "answers": self.raw.get("answers", [])}) - for form in self.raw.get("forms", []) - ] - +def make_property(constructor, key): @property - def fields(self): - return [field for fields in self.fieldsets for field in fields] + def getter(self): + return constructor(self.raw.get(key)) - @property - def jexl(self): - raise NotImplementedError("JEXL is not supported by this library") + return getter - def find_answer(self, question_slug): - # TODO - raise NotImplementedError - def find_field(self, slug): - # TODO - raise NotImplementedError +class Form: + def __init__(self, raw): + assert raw.get("__typename") == "Form", "raw must be a caluma `Form`" + self.raw = raw + uuid = make_property(decode_id, "id") -class Fieldset: - def __init__(self, document, raw): - assert ( - "form" in raw and "answers" in raw - ), "Raw must contain `form` and `answers`" - self.document = document +class Question: + def __init__(self, raw): + assert raw.get("__typename").endswith( + "Question" + ), "raw must be a caluma `Question`" self.raw = raw - self.form = None - self.fields = [] - - self._create_form() - self._create_fields() - def _create_form(self): - self.form = Form(self.raw.get("form")) - def _create_fields(self): - def _find_answer(question): - return next( - ( - answer - for answer in self.raw["answers"] - if answer.get("question", {}).get("slug") == question.get("slug") - ), - None, - ) +class Answer: + def __init__(self, raw): + assert raw.get("__typename", "").endswith( + "Answer" + ), "raw must be a caluma `Answer`" + self.raw = raw - self.fields = [ - Field(self, {"question": question, "answer": _find_answer(question)}) - for question in self.raw["form"]["questions"] - ] + uuid = make_property(decode_id, "id") + value_key = make_property(answer_value_key, "__typename") @property - def field(self): - raise NotImplementedError + def value(self): + value = self.raw.get(self.value_key) + + if self.raw["__typename"] == "TableAnswer" and value: + return [Document(parse_document(raw)) for raw in value] + else: + return value class Field: @@ -111,20 +80,19 @@ def __init__(self, fieldset, raw): self.fieldset = fieldset self.raw = raw - self.question = Question(self.raw["question"]) - self.answer = None self.options = None # TODO? - self._create_answer() - def _create_answer(self): + question = make_property(Question, "question") + + @property + def answer(self): if self.raw.get("answer"): - self.answer = Answer(self.raw["answer"]) - return + return Answer(self.raw["answer"]) question_type = self.raw["question"]["__typename"] answer_type = ANSWER_TYPE_MAP.get(question_type) if answer_type is None: - return + return None value_key = answer_value_key(question_type) raw = dict( @@ -133,47 +101,71 @@ def _create_answer(self): (answer_type, self.raw["question"].get("slug"), None), ) ) - self.answer = Answer(raw) + return Answer(raw) -class Form: +class Document: def __init__(self, raw): - assert raw.get("__typename") == "Form", "raw must be a caluma `Form`" - + assert raw.get("__typename") == "Document", "raw must be a caluma `Document`" self.raw = raw + uuid = make_property(decode_id, "id") + root_form = make_property(Form, "rootForm") + @property - def uuid(self): - return decode_id(self.raw["id"]) if "id" in self.raw else None + def pk(self): + return f"Document:{self.uuid}" + @property + def fieldsets(self): + return [ + Fieldset(self, {"form": form, "answers": self.raw.get("answers", [])}) + for form in self.raw.get("forms", []) + ] -class Question: - def __init__(self, raw): - assert raw.get("__typename").endswith( - "Question" - ), "raw must be a caluma `Question`" - self.raw = raw - # self.is_choice = raw["__typename"]. + @property + def fields(self): + return [field for fields in self.fieldsets for field in fields] + @property + def jexl(self): + raise NotImplementedError("JEXL is not supported by this library") + + def find_answer(self, question_slug): + raise NotImplementedError + + def find_field(self, slug): + raise NotImplementedError + + +class Fieldset: + def __init__(self, document, raw): + assert ( + "form" in raw and "answers" in raw + ), "Raw must contain `form` and `answers`" -class Answer: - def __init__(self, raw): - assert raw.get("__typename", "").endswith( - "Answer" - ), "raw must be a caluma `Answer`" self.raw = raw - self.__typename = raw["__typename"] - self.value_key = answer_value_key(self.raw["__typename"]) - self._create_value() + self.document = document - def _create_value(self): - value = self.raw.get(self.value_key) + form = make_property(Form, "form") - if self.__typename == "TableAnswer" and value: - self.value = [Document(parse_document(raw)) for raw in value] - else: - self.value = value + @property + def fields(self): + def _find_answer(question): + return next( + ( + answer + for answer in self.raw["answers"] + if answer.get("question", {}).get("slug") == question.get("slug") + ), + None, + ) + + return [ + Field(self, {"question": question, "answer": _find_answer(question)}) + for question in self.raw["form"]["questions"] + ] @property - def uuid(self): - return decode_id(self.raw["id"]) if "id" in self.raw else None + def field(self): + raise NotImplementedError