diff --git a/.coveragerc b/.coveragerc index 68ecdd7..c13b06c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,3 +2,7 @@ exclude_lines = pragma: no cover if\s+(typing\.)?TYPE_CHECKING: + +[run] +parallel = True +concurrency = multiprocessing, thread diff --git a/poetry.lock b/poetry.lock index 17dd5b5..af0cf1b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -53,6 +53,17 @@ files = [ {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, ] +[[package]] +name = "blinker" +version = "1.6.3" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.7" +files = [ + {file = "blinker-1.6.3-py3-none-any.whl", hash = "sha256:296320d6c28b006eb5e32d4712202dbcdcbf5dc482da298c2f44881c43884aaa"}, + {file = "blinker-1.6.3.tar.gz", hash = "sha256:152090d27c1c5c722ee7e48504b02d76502811ce02e1523553b4cf8c8b3d3a8d"}, +] + [[package]] name = "certifi" version = "2023.7.22" @@ -64,6 +75,70 @@ files = [ {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "charset-normalizer" version = "3.3.0" @@ -377,6 +452,60 @@ files = [ [package.extras] tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] +[[package]] +name = "flask" +version = "3.0.0" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "flask-3.0.0-py3-none-any.whl", hash = "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638"}, + {file = "flask-3.0.0.tar.gz", hash = "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=3.0.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "flask-cors" +version = "4.0.0" +description = "A Flask extension adding a decorator for CORS support" +optional = false +python-versions = "*" +files = [ + {file = "Flask-Cors-4.0.0.tar.gz", hash = "sha256:f268522fcb2f73e2ecdde1ef45e2fd5c71cc48fe03cffb4b441c6d1b40684eb0"}, + {file = "Flask_Cors-4.0.0-py2.py3-none-any.whl", hash = "sha256:bc3492bfd6368d27cfe79c7821df5a8a319e1a6d5eab277a3794be19bdc51783"}, +] + +[package.dependencies] +Flask = ">=0.9" + +[[package]] +name = "flask-sock" +version = "0.7.0" +description = "WebSocket support for Flask" +optional = false +python-versions = ">=3.6" +files = [ + {file = "flask-sock-0.7.0.tar.gz", hash = "sha256:e023b578284195a443b8d8bdb4469e6a6acf694b89aeb51315b1a34fcf427b7d"}, + {file = "flask_sock-0.7.0-py3-none-any.whl", hash = "sha256:caac4d679392aaf010d02fabcf73d52019f5bdaf1c9c131ec5a428cb3491204a"}, +] + +[package.dependencies] +flask = ">=2" +simple-websocket = ">=0.5.1" + +[package.extras] +docs = ["sphinx"] + [[package]] name = "fonttools" version = "4.43.0" @@ -442,6 +571,68 @@ ufo = ["fs (>=2.2.0,<3)"] unicode = ["unicodedata2 (>=15.0.0)"] woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] +[[package]] +name = "gevent" +version = "23.9.1" +description = "Coroutine-based network library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "gevent-23.9.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:a3c5e9b1f766a7a64833334a18539a362fb563f6c4682f9634dea72cbe24f771"}, + {file = "gevent-23.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b101086f109168b23fa3586fccd1133494bdb97f86920a24dc0b23984dc30b69"}, + {file = "gevent-23.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36a549d632c14684bcbbd3014a6ce2666c5f2a500f34d58d32df6c9ea38b6535"}, + {file = "gevent-23.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:272cffdf535978d59c38ed837916dfd2b5d193be1e9e5dcc60a5f4d5025dd98a"}, + {file = "gevent-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcb8612787a7f4626aa881ff15ff25439561a429f5b303048f0fca8a1c781c39"}, + {file = "gevent-23.9.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d57737860bfc332b9b5aa438963986afe90f49645f6e053140cfa0fa1bdae1ae"}, + {file = "gevent-23.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5f3c781c84794926d853d6fb58554dc0dcc800ba25c41d42f6959c344b4db5a6"}, + {file = "gevent-23.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:dbb22a9bbd6a13e925815ce70b940d1578dbe5d4013f20d23e8a11eddf8d14a7"}, + {file = "gevent-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:707904027d7130ff3e59ea387dddceedb133cc742b00b3ffe696d567147a9c9e"}, + {file = "gevent-23.9.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:45792c45d60f6ce3d19651d7fde0bc13e01b56bb4db60d3f32ab7d9ec467374c"}, + {file = "gevent-23.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e24c2af9638d6c989caffc691a039d7c7022a31c0363da367c0d32ceb4a0648"}, + {file = "gevent-23.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e1ead6863e596a8cc2a03e26a7a0981f84b6b3e956101135ff6d02df4d9a6b07"}, + {file = "gevent-23.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65883ac026731ac112184680d1f0f1e39fa6f4389fd1fc0bf46cc1388e2599f9"}, + {file = "gevent-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7af500da05363e66f122896012acb6e101a552682f2352b618e541c941a011"}, + {file = "gevent-23.9.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c3e5d2fa532e4d3450595244de8ccf51f5721a05088813c1abd93ad274fe15e7"}, + {file = "gevent-23.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c84d34256c243b0a53d4335ef0bc76c735873986d478c53073861a92566a8d71"}, + {file = "gevent-23.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ada07076b380918829250201df1d016bdafb3acf352f35e5693b59dceee8dd2e"}, + {file = "gevent-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:921dda1c0b84e3d3b1778efa362d61ed29e2b215b90f81d498eb4d8eafcd0b7a"}, + {file = "gevent-23.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ed7a048d3e526a5c1d55c44cb3bc06cfdc1947d06d45006cc4cf60dedc628904"}, + {file = "gevent-23.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c1abc6f25f475adc33e5fc2dbcc26a732608ac5375d0d306228738a9ae14d3b"}, + {file = "gevent-23.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4368f341a5f51611411ec3fc62426f52ac3d6d42eaee9ed0f9eebe715c80184e"}, + {file = "gevent-23.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:52b4abf28e837f1865a9bdeef58ff6afd07d1d888b70b6804557e7908032e599"}, + {file = "gevent-23.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52e9f12cd1cda96603ce6b113d934f1aafb873e2c13182cf8e86d2c5c41982ea"}, + {file = "gevent-23.9.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:de350fde10efa87ea60d742901e1053eb2127ebd8b59a7d3b90597eb4e586599"}, + {file = "gevent-23.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fde6402c5432b835fbb7698f1c7f2809c8d6b2bd9d047ac1f5a7c1d5aa569303"}, + {file = "gevent-23.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:dd6c32ab977ecf7c7b8c2611ed95fa4aaebd69b74bf08f4b4960ad516861517d"}, + {file = "gevent-23.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:455e5ee8103f722b503fa45dedb04f3ffdec978c1524647f8ba72b4f08490af1"}, + {file = "gevent-23.9.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:7ccf0fd378257cb77d91c116e15c99e533374a8153632c48a3ecae7f7f4f09fe"}, + {file = "gevent-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d163d59f1be5a4c4efcdd13c2177baaf24aadf721fdf2e1af9ee54a998d160f5"}, + {file = "gevent-23.9.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7532c17bc6c1cbac265e751b95000961715adef35a25d2b0b1813aa7263fb397"}, + {file = "gevent-23.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:78eebaf5e73ff91d34df48f4e35581ab4c84e22dd5338ef32714264063c57507"}, + {file = "gevent-23.9.1-cp38-cp38-win32.whl", hash = "sha256:f632487c87866094546a74eefbca2c74c1d03638b715b6feb12e80120960185a"}, + {file = "gevent-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:62d121344f7465e3739989ad6b91f53a6ca9110518231553fe5846dbe1b4518f"}, + {file = "gevent-23.9.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:bf456bd6b992eb0e1e869e2fd0caf817f0253e55ca7977fd0e72d0336a8c1c6a"}, + {file = "gevent-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43daf68496c03a35287b8b617f9f91e0e7c0d042aebcc060cadc3f049aadd653"}, + {file = "gevent-23.9.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7c28e38dcde327c217fdafb9d5d17d3e772f636f35df15ffae2d933a5587addd"}, + {file = "gevent-23.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fae8d5b5b8fa2a8f63b39f5447168b02db10c888a3e387ed7af2bd1b8612e543"}, + {file = "gevent-23.9.1-cp39-cp39-win32.whl", hash = "sha256:2c7b5c9912378e5f5ccf180d1fdb1e83f42b71823483066eddbe10ef1a2fcaa2"}, + {file = "gevent-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:a2898b7048771917d85a1d548fd378e8a7b2ca963db8e17c6d90c76b495e0e2b"}, + {file = "gevent-23.9.1.tar.gz", hash = "sha256:72c002235390d46f94938a96920d8856d4ffd9ddf62a303a0d7c118894097e34"}, +] + +[package.dependencies] +cffi = {version = ">=1.12.2", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} +greenlet = {version = ">=3.0rc3", markers = "platform_python_implementation == \"CPython\" and python_version >= \"3.11\""} +"zope.event" = "*" +"zope.interface" = "*" + +[package.extras] +dnspython = ["dnspython (>=1.16.0,<2.0)", "idna"] +docs = ["furo", "repoze.sphinx.autointerface", "sphinx", "sphinxcontrib-programoutput", "zope.schema"] +monitor = ["psutil (>=5.7.0)"] +recommended = ["cffi (>=1.12.2)", "dnspython (>=1.16.0,<2.0)", "idna", "psutil (>=5.7.0)"] +test = ["cffi (>=1.12.2)", "coverage (>=5.0)", "dnspython (>=1.16.0,<2.0)", "idna", "objgraph", "psutil (>=5.7.0)", "requests", "setuptools"] + [[package]] name = "ghp-import" version = "2.1.0" @@ -459,6 +650,87 @@ python-dateutil = ">=2.8.1" [package.extras] dev = ["flake8", "markdown", "twine", "wheel"] +[[package]] +name = "greenlet" +version = "3.0.1" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f89e21afe925fcfa655965ca8ea10f24773a1791400989ff32f467badfe4a064"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28e89e232c7593d33cac35425b58950789962011cc274aa43ef8865f2e11f46d"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8ba29306c5de7717b5761b9ea74f9c72b9e2b834e24aa984da99cbfc70157fd"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19bbdf1cce0346ef7341705d71e2ecf6f41a35c311137f29b8a2dc2341374565"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:599daf06ea59bfedbec564b1692b0166a0045f32b6f0933b0dd4df59a854caf2"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b641161c302efbb860ae6b081f406839a8b7d5573f20a455539823802c655f63"}, + {file = "greenlet-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d57e20ba591727da0c230ab2c3f200ac9d6d333860d85348816e1dca4cc4792e"}, + {file = "greenlet-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5805e71e5b570d490938d55552f5a9e10f477c19400c38bf1d5190d760691846"}, + {file = "greenlet-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:52e93b28db27ae7d208748f45d2db8a7b6a380e0d703f099c949d0f0d80b70e9"}, + {file = "greenlet-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f7bfb769f7efa0eefcd039dd19d843a4fbfbac52f1878b1da2ed5793ec9b1a65"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e6c7db42638dc45cf2e13c73be16bf83179f7859b07cfc139518941320be96"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1757936efea16e3f03db20efd0cd50a1c86b06734f9f7338a90c4ba85ec2ad5a"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19075157a10055759066854a973b3d1325d964d498a805bb68a1f9af4aaef8ec"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9d21aaa84557d64209af04ff48e0ad5e28c5cca67ce43444e939579d085da72"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2847e5d7beedb8d614186962c3d774d40d3374d580d2cbdab7f184580a39d234"}, + {file = "greenlet-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:97e7ac860d64e2dcba5c5944cfc8fa9ea185cd84061c623536154d5a89237884"}, + {file = "greenlet-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b2c02d2ad98116e914d4f3155ffc905fd0c025d901ead3f6ed07385e19122c94"}, + {file = "greenlet-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:22f79120a24aeeae2b4471c711dcf4f8c736a2bb2fabad2a67ac9a55ea72523c"}, + {file = "greenlet-3.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:100f78a29707ca1525ea47388cec8a049405147719f47ebf3895e7509c6446aa"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60d5772e8195f4e9ebf74046a9121bbb90090f6550f81d8956a05387ba139353"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:daa7197b43c707462f06d2c693ffdbb5991cbb8b80b5b984007de431493a319c"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea6b8aa9e08eea388c5f7a276fabb1d4b6b9d6e4ceb12cc477c3d352001768a9"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d11ebbd679e927593978aa44c10fc2092bc454b7d13fdc958d3e9d508aba7d0"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbd4c177afb8a8d9ba348d925b0b67246147af806f0b104af4d24f144d461cd5"}, + {file = "greenlet-3.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20107edf7c2c3644c67c12205dc60b1bb11d26b2610b276f97d666110d1b511d"}, + {file = "greenlet-3.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8bef097455dea90ffe855286926ae02d8faa335ed8e4067326257cb571fc1445"}, + {file = "greenlet-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:b2d3337dcfaa99698aa2377c81c9ca72fcd89c07e7eb62ece3f23a3fe89b2ce4"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80ac992f25d10aaebe1ee15df45ca0d7571d0f70b645c08ec68733fb7a020206"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:337322096d92808f76ad26061a8f5fccb22b0809bea39212cd6c406f6a7060d2"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9934adbd0f6e476f0ecff3c94626529f344f57b38c9a541f87098710b18af0a"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc4d815b794fd8868c4d67602692c21bf5293a75e4b607bb92a11e821e2b859a"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41bdeeb552d814bcd7fb52172b304898a35818107cc8778b5101423c9017b3de"}, + {file = "greenlet-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6e6061bf1e9565c29002e3c601cf68569c450be7fc3f7336671af7ddb4657166"}, + {file = "greenlet-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:fa24255ae3c0ab67e613556375a4341af04a084bd58764731972bcbc8baeba36"}, + {file = "greenlet-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:b489c36d1327868d207002391f662a1d163bdc8daf10ab2e5f6e41b9b96de3b1"}, + {file = "greenlet-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f33f3258aae89da191c6ebaa3bc517c6c4cbc9b9f689e5d8452f7aedbb913fa8"}, + {file = "greenlet-3.0.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:d2905ce1df400360463c772b55d8e2518d0e488a87cdea13dd2c71dcb2a1fa16"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a02d259510b3630f330c86557331a3b0e0c79dac3d166e449a39363beaae174"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55d62807f1c5a1682075c62436702aaba941daa316e9161e4b6ccebbbf38bda3"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fcc780ae8edbb1d050d920ab44790201f027d59fdbd21362340a85c79066a74"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eddd98afc726f8aee1948858aed9e6feeb1758889dfd869072d4465973f6bfd"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eabe7090db68c981fca689299c2d116400b553f4b713266b130cfc9e2aa9c5a9"}, + {file = "greenlet-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f2f6d303f3dee132b322a14cd8765287b8f86cdc10d2cb6a6fae234ea488888e"}, + {file = "greenlet-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d923ff276f1c1f9680d32832f8d6c040fe9306cbfb5d161b0911e9634be9ef0a"}, + {file = "greenlet-3.0.1-cp38-cp38-win32.whl", hash = "sha256:0b6f9f8ca7093fd4433472fd99b5650f8a26dcd8ba410e14094c1e44cd3ceddd"}, + {file = "greenlet-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:990066bff27c4fcf3b69382b86f4c99b3652bab2a7e685d968cd4d0cfc6f67c6"}, + {file = "greenlet-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ce85c43ae54845272f6f9cd8320d034d7a946e9773c693b27d620edec825e376"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ee2e967bd7ff85d84a2de09df10e021c9b38c7d91dead95b406ed6350c6997"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87c8ceb0cf8a5a51b8008b643844b7f4a8264a2c13fcbcd8a8316161725383fe"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6a8c9d4f8692917a3dc7eb25a6fb337bff86909febe2f793ec1928cd97bedfc"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fbc5b8f3dfe24784cee8ce0be3da2d8a79e46a276593db6868382d9c50d97b1"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85d2b77e7c9382f004b41d9c72c85537fac834fb141b0296942d52bf03fe4a3d"}, + {file = "greenlet-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:696d8e7d82398e810f2b3622b24e87906763b6ebfd90e361e88eb85b0e554dc8"}, + {file = "greenlet-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:329c5a2e5a0ee942f2992c5e3ff40be03e75f745f48847f118a3cfece7a28546"}, + {file = "greenlet-3.0.1-cp39-cp39-win32.whl", hash = "sha256:cf868e08690cb89360eebc73ba4be7fb461cfbc6168dd88e2fbbe6f31812cd57"}, + {file = "greenlet-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:ac4a39d1abae48184d420aa8e5e63efd1b75c8444dd95daa3e03f6c6310e9619"}, + {file = "greenlet-3.0.1.tar.gz", hash = "sha256:816bd9488a94cba78d93e1abb58000e8266fa9cc2aa9ccdd6eb0696acb24005b"}, +] + +[package.extras] +docs = ["Sphinx"] +test = ["objgraph", "psutil"] + +[[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 = "idna" version = "3.4" @@ -551,6 +823,17 @@ qtconsole = ["qtconsole"] test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.7" +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + [[package]] name = "jedi" version = "0.19.1" @@ -1433,6 +1716,17 @@ files = [ [package.extras] tests = ["pytest"] +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + [[package]] name = "pygments" version = "2.16.1" @@ -2052,6 +2346,23 @@ docs = ["entangled-cli[rich]", "mkdocs", "mkdocs-entangled-plugin", "mkdocs-mate rich = ["rich"] test = ["build", "pytest", "rich", "wheel"] +[[package]] +name = "simple-websocket" +version = "1.0.0" +description = "Simple WebSocket server and client for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "simple-websocket-1.0.0.tar.gz", hash = "sha256:17d2c72f4a2bd85174a97e3e4c88b01c40c3f81b7b648b0cc3ce1305968928c8"}, + {file = "simple_websocket-1.0.0-py3-none-any.whl", hash = "sha256:1d5bf585e415eaa2083e2bcf02a3ecf91f9712e7b3e6b9fa0b461ad04e0837bc"}, +] + +[package.dependencies] +wsproto = "*" + +[package.extras] +docs = ["sphinx"] + [[package]] name = "six" version = "1.16.0" @@ -2214,7 +2525,109 @@ files = [ {file = "wcwidth-0.2.8.tar.gz", hash = "sha256:8705c569999ffbb4f6a87c6d1b80f324bd6db952f5eb0b95bc07517f4c1813d4"}, ] +[[package]] +name = "werkzeug" +version = "3.0.1" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"}, + {file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[[package]] +name = "wsproto" +version = "1.2.0" +description = "WebSockets state-machine based protocol implementation" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, + {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, +] + +[package.dependencies] +h11 = ">=0.9.0,<1" + +[[package]] +name = "zope-event" +version = "5.0" +description = "Very basic event publishing system" +optional = false +python-versions = ">=3.7" +files = [ + {file = "zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26"}, + {file = "zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd"}, +] + +[package.dependencies] +setuptools = "*" + +[package.extras] +docs = ["Sphinx"] +test = ["zope.testrunner"] + +[[package]] +name = "zope-interface" +version = "6.1" +description = "Interfaces for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "zope.interface-6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:43b576c34ef0c1f5a4981163b551a8781896f2a37f71b8655fd20b5af0386abb"}, + {file = "zope.interface-6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67be3ca75012c6e9b109860820a8b6c9a84bfb036fbd1076246b98e56951ca92"}, + {file = "zope.interface-6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9bc671626281f6045ad61d93a60f52fd5e8209b1610972cf0ef1bbe6d808e3"}, + {file = "zope.interface-6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbe81def9cf3e46f16ce01d9bfd8bea595e06505e51b7baf45115c77352675fd"}, + {file = "zope.interface-6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dc998f6de015723196a904045e5a2217f3590b62ea31990672e31fbc5370b41"}, + {file = "zope.interface-6.1-cp310-cp310-win_amd64.whl", hash = "sha256:239a4a08525c080ff833560171d23b249f7f4d17fcbf9316ef4159f44997616f"}, + {file = "zope.interface-6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9ffdaa5290422ac0f1688cb8adb1b94ca56cee3ad11f29f2ae301df8aecba7d1"}, + {file = "zope.interface-6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34c15ca9248f2e095ef2e93af2d633358c5f048c49fbfddf5fdfc47d5e263736"}, + {file = "zope.interface-6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b012d023b4fb59183909b45d7f97fb493ef7a46d2838a5e716e3155081894605"}, + {file = "zope.interface-6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97806e9ca3651588c1baaebb8d0c5ee3db95430b612db354c199b57378312ee8"}, + {file = "zope.interface-6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fddbab55a2473f1d3b8833ec6b7ac31e8211b0aa608df5ab09ce07f3727326de"}, + {file = "zope.interface-6.1-cp311-cp311-win_amd64.whl", hash = "sha256:a0da79117952a9a41253696ed3e8b560a425197d4e41634a23b1507efe3273f1"}, + {file = "zope.interface-6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8bb9c990ca9027b4214fa543fd4025818dc95f8b7abce79d61dc8a2112b561a"}, + {file = "zope.interface-6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b51b64432eed4c0744241e9ce5c70dcfecac866dff720e746d0a9c82f371dfa7"}, + {file = "zope.interface-6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa6fd016e9644406d0a61313e50348c706e911dca29736a3266fc9e28ec4ca6d"}, + {file = "zope.interface-6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c8cf55261e15590065039696607f6c9c1aeda700ceee40c70478552d323b3ff"}, + {file = "zope.interface-6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e30506bcb03de8983f78884807e4fd95d8db6e65b69257eea05d13d519b83ac0"}, + {file = "zope.interface-6.1-cp312-cp312-win_amd64.whl", hash = "sha256:e33e86fd65f369f10608b08729c8f1c92ec7e0e485964670b4d2633a4812d36b"}, + {file = "zope.interface-6.1-cp37-cp37m-macosx_11_0_x86_64.whl", hash = "sha256:2f8d89721834524a813f37fa174bac074ec3d179858e4ad1b7efd4401f8ac45d"}, + {file = "zope.interface-6.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13b7d0f2a67eb83c385880489dbb80145e9d344427b4262c49fbf2581677c11c"}, + {file = "zope.interface-6.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef43ee91c193f827e49599e824385ec7c7f3cd152d74cb1dfe02cb135f264d83"}, + {file = "zope.interface-6.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e441e8b7d587af0414d25e8d05e27040d78581388eed4c54c30c0c91aad3a379"}, + {file = "zope.interface-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f89b28772fc2562ed9ad871c865f5320ef761a7fcc188a935e21fe8b31a38ca9"}, + {file = "zope.interface-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:70d2cef1bf529bff41559be2de9d44d47b002f65e17f43c73ddefc92f32bf00f"}, + {file = "zope.interface-6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ad54ed57bdfa3254d23ae04a4b1ce405954969c1b0550cc2d1d2990e8b439de1"}, + {file = "zope.interface-6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef467d86d3cfde8b39ea1b35090208b0447caaabd38405420830f7fd85fbdd56"}, + {file = "zope.interface-6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6af47f10cfc54c2ba2d825220f180cc1e2d4914d783d6fc0cd93d43d7bc1c78b"}, + {file = "zope.interface-6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9559138690e1bd4ea6cd0954d22d1e9251e8025ce9ede5d0af0ceae4a401e43"}, + {file = "zope.interface-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:964a7af27379ff4357dad1256d9f215047e70e93009e532d36dcb8909036033d"}, + {file = "zope.interface-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:387545206c56b0315fbadb0431d5129c797f92dc59e276b3ce82db07ac1c6179"}, + {file = "zope.interface-6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:57d0a8ce40ce440f96a2c77824ee94bf0d0925e6089df7366c2272ccefcb7941"}, + {file = "zope.interface-6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ebc4d34e7620c4f0da7bf162c81978fce0ea820e4fa1e8fc40ee763839805f3"}, + {file = "zope.interface-6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a804abc126b33824a44a7aa94f06cd211a18bbf31898ba04bd0924fbe9d282d"}, + {file = "zope.interface-6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f294a15f7723fc0d3b40701ca9b446133ec713eafc1cc6afa7b3d98666ee1ac"}, + {file = "zope.interface-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:a41f87bb93b8048fe866fa9e3d0c51e27fe55149035dcf5f43da4b56732c0a40"}, + {file = "zope.interface-6.1.tar.gz", hash = "sha256:2fdc7ccbd6eb6b7df5353012fbed6c3c5d04ceaca0038f75e601060e95345309"}, +] + +[package.dependencies] +setuptools = "*" + +[package.extras] +docs = ["Sphinx", "repoze.sphinx.autointerface", "sphinx-rtd-theme"] +test = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] +testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] + [metadata] lock-version = "2.0" python-versions = "^3.11,<3.13" -content-hash = "382f517371633a4c34d09fe27260d724586ff5e6c9e6436e2f48d21cc480f1ed" +content-hash = "d3feaaef7a0261fb0a4bf92e3f1a40f7e0157a6e2b4490983f1671abcc4e4a64" diff --git a/pyproject.toml b/pyproject.toml index 2a8f856..1429b53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,10 @@ packages = [ [tool.poetry.dependencies] python = "^3.11,<3.13" safe-ds = ">=0.14,<0.17" +flask = "^3.0.0" +flask-cors = "^4.0.0" +flask-sock = "^0.7.0" +gevent = "^23.9.1" [tool.poetry.dev-dependencies] pytest = "^7.4.3" diff --git a/src/safeds_runner/server/__init__.py b/src/safeds_runner/server/__init__.py new file mode 100644 index 0000000..91a0395 --- /dev/null +++ b/src/safeds_runner/server/__init__.py @@ -0,0 +1 @@ +"""Infrastructure for dynamically running Safe-DS pipelines and communication with the VS Code extension.""" diff --git a/src/safeds_runner/server/main.py b/src/safeds_runner/server/main.py new file mode 100644 index 0000000..a8a8984 --- /dev/null +++ b/src/safeds_runner/server/main.py @@ -0,0 +1,188 @@ +"""Module containing the main entry point, for starting the Safe-DS runner.""" + +import argparse +import json +import logging + +import flask.app +import flask_sock +import simple_websocket +from flask import Flask +from flask_cors import CORS +from flask_sock import Sock + +from safeds_runner.server import messages +from safeds_runner.server.messages import ( + Message, + create_placeholder_value, + message_type_placeholder_value, + parse_validate_message, +) +from safeds_runner.server.pipeline_manager import PipelineManager + + +def create_flask_app(testing: bool = False) -> flask.app.App: + """ + Create a flask app, that handles all requests. + + Parameters + ---------- + testing : bool + Whether the app should run in a testing context. + + Returns + ------- + flask.app.App + Flask app. + """ + flask_app = Flask(__name__) + # Websocket Configuration + flask_app.config["SOCK_SERVER_OPTIONS"] = {"ping_interval": 25} + flask_app.config["TESTING"] = testing + + # Allow access from VSCode extension + CORS(flask_app, resources={r"/*": {"origins": "vscode-webview://*"}}) + return flask_app + + +def create_flask_websocket(flask_app: flask.app.App) -> flask_sock.Sock: + """ + Create a flask websocket extension. + + Parameters + ---------- + flask_app: flask.app.App + Flask App Instance. + + Returns + ------- + flask_sock.Sock + Websocket extension for the provided flask app. + """ + return Sock(flask_app) + + +app = create_flask_app() +sock = create_flask_websocket(app) +app_pipeline_manager = PipelineManager() + + +@sock.route("/WSMain") +def _ws_main(ws: simple_websocket.Server) -> None: + ws_main(ws, app_pipeline_manager) # pragma: no cover + + +def ws_main(ws: simple_websocket.Server, pipeline_manager: PipelineManager) -> None: + """ + Handle websocket requests to the WSMain endpoint. + + This function handles the bidirectional communication between the runner and the VS Code extension. + + Parameters + ---------- + ws : simple_websocket.Server + Websocket Connection, provided by flask. + pipeline_manager : PipelineManager + Manager used to execute pipelines on, and retrieve placeholders from + """ + logging.debug("Request to WSRunProgram") + pipeline_manager.set_new_websocket_target(ws) + while True: + # This would be a JSON message + received_message: str = ws.receive() + if received_message is None: + logging.debug("Received EOF, closing connection") + ws.close() + return + logging.debug("Received Message: %s", received_message) + received_object, error_detail, error_short = parse_validate_message(received_message) + if received_object is None: + logging.error(error_detail) + ws.close(message=error_short) + return + match received_object.type: + case "program": + program_data, invalid_message = messages.validate_program_message_data(received_object.data) + if program_data is None: + logging.error("Invalid message data specified in: %s (%s)", received_message, invalid_message) + ws.close(None, invalid_message) + return + # This should only be called from the extension as it is a security risk + pipeline_manager.execute_pipeline(program_data, received_object.id) + case "placeholder_query": + placeholder_query_data, invalid_message = messages.validate_placeholder_query_message_data( + received_object.data, + ) + if placeholder_query_data is None: + logging.error("Invalid message data specified in: %s (%s)", received_message, invalid_message) + ws.close(None, invalid_message) + return + placeholder_type, placeholder_value = pipeline_manager.get_placeholder( + received_object.id, + placeholder_query_data, + ) + # send back a value message + if placeholder_type is not None: + send_websocket_message( + ws, + Message( + message_type_placeholder_value, + received_object.id, + create_placeholder_value(placeholder_query_data, placeholder_type, placeholder_value), + ), + ) + else: + # Send back empty type / value, to communicate that no placeholder exists (yet) + # Use name from query to allow linking a response to a request on the peer + send_websocket_message( + ws, + Message( + message_type_placeholder_value, + received_object.id, + create_placeholder_value(placeholder_query_data, "", ""), + ), + ) + case _: + if received_object.type not in messages.message_types: + logging.warning("Invalid message type: %s", received_object.type) + + +def send_websocket_message(connection: simple_websocket.Server, message: Message) -> None: + """ + Send any message to the VS Code extension. + + Parameters + ---------- + connection : simple_websocket.Server + Websocket connection. + message : Message + Object that will be sent. + """ + connection.send(json.dumps(message.to_dict())) + + +def main() -> None: # pragma: no cover + """ + Execute the runner application. + + Main entry point of the runner application. + """ + # Allow prints to be unbuffered by default + import builtins + import functools + + builtins.print = functools.partial(print, flush=True) # type: ignore[assignment] + + logging.getLogger().setLevel(logging.DEBUG) + from gevent.pywsgi import WSGIServer + + parser = argparse.ArgumentParser(description="Start Safe-DS Runner on a specific port.") + parser.add_argument("--port", type=int, default=5000, help="Port on which to run the python server.") + args = parser.parse_args() + logging.info("Starting Safe-DS Runner on port %s", str(args.port)) + # Only bind to host=127.0.0.1. Connections from other devices should not be accepted + WSGIServer(("127.0.0.1", args.port), app).serve_forever() + + +if __name__ == "__main__": + main() # pragma: no cover diff --git a/src/safeds_runner/server/messages.py b/src/safeds_runner/server/messages.py new file mode 100644 index 0000000..68056e6 --- /dev/null +++ b/src/safeds_runner/server/messages.py @@ -0,0 +1,330 @@ +"""Module that contains functions for creating and validating messages exchanged with the vscode extension.""" + +from __future__ import annotations + +import dataclasses +import json +from dataclasses import dataclass +from typing import Any + +message_type_program = "program" +message_type_placeholder_query = "placeholder_query" +message_type_placeholder_type = "placeholder_type" +message_type_placeholder_value = "placeholder_value" +message_type_runtime_error = "runtime_error" +message_type_runtime_progress = "runtime_progress" + +message_types = [ + message_type_program, + message_type_placeholder_query, + message_type_placeholder_type, + message_type_placeholder_value, + message_type_runtime_error, + message_type_runtime_progress, +] + + +@dataclass(frozen=True) +class Message: + """ + A message object, which is exchanged between the runner and the VS Code extension. + + Parameters + ---------- + type : str + Type that identifies the kind of message. + id : str + ID that identifies the execution where this message belongs to. + data : Any + Message data section. Differs between message types. + """ + + type: str + id: str + data: Any + + @staticmethod + def from_dict(d: dict[str, Any]) -> Message: + """ + Create a new Message object from a dictionary. + + Parameters + ---------- + d : dict[str, Any] + Dictionary which should contain all needed fields. + + Returns + ------- + Message + Dataclass which contains information copied from the provided dictionary. + """ + return Message(**d) + + def to_dict(self) -> dict[str, Any]: + """ + Convert this dataclass to a dictionary. + + Returns + ------- + dict[str, Any] + Dictionary containing all the fields which are part of this dataclass. + """ + return dataclasses.asdict(self) + + +@dataclass(frozen=True) +class MessageDataProgram: + """ + Message data for a program message. + + Parameters + ---------- + code : dict[str, dict[str, str]] + A dictionary containing the code needed for executed, + in a virtual filesystem. Keys of the outer dictionary are the module path, keys of the inner dictionary are the + module name. The values of the inner dictionary is the python code for each module. + main : ProgramMainInformation + Information where the main pipeline (the pipeline to be executed) is located. + """ + + code: dict[str, dict[str, str]] + main: ProgramMainInformation + + @staticmethod + def from_dict(d: dict[str, Any]) -> MessageDataProgram: + """ + Create a new MessageDataProgram object from a dictionary. + + Parameters + ---------- + d : dict[str, Any] + Dictionary which should contain all needed fields. + + Returns + ------- + MessageDataProgram + Dataclass which contains information copied from the provided dictionary. + """ + return MessageDataProgram(d["code"], ProgramMainInformation.from_dict(d["main"])) + + def to_dict(self) -> dict[str, Any]: + """ + Convert this dataclass to a dictionary. + + Returns + ------- + dict[str, Any] + Dictionary containing all the fields which are part of this dataclass. + """ + return dataclasses.asdict(self) # pragma: no cover + + +@dataclass(frozen=True) +class ProgramMainInformation: + """ + Information that can be used to locate a pipeline. + + Parameters + ---------- + modulepath : str + Path, where the main module is located. + module : str + Safe-DS module name. + pipeline : str + Safe-DS pipeline name. + """ + + modulepath: str + module: str + pipeline: str + + @staticmethod + def from_dict(d: dict[str, Any]) -> ProgramMainInformation: + """ + Create a new ProgramMainInformation object from a dictionary. + + Parameters + ---------- + d : dict[str, Any] + Dictionary which should contain all needed fields. + + Returns + ------- + ProgramMainInformation + Dataclass which contains information copied from the provided dictionary. + """ + return ProgramMainInformation(**d) + + def to_dict(self) -> dict[str, Any]: + """ + Convert this dataclass to a dictionary. + + Returns + ------- + dict[str, Any] + Dictionary containing all the fields which are part of this dataclass. + """ + return dataclasses.asdict(self) # pragma: no cover + + +def create_placeholder_description(name: str, type_: str) -> dict[str, str]: + """ + Create the message data of a placeholder description message containing only name and type. + + Parameters + ---------- + name : str + Name of the placeholder. + type_ : str + Type of the placeholder. + + Returns + ------- + dict[str, str] + Message data of "placeholder_type" messages. + """ + return {"name": name, "type": type_} + + +def create_placeholder_value(name: str, type_: str, value: Any) -> dict[str, Any]: + """ + Create the message data of a placeholder value message containing name, type and the actual value. + + Parameters + ---------- + name : str + Name of the placeholder. + type_ : str + Type of the placeholder. + value : Any + Value of the placeholder. + + Returns + ------- + dict[str, str] + Message data of "placeholder_value" messages. + """ + return {"name": name, "type": type_, "value": value} + + +def create_runtime_error_description(message: str, backtrace: list[dict[str, Any]]) -> dict[str, Any]: + """ + Create the message data of a runtime error message containing error information and a backtrace. + + Parameters + ---------- + message : str + Error information message. + backtrace : list[dict[str, Any]] + Python backtrace of the error. Each list entry represents a stack frame. + + Returns + ------- + dict[str, Any] + Message data of "runtime_error" messages. + """ + return {"message": message, "backtrace": backtrace} + + +def create_runtime_progress_done() -> str: + """ + Create the message data of a runtime progress message containing 'done'. + + Returns + ------- + str + Message data of "runtime_progress" messages. + """ + return "done" + + +def parse_validate_message(message: str) -> tuple[Message | None, str | None, str | None]: + """ + Validate the basic structure of a received message string and return a parsed message object. + + Parameters + ---------- + message : str + Message string, that should be in JSON format. + + Returns + ------- + tuple[Message | None, str | None, str | None] + A tuple containing either a message or a detailed error description and a short error message. + """ + try: + message_dict: dict[str, Any] = json.loads(message) + except json.JSONDecodeError: + return None, f"Invalid message received: {message}", "Invalid Message: not JSON" + if "type" not in message_dict: + return None, f"No message type specified in: {message}", "Invalid Message: no type" + if "id" not in message_dict: + return None, f"No message id specified in: {message}", "Invalid Message: no id" + if "data" not in message_dict: + return None, f"No message data specified in: {message}", "Invalid Message: no data" + if not isinstance(message_dict["type"], str): + return None, f"Message type is not a string: {message}", "Invalid Message: invalid type" + if not isinstance(message_dict["id"], str): + return None, f"Message id is not a string: {message}", "Invalid Message: invalid id" + return Message.from_dict(message_dict), None, None + + +def validate_program_message_data(message_data: dict[str, Any] | str) -> tuple[MessageDataProgram | None, str | None]: + """ + Validate the message data of a program message. + + Parameters + ---------- + message_data : dict[str, Any] | str + Message data dictionary or string. + + Returns + ------- + tuple[MessageDataProgram | None, str | None] + A tuple containing either a validated message data object or an error message. + """ + if not isinstance(message_data, dict): + return None, "Message data is not a JSON object" + if "code" not in message_data: + return None, "No 'code' parameter given" + if "main" not in message_data: + return None, "No 'main' parameter given" + if ( + not isinstance(message_data["main"], dict) + or "modulepath" not in message_data["main"] + or "module" not in message_data["main"] + or "pipeline" not in message_data["main"] + ): + return None, "Invalid 'main' parameter given" + if len(message_data["main"]) != 3: + return None, "Invalid 'main' parameter given" + if not isinstance(message_data["code"], dict): + return None, "Invalid 'code' parameter given" + code: dict = message_data["code"] + for key in code: + if not isinstance(code[key], dict): + return None, "Invalid 'code' parameter given" + next_dict: dict = code[key] + for next_key in next_dict: + if not isinstance(next_dict[next_key], str): + return None, "Invalid 'code' parameter given" + return MessageDataProgram.from_dict(message_data), None + + +def validate_placeholder_query_message_data(message_data: dict[str, Any] | str) -> tuple[str | None, str | None]: + """ + Validate the message data of a placeholder query message. + + Parameters + ---------- + message_data : dict[str, Any] | str + Message data dictionary or string. + + Returns + ------- + tuple[str | None, str | None] + A tuple containing either a validated message data as a string or an error message. + """ + if not isinstance(message_data, str): + return None, "Message data is not a string" + return message_data, None diff --git a/src/safeds_runner/server/module_manager.py b/src/safeds_runner/server/module_manager.py new file mode 100644 index 0000000..2aabacb --- /dev/null +++ b/src/safeds_runner/server/module_manager.py @@ -0,0 +1,156 @@ +"""Module that contains the infrastructure for finding and loading modules in-memory.""" + +import importlib.abc +import importlib.util +import logging +import sys +import types +import typing +from abc import ABC +from importlib.machinery import ModuleSpec + + +class InMemoryLoader(importlib.abc.SourceLoader, ABC): + """Load a virtual python module from a byte array and a filename.""" + + def __init__(self, code_bytes: bytes, filename: str): + """ + Create a new in-memory loader. + + Parameters + ---------- + code_bytes : bytes + Byte array containing python code. + filename : str + Filename of the python module. + """ + self.code_bytes = code_bytes + self.filename = filename + + def get_data(self, _path: bytes | str) -> bytes: + """ + Get module code as a byte array. + + Parameters + ---------- + _path : bytes | str + Module path. + + Returns + ------- + bytes + Module code. + """ + return self.code_bytes + + def get_filename(self, _fullname: str) -> str: + """ + Get file name for a module path. + + Parameters + ---------- + _fullname : str + Module path. + + Returns + ------- + str + virtual module path, as located in the code array in the InMemoryFinder that created this loader. + """ + return self.filename + + +class InMemoryFinder(importlib.abc.MetaPathFinder): + """Find python modules in an in-memory dictionary.""" + + def __init__(self, code: dict[str, dict[str, str]]): + """ + Create a new in-memory finder. + + Parameters + ---------- + code : dict[str, dict[str, str]] + A dictionary containing the code to be executed, + grouped by module path containing a mapping from module name to module code. + """ + self.code = code + self.allowed_packages = set(code.keys()) + self.imports_to_remove: set[str] = set() + for key in code: + self._add_possible_packages_for_package_path(key) + + def _add_possible_packages_for_package_path(self, package_path: str) -> None: + while "." in package_path: + package_path = package_path.rpartition(".")[0] + self.allowed_packages.add(package_path) + + def find_spec( + self, + fullname: str, + path: typing.Sequence[str] | None = None, + target: types.ModuleType | None = None, + ) -> ModuleSpec | None: + """ + Find a module which may be registered in the code dictionary. + + Parameters + ---------- + fullname : str + Full module path (separated with '.'). + path : typing.Sequence[str] | None + Module Path. + target : types.ModuleType | None + Module Type. + + Returns + ------- + ModuleSpec | None + A module spec, if found. None otherwise + """ + logging.debug("Find Spec: %s %s %s", fullname, path, target) + if fullname in self.allowed_packages: + parent_package = importlib.util.spec_from_loader( + fullname, + loader=InMemoryLoader(b"", fullname.replace(".", "/")), + ) + if parent_package is None: + return None # pragma: no cover + if parent_package.submodule_search_locations is None: + parent_package.submodule_search_locations = [] + parent_package.submodule_search_locations.append(fullname.replace(".", "/")) + self.imports_to_remove.add(fullname) + return parent_package + module_path = fullname.split(".") + if len(module_path) == 1 and "" in self.code and fullname in self.code[""]: + self.imports_to_remove.add(fullname) + return importlib.util.spec_from_loader( + fullname, + loader=InMemoryLoader(self.code[""][fullname].encode("utf-8"), fullname.replace(".", "/")), + origin="", + ) + parent_package_path = ".".join(module_path[:-1]) + submodule_name = module_path[-1] + if parent_package_path in self.code and submodule_name in self.code[parent_package_path]: + self.imports_to_remove.add(fullname) + return importlib.util.spec_from_loader( + fullname, + loader=InMemoryLoader( + self.code[parent_package_path][submodule_name].encode("utf-8"), + fullname.replace(".", "/"), + ), + origin=parent_package_path, + ) + return None # pragma: no cover + + def attach(self) -> None: + """Attach this finder to the meta path.""" + sys.meta_path.append(self) + + def detach(self) -> None: + """Remove modules found in this finder and remove finder from meta path.""" + # As modules should not be used from other modules, outside our pipeline, + # it should be safe to just remove all newly imported modules + for key in self.imports_to_remove: + if key in sys.modules: + del sys.modules[key] + sys.meta_path.remove(self) diff --git a/src/safeds_runner/server/pipeline_manager.py b/src/safeds_runner/server/pipeline_manager.py new file mode 100644 index 0000000..53c0992 --- /dev/null +++ b/src/safeds_runner/server/pipeline_manager.py @@ -0,0 +1,296 @@ +"""Module that contains the infrastructure for pipeline execution in child processes.""" + +import json +import logging +import multiprocessing +import queue +import runpy +import threading +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from multiprocessing.managers import SyncManager +from typing import Any + +import simple_websocket +import stack_data + +from safeds_runner.server.messages import ( + Message, + MessageDataProgram, + create_placeholder_description, + create_runtime_error_description, + create_runtime_progress_done, + message_type_placeholder_type, + message_type_runtime_error, + message_type_runtime_progress, +) +from safeds_runner.server.module_manager import InMemoryFinder + + +class PipelineManager: + """ + A PipelineManager handles the execution of pipelines and processing of results. + + This includes launching subprocesses and the communication between the + subprocess and the main process using a shared message queue. + """ + + def __init__(self) -> None: + """ + Prepare the runner for running Safe-DS pipelines. + + Firstly, structures shared between processes are created. + After that a message queue handling thread is started in the main process. + This allows receiving messages directly from one of the pipeline processes and relaying information + directly to the websocket connection (to the VS Code extension). + """ + self._multiprocessing_manager: SyncManager = multiprocessing.Manager() + self._placeholder_map: dict = {} + self._messages_queue: queue.Queue[Message] = self._multiprocessing_manager.Queue() + self._websocket_target: simple_websocket.Server | None = None + self._messages_queue_thread: threading.Thread = threading.Thread( + target=self._handle_queue_messages, + daemon=True, + ) + self._messages_queue_thread.start() + + def _handle_queue_messages(self) -> None: + """ + Relay messages from pipeline processes to the currently connected websocket endpoint. + + Should be used in a dedicated thread. + """ + try: + while self._messages_queue is not None: + message = self._messages_queue.get() + if self._websocket_target is not None: + self._websocket_target.send(json.dumps(message.to_dict())) + except BaseException as error: # noqa: BLE001 # pragma: no cover + logging.warning("Message queue terminated: %s", error.__repr__()) # pragma: no cover + + def set_new_websocket_target(self, websocket_connection: simple_websocket.Server) -> None: + """ + Change the websocket connection to relay messages to, which are occurring during pipeline execution. + + Parameters + ---------- + websocket_connection : simple_websocket.Server + New websocket connection. + """ + self._websocket_target = websocket_connection + + def execute_pipeline( + self, + pipeline: MessageDataProgram, + execution_id: str, + ) -> None: + """ + Run a Safe-DS pipeline. + + Parameters + ---------- + pipeline : MessageDataProgram + Message object that contains the information to run a pipeline. + execution_id : str + Unique ID to identify this execution. + """ + if execution_id not in self._placeholder_map: + self._placeholder_map[execution_id] = self._multiprocessing_manager.dict() + process = PipelineProcess( + pipeline, + execution_id, + self._messages_queue, + self._placeholder_map[execution_id], + ) + process.execute() + + def get_placeholder(self, execution_id: str, placeholder_name: str) -> tuple[str | None, Any]: + """ + Get a placeholder type and value for an execution id and placeholder name. + + Parameters + ---------- + execution_id : str + Unique ID identifying the execution in which the placeholder was calculated. + placeholder_name : str + Name of the placeholder. + + Returns + ------- + tuple[str | None, Any] + Tuple containing placeholder type and placeholder value, or (None, None) if the placeholder does not exist. + """ + if execution_id not in self._placeholder_map: + return None, None + if placeholder_name not in self._placeholder_map[execution_id]: + return None, None + value = self._placeholder_map[execution_id][placeholder_name] + return _get_placeholder_type(value), value + + +class PipelineProcess: + """A process that executes a Safe-DS pipeline.""" + + def __init__( + self, + pipeline: MessageDataProgram, + execution_id: str, + messages_queue: queue.Queue[Message], + placeholder_map: dict[str, Any], + ): + """ + Create a new process which will execute the given pipeline, when started. + + Parameters + ---------- + pipeline : MessageDataProgram + Message object that contains the information to run a pipeline. + execution_id : str + Unique ID to identify this process. + messages_queue : queue.Queue[Message] + A queue to write outgoing messages to. + placeholder_map : dict[str, Any] + A map to save calculated placeholders in. + """ + self._pipeline = pipeline + self._id = execution_id + self._messages_queue = messages_queue + self._placeholder_map = placeholder_map + self._process = multiprocessing.Process(target=self._execute, daemon=True) + + def _send_message(self, message_type: str, value: dict[Any, Any] | str) -> None: + self._messages_queue.put(Message(message_type, self._id, value)) + + def _send_exception(self, exception: BaseException) -> None: + backtrace = get_backtrace_info(exception) + self._send_message(message_type_runtime_error, create_runtime_error_description(exception.__str__(), backtrace)) + + def save_placeholder(self, placeholder_name: str, value: Any) -> None: + """ + Save a calculated placeholder in the map. + + Parameters + ---------- + placeholder_name : str + Name of the placeholder. + value : Any + Actual value of the placeholder. + """ + self._placeholder_map[placeholder_name] = value + placeholder_type = _get_placeholder_type(value) + self._send_message( + message_type_placeholder_type, + create_placeholder_description(placeholder_name, placeholder_type), + ) + + def _execute(self) -> None: + logging.info( + "Executing %s.%s.%s...", + self._pipeline.main.modulepath, + self._pipeline.main.module, + self._pipeline.main.pipeline, + ) + pipeline_finder = InMemoryFinder(self._pipeline.code) + pipeline_finder.attach() + main_module = f"gen_{self._pipeline.main.module}_{self._pipeline.main.pipeline}" + # Populate current_pipeline global, so child process can save placeholders in correct location + globals()["current_pipeline"] = self + try: + runpy.run_module( + ( + main_module + if len(self._pipeline.main.modulepath) == 0 + else f"{self._pipeline.main.modulepath}.{main_module}" + ), + run_name="__main__", + alter_sys=True, + ) + self._send_message(message_type_runtime_progress, create_runtime_progress_done()) + except BaseException as error: # noqa: BLE001 + self._send_exception(error) + finally: + pipeline_finder.detach() + + def execute(self) -> None: + """ + Execute this pipeline in a newly created process. + + Results, progress and errors are communicated back to the main process. + """ + self._process.start() + + +# Pipeline process object visible in child process +current_pipeline: PipelineProcess | None = None + + +def runner_save_placeholder(placeholder_name: str, value: Any) -> None: + """ + Save a placeholder for the current running pipeline. + + Parameters + ---------- + placeholder_name : str + Name of the placeholder. + value : Any + Actual value of the placeholder. + """ + if current_pipeline is not None: + current_pipeline.save_placeholder(placeholder_name, value) + + +def get_backtrace_info(error: BaseException) -> list[dict[str, Any]]: + """ + Create a simplified backtrace from an exception. + + Parameters + ---------- + error : BaseException + Caught exception. + + Returns + ------- + list[dict[str, Any]] + List containing file and line information for each stack frame. + """ + backtrace_list = [] + for frame in stack_data.core.FrameInfo.stack_data(error.__traceback__): + backtrace_list.append({"file": frame.filename, "line": int(frame.lineno)}) + return backtrace_list + + +def _get_placeholder_type(value: Any) -> str: + """ + Convert a python object to a Safe-DS type. + + Parameters + ---------- + value : Any + A python object. + + Returns + ------- + str + Safe-DS name corresponding to the given python object instance. + """ + match value: + case bool(): + return "Boolean" + case float(): + return "Float" + case int(): + return "Int" + case str(): + return "String" + case object(): + object_name = type(value).__name__ + match object_name: + case "function": + return "Callable" + case "NoneType": + return "Null" + case _: + return object_name + case _: # pragma: no cover + return "Any" # pragma: no cover diff --git a/tests/safeds_runner/server/__init__.py b/tests/safeds_runner/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/safeds_runner/server/test_pipeline_manager_type_conversion.py b/tests/safeds_runner/server/test_pipeline_manager_type_conversion.py new file mode 100644 index 0000000..313795f --- /dev/null +++ b/tests/safeds_runner/server/test_pipeline_manager_type_conversion.py @@ -0,0 +1,49 @@ +from typing import Any + +import pytest +from safeds_runner.server.pipeline_manager import _get_placeholder_type + + +@pytest.mark.parametrize( + argnames="value,type_", + argvalues=[ + (True, "Boolean"), + (False, "Boolean"), + (1.23, "Float"), + (4.156e5, "Float"), + (-1.23e5, "Float"), + (1, "Int"), + (-2, "Int"), + (0, "Int"), + ("abc", "String"), + ("18", "String"), + ("96.51615", "String"), + ("True", "String"), + ("False", "String"), + ("1.3e5", "String"), + (object(), "object"), + (None, "Null"), + (lambda x: x + 1, "Callable"), + ], + ids=[ + "boolean_true", + "boolean_false", + "float", + "float_exp", + "float_negative", + "int", + "int_negative", + "int_zero", + "string", + "string_int", + "string_float", + "string_boolean_true", + "string_boolean_false", + "string_float_exp", + "object", + "null", + "callable", + ], +) +def test_should_placeholder_type_match_safeds_dsl_placeholder(value: Any, type_: str) -> None: + assert _get_placeholder_type(value=value) == type_ diff --git a/tests/safeds_runner/server/test_websocket_mock.py b/tests/safeds_runner/server/test_websocket_mock.py new file mode 100644 index 0000000..f428ed1 --- /dev/null +++ b/tests/safeds_runner/server/test_websocket_mock.py @@ -0,0 +1,364 @@ +import json +import os +import sys +import threading + +import pytest +from safeds_runner.server.main import app_pipeline_manager, ws_main +from safeds_runner.server.messages import ( + Message, + create_placeholder_description, + create_placeholder_value, + create_runtime_progress_done, + message_type_placeholder_type, + message_type_placeholder_value, + message_type_runtime_error, + message_type_runtime_progress, +) + + +class MockWebsocketConnection: + def __init__(self, messages: list[str]): + self.messages = messages + self.received: list[str] = [] + self.close_reason: int | None = None + self.close_message: str | None = None + self.condition_variable = threading.Condition() + + def send(self, msg: str) -> None: + self.received.append(msg) + with self.condition_variable: + self.condition_variable.notify_all() + + def receive(self) -> str | None: + if len(self.messages) == 0: + return None + return self.messages.pop(0) + + def close(self, reason: int | None = None, message: str | None = None) -> None: + self.close_reason = reason + self.close_message = message + + def wait_for_messages(self, wait_for_messages: int = 1) -> None: + while True: + with self.condition_variable: + if len(self.received) >= wait_for_messages: + return + self.condition_variable.wait() + + +@pytest.mark.parametrize( + argnames="websocket_message,exception_message", + argvalues=[ + ("", "Invalid Message: not JSON"), + (json.dumps({"id": "a", "data": "b"}), "Invalid Message: no type"), + (json.dumps({"type": "a", "data": "b"}), "Invalid Message: no id"), + (json.dumps({"type": "b", "id": "123"}), "Invalid Message: no data"), + (json.dumps({"type": {"program": "2"}, "id": "123", "data": "a"}), "Invalid Message: invalid type"), + (json.dumps({"type": "c", "id": {"": "1233"}, "data": "a"}), "Invalid Message: invalid id"), + (json.dumps({"type": "program", "id": "1234", "data": "a"}), "Message data is not a JSON object"), + (json.dumps({"type": "placeholder_query", "id": "123", "data": {"a": "v"}}), "Message data is not a string"), + ( + json.dumps({ + "type": "program", + "id": "1234", + "data": {"main": {"modulepath": "1", "module": "2", "pipeline": "3"}}, + }), + "No 'code' parameter given", + ), + ( + json.dumps({"type": "program", "id": "1234", "data": {"code": {"": {"entry": ""}}}}), + "No 'main' parameter given", + ), + ( + json.dumps({ + "type": "program", + "id": "1234", + "data": {"code": {"": {"entry": ""}}, "main": {"modulepath": "1", "module": "2"}}, + }), + "Invalid 'main' parameter given", + ), + ( + json.dumps({ + "type": "program", + "id": "1234", + "data": {"code": {"": {"entry": ""}}, "main": {"modulepath": "1", "pipeline": "3"}}, + }), + "Invalid 'main' parameter given", + ), + ( + json.dumps({ + "type": "program", + "id": "1234", + "data": {"code": {"": {"entry": ""}}, "main": {"module": "2", "pipeline": "3"}}, + }), + "Invalid 'main' parameter given", + ), + ( + json.dumps({ + "type": "program", + "id": "1234", + "data": { + "code": {"": {"entry": ""}}, + "main": {"modulepath": "1", "module": "2", "pipeline": "3", "other": "4"}, + }, + }), + "Invalid 'main' parameter given", + ), + ( + json.dumps({ + "type": "program", + "id": "1234", + "data": { + "code": {"": {"entry": ""}}, + "main": {"modulepath": "1", "module": "2", "pipeline": "3", "other": {"4": "a"}}, + }, + }), + "Invalid 'main' parameter given", + ), + ( + json.dumps({ + "type": "program", + "id": "1234", + "data": {"code": "a", "main": {"modulepath": "1", "module": "2", "pipeline": "3"}}, + }), + "Invalid 'code' parameter given", + ), + ( + json.dumps({ + "type": "program", + "id": "1234", + "data": {"code": {"": "a"}, "main": {"modulepath": "1", "module": "2", "pipeline": "3"}}, + }), + "Invalid 'code' parameter given", + ), + ( + json.dumps({ + "type": "program", + "id": "1234", + "data": {"code": {"": {"a": {"b": "c"}}}, "main": {"modulepath": "1", "module": "2", "pipeline": "3"}}, + }), + "Invalid 'code' parameter given", + ), + ], + ids=[ + "no_json", + "any_no_type", + "any_no_id", + "any_no_data", + "any_invalid_type", + "any_invalid_id", + "program_invalid_data", + "placeholder_query_invalid_data", + "program_no_code", + "program_no_main", + "program_invalid_main1", + "program_invalid_main2", + "program_invalid_main3", + "program_invalid_main4", + "program_invalid_main5", + "program_invalid_code1", + "program_invalid_code2", + "program_invalid_code3", + ], +) +def test_should_fail_message_validation(websocket_message: str, exception_message: str) -> None: + mock_connection = MockWebsocketConnection([websocket_message]) + ws_main(mock_connection, app_pipeline_manager) + assert str(mock_connection.close_message) == exception_message + + +@pytest.mark.skipif( + sys.platform.startswith("win") and os.getenv("COVERAGE_RCFILE") is not None, + reason=( + "skipping multiprocessing tests on windows if coverage is enabled, as pytest " + "causes Manager to hang, when using multiprocessing coverage" + ), +) +@pytest.mark.parametrize( + argnames="messages,expected_response_runtime_error", + argvalues=[ + ( + [ + json.dumps({ + "type": "program", + "id": "abcdefgh", + "data": { + "code": { + "": { + "gen_test_a": "def pipe():\n\traise Exception('Test Exception')\n", + "gen_test_a_pipe": ( + "from gen_test_a import pipe\n\nif __name__ == '__main__':\n\tpipe()" + ), + }, + }, + "main": {"modulepath": "", "module": "test_a", "pipeline": "pipe"}, + }, + }), + ], + Message(message_type_runtime_error, "abcdefgh", {"message": "Test Exception"}), + ), + ], + ids=["raise_exception"], +) +def test_should_execute_pipeline_return_exception( + messages: list[str], + expected_response_runtime_error: Message, +) -> None: + mock_connection = MockWebsocketConnection(messages) + ws_main(mock_connection, app_pipeline_manager) + mock_connection.wait_for_messages(1) + exception_message = Message.from_dict(json.loads(mock_connection.received.pop(0))) + + assert exception_message.type == expected_response_runtime_error.type + assert exception_message.id == expected_response_runtime_error.id + assert isinstance(exception_message.data, dict) + assert exception_message.data["message"] == expected_response_runtime_error.data["message"] + assert isinstance(exception_message.data["backtrace"], list) + assert len(exception_message.data["backtrace"]) > 0 + for frame in exception_message.data["backtrace"]: + assert "file" in frame + assert isinstance(frame["file"], str) + assert "line" in frame + assert isinstance(frame["line"], int) + + +@pytest.mark.skipif( + sys.platform.startswith("win") and os.getenv("COVERAGE_RCFILE") is not None, + reason=( + "skipping multiprocessing tests on windows if coverage is enabled, as pytest " + "causes Manager to hang, when using multiprocessing coverage" + ), +) +@pytest.mark.parametrize( + argnames="initial_messages,initial_execution_message_wait,appended_messages,expected_responses", + argvalues=[ + ( + [ + json.dumps({ + "type": "program", + "id": "abcdefg", + "data": { + "code": { + "": { + "gen_test_a": ( + "import safeds_runner.server.pipeline_manager\n\ndef pipe():\n\tvalue1 =" + " 1\n\tsafeds_runner.server.pipeline_manager.runner_save_placeholder('value1'," + " value1)\n" + ), + "gen_test_a_pipe": ( + "from gen_test_a import pipe\n\nif __name__ == '__main__':\n\tpipe()" + ), + }, + }, + "main": {"modulepath": "", "module": "test_a", "pipeline": "pipe"}, + }, + }), + ], + 2, + [ + # Query Placeholder + json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": "value1"}), + # Query invalid placeholder + json.dumps({"type": "placeholder_query", "id": "abcdefg", "data": "value2"}), + ], + [ + # Validate Placeholder Information + Message(message_type_placeholder_type, "abcdefg", create_placeholder_description("value1", "Int")), + # Validate Progress Information + Message(message_type_runtime_progress, "abcdefg", create_runtime_progress_done()), + # Query Result Valid + Message(message_type_placeholder_value, "abcdefg", create_placeholder_value("value1", "Int", 1)), + # Query Result Invalid + Message(message_type_placeholder_value, "abcdefg", create_placeholder_value("value2", "", "")), + ], + ), + ], + ids=["query_valid_query_invalid"], +) +def test_should_execute_pipeline_return_valid_placeholder( + initial_messages: list[str], + initial_execution_message_wait: int, + appended_messages: list[str], + expected_responses: list[Message], +) -> None: + # Initial execution + mock_connection = MockWebsocketConnection(initial_messages) + ws_main(mock_connection, app_pipeline_manager) + # Wait for at least enough messages to successfully execute pipeline + mock_connection.wait_for_messages(initial_execution_message_wait) + # Now send queries + mock_connection.messages.extend(appended_messages) + ws_main(mock_connection, app_pipeline_manager) + # And compare with expected responses + while len(expected_responses) > 0: + mock_connection.wait_for_messages(1) + next_message = Message.from_dict(json.loads(mock_connection.received.pop(0))) + assert next_message == expected_responses.pop(0) + + +@pytest.mark.skipif( + sys.platform.startswith("win") and os.getenv("COVERAGE_RCFILE") is not None, + reason=( + "skipping multiprocessing tests on windows if coverage is enabled, as pytest " + "causes Manager to hang, when using multiprocessing coverage" + ), +) +@pytest.mark.parametrize( + argnames="messages,expected_response", + argvalues=[ + ( + [ + json.dumps({ + "type": "program", + "id": "123456789", + "data": { + "code": { + "": { + "gen_b": ( + "import safeds_runner.codegen\n" + "from a.stub import u\n" + "from v.u.s.testing import add1\n" + "\n" + "def c():\n" + "\ta1 = 1\n" + "\ta2 = safeds_runner.codegen.eager_or(True, False)\n" + "\tprint('test2')\n" + "\tprint('new dynamic output')\n" + "\tprint(f'Add1: {add1(1, 2)}')\n" + "\treturn a1 + a2\n" + ), + "gen_b_c": "from gen_b import c\n\nif __name__ == '__main__':\n\tc()", + }, + "a": {"stub": "def u():\n\treturn 1"}, + "v.u.s": { + "testing": "import a.stub;\n\ndef add1(v1, v2):\n\treturn v1 + v2 + a.stub.u()\n", + }, + }, + "main": {"modulepath": "", "module": "b", "pipeline": "c"}, + }, + }), + ], + Message(message_type_runtime_progress, "123456789", create_runtime_progress_done()), + ), + ( + # Query Result Invalid (no pipeline exists) + [ + json.dumps({"type": "invalid_message_type", "id": "unknown-code-id-never-generated", "data": ""}), + json.dumps({"type": "placeholder_query", "id": "unknown-code-id-never-generated", "data": "v"}), + ], + Message( + message_type_placeholder_value, + "unknown-code-id-never-generated", + create_placeholder_value("v", "", ""), + ), + ), + ], + ids=["progress_message_done", "invalid_message_invalid_placeholder_query"], +) +def test_should_successfully_execute_simple_flow(messages: list[str], expected_response: Message) -> None: + mock_connection = MockWebsocketConnection(messages) + ws_main(mock_connection, app_pipeline_manager) + mock_connection.wait_for_messages(1) + query_result_invalid = Message.from_dict(json.loads(mock_connection.received.pop(0))) + assert query_result_invalid == expected_response