diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 9f3c25492..c065c5237 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -7,7 +7,7 @@ on: jobs: tests: - runs-on: ubuntu-latest + runs-on: ubuntu-latest-4-cores strategy: matrix: browser: [ "chromium", "firefox", "webkit" ] diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 28af82298..4dbe22ade 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -11,7 +11,7 @@ on: jobs: build: - runs-on: macos-13 + runs-on: macos-latest-large strategy: matrix: python-version: [ "3.9", "3.10", "3.11", "3.12" ] diff --git a/apps/default/.wf/components-workflows_workflow-0-lfltcky7l1fsm6j2.jsonl b/apps/default/.wf/components-workflows_workflow-0-lfltcky7l1fsm6j2.jsonl new file mode 100644 index 000000000..ae4e87d67 --- /dev/null +++ b/apps/default/.wf/components-workflows_workflow-0-lfltcky7l1fsm6j2.jsonl @@ -0,0 +1 @@ +{"id": "lfltcky7l1fsm6j2", "type": "workflows_workflow", "content": {}, "handlers": {}, "parentId": "workflows_root", "position": 0} diff --git a/apps/default/.wf/metadata.json b/apps/default/.wf/metadata.json index e47807980..0874722e9 100644 --- a/apps/default/.wf/metadata.json +++ b/apps/default/.wf/metadata.json @@ -1,3 +1,3 @@ { - "writer_version": "0.8.0rc1" + "writer_version": "0.8.1" } \ No newline at end of file diff --git a/apps/hello/.wf/components-page-0-bb4d0e86-619e-4367-a180-be28ab6059f4.jsonl b/apps/hello/.wf/components-page-0-bb4d0e86-619e-4367-a180-be28ab6059f4.jsonl index 2a569b24f..d4e90d3b5 100644 --- a/apps/hello/.wf/components-page-0-bb4d0e86-619e-4367-a180-be28ab6059f4.jsonl +++ b/apps/hello/.wf/components-page-0-bb4d0e86-619e-4367-a180-be28ab6059f4.jsonl @@ -5,11 +5,11 @@ {"id": "385247e5-5c89-4352-a598-b8da81146a5a", "type": "sliderinput", "binding": {"eventType": "wf-number-change", "stateRef": "filter.min_weight"}, "content": {"label": "Minimum weight", "maxValue": "600", "minValue": "300", "stepSize": "1"}, "handlers": {"wf-number-change": "update"}, "isCodeManaged": false, "parentId": "7e625201-20c2-4b05-951c-d825de28b216", "position": 1} {"id": "70d82458-a08f-4005-8f96-dc8d3ba92fad", "type": "section", "content": {"title": "About this app"}, "isCodeManaged": false, "parentId": "fbad9feb-5c88-4425-bb17-0d138286a875", "position": 1} {"id": "12e11005-3b5e-4bd8-9a8c-fc7b8cb757d0", "type": "text", "content": {"text": "This app is meant to serve as a lighthearted introduction to the framework. It's not a comprehensive demonstration of its capabilities."}, "isCodeManaged": false, "parentId": "70d82458-a08f-4005-8f96-dc8d3ba92fad", "position": 0} -{"id": "84378aea-b64c-49a3-9539-f854532279ee", "type": "header", "content": {"accentColor": "#ffffff", "emptinessColor": "#000000", "text": "HACKER PIGEONS"}, "isCodeManaged": false, "parentId": "bb4d0e86-619e-4367-a180-be28ab6059f4", "position": 0} +{"id": "84378aea-b64c-49a3-9539-f854532279ee", "type": "header", "content": {"accentColor": "#ffffff", "emptinessColor": "#000000", "text": "Hacker Pigeons"}, "isCodeManaged": false, "parentId": "bb4d0e86-619e-4367-a180-be28ab6059f4", "position": 0} {"id": "92a2c0c8-7ab4-4865-b7eb-ed437408c8f5", "type": "columns", "content": {}, "isCodeManaged": false, "parentId": "bb4d0e86-619e-4367-a180-be28ab6059f4", "position": 1} -{"id": "d1e01ce1-fab1-4a6e-91a1-1f45f9e57aa5", "type": "column", "content": {"isCollapsible": "", "isSticky": "yes", "title": "", "width": "1"}, "isCodeManaged": false, "parentId": "92a2c0c8-7ab4-4865-b7eb-ed437408c8f5", "position": 0} +{"id": "d1e01ce1-fab1-4a6e-91a1-1f45f9e57aa5", "type": "column", "content": {"isCollapsible": "", "isSticky": "yes", "title": "", "width": "0.8"}, "isCodeManaged": false, "parentId": "92a2c0c8-7ab4-4865-b7eb-ed437408c8f5", "position": 0} {"id": "9c30af6d-4ee5-4782-9169-0f361d67fa76", "type": "section", "content": {"title": ""}, "isCodeManaged": false, "parentId": "d1e01ce1-fab1-4a6e-91a1-1f45f9e57aa5", "position": 0} -{"id": "7fdd1d02-53de-4466-bd3c-4822cbc2694f", "type": "image", "content": {"caption": "", "src": "static/pigeon1.jpg?8"}, "isCodeManaged": false, "parentId": "9c30af6d-4ee5-4782-9169-0f361d67fa76", "position": 0} +{"id": "7fdd1d02-53de-4466-bd3c-4822cbc2694f", "type": "image", "content": {"caption": "", "src": "static/pigeon1.jpg?9"}, "isCodeManaged": false, "parentId": "9c30af6d-4ee5-4782-9169-0f361d67fa76", "position": 0} {"id": "31c1b0d5-bfb6-4304-82bd-1687d492f0a2", "type": "heading", "content": {"alignment": "", "text": "Pigeon Power: Birds Tackle Data Apps"}, "isCodeManaged": false, "parentId": "9c30af6d-4ee5-4782-9169-0f361d67fa76", "position": 1} {"id": "b27lw9ex8ig3x17p", "type": "tags", "content": {"seed": "", "tags": "{\n \"fiction\": \"fiction\",\n \"inspirational\": \"inspirational\",\n \"ai-generated\": \"ai-generated\"\n}"}, "isCodeManaged": false, "parentId": "9c30af6d-4ee5-4782-9169-0f361d67fa76", "position": 2, "visible": {"binding": "", "expression": true, "reversed": false}} {"id": "804e15bf-11a7-463d-8082-f46ea3acac1b", "type": "separator", "content": {}, "isCodeManaged": false, "parentId": "9c30af6d-4ee5-4782-9169-0f361d67fa76", "position": 3} @@ -44,10 +44,10 @@ {"id": "573f095f-94a7-43e4-a94e-b2f69439a164", "type": "separator", "content": {}, "isCodeManaged": false, "parentId": "fb22acfc-cdb5-44b6-9e97-76c3a51a8fff", "position": 1} {"id": "b1ee642e-f2e7-453b-a6ef-3d96eea37140", "type": "column", "content": {"contentHAlign": "center", "isCollapsible": "", "startCollapsed": "", "title": "HTML Element", "width": "1"}, "isCodeManaged": false, "parentId": "fb22acfc-cdb5-44b6-9e97-76c3a51a8fff", "position": 2} {"id": "71766c0c-e1e5-4675-9dd0-3aa2627773a0", "type": "html", "content": {"styles": "{\n \"padding\": \"16px\",\n \"margin\": \"24px\",\n \"min-height\": \"64px\",\n \"min-width\": \"64px\",\n \"max-width\": \"90%\",\n \"border-radius\": \"8px\",\n \"transform\": \"rotate(-3deg)\",\n \"box-shadow\": \"0 4px 16px -8px black\"\n}"}, "isCodeManaged": false, "parentId": "b1ee642e-f2e7-453b-a6ef-3d96eea37140", "position": 0} -{"id": "c921816d-6d45-4ce3-9c18-2c78ff850e0e", "type": "html", "content": {"attrs": "{ \"src\": \"static/pigeon1.jpg?3\"}", "element": "img", "styles": "{\n \"filter\": \"hue-rotate(calc(80deg + @{hue_rotation}deg))\"\n}"}, "isCodeManaged": false, "parentId": "71766c0c-e1e5-4675-9dd0-3aa2627773a0", "position": 0} +{"id": "c921816d-6d45-4ce3-9c18-2c78ff850e0e", "type": "html", "content": {"attrs": "{ \"src\": \"static/pigeon1.jpg?9\"}", "element": "img", "styles": "{\n \"filter\": \"hue-rotate(calc(80deg + @{hue_rotation}deg))\"\n}"}, "isCodeManaged": false, "parentId": "71766c0c-e1e5-4675-9dd0-3aa2627773a0", "position": 0} {"id": "c73602a6-453d-4ccf-b8e3-b1774ab4ff17", "type": "text", "content": {"text": "Use the HTML Element component when you need additional control."}, "isCodeManaged": false, "parentId": "71766c0c-e1e5-4675-9dd0-3aa2627773a0", "position": 1} {"id": "a36b75bc-58e6-48ba-bdef-0824e6b21e8d", "type": "html", "content": {"styles": "{\n \"padding\": \"16px\",\n \"margin\": \"24px\",\n \"min-height\": \"64px\",\n \"min-width\": \"64px\",\n \"max-width\": \"90%\",\n \"border-radius\": \"8px\",\n \"transform\": \"rotate(3deg)\",\n \"box-shadow\": \"0 4px 16px -8px black\"\n}"}, "isCodeManaged": false, "parentId": "b1ee642e-f2e7-453b-a6ef-3d96eea37140", "position": 1} -{"id": "c684f61e-0c79-4cb1-af9f-46c9cab5dfea", "type": "html", "content": {"attrs": "{ \"src\": \"static/pigeon1.jpg?3\"}", "element": "img", "styles": "{\n \"filter\": \"hue-rotate(calc(140deg + @{hue_rotation}deg))\"\n}"}, "isCodeManaged": false, "parentId": "a36b75bc-58e6-48ba-bdef-0824e6b21e8d", "position": 0} +{"id": "c684f61e-0c79-4cb1-af9f-46c9cab5dfea", "type": "html", "content": {"attrs": "{ \"src\": \"static/pigeon1.jpg?9\"}", "element": "img", "styles": "{\n \"filter\": \"hue-rotate(calc(140deg + @{hue_rotation}deg))\"\n}"}, "isCodeManaged": false, "parentId": "a36b75bc-58e6-48ba-bdef-0824e6b21e8d", "position": 0} {"id": "5da5e007-d60a-4313-9d21-885deae7b37d", "type": "text", "content": {"text": "You can put other Writer Framework components inside HTML Elements."}, "isCodeManaged": false, "parentId": "a36b75bc-58e6-48ba-bdef-0824e6b21e8d", "position": 1} {"id": "ee82e035-cfb2-4d00-95ce-ccbb9eb2dbb9", "type": "sliderinput", "binding": {"eventType": "wf-number-change", "stateRef": "hue_rotation"}, "content": {"label": "Hue rotation", "maxValue": "360", "minValue": "0", "stepSize": "1"}, "isCodeManaged": false, "parentId": "b1ee642e-f2e7-453b-a6ef-3d96eea37140", "position": 2} {"id": "c9bb4720-d07a-4fd8-bc53-5bda8dc64046", "type": "text", "content": {"text": "As shown above, you can use _HTML Element_ components together with state references.", "useMarkdown": "yes"}, "isCodeManaged": false, "parentId": "b1ee642e-f2e7-453b-a6ef-3d96eea37140", "position": 3} @@ -74,5 +74,5 @@ {"id": "e296866a-75d2-4677-b55d-3c1456113b89", "type": "text", "content": {"text": "Refreshing automatically using a timer."}, "isCodeManaged": false, "parentId": "09ddb2da-6fa3-4157-8da3-4d5d44a6a58d", "position": 1} {"id": "db4c66d6-1eb7-44d3-a2d4-65d0b3e5cf12", "type": "dataframe", "content": {"dataframe": "@{random_df}", "enableDownload": "", "enableSearch": "", "fontStyle": "monospace"}, "isCodeManaged": false, "parentId": "85120b55-69c6-4b50-853a-bbbf73ff8121", "position": 1} {"id": "fdf38e46-c01e-4a93-94d5-e187f9e4c823", "type": "text", "content": {"primaryTextColor": "#8a8a8a", "text": "_pgcf_ stands for \"Pigeon Coefficient\" and is a meaningless, randomly-generated value.", "useMarkdown": "yes"}, "isCodeManaged": false, "parentId": "85120b55-69c6-4b50-853a-bbbf73ff8121", "position": 2} -{"id": "e2c46zr4072th36z", "type": "tab", "content": {"name": "Dataframe"}, "handlers": {}, "isCodeManaged": false, "parentId": "ee919cd6-8153-4f34-8c6a-bfc1153df360", "position": 6} +{"id": "e2c46zr4072th36z", "type": "tab", "content": {"name": "Dataframe"}, "handlers": {}, "isCodeManaged": false, "parentId": "ee919cd6-8153-4f34-8c6a-bfc1153df360", "position": 5} {"id": "qelh30k75scw87ma", "type": "dataframe", "content": {"dataframe": "@{editable_df}", "enableRecordAdd": "yes", "enableRecordUpdate": "yes"}, "handlers": {}, "isCodeManaged": false, "parentId": "e2c46zr4072th36z", "position": 0} diff --git a/apps/hello/.wf/components-page-1-23bc1387-26ed-4ff2-8565-b027c2960c3c.jsonl b/apps/hello/.wf/components-page-1-23bc1387-26ed-4ff2-8565-b027c2960c3c.jsonl index ef4db36ac..dc4639284 100644 --- a/apps/hello/.wf/components-page-1-23bc1387-26ed-4ff2-8565-b027c2960c3c.jsonl +++ b/apps/hello/.wf/components-page-1-23bc1387-26ed-4ff2-8565-b027c2960c3c.jsonl @@ -1,5 +1,5 @@ {"id": "23bc1387-26ed-4ff2-8565-b027c2960c3c", "type": "page", "content": {"buttonColor": "#242424", "buttonTextColor": "#ffffff", "containerBackgroundColor": "#4F4F4F", "emptinessColor": "#333333", "key": "story", "primaryTextColor": "#ffffff", "separatorColor": "rgba(0, 0, 0, 0.2)"}, "handlers": {}, "isCodeManaged": false, "parentId": "root", "position": 1, "visible": {"binding": "", "expression": true, "reversed": false}} -{"id": "1d195388-35a3-43e1-b825-1d263b100a28", "type": "header", "content": {"text": "HACKER PIGEONS"}, "handlers": {}, "isCodeManaged": false, "parentId": "23bc1387-26ed-4ff2-8565-b027c2960c3c", "position": 0, "visible": {"binding": "", "expression": true, "reversed": false}} +{"id": "qvxrj45yilce889c", "type": "reuse", "content": {"proxyId": "84378aea-b64c-49a3-9539-f854532279ee"}, "handlers": {}, "isCodeManaged": false, "parentId": "23bc1387-26ed-4ff2-8565-b027c2960c3c", "position": 0} {"id": "8fe33adf-a5ea-4c7a-8d1d-59cc4dc14f05", "type": "columns", "content": {}, "handlers": {}, "isCodeManaged": false, "parentId": "23bc1387-26ed-4ff2-8565-b027c2960c3c", "position": 1, "visible": {"binding": "", "expression": true, "reversed": false}} {"id": "dfaae7f9-db20-4f70-a376-919bdc7b6010", "type": "column", "content": {"width": "1"}, "handlers": {}, "isCodeManaged": false, "parentId": "8fe33adf-a5ea-4c7a-8d1d-59cc4dc14f05", "position": 0, "visible": {"binding": "", "expression": true, "reversed": false}} {"id": "771dc336-69b2-400e-9ea3-e881e2332c9d", "type": "section", "content": {"title": "The story"}, "handlers": {}, "isCodeManaged": false, "parentId": "dfaae7f9-db20-4f70-a376-919bdc7b6010", "position": 0, "visible": {"binding": "", "expression": true, "reversed": false}} @@ -9,7 +9,7 @@ {"id": "90fbfa9d-3178-4fc2-b445-c31e1acfa6a7", "type": "button", "content": {"icon": "arrow_back", "text": "Go back to the main page"}, "handlers": {"click": "$goToPage_main"}, "isCodeManaged": false, "parentId": "9bb8a686-7013-4af7-a89e-d89c7754120d", "position": 0, "visible": {"binding": "", "expression": true, "reversed": false}} {"id": "ed010441-0cac-4aa5-9e6f-97228e0c3536", "type": "button", "content": {"icon": "download", "text": "Download this story"}, "handlers": {"click": "handle_story_download"}, "isCodeManaged": false, "parentId": "9bb8a686-7013-4af7-a89e-d89c7754120d", "position": 1, "visible": {"binding": "", "expression": true, "reversed": false}} {"id": "7402263c-cb8b-412d-b170-e6dc6ffcb706", "type": "column", "content": {"width": "1"}, "handlers": {}, "isCodeManaged": false, "parentId": "8fe33adf-a5ea-4c7a-8d1d-59cc4dc14f05", "position": 1, "visible": {"binding": "", "expression": true, "reversed": false}} -{"id": "5bc38721-8b48-43d5-a454-ee5ebe713a4c", "type": "section", "content": {"title": ""}, "isCodeManaged": false, "parentId": "7402263c-cb8b-412d-b170-e6dc6ffcb706", "position": 0} +{"id": "5bc38721-8b48-43d5-a454-ee5ebe713a4c", "type": "section", "content": {"containerBackgroundColor": "#ffffff", "title": ""}, "isCodeManaged": false, "parentId": "7402263c-cb8b-412d-b170-e6dc6ffcb706", "position": 0} {"id": "42ab5c3d-21fc-4e88-befd-33e52fd15e8b", "type": "image", "content": {"caption": "", "src": "static/pigeon1.jpg"}, "isCodeManaged": false, "parentId": "5bc38721-8b48-43d5-a454-ee5ebe713a4c", "position": 0} -{"id": "2df56a4b-b6e7-423d-a7a1-5d23c77f65fa", "type": "heading", "content": {"alignment": "", "text": "Each page can have its own style"}, "isCodeManaged": false, "parentId": "5bc38721-8b48-43d5-a454-ee5ebe713a4c", "position": 1} -{"id": "77cb256b-ef12-4a55-a051-500497f41302", "type": "text", "content": {"text": "It's easy to switch between pages and it can be done from the frontend (via Writer Framework Builder) and from the backend (via Python)."}, "handlers": {}, "isCodeManaged": false, "parentId": "5bc38721-8b48-43d5-a454-ee5ebe713a4c", "position": 2, "visible": {"binding": "", "expression": true, "reversed": false}} +{"id": "2df56a4b-b6e7-423d-a7a1-5d23c77f65fa", "type": "heading", "content": {"alignment": "", "primaryTextColor": "#486778", "text": "Each page can have its own style"}, "isCodeManaged": false, "parentId": "5bc38721-8b48-43d5-a454-ee5ebe713a4c", "position": 1} +{"id": "77cb256b-ef12-4a55-a051-500497f41302", "type": "text", "content": {"primaryTextColor": "#1e2a31", "text": "It's easy to switch between pages and it can be done from the frontend (via Writer Framework Builder) and from the backend (via Python)."}, "handlers": {}, "isCodeManaged": false, "parentId": "5bc38721-8b48-43d5-a454-ee5ebe713a4c", "position": 2, "visible": {"binding": "", "expression": true, "reversed": false}} diff --git a/apps/hello/.wf/components-page-2-w0pur56ujeiv3s5v.jsonl b/apps/hello/.wf/components-page-2-w0pur56ujeiv3s5v.jsonl new file mode 100644 index 000000000..7e68496f8 --- /dev/null +++ b/apps/hello/.wf/components-page-2-w0pur56ujeiv3s5v.jsonl @@ -0,0 +1,7 @@ +{"id": "w0pur56ujeiv3s5v", "type": "page", "content": {}, "handlers": {}, "parentId": "root", "position": 2} +{"id": "tj5rln4r1dxr4t9y", "type": "section", "content": {"isCollapsible": "yes", "title": "Section Title"}, "handlers": {}, "parentId": "w0pur56ujeiv3s5v", "position": 0} +{"id": "1h2u1o63xfedkjgu", "type": "text", "content": {"text": "Text"}, "handlers": {}, "parentId": "tj5rln4r1dxr4t9y", "position": 0} +{"id": "o76ltlj9uym0f3p3", "type": "section", "content": {"isCollapsible": "yes", "startCollapsed": "yes", "title": "Section Title"}, "handlers": {}, "parentId": "w0pur56ujeiv3s5v", "position": 1} +{"id": "44o2mezeegspifl3", "type": "text", "content": {"text": "Text"}, "handlers": {}, "parentId": "o76ltlj9uym0f3p3", "position": 0} +{"id": "4go1gvv5s6pus80p", "type": "section", "content": {"isCollapsible": "yes", "title": "Section Title"}, "handlers": {}, "parentId": "w0pur56ujeiv3s5v", "position": 2} +{"id": "nwmweasodpa9sr9w", "type": "text", "content": {"text": "Text"}, "handlers": {}, "parentId": "4go1gvv5s6pus80p", "position": 0} diff --git a/apps/hello/.wf/metadata.json b/apps/hello/.wf/metadata.json index e47807980..0874722e9 100644 --- a/apps/hello/.wf/metadata.json +++ b/apps/hello/.wf/metadata.json @@ -1,3 +1,3 @@ { - "writer_version": "0.8.0rc1" + "writer_version": "0.8.1" } \ No newline at end of file diff --git a/apps/hello/main.py b/apps/hello/main.py index d97a965a9..40462ed07 100644 --- a/apps/hello/main.py +++ b/apps/hello/main.py @@ -170,7 +170,7 @@ def on_editable_df_record_action(state, payload): "editable_df_open_text": "", "highlighted_members": _get_highlighted_members(), "random_df": _generate_random_df(), - "hue_rotation": 26, + "hue_rotation": 180, "paginated_members": _get_paginated_members(0, 2), "paginated_members_page": 1, "paginated_members_total_items": len(_get_main_df()), diff --git a/apps/hello/static/pigeon1.jpg b/apps/hello/static/pigeon1.jpg index efa6ea399..9b0fcd60b 100644 Binary files a/apps/hello/static/pigeon1.jpg and b/apps/hello/static/pigeon1.jpg differ diff --git a/pyproject.toml b/pyproject.toml index 32fe21611..dff683345 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "writer" -version = "0.8.1" +version = "0.8.2" description = "An open-source, Python framework for building feature-rich apps that are fully integrated with the Writer platform." authors = ["Writer, Inc."] readme = "README.md" diff --git a/src/ui/public/components/annotatedtext.svg b/src/ui/public/components/annotatedtext.svg new file mode 100644 index 000000000..0f3e348a3 --- /dev/null +++ b/src/ui/public/components/annotatedtext.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/public/components/colorinput.svg b/src/ui/public/components/colorinput.svg new file mode 100644 index 000000000..0a63138f9 --- /dev/null +++ b/src/ui/public/components/colorinput.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/ui/public/components/jsonviewer.svg b/src/ui/public/components/jsonviewer.svg new file mode 100644 index 000000000..eb9c6db4e --- /dev/null +++ b/src/ui/public/components/jsonviewer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/public/components/rangeinput.svg b/src/ui/public/components/rangeinput.svg new file mode 100644 index 000000000..9d4c5febd --- /dev/null +++ b/src/ui/public/components/rangeinput.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/public/components/timeinput.svg b/src/ui/public/components/timeinput.svg new file mode 100644 index 000000000..032812a08 --- /dev/null +++ b/src/ui/public/components/timeinput.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/src/builder/settings/BuilderSettingsHandlers.vue b/src/ui/src/builder/settings/BuilderSettingsHandlers.vue index 54f6cd71b..96d74fc6f 100644 --- a/src/ui/src/builder/settings/BuilderSettingsHandlers.vue +++ b/src/ui/src/builder/settings/BuilderSettingsHandlers.vue @@ -162,18 +162,16 @@ const options = computed(() => { .map((v) => v.name) .map((v) => ({ value: v, label: v, icon: "function" })); - const pageKeyOptions: Option[] = pageKeys.value - .map((v) => `$goToPage_${v}`) - .map((v) => ({ - icon: "link", - value: v, - label: v, - })); + const pageKeyOptions: Option[] = pageKeys.value.map((v) => ({ + label: `Go to page "${v}"`, + value: `$goToPage_${v}`, + icon: "link", + })); const workflowKeyOptions: Option[] = workflowKeys.value.map((v) => ({ - label: `$runWorkflow_${v}`, - value: `Run workflow "${v}"`, - icon: "move_down", + label: `Run workflow "${v}"`, + value: `$runWorkflow_${v}`, + icon: "linked_services", })); return [ diff --git a/src/ui/src/builder/useComponentActions.ts b/src/ui/src/builder/useComponentActions.ts index a7382bbde..c3c863d66 100644 --- a/src/ui/src/builder/useComponentActions.ts +++ b/src/ui/src/builder/useComponentActions.ts @@ -496,6 +496,10 @@ export function useComponentActions(wf: Core, ssbm: BuilderManager) { }); }); c.id = newId; + if (typeof c.x !== "undefined" && typeof c.y !== "undefined") { + c.x += 36; + c.y += 36; + } }); return deepCopiedSubtree; } diff --git a/src/ui/src/components/core/other/CoreReuse.vue b/src/ui/src/components/core/other/CoreReuse.vue index af43e616d..88a974b45 100644 --- a/src/ui/src/components/core/other/CoreReuse.vue +++ b/src/ui/src/components/core/other/CoreReuse.vue @@ -51,7 +51,12 @@ function renderError(message: string, cls: string) { shouldRender.value = props.contextSlot === "default"; vnode.value = h( "div", - { class: ["CoreReuse", cls] }, + { + class: ["CoreReuse", cls], + style: { + color: "var(--primaryTextColor)", + }, + }, isBeingEdited.value ? message : "", ); } diff --git a/src/writer/ai.py b/src/writer/ai.py index e3bc23a27..c86bf1753 100644 --- a/src/writer/ai.py +++ b/src/writer/ai.py @@ -41,6 +41,8 @@ from writerai.types.chat_chat_params import MessageGraphData from writerai.types.chat_chat_params import ToolFunctionTool as SDKFunctionTool from writerai.types.chat_chat_params import ToolGraphTool as SDKGraphTool +from writerai.types.question import Question +from writerai.types.question_response_chunk import QuestionResponseChunk from writer.core import get_app_process @@ -288,6 +290,10 @@ def _retrieve_graphs_accessor() -> GraphsResource: """ return WriterAIManager.acquire_client().graphs + @property + def is_stale(self): + return self.id in Graph.stale_ids + @property def id(self) -> str: return self._get_property('id') @@ -296,14 +302,18 @@ def id(self) -> str: def created_at(self) -> datetime: return self._get_property('created_at') - def _fetch_object_updates(self): + def _fetch_object_updates(self, force=False): """ Fetches updates for the graph object if it is stale. """ - if self.id in Graph.stale_ids: + def _get_fresh_object(): graphs = self._retrieve_graphs_accessor() fresh_object = graphs.retrieve(self.id) self._wrapped = fresh_object + + if self.is_stale or force is True: + _get_fresh_object() + if self.is_stale: Graph.stale_ids.remove(self.id) @property @@ -318,7 +328,7 @@ def description(self) -> Optional[str]: @property def file_status(self): - self._fetch_object_updates() + self._fetch_object_updates(force=True) return self._wrapped.file_status def update( @@ -452,11 +462,134 @@ def remove_file( graphs = self._retrieve_graphs_accessor() response = graphs.remove_file_from_graph( graph_id=self.id, - file_id=file_id + file_id=file_id, + **config ) Graph.stale_ids.add(self.id) return response + def _question( + self, + question: str, + stream: bool = True, + subqueries: bool = False, + config: Optional[APIOptions] = None + ): + if question == "": + logging.warning( + 'Using empty `question` string ' + + 'against `graphs.question` resource. ' + + 'The model is not likely to produce a meaningful response.' + ) + config = config or {} + graphs = self._retrieve_graphs_accessor() + response = graphs.question( + graph_ids=[self.id,], + question=question, + subqueries=subqueries, + stream=stream, + **config + ) + return response + + def stream_ask( + self, + question: str, + subqueries: bool = False, + config: Optional[APIOptions] = None + ) -> Generator[str, None, None]: + """ + Streams response for a question posed to the graph. + + This method returns incremental chunks of the response, ideal for long + responses or when reduced latency is needed. + + :param question: The query or question to be answered by the graph. + :param subqueries: Enables subquery generation if set to True, + enhancing the result. + :param config: Optional dictionary for additional API + configuration settings. + The configuration can include: + - ``extra_headers`` (Optional[Headers]): Additional headers. + - ``extra_query`` (Optional[Query]): Extra query parameters. + - ``extra_body`` (Optional[Body]): Additional body data. + - ``timeout`` (Union[float, Timeout, None, NotGiven]): Request timeout. + + :yields: Incremental chunks of the answer to the question. + + :raises ValueError: If an invalid graph or graph ID + is provided in `graphs_or_graph_ids`. + + **Example Usage**: + + >>> for chunk in graph.stream_ask( + ... question="What are the benefits of renewable energy?" + ... ): + ... print(chunk) + ... + """ + + response = cast( + Stream[QuestionResponseChunk], + self._question( + question=question, + subqueries=subqueries, + stream=True, + config=config + ) + ) + for chunk in response._iter_events(): + raw_data = chunk.data + answer = "" + try: + data = json.loads(raw_data) + answer = data.get("answer", "") + except json.JSONDecodeError: + logging.error( + "Couldn't parse chunk data during `question` streaming" + ) + yield answer + + def ask( + self, + question: str, + subqueries: bool = False, + config: Optional[APIOptions] = None + ): + """ + Sends a question to the graph and retrieves + a single response. + + :param question: The query or question to be answered by the graph. + :param subqueries: Enables subquery generation if set to True, + enhancing the result. + :param config: Optional dictionary for additional API + configuration settings. + The configuration can include: + - ``extra_headers`` (Optional[Headers]): Additional headers. + - ``extra_query`` (Optional[Query]): Extra query parameters. + - ``extra_body`` (Optional[Body]): Additional body data. + - ``timeout`` (Union[float, Timeout, None, NotGiven]): Request timeout. + + :return: The answer to the question from the graph(s). + + **Example Usage**: + + >>> response = graph.ask( + ... question="What is the capital of France?", + ... ) + """ + response = cast( + Question, + self._question( + question=question, + subqueries=subqueries, + stream=False, + config=config + ) + ) + return response.answer + def create_graph( name: str, @@ -1976,6 +2109,143 @@ def stream_complete( response.close() +def _gather_graph_ids(graphs_or_graph_ids: list) -> List[str]: + graph_ids = [] + for item in graphs_or_graph_ids: + if isinstance(item, Graph): + graph_ids.append(item.id) + elif isinstance(item, str): + graph_ids.append(item) + else: + raise ValueError( + f"Invalid item in graphs_or_graph_ids list: {type(item)}" + ) + + return graph_ids + + +def ask( + question: str, + graphs_or_graph_ids: List[Union[Graph, str]], + subqueries: bool = False, + config: Optional[APIOptions] = None +): + """ + Sends a question to the specified graph(s) and retrieves + a single response. + + :param question: The query or question to be answered by the graph(s). + :param graphs_or_graph_ids: A list of `Graph` objects or graph IDs that + should be queried. + :param subqueries: Enables subquery generation if set to True, + enhancing the result. + :param config: Optional dictionary for additional API + configuration settings. + The configuration can include: + - ``extra_headers`` (Optional[Headers]): Additional headers. + - ``extra_query`` (Optional[Query]): Extra query parameters. + - ``extra_body`` (Optional[Body]): Additional body data. + - ``timeout`` (Union[float, Timeout, None, NotGiven]): Request timeout. + + :return: The answer to the question from the graph(s). + + :raises ValueError: If an invalid graph or graph ID is provided + in `graphs_or_graph_ids`. + :raises RuntimeError: If the API response is improperly formatted + or the answer cannot be retrieved. + + **Example Usage**: + + >>> response = ask( + ... question="What is the capital of France?", + ... graphs_or_graph_ids=["graph_id_1", "graph_id_2"] + ... ) + """ + config = config or {} + client = WriterAIManager.acquire_client() + graph_ids = _gather_graph_ids(graphs_or_graph_ids) + + response = cast( + Question, + client.graphs.question( + graph_ids=graph_ids, + question=question, + stream=False, + subqueries=subqueries, + **config + ) + ) + + return response.answer + + +def stream_ask( + question: str, + graphs_or_graph_ids: List[Union[Graph, str]], + subqueries: bool = False, + config: Optional[APIOptions] = None +) -> Generator[str, None, None]: + """ + Streams response for a question posed to the specified graph(s). + + This method returns incremental chunks of the response, ideal for long + responses or when reduced latency is needed. + + :param question: The query or question to be answered by the graph(s). + :param graphs_or_graph_ids: A list of Graph objects or graph IDs that + should be queried. + :param subqueries: Enables subquery generation if set to True, + enhancing the result. + :param config: Optional dictionary for additional API + configuration settings. + The configuration can include: + - ``extra_headers`` (Optional[Headers]): Additional headers. + - ``extra_query`` (Optional[Query]): Extra query parameters. + - ``extra_body`` (Optional[Body]): Additional body data. + - ``timeout`` (Union[float, Timeout, None, NotGiven]): Request timeout. + + :yields: Incremental chunks of the answer to the question. + + :raises ValueError: If an invalid graph or graph ID + is provided in `graphs_or_graph_ids`. + + **Example Usage**: + + >>> for chunk in stream_ask( + ... question="What are the benefits of renewable energy?", + ... graphs_or_graph_ids=["graph_id_1"] + ... ): + ... print(chunk) + ... + """ + config = config or {} + client = WriterAIManager.acquire_client() + graph_ids = _gather_graph_ids(graphs_or_graph_ids) + + response = cast( + Stream[QuestionResponseChunk], + client.graphs.question( + graph_ids=graph_ids, + question=question, + stream=True, + subqueries=subqueries, + **config + ) + ) + + for chunk in response._iter_events(): + raw_data = chunk.data + answer = "" + try: + data = json.loads(raw_data) + answer = data.get("answer", "") + except json.JSONDecodeError: + logging.error( + "Couldn't parse chunk data during `question` streaming" + ) + yield answer + + def init(token: Optional[str] = None): """ Initializes the WriterAIManager with an optional token. diff --git a/src/writer/command_line.py b/src/writer/command_line.py index ef2a0e00a..f7ff8c315 100644 --- a/src/writer/command_line.py +++ b/src/writer/command_line.py @@ -1,4 +1,3 @@ -import logging import os import shutil import sys @@ -7,7 +6,7 @@ import click import writer.serve -from writer import VERSION +from writer import VERSION, wf_project from writer.deploy import cloud, deploy CONTEXT_SETTINGS = {'help_option_names': ['-h', '--help']} @@ -27,8 +26,13 @@ def run(path: str, host: str, port: Optional[int]): """Run the app from PATH folder in run mode.""" abs_path = os.path.abspath(path) - if not os.path.isdir(abs_path): - raise click.ClickException("A path to a folder containing a Writer Framework app is required. For example: writer run my_app") + if wf_project.is_project(path) is False and \ + wf_project.can_create_project(path) is True: + raise click.ClickException(f"There’s no Writer Framework project at this location, create a new one with `writer create {path}`") + + if wf_project.is_project(path) is False and \ + wf_project.can_create_project(path) is False: + raise click.ClickException(f"There’s no Writer Framework project at this location : {abs_path}") writer.serve.serve( abs_path, mode="run", port=port, host=host, enable_server_setup=True) @@ -38,13 +42,31 @@ def run(path: str, host: str, port: Optional[int]): @click.option('--port', default=None, help="Port to run the app on") @click.option('--enable-remote-edit', help="Set this flag to allow non-local requests in edit mode.", is_flag=True) @click.option('--enable-server-setup', help="Set this flag to enable server setup hook in edit mode.", is_flag=True) +@click.option("--no-interactive", help="Set the flask to ask the app to run without asking anything to the user", is_flag=True) @click.argument('path') -def edit(path: str, port: Optional[int], host: str, enable_remote_edit: bool, enable_server_setup: bool): +def edit( + path: str, + port: Optional[int], + host: str, + enable_remote_edit: bool, + enable_server_setup: bool, + no_interactive: bool +): """Run the app from PATH folder in edit mode.""" - abs_path = os.path.abspath(path) - if not os.path.isdir(abs_path): - raise click.ClickException("A path to a folder containing a Writer Framework app is required. For example: writer edit my_app") + if wf_project.is_project(path) is False and \ + wf_project.can_create_project(path) is True and \ + no_interactive is False: + click.confirm("There’s no Writer Framework project at this location, would you like to create a new one ?", default=False, abort=True) + create_app(path, template_name="default", overwrite=False) + + if wf_project.is_project(path) is False and \ + wf_project.can_create_project(path) is True: + raise click.ClickException(f"There’s no Writer Framework project at this location, create a new one with `writer create {path}`") + + if wf_project.is_project(path) is False and \ + wf_project.can_create_project(path) is False: + raise click.ClickException(f"There’s no Writer Framework project at this location : {abs_path}") writer.serve.serve( abs_path, mode="edit", port=port, host=host, @@ -81,19 +103,14 @@ def create_app(app_path: str, template_name: Optional[str], overwrite=False): if template_name is None: template_name = "default" - is_folder_created = os.path.exists(app_path) - is_folder_empty = True if not is_folder_created else len(os.listdir(app_path)) == 0 - - if not overwrite and not is_folder_empty: - logging.error("The target folder must be empty or not already exist.") - sys.exit(1) + if wf_project.can_create_project(path=app_path) is False: + raise click.ClickException("The target folder must be empty or not already exist.") server_path = os.path.dirname(__file__) template_path = os.path.join(server_path, "app_templates", template_name) if not os.path.exists(template_path): - logging.error(f"Template { template_name } couldn't be found.") - sys.exit(1) + raise click.ClickException(f"Template { template_name } couldn't be found.") shutil.copytree(template_path, app_path, dirs_exist_ok=True) # create/update requirements.txt and add writer to it diff --git a/src/writer/wf_project.py b/src/writer/wf_project.py index 61530b741..1357adc22 100644 --- a/src/writer/wf_project.py +++ b/src/writer/wf_project.py @@ -292,3 +292,32 @@ def _start_process_write_files_async_process(context: WfProjectContext, save_int write_files(app_path, metadata, components) time.sleep(save_interval) + + +def is_project(path: str) -> bool: + """ + Returns True if the path is a Writer Framework project. + + >>> wf_project.is_project('app/hello') + """ + has_main_py = os.path.isfile(os.path.join(path, "main.py")) + has_wf_directory = os.path.isdir(os.path.join(path, ".wf")) + has_ui_json_file = os.path.isfile(os.path.join(path, "ui.json")) + + return has_main_py and (has_wf_directory or has_ui_json_file) + + +def can_create_project(path: str) -> bool: + """ + Returns True the path does not contain a Writer Framework project and + it is possible to create one automatically. + + >>> wf_project.can_create_project('app/hello') + """ + if not os.path.isdir(path): + return True + + if len(os.listdir(path)) == 0: + return True + + return False diff --git a/tests/backend/blocks/test_writeraddtokg.py b/tests/backend/blocks/test_writeraddtokg.py index 7042a874c..bea0046bf 100644 --- a/tests/backend/blocks/test_writeraddtokg.py +++ b/tests/backend/blocks/test_writeraddtokg.py @@ -23,9 +23,10 @@ def mock_upload_file(data, type, name): return MockFile() -def test_add_to_kg(session, runner): - writer.ai.retrieve_graph = mock_retrieve_graph - writer.ai.upload_file = mock_upload_file +def test_add_to_kg(monkeypatch, session, runner): + monkeypatch.setattr("writer.ai.retrieve_graph", mock_retrieve_graph) + monkeypatch.setattr("writer.ai.upload_file", mock_upload_file) + session.session_state["my_files"] = [ { "data": b"123", @@ -47,9 +48,10 @@ def test_add_to_kg(session, runner): assert block.outcome == "success" -def test_add_to_kg_missing_type(session, runner): - writer.ai.retrieve_graph = mock_retrieve_graph - writer.ai.upload_file = mock_upload_file +def test_add_to_kg_missing_type(monkeypatch, session, runner): + monkeypatch.setattr("writer.ai.retrieve_graph", mock_retrieve_graph) + monkeypatch.setattr("writer.ai.upload_file", mock_upload_file) + session.session_state["my_files"] = [ { "data": b"123", @@ -65,9 +67,10 @@ def test_add_to_kg_missing_type(session, runner): with pytest.raises(WriterConfigurationError): block.run() -def test_add_to_kg_wrong_type(session, runner): - writer.ai.retrieve_graph = mock_retrieve_graph - writer.ai.upload_file = mock_upload_file +def test_add_to_kg_wrong_type(monkeypatch, session, runner): + monkeypatch.setattr("writer.ai.retrieve_graph", mock_retrieve_graph) + monkeypatch.setattr("writer.ai.upload_file", mock_upload_file) + session.session_state["my_files"] = "should be list" session.add_fake_component({ "graphId": "abc123", diff --git a/tests/backend/blocks/test_writerclassification.py b/tests/backend/blocks/test_writerclassification.py index 730968e77..eadcc9387 100644 --- a/tests/backend/blocks/test_writerclassification.py +++ b/tests/backend/blocks/test_writerclassification.py @@ -14,8 +14,8 @@ def fake_complete(prompt, config): return "other" -def test_classify(session, runner): - writer.ai.complete = fake_complete +def test_classify(monkeypatch, session, runner): + monkeypatch.setattr("writer.ai.complete", fake_complete) session.add_fake_component({ "text": "canine", "categories": json.dumps({ @@ -30,8 +30,8 @@ def test_classify(session, runner): assert block.outcome == "category_dog" -def test_classify_missing_categories(session, runner): - writer.ai.complete = fake_complete +def test_classify_missing_categories(monkeypatch, session, runner): + monkeypatch.setattr("writer.ai.complete", fake_complete) session.add_fake_component({ "text": "canine", "categories": json.dumps({}) diff --git a/tests/backend/blocks/test_writercompletion.py b/tests/backend/blocks/test_writercompletion.py index 6573a7f8f..9b1ca46a7 100644 --- a/tests/backend/blocks/test_writercompletion.py +++ b/tests/backend/blocks/test_writercompletion.py @@ -2,14 +2,14 @@ from writer.blocks.writercompletion import WriterCompletion -def test_complete(session, runner): +def test_complete(monkeypatch, session, runner): def fake_complete(prompt, config): assert config.get("temperature") == 0.9 assert config.get("model") == "buenos-aires-x-004" assert prompt == "What color is the sea?" return "Blue." - writer.ai.complete = fake_complete + monkeypatch.setattr("writer.ai.complete", fake_complete) session.add_fake_component({ "prompt": "What color is the sea?", "modelId": "buenos-aires-x-004", @@ -20,14 +20,14 @@ def fake_complete(prompt, config): assert block.result == "Blue." assert block.outcome == "success" -def test_complete_missing_text(session, runner): +def test_complete_missing_text(monkeypatch, session, runner): def fake_complete(prompt, config): assert config.get("temperature") == 0.9 assert config.get("model") == "buenos-aires-x-004" assert not prompt return "Plants are usually green." - writer.ai.complete = fake_complete + monkeypatch.setattr("writer.ai.complete", fake_complete) session.add_fake_component({ "prompt": "", "modelId": "buenos-aires-x-004", diff --git a/tests/backend/blocks/test_writernocodeapp.py b/tests/backend/blocks/test_writernocodeapp.py index f93cd38a6..9c8f576cd 100644 --- a/tests/backend/blocks/test_writernocodeapp.py +++ b/tests/backend/blocks/test_writernocodeapp.py @@ -1,26 +1,21 @@ import json -from unittest.mock import AsyncMock, MagicMock, patch import pytest import writer.ai from writer.blocks.writernocodeapp import WriterNoCodeApp -@pytest.fixture -def mock_app_content_generation(): - with patch('writer.ai.apps.generate_content') as mock_generate_content: - def fake_generate_content(application_id, app_inputs): - assert application_id == "123" +def fake_generate_content(application_id, app_inputs): + assert application_id == "123" - name = app_inputs.get("name") - animal = app_inputs.get("animal") + name = app_inputs.get("name") + animal = app_inputs.get("animal") - return f"{name} the {animal} " - mock_generate_content.side_effect = fake_generate_content + return f"{name} the {animal} " - yield mock_generate_content - -def test_call_nocode_app(mock_app_content_generation, session, runner): +def test_call_nocode_app(monkeypatch, session, runner): + monkeypatch.setattr("writer.ai.apps.generate_content", fake_generate_content) + writer.ai.apps.generate_content = fake_generate_content session.add_fake_component({ "appId": "123", "appInputs": json.dumps({ @@ -34,7 +29,8 @@ def test_call_nocode_app(mock_app_content_generation, session, runner): assert block.outcome == "success" -def test_call_nocode_app_missing_appid(mock_app_content_generation, session, runner): +def test_call_nocode_app_missing_appid(monkeypatch, session, runner): + monkeypatch.setattr("writer.ai.apps.generate_content", fake_generate_content) session.add_fake_component({ "appId": "", "appInputs": json.dumps({ diff --git a/tests/backend/test_ai.py b/tests/backend/test_ai.py index 3876715c4..23165a94d 100644 --- a/tests/backend/test_ai.py +++ b/tests/backend/test_ai.py @@ -40,6 +40,7 @@ ``` """ +import time from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch @@ -56,6 +57,7 @@ SDKGraph, WriterAIManager, apps, + ask, complete, create_graph, delete_file, @@ -65,6 +67,7 @@ list_graphs, retrieve_file, retrieve_graph, + stream_ask, stream_complete, upload_file, ) @@ -129,6 +132,9 @@ def mock_non_streaming_client(): non_streaming_client.completions.create.return_value = \ Completion(choices=[{"text": test_complete_literal}]) + non_streaming_client.graphs.question.return_value = \ + MagicMock(answer="Mocked Answer") + yield non_streaming_client @@ -159,7 +165,16 @@ def fake_response_content(): StreamingData(value="part1"), StreamingData(value=" part2") ]) - streaming_client.completions.create.return_value = mock_completion_stream + streaming_client.completions.create.return_value = \ + mock_completion_stream + + # Mock question streaming + mock_graph_stream = MagicMock() + mock_graph_stream._iter_events.return_value = iter([ + MagicMock(data='{"answer": "Part 1"}'), + MagicMock(data='{"answer": "Part 2"}'), + ]) + streaming_client.graphs.question.return_value = mock_graph_stream yield streaming_client @@ -507,6 +522,74 @@ def test_delete_file(mock_files_accessor): assert response.deleted is True +@pytest.mark.set_token("fake_token") +def test_ask(mock_non_streaming_client): + question = "What is the capital of France?" + graphs_or_graph_ids = ["graph_id_1"] + + response = ask(question, graphs_or_graph_ids) + + # Assert response and ensure proper method calls + assert response == "Mocked Answer" + mock_non_streaming_client.graphs.question.assert_called_once_with( + graph_ids=["graph_id_1"], + question=question, + stream=False, + subqueries=False + ) + + +@pytest.mark.set_token("fake_token") +def test_stream_ask(mock_streaming_client): + question = "Test question" + graphs_or_graph_ids = ["graph_id_1"] + + response_chunks = list(stream_ask(question, graphs_or_graph_ids)) + + # Assert response and ensure proper method calls + assert response_chunks == ["Part 1", "Part 2"] + mock_streaming_client.graphs.question.assert_called_once_with( + graph_ids=["graph_id_1"], + question=question, + stream=True, + subqueries=False + ) + + +@pytest.mark.set_token("fake_token") +def test_ask_graph_class(mock_non_streaming_client): + question = "Test question" + graph_object = Graph(MagicMock(id="test_graph_id")) + + response = graph_object.ask(question) + + # Assert response and ensure proper method calls + assert response == "Mocked Answer" + mock_non_streaming_client.graphs.question.assert_called_once_with( + graph_ids=["test_graph_id"], + question=question, + stream=False, + subqueries=False + ) + + +@pytest.mark.set_token("fake_token") +def test_stream_ask_graph_class(mock_streaming_client): + question = "Test question" + graph_object = Graph(MagicMock(id="test_graph_id")) + + response_chunks = list(graph_object.stream_ask(question)) + + # Assert response and ensure proper method calls + assert response_chunks == ["Part 1", "Part 2"] + mock_streaming_client.graphs.question.assert_called_once_with( + graph_ids=["test_graph_id"], + question=question, + stream=True, + subqueries=False + ) + + @explicit def test_explicit_conversation_complete(emulate_app_process): conversation = Conversation() @@ -641,5 +724,181 @@ def test_explicit_delete_file(emulate_app_process, created_files): assert response.deleted is True + +@explicit +def test_explicit_ask_graph_class( + emulate_app_process, + created_graphs, + created_files +): + uploaded_file = upload_file( + data=b"Source word is PARIS", + type="text/plain", + name="code_words" + ) + created_files.append(uploaded_file) + graph = create_graph( + name="integration_test_graph", + description="Integration test graph" + ) + created_graphs.append(graph) + graph.add_file(uploaded_file) + + # Await file processing + while True: + try: + file_status = graph.file_status + except AttributeError: + continue + + if file_status.in_progress == 1: + # File still being processed + time.sleep(5) + continue + else: + # File is ready + break + + answer = graph.ask( + "What is the source word? Name only the word and nothing else" + ) + + assert isinstance(answer, str) + assert answer == " PARIS" + + +@explicit +def test_explicit_stream_ask_graph_class( + emulate_app_process, + created_graphs, + created_files +): + uploaded_file = upload_file( + data=b"Source word is PARIS", + type="text/plain", + name="code_words" + ) + created_files.append(uploaded_file) + graph = create_graph( + name="integration_test_graph", + description="Integration test graph" + ) + created_graphs.append(graph) + graph.add_file(uploaded_file) + + # Await file processing + while True: + try: + file_status = graph.file_status + except AttributeError: + continue + + if file_status.in_progress == 1: + # File still being processed + time.sleep(5) + continue + else: + # File is ready + break + + answer = "" + stream = graph.stream_ask( + "What is the source word? Name only the word and nothing else" + ) + for chunk in stream: + answer += chunk + + assert isinstance(answer, str) + assert answer == " PARIS" + + +@explicit +def test_explicit_ask( + emulate_app_process, + created_graphs, + created_files +): + uploaded_file = upload_file( + data=b"Source word is PARIS", + type="text/plain", + name="code_words" + ) + created_files.append(uploaded_file) + graph = create_graph( + name="integration_test_graph", + description="Integration test graph" + ) + created_graphs.append(graph) + graph.add_file(uploaded_file) + + # Await file processing + while True: + try: + file_status = graph.file_status + except AttributeError: + continue + + if file_status.in_progress == 1: + # File still being processed + time.sleep(5) + continue + else: + # File is ready + break + + answer = ask( + question="What is the source word? Name only the word and nothing else", + graphs_or_graph_ids=[graph] + ) + + assert isinstance(answer, str) + assert answer == " PARIS" + + +@explicit +def test_explicit_stream_ask( + emulate_app_process, + created_graphs, + created_files +): + uploaded_file = upload_file( + data=b"Source word is PARIS", + type="text/plain", + name="code_words" + ) + created_files.append(uploaded_file) + graph = create_graph( + name="integration_test_graph", + description="Integration test graph" + ) + created_graphs.append(graph) + graph.add_file(uploaded_file) + + # Await file processing + while True: + try: + file_status = graph.file_status + except AttributeError: + continue + + if file_status.in_progress == 1: + # File still being processed + time.sleep(5) + continue + else: + # File is ready + break + + answer = "" + stream = stream_ask( + question="What is the source word? Name only the word and nothing else", + graphs_or_graph_ids=[graph] + ) + for chunk in stream: + answer += chunk + + assert isinstance(answer, str) + assert answer == " PARIS" + # For doing a explicit test of apps.generate_content() we need a no-code app that # nobody will touch. That is a challenge. diff --git a/tests/backend/test_wf_project.py b/tests/backend/test_wf_project.py index 22ca9846b..512c22e37 100644 --- a/tests/backend/test_wf_project.py +++ b/tests/backend/test_wf_project.py @@ -1,3 +1,4 @@ +import io import os import shutil import tempfile @@ -126,3 +127,63 @@ def test_wf_project_migrate_obsolete_ui_json_should_migrate_ui_json_into_wf_dire assert not os.path.isfile(os.path.join(tmp_app_dir, 'ui.json')) assert os.path.isfile(os.path.join(tmp_app_dir, '.wf', 'metadata.json')) assert os.path.isfile(os.path.join(tmp_app_dir, '.wf', 'components-root.jsonl')) + + +def test_wf_project_is_project_work_on_current_project(): + with tempfile.TemporaryDirectory('wf_project_migrate_obsolete_ui_json') as tmp_app_dir: + shutil.copytree(testobsoleteapp, tmp_app_dir, dirs_exist_ok=True) + + # When + is_wf_project = wf_project.is_project(tmp_app_dir) + + # Then + assert is_wf_project is True + + +def test_wf_project_is_project_work_on_obsolete_project(): + with tempfile.TemporaryDirectory('test_wf_project_write_files') as tmp_app_dir: + shutil.copytree(test_app_dir, tmp_app_dir, dirs_exist_ok=True) + + # When + is_wf_project = wf_project.is_project(tmp_app_dir) + + # Then + assert is_wf_project is True + + +def test_wf_project_can_create_project_does_not_work_on_project_with_ui_json(): + with tempfile.TemporaryDirectory('test_wf_project_write_files') as tmp_app_dir: + shutil.copytree(testobsoleteapp, tmp_app_dir, dirs_exist_ok=True) + os.remove(os.path.join(tmp_app_dir, 'main.py')) + + # When + is_wf_project = wf_project.is_project(tmp_app_dir) + can_create_wf_project = wf_project.can_create_project(tmp_app_dir) + + # Then + assert is_wf_project is False + assert can_create_wf_project is False + + +def test_wf_project_can_create_project_does_not_work_on_project_with_a_directory(): + with tempfile.TemporaryDirectory('test_wf_project_can_create_project') as tmp_app_dir: + io.open(os.path.join(tmp_app_dir, 'file'), 'w').close() + + # When + is_wf_project = wf_project.is_project(tmp_app_dir) + can_create_wf_project = wf_project.can_create_project(tmp_app_dir) + + # Then + assert is_wf_project is False + assert can_create_wf_project is False + + +def test_wf_project_can_create_project_on_empty_directory(): + with tempfile.TemporaryDirectory('test_wf_project_can_create_project') as tmp_app_dir: + # When + is_wf_project = wf_project.is_project(tmp_app_dir) + can_create_wf_project = wf_project.can_create_project(tmp_app_dir) + + # Then + assert is_wf_project is False + assert can_create_wf_project is True