From f254defc6b58bb4e06d4dbc8b3d1a992c2bba65c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 19 Sep 2023 20:11:32 +0200 Subject: [PATCH] feat: add speckle automate package with some basic sanity tests --- poetry.lock | 194 ++++++------ pyproject.toml | 4 +- src/speckle_automate/__init__.py | 23 ++ src/speckle_automate/automation_context.py | 278 ++++++++++++++++++ src/speckle_automate/runner.py | 155 ++++++++++ src/speckle_automate/schema.py | 73 +++++ .../intergration/speckle_automate/__init__.py | 0 .../test_automation_context.py | 263 +++++++++++++++++ 8 files changed, 880 insertions(+), 110 deletions(-) create mode 100644 src/speckle_automate/__init__.py create mode 100644 src/speckle_automate/automation_context.py create mode 100644 src/speckle_automate/runner.py create mode 100644 src/speckle_automate/schema.py create mode 100644 tests/intergration/speckle_automate/__init__.py create mode 100644 tests/intergration/speckle_automate/test_automation_context.py diff --git a/poetry.lock b/poetry.lock index 7cbb0de..ea2c4e8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,8 +11,26 @@ files = [ {file = "annotated_types-0.5.0.tar.gz", hash = "sha256:47cdc3490d9ac1506ce92c7aaa76c579dc3509ff11e098fc867e5130ab7be802"}, ] +[[package]] +name = "anyio" +version = "4.0.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.0.0-py3-none-any.whl", hash = "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f"}, + {file = "anyio-4.0.0.tar.gz", hash = "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a"}, +] + [package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.22)"] [[package]] name = "appdirs" @@ -36,9 +54,6 @@ files = [ {file = "argcomplete-2.0.6.tar.gz", hash = "sha256:dc33528d96727882b576b24bc89ed038f3c6abbb6855ff9bb6be23384afff9d6"}, ] -[package.dependencies] -importlib-metadata = {version = ">=0.23,<6", markers = "python_version == \"3.7\""} - [package.extras] lint = ["flake8", "mypy"] test = ["coverage", "flake8", "mypy", "pexpect", "wheel"] @@ -56,7 +71,6 @@ files = [ [package.dependencies] lazy-object-proxy = ">=1.4.0" -typed-ast = {version = ">=1.4.0,<2.0", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} wrapt = [ {version = ">=1.11,<2", markers = "python_version < \"3.11\""}, @@ -91,9 +105,6 @@ files = [ {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, ] -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - [package.extras] cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] dev = ["attrs[docs,tests]", "pre-commit"] @@ -139,7 +150,6 @@ mypy-extensions = ">=0.4.3" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} [package.extras] @@ -197,7 +207,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" @@ -461,8 +470,60 @@ files = [ {file = "graphql_core-3.2.3-py3-none-any.whl", hash = "sha256:5766780452bd5ec8ba133f8bf287dc92713e3868ddd83aee4faab9fc3e303dc3"}, ] +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "0.18.0" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-0.18.0-py3-none-any.whl", hash = "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced"}, + {file = "httpcore-0.18.0.tar.gz", hash = "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9"}, +] + [package.dependencies] -typing-extensions = {version = ">=4.2,<5", markers = "python_version < \"3.8\""} +anyio = ">=3.0,<5.0" +certifi = "*" +h11 = ">=0.13,<0.15" +sniffio = "==1.*" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "httpx" +version = "0.25.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.25.0-py3-none-any.whl", hash = "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100"}, + {file = "httpx-0.25.0.tar.gz", hash = "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875"}, +] + +[package.dependencies] +certifi = "*" +httpcore = ">=0.18.0,<0.19.0" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] [[package]] name = "identify" @@ -489,26 +550,6 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] -[[package]] -name = "importlib-metadata" -version = "5.2.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "importlib_metadata-5.2.0-py3-none-any.whl", hash = "sha256:0eafa39ba42bf225fc00e67f701d71f85aead9f878569caf13c3724f704b970f"}, - {file = "importlib_metadata-5.2.0.tar.gz", hash = "sha256:404d48d62bba0b7a77ff9d405efd91501bef2e67ff4ace0bed40a0cf28c3c7cd"}, -] - -[package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -788,7 +829,6 @@ files = [ [package.dependencies] mypy-extensions = ">=0.4.3" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} typing-extensions = ">=3.10" [package.extras] @@ -854,9 +894,6 @@ files = [ {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.8\""} - [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] @@ -872,9 +909,6 @@ files = [ {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, ] -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] @@ -893,7 +927,6 @@ files = [ [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" @@ -1092,7 +1125,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" @@ -1287,6 +1319,17 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + [[package]] name = "stringcase" version = "1.2.0" @@ -1333,56 +1376,6 @@ files = [ {file = "tomlkit-0.12.1.tar.gz", hash = "sha256:38e1ff8edb991273ec9f6181244a6a391ac30e9f5098e7535640ea6be97a7c86"}, ] -[[package]] -name = "typed-ast" -version = "1.5.5" -description = "a fork of Python 2 and 3 ast modules with type comment support" -optional = false -python-versions = ">=3.6" -files = [ - {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, - {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, - {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, - {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04"}, - {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d"}, - {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d"}, - {file = "typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02"}, - {file = "typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee"}, - {file = "typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18"}, - {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88"}, - {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2"}, - {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9"}, - {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8"}, - {file = "typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b"}, - {file = "typed_ast-1.5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:44f214394fc1af23ca6d4e9e744804d890045d1643dd7e8229951e0ef39429b5"}, - {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:118c1ce46ce58fda78503eae14b7664163aa735b620b64b5b725453696f2a35c"}, - {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4919b808efa61101456e87f2d4c75b228f4e52618621c77f1ddcaae15904fa"}, - {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fc2b8c4e1bc5cd96c1a823a885e6b158f8451cf6f5530e1829390b4d27d0807f"}, - {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:16f7313e0a08c7de57f2998c85e2a69a642e97cb32f87eb65fbfe88381a5e44d"}, - {file = "typed_ast-1.5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2b946ef8c04f77230489f75b4b5a4a6f24c078be4aed241cfabe9cbf4156e7e5"}, - {file = "typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e"}, - {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e"}, - {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311"}, - {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2"}, - {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4"}, - {file = "typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431"}, - {file = "typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a"}, - {file = "typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437"}, - {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede"}, - {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4"}, - {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6"}, - {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4"}, - {file = "typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b"}, - {file = "typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10"}, - {file = "typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814"}, - {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8"}, - {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274"}, - {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a"}, - {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba"}, - {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, - {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, -] - [[package]] name = "types-deprecated" version = "1.2.9.3" @@ -1545,7 +1538,6 @@ files = [ [package.dependencies] distlib = ">=0.3.6,<1" filelock = ">=3.4.1,<4" -importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.8\""} platformdirs = ">=2.4,<4" [package.extras] @@ -1811,24 +1803,8 @@ files = [ [package.dependencies] idna = ">=2.0" multidict = ">=4.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} - -[[package]] -name = "zipp" -version = "3.15.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.7" -files = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [metadata] lock-version = "2.0" -python-versions = ">=3.7.2, <4.0" -content-hash = "68b9e18f308a957906221e252919436a88f414a96bba50969afaa73293caa7ea" +python-versions = ">=3.9.0, <4.0" +content-hash = "2c37066950fbf65bc5aacf7eaa24f527e3757aec8305b9de8adf9bef5e17e78d" diff --git a/pyproject.toml b/pyproject.toml index 923d39f..6048767 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,11 +10,12 @@ documentation = "https://speckle.guide/dev/py-examples.html" homepage = "https://speckle.systems/" packages = [ { include = "specklepy", from = "src" }, + { include = "speckle_automate", from = "src" }, ] [tool.poetry.dependencies] -python = ">=3.7.2, <4.0" +python = ">=3.9.0, <4.0" pydantic = "^2.0" appdirs = "^1.4.4" gql = {extras = ["requests", "websockets"], version = "^3.3.0"} @@ -22,6 +23,7 @@ ujson = "^5.3.0" Deprecated = "^1.2.13" stringcase = "^1.2.0" attrs = "^23.1.0" +httpx = "^0.25.0" [tool.poetry.group.dev.dependencies] black = "^22.8.0" diff --git a/src/speckle_automate/__init__.py b/src/speckle_automate/__init__.py new file mode 100644 index 0000000..be29deb --- /dev/null +++ b/src/speckle_automate/__init__.py @@ -0,0 +1,23 @@ +"""This module contains an SDK for working with Speckle Automate.""" +from speckle_automate.automation_context import AutomationContext +from speckle_automate.runner import execute_automate_function, run_function +from speckle_automate.schema import ( + AutomateBase, + AutomationResult, + AutomationRunData, + AutomationStatus, + ObjectResult, + ObjectResultLevel, +) + +__all__ = [ + "AutomationContext", + "AutomateBase", + "AutomationStatus", + "AutomationResult", + "AutomationRunData", + "ObjectResult", + "ObjectResultLevel", + "run_function", + "execute_automate_function", +] diff --git a/src/speckle_automate/automation_context.py b/src/speckle_automate/automation_context.py new file mode 100644 index 0000000..ff9082f --- /dev/null +++ b/src/speckle_automate/automation_context.py @@ -0,0 +1,278 @@ +"""This module provides an abstraction layer above the Speckle Automate runtime.""" +from dataclasses import dataclass, field +from pathlib import Path +import time +from typing import Optional, Union + +import httpx +from gql import gql +from specklepy.api import operations +from specklepy.api.client import SpeckleClient +from specklepy.objects import Base +from specklepy.transports.memory import MemoryTransport +from specklepy.transports.server import ServerTransport + +from speckle_automate.schema import ( + AutomateBase, + AutomationResult, + AutomationRunData, + AutomationStatus, + ObjectResult, + ObjectResultLevel, +) + + +@dataclass +class AutomationContext: + """A context helper class. + + This class exposes methods to work with the Speckle Automate context inside + Speckle Automate functions. + + An instance of AutomationContext is injected into every run of a function. + """ + + automation_run_data: AutomationRunData + speckle_client: SpeckleClient + _server_transport: ServerTransport + _speckle_token: str + + #: keep a memory transponrt at hand, to speed up things if needed + _memory_transport: MemoryTransport = field(default_factory=MemoryTransport) + + #: added for performance measuring + _init_time: float = field(default_factory=time.perf_counter) + _automation_result: AutomationResult = field(default_factory=AutomationResult) + + @classmethod + def initialize( + cls, automation_run_data: Union[str, AutomationRunData], speckle_token: str + ) -> "AutomationContext": + """Bootstrap the AutomateSDK from raw data. + + Todo: + ---- + * bootstrap a structlog logger instance + * expose a logger, that ppl can use instead of print + """ + # parse the json value if its not an initialized project data instance + automation_run_data = ( + automation_run_data + if isinstance(automation_run_data, AutomationRunData) + else AutomationRunData.model_validate_json(automation_run_data) + ) + speckle_client = SpeckleClient( + automation_run_data.speckle_server_url, + automation_run_data.speckle_server_url.startswith("https"), + ) + speckle_client.authenticate_with_token(speckle_token) + if not speckle_client.account: + msg = ( + f"Could not autenticate to {automation_run_data.speckle_server_url}", + "with the provided token", + ) + raise ValueError(msg) + server_transport = ServerTransport( + automation_run_data.project_id, speckle_client + ) + return cls(automation_run_data, speckle_client, server_transport, speckle_token) + + @property + def run_status(self) -> AutomationStatus: + """Get the status of the automation run.""" + return self._automation_result.run_status + + def elapsed(self) -> float: + """Return the elapsed time in seconds since the initialization time.""" + return time.perf_counter() - self._init_time + + def receive_version(self) -> Base: + """Receive the Speckle project version that triggered this automation run.""" + commit = self.speckle_client.commit.get( + self.automation_run_data.project_id, self.automation_run_data.version_id + ) + if not commit.referencedObject: + raise ValueError("The commit has no referencedObject, cannot receive it.") + base = operations.receive( + commit.referencedObject, self._server_transport, self._memory_transport + ) + print( + f"It took {self.elapsed():2f} seconds to receive", + f" the speckle version {self.automation_run_data.version_id}", + ) + return base + + def create_new_version_in_project( + self, root_object: Base, model_id: str, version_message: str = "" + ) -> None: + """Save a base model to a new version on the project. + + Args: + root_object (Base): The Speckle base object for the new version. + model_id (str): For now please use a `branchName`! + version_message (str): The message for the new version. + """ + if model_id == self.automation_run_data.model_id: + raise ValueError( + f"The target model id: {model_id} cannot match the model id" + f" that triggered this automation: {self.automation_run_data.model_id}" + ) + + root_object_id = operations.send( + root_object, + [self._server_transport, self._memory_transport], + use_default_cache=False, + ) + + version_id = self.speckle_client.commit.create( + stream_id=self.automation_run_data.project_id, + object_id=root_object_id, + branch_name=model_id, + message=version_message, + source_application="SpeckleAutomate", + ) + self._automation_result.result_versions.append(version_id) + + def report_run_status(self) -> None: + """Report the current run status to the project of this automation.""" + query = gql( + """ + mutation ReportFunctionRunStatus( + $automationId: String!, + $automationRevisionId: String!, + $automationRunId: String!, + $versionId: String!, + $functionId: String!, + $runStatus: AutomationRunStatus! + $elapsed: Float! + $resultVersionIds: [String!]! + $statusMessage: String + $objectResults: JSONObject + ){ + automationMutations { + functionRunStatusReport(input: { + automationId: $automationId + automationRevisionId: $automationRevisionId + automationRunId: $automationRunId + versionId: $versionId + functionRuns: [ + { + functionId: $functionId + status: $runStatus, + elapsed: $elapsed, + resultVersionIds: $resultVersionIds, + statusMessage: $statusMessage + results: $objectResults + }] + }) + } + } + """ + ) + if self.run_status in [AutomationStatus.SUCCEEDED, AutomationStatus.FAILED]: + object_results = { + "version": "1.0.0", + "values": { + "speckleObjects": self._automation_result.model_dump(by_alias=True)[ + "objectResults" + ], + "blobs": self._automation_result.blobs, + }, + } + else: + object_results = None + params = { + "automationId": self.automation_run_data.automation_id, + "automationRevisionId": self.automation_run_data.automation_revision_id, + "automationRunId": self.automation_run_data.automation_run_id, + "versionId": self.automation_run_data.version_id, + "functionId": self.automation_run_data.function_id, + "runStatus": self.run_status.value, + "statusMessage": self._automation_result.status_message, + "elapsed": self.elapsed(), + "resultVersionIds": self._automation_result.result_versions, + "objectResults": object_results, + } + self.speckle_client.httpclient.execute(query, params) + + def store_file_result(self, file_path: Union[Path, str]) -> None: + """Save a file attached to the project of this automation.""" + path_obj = ( + Path(file_path).resolve() if isinstance(file_path, str) else file_path + ) + + class UploadResult(AutomateBase): + blob_id: str + file_name: str + upload_status: int + + class BlobUploadResponse(AutomateBase): + upload_results: list[UploadResult] + + if not path_obj.exists(): + raise ValueError("The given file path doesn't exist") + files = {path_obj.name: open(str(path_obj), "rb")} + + url = ( + f"{self.automation_run_data.speckle_server_url}/api/stream/" + f"{self.automation_run_data.project_id}/blob" + ) + data = ( + httpx.post( + url, + files=files, + headers={"authorization": f"Bearer {self._speckle_token}"}, + ) + .raise_for_status() + .json() + ) + + upload_response = BlobUploadResponse.model_validate(data) + + if len(upload_response.upload_results) != 1: + raise ValueError("Expecting one upload result.") + + for upload_result in upload_response.upload_results: + self._automation_result.blobs.append(upload_result.blob_id) + + def mark_run_failed(self, status_message: str) -> None: + """Mark the current run a failure.""" + self._mark_run(AutomationStatus.FAILED, status_message) + + def mark_run_success(self, status_message: Optional[str]) -> None: + """Mark the current run a success with an optional message.""" + self._mark_run(AutomationStatus.SUCCEEDED, status_message) + + def _mark_run( + self, status: AutomationStatus, status_message: Optional[str] + ) -> None: + duration = self.elapsed() + self._automation_result.status_message = status_message + self._automation_result.run_status = status + self._automation_result.elapsed = duration + + msg = f"Automation run {status.value} after {duration:2f} seconds." + print("\n".join([msg, status_message]) if status_message else msg) + + def add_object_error(self, object_id: str, error_cause: str) -> None: + """Add an error to a given Speckle object.""" + self._add_object_result(object_id, ObjectResultLevel.ERROR, error_cause) + + def add_object_warning(self, object_id: str, warning: str) -> None: + """Add a warning to a given Speckle object.""" + self._add_object_result(object_id, ObjectResultLevel.WARNING, warning) + + def add_object_info(self, object_id: str, info: str) -> None: + """Add an info message to a given Speckle object.""" + self._add_object_result(object_id, ObjectResultLevel.INFO, info) + + def _add_object_result( + self, object_id: str, level: ObjectResultLevel, status_message: str + ) -> None: + print( + f"Object {object_id} was marked with {level.value.upper()}", + f" cause: {status_message}", + ) + self._automation_result.object_results[object_id].append( + ObjectResult(level=level, status_message=status_message) + ) diff --git a/src/speckle_automate/runner.py b/src/speckle_automate/runner.py new file mode 100644 index 0000000..90c91b9 --- /dev/null +++ b/src/speckle_automate/runner.py @@ -0,0 +1,155 @@ +"""Function execution module. + +Provides mechanisms to execute any function, + that conforms to the AutomateFunction "interface" +""" +import json +import os +import sys +import traceback +from pathlib import Path +from typing import Callable, Optional, TypeVar, Union, overload + +from speckle_automate.automation_context import AutomationContext +from speckle_automate.schema import AutomateBase, AutomationRunData, AutomationStatus + +T = TypeVar("T", bound=AutomateBase) + +AutomateFunction = Callable[[AutomationContext, T], None] +AutomateFunctionWithoutInputs = Callable[[AutomationContext], None] + + +@overload +def execute_automate_function( + automate_function: AutomateFunction[T], + input_schema: type[T], +) -> None: + ... + + +@overload +def execute_automate_function(automate_function: AutomateFunctionWithoutInputs) -> None: + ... + + +def execute_automate_function( + automate_function: Union[AutomateFunction[T], AutomateFunctionWithoutInputs], + input_schema: Optional[type[T]] = None, +): + """Runs the provided automate function with the input schema.""" + # first arg is the python file name, we do not need that + args = sys.argv[1:] + + if len(args) < 2: + raise ValueError("too few arguments specified need minimum 2") + + if len(args) > 4: + raise ValueError("too many arguments specified, max supported is 4") + + # we rely on a command name convention to decide what to do. + # this is here, so that the function authors do not see any of this + command = args[0] + + if command == "generate_schema": + path = Path(args[1]) + schema = json.dumps( + input_schema.model_json_schema(by_alias=True) if input_schema else {} + ) + path.write_text(schema) + + elif command == "run": + automation_run_data = args[1] + function_inputs = args[2] + + speckle_token = os.environ.get("SPECKLE_TOKEN", None) + if not speckle_token and len(args) != 4: + raise ValueError("Cannot get speckle token from arguments or environment") + + speckle_token = speckle_token if speckle_token else args[3] + + inputs = ( + input_schema.model_validate_json(function_inputs) + if input_schema + else input_schema + ) + + if inputs: + automation_context = run_function( + automate_function, # type: ignore + automation_run_data, + speckle_token, + inputs, + ) + else: + automation_context = run_function( + automate_function, # type: ignore + automation_run_data, + speckle_token, + ) + + exit_code = ( + 0 if automation_context.run_status == AutomationStatus.SUCCEEDED else 1 + ) + exit(exit_code) + + else: + raise NotImplementedError(f"Command: '{command}' is not supported.") + + +@overload +def run_function( + automate_function: AutomateFunction[T], + automation_run_data: Union[AutomationRunData, str], + speckle_token: str, + inputs: T, +) -> AutomationContext: + ... + + +@overload +def run_function( + automate_function: AutomateFunctionWithoutInputs, + automation_run_data: Union[AutomationRunData, str], + speckle_token: str, +) -> AutomationContext: + ... + + +def run_function( + automate_function: Union[AutomateFunction[T], AutomateFunctionWithoutInputs], + automation_run_data: Union[AutomationRunData, str], + speckle_token: str, + inputs: Optional[T] = None, +) -> AutomationContext: + """Run the provided function with the automate sdk context.""" + automation_context = AutomationContext.initialize( + automation_run_data, speckle_token + ) + automation_context.report_run_status() + + try: + # avoiding complex type gymnastics here on the internals. + # the external type overloads make this correct + if inputs: + automate_function(automation_context, inputs) # type: ignore + else: + automate_function(automation_context) # type: ignore + + # the function author forgot to mark the function success + if automation_context.run_status not in [ + AutomationStatus.FAILED, + AutomationStatus.SUCCEEDED, + ]: + automation_context.mark_run_success( + "WARNING: Automate assumed a success status," + " but it was not marked as so by the function." + ) + except Exception: + trace = traceback.format_exc() + print(trace) + automation_context.mark_run_failed( + "Function error. Check the automation run logs for details." + ) + finally: + automation_context.report_run_status() + return automation_context diff --git a/src/speckle_automate/schema.py b/src/speckle_automate/schema.py new file mode 100644 index 0000000..7309b86 --- /dev/null +++ b/src/speckle_automate/schema.py @@ -0,0 +1,73 @@ +"""""" +from collections import defaultdict +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field +from stringcase import camelcase + + +class AutomateBase(BaseModel): + """Use this class as a base model for automate related DTO.""" + + model_config = ConfigDict(alias_generator=camelcase, populate_by_name=True) + + +class AutomationRunData(BaseModel): + """Values of the project / model that triggered the run of this function.""" + + project_id: str + model_id: str + branch_name: str + version_id: str + speckle_server_url: str + + automation_id: str + automation_revision_id: str + automation_run_id: str + + function_id: str + function_revision: str + + model_config = ConfigDict( + alias_generator=camelcase, populate_by_name=True, protected_namespaces=() + ) + + +class AutomationStatus(str, Enum): + """Set the status of the automation.""" + + INITIALIZING = "INITIALIZING" + RUNNING = "RUNNING" + FAILED = "FAILED" + SUCCEEDED = "SUCCEEDED" + + +class ObjectResultLevel(str, Enum): + """Possible status message levels for object reports.""" + + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + + +class ObjectResult(AutomateBase): + """An object level result.""" + + level: ObjectResultLevel + status_message: str + + +class AutomationResult(AutomateBase): + """Schema accepted by the Speckle server as a result for an automation run.""" + + elapsed: float = 0 + result_view: Optional[str] = None + result_versions: list[str] = Field(default_factory=list) + blobs: list[str] = Field(default_factory=list) + run_status: AutomationStatus = AutomationStatus.RUNNING + status_message: Optional[str] = None + + object_results: dict[str, list[ObjectResult]] = Field( + default_factory=lambda: defaultdict(list) # typing: ignore + ) diff --git a/tests/intergration/speckle_automate/__init__.py b/tests/intergration/speckle_automate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/intergration/speckle_automate/test_automation_context.py b/tests/intergration/speckle_automate/test_automation_context.py new file mode 100644 index 0000000..9923d0b --- /dev/null +++ b/tests/intergration/speckle_automate/test_automation_context.py @@ -0,0 +1,263 @@ +"""Run integration tests with a speckle server.""" +import os +import secrets +import string +from pathlib import Path +from typing import Dict + +import pytest +from gql import gql +from speckle_automate.schema import AutomateBase +from specklepy.api import operations +from specklepy.api.client import SpeckleClient +from specklepy.objects.base import Base +from specklepy.transports.server import ServerTransport + +from speckle_automate import ( + AutomationContext, + AutomationRunData, + AutomationStatus, + run_function, +) + + +def crypto_random_string(length: int) -> str: + """Generate a semi crypto random string of a given length.""" + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def register_new_automation( + project_id: str, + model_id: str, + speckle_client: SpeckleClient, + automation_id: str, + automation_name: str, + automation_revision_id: str, +): + """Register a new automation in the speckle server.""" + query = gql( + """ + mutation CreateAutomation( + $projectId: String! + $modelId: String! + $automationName: String! + $automationId: String! + $automationRevisionId: String! + ) { + automationMutations { + create( + input: { + projectId: $projectId + modelId: $modelId + automationName: $automationName + automationId: $automationId + automationRevisionId: $automationRevisionId + } + ) + } + } + """ + ) + params = { + "projectId": project_id, + "modelId": model_id, + "automationName": automation_name, + "automationId": automation_id, + "automationRevisionId": automation_revision_id, + } + speckle_client.httpclient.execute(query, params) + + +@pytest.fixture() +def speckle_token(user_dict: Dict[str, str]) -> str: + """Provide a speckle token for the test suite.""" + return user_dict["token"] + + +@pytest.fixture() +def speckle_server_url(host: str) -> str: + """Provide a speckle server url for the test suite, default to localhost.""" + return f"http://{host}" + + +@pytest.fixture() +def test_client(speckle_server_url: str, speckle_token: str) -> SpeckleClient: + """Initialize a SpeckleClient for testing.""" + test_client = SpeckleClient(speckle_server_url, use_ssl=False) + test_client.authenticate_with_token(speckle_token) + return test_client + + +@pytest.fixture() +def test_object() -> Base: + """Create a Base model for testing.""" + root_object = Base() + root_object.foo = "bar" + return root_object + + +@pytest.fixture() +def automation_run_data( + test_object: Base, test_client: SpeckleClient, speckle_server_url: str +) -> AutomationRunData: + """Set up an automation context for testing.""" + project_id = test_client.stream.create("Automate function e2e test") + branch_name = "main" + + model = test_client.branch.get(project_id, branch_name, commits_limit=1) + model_id: str = model.id + + root_obj_id = operations.send( + test_object, [ServerTransport(project_id, test_client)] + ) + version_id = test_client.commit.create(project_id, root_obj_id) + + automation_name = crypto_random_string(10) + automation_id = crypto_random_string(10) + automation_revision_id = crypto_random_string(10) + + register_new_automation( + project_id, + model_id, + test_client, + automation_id, + automation_name, + automation_revision_id, + ) + + automation_run_id = crypto_random_string(10) + function_id = crypto_random_string(10) + function_revision = crypto_random_string(10) + return AutomationRunData( + project_id=project_id, + model_id=model_id, + branch_name=branch_name, + version_id=version_id, + speckle_server_url=speckle_server_url, + automation_id=automation_id, + automation_revision_id=automation_revision_id, + automation_run_id=automation_run_id, + function_id=function_id, + function_revision=function_revision, + ) + + +def get_automation_status( + project_id: str, + model_id: str, + speckle_client: SpeckleClient, +): + query = gql( + """ +query AutomationRuns( + $projectId: String! + $modelId: String! + ) +{ + project(id: $projectId) { + model(id: $modelId) { + automationStatus { + id + status + statusMessage + automationRuns { + id + automationId + versionId + createdAt + updatedAt + status + functionRuns { + id + functionId + elapsed + status + contextView + statusMessage + results + resultVersions { + id + } + } + } + } + } + } +} + """ + ) + params = { + "projectId": project_id, + "modelId": model_id, + } + response = speckle_client.httpclient.execute(query, params) + return response["project"]["model"]["automationStatus"] + + +class FunctionInputs(AutomateBase): + forbidden_speckle_type: str + + +def automate_function( + automate_context: AutomationContext, + function_inputs: FunctionInputs, +) -> None: + """Hey, trying the automate sdk experience here.""" + version_root_object = automate_context.receive_version() + + count = 0 + if version_root_object.speckle_type == function_inputs.forbidden_speckle_type: + if not version_root_object.id: + raise ValueError("Cannot operate on objects without their id's.") + automate_context.add_object_error( + version_root_object.id, + "This project should not contain the type: " + f"{function_inputs.forbidden_speckle_type}", + ) + count += 1 + + if count > 0: + automate_context.mark_run_failed( + "Automation failed: " + f"Found {count} object that have a forbidden speckle type: " + f"{function_inputs.forbidden_speckle_type}" + ) + + else: + automate_context.mark_run_success("No forbidden types found.") + + +def test_function_run(automation_run_data: AutomationRunData, speckle_token: str): + """Run an integration test for the automate function.""" + automation_context = run_function( + automate_function, + automation_run_data, + speckle_token, + FunctionInputs(forbidden_speckle_type="Base"), + ) + + assert automation_context.run_status == AutomationStatus.FAILED + status = get_automation_status( + automation_run_data.project_id, + automation_run_data.model_id, + automation_context.speckle_client, + ) + assert status["status"] == automation_context.run_status + status_message = status["automationRuns"][0]["functionRuns"][0]["statusMessage"] + assert status_message == automation_context._automation_result.status_message + + +def test_file_uploads(automation_run_data: AutomationRunData, speckle_token: str): + """Test file store capabilities of the automate sdk.""" + automation_context = AutomationContext.initialize( + automation_run_data, speckle_token + ) + + path = Path(f"./{crypto_random_string(10)}").resolve() + path.write_text("foobar") + + automation_context.store_file_result(path) + + os.remove(path) + assert len(automation_context._automation_result.blobs) == 1