From aac9cba5b9e2db8ff8c220f80d34cc77e9956387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jero=CC=81nimo=20Albi?= Date: Sun, 5 Jul 2020 11:46:01 +0200 Subject: [PATCH] Code refactor with typing and pure python3 --- .coveragerc | 8 + .flake8 | 5 +- .gitignore | 4 +- .isort.cfg | 3 + CHANGELOG.md | 4 + LICENSE | 2 +- Pipfile | 23 + Pipfile.lock | 424 +++++ README.md | 4 +- docs/Makefile | 20 - docs/source/conf.py | 138 -- docs/source/index.rst | 23 - docs/source/sdk/kusanagi.rst | 105 - docs/source/sdk/kusanagi.sdk.http.rst | 18 - docs/source/sdk/kusanagi.sdk.rst | 98 - docs/source/sdk/kusanagi.sdk.schema.rst | 42 - kusanagi/__init__.py | 17 +- kusanagi/errors.py | 26 - kusanagi/logging.py | 203 -- kusanagi/middleware.py | 192 -- kusanagi/payload.py | 500 ----- kusanagi/schema.py | 99 - kusanagi/sdk/__init__.py | 38 +- kusanagi/sdk/action.py | 1693 +++++++++-------- kusanagi/sdk/actiondata.py | 54 + kusanagi/sdk/api.py | 164 ++ kusanagi/sdk/base.py | 217 --- kusanagi/sdk/callee.py | 79 + kusanagi/sdk/caller.py | 49 + kusanagi/sdk/component.py | 167 +- kusanagi/sdk/error.py | 77 + kusanagi/sdk/file.py | 392 ++-- kusanagi/sdk/http/__init__.py | 0 kusanagi/sdk/http/request.py | 446 ----- kusanagi/sdk/http/response.py | 299 --- kusanagi/sdk/lib/__init__.py | 10 + kusanagi/sdk/lib/asynchronous.py | 296 +++ kusanagi/sdk/lib/call.py | 179 ++ kusanagi/sdk/lib/cli.py | 245 +++ kusanagi/sdk/lib/datatypes.py | 29 + kusanagi/sdk/{schema => lib}/error.py | 5 +- kusanagi/sdk/lib/events.py | 68 + kusanagi/sdk/lib/formatting.py | 69 + kusanagi/{ => sdk/lib}/json.py | 37 +- kusanagi/sdk/lib/logging.py | 243 +++ kusanagi/sdk/lib/msgpack.py | 96 + kusanagi/sdk/lib/payload/__init__.py | 226 +++ kusanagi/sdk/lib/payload/action.py | 127 ++ kusanagi/sdk/lib/payload/command.py | 128 ++ kusanagi/sdk/lib/payload/error.py | 45 + kusanagi/sdk/lib/payload/file.py | 35 + kusanagi/sdk/lib/payload/mapping.py | 52 + kusanagi/sdk/lib/payload/ns.py | 145 ++ kusanagi/sdk/lib/payload/param.py | 35 + kusanagi/sdk/lib/payload/reply.py | 136 ++ kusanagi/sdk/lib/payload/request.py | 12 + kusanagi/sdk/lib/payload/response.py | 58 + kusanagi/sdk/lib/payload/service.py | 69 + kusanagi/sdk/lib/payload/transport.py | 506 +++++ kusanagi/sdk/lib/payload/utils.py | 114 ++ kusanagi/sdk/lib/server.py | 388 ++++ kusanagi/sdk/lib/singleton.py | 29 + kusanagi/sdk/lib/state.py | 95 + kusanagi/sdk/lib/version.py | 163 ++ kusanagi/sdk/link.py | 47 + kusanagi/sdk/middleware.py | 56 +- kusanagi/sdk/param.py | 460 +++-- kusanagi/sdk/relation.py | 110 ++ kusanagi/sdk/request.py | 596 ++++-- kusanagi/sdk/response.py | 315 ++- kusanagi/sdk/runner.py | 463 ----- kusanagi/sdk/schema/__init__.py | 0 kusanagi/sdk/schema/action.py | 564 ------ kusanagi/sdk/schema/file.py | 130 -- kusanagi/sdk/schema/param.py | 270 --- kusanagi/sdk/schema/service.py | 120 -- kusanagi/sdk/service.py | 132 +- kusanagi/sdk/servicedata.py | 55 + kusanagi/sdk/transaction.py | 79 + kusanagi/sdk/transport.py | 210 ++ kusanagi/sdk/transport/__init__.py | 288 --- kusanagi/sdk/transport/call.py | 155 -- kusanagi/sdk/transport/data.py | 106 -- kusanagi/sdk/transport/error.py | 84 - kusanagi/sdk/transport/link.py | 56 - kusanagi/sdk/transport/relation.py | 118 -- kusanagi/sdk/transport/transaction.py | 86 - kusanagi/serialization.py | 103 - kusanagi/server.py | 448 ----- kusanagi/service.py | 161 -- kusanagi/urn.py | 34 - kusanagi/utils.py | 829 -------- kusanagi/versions.py | 153 -- mypy.ini | 7 + pylama.ini | 5 +- pylintrc | 1 + pytest.ini | 4 + setup.py | 20 +- tests/conftest.py | 268 ++- tests/data/file.txt | 1 + tests/data/foo.json | 1 - tests/data/schema-file.json | 12 - tests/data/schema-param.json | 24 - tests/data/schema-service.json | 124 -- tests/data/transport.json | 223 --- tests/sdk/http/test_http_request.py | 153 -- tests/sdk/http/test_http_response.py | 89 - tests/sdk/schema/test_schema_action.py | 165 -- tests/sdk/schema/test_schema_file.py | 40 - tests/sdk/schema/test_schema_param.py | 66 - tests/sdk/schema/test_schema_service.py | 48 - tests/sdk/test_action.py | 823 -------- tests/sdk/test_base.py | 159 -- tests/sdk/test_component.py | 84 - tests/sdk/test_file.py | 149 -- tests/sdk/test_middleware.py | 29 - tests/sdk/test_param.py | 113 -- tests/sdk/test_request.py | 188 -- tests/sdk/test_response.py | 83 - tests/sdk/test_runner.py | 300 --- tests/sdk/test_service.py | 21 - tests/sdk/test_transport.py | 257 --- tests/test_action.py | 847 +++++++++ tests/test_actiondata.py | 27 + tests/test_api.py | 63 + tests/test_callee.py | 54 + tests/test_caller.py | 18 + tests/test_component.py | 96 + tests/test_error.py | 43 + tests/test_errors.py | 36 - tests/test_file.py | 361 ++++ tests/test_json.py | 69 - tests/test_lib_asynchronous.py | 92 + tests/test_lib_call.py | 250 +++ tests/test_lib_cli.py | 173 ++ tests/test_lib_events.py | 76 + tests/test_lib_formatting.py | 45 + tests/test_lib_json.py | 49 + tests/test_lib_logging.py | 298 +++ tests/test_lib_msgpack.py | 61 + tests/test_lib_payload.py | 132 ++ tests/test_lib_payload_action.py | 113 ++ tests/test_lib_payload_command.py | 78 + tests/test_lib_payload_error.py | 34 + tests/test_lib_payload_mapping.py | 45 + tests/test_lib_payload_reply.py | 99 + tests/test_lib_payload_transport.py | 370 ++++ tests/test_lib_payload_utils.py | 84 + tests/test_lib_server.py | 647 +++++++ tests/test_lib_singleton.py | 21 + .../{test_versions.py => test_lib_version.py} | 119 +- tests/test_link.py | 17 + tests/test_logging.py | 77 - tests/test_middleware.py | 31 + tests/test_param.py | 242 +++ tests/test_payload.py | 322 ---- tests/test_relation.py | 37 + tests/test_request.py | 253 +++ tests/test_response.py | 191 ++ tests/test_schema.py | 43 - tests/test_serialization.py | 99 - tests/test_service.py | 73 + tests/test_servicedata.py | 24 + tests/test_transaction.py | 54 + tests/test_transport.py | 370 ++++ tests/test_urn.py | 18 - tests/test_utils.py | 713 ------- tests/tests_lib_state.py | 35 + 168 files changed, 13104 insertions(+), 12853 deletions(-) create mode 100644 .coveragerc create mode 100644 .isort.cfg create mode 100644 Pipfile create mode 100644 Pipfile.lock delete mode 100644 docs/Makefile delete mode 100644 docs/source/conf.py delete mode 100644 docs/source/index.rst delete mode 100644 docs/source/sdk/kusanagi.rst delete mode 100644 docs/source/sdk/kusanagi.sdk.http.rst delete mode 100644 docs/source/sdk/kusanagi.sdk.rst delete mode 100644 docs/source/sdk/kusanagi.sdk.schema.rst delete mode 100644 kusanagi/errors.py delete mode 100644 kusanagi/logging.py delete mode 100644 kusanagi/middleware.py delete mode 100644 kusanagi/payload.py delete mode 100644 kusanagi/schema.py create mode 100644 kusanagi/sdk/actiondata.py create mode 100644 kusanagi/sdk/api.py delete mode 100644 kusanagi/sdk/base.py create mode 100644 kusanagi/sdk/callee.py create mode 100644 kusanagi/sdk/caller.py create mode 100644 kusanagi/sdk/error.py delete mode 100644 kusanagi/sdk/http/__init__.py delete mode 100644 kusanagi/sdk/http/request.py delete mode 100644 kusanagi/sdk/http/response.py create mode 100644 kusanagi/sdk/lib/__init__.py create mode 100644 kusanagi/sdk/lib/asynchronous.py create mode 100644 kusanagi/sdk/lib/call.py create mode 100644 kusanagi/sdk/lib/cli.py create mode 100644 kusanagi/sdk/lib/datatypes.py rename kusanagi/sdk/{schema => lib}/error.py (70%) create mode 100644 kusanagi/sdk/lib/events.py create mode 100644 kusanagi/sdk/lib/formatting.py rename kusanagi/{ => sdk/lib}/json.py (53%) create mode 100644 kusanagi/sdk/lib/logging.py create mode 100644 kusanagi/sdk/lib/msgpack.py create mode 100644 kusanagi/sdk/lib/payload/__init__.py create mode 100644 kusanagi/sdk/lib/payload/action.py create mode 100644 kusanagi/sdk/lib/payload/command.py create mode 100644 kusanagi/sdk/lib/payload/error.py create mode 100644 kusanagi/sdk/lib/payload/file.py create mode 100644 kusanagi/sdk/lib/payload/mapping.py create mode 100644 kusanagi/sdk/lib/payload/ns.py create mode 100644 kusanagi/sdk/lib/payload/param.py create mode 100644 kusanagi/sdk/lib/payload/reply.py create mode 100644 kusanagi/sdk/lib/payload/request.py create mode 100644 kusanagi/sdk/lib/payload/response.py create mode 100644 kusanagi/sdk/lib/payload/service.py create mode 100644 kusanagi/sdk/lib/payload/transport.py create mode 100644 kusanagi/sdk/lib/payload/utils.py create mode 100644 kusanagi/sdk/lib/server.py create mode 100644 kusanagi/sdk/lib/singleton.py create mode 100644 kusanagi/sdk/lib/state.py create mode 100644 kusanagi/sdk/lib/version.py create mode 100644 kusanagi/sdk/link.py create mode 100644 kusanagi/sdk/relation.py delete mode 100644 kusanagi/sdk/runner.py delete mode 100644 kusanagi/sdk/schema/__init__.py delete mode 100644 kusanagi/sdk/schema/action.py delete mode 100644 kusanagi/sdk/schema/file.py delete mode 100644 kusanagi/sdk/schema/param.py delete mode 100644 kusanagi/sdk/schema/service.py create mode 100644 kusanagi/sdk/servicedata.py create mode 100644 kusanagi/sdk/transaction.py create mode 100644 kusanagi/sdk/transport.py delete mode 100644 kusanagi/sdk/transport/__init__.py delete mode 100644 kusanagi/sdk/transport/call.py delete mode 100644 kusanagi/sdk/transport/data.py delete mode 100644 kusanagi/sdk/transport/error.py delete mode 100644 kusanagi/sdk/transport/link.py delete mode 100644 kusanagi/sdk/transport/relation.py delete mode 100644 kusanagi/sdk/transport/transaction.py delete mode 100644 kusanagi/serialization.py delete mode 100644 kusanagi/server.py delete mode 100644 kusanagi/service.py delete mode 100644 kusanagi/urn.py delete mode 100644 kusanagi/utils.py delete mode 100644 kusanagi/versions.py create mode 100644 mypy.ini create mode 100644 tests/data/file.txt delete mode 100644 tests/data/foo.json delete mode 100644 tests/data/schema-file.json delete mode 100644 tests/data/schema-param.json delete mode 100644 tests/data/schema-service.json delete mode 100644 tests/data/transport.json delete mode 100644 tests/sdk/http/test_http_request.py delete mode 100644 tests/sdk/http/test_http_response.py delete mode 100644 tests/sdk/schema/test_schema_action.py delete mode 100644 tests/sdk/schema/test_schema_file.py delete mode 100644 tests/sdk/schema/test_schema_param.py delete mode 100644 tests/sdk/schema/test_schema_service.py delete mode 100644 tests/sdk/test_action.py delete mode 100644 tests/sdk/test_base.py delete mode 100644 tests/sdk/test_component.py delete mode 100644 tests/sdk/test_file.py delete mode 100644 tests/sdk/test_middleware.py delete mode 100644 tests/sdk/test_param.py delete mode 100644 tests/sdk/test_request.py delete mode 100644 tests/sdk/test_response.py delete mode 100644 tests/sdk/test_runner.py delete mode 100644 tests/sdk/test_service.py delete mode 100644 tests/sdk/test_transport.py create mode 100644 tests/test_action.py create mode 100644 tests/test_actiondata.py create mode 100644 tests/test_api.py create mode 100644 tests/test_callee.py create mode 100644 tests/test_caller.py create mode 100644 tests/test_component.py create mode 100644 tests/test_error.py delete mode 100644 tests/test_errors.py create mode 100644 tests/test_file.py delete mode 100644 tests/test_json.py create mode 100644 tests/test_lib_asynchronous.py create mode 100644 tests/test_lib_call.py create mode 100644 tests/test_lib_cli.py create mode 100644 tests/test_lib_events.py create mode 100644 tests/test_lib_formatting.py create mode 100644 tests/test_lib_json.py create mode 100644 tests/test_lib_logging.py create mode 100644 tests/test_lib_msgpack.py create mode 100644 tests/test_lib_payload.py create mode 100644 tests/test_lib_payload_action.py create mode 100644 tests/test_lib_payload_command.py create mode 100644 tests/test_lib_payload_error.py create mode 100644 tests/test_lib_payload_mapping.py create mode 100644 tests/test_lib_payload_reply.py create mode 100644 tests/test_lib_payload_transport.py create mode 100644 tests/test_lib_payload_utils.py create mode 100644 tests/test_lib_server.py create mode 100644 tests/test_lib_singleton.py rename tests/{test_versions.py => test_lib_version.py} (51%) create mode 100644 tests/test_link.py delete mode 100644 tests/test_logging.py create mode 100644 tests/test_middleware.py create mode 100644 tests/test_param.py delete mode 100644 tests/test_payload.py create mode 100644 tests/test_relation.py create mode 100644 tests/test_request.py create mode 100644 tests/test_response.py delete mode 100644 tests/test_schema.py delete mode 100644 tests/test_serialization.py create mode 100644 tests/test_service.py create mode 100644 tests/test_servicedata.py create mode 100644 tests/test_transaction.py create mode 100644 tests/test_transport.py delete mode 100644 tests/test_urn.py delete mode 100644 tests/test_utils.py create mode 100644 tests/tests_lib_state.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..7b47f3e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Ignore the blocks that import the modules for type checking + if TYPE_CHECKING: diff --git a/.flake8 b/.flake8 index 2e03cbd..efd3688 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,4 @@ [flake8] -# Github review line length -max-line-length=119 +max-line-length = 119 +ignore = T499 +exclude = .git,__pycache__,.venv,venv,tests,build,dist diff --git a/.gitignore b/.gitignore index 1636246..63e63a8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,9 @@ venv/ *.egg .cache/ .pytest_cache/ -build/ +.mypy_cache/ docs/build/ -Pipfile -Pipfile.lock pip-log.txt pip-delete-this-directory.txt .coverage diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..2d83bc5 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,3 @@ +[settings] +line_length = 119 +force_single_line = true diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb354e..a1ce300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## [2.1.0] - 2020-07-05 +### Changed +- Code refactor with typing and pure python3 + ## [2.0.0] - 2020-03-01 ### Fixed - Fixes Param type resolution when no type is given diff --git a/LICENSE b/LICENSE index e24395c..79cbe6e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016-2019 KUSANAGI S.L. (http://kusanagi.io). All rights reserved. +Copyright (c) 2016-2020 KUSANAGI S.L. (http://kusanagi.io). All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..fdf1d92 --- /dev/null +++ b/Pipfile @@ -0,0 +1,23 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +msgpack = "*" +pyzmq = "*" + +[dev-packages] +pylama = "*" +pytest = "*" +v = {version = "*",editable = true} +pytest-cov = "*" +isort = "*" +"flake8" = "*" +pylint = "*" +pytest-mock = "*" +pytest-mypy = "*" +pytest-asyncio = "*" + +[requires] +python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..5e0f16d --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,424 @@ +{ + "_meta": { + "hash": { + "sha256": "8d6661273584804bc947692ab1bd176507a16d173324a89439d1d04ea7dc658f" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.7" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "msgpack": { + "hashes": [ + "sha256:002a0d813e1f7b60da599bdf969e632074f9eec1b96cbed8fb0973a63160a408", + "sha256:25b3bc3190f3d9d965b818123b7752c5dfb953f0d774b454fd206c18fe384fb8", + "sha256:271b489499a43af001a2e42f42d876bb98ccaa7e20512ff37ca78c8e12e68f84", + "sha256:39c54fdebf5fa4dda733369012c59e7d085ebdfe35b6cf648f09d16708f1be5d", + "sha256:4233b7f86c1208190c78a525cd3828ca1623359ef48f78a6fea4b91bb995775a", + "sha256:5bea44181fc8e18eed1d0cd76e355073f00ce232ff9653a0ae88cb7d9e643322", + "sha256:5dba6d074fac9b24f29aaf1d2d032306c27f04187651511257e7831733293ec2", + "sha256:7a22c965588baeb07242cb561b63f309db27a07382825fc98aecaf0827c1538e", + "sha256:908944e3f038bca67fcfedb7845c4a257c7749bf9818632586b53bcf06ba4b97", + "sha256:9534d5cc480d4aff720233411a1f765be90885750b07df772380b34c10ecb5c0", + "sha256:aa5c057eab4f40ec47ea6f5a9825846be2ff6bf34102c560bad5cad5a677c5be", + "sha256:b3758dfd3423e358bbb18a7cccd1c74228dffa7a697e5be6cb9535de625c0dbf", + "sha256:c901e8058dd6653307906c5f157f26ed09eb94a850dddd989621098d347926ab", + "sha256:cec8bf10981ed70998d98431cd814db0ecf3384e6b113366e7f36af71a0fca08", + "sha256:db685187a415f51d6b937257474ca72199f393dad89534ebbdd7d7a3b000080e", + "sha256:e35b051077fc2f3ce12e7c6a34cf309680c63a842db3a0616ea6ed25ad20d272", + "sha256:e7bbdd8e2b277b77782f3ce34734b0dfde6cbe94ddb74de8d733d603c7f9e2b1", + "sha256:ea41c9219c597f1d2bf6b374d951d310d58684b5de9dc4bd2976db9e1e22c140" + ], + "index": "pypi", + "version": "==1.0.0" + }, + "pyzmq": { + "hashes": [ + "sha256:0bbc1728fe4314b4ca46249c33873a390559edac7c217ec7001b5e0c34a8fb7f", + "sha256:1e076ad5bd3638a18c376544d32e0af986ca10d43d4ce5a5d889a8649f0d0a3d", + "sha256:242d949eb6b10197cda1d1cec377deab1d5324983d77e0d0bf9dc5eb6d71a6b4", + "sha256:26f4ae420977d2a8792d7c2d7bda43128b037b5eeb21c81951a94054ad8b8843", + "sha256:32234c21c5e0a767c754181c8112092b3ddd2e2a36c3f76fc231ced817aeee47", + "sha256:3f12ce1e9cc9c31497bd82b207e8e86ccda9eebd8c9f95053aae46d15ccd2196", + "sha256:4557d5e036e6d85715b4b9fdb482081398da1d43dc580d03db642b91605b409f", + "sha256:4f562dab21c03c7aa061f63b147a595dbe1006bf4f03213272fc9f7d5baec791", + "sha256:5e071b834051e9ecb224915398f474bfad802c2fff883f118ff5363ca4ae3edf", + "sha256:5e1f65e576ab07aed83f444e201d86deb01cd27dcf3f37c727bc8729246a60a8", + "sha256:5f10a31f288bf055be76c57710807a8f0efdb2b82be6c2a2b8f9a61f33a40cea", + "sha256:6aaaf90b420dc40d9a0e1996b82c6a0ff91d9680bebe2135e67c9e6d197c0a53", + "sha256:75238d3c16cab96947705d5709187a49ebb844f54354cdf0814d195dd4c045de", + "sha256:7f7e7b24b1d392bb5947ba91c981e7d1a43293113642e0d8870706c8e70cdc71", + "sha256:84b91153102c4bcf5d0f57d1a66a0f03c31e9e6525a5f656f52fc615a675c748", + "sha256:944f6bb5c63140d76494467444fd92bebd8674236837480a3c75b01fe17df1ab", + "sha256:a1f957c20c9f51d43903881399b078cddcf710d34a2950e88bce4e494dcaa4d1", + "sha256:a49fd42a29c1cc1aa9f461c5f2f5e0303adba7c945138b35ee7f4ab675b9f754", + "sha256:a99ae601b4f6917985e9bb071549e30b6f93c72f5060853e197bdc4b7d357e5f", + "sha256:ad48865a29efa8a0cecf266432ea7bc34e319954e55cf104be0319c177e6c8f5", + "sha256:b08e425cf93b4e018ab21dc8fdbc25d7d0502a23cc4fea2380010cf8cf11e462", + "sha256:bb10361293d96aa92be6261fa4d15476bca56203b3a11c62c61bd14df0ef89ba", + "sha256:bd1a769d65257a7a12e2613070ca8155ee348aa9183f2aadf1c8b8552a5510f5", + "sha256:cb3b7156ef6b1a119e68fbe3a54e0a0c40ecacc6b7838d57dd708c90b62a06dc", + "sha256:e8e4efb52ec2df8d046395ca4c84ae0056cf507b2f713ec803c65a8102d010de", + "sha256:f37c29da2a5b0c5e31e6f8aab885625ea76c807082f70b2d334d3fd573c3100a", + "sha256:f4d558bc5668d2345773a9ff8c39e2462dafcb1f6772a2e582fbced389ce527f", + "sha256:f5b6d015587a1d6f582ba03b226a9ddb1dfb09878b3be04ef48b01b7d4eb6b2a" + ], + "index": "pypi", + "version": "==19.0.0" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:29fa5d46a2404d01c834fcb802a3943685f1fc538eb2a02a161349f5505ac196", + "sha256:2fecea42b20abb1922ed65c7b5be27edfba97211b04b2b6abc6a43549a024ea6" + ], + "version": "==2.4.0" + }, + "attrs": { + "hashes": [ + "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", + "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + ], + "version": "==19.3.0" + }, + "coverage": { + "hashes": [ + "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", + "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", + "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", + "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", + "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", + "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", + "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", + "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", + "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", + "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", + "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", + "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", + "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", + "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", + "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", + "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", + "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", + "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", + "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", + "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", + "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", + "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", + "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", + "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", + "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", + "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", + "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", + "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", + "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", + "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", + "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" + ], + "version": "==5.1" + }, + "entrypoints": { + "hashes": [ + "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", + "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" + ], + "version": "==0.3" + }, + "filelock": { + "hashes": [ + "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", + "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" + ], + "version": "==3.0.12" + }, + "flake8": { + "hashes": [ + "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", + "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca" + ], + "index": "pypi", + "version": "==3.7.9" + }, + "importlib-metadata": { + "hashes": [ + "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f", + "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e" + ], + "markers": "python_version < '3.8'", + "version": "==1.6.0" + }, + "isort": { + "hashes": [ + "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", + "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" + ], + "index": "pypi", + "version": "==4.3.21" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", + "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", + "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", + "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", + "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", + "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", + "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", + "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", + "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", + "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", + "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", + "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", + "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", + "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", + "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", + "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", + "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", + "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", + "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", + "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", + "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" + ], + "version": "==1.4.3" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "more-itertools": { + "hashes": [ + "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c", + "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507" + ], + "version": "==8.2.0" + }, + "mypy": { + "hashes": [ + "sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2", + "sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1", + "sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164", + "sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761", + "sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce", + "sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27", + "sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754", + "sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae", + "sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9", + "sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600", + "sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65", + "sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8", + "sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913", + "sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3" + ], + "markers": "python_version >= '3.5' and python_version < '3.8'", + "version": "==0.770" + }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, + "packaging": { + "hashes": [ + "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3", + "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752" + ], + "version": "==20.3" + }, + "pluggy": { + "hashes": [ + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + ], + "version": "==0.13.1" + }, + "py": { + "hashes": [ + "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", + "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" + ], + "version": "==1.8.1" + }, + "pycodestyle": { + "hashes": [ + "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", + "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" + ], + "version": "==2.5.0" + }, + "pydocstyle": { + "hashes": [ + "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586", + "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5" + ], + "version": "==5.0.2" + }, + "pyflakes": { + "hashes": [ + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" + ], + "version": "==2.1.1" + }, + "pylama": { + "hashes": [ + "sha256:9bae53ef9c1a431371d6a8dca406816a60d547147b60a4934721898f553b7d8f", + "sha256:fd61c11872d6256b019ef1235be37b77c922ef37ac9797df6bd489996dddeb15" + ], + "index": "pypi", + "version": "==7.7.1" + }, + "pylint": { + "hashes": [ + "sha256:588e114e3f9a1630428c35b7dd1c82c1c93e1b0e78ee312ae4724c5e1a1e0245", + "sha256:bd556ba95a4cf55a1fc0004c00cf4560b1e70598a54a74c6904d933c8f3bd5a8" + ], + "index": "pypi", + "version": "==2.5.0" + }, + "pyparsing": { + "hashes": [ + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + ], + "version": "==2.4.7" + }, + "pytest": { + "hashes": [ + "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172", + "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970" + ], + "index": "pypi", + "version": "==5.4.1" + }, + "pytest-asyncio": { + "hashes": [ + "sha256:6096d101a1ae350d971df05e25f4a8b4d3cd13ffb1b32e42d902ac49670d2bfa", + "sha256:c54866f3cf5dd2063992ba2c34784edae11d3ed19e006d220a3cf0bfc4191fcb" + ], + "index": "pypi", + "version": "==0.11.0" + }, + "pytest-cov": { + "hashes": [ + "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b", + "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626" + ], + "index": "pypi", + "version": "==2.8.1" + }, + "pytest-mock": { + "hashes": [ + "sha256:997729451dfc36b851a9accf675488c7020beccda15e11c75632ee3d1b1ccd71", + "sha256:ce610831cedeff5331f4e2fc453a5dd65384303f680ab34bee2c6533855b431c" + ], + "index": "pypi", + "version": "==3.1.0" + }, + "pytest-mypy": { + "hashes": [ + "sha256:2560a9b27d59bb17810d12ec3402dfc7c8e100e40539a70d2814bcbb27240f27", + "sha256:76e705cfd3800bf2b534738e792245ac5bb8d780698d0f8cd6c79032cc5e9923" + ], + "index": "pypi", + "version": "==0.6.2" + }, + "six": { + "hashes": [ + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + ], + "version": "==1.14.0" + }, + "snowballstemmer": { + "hashes": [ + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" + ], + "version": "==2.0.0" + }, + "toml": { + "hashes": [ + "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", + "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" + ], + "version": "==0.10.0" + }, + "typed-ast": { + "hashes": [ + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + ], + "markers": "implementation_name == 'cpython' and python_version < '3.8'", + "version": "==1.4.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", + "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", + "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" + ], + "version": "==3.7.4.2" + }, + "v": { + "hashes": [ + "sha256:2d5a8f79a36aaebe62ef2c7068e3ec7f86656078202edabfdbf74715dc822d36", + "sha256:cd6b6b20b4a611f209c88bcdfb7211321f85662efb2bdd53a7b40314d0a84618" + ], + "index": "pypi", + "version": "==0.0.0" + }, + "wcwidth": { + "hashes": [ + "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1", + "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" + ], + "version": "==0.1.9" + }, + "wrapt": { + "hashes": [ + "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" + ], + "version": "==1.12.1" + }, + "zipp": { + "hashes": [ + "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", + "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" + ], + "version": "==3.1.0" + } + } +} diff --git a/README.md b/README.md index b7c93c6..5904a07 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Requirements ------------ * [KUSANAGI framework](http://kusanagi.io) 2.0+ -* [Python](https://www.python.org/downloads/) 3.6+ +* [Python](https://www.python.org/downloads/) 3.7+ * [libzmq](http://zeromq.org/intro:get-the-software) 4.1.5+ Installation @@ -24,7 +24,7 @@ $ pip install kusanagi-sdk-python Or to run all unit tests and code coverage: ``` -$ python setup.py test +$ pytest ``` Getting Started diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 040c073..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = KusanagiSdkForPython -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index f55bdc9..0000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# -# KUSANAGI SDK for Python documentation build configuration file, created by -# sphinx-quickstart on Fri Jan 2 15:37:32 2019. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) -from datetime import datetime -from kusanagi import __version__ - -# -- General configuration ------------------------------------------------ - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', - ] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -this_year = datetime.now().year -project = 'Python SDK for the KUSANAGI framework' -copyright = '2016-{} KUSANAGI S.L. All rights reserved'.format(this_year) -author = 'Jerónimo Albi' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = __version__.split('-', 1)[0] -# The full version, including alpha/beta/rc tags. -release = __version__ - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = [] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - -add_module_names = False -html_show_sphinx = False - -# Include both class docstring and __init__ -autoclass_content = 'both' - -autodoc_default_flags = [ - 'members', - 'undoc-members', - 'show-inheritance', - # 'inherited-members', - # 'private-members', - ] - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'sphinx_rtd_theme' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -html_title = 'Release v{}'.format(release) - -# -- Options for HTMLHelp output ------------------------------------------ - -# Output file base name for HTML help builder. -htmlhelp_basename = 'KusanagiSdkForPythonDoc' - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [( - master_doc, - 'kusanagisdkforpython', - 'KUSANAGI Python SDK Documentation', - [author], - 1, - )] - - -# -- Custom code ------------------------------- - -def remove_module_docstring(app, what, name, obj, options, lines): - if what == 'module': - del lines[:] - - -def mod_signature(app, what, name, obj, options, signature, return_annotation): - if what != 'module': - return - - if len(name) > 6: - return (name[6:], return_annotation) - - -def setup(app): - app.connect("autodoc-process-docstring", remove_module_docstring) - app.connect("autodoc-process-signature", mod_signature) diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index 99c5bac..0000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,23 +0,0 @@ -.. API documentation templates inside `source/sdk` were -.. generated using: sphinx-apidoc -o source/sdk -T ../kusanagi - -Python SDK for the KUSANAGI framework -===================================== - -:Release: |release| -:Date: |today| - -API Documentation -================= - -.. toctree:: - :maxdepth: 1 - :glob: - - sdk/* - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` diff --git a/docs/source/sdk/kusanagi.rst b/docs/source/sdk/kusanagi.rst deleted file mode 100644 index 0e65d40..0000000 --- a/docs/source/sdk/kusanagi.rst +++ /dev/null @@ -1,105 +0,0 @@ -kusanagi -======== - -Subpackages ------------ - -.. toctree:: - - kusanagi.sdk - -kusanagi.errors ---------------- - -.. automodule:: kusanagi.errors - :members: - :undoc-members: - :show-inheritance: - -kusanagi.json -------------- - -.. automodule:: kusanagi.json - :members: - :undoc-members: - :show-inheritance: - -kusanagi.logging ----------------- - -.. automodule:: kusanagi.logging - :members: - :undoc-members: - :show-inheritance: - -kusanagi.middleware -------------------- - -.. automodule:: kusanagi.middleware - :members: - :undoc-members: - :show-inheritance: - -kusanagi.payload ----------------- - -.. automodule:: kusanagi.payload - :members: - :undoc-members: - :show-inheritance: - -kusanagi.schema ---------------- - -.. automodule:: kusanagi.schema - :members: - :undoc-members: - :show-inheritance: - -kusanagi.serialization ----------------------- - -.. automodule:: kusanagi.serialization - :members: - :undoc-members: - :show-inheritance: - -kusanagi.server ---------------- - -.. automodule:: kusanagi.server - :members: - :undoc-members: - :show-inheritance: - -kusanagi.service ----------------- - -.. automodule:: kusanagi.service - :members: - :undoc-members: - :show-inheritance: - -kusanagi.urn ------------- - -.. automodule:: kusanagi.urn - :members: - :undoc-members: - :show-inheritance: - -kusanagi.utils --------------- - -.. automodule:: kusanagi.utils - :members: - :undoc-members: - :show-inheritance: - -kusanagi.versions ------------------ - -.. automodule:: kusanagi.versions - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/sdk/kusanagi.sdk.http.rst b/docs/source/sdk/kusanagi.sdk.http.rst deleted file mode 100644 index 6ae4fae..0000000 --- a/docs/source/sdk/kusanagi.sdk.http.rst +++ /dev/null @@ -1,18 +0,0 @@ -kusanagi.sdk.http -================= - -kusanagi.sdk.http.request -------------------------- - -.. automodule:: kusanagi.sdk.http.request - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.http.response --------------------------- - -.. automodule:: kusanagi.sdk.http.response - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/sdk/kusanagi.sdk.rst b/docs/source/sdk/kusanagi.sdk.rst deleted file mode 100644 index 4119247..0000000 --- a/docs/source/sdk/kusanagi.sdk.rst +++ /dev/null @@ -1,98 +0,0 @@ -kusanagi.sdk -============ - -Subpackages ------------ - -.. toctree:: - - kusanagi.sdk.http - kusanagi.sdk.schema - -kusanagi.sdk.action -------------------- - -.. automodule:: kusanagi.sdk.action - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.base ------------------ - -.. automodule:: kusanagi.sdk.base - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.component ----------------------- - -.. automodule:: kusanagi.sdk.component - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.file ------------------ - -.. automodule:: kusanagi.sdk.file - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.middleware ------------------------ - -.. automodule:: kusanagi.sdk.middleware - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.param ------------------- - -.. automodule:: kusanagi.sdk.param - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.request --------------------- - -.. automodule:: kusanagi.sdk.request - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.response ---------------------- - -.. automodule:: kusanagi.sdk.response - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.runner -------------------- - -.. automodule:: kusanagi.sdk.runner - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.service --------------------- - -.. automodule:: kusanagi.sdk.service - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.transport ----------------------- - -.. automodule:: kusanagi.sdk.transport - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/sdk/kusanagi.sdk.schema.rst b/docs/source/sdk/kusanagi.sdk.schema.rst deleted file mode 100644 index 6edeb08..0000000 --- a/docs/source/sdk/kusanagi.sdk.schema.rst +++ /dev/null @@ -1,42 +0,0 @@ -kusanagi.sdk.schema -=================== - -kusanagi.sdk.schema.action --------------------------- - -.. automodule:: kusanagi.sdk.schema.action - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.schema.error -------------------------- - -.. automodule:: kusanagi.sdk.schema.error - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.schema.file ------------------------- - -.. automodule:: kusanagi.sdk.schema.file - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.schema.param -------------------------- - -.. automodule:: kusanagi.sdk.schema.param - :members: - :undoc-members: - :show-inheritance: - -kusanagi.sdk.schema.service ---------------------------- - -.. automodule:: kusanagi.sdk.schema.service - :members: - :undoc-members: - :show-inheritance: diff --git a/kusanagi/__init__.py b/kusanagi/__init__.py index 035a647..64590cd 100644 --- a/kusanagi/__init__.py +++ b/kusanagi/__init__.py @@ -1,10 +1,7 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -__license__ = "MIT" -__copyright__ = "Copyright (c) 2016-2020 KUSANAGI S.L. (http://kusanagi.io)" -__version__ = '2.0.0' +# See https://setuptools.readthedocs.io/en/latest/setuptools.html#namespace-packages +# pragma: no cover +try: + __import__('pkg_resources').declare_namespace(__name__) +except ImportError: # pragma: no cover + from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) diff --git a/kusanagi/errors.py b/kusanagi/errors.py deleted file mode 100644 index 4c7e6f1..0000000 --- a/kusanagi/errors.py +++ /dev/null @@ -1,26 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. - - -class KusanagiError(Exception): - """Base exception for KUSANAGI errors.""" - - message = None - - def __init__(self, message=None): - if message: - self.message = message - - super().__init__(self.message) - - def __str__(self): - return self.message or self.__class__.__name__ - - -class KusanagiTypeError(KusanagiError): - """Kusanagi type error.""" diff --git a/kusanagi/logging.py b/kusanagi/logging.py deleted file mode 100644 index 1812f7d..0000000 --- a/kusanagi/logging.py +++ /dev/null @@ -1,203 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -import base64 -import logging -import time -import types -import sys - -from datetime import datetime - -from . import json - -# Syslog numeric levels -DEBUG = logging.DEBUG -INFO = logging.INFO -WARNING = logging.WARNING -NOTICE = WARNING + 1 -ERROR = logging.ERROR -CRITICAL = logging.CRITICAL -ALERT = CRITICAL + 1 -EMERGENCY = ALERT + 1 - -# Mappings between Syslog numeric severity levels and python logging levels -SYSLOG_NUMERIC = { - 0: EMERGENCY, - 1: ALERT, - 2: logging.CRITICAL, - 3: logging.ERROR, - 4: NOTICE, - 5: logging.WARNING, - 6: logging.INFO, - 7: logging.DEBUG, - } - - -class RequestLogger(object): - """ - Logger for requests. - - It appends the request ID to all logging messages. - - """ - - def __init__(self, rid, name): - self.rid = rid - self.__logger = logging.getLogger(name) - - def debug(self, msg, *args, **kw): - if self.rid: - msg += ' |{}|'.format(self.rid) - - self.__logger.debug(msg, *args, **kw) - - def info(self, msg, *args, **kw): - if self.rid: - msg += ' |{}|'.format(self.rid) - - self.__logger.info(msg, *args, **kw) - - def warning(self, msg, *args, **kw): - if self.rid: - msg += ' |{}|'.format(self.rid) - - self.__logger.warning(msg, *args, **kw) - - def error(self, msg, *args, **kw): - if self.rid: - msg += ' |{}|'.format(self.rid) - - self.__logger.error(msg, *args, **kw) - - def critical(self, msg, *args, **kw): - if self.rid: - msg += ' |{}|'.format(self.rid) - - self.__logger.critical(msg, *args, **kw) - - def exception(self, msg, *args, **kw): - if self.rid: - msg += ' |{}|'.format(self.rid) - - self.__logger.exception(msg, *args, **kw) - - def log(self, lvl, msg, *args, **kw): - if self.rid: - msg += ' |{}|'.format(self.rid) - - self.__logger.log(lvl, msg, *args, **kw) - - -class KusanagiFormatter(logging.Formatter): - """Default KUSANAGI logging formatter.""" - - def formatTime(self, record, *args, **kwargs): - utc = time.mktime(time.gmtime(record.created)) + (record.created % 1) - return datetime.fromtimestamp(utc).isoformat()[:-3] - - -def value_to_log_string(value, max_chars=100000): - """Convert a value to a string. - - :param value: A value to log. - :type value: object - :param max_chars: Optional maximum number of characters to return. - :type max_chars: int - - :rtype: str - - """ - - if value is None: - output = 'NULL' - elif isinstance(value, bool): - output = 'TRUE' if value else 'FALSE' - elif isinstance(value, str): - output = value - elif isinstance(value, bytes): - # Binary data is logged as base64 - output = base64.b64encode(value).decode('utf8') - elif isinstance(value, (dict, list, tuple)): - output = json.serialize(value, prettify=True).decode('utf8') - elif isinstance(value, types.FunctionType): - if value.__name__ == '': - output = 'anonymous' - else: - output = '[function {}]'.format(value.__name__) - else: - output = repr(value) - - return output[:max_chars] - - -def get_output_buffer(): - """Get buffer interface to send logging output. - - :rtype: io.IOBase - - """ - - return sys.stdout - - -def disable_logging(): - """Disable all logs.""" - - logging.disable(sys.maxsize) - - -def setup_kusanagi_logging(type, name, version, framework, level): - """Initialize logging defaults for KUSANAGI. - - :param type: Component type. - :param name: Component name. - :param version: Component version. - :param framework: KUSANAGI framework version. - :param level: Logging level. - - """ - - # Add the new logging levels to follow KUSANAGI SDK specs - logging.addLevelName(NOTICE, 'NOTICE') - logging.addLevelName(ALERT, 'ALERT') - logging.addLevelName(EMERGENCY, 'EMERGENCY') - - format = "%(asctime)sZ {} [%(levelname)s] [SDK] %(message)s".format( - "{} {}/{} ({})".format(type, name, version, framework) - ) - - output = get_output_buffer() - - # Setup root logger - root = logging.root - if not root.handlers: - logging.basicConfig(level=level, stream=output) - root.setLevel(level) - root.handlers[0].setFormatter(KusanagiFormatter(format)) - - # Setup kusanagi logger - logger = logging.getLogger('kusanagi') - logger.setLevel(level) - if not logger.handlers: - handler = logging.StreamHandler(stream=output) - handler.setFormatter(KusanagiFormatter(format)) - logger.addHandler(handler) - logger.propagate = False - - # Setup kusanagi logger - logger = logging.getLogger('kusanagi.sdk') - logger.setLevel(level) - if not logger.handlers: - handler = logging.StreamHandler(stream=output) - handler.setFormatter(KusanagiFormatter(format)) - logger.addHandler(handler) - logger.propagate = False - - # Setup other loggers - logger = logging.getLogger('asyncio') - logger.setLevel(logging.ERROR) diff --git a/kusanagi/middleware.py b/kusanagi/middleware.py deleted file mode 100644 index a803e5e..0000000 --- a/kusanagi/middleware.py +++ /dev/null @@ -1,192 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -import logging - -from .sdk.param import param_to_payload -from .sdk.request import Request -from .sdk.response import NO_RETURN_VALUE -from .sdk.response import Response -from .sdk.transport import Transport -from .payload import ErrorPayload -from .payload import Payload -from .payload import ResponsePayload -from .payload import ServiceCallPayload -from .server import ComponentServer -from .utils import MultiDict - -LOG = logging.getLogger(__name__) - -REQUEST_MIDDLEWARE = 1 -RESPONSE_MIDDLEWARE = 2 -BIDIRECTIONAL_MIDDLEWARE = 3 - - -class MiddlewareServer(ComponentServer): - """Server class for middleware component.""" - - def __init__(self, *args, **kwargs): - from .sdk.middleware import get_component - - super().__init__(*args, **kwargs) - self.__component = get_component() - - @staticmethod - def get_type(): - return 'middleware' - - @staticmethod - def http_request_from_payload(payload): - if not payload.path_exists('request'): - return - - return { - 'method': payload.get('request/method'), - 'url': payload.get('request/url'), - 'protocol_version': payload.get('request/version'), - 'query': MultiDict(payload.get('request/query', {})), - 'headers': MultiDict(payload.get('request/headers', {})), - 'post_data': MultiDict(payload.get('request/post_data', {})), - 'body': payload.get('request/body'), - 'files': MultiDict(payload.get('request/files', {})), - } - - @staticmethod - def http_response_from_payload(payload): - if not payload.path_exists('response'): - return - - code, text = payload.get('response/status').split(' ', 1) - return { - 'version': payload.get('response/version', '1.1'), - 'headers': MultiDict(payload.get('response/headers', {})), - 'status_code': int(code), - 'status_text': text, - 'body': payload.get('response/body', ''), - } - - def _create_request_component_instance(self, payload, extra): - return Request( - self.__component, - self.source_file, - self.component_name, - self.component_version, - self.framework_version, - attributes=extra.get('attributes'), - variables=self.variables, - debug=self.debug, - # TODO: Use meta and call as arguments instead these many kwargs - service_name=payload.get('call/service'), - service_version=payload.get('call/version'), - action_name=payload.get('call/action'), - params=payload.get('call/params', []), - rid=payload.get('meta/id'), - timestamp=payload.get('meta/datetime'), - gateway_protocol=payload.get('meta/protocol'), - gateway_addresses=payload.get('meta/gateway'), - client_address=payload.get('meta/client'), - http_request=self.http_request_from_payload(payload), - ) - - def _create_response_component_instance(self, payload, extra): - return Response( - Transport(payload.get('transport')), - self.__component, - self.source_file, - self.component_name, - self.component_version, - self.framework_version, - attributes=extra.get('attributes'), - debug=self.debug, - variables=self.variables, - return_value=payload.get('return', NO_RETURN_VALUE), - # TODO: Use meta and argument - gateway_protocol=payload.get('meta/protocol'), - gateway_addresses=payload.get('meta/gateway'), - http_request=self.http_request_from_payload(payload), - http_response=self.http_response_from_payload(payload), - ) - - def create_component_instance(self, action, payload, extra): - """Create a component instance for current command payload. - - :param action: Name of action that must process payload. - :type action: str - :param payload: Command payload. - :type payload: CommandPayload - :param extra: A payload to add extra command reply values to result. - :type extra: Payload - - :rtype: `Request` or `Response` - - """ - - payload = Payload(payload.get('command/arguments')) - - # Always create a new dictionary to store request attributes and save - # it inside the command reply extra result values. - # If attributes doesn't exist in payload meta use an empty dictionary. - extra.set('attributes', dict(payload.get('meta/attributes', {}))) - - middleware_type = payload.get('meta/type') - if middleware_type == REQUEST_MIDDLEWARE: - return self._create_request_component_instance(payload, extra) - elif middleware_type == RESPONSE_MIDDLEWARE: - return self._create_response_component_instance(payload, extra) - - def component_to_payload(self, payload, component): - """Convert component to a command result payload. - - Valid components are `Request` and `Response` objects. - - :params payload: Command payload from current request. - :type payload: `CommandPayload` - :params component: The component being used. - :type component: `Component` - - :returns: A result payload. - :rtype: `Payload` - - """ - - if isinstance(component, Request): - # Return a service call payload - payload = ServiceCallPayload.new( - service=component.get_service_name(), - version=component.get_service_version(), - action=component.get_action_name(), - params=[ - param_to_payload(param) for param in component.get_params() - ] - ) - elif isinstance(component, Response): - http_response = component.get_http_response() - # Return a response payload - payload = ResponsePayload.new( - version=http_response.get_protocol_version(), - status=http_response.get_status(), - body=http_response.get_body(), - headers=dict(http_response.get_headers_array()), - ) - else: - LOG.error('Invalid Middleware callback result') - payload = ErrorPayload.new() - - return payload.entity() - - def create_error_payload(self, exc, component, **kwargs): - if isinstance(component, Request): - http = component.get_http_request() - else: - http = component.get_http_response() - - # Create a response with the error - return ResponsePayload.new( - version=http.get_protocol_version(), - status='500 Internal Server Error', - body=str(exc), - ).entity() diff --git a/kusanagi/payload.py b/kusanagi/payload.py deleted file mode 100644 index b745bfa..0000000 --- a/kusanagi/payload.py +++ /dev/null @@ -1,500 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from . import utils -from .utils import DELIMITER as SEP -from .utils import date_to_str -from .utils import EMPTY -from .utils import LookupDict -from .utils import utcnow - -# Disable field mappings in all payloads -DISABLE_FIELD_MAPPINGS = False - -# Field name mappings for all payload fields -FIELD_MAPPINGS = { - 'action': 'a', - 'address': 'a', - 'arguments': 'a', - 'attributes': 'a', - 'available': 'a', - 'actions': 'ac', - 'array_format': 'af', - 'base_path': 'b', - 'body': 'b', - 'buffers': 'b', - 'busy': 'b', - 'cached': 'c', - 'call': 'c', - 'callback': 'c', - 'callee': 'c', - 'client': 'c', - 'code': 'c', - 'collection': 'c', - 'command': 'c', - 'commit': 'c', - 'component': 'c', - 'config': 'c', - 'count': 'c', - 'cpu': 'c', - 'caller': 'C', - 'calls': 'C', - 'complete': 'C', - 'command_reply': 'cr', - 'data': 'd', - 'datetime': 'd', - 'default_value': 'd', - 'disk': 'd', - 'path_delimiter': 'd', - 'deferred_calls': 'dc', - 'deprecated': 'D', - 'duration': 'D', - 'allow_empty': 'e', - 'end_time': 'e', - 'entity_path': 'e', - 'errors': 'e', - 'entity': 'E', - 'error': 'E', - 'enum': 'em', - 'exclusive_min': 'en', - 'exclusive_max': 'ex', - 'family': 'f', - 'field': 'f', - 'filename': 'f', - 'files': 'f', - 'format': 'f', - 'free': 'f', - 'fallback': 'F', - 'fallbacks': 'F', - 'fields': 'F', - 'gateway': 'g', - 'header': 'h', - 'headers': 'h', - 'http': 'h', - 'http_body': 'hb', - 'http_input': 'hi', - 'http_method': 'hm', - 'http_security': 'hs', - 'id': 'i', - 'idle': 'i', - 'in': 'i', - 'input': 'i', - 'interval': 'i', - 'items': 'i', - 'primary_key': 'k', - 'laddr': 'l', - 'level': 'l', - 'links': 'l', - 'memory': 'm', - 'message': 'm', - 'meta': 'm', - 'method': 'm', - 'mime': 'm', - 'min': 'mn', - 'multiple_of': 'mo', - 'max': 'mx', - 'name': 'n', - 'network': 'n', - 'min_items': 'ni', - 'optional': 'o', - 'origin': 'o', - 'out': 'o', - 'param': 'p', - 'params': 'p', - 'path': 'p', - 'pattern': 'p', - 'percent': 'p', - 'pid': 'p', - 'post_data': 'p', - 'properties': 'p', - 'protocol': 'p', - 'query': 'q', - 'raddr': 'r', - 'reads': 'r', - 'request': 'r', - 'required': 'r', - 'relations': 'r', - 'result': 'r', - 'rollback': 'r', - 'remote_calls': 'rc', - 'return': 'rv', - 'response': 'R', - 'schema': 's', - 'schemes': 's', - 'scope': 's', - 'service': 's', - 'shared': 's', - 'size': 's', - 'start_time': 's', - 'status': 's', - 'swap': 's', - 'system': 's', - 'tags': 't', - 'terminate': 't', - 'token': 't', - 'total': 't', - 'transactions': 't', - 'type': 't', - 'transport': 'T', - 'url': 'u', - 'used': 'u', - 'user': 'u', - 'unique_items': 'ui', - 'value': 'v', - 'version': 'v', - 'validate': 'V', - 'iowait': 'w', - 'writes': 'w', - 'timeout': 'x', - 'max_items': 'xi', - } - -# Transport path for field that must be merged when service calls are made -TRANSPORT_MERGEABLE_PATHS = ( - 'data', - 'relations', - 'links', - 'calls', - 'transactions', - 'errors', - 'body', - 'files', - 'meta/fallbacks', - 'meta/properties', - ) - - -def get_path(payload, path, default=EMPTY, mappings=None, delimiter=SEP): - """Get payload dictionary value by path. - - Global payload field mappings are used when no mappings are given. - - See: `kusanagi.utils.get_path`. - - :param payload: A dictionaty like object. - :type payload: dict - :param path: Path to a value. - :type path: str - :param default: Default value to return when value is not found. - :type default: object - :param mappings: Optional field name mappings. - :type mappings: dict - :param delimiter: Optional path delimiter. - :type delimiter: str - - :raises: `KeyError` - - :returns: The value for the given path. - :rtype: object - - """ - - return utils.get_path( - payload, - path, - default, - mappings or FIELD_MAPPINGS, - delimiter=delimiter, - ) - - -def set_path(payload, path, value, mappings=None, delimiter=SEP): - return utils.set_path( - payload, - path, - value, - mappings=(mappings or FIELD_MAPPINGS), - delimiter=delimiter, - ) - - -def delete_path(payload, path, mappings=None, delimiter=SEP): - return utils.delete_path( - payload, - path, - mappings=(mappings or FIELD_MAPPINGS), - delimiter=delimiter, - ) - - -def path_exists(payload, path, mappings=None, delimiter=SEP): - """Check if a path is available. - - :rtype: bool. - - """ - - try: - utils.get_path( - payload, - path, - mappings=(mappings or FIELD_MAPPINGS), - delimiter=delimiter, - ) - except KeyError: - return False - else: - return True - - -class Payload(LookupDict): - """Class to wrap and access payload data using paths. - - Global payload field names mappings are used by default. - - """ - - # Payload entity name - name = '' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Use global payload field name mappings - if not DISABLE_FIELD_MAPPINGS: - self.set_mappings(FIELD_MAPPINGS) - - @property - def is_entity(self): - """Check if current payload is an entity. - - :rtype: bool - - """ - - return self.path_exists(self.name) - - def set_mappings(self, mappings): - if not DISABLE_FIELD_MAPPINGS: - super().set_mappings(mappings) - - def entity(self, undo=False): - """Get payload as an entity. - - When a payload is created it contains all fields as first - level values. A payload entity moves all fields in payload - to a "namespace"; This way is possible to reference fields - using a path like 'entity-name/field' instead of just using - 'field', and is useful to avoid conflict with fields from - other payloads. - - To remove the entity "namespace" call this method with - `undo` as True. - - :param undo: Optionally undo an entity payload. - :type undo: bool - - :rtype: `Payload` - - """ - - if undo: - # Only apply undo when payload is an entity - if self.is_entity: - return Payload(self.get(self.name)) - elif self.name: - # Apply namespace only when a name is defined - payload = Payload() - payload.set(self.name, self) - return payload - - return self - - -class ErrorPayload(Payload): - """Class definition for error payloads.""" - - name = 'error' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.set_defaults({ - 'message': 'Unknown error', - 'code': 0, - 'status': '500 Internal Server Error', - }) - - @classmethod - def new(cls, message=None, code=None, status=None): - payload = cls() - if message: - payload.set('message', message) - - if code: - payload.set('code', code) - - if status: - payload.set('status', status) - - return payload - - -class MetaPayload(Payload): - """Class definition for request/response meta payloads.""" - - name = 'meta' - - @classmethod - def new(cls, version, id, protocol, gateway, client): - payload = cls() - payload.set('version', version) - payload.set('id', id) # Request ID - payload.set('protocol', protocol) - payload.set('gateway', gateway) - payload.set('datetime', date_to_str(utcnow())) - payload.set('client', client) - return payload - - -class HttpRequestPayload(Payload): - """Class definition for HTTP request payloads.""" - - name = 'request' - - @classmethod - def new(cls, request, files=None): - payload = cls() - payload.set('version', request.version) - payload.set('method', request.method) - payload.set('url', request.url) - payload.set('body', request.body or '') - if request.query: - payload.set('query', request.query) - - if request.post_data: - payload.set('post_data', request.post_data) - - if request.headers: - payload.set('headers', request.headers) - - if files: - payload.set('files', files) - - return payload - - -class ServiceCallPayload(Payload): - """Class definition for service call payloads.""" - - name = 'call' - - @classmethod - def new(cls, service=None, version=None, action=None, params=None): - payload = cls() - payload.set('service', service or '') - payload.set('version', version or '') - payload.set('action', action or '') - payload.set('params', params or []) - return payload - - -class ResponsePayload(Payload): - """Class definition for response payloads.""" - - name = 'response' - - @classmethod - def new(cls, version=None, status=None, body=None, **kwargs): - payload = cls() - payload.set('version', version or '1.1') - payload.set('status', status or '200 OK') - payload.set('body', body or '') - - headers = kwargs.get('headers') - if headers: - payload.set('headers', headers) - - if 'return_value' in kwargs: - payload.set('return', kwargs['return_value']) - - return payload - - -class TransportPayload(Payload): - """Class definition for transport payloads.""" - - name = 'transport' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.set_defaults({ - 'body': {}, - 'files': {}, - 'data': {}, - 'relations': {}, - 'links': {}, - 'calls': {}, - 'transactions': {}, - 'errors': {}, - }) - - @classmethod - def new(cls, version, request_id, origin=None, date_time=None, **kwargs): - payload = cls() - payload.set('meta/version', version) - payload.set('meta/id', request_id) - payload.set('meta/datetime', date_to_str(date_time or utcnow())) - payload.set('meta/origin', origin or []) - payload.set('meta/gateway', kwargs.get('gateway')) - payload.set('meta/level', 1) - if kwargs.get('properties'): - payload.set('meta/properties', kwargs['properties']) - - return payload - - -class CommandPayload(Payload): - """Class definition for command payloads.""" - - name = 'command' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.set_defaults({ - 'command/arguments': None, - }) - - @classmethod - def new(cls, name, scope, args=None): - payload = cls() - payload.set('command/name', name) - payload.set('meta/scope', scope) - if args: - payload.set('command/arguments', args) - - return payload - - @property - def request_id(self): - """ - Get current request ID from command arguments. - - The ID is available for request, response and action commands. - - :rtype: str - - """ - - # For request and response meta is an argument - rid = self.get('command/arguments/meta/id', '') - if not rid: - # For action payloads meta is part of the transport - rid = self.get('command/arguments/transport/meta/id', '') - - return rid - - -class CommandResultPayload(Payload): - """Class definition for command result payloads.""" - - name = 'command_reply' - - @classmethod - def new(cls, name, result=None): - payload = cls() - payload.set('name', name) - payload.set('result', result) - return payload diff --git a/kusanagi/schema.py b/kusanagi/schema.py deleted file mode 100644 index d193e5a..0000000 --- a/kusanagi/schema.py +++ /dev/null @@ -1,99 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from .errors import KusanagiError -from .payload import Payload -from .utils import Singleton - - -class SchemaRegistry(object, metaclass=Singleton): - """Global service schema registry.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__mappings = Payload() - - @staticmethod - def is_empty(value): - """Check if a value is the empty value. - - :rtype: bool - - """ - - return Payload.is_empty(value) - - @property - def has_mappings(self): - """Check if registry contains mappings. - - :rtype: bool - - """ - - return len(self.__mappings) > 0 - - def update_registry(self, mappings): - """Update schema registry with mappings info. - - :param mappings: Mappings payload. - :type mappings: dict - - """ - - self.__mappings = Payload(mappings or {}) - - def path_exists(self, path): - """Check if a path is available. - - For arguments see `Payload.path_exists()`. - - :param path: Path to a value. - :type path: str - - :rtype: bool - - """ - - return self.__mappings.path_exists(path) - - def get(self, path, *args, **kwargs): - """Get value by key path. - - For arguments see `Payload.get()`. - - :param path: Path to a value. - :type path: str - - :returns: The value for the given path. - :rtype: object - - """ - - return self.__mappings.get(path, *args, **kwargs) - - def get_service_names(self): - """Get the list of service names in schema. - - :rtype: list - - """ - - return list(self.__mappings.keys()) - - -def get_schema_registry(): - """Get global schema registry. - - :rtype: SchemaRegistry - - """ - - if not SchemaRegistry.instance: - raise KusanagiError('Global schema registry is not initialized') - - return SchemaRegistry.instance diff --git a/kusanagi/sdk/__init__.py b/kusanagi/sdk/__init__.py index b1162d8..424e27b 100644 --- a/kusanagi/sdk/__init__.py +++ b/kusanagi/sdk/__init__.py @@ -1,11 +1,41 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Python SDK for the KUSANAGI(tm) framework (http://kusanagi.io) # Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. # # Distributed under the MIT license. # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. +__license__ = "MIT" +__copyright__ = "Copyright (c) 2016-2020 KUSANAGI S.L. (http://kusanagi.io)" +__version__ = "2.1.0" -# These 2 classes are imported here to follow KUSANAGI SDK specs -from .middleware import Middleware # noqa -from .service import Service # noqa +# flake8: noqa +from .action import Action +from .action import ActionSchema +from .action import HttpActionSchema +from .actiondata import ActionData +from .callee import Callee +from .caller import Caller +from .error import Error +from .file import File +from .file import FileSchema +from .file import HttpFileSchema +from .lib.asynchronous import AsyncAction +from .lib.error import KusanagiError +from .link import Link +from .middleware import Middleware +from .param import HttpParamSchema +from .param import Param +from .param import ParamSchema +from .relation import ForeignRelation +from .relation import Relation +from .request import HttpRequest +from .request import Request +from .response import HttpResponse +from .response import Response +from .service import HttpServiceSchema +from .service import Service +from .service import ServiceSchema +from .servicedata import ServiceData +from .transaction import Transaction +from .transport import Transport diff --git a/kusanagi/sdk/action.py b/kusanagi/sdk/action.py index a184dcd..e9a8b94 100644 --- a/kusanagi/sdk/action.py +++ b/kusanagi/sdk/action.py @@ -5,619 +5,304 @@ # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. -import copy -import logging +from __future__ import annotations +import copy from decimal import Decimal +from typing import TYPE_CHECKING -import zmq - -from ..logging import RequestLogger -from ..payload import CommandPayload -from ..payload import ErrorPayload -from ..payload import get_path -from ..payload import Payload -from ..payload import TRANSPORT_MERGEABLE_PATHS -from ..utils import ipc -from ..utils import nomap -from ..serialization import pack -from ..serialization import unpack - -from .base import Api -from .base import ApiError +from .api import Api from .file import File -from .file import file_to_payload -from .file import payload_to_file +from .file import FileSchema +from .file import validate_file_list +from .lib import datatypes +from .lib.call import Client +from .lib.error import KusanagiError +from .lib.payload import ns +from .lib.payload.error import ErrorPayload +from .lib.payload.transport import TransportPayload +from .lib.payload.utils import payload_to_file +from .lib.payload.utils import payload_to_param +from .lib.version import VersionString from .param import Param -from .param import param_to_payload - -LOG = logging.getLogger(__name__) - -# Default return values by type name -DEFAULT_RETURN_VALUES = { - 'boolean': False, - 'integer': 0, - 'float': 0.0, - 'string': '', - 'binary': '', - 'array': [], - 'object': {}, - } - -RETURN_TYPES = { - 'boolean': (bool, ), - 'integer': (int, ), - 'float': (float, Decimal), - 'string': (str, ), - 'binary': (str, ), - 'array': (list, ), - 'object': (dict, ), - } - -CONTEXT = zmq.Context.instance() -CONTEXT.linger = 0 - -RUNTIME_CALL = b'\x01' - -EXECUTION_TIMEOUT = 30000 # ms - - -class RuntimeCallError(ApiError): - """Error raised when when run-time call fails.""" - - message = 'Run-time call failed: {}' +from .param import ParamSchema +from .param import validate_parameter_list - def __init__(self, message): - super().__init__(self.message.format(message)) +if TYPE_CHECKING: + from typing import Any + from typing import List + from .lib.payload.action import ActionSchemaPayload + from .lib.payload.action import HttpActionSchemaPayload -class NoFileServerError(ApiError): - """Error raised when file server is not configured.""" - message = 'File server not configured: "{service}" ({version})' - - def __init__(self, service, version): - self.service = service - self.version = version - super().__init__(self.message.format(service=service, version=version)) - - -class ActionError(ApiError): - """Base error class for API Action errors.""" - - def __init__(self, service, version, action, *args, **kwargs): - super().__init__(*args, **kwargs) - self.service = service - self.version = version - self.action = action - self.service_string = '"{}" ({})'.format(service, version) - - -class UndefinedReturnValueError(ActionError): - """Error raised when no return value is defined for an action.""" - - message = 'Cannot set a return value in {service} for action: "{action}"' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.message = self.message.format( - service=self.service_string, - action=self.action, - ) +# Default action execution timeout in milliseconds +EXECUTION_TIMEOUT = 30000 +# Valid types for the action return values +RETURN_TYPES = { + datatypes.TYPE_NULL: (type(None), ), + datatypes.TYPE_BOOLEAN: (bool, ), + datatypes.TYPE_INTEGER: (int, ), + datatypes.TYPE_FLOAT: (float, Decimal), + datatypes.TYPE_STRING: (str, ), + datatypes.TYPE_BINARY: (str, ), + datatypes.TYPE_ARRAY: (list, ), + datatypes.TYPE_OBJECT: (dict, ), +} + +# Default return values +DEFAULT_RETURN_VALUES = { + datatypes.TYPE_NULL: None, + datatypes.TYPE_BOOLEAN: False, + datatypes.TYPE_INTEGER: 0, + datatypes.TYPE_FLOAT: 0.0, + datatypes.TYPE_STRING: '', + datatypes.TYPE_BINARY: '', + datatypes.TYPE_ARRAY: [], + datatypes.TYPE_OBJECT: {}, +} -class ReturnTypeError(ActionError): - """Error raised when return value type is invalid for an action.""" - message = 'Invalid return type given in {service} for action: "{action}"' +class Action(Api): + """Action API class for service component.""" def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.message = self.message.format( - service=self.service_string, - action=self.action, - ) - - -def parse_params(params): - """Parse a list of parameters to be used in payloads. - - Each parameter is converted to a `Payload`. - - :param params: List of `Param` instances. - :type params: list - - :returns: A list of `Payload`. - :rtype: list - - """ - - result = [] - if not params: - return result - - if not isinstance(params, list): - raise TypeError('Parameters must be a list') - - for param in params: - if not isinstance(param, Param): - raise TypeError('Parameter must be an instance of Param class') - else: - result.append(Payload().set_many({ - 'name': param.get_name(), - 'value': param.get_value(), - 'type': param.get_type(), - })) - - return result - - -def runtime_call(address, transport, action, callee, **kwargs): - """Make a Service run-time call. - - :param address: Caller Service address. - :type address: str - :param transport: Current transport payload - :type transport: TransportPayload - :param action: The caller action name. - :type action: str - :param callee: The callee Service name, version and action name. - :type callee: list - :param params: Optative list of Param objects. - :type params: list - :param files: Optative list of File objects. - :type files: list - :param timeout: Optative timeout in milliseconds. - :type timeout: int - - :raises: ApiError - :raises: RuntimeCallError - - :returns: The transport and the return value for the call. - :rtype: tuple - - """ - - args = Payload().set_many({ - 'action': action, - 'callee': callee, - 'transport': transport, - }) - - params = kwargs.get('params') - if params: - args.set('params', [param_to_payload(param) for param in params]) - - files = kwargs.get('files') - if files: - args.set('files', [file_to_payload(file) for file in files]) - - command = CommandPayload.new('runtime-call', 'service', args=args) - - timeout = kwargs.get('timeout') or EXECUTION_TIMEOUT - # TODO: See how to check for TCP when enabled - channel = ipc(address) - socket = CONTEXT.socket(zmq.REQ) - - try: - socket.connect(channel) - socket.send_multipart([RUNTIME_CALL, pack(command)], zmq.NOBLOCK) - poller = zmq.Poller() - poller.register(socket, zmq.POLLIN) - event = dict(poller.poll(timeout)) - - if event.get(socket) == zmq.POLLIN: - stream = socket.recv() - else: - stream = None - except zmq.error.ZMQError as err: - LOG.exception('Run-time call to address failed: %s', address) - raise RuntimeCallError('Connection failed') - finally: - if not socket.closed: - socket.disconnect(channel) - socket.close() - - if not stream: - raise RuntimeCallError('Timeout') - - try: - payload = Payload(unpack(stream)) - except (TypeError, ValueError): - raise RuntimeCallError('Communication failed') - - if payload.path_exists('error'): - raise ApiError(payload.get('error/message')) - elif payload.path_exists('command_reply/result/error'): - raise ApiError(payload.get('command_reply/result/error/message')) - - result = payload.get('command_reply/result') - - return (get_path(result, 'transport'), get_path(result, 'return')) - - -class Action(Api): - """Action API class for Service component.""" + """Constructor.""" - def __init__(self, action, params, transport, *args, **kwargs): super().__init__(*args, **kwargs) - self.__action = action - self.__transport = transport - self.__gateway = transport.get('meta/gateway') - self.__params = { - get_path(param, 'name'): Payload(param) - for param in params - } - - rid = transport.get('meta/id') - self._logger = RequestLogger(rid, 'kusanagi.sdk') - - service = self.get_name() - version = self.get_version() - action_name = self.get_action_name() - - # Get files for current service, version and action and save - # them in a dictionary where the keys are the file parameter - # name and the value the file payload. - path = 'files|{}|{}|{}|{}'.format( - self.__gateway[1], - nomap(service), - version, - nomap(action_name), - ) - self.__files = {} - for file in transport.get(path, default=[], delimiter='|'): - name = get_path(file, 'name', None) - if not name: - continue - - self.__files[name] = file - - # Get schema for current action - try: - self.__schema = self.get_service_schema(service, version) - self.__action_schema = self.__schema.get_action_schema(action_name) - except ApiError: - # When schema for current service can't be resolved it means action - # is run from CLI and because of that there are no mappings to - # resolve schemas. - self.__schema = None - self.__action_schema = None - - # Init return value with a default when action supports it - self.__return_value = kwargs.get('return_value', Payload()) - if not self.__action_schema: - self.__return_value.set('return', None) - elif self.__action_schema.has_return(): - # When return value is supported set a default value by type - rtype = self.__action_schema.get_return_type() - self.__return_value.set('return', DEFAULT_RETURN_VALUES.get(rtype)) - - # Make a transport clone to be used for runtime calls. - # This is required to avoid merging back values that are - # already inside current transport. - self.__runtime_transport = copy.deepcopy(self.__transport) - - def __files_to_payload(self, files): - if self.__schema: - has_file_server = self.__schema.has_file_server() - else: - # When schema for current service can't be resolved it means action - # is run from CLI and because of that there are no mappings to - # resolve schemas. For this case is valid to set has_file_server - # to true. - has_file_server = True - - files_list = [] - for file in files: - if file.is_local() and not has_file_server: - raise NoFileServerError(self.get_name(), self.get_version()) - - files_list.append(file_to_payload(file)) - - return files_list - - def is_origin(self): - """Determines if the current service is the origin of the request. - - :rtype: bool - + # Copy the command transport without keeping references to avoid changing + # the transport data in the command when the action changes the transport. + # This leaves a "vanilla" transport inside the command, to be used as base + # transport for the runtime calls. + payload = copy.deepcopy(self._command.get([ns.TRANSPORT], {})) + self._transport = TransportPayload(payload) + self._transport.set_reply(self._reply) + + # Index the files for the current action by name + gateway = self._transport.get([ns.META, ns.GATEWAY], ['', ''])[1] + path = [ns.FILES, gateway, self.get_name(), self.get_version(), self.get_action_name()] + self.__files = {file[ns.NAME]: file for file in self._transport.get(path, [])} + + # Index parameters by name + self.__params = {param[ns.NAME]: param for param in self._command.get([ns.PARAMS], [])} + + # Set a default return value for the action when there are schemas + if self._schemas: + try: + schema = self.get_service_schema(self.get_name(), self.get_version()) + action_schema = schema.get_action_schema(self.get_action_name()) + if action_schema.has_return(): + return_type = action_schema.get_return_type() + self._transport.set_return(DEFAULT_RETURN_VALUES[return_type]) + except Exception: # pragma: no cover + pass + + def is_origin(self) -> bool: + """Check if the current service is the origin of the request.""" + + origin = [self.get_name(), self.get_version(), self.get_action_name()] + return self._reply.get([ns.TRANSPORT, ns.META, ns.ORIGIN]) == origin + + def get_action_name(self) -> str: + """Get the name of the action.""" + + return self._state.action + + def set_property(self, name: str, value: str) -> Action: """ - - origin = self.__transport.get('meta/origin') - return (origin == [ - self.get_name(), - self.get_version(), - self.get_action_name() - ]) - - def get_action_name(self): - """Get the name of the action. - - :rtype: str - - """ - - return self.__action - - def set_property(self, name, value): - """Sets a user land property. - - Sets a userland property in the transport with the given - name and value. + Set a userland property in the transport with the given name and value. :param name: The property name. - :type name: str :param value: The property value. - :type value: str :raises: TypeError - :rtype: Action - """ if not isinstance(value, str): raise TypeError('Value is not a string') - self.__transport.set( - 'meta/properties/{}'.format(nomap(name)), - str(value), - ) + self._reply.set([ns.TRANSPORT, ns.META, ns.PROPERTIES, name], value) return self - def has_param(self, name): - """Check if a parameter exists. + def has_param(self, name: str) -> bool: + """ + Check if a parameter exists. :param name: The parameter name. - :type name: str - - :rtype: bool """ - return (name in self.__params) + return name in self.__params - def get_param(self, name): - """Get an action parameter. + def get_param(self, name: str) -> Param: + """ + Get an action parameter. :param name: The parameter name. - :type name: str - - :rtype: `Param` """ + # TODO: Create the params from the service schema (see PHP SDK) if not self.has_param(name): return Param(name) - return Param( - name, - value=self.__params[name].get('value'), - type=self.__params[name].get('type'), - exists=True, - ) + return payload_to_param(self.__params[name]) - def get_params(self): - """Get all action parameters. + def get_params(self) -> List[Param]: + """Get all action parameters.""" - :rtype: list + return [payload_to_param(payload) for payload in self.__params.values()] + def new_param(self, name: str, value: Any = '', type: str = '') -> Param: """ + Create a new parameter object. - params = [] - for payload in self.__params.values(): - params.append(Param( - payload.get('name'), - value=payload.get('value'), - type=payload.get('type'), - exists=True, - )) - - return params - - def new_param(self, name, value=None, type=None): - """Creates a new parameter object. - - Creates an instance of Param with the given name, and optionally - the value and data type. If the value is not provided then - an empty string is assumed. If the data type is not defined then - "string" is assumed. - - Valid data types are "null", "boolean", "integer", "float", "string", - "array" and "object". + Creates an instance of Param with the given name, and optionally the value and data type. + When the value is not provided then an empty string is assumed. + If the data type is not defined then "string" is assumed. :param name: The parameter name. - :type name: str :param value: The parameter value. - :type value: mixed :param type: The data type of the value. - :type type: str - - :raises: TypeError - - :rtype: Param """ - if type and Param.resolve_type(value) != type: - raise TypeError('Incorrect data type given for parameter value') - else: - type = Param.resolve_type(value) - - return Param(name, value=value, type=type, exists=False) + return Param(name, value=value, type=type, exists=True) - def has_file(self, name): - """Check if a file was provided for the action. + def has_file(self, name: str) -> bool: + """ + Check if a file was provided for the action. :param name: File name. - :type name: str - - :rtype: bool """ return name in self.__files - def get_file(self, name): - """Get a file with a given name. + def get_file(self, name: str) -> File: + """ + Get a file with a given name. :param name: File name. - :type name: str - - :rtype: `File` """ - if self.has_file(name): - return payload_to_file(self.__files[name]) - else: - return File(name, path='') - - def get_files(self): - """Get all action files. - - :rtype: list + # TODO: Create the file from the service schema (see PHP SDK and change async too) + if not self.has_file(name): + return File(name) - """ + return payload_to_file(self.__files[name]) - files = [] - for payload in self.__files.values(): - files.append(payload_to_file(payload)) + def get_files(self) -> List[File]: + """Get all action files.""" - return files + return [payload_to_file(payload) for payload in self.__files.values()] - def new_file(self, name, path, mime=None): - """Create a new file. + def new_file(self, name: str, path: str, mime: str = '') -> File: + """ + Create a new file. :param name: File name. - :type name: str :param path: File path. - :type path: str :param mime: Optional file mime type. - :type mime: str - - :rtype: `File` """ return File(name, path, mime=mime) - def set_download(self, file): - """Set a file as the download. - - Sets a File object as the file to be downloaded via the Gateway. + def set_download(self, file: File) -> Action: + """ + Set a file as the download. :param file: The file object. - :type file: `File` - - :raises: TypeError - :raises: NoFileServerError - :rtype: Action + :raises: LookupError """ if not isinstance(file, File): raise TypeError('File must be an instance of File class') - # Check that files server is enabled - service = self.get_name() - version = self.get_version() - path = '{}/{}'.format(service, version) + # Check that files server is enabled when the file is a local file + if file.is_local(): + name = self.get_name() + version = self.get_version() + schema = self.get_service_schema(name, version) + if not schema.has_file_server(): + raise LookupError(f'File server not configured: "{name}" ({version})') - # Check if there are mappings to validate. - # Note: When action is run from CLI mappings will be ampty. - if self._registry.has_mappings: - if not get_path(self._registry.get(path), 'files', False): - raise NoFileServerError(service, version) - - self.__transport.set('body', file_to_payload(file)) + self._transport.set_download(file) return self - def set_return(self, value): - """Sets the value to be returned as "return value". - - Supported value types: bool, int, float, str, list, dict and None. - - :param value: A supported return value. - :type value: object - - :raises: UndefinedReturnValueError - :raises: ReturnTypeError - - :rtype: Action - + def set_return(self, value: Any) -> Action: """ + Set the value to be returned by the action. - service = self.get_name() - version = self.get_version() - action = self.get_action_name() + :param value: A value to return. - # When runnong from CLI allow any return values - if not self.__action_schema: - self.__return_value.set('return', value) - return self + :raises: KusanagiError - if not self.__action_schema.has_return(): - raise UndefinedReturnValueError(service, version, action) - - # Check that value type matches return type - if value is not None: - rtype = self.__action_schema.get_return_type() - if not isinstance(value, RETURN_TYPES[rtype]): - raise ReturnTypeError(service, version, action) + """ - self.__return_value.set('return', value) + if self._schemas: + name = self.get_name() + version = self.get_version() + try: + # Check that the schema for the current action is available + schema = self.get_service_schema(name, version) + action = self.get_action_name() + action_schema = schema.get_action_schema(action) + if not action_schema.has_return(): + raise KusanagiError(f'Cannot set a return value in "{name}" ({version}) for action: "{action}"') + + # Validate that the return value has the type defined in the config + return_type = action_schema.get_return_type() + if not isinstance(value, RETURN_TYPES[return_type]): + raise KusanagiError(f'Invalid return type given in "{name}" ({version}) for action: "{action}"') + except LookupError as err: + # LookupError is raised when the schema for the current service, + # or for the current action is not available. + raise KusanagiError(err) + else: # pragma: no cover + # When running the action from the CLI there is no schema available, but the + # setting of return values must be allowed without restrictions in this case. + self._logger.warning('Return value set without discovery mapping available') + + self._transport.set_return(value) return self - def set_entity(self, entity): - """Sets the entity data. + def set_entity(self, entity: dict) -> Action: + """ + Set the entity data. Sets an object as the entity to be returned by the action. - Entity is validated when validation is enabled for an entity - in the Service config file. + Entity is validated when validation is enabled for an entity in the service config file. :param entity: The entity object. - :type entity: dict :raises: TypeError - :rtype: Action - """ if not isinstance(entity, dict): - raise TypeError('Entity must be an dict') + raise TypeError('Entity must be a dictionary') - self.__transport.push( - 'data|{}|{}|{}|{}'.format( - self.__gateway[1], - nomap(self.get_name()), - self.get_version(), - nomap(self.get_action_name()), - ), - entity, - delimiter='|', - ) + self._transport.add_data(self.get_name(), self.get_version(), self.get_action_name(), entity) return self - def set_collection(self, collection): - """Sets the collection data. - - Sets a list as the collection of entities to be returned by the action. + def set_collection(self, collection: List[dict]) -> Action: + """ + Set the collection data. - Collextion is validated when validation is enabled for an entity - in the Service config file. + Collection is validated when validation is enabled for an entity in the service config file. :param collection: The collection list. - :type collection: list :raises: TypeError - :rtype: Action - """ if not isinstance(collection, list): @@ -625,489 +310,837 @@ def set_collection(self, collection): for entity in collection: if not isinstance(entity, dict): - raise TypeError('Entity must be an dict') + raise TypeError('Collection entities must be of type dict') - self.__transport.push( - 'data|{}|{}|{}|{}'.format( - self.__gateway[1], - nomap(self.get_name()), - self.get_version(), - nomap(self.get_action_name()), - ), - collection, - delimiter='|', - ) + self._transport.add_data(self.get_name(), self.get_version(), self.get_action_name(), collection) return self - def relate_one(self, primary_key, service, foreign_key): - """Creates a "one-to-one" relation between two entities. + def relate_one(self, primary_key: str, service: str, foreign_key: str) -> Action: + """ + Create a "one-to-one" relation between two entities. - Creates a "one-to-one" relation between the entity with the given - primary key and service with the foreign key. + Creates a "one-to-one" relation between the entity's primary key and service with the foreign key. :param primery_key: The primary key. - :type primary_key: str, int :param service: The foreign service. - :type service: str :param foreign_key: The foreign key. - :type foreign_key: str, int - :rtype: Action + :raises: ValueError """ - self.__transport.set( - 'relations|{}|{}|{}|{}|{}'.format( - self.__gateway[1], - nomap(self.get_name()), - nomap(primary_key), - self.__gateway[1], - nomap(service), - ), - foreign_key, - delimiter='|', - ) + if not primary_key: + raise ValueError('The primary key is empty') + + if not service: + raise ValueError('The foreign service name is empty') + + if not foreign_key: + raise ValueError('The foreign key is empty') + + self._transport.add_relate_one(self.get_name(), primary_key, service, foreign_key) return self - def relate_many(self, primary_key, service, foreign_keys): - """Creates a "one-to-many" relation between entities. + def relate_many(self, primary_key: str, service: str, foreign_keys: List[str]) -> Action: + """ + Create a "one-to-many" relation between entities. - Creates a "one-to-many" relation between the entity with the given - primary key and service with the foreign keys. + Creates a "one-to-many" relation between the entity's primary key and service with the foreign keys. :param primery_key: The primary key. - :type primary_key: str, int :param service: The foreign service. - :type service: str :param foreign_key: The foreign keys. - :type foreign_key: list - - :raises: TypeError - :rtype: Action + :raises: TypeError, ValueError """ - if not isinstance(foreign_keys, list): + if not primary_key: + raise ValueError('The primary key is empty') + + if not service: + raise ValueError('The foreign service name is empty') + + if not foreign_keys: + raise ValueError('The foreign keys are empty') + elif not isinstance(foreign_keys, list): raise TypeError('Foreign keys must be a list') - self.__transport.set( - 'relations|{}|{}|{}|{}|{}'.format( - self.__gateway[1], - nomap(self.get_name()), - nomap(primary_key), - self.__gateway[1], - nomap(service), - ), - foreign_keys, - delimiter='|', - ) + self._transport.add_relate_many(self.get_name(), primary_key, service, foreign_keys) return self - def relate_one_remote(self, primary_key, address, service, foreign_key): - """Creates a "one-to-one" relation between two entities. + def relate_one_remote(self, primary_key: str, address: str, service: str, foreign_key: str) -> Action: + """ + Creates a "one-to-one" relation between two entities. - Creates a "one-to-one" relation between the entity with the given - primary key and service with the foreign key. + Creates a "one-to-one" relation between the entity's primary key and service with the foreign key. This type of relation is done between entities in different realms. :param primery_key: The primary key. - :type primary_key: str, int :param address: Foreign service public address. - :type address: str :param service: The foreign service. - :type service: str :param foreign_key: The foreign key. - :type foreign_key: str, int - :rtype: Action + :raises: ValueError """ - self.__transport.set( - 'relations|{}|{}|{}|{}|{}'.format( - self.__gateway[1], - nomap(self.get_name()), - nomap(primary_key), - address, - nomap(service), - ), - foreign_key, - delimiter='|', - ) + if not primary_key: + raise ValueError('The primary key is empty') + + if not address: + raise ValueError('The foreign service address is empty') + + if not service: + raise ValueError('The foreign service name is empty') + + if not foreign_key: + raise ValueError('The foreign key is empty') + + self._transport.add_relate_one_remote(self.get_name(), primary_key, address, service, foreign_key) return self - def relate_many_remote(self, primary_key, address, service, foreign_keys): - """Creates a "one-to-many" relation between entities. + def relate_many_remote(self, primary_key: str, address: str, service: str, foreign_keys: List[str]) -> Action: + """ + Create a "one-to-many" relation between entities. - Creates a "one-to-many" relation between the entity with the given - primary key and service with the foreign keys. + Creates a "one-to-many" relation between the entity's primary key and service with the foreign keys. This type of relation is done between entities in different realms. :param primery_key: The primary key. - :type primary_key: str, int :param address: Foreign service public address. - :type address: str :param service: The foreign service. - :type service: str :param foreign_key: The foreign keys. - :type foreign_key: list - :raises: TypeError - - :rtype: Action + :raises: ValueError, TypeError """ - if not isinstance(foreign_keys, list): + if not primary_key: + raise ValueError('The primary key is empty') + + if not address: + raise ValueError('The foreign service address is empty') + + if not service: + raise ValueError('The foreign service name is empty') + + if not foreign_keys: + raise ValueError('The foreign keys are empty') + elif not isinstance(foreign_keys, list): raise TypeError('Foreign keys must be a list') - self.__transport.set( - 'relations|{}|{}|{}|{}|{}'.format( - self.__gateway[1], - nomap(self.get_name()), - nomap(primary_key), - address, - nomap(service), - ), - foreign_keys, - delimiter='|', - ) + self._transport.add_relate_many_remote(self.get_name(), primary_key, address, service, foreign_keys) return self - def set_link(self, link, uri): - """Sets a link for the given URI. + def set_link(self, link: str, uri: str) -> Action: + """ + Set a link for the given URI. :param link: The link name. - :type link: str :param uri: The link URI. - :type uri: str - :rtype: Action + :raises: ValueError """ - self.__transport.set( - 'links|{}|{}|{}'.format( - self.__gateway[1], - nomap(self.get_name()), - nomap(link), - ), - uri, - delimiter='|', - ) + if not link: + raise ValueError('The link is empty') + + if not uri: + raise ValueError('The URI is empty') + + self._transport.add_link(self.get_name(), link, uri) return self - def commit(self, action, params=None): - """Register a transaction to be called when request succeeds. + def commit(self, action: str, params: List[Param] = None) -> Action: + """ + Register a transaction to be called when request succeeds. :param action: The action name. - :type action: str - :param params: Optional list of Param objects. - :type params: list + :param params: Optional list of parameters. - :rtype: Action + :raises: ValueError """ - payload = Payload().set_many({ - 'name': self.get_name(), - 'version': self.get_version(), - 'action': action, - 'caller': self.get_action_name(), - }) - if params: - payload.set('params', parse_params(params)) + if not action: + raise ValueError('The action name is empty') - self.__transport.push('transactions/commit', payload) + self._transport.add_transaction( + self._transport.TRANSACTION_COMMIT, + self.get_name(), + self.get_version(), + self.get_action_name(), + action, + params=params, + ) return self - def rollback(self, action, params=None): - """Register a transaction to be called when request fails. + def rollback(self, action: str, params: List[Param] = None) -> Action: + """ + Register a transaction to be called when request fails. :param action: The action name. - :type action: str - :param params: Optional list of Param objects. - :type params: list + :param params: Optional list of parameters. - :rtype: Action + :raises: ValueError """ - payload = Payload().set_many({ - 'name': self.get_name(), - 'version': self.get_version(), - 'action': action, - 'caller': self.get_action_name(), - }) - if params: - payload.set('params', parse_params(params)) + if not action: + raise ValueError('The action name is empty') - self.__transport.push('transactions/rollback', payload) + self._transport.add_transaction( + self._transport.TRANSACTION_ROLLBACK, + self.get_name(), + self.get_version(), + self.get_action_name(), + action, + params=params, + ) return self - def complete(self, action, params=None): - """Register a transaction to be called when request finishes. + def complete(self, action: str, params: List[Param] = None) -> Action: + """ + Register a transaction to be called when request finishes. - This transaction is ALWAYS executed, it doesn't matter if request - fails or succeeds. + This transaction is ALWAYS executed, it doesn't matter if request fails or succeeds. :param action: The action name. - :type action: str - :param params: Optional list of Param objects. - :type params: list + :param params: Optional list of parameters. - :rtype: Action + :raises: ValueError """ - payload = Payload().set_many({ - 'name': self.get_name(), - 'version': self.get_version(), - 'action': action, - 'caller': self.get_action_name(), - }) - if params: - payload.set('params', parse_params(params)) + if not action: + raise ValueError('The action name is empty') - self.__transport.push('transactions/complete', payload) + self._transport.add_transaction( + self._transport.TRANSACTION_COMPLETE, + self.get_name(), + self.get_version(), + self.get_action_name(), + action, + params=params, + ) return self - def call(self, service, version, action, **kwargs): - """Perform a run-time call to a service. + def call( + self, + service: str, + version: str, + action: str, + params: List[Param] = None, + files: List[File] = None, + timeout: int = EXECUTION_TIMEOUT, + ) -> Any: + """ + Perform a run-time call to a service. + + The result of this call is the return value from the remote action. :param service: The service name. - :type service: str :param version: The service version. - :type version: str :param action: The action name. - :type action: str - :param params: Optative list of Param objects. - :type params: list - :param files: Optative list of File objects. - :type files: list - :param timeout: Optative timeout in milliseconds. - :type timeout: int - - :raises: ApiError - :raises: RuntimeCallError + :param params: Optional list of Param objects. + :param files: Optional list of File objects. + :param timeout: Optional timeout in milliseconds. - :rtype: Action + :raises: ValueError, KusanagiError """ - # Get address for current action's service - path = '/'.join([self.get_name(), self.get_version(), 'address']) - address = self._registry.get(path, None) + # Check that the call exists in the config + service_title = f'"{service}" ({version})' + try: + schema = self.get_service_schema(self.get_name(), self.get_version()) + action_schema = schema.get_action_schema(self.get_action_name()) + if not action_schema.has_call(service, version, action): + msg = f'Call not configured, connection to action on {service_title} aborted: "{action}"' + raise KusanagiError(msg) + except LookupError as err: + raise KusanagiError(err) + + # Check that the remote action exists and can return a value, and if it doesn't issue a warning + try: + remote_action_schema = self.get_service_schema(service, version).get_action_schema(action) + except LookupError as err: # pragma: no cover + self._logger.warning(err) + else: + if not remote_action_schema.has_return(): + raise KusanagiError(f'Cannot return value from {service_title} for action: "{action}"') - if not address: - msg = 'Failed to get address for Service: "{}" ({})'.format( - self.get_name(), - self.get_version(), - ) - raise ApiError(msg) + validate_parameter_list(params) + validate_file_list(files) - # Check that files are supported by the service if local files are used - files = kwargs.get('files') + # Check that the file server is enabled when one of the files is local if files: for file in files: - if not file.is_local(): - continue - - # A local file is included in the files list - if self.__schema and self.__schema.has_file_server(): - # Files are supported - break + if file.is_local(): + # Stop checking when one local file is found and the file server is enables + if schema.has_file_server(): + break - raise NoFileServerError(self.get_name(), self.get_version()) + raise KusanagiError(f'File server not configured: {service_title}') + return_value = None transport = None - exc = None + client = Client(self._logger, tcp=self._state.values.is_tcp_enabled()) try: - transport, result = runtime_call( - address, - self.__runtime_transport, + # NOTE: The transport set to the call won't have any data added by the action component + return_value, transport = client.call( + schema.get_address(), self.get_action_name(), [service, version, action], - **kwargs - ) - except Exception as e: - exc = e - - if transport: - # Clear default to succesfully merge dictionaries. Without - # this merge would be done with a default value that is not - # part of the payload. - self.__transport.set_defaults({}) - - for path in TRANSPORT_MERGEABLE_PATHS: - value = get_path(transport, path, None) - # Don't merge empty values - if value: - self.__transport.merge(path, value) - - # Add the call to the transport - payload = Payload().set_many({ - 'name': service, - 'version': version, - 'action': action, - 'caller': self.get_action_name(), - 'timeout': kwargs.get('timeout') or 1000, - }) - - if 'params' in kwargs: - payload.set('params', [param_to_payload(param) for param in kwargs['params']]) - - if 'files' in kwargs: - payload.set('files', [file_to_payload(file) for file in kwargs['files']]) - - self.__transport.push( - 'calls/{}/{}'.format(nomap(self.get_name()), self.get_version()), - payload + timeout, + transport=TransportPayload(self._command.get_transport_data()), # Use a clean transport for the call + params=params, + files=files, + ) + finally: + # Always add the call info to the transport, even after an exception is raised during the call + self._transport.add_call( + self.get_name(), + self.get_version(), + self.get_action_name(), + service, + version, + action, + client.get_duration(), + params=params, + files=files, + timeout=timeout, + transport=transport, ) - if exc: - raise exc - - return result + return return_value - def defer_call(self, service, version, action, params=None, files=None): - """Register a deferred call to a service. + def defer_call( + self, + service: str, + version: str, + action: str, + params: List[Param] = None, + files: List[File] = None, + ) -> Action: + """ + Register a deferred call to a service. :param service: The service name. - :type service: str :param version: The service version. - :type version: str :param action: The action name. - :type action: str - :param params: Optative list of Param objects. - :type params: list - :param files: Optative list of File objects. - :type files: list + :param params: Optional list of parameters. + :param files: Optional list of files. - :raises: NoFileServerError - - :rtype: Action + :raises: ValueError, KusanagiError """ - # Add files to transport + # Check that the deferred call exists in the config + service_title = f'"{service}" ({version})' + try: + schema = self.get_service_schema(self.get_name(), self.get_version()) + action_schema = schema.get_action_schema(self.get_action_name()) + if not action_schema.has_defer_call(service, version, action): + msg = f'Deferred call not configured, connection to action on {service_title} aborted: "{action}"' + raise KusanagiError(msg) + except LookupError as err: + raise KusanagiError(err) + + # Check that the remote action exists and if it doesn't issue a warning + try: + self.get_service_schema(service, version).get_action_schema(action) + except LookupError as err: # pragma: no cover + self._logger.warning(err) + + validate_parameter_list(params) + validate_file_list(files) + + # Check that the file server is enabled when one of the files is local if files: - self.__transport.set( - 'files|{}|{}|{}|{}'.format( - self.__gateway[1], - nomap(service), - version, - nomap(action), - ), - self.__files_to_payload(files), - delimiter='|', - ) - - payload = Payload().set_many({ - 'name': service, - 'version': version, - 'action': action, - 'caller': self.get_action_name(), - }) - if params: - payload.set('params', parse_params(params)) - - # Calls are aggregated to transport calls - self.__transport.push( - 'calls/{}/{}'.format(nomap(self.get_name()), self.get_version()), - payload - ) + for file in files: + if file.is_local(): + # Stop checking when one local file is found and the file server is enables + if schema.has_file_server(): + break + + raise KusanagiError(f'File server not configured: {service_title}') + + self._transport.add_defer_call( + self.get_name(), + self.get_version(), + self.get_action_name(), + service, + version, + action, + params=params, + files=files, + ) return self - def remote_call(self, address, service, version, action, **kwargs): - """Register a call to a remote service. + def remote_call( + self, + address: str, + service: str, + version: str, + action: str, + params: List[Param] = None, + files: List[File] = None, + timeout: int = EXECUTION_TIMEOUT, + ) -> Action: + """ + Register a call to a remote service in another realm. + + These types of calls are done using KTP (KUSANAGI transport protocol). - :param address: Public address of a Gateway from another Realm. - :type address: str + :param address: Public address of a gateway from another realm. :param service: The service name. - :type service: str :param version: The service version. - :type version: str :param action: The action name. - :type action: str - :param params: Optative list of Param objects. - :type params: list - :param files: Optative list of File objects. - :type files: list - :param timeout: Optative call timeout in milliseconds. - :type timeout: int - - :raises: NoFileServerError + :param params: Optional list of parameters. + :param files: Optional list of files. + :param timeout: Optional call timeout in milliseconds. - :rtype: Action + :raises: ValueError, KusanagiError """ - if address[:3] != 'ktp': - address = 'ktp://{}'.format(address) + if not address.startswith('ktp://'): + raise ValueError(f'The address must start with "ktp://": {address}') + + # Check that the deferred call exists in the config + service_title = f'[{address}] "{service}" ({version})' + try: + schema = self.get_service_schema(self.get_name(), self.get_version()) + action_schema = schema.get_action_schema(self.get_action_name()) + if not action_schema.has_remote_call(address, service, version, action): + msg = f'Remote call not configured, connection to action on {service_title} aborted: "{action}"' + raise KusanagiError(msg) + except LookupError as err: + raise KusanagiError(err) + + # Check that the remote action exists and if it doesn't issue a warning + try: + self.get_service_schema(service, version).get_action_schema(action) + except LookupError as err: # pragma: no cover + self._logger.warning(err) + + validate_parameter_list(params) + validate_file_list(files) - # Add files to transport - files = kwargs.get('files') + # Check that the file server is enabled when one of the files is local if files: - self.__transport.set( - 'files|{}|{}|{}|{}'.format( - self.__gateway[1], - nomap(service), - version, - nomap(action), - ), - self.__files_to_payload(files), - delimiter='|', - ) - - payload = Payload().set_many({ - 'gateway': address, - 'name': service, - 'version': version, - 'action': action, - 'caller': self.get_action_name(), - 'timeout': kwargs.get('timeout') or 1000, - }) - - params = kwargs.get('params') - if params: - payload.set('params', parse_params(params)) - - # Calls are aggregated to transport calls - self.__transport.push( - 'calls/{}/{}'.format(nomap(self.get_name()), self.get_version()), - payload - ) + for file in files: + if file.is_local(): + # Stop checking when one local file is found and the file server is enables + if schema.has_file_server(): + break + + raise KusanagiError(f'File server not configured: {service_title}') + + self._transport.add_remote_call( + address, + self.get_name(), + self.get_version(), + self.get_action_name(), + service, + version, + action, + params=params, + files=files, + timeout=timeout, + ) return self - def error(self, message, code=None, status=None): - """Adds an error for the current Service. + def error(self, message: str, code: int = 0, status: str = ErrorPayload.DEFAULT_STATUS) -> Action: + """ + Add an error for the current service. Adds an error object to the Transport with the specified message. - If the code is not set then 0 is assumed. If the status is not - set then 500 Internal Server Error is assumed. :param message: The error message. - :type message: str :param code: The error code. - :type code: int :param status: The HTTP status message. - :type status: str - - :rtype: Action """ - self.__transport.push( - 'errors|{}|{}|{}'.format( - self.__gateway[1], - nomap(self.get_name()), - self.get_version(), - ), - ErrorPayload.new(message, code, status), - delimiter='|', - ) + self._transport.add_error(self.get_name(), self.get_version(), message, code, status) return self + + +class ActionSchema(object): + """Service action schema.""" + + DEFAULT_EXECUTION_TIMEOUT = EXECUTION_TIMEOUT + + def __init__(self, name: str, payload: ActionSchemaPayload): + """ + Constructor. + + :param name: The name of the service action. + :param payload: The action schema payload. + + """ + + self.__name = name + self.__payload = payload + + def get_timeout(self) -> int: + """Get the maximum execution time defined in milliseconds for the action.""" + + return self.__payload.get([ns.TIMEOUT], self.DEFAULT_EXECUTION_TIMEOUT) + + def is_deprecated(self) -> bool: + """Check if action has been deprecated.""" + + return self.__payload.get([ns.DEPRECATED], False) + + def is_collection(self) -> bool: + """Check if the action returns a collection of entities.""" + + return self.__payload.get([ns.COLLECTION], False) + + def get_name(self) -> str: + """Get action name.""" + + return self.__name + + def get_entity_path(self) -> str: + """Get path to the entity.""" + + return self.__payload.get([ns.ENTITY_PATH], '') + + def get_path_delimiter(self) -> str: + """Get delimiter to use for the entity path.""" + + return self.__payload.get([ns.PATH_DELIMITER], '/') + + def resolve_entity(self, data: dict) -> dict: + """ + Get entity from data. + + Get the entity part, based upon the `entity-path` and `path-delimiter` + properties in the action configuration. + + :param data: The object to get entity from. + + :raises: LookupError + + """ + + # The data is traversed only when there is a path, otherwise data is returned as is + path = self.get_entity_path() + if path: + delimiter = self.get_path_delimiter() + try: + for name in path.split(delimiter): + data = data[name] + except (TypeError, KeyError): + raise LookupError(f'Cannot resolve entity for action: {self.get_name()}') + + return data + + def has_entity(self) -> bool: + """Check if an entity definition exists for the action.""" + + return self.__payload.exists([ns.ENTITY]) + + def get_entity(self) -> dict: + """Get the entity definition.""" + + return self.__payload.get_entity() + + def has_relations(self) -> bool: + """Check if any relations exists for the action.""" + + return self.__payload.exists([ns.RELATIONS]) + + def get_relations(self) -> list: + """ + Get the relations. + + Each item is an array contains the relation type and the service name. + + """ + + return self.__payload.get_relations() + + def get_calls(self) -> list: + """ + Get service run-time calls. + + Each call item is a list containing the service name, the service version and the action name. + + """ + + return self.__payload.get([ns.CALLS], []) + + def has_call(self, name: str, version: str = None, action: str = None) -> bool: + """ + Check if a run-time call exists for a service. + + :param name: Service name. + :param version: Optional service version. + :param action: Optional action name. + + """ + + for call in self.get_calls(): + if call[0] not in ('*', name): + continue + + if version and call[1] not in ('*', version) and not VersionString(version).match(call[1]): + continue + + if action and call[2] not in ('*', action): + continue + + # When all given arguments match the call return True + return True + + # By default call does not exist + return False + + def has_calls(self) -> bool: + """Check if any run-time call exists for the action.""" + + return self.__payload.exists([ns.CALLS]) + + def get_defer_calls(self) -> list: + """ + Get deferred service calls. + + Each call item is a list containing the service name, the service version and the action name. + + """ + + return self.__payload.get([ns.DEFERRED_CALLS], []) + + def has_defer_call(self, name, version: str = None, action: str = None) -> bool: + """ + Check if a deferred call exists for a service. + + :param name: Service name. + :param version: Optional service version. + :param action: Optional action name. + + """ + + for call in self.get_defer_calls(): + if call[0] not in ('*', name): + continue + + if version and call[1] not in ('*', version) and not VersionString(version).match(call[1]): + continue + + if action and call[2] not in ('*', action): + continue + + # When all given arguments match the call return True + return True + + # By default call does not exist + return False + + def has_defer_calls(self): + """Check if any deferred call exists for the action.""" + + return self.__payload.exists([ns.DEFERRED_CALLS]) + + def get_remote_calls(self) -> list: + """ + Get remote service calls. + + Each remote call item is a list containing the public address of the gateway, + the service name, the service version and the action name. + + """ + + return self.__payload.get([ns.REMOTE_CALLS], []) + + def has_remote_call(self, address: str, name: str = None, version: str = None, action: str = None) -> bool: + """ + Check if a remote call exists for a service. + + :param address: Gateway address. + :param name: Optional service name. + :param version: Optional service version. + :param action: Optional action name. + + """ + + for call in self.get_remote_calls(): + if call[0] not in ('*', address): + continue + + if name and call[1] not in ('*', name): + continue + + if version and call[2] not in ('*', version) and not VersionString(version).match(call[2]): + continue + + if action and call[3] not in ('*', action): + continue + + # When all given arguments match the call return True + return True + + # By default call does not exist + return False + + def has_remote_calls(self) -> bool: + """Check if any remote call exists for the action.""" + + return self.__payload.exists([ns.REMOTE_CALLS]) + + def has_return(self) -> bool: + """Check if a return value is defined for the action.""" + + return self.__payload.exists([ns.RETURN]) + + def get_return_type(self) -> str: + """ + Get the data type of the returned action value. + + :raises: ValueError + + """ + + if not self.__payload.exists([ns.RETURN, ns.TYPE]): + raise ValueError(f'Return value not defined for action: {self.get_name()}') + + return self.__payload.get([ns.RETURN, ns.TYPE]) + + def get_params(self) -> List[str]: + """Get the parameter names defined for the action.""" + + return self.__payload.get_param_names() + + def has_param(self, name: str) -> bool: + """ + Check that schema for a parameter exists. + + :param name: A parameter name. + + """ + + return name in self.get_params() + + def get_param_schema(self, name: str) -> ParamSchema: + """ + Get the schema for a parameter. + + :param name: The parameter name. + + :raises: LookupError + + """ + + if not self.has_param(name): + raise LookupError(f'Cannot resolve schema for parameter: {name}') + + return ParamSchema(self.__payload.get_param_schema_payload(name)) + + def get_files(self) -> List[str]: + """Get the file parameter names defined for the action.""" + + return self.__payload.get_file_names() + + def has_file(self, name: str) -> bool: + """ + Check that schema for a file parameter exists. + + :param name: A file parameter name. + + """ + + return name in self.get_files() + + def get_file_schema(self, name: str) -> FileSchema: + """ + Get schema for a file parameter. + + :param name: File parameter name. + + :raises: LookupError + + """ + + if not self.has_file(name): + raise LookupError(f'Cannot resolve schema for file parameter: "{name}"') + + return FileSchema(self.__payload.get_file_schema_payload(name)) + + def get_tags(self) -> List[str]: + """Get the tags defined for the action.""" + + return list(self.__payload.get([ns.TAGS], [])) + + def has_tag(self, name: str) -> bool: + """ + Check that a tag is defined for the action. + + The tag name is case sensitive. + + :param name: The tag name. + + """ + + return name in self.get_tags() + + def get_http_schema(self) -> HttpActionSchema: + """Get HTTP action schema.""" + + return HttpActionSchema(self.__payload.get_http_action_schema_payload()) + + +class HttpActionSchema(object): + """HTTP semantics of an action schema in the framework.""" + + DEFAULT_METHOD = 'GET' + DEFAULT_INPUT = 'query' + DEFAULT_BODY = 'text/plain' + + def __init__(self, payload: HttpActionSchemaPayload): + """ + Constructor. + + :param payload: The HTTP action schema payload. + + """ + + self.__payload = payload + + def is_accessible(self) -> bool: + """Check if the gateway has access to the action.""" + + return self.__payload.get([ns.GATEWAY], True) + + def get_method(self) -> str: + """Get HTTP method for the action.""" + + return self.__payload.get([ns.METHOD], self.DEFAULT_METHOD) + + def get_path(self) -> str: + """Get URL path for the action.""" + + return self.__payload.get([ns.PATH], '') + + def get_input(self) -> str: + """Get default location of parameters for the action.""" + + return self.__payload.get([ns.INPUT], self.DEFAULT_INPUT) + + def get_body(self) -> str: + """ + Get expected MIME type of the HTTP request body. + + Result may contain a comma separated list of MIME types. + + """ + + return ','.join(self.__payload.get([ns.BODY], [self.DEFAULT_BODY])) diff --git a/kusanagi/sdk/actiondata.py b/kusanagi/sdk/actiondata.py new file mode 100644 index 0000000..c0bde2e --- /dev/null +++ b/kusanagi/sdk/actiondata.py @@ -0,0 +1,54 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import List + from typing import Union + + DataType = Union[dict, List[dict]] + + +class ActionData(object): + """The ActionData class represents action data in the transport.""" + + def __init__(self, name: str, data: List[DataType]): + """ + Constructor. + + The data dan be a list where each item can be a list or a dictionary. + An item that is a list means that a "collection" wad returned for that call, + where and item that is a dictionary means that an "entity" was returned in + that call. + + :param name: Name of the service action. + :param data: Transport data for the calls made to the service action. + + """ + + self.__name = name + self.__data = data + + def get_name(self) -> str: + """Get the name of the service action that returned the data.""" + + return self.__name + + def is_collection(self) -> bool: + """Checks if the data for this action is a collection.""" + + return isinstance(self.__data[0], list) + + def get_data(self) -> List[DataType]: + """Get the transport data for the service action.""" + + # Copy the data to avoid indirect modification + return copy.deepcopy(self.__data) diff --git a/kusanagi/sdk/api.py b/kusanagi/sdk/api.py new file mode 100644 index 0000000..bf36f4d --- /dev/null +++ b/kusanagi/sdk/api.py @@ -0,0 +1,164 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from .lib.logging import INFO +from .lib.logging import value_to_log_string + +if TYPE_CHECKING: + from typing import Any + from typing import List + from typing import Union + + from .lib.payload.command import CommandPayload + from .lib.payload.mapping import MappingPayload + from .lib.payload.reply import ReplyPayload + from .lib.state import State + from .middleware import Middleware + from .service import Service + from .service import ServiceSchema + + +class Api(object): + """API class for SDK components.""" + + def __init__(self, component: Union[Middleware, Service], state: State): + """ + Constructor. + + :param component: The framework component being run. + :param state: The current state. + + """ + + self._schemas: MappingPayload = state.context.get('schemas') + self._component = component + self._state = state + self._logger = state.logger + self._command: CommandPayload = state.context.get('command') + self._reply: ReplyPayload = state.context.get('reply') + + def is_debug(self) -> bool: + """Check if the component is running in debug mode.""" + + return self._state.values.is_debug() + + def get_framework_version(self): + """Get the KUSANAGI framework version.""" + + return self._state.values.get_framework_version() + + def get_path(self) -> str: + """Get source file path.""" + + return os.path.dirname(self._state.values.get_path()) + + def get_name(self) -> str: + """Get component name.""" + + return self._state.values.get_name() + + def get_version(self) -> str: + """Get component version.""" + + return self._state.values.get_version() + + def has_variable(self, name: str) -> bool: + """ + Checks if a variable exists. + + :param name: Variable name. + + """ + + return self._state.values.has_variable(name) + + def get_variables(self) -> dict: + """Gets all component variables.""" + + return self._state.values.get_variables() + + def get_variable(self, name: str) -> str: + """ + Get a single component variable. + + :param name: Variable name. + + """ + + return self._state.values.get_variable(name) + + def has_resource(self, name: str) -> bool: + """ + Check if a resource name exist. + + :param name: Name of the resource. + + """ + + return self._component.has_resource(name) + + def get_resource(self, name: str) -> Any: + """ + Get a resource. + + :param name: Name of the resource. + + :raises: LookupError + + """ + + return self._component.get_resource(name) + + def get_services(self) -> List[dict]: + """Get service names and versions from the mapping schemas.""" + + return list(self._schemas.get_services()) + + def get_service_schema(self, name: str, version: str) -> ServiceSchema: + """ + Get the schema for a service. + + The version can be either a fixed version or a pattern that uses "*" + and resolves to the higher version available that matches. + + :param name: The name of the service. + :para version: The version of the service. + + :raises: LookupError + + """ + + from .service import ServiceSchema + + payload = self._schemas.get_service_schema_payload(name, version) + return ServiceSchema(payload) + + def log(self, value: Any, level: int = INFO) -> Api: + """ + Write a value to the KUSANAGI logs. + + Given value is converted to string before being logged. + + Output is truncated to have a maximum of 100000 characters. + + :param value: The value to log. + :param level: An optional log level to use for the log message. + + """ + + self._logger.log(level, value_to_log_string(value)) + return self + + def done(self) -> bool: + """Dummy method to comply with KUSANAGI SDK specifications.""" + + raise Exception('SDK does not support async call to end action: Api.done()') diff --git a/kusanagi/sdk/base.py b/kusanagi/sdk/base.py deleted file mode 100644 index bad43a0..0000000 --- a/kusanagi/sdk/base.py +++ /dev/null @@ -1,217 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from .schema.service import ServiceSchema -from ..errors import KusanagiError -from ..logging import INFO -from ..logging import value_to_log_string -from ..schema import get_schema_registry -from ..versions import VersionString - - -class ApiError(KusanagiError): - """Exception class for API errors.""" - - -class Api(object): - """Base API class for SDK components.""" - - def __init__(self, component, path, name, version, framework_version, **kw): - self.__path = path - self.__name = name - self.__version = version - self.__framework_version = framework_version - self.__variables = kw.get('variables') or {} - self.__debug = kw.get('debug', False) - self._registry = get_schema_registry() - self._component = component - # Logger must be initialized by child classes - self._logger = None - - def is_debug(self): - """Determine if component is running in debug mode. - - :rtype: bool - - """ - - return self.__debug - - def get_framework_version(self): - """Get KUSANAGI framework version. - - :rtype: str - - """ - - return self.__framework_version - - def get_path(self): - """Get source file path. - - Returns the path to the endpoint userland source file. - - :returns: Source path file. - :rtype: str - - """ - - return self.__path - - def get_name(self): - """Get component name. - - :rtype: str - - """ - - return self.__name - - def get_version(self): - """Get component version. - - :rtype: str - - """ - - return self.__version - - def has_variable(self, name): - """Checks if a variable exists. - - :param name: Variable name. - :type name: str - - :rtype: bool - - """ - - return name in self.__variables - - def get_variables(self): - """Gets all component variables. - - :rtype: dict - - """ - - return self.__variables - - def get_variable(self, name): - """Get a single component variable. - - :param name: Variable name. - :type name: str - - :rtype: str - - """ - - return self.__variables.get(name, '') - - def has_resource(self, name): - """Check if a resource name exist. - - :param name: Name of the resource. - :type name: str - - :rtype: bool - - """ - - return self._component.has_resource(name) - - def get_resource(self, name): - """Get a resource. - - :param name: Name of the resource. - :type name: str - - :raises: ComponentError - - :rtype: object - - """ - - return self._component.get_resource(name) - - def get_services(self): - """Get service names and versions. - - :rtype: list - - """ - - services = [] - for name in self._registry.get_service_names(): - for version in self._registry.get(name).keys(): - services.append({'name': name, 'version': version}) - - return services - - def get_service_schema(self, name, version): - """Get service schema. - - Service version string may contain many `*` that will be - resolved to the higher version available. For example: `1.*.*`. - - :param name: Service name. - :type name: str - :param version: Service version string. - :type version: str - - :raises: ApiError - - :rtype: ServiceSchema - - """ - - # Resolve service version when wildcards are used - if '*' in version: - try: - version = VersionString(version).resolve( - self._registry.get(name, {}).keys() - ) - # NOTE: Space is uses ad separator because service names allow - # any character except spaces, \t or \n. - path = '{} {}'.format(name, version) - payload = self._registry.get(path, None, delimiter=" ") - except KusanagiError: - payload = None - else: - path = '{} {}'.format(name, version) - payload = self._registry.get(path, None, delimiter=" ") - - if not payload: - error = 'Cannot resolve schema for Service: "{}" ({})' - raise ApiError(error.format(name, version)) - - return ServiceSchema(name, version, payload) - - def log(self, value, level=INFO): - """Write a value to KUSANAGI logs. - - Given value is converted to string before being logged. - - Output is truncated to have a maximum of 100000 characters. - - """ - - if self._logger: - self._logger.log(level, value_to_log_string(value)) - - def done(self): - """This is a dummy method that only raises an exception. - - It is implemented to comply with KUSANAGI SDK specifications. - - :raises: ApiError - - """ - - msg = 'SDK does not support async call to end action: Api.done()' - raise ApiError(msg) diff --git a/kusanagi/sdk/callee.py b/kusanagi/sdk/callee.py new file mode 100644 index 0000000..05f1a55 --- /dev/null +++ b/kusanagi/sdk/callee.py @@ -0,0 +1,79 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .action import ActionSchema +from .lib.payload import ns +from .lib.payload.utils import payload_to_param + +if TYPE_CHECKING: + from typing import List + + from .lib.payload import Payload + from .param import Param + + +class Callee(object): + """Represents a callee service call info.""" + + def __init__(self, payload: Payload): + """ + Constructor. + + :param payload: The payload with the callee info. + + """ + + self.__payload = payload + + def get_duration(self) -> int: + """Get the duration of the call in milliseconds.""" + + return self.__payload.get([ns.DURATION], 0) + + def is_remote(self) -> bool: + """Check if the call is to a service in another Realm.""" + + return self.__payload.exists([ns.GATEWAY]) + + def get_address(self) -> str: + """Get the remote gateway address.""" + + return self.__payload.get([ns.GATEWAY], '') + + def get_timeout(self) -> int: + """Get the timeout in milliseconds for the call to a service in another realm.""" + + return self.__payload.get([ns.TIMEOUT], ActionSchema.DEFAULT_EXECUTION_TIMEOUT) + + def get_name(self) -> str: + """Get the name of the service.""" + + return self.__payload.get([ns.NAME], '') + + def get_version(self) -> str: + """Get the service version.""" + + return self.__payload.get([ns.VERSION], '') + + def get_action(self) -> str: + """Get the name of the service action.""" + + return self.__payload.get([ns.ACTION], '') + + def get_params(self) -> List[Param]: + """ + Get the call parameters. + + An empty list is returned when there are no parameters defined for the call. + + """ + + return [payload_to_param(payload) for payload in self.__payload.get([ns.PARAMS], [])] diff --git a/kusanagi/sdk/caller.py b/kusanagi/sdk/caller.py new file mode 100644 index 0000000..845bbaf --- /dev/null +++ b/kusanagi/sdk/caller.py @@ -0,0 +1,49 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from .callee import Callee +from .lib.payload import Payload + + +class Caller(object): + """Represents a service which registered call in the transport.""" + + def __init__(self, name: str, version: str, action: str, callee: dict): + """ + Constructor. + + :param name: The name of the service. + :param version: The version of the service. + :param action: The name of the action. + :param calee: The payload data with the callee info. + + """ + + self.__name = name + self.__version = version + self.__action = action + self.__callee = Payload(callee) + + def get_name(self) -> str: + """Get the name of the service.""" + + return self.__name + + def get_version(self) -> str: + """Get the service version.""" + + return self.__version + + def get_action(self) -> str: + """Get the name of the service action.""" + + return self.__action + + def get_callee(self) -> Callee: + """Get the callee info for the service being called.""" + + return Callee(self.__callee) diff --git a/kusanagi/sdk/component.py b/kusanagi/sdk/component.py index 494a1d5..c8d9edf 100644 --- a/kusanagi/sdk/component.py +++ b/kusanagi/sdk/component.py @@ -5,160 +5,149 @@ # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. -import logging +from __future__ import annotations -from ..errors import KusanagiError -from ..logging import INFO -from ..logging import value_to_log_string -from ..schema import SchemaRegistry -from ..utils import Singleton +from typing import TYPE_CHECKING +from .lib.events import Events +from .lib.logging import INFO +from .lib.logging import Logger +from .lib.logging import value_to_log_string +from .lib.server import create_server +from .lib.singleton import Singleton -class ComponentError(KusanagiError): - """Exception calss for component errors.""" +if TYPE_CHECKING: + from typing import Any + from typing import Callable + # Component callback types + Callback = Callable[['Component'], Any] + ErrorCallback = Callable[[Exception], Any] -class Component(object, metaclass=Singleton): - """Base KUSANAGI SDK component class.""" + +class Component(metaclass=Singleton): + """KUSANAGI SDK component class.""" def __init__(self): + # Private self.__resources = {} - self.__startup_callback = None - self.__shutdown_callback = None - self.__error_callback = None + self.__startup = None + self.__shutdown = None + self.__error = None + self.__logger = Logger(__name__) + + # Protected self._callbacks = {} - self._runner = None - self.__logger = logging.getLogger('kusanagi.sdk') - def has_resource(self, name): - """Check if a resource name exist. + def has_resource(self, name) -> bool: + """ + Check if a resource name exist. :param name: Name of the resource. - :type name: str - - :rtype: bool """ return name in self.__resources - def set_resource(self, name, callable): - """Store a resource. + def set_resource(self, name: str, factory: Callback): + """ + Store a resource. Callback receives the `Component` instance as first argument. :param name: Name of the resource. - :type name: str - :param callable: A callable that returns the resource value. - :type callable: function + :param factory: A callable that returns the resource value. - :raises: ComponentError + :raises: ValueError """ - value = callable(self) + value = factory(self) if value is None: - err = 'Invalid result value for resource "{}"'.format(name) - raise ComponentError(err) + raise ValueError(f'Invalid result value for resource "{name}"') self.__resources[name] = value - def get_resource(self, name): - """Get a resource. + def get_resource(self, name: str) -> Any: + """ + Get a resource. :param name: Name of the resource. - :type name: str - - :raises: ComponentError - :rtype: object + :raises: LookupError """ if not self.has_resource(name): - raise ComponentError('Resource "{}" not found'.format(name)) + raise LookupError(f'Resource "{name}" not found') return self.__resources[name] - def startup(self, callback): - """Register a callback to be called during component startup. - - Callback receives a single argument with the Component instance. + def startup(self, callback: Callback) -> Component: + """ + Register a callback to be called during component startup. :param callback: A callback to execute on startup. - :type callback: function - - :rtype: Component """ - self.__startup_callback = callback + self.__startup = callback return self - def shutdown(self, callback): - """Register a callback to be called during component shutdown. - - Callback receives a single argument with the Component instance. + def shutdown(self, callback: Callback) -> Component: + """ + Register a callback to be called during component shutdown. :param callback: A callback to execute on shutdown. - :type callback: function - - :rtype: Component """ - self.__shutdown_callback = callback + self.__shutdown = callback return self - def error(self, callback): - """Register a callback to be called on message callback errors. - - Callback receives a single argument with the Exception instance. - - :param callback: A callback to execute a message callback fails. - :type callback: function + def error(self, callback: ErrorCallback) -> Component: + """ + Register a callback to be called on error. - :rtype: Component + :param callback: A callback to execute when the component fails to handle a request. """ - self.__error_callback = callback + self.__error = callback return self - def run(self): - """Run SDK component. - - Calling this method checks command line arguments before - component server starts. - + def log(self, value: Any, level: int = INFO) -> Component: """ - - if not self._runner: - # Child classes must create a component runner instance - raise Exception('No component runner defined') - - if self.__startup_callback: - self._runner.set_startup_callback(self.__startup_callback) - - if self.__shutdown_callback: - self._runner.set_shutdown_callback(self.__shutdown_callback) - - if self.__error_callback: - self._runner.set_error_callback(self.__error_callback) - - # Create the global schema registry instance on run - SchemaRegistry() - - self._runner.set_callbacks(self._callbacks) - self._runner.run() - - def log(self, value, level=INFO): - """Write a value to KUSANAGI logs. + Write a value to KUSANAGI logs. Given value is converted to string before being logged. Output is truncated to have a maximum of 100000 characters. + :param value: The value to log. + :param level: An optional log level to use for the log message. + """ self.__logger.log(level, value_to_log_string(value)) + return self + + def run(self) -> bool: + """Run the SDK component.""" + + # Create a helper to process component events + events = Events(self.__startup, self.__shutdown, self.__error) + + # Run the server and check if all the callbacks run successfully + success = False + if events.startup(self): + try: + server = create_server(self, self._callbacks, events.error) + server.start() + except Exception as exc: + self.__logger.exception(f'Component error: {exc}') + else: + success = True + + # Return False when shutdown fails otherwise use the success value + return success if events.shutdown(self) else False diff --git a/kusanagi/sdk/error.py b/kusanagi/sdk/error.py new file mode 100644 index 0000000..4794b29 --- /dev/null +++ b/kusanagi/sdk/error.py @@ -0,0 +1,77 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +class Error(Exception): + """Represents an error object in the transport.""" + + DEFAULT_MESSAGE = 'Unknown error' + DEFAULT_CODE = 0 + DEFAULT_STATUS = '500 Internal Server Error' + + def __init__( + self, + address: str, + name: str, + version: str, + message: str = DEFAULT_MESSAGE, + code: int = DEFAULT_CODE, + status: str = DEFAULT_STATUS, + ): + """ + Constructor. + + :param address: The network address of the gateway. + :param name: The name of the service. + :param version: The version of the service. + :param message: An optional error message. + :param code: An optional code for the error. + :param status: An optional status text for the error. + + """ + + self.__address = address + self.__name = name + self.__version = version + self.__message = message + self.__code = code + self.__status = status + + def __str__(self): + message = self.get_message() + return f'[{self.__address}] {self.__name} ({self.__version}) error: {message}' + + def get_address(self) -> str: + """Get the gateway address of the service.""" + + return self.__address + + def get_name(self) -> str: + """Get the name of the service.""" + + return self.__name + + def get_version(self) -> str: + """Get the service version.""" + + return self.__version + + def get_message(self) -> str: + """Get the error message.""" + + return self.__message or self.DEFAULT_MESSAGE + + def get_code(self) -> int: + """Get the error code.""" + + return self.__code or self.DEFAULT_CODE + + def get_status(self) -> str: + """Get the status text for the error.""" + + return self.__status or self.DEFAULT_STATUS diff --git a/kusanagi/sdk/file.py b/kusanagi/sdk/file.py index c4232d5..c190b4c 100644 --- a/kusanagi/sdk/file.py +++ b/kusanagi/sdk/file.py @@ -5,265 +5,293 @@ # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. -import http.client +from __future__ import annotations + import logging import mimetypes import os +import sys import urllib.request +from typing import TYPE_CHECKING +from typing import List -from urllib.parse import urlparse +from .lib.error import KusanagiError +from .lib.payload import ns -from ..payload import get_path -from ..payload import Payload +if TYPE_CHECKING: + from .lib.payload.file import FileSchemaPayload + from .lib.payload.file import HttpFileSchemaPayload LOG = logging.getLogger(__name__) -def file_to_payload(file): - """Convert a File object to a payload. +class File(object): + """ + File parameter. - :param file: A File object. - :type file: `File` + Actions receive files thought calls to a service component. - :rtype: Payload + Files can also be returned from the service actions. """ - return Payload().set_many({ - 'name': file.get_name(), - 'path': file.get_path(), - 'mime': file.get_mime(), - 'filename': file.get_filename(), - 'size': file.get_size(), - 'token': file.get_token(), - }) + def __init__(self, name: str, path: str = '', mime: str = '', filename: str = '', size: int = 0, token: str = ''): + """ + Constructor. + When the path is local it can start with "file://" or be a path to a local file, + otherwise it means is a remote file and it must start with "http://". -def payload_to_file(payload): - """Convert payload to a File. + :param name: Name of the file parameter. + :param path: Optional path to the file. + :param mime: Optional MIME type of the file contents. + :param filename: Optional file name and extension. + :param size: Optional file size in bytes. + :param token: Optional file server security token to access the file. - :param payload: A payload object. - :type payload: dict + :raises: ValueError, KusanagiError - :rtype: `File` + """ - """ + if path and path.startswith('http://'): + if not mime.strip(): + raise ValueError('File missing MIME type') + elif not filename.strip(): + raise ValueError('File missing file name') + elif size < 0: + raise ValueError('Invalid file size') + elif not token.strip(): + raise ValueError('File missing token') + elif path: + if token: + raise ValueError('Unexpected file token') + + local_path = path[7:] if path.startswith('file://') else path + if not os.path.isfile(local_path): + raise KusanagiError(f'File doesn\'t exist: "{local_path}"') + + if not mime.strip(): + mime = mimetypes.guess_type(local_path)[0] or 'text/plain' + + if not filename.strip(): + filename = os.path.basename(local_path) + + if not size: + try: + size = os.path.getsize(local_path) + except OSError: # pragma: no cover + size = 0 - # All files created from payload data are remote - return File( - get_path(payload, 'name'), - get_path(payload, 'path'), - mime=get_path(payload, 'mime', None), - filename=get_path(payload, 'filename', None), - size=get_path(payload, 'size', None), - token=get_path(payload, 'token', None), - ) + if not path.startswith('file://'): + path = f'file://{local_path}' + self.__name = name + self.__path = path + self.__mime = mime + self.__filename = filename + self.__size = size + self.__token = token -class File(object): - """File class for API. + def get_name(self) -> str: + """Get the name of the file parameter.""" - Represents a file received or to be sent to another Service component. + return self.__name - """ + def get_path(self) -> str: + """Get the file path.""" - def __init__(self, name, path, **kwargs): - # Validate and set file name - if not (name or '').strip(): - raise TypeError('Invalid file name') - else: - self.__name = name - - # Validate and set file path - path = (path or '').strip() - protocol = path[:7] - if path and protocol not in ('file://', 'http://'): - self.__path = 'file://{}'.format(path) - protocol = 'file://' - else: - self.__path = path + return self.__path - # Set mime type, or guess it from path - self.__mime = kwargs.get('mime') - if not self.__mime: - self.__mime = mimetypes.guess_type(path)[0] or 'text/plain' + def get_mime(self) -> str: + """Get the MIME type of the file contents.""" - # Set file name, or get it from path - self.__filename = kwargs.get('filename') or os.path.basename(path) + return self.__mime - # Set file size - self.__size = kwargs.get('size') - if self.__size is None: - if protocol == 'file://': - try: - # Get file size from file - self.__size = os.path.getsize(self.__path[7:]) - except OSError: - self.__size = 0 - else: - self.__size = 0 + def get_filename(self) -> str: + """Get the file name.""" + + return self.__filename + + def get_size(self) -> int: + """Get the file size in bytes.""" + + return self.__size - # Token is required for remote file paths - self.__token = kwargs.get('token') or '' - if protocol == 'http://' and not self.__token: - raise TypeError('Token is required for remote file paths') + def get_token(self) -> str: + """Get the file server security token to access the file.""" - def get_name(self): - """Get parameter name. + return self.__token + + def exists(self) -> bool: + """Check if the file exists.""" + + return self.__path != '' and not self.__path.startswith('file://') - :rtype: str + def is_local(self) -> bool: + """Check if file is located in the local file system.""" + return self.__path.startswith('file://') + + def read(self) -> bytes: """ + Read the file contents. - return self.__name + When the file is local it is read from the file system, otherwise + an HTTP request is made to the remote file server to get its content. - def get_path(self): - """Get path. + :raises: KusanagiError + + """ + + # When the file is remote read it from the remote file server, otherwise + # the file is a local file, so check if it exists and read its contents. + if self.__path.startswith('http://'): + # Prepare the headers for request + headers = {} + if self.__token: + headers['X-Token'] = self.__token - :rtype: str + # Read file contents from remote file server + request = urllib.request.Request(self.__path, headers=headers) + try: + with urllib.request.urlopen(request) as f: + return f.read() + except Exception: + raise KusanagiError(f'Failed to read file: "{self.__path}"') + elif os.path.isfile(self.__path[7:]): + try: + # Read the local file contents + with open(self.__path[7:], 'rb') as f: + return f.read() + except Exception: + raise KusanagiError(f'Failed to read file: "{self.__path}"') + else: + # The file is local and can't be read + raise KusanagiError(f'File does not exist in path: "{self.__path}"') + def copy_with_name(self, name: str) -> File: """ + Copy the file parameter with a new name. - return self.__path + :param name: Name of the new file parameter. - def get_mime(self): - """Get mime type. + """ - :rtype: str. + return self.__class__(name, self.__path, self.__mime, self.__filename, self.__size, self.__token) + def copy_with_mime(self, mime: str) -> File: """ + Copy the file parameter with a new MIME type. - return self.__mime + :param mime: MIME type of the new file parameter. - def get_filename(self): - """Get file name. + """ - :rtype: str. + return self.__class__(self.__name, self.__path, mime, self.__filename, self.__size, self.__token) + +class FileSchema(object): + """File parameter schema in the framework.""" + + def __init__(self, payload: FileSchemaPayload): + self.__payload = payload + + def get_name(self) -> str: + """Get file parameter name.""" + + return self.__payload.get_name() + + def get_mime(self) -> str: + """Get mime type.""" + + return self.__payload.get([ns.MIME], 'text/plain') + + def is_required(self) -> bool: + """Check if file parameter is required.""" + + return self.__payload.get([ns.REQUIRED], False) + + def get_max(self) -> int: """ + Get maximum file size allowed for the parameter. - return self.__filename + Returns 0 if not defined. - def get_size(self): - """Get file size. + """ - :rtype: int. + return self.__payload.get([ns.MAX], sys.maxsize) + def is_exclusive_max(self) -> bool: """ + Check if maximum size is inclusive. - return self.__size + When max is not defined inclusive is False. - def get_token(self): - """Get file server token. + """ + + if not self.__payload.exists([ns.MAX]): + return False - :rtype: str. + return self.__payload.get([ns.EXCLUSIVE_MAX], False) + def get_min(self) -> int: """ + Get minimum file size allowed for the parameter. - return self.__token + Returns 0 if not defined. - def exists(self): - """Check if file exists. + """ - A request is made to check existence when file - is located in a remote file server. + return self.__payload.get([ns.MIN], 0) - :rtype: bool. + def is_exclusive_min(self) -> bool: + """ + Check if minimum size is inclusive. + + When min is not defined inclusive is False. """ - if not self.__path: + if not self.__payload.exists([ns.MIN]): return False - # Check remote file existence when path is HTTP (otherwise is file://) - if self.__path[:7] == 'http://': - # Setup headers for request - headers = {} - if self.__token: - headers['X-Token'] = self.__token + return self.__payload.get([ns.EXCLUSIVE_MIN], False) - # Make a HEAD request to check that file exists - part = urlparse(self.__path) - try: - conn = http.client.HTTPConnection(part.netloc, timeout=2) - conn.request('HEAD', part.path, headers=headers) - response = conn.getresponse() - exists = response.status == 200 - if not exists: - LOG.error( - 'File server request failed for %s, with error %s %s', - self.__path, - response.status, - response.reason, - ) - return exists - except: - LOG.exception('File server request failed: %s', self.__path) - return False - else: - # Check file existence locally - return os.path.isfile(self.__path[7:]) + def get_http_schema(self) -> HttpFileSchema: + """Get HTTP file param schema.""" - def is_local(self): - """Check if file is a local file. + payload = self.__payload.get_http_file_schema_payload() + return HttpFileSchema(payload) - :rtype: bool - """ +class HttpFileSchema(object): + """HTTP semantics of a file parameter schema in the framework.""" - return self.__path[:7] == 'file://' + def __init__(self, payload: HttpFileSchemaPayload): + self.__payload = payload - def read(self): - """Get file data. + def is_accessible(self) -> bool: + """Check if the Gateway has access to the parameter.""" - Returns the file data from the stored path. + return self.__payload.get([ns.GATEWAY], True) - :returns: The file data. - :rtype: bytes + def get_param(self) -> str: + """Get name as specified via HTTP to be mapped to the name property.""" - """ + return self.__payload.get([ns.PARAM], self.__payload.get_name()) - # Check if file is a remote file - if self.__path[:7] == 'http://': - # Setup headers for request - headers = {} - if self.__token: - headers['X-Token'] = self.__token - request = urllib.request.Request(self.__path, headers=headers) +def validate_file_list(files: List[File]): + """ + Check that all the items in the list are File instances. - # Read file contents from remote file server - try: - with urllib.request.urlopen(request) as file: - return file.read() - except: - LOG.exception('Unable to read file: %s', self.__path) - else: - # Check that file exists locally - if not os.path.isfile(self.__path[7:]): - LOG.error('File does not exist: %s', self.__path) - else: - # Read local file contents - try: - with open(self.__path[7:], 'rb') as file: - return file.read() - except: - LOG.exception('Unable to read file: %s', self.__path) - - return b'' - - def copy_with_name(self, name): - return self.__class__( - name, - self.__path, - size=self.__size, - mime=self.__mime, - ) - - def copy_with_mime(self, mime): - return self.__class__( - self.__name, - self.__path, - size=self.__size, - mime=mime, - ) + :raises: ValueError + + """ + + if not files: + return + + for file in files: + if not isinstance(file, File): + raise ValueError(f'The file is not an instance of File: {file.__class__}') diff --git a/kusanagi/sdk/http/__init__.py b/kusanagi/sdk/http/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/kusanagi/sdk/http/request.py b/kusanagi/sdk/http/request.py deleted file mode 100644 index 711419a..0000000 --- a/kusanagi/sdk/http/request.py +++ /dev/null @@ -1,446 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from itertools import chain -from urllib.parse import urlparse - -from ..file import File -from ...utils import MultiDict - - -class HttpRequest(object): - """HTTP request class.""" - - def __init__(self, method, url, **kwargs): - super().__init__() - self.__method = method.upper() - self.__url = url - self.__protocol_version = kwargs.get('protocol_version') or '1.1' - self.__query = kwargs.get('query') or MultiDict() - self.__post_data = kwargs.get('post_data') or MultiDict() - self.__body = kwargs.get('body') or '' - self.__files = kwargs.get('files') or MultiDict() - # Save headers names in upper case - self.__headers = MultiDict({ - name.upper(): value - for name, value in (kwargs.get('headers') or {}).items() - }) - - # Save parsed URL - self.__parsed_url = urlparse(self.get_url()) - - def is_method(self, method): - """Determine if the request used the given HTTP method. - - Returns True if the HTTP method of the request is the same - as the specified method, otherwise False. - - :param method: The HTTP method. - :type method: str - - :rtype: bool - - """ - - return self.__method == method.upper() - - def get_method(self): - """Gets the HTTP method. - - Returns the HTTP method used for the request. - - :returns: The HTTP method. - :rtype: str - - """ - - return self.__method - - def get_url(self): - """Get request URL. - - :rtype: str - - """ - - return self.__url - - def get_url_scheme(self): - """Get request URL scheme. - - :rtype: str - - """ - - return self.__parsed_url.scheme - - def get_url_host(self): - """Get request URL host. - - :rtype: str - - """ - - # The port number is ignored when present - return self.__parsed_url.netloc.split(':')[0] - - def get_url_port(self): - """Get request URL port. - - :rtype: int - - """ - - return self.__parsed_url.port or 0 - - def get_url_path(self): - """Get request URL path. - - :rtype: str - - """ - - return self.__parsed_url.path.rstrip('/') - - def has_query_param(self, name): - """Determines if the param is defined. - - Returns True if the param is defined in the HTTP query string, - otherwise False. - - :param name: The HTTP param. - :type name: str - - :rtype: bool - - """ - - return name in self.__query - - def get_query_param(self, name, default=''): - """Gets a param from the HTTP query string. - - Returns the param from the HTTP query string with the given - name, or and empty string if not defined. - - :param name: The param from the HTTP query string. - :type name: str - :param default: The optional default value. - :type name: str - - :returns: The HTTP param value. - :rtype: str - - """ - - return self.__query.get(name, (default, ))[0] - - def get_query_param_array(self, name, default=None): - """Gets a param from the HTTP query string. - - Parameter is returned as a list of values. - - :param name: The param from the HTTP query string. - :type name: str - :param default: The optional default value. - :type default: list - - :raises: ValueError - - :returns: The HTTP param values as a list. - :rtype: list - - """ - - if default is None: - default = [] - elif not isinstance(default, list): - raise ValueError('Default value is not a list') - - return self.__query.get(name, default) - - def get_query_params(self): - """Get all HTTP query params. - - :returns: The HTTP params. - :rtype: dict - - """ - - return {key: value[0] for key, value in self.__query.items()} - - def get_query_params_array(self): - """Get all HTTP query params. - - Each parameter value is returned as a list. - - :returns: The HTTP params. - :rtype: `MultiDict` - - """ - - return self.__query - - def has_post_param(self, name): - """Determines if the param is defined. - - Returns True if the param is defined in the HTTP post data, - otherwise False. - - :param name: The HTTP param. - :type name: str - - :rtype: bool - - """ - - return name in self.__post_data - - def get_post_param(self, name, default=''): - """Gets a param from the HTTP post data. - - Returns the param from the HTTP post data with the given - name, or and empty string if not defined. - - :param name: The param from the HTTP post data. - :type name: str - - :param default: The optional default value. - :type name: str - - :returns: The HTTP param. - :rtype: str - - """ - - return self.__post_data.get(name, (default, ))[0] - - def get_post_param_array(self, name, default=None): - """Gets a param from the HTTP post data. - - Parameter is returned as a list of values. - - :param name: The param from the HTTP post data. - :type name: str - :param default: The optional default value. - :type default: list - - :raises: ValueError - - :returns: The HTTP param values as a list. - :rtype: list - - """ - - if default is None: - default = [] - elif not isinstance(default, list): - raise ValueError('Default value is not a list') - - return self.__post_data.get(name, default) - - def get_post_params(self): - """Get all HTTP post params. - - :returns: The HTTP post params. - :rtype: dict - - """ - - return {key: value[0] for key, value in self.__post_data.items()} - - def get_post_params_array(self): - """Get all HTTP post params. - - Each parameter value is returned as a list. - - :returns: The HTTP post params. - :rtype: `MultiDict` - - """ - - return self.__post_data - - def is_protocol_version(self, version): - """Determine if the request used the given HTTP version. - - Returns True if the HTTP version of the request is the same - as the specified protocol version, otherwise False. - - :param version: The HTTP version. - :type version: str - - :rtype: bool - - """ - - return self.__protocol_version == version - - def get_protocol_version(self): - """Gets the HTTP version. - - Returns the HTTP version used for the request. - - :returns: The HTTP version. - :rtype: str - - """ - - return self.__protocol_version - - def has_header(self, name): - """Determines if the HTTP header is defined. - - Header name is case insensitive. - - Returns True if the HTTP header is defined, otherwise False. - - :param name: The HTTP header name. - :type name: str - - :rtype: bool - - """ - - return name.upper() in self.__headers - - def get_header(self, name, default=''): - """Get an HTTP header. - - Returns the HTTP header with the given name, or and empty - string if not defined. - - Header name is case insensitive. - - :param name: The HTTP header name. - :type name: str - :param default: The optional default value. - :type default: str - - :returns: The HTTP header value. - :rtype: str - - """ - - name = name.upper() - if not self.has_header(name): - return default - - return self.__headers.get(name)[0] - - def get_header_array(self, name, default=None): - """Gets an HTTP header. - - Header name is case insensitive. - - :param name: The HTTP header name. - :type name: str - :param default: The optional default value. - :type default: list - - :raises: ValueError - - :returns: The HTTP header values as a list. - :rtype: list - - """ - - if default is None: - default = [] - elif not isinstance(default, list): - raise ValueError('Default value is not a list') - - return self.__headers.get(name.upper(), default) - - def get_headers(self): - """Get all HTTP headers. - - :returns: The HTTP headers. - :rtype: dict - - """ - - return {key: value[0] for key, value in self.__headers.items()} - - def get_headers_array(self): - """Get all HTTP headers. - - :returns: The HTTP headers. - :rtype: `MultiDict` - - """ - - return self.__headers - - def has_body(self): - """Determines if the HTTP request body has content. - - Returns True if the HTTP request body has content, otherwise False. - - :rtype: bool - - """ - - return self.__body != '' - - def get_body(self): - """Gets the HTTP request body. - - Returns the body of the HTTP request, or and empty string if - no content. - - :returns: The HTTP request body. - :rtype: str - - """ - - return self.__body - - def has_file(self, name): - """Check if a file was uploaded in current request. - - :param name: File name. - :type name: str - - :rtype: bool - - """ - - return name in self.__files - - def get_file(self, name): - """Get an uploaded file. - - Returns the file uploaded with the HTTP request, or None. - - :param name: Name of the file. - :type name: str - - :returns: The uploaded file. - :rtype: `File` - - """ - - if name in self.__files: - # Get only the first file - return self.__files.getone(name) - else: - return File(name, path='') - - def get_files(self): - """Get uploaded files. - - Returns the files uploaded with the HTTP request. - - :returns: A list of `File` objects. - :rtype: iter - - """ - - # Fields might have more than one file uploaded for the same name, - # there for it can happen that file names are duplicated. - return chain.from_iterable(self.__files.values()) diff --git a/kusanagi/sdk/http/response.py b/kusanagi/sdk/http/response.py deleted file mode 100644 index 4766cc8..0000000 --- a/kusanagi/sdk/http/response.py +++ /dev/null @@ -1,299 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from ...utils import MultiDict - - -class HttpResponse(object): - """HTTP response class.""" - - def __init__(self, status_code, status_text, *args, **kwargs): - super().__init__() - self.__headers = MultiDict() - self.set_status(status_code, status_text) - self.set_protocol_version(kwargs.get('protocol_version')) - self.set_body(kwargs.get('body')) - - # Save header name mappings to avoid changing use setted header name - self.__header_names = {} - - # Set response headers - headers = kwargs.get('headers') - if headers: - # Headers should be a list of tuples - if isinstance(headers, dict): - headers = headers.items() - - for name, values in headers: - for value in values: - self.set_header(name, value) - - # Save header name mapping - self.__header_names[name.upper()] = name - - def is_protocol_version(self, version): - """Determine if the response uses the given HTTP version. - - :param version: The HTTP version. - :type version: str - - :rtype: bool - - """ - - return self.__protocol_version == version - - def get_protocol_version(self): - """Get the HTTP version. - - :rtype: str - - """ - - return self.__protocol_version - - def set_protocol_version(self, version): - """Set the HTTP version to the given protocol version. - - Sets the HTTP version of the response to the specified - protocol version. - - :param version: The HTTP version. - :type version: str - - :rtype: HttpResponse - - """ - - self.__protocol_version = version or '1.1' - return self - - def is_status(self, status): - """Determine if the response uses the given status. - - :param status: The HTTP status. - :type status: str - - :rtype: bool - - """ - - return self.__status == status - - def get_status(self): - """Get the HTTP status. - - :rtype: str - - """ - - return self.__status - - def get_status_code(self): - """Get HTTP status code. - - :rtype: int - - """ - - return self.__status_code - - def get_status_text(self): - """Get HTTP status text. - - :rtype: str - - """ - - return self.__status_text - - def set_status(self, code, text): - """Set the HTTP status to the given status. - - Sets the status of the response to the specified - status code and text. - - :param code: The HTTP status code. - :type code: int - :param text: The HTTP status text. - :type text: str - - :rtype: HttpResponse - - """ - - self.__status_code = code - self.__status_text = text - self.__status = '{} {}'.format(code, text) - return self - - def has_header(self, name): - """Determines if the HTTP header is defined. - - Header name is case insensitive. - - :param name: The HTTP header name. - :type name: str - - :rtype: bool - - """ - - return name.upper() in self.__header_names - - def get_header(self, name, default=''): - """Get an HTTP header. - - Returns the HTTP header with the given name, or and empty - string if not defined. - - Header name is case insensitive. - - :param name: The HTTP header name. - :type name: str - :param default: The optional default value. - :type default: str - - :returns: The HTTP header value. - :rtype: str - - """ - - # Get the header name with the original casing - name = self.__header_names.get(name.upper(), name) - - values = self.__headers.get(name) - if not values: - return '' - - # Get first header value - return values[0] - - def get_header_array(self, name, default=None): - """Gets an HTTP header. - - Header name is case insensitive. - - :param name: The HTTP header name. - :type name: str - :param default: The optional default value. - :type default: list - - :raises: ValueError - - :returns: The HTTP header values as a list. - :rtype: list - - """ - - if default is None: - default = [] - elif not isinstance(default, list): - raise ValueError('Default value is not a list') - - # Get the header name with the original casing - name = self.__header_names.get(name.upper(), name) - return self.__headers.get(name, default) - - def get_headers(self): - """Get all HTTP headers. - - :returns: The HTTP headers. - :rtype: dict - - """ - - return {key: value[0] for key, value in self.__headers.items()} - - def get_headers_array(self): - """Get all HTTP headers. - - :returns: The HTTP headers. - :rtype: `MultiDict` - - """ - - return self.__headers - - def set_header(self, name, value, overwrite=False): - """Set a HTTP with the given name and value. - - Sets a header in the HTTP response with the specified name and value. - - :param name: The HTTP header. - :type name: str - :param value: The header value. - :type value: str - :param overwrite: Determines if an existing header should be overwritten. - :type overwrite: bool - - :rtype: `HttpResponse` - - """ - - # Check if a header name (in uppercase) exists in the list of header names - # NOTE: Uppercase is used to be able to support different name casing for the same header - uppercase_name = name.upper() - header_exists = uppercase_name in self.__header_names - if header_exists: - # Get the previous name used to set the header value - header_name = self.__header_names[uppercase_name] - - if overwrite: - # Delete previous header values when overwite is true - del self.__headers[header_name] - elif name != header_name: - # Move current header values to a hader with the same name but different casing - for value in self.__headers[header_name]: - self.__headers[name] = value - - # Remove the existing header name leaving the values in the new header name - del self.__headers[header_name] - - # Add the header value to the headers - self.__headers[name] = value - # Save the last name used to change the header - self.__header_names[uppercase_name] = name - - return self - - def has_body(self): - """Determines if the response has content. - - Returns True if the HTTP response body has content, otherwise False. - - :rtype: bool - - """ - - return self.__body != '' - - def get_body(self): - """Gets the response body. - - :returns: The HTTP response body. - :rtype: str - - """ - - return self.__body - - def set_body(self, content=None): - """Set the content of the HTTP response. - - Sets the content of the body of the HTTP response with - the specified content. - - :param content: The content for the HTTP response body. - :type content: str - - :rtype: HttpResponse - - """ - - self.__body = content or '' - return self diff --git a/kusanagi/sdk/lib/__init__.py b/kusanagi/sdk/lib/__init__.py new file mode 100644 index 0000000..e03c450 --- /dev/null +++ b/kusanagi/sdk/lib/__init__.py @@ -0,0 +1,10 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +# flake8: noqa +from .payload import Payload +from .payload import ns diff --git a/kusanagi/sdk/lib/asynchronous.py b/kusanagi/sdk/lib/asynchronous.py new file mode 100644 index 0000000..4559172 --- /dev/null +++ b/kusanagi/sdk/lib/asynchronous.py @@ -0,0 +1,296 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +import asyncio +import io +import os +import urllib +from http.client import parse_headers +from typing import TYPE_CHECKING + +from ..action import EXECUTION_TIMEOUT +from ..action import Action +from ..file import File +from ..file import validate_file_list +from ..param import validate_parameter_list +from ..request import HttpRequest +from ..request import Request +from ..response import Response +from ..transport import Transport +from .call import AsyncClient +from .error import KusanagiError +from .payload import ns +from .payload.request import HttpRequestPayload +from .payload.transport import TransportPayload + +if TYPE_CHECKING: + from typing import Any + from typing import List + + from ..param import Param + + +def file_to_async(f: File) -> AsyncFile: + """ + Convert file to an async file + + :param f: The file to convert. + + """ + + return AsyncFile( + f.get_name(), + f.get_path(), + mime=f.get_mime(), + filename=f.get_filename(), + size=f.get_size(), + token=f.get_token(), + ) + + +class AsyncRequest(Request): + """Request API class for the middleware component.""" + + def get_http_request(self) -> AsyncHttpRequest: + """Get HTTP request for current request.""" + + payload = HttpRequestPayload(self._command.get([ns.REQUEST], {})) + return AsyncHttpRequest(payload) + + +class AsyncHttpRequest(HttpRequest): + """HTTP request class.""" + + def __init__(self, payload: HttpRequestPayload): + """ + Constructor. + + :param payload: The payload with the HTTP request data. + + """ + + super().__init__(payload) + # Convert the files to async files + self.__files = {name: file_to_async(file) for name, file in self.__files} + + def get_file(self, name: str) -> AsyncFile: + """ + Get an uploaded file. + + :param name: The name of the file. + + """ + + return super().get_file(name) + + def get_files(self) -> List[AsyncFile]: + """Get uploaded files.""" + + return super().get_files() + + +class AsyncResponse(Response): + """Response API class for the middleware component.""" + + def get_transport(self) -> AsyncTransport: + """Get the Transport object.""" + + payload = TransportPayload(self._command.get_transport_data()) + return AsyncTransport(payload) + + +class AsyncTransport(Transport): + """Transport class encapsulates the transport object.""" + + def get_download(self) -> AsyncFile: + """Get the file download defined for the response.""" + + return file_to_async(super().get_download()) + + +class AsyncAction(Action): + """Action API class for service component.""" + + def get_file(self, name: str) -> AsyncFile: + """ + Get a file with a given name. + + :param name: File name. + + """ + + # TODO: Create the file from the service schema (see PHP SDK) + if not self.has_file(name): + return AsyncFile(name) + + return file_to_async(super().get_file(name)) + + def new_file(self, name: str, path: str, mime: str = '') -> AsyncFile: + """ + Create a new file. + + :param name: File name. + :param path: File path. + :param mime: Optional file mime type. + + """ + + return AsyncFile(name, path, mime=mime) + + async def call( + self, + service: str, + version: str, + action: str, + params: List[Param] = None, + files: List[File] = None, + timeout: int = EXECUTION_TIMEOUT, + ) -> Any: + """ + Perform a run-time call to a service. + + The result of this call is the return value from the remote action. + + :param service: The service name. + :param version: The service version. + :param action: The action name. + :param params: Optional list of Param objects. + :param files: Optional list of File objects. + :param timeout: Optional timeout in milliseconds. + + :raises: ValueError, KusanagiError + + """ + + # Check that the call exists in the config + service_title = f'"{service}" ({version})' + try: + schema = self.get_service_schema(self.get_name(), self.get_version()) + action_schema = schema.get_action_schema(self.get_action_name()) + if not action_schema.has_call(service, version, action): + msg = f'Call not configured, connection to action on {service_title} aborted: "{action}"' + raise KusanagiError(msg) + except LookupError as err: + raise KusanagiError(err) + + # Check that the remote action exists and can return a value, and if it doesn't issue a warning + try: + remote_action_schema = self.get_service_schema(service, version).get_action_schema(action) + except LookupError as err: # pragma: no cover + self._logger.warning(err) + else: + if not remote_action_schema.has_return(): + raise KusanagiError(f'Cannot return value from {service_title} for action: "{action}"') + + validate_parameter_list(params) + validate_file_list(files) + + # Check that the file server is enabled when one of the files is local + if files: + for file in files: + if file.is_local(): + # Stop checking when one local file is found and the file server is enables + if schema.has_file_server(): + break + + raise KusanagiError(f'File server not configured: {service_title}') + + return_value = None + transport = None + client = AsyncClient(self._logger, tcp=self._state.values.is_tcp_enabled()) + try: + # NOTE: The transport set to the call won't have any data added by the action component + return_value, transport = await client.call( + schema.get_address(), + self.get_action_name(), + [service, version, action], + timeout, + transport=TransportPayload(self._command.get_transport_data()), # Use a clean transport for the call + params=params, + files=files, + ) + finally: + # Always add the call info to the transport, even after an exception is raised during the call + self._transport.add_call( + self.get_name(), + self.get_version(), + self.get_action_name(), + service, + version, + action, + client.get_duration(), + params=params, + files=files, + timeout=timeout, + transport=transport, + ) + + return return_value + + +def AsyncFile(File): + """ + File parameter. + + Actions receive files thought calls to a service component. + + Files can also be returned from the service actions. + + """ + + async def read(self) -> bytes: + """ + Read the file contents. + + When the file is local it is read from the file system, otherwise + an HTTP request is made to the remote file server to get its content. + + :raises: KusanagiError + + """ + + # When the file is remote read it from the remote file server, otherwise + # the file is a local file, so check if it exists and read its contents. + if self.__path.startswith('http://'): + # Parse the remote file URL and open a connection to the file server + url = urllib.parse.urlsplit(self.__path) + reader, writer = await asyncio.open_connection(url.hostname, 80) + # Prepare the HTTP request + request = [ + f'GET {url.path} HTTP/1.0', + f'Host: {url.hostname}', + f'X-Token: {self.__token}', + '\r\n' + ] + writer.write('\r\n'.join(request).encode('utf8')) + try: + # Make the request + response = io.BytesIO(await reader.read()) + # Remove the HTTP response start line to allow header parsing + response.readline() + # Parse the headers to be able to read only the response body from the response + parse_headers(response) + # read the file contents from the response body + return response.read() + except asyncio.CancelledError: + raise + except Exception: + raise KusanagiError(f'Failed to read file: "{self.__path}"') + finally: + writer.close() + elif os.path.isfile(self.__path[7:]): + try: + # Read the local file contents + with open(self.__path[7:], 'rb') as f: + return f.read() + except Exception: + raise KusanagiError(f'Failed to read file: "{self.__path}"') + else: + # The file is local and can't be read + raise KusanagiError(f'File does not exist in path: "{self.__path}"') diff --git a/kusanagi/sdk/lib/call.py b/kusanagi/sdk/lib/call.py new file mode 100644 index 0000000..65b3d87 --- /dev/null +++ b/kusanagi/sdk/lib/call.py @@ -0,0 +1,179 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +import asyncio +import re +import time +from typing import TYPE_CHECKING + +import zmq +import zmq.asyncio + +from .error import KusanagiError +from .logging import RequestLogger +from .msgpack import pack +from .msgpack import unpack +from .payload import ns +from .payload.command import CommandPayload +from .payload.error import ErrorPayload +from .payload.reply import ReplyPayload +from .payload.transport import TransportPayload + +if TYPE_CHECKING: + from typing import Any + from typing import List + from typing import Tuple + + from .file import File + from .param import Param + +# Regexp to parse the addresses to be used as IPC names +IPC_RE = re.compile(r'[^a-zA-Z0-9]{1,}') + +# Create a global ZMQ context for the run-time calls +CONTEXT = zmq.asyncio.Context() +CONTEXT.linger = 0 + +# Frame data to use for the run-time call multipart messages +RUNTIME_CALL = b'\x01' + + +def ipc(*args) -> str: + """Create an IPC connection string.""" + + name = IPC_RE.sub('-', '-'.join(args)) + return f'ipc://@kusanagi-{name}' + + +class CallError(KusanagiError): + """Error raised when when run-time call fails.""" + + def __init__(self, message: Any): + super().__init__(f'Run-time call failed: {message}') + + +class AsyncClient(object): + """Run-time service call client.""" + + def __init__(self, logger: RequestLogger, tcp: bool = False): + """ + Constructor. + + :param logger: A request logger. + :param tcp: Optional flag to use TCP instead of IPC. + + """ + + self.__logger = logger + self.__tcp = tcp + self.__duration = 0 + + def get_duration(self) -> int: + """Get the duration of the last call in milliseconds.""" + + return round(self.__duration) if self.__duration is not None else 0 + + async def call( + self, + address: str, + action: str, + callee: List[str], + timeout: int, + transport: TransportPayload, + params: List[Param] = None, + files: List[File] = None, + ) -> Tuple[Any, TransportPayload]: + """ + Make a Service run-time call. + + :param address: Caller Service address. + :param action: The caller action name. + :param callee: The callee Service name, version and action name. + :param timeout: Timeout in milliseconds. + :param transport: A transport payload. + :param params: Optional list of parameters. + :param files: Optional list of files. + + :raises: ValueError, KusanagiError, CallError + + """ + + if transport is None: + raise ValueError('Transport is required to make run-time calls') + + # Create the command payload for the call + command = CommandPayload.new_runtime_call( + action, + callee[0], + callee[1], + callee[2], + params=params, + files=files, + transport=transport, + ) + + # NOTE: Run-time calls are made to the server address where the caller is runnning + # and NOT directly to the service we wish to call. The KUSANAGI framework + # takes care of the call logic for us to keep consistency between all the SDKs. + channel = f'tcp://{address}' if self.__tcp else ipc(address) + socket = CONTEXT.socket(zmq.REQ) + stream = None + start = time.time() + try: + socket.connect(channel) + await socket.send_multipart([RUNTIME_CALL, pack(command)], zmq.NOBLOCK) + event = await socket.poll(timeout) + if event == zmq.POLLIN: + stream = await socket.recv() + else: + raise TimeoutError() + except asyncio.CancelledError: # pragma: no cover + raise + except TimeoutError: + self.__logger.error(f'Run-time call to address "{address}" failed: Timeout') + raise CallError('Timeout') + except Exception as exc: + self.__logger.error(f'Run-time call to address "{address}" failed: {exc}') + raise CallError('Failed to make the request') + finally: + # Update the call duration in milliseconds + self.__duration = (time.time() - start) * 1000 + if not socket.closed: + socket.disconnect(channel) + socket.close() + + # Parse the response stream + try: + payload = unpack(stream) + except asyncio.CancelledError: # pragma: no cover + # Async task canceled during unpack + raise + except Exception as exc: + self.__logger.error(f'Run-time call response format is invalid: {exc}') + raise CallError('The response format is invalid') + + # Check that the response is a dictionary + if not isinstance(payload, dict): + self.__logger.error(f'Run-time call response data is not a dictionary') + raise CallError('The payload data is not valid') + + # Check if the payload is an error payload, and if so use to raise an error + if isinstance(payload, dict) and ns.ERROR in payload: + error = ErrorPayload(payload) + raise CallError(error.get_message()) + + reply = ReplyPayload(payload) + return (reply.get_return_value(), reply.get_transport()) + + +class Client(AsyncClient): + """Run-time service call client for non async contexts.""" + + def call(self, *args, **kwargs): + return asyncio.run(super().call(*args, **kwargs)) diff --git a/kusanagi/sdk/lib/cli.py b/kusanagi/sdk/lib/cli.py new file mode 100644 index 0000000..faa62d5 --- /dev/null +++ b/kusanagi/sdk/lib/cli.py @@ -0,0 +1,245 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +import argparse +import inspect +import logging + +from .call import ipc +from .logging import SYSLOG_NUMERIC + +# This global is True when the SDK component is run in debug mode +DEBUG = False + +# List of CLI options to run the SDK components +PARSER = argparse.ArgumentParser(allow_abbrev=False) +PARSER.add_argument( + '-c', '--component', + help='Component type.', + required=True, + choices=['service', 'middleware'], +) +PARSER.add_argument( + '-D', '--debug', + help='Enable component debug.', + action='store_true', +) +PARSER.add_argument( + '-L', '--log-level', + help='Enable a logging using a numeric Syslog severity value to set the level.', + type=int, + choices=range(8), +) +PARSER.add_argument( + '-n', '--name', + help='Component name.', + required=True, +) +PARSER.add_argument( + '-p', '--framework-version', + help='KUSANAGI framework version.', + required=True, +) +PARSER.add_argument( + '-s', '--socket', + help='IPC socket name.', +) +PARSER.add_argument( + '-t', '--tcp', + help='TCP port to use when IPC socket is not used.', + type=int, +) +PARSER.add_argument( + '-T', '--timeout', + help='Process execution timeout per request in milliseconds.', + type=int, + default=30000, +) +PARSER.add_argument( + '-v', '--version', + help='Component version.', + required=True, +) +PARSER.add_argument( + '-V', '--var', + help='Component variables.', + action='append', + default=[], +) + + +def parse_args() -> Input: + """Parse CLI argument values.""" + + # Get the name of the current script + caller_frame = inspect.getouterframes(inspect.currentframe())[-1] + source_file = caller_frame[1] + # Use the name as program name for the parser + PARSER.prog = source_file + # Get the CLI values + values = vars(PARSER.parse_args()) + values['var'] = parse_key_value_list(values['var']) + return Input(source_file, **values) + + +def parse_key_value_list(values: list) -> dict: + """ + Option callback to validate a list of key/value arguments. + + Converts 'NAME=VALUE' CLI parameters to a dictionary. + + :raises: ValueError + + """ + + if not values: + return {} + + params = {} + for value in values: + parts = value.split('=', 1) + if len(parts) != 2: + raise ValueError('Invalid parameter format') + + param_name, param_value = parts + params[param_name] = param_value + + return params + + +class Input(object): + """CLI input values.""" + + def __init__(self, path: str, **kwargs): + """ + Constructor. + + The keywords are used as the input values dictionary. + + :param path: Path to the file being executed. + + """ + self.__path = path + self.__values = kwargs + + def get_path(self) -> str: + """ + Get the path to the file being executed. + + The path includes the file name. + + """ + + return self.__path + + def get_component(self) -> str: + """Get the component type.""" + + return self.__values['component'] + + def get_name(self) -> str: + """Get the component name.""" + + return self.__values['name'] + + def get_version(self) -> str: + """Get the component version.""" + + return self.__values['version'] + + def get_framework_version(self) -> str: + """Get the KUSANAGI framework version.""" + + return self.__values['framework_version'] + + def get_socket(self) -> str: + """Get the ZMQ socket name.""" + + if self.is_tcp_enabled(): + return '' + + if not self.__values['socket']: + # Create a default name for the socket when no name is available. + # The 'ipc://' prafix is removed from the string to get the socket name. + return ipc(self.get_component(), self.get_name(), self.get_version())[6:] + else: + return self.__values['socket'] + + def get_tcp(self) -> int: + """ + Get the port to use for TCP connections. + + Zero is returned when there is no TCP port assigned. + + """ + + return self.__values['tcp'] or 0 + + def is_tcp_enabled(self) -> bool: + """Check if TCP connections should be used instead of IPC.""" + + return self.get_tcp() != 0 + + def get_channel(self) -> str: + """Get the ZMQ channel to use for listening incoming requests.""" + + if self.is_tcp_enabled(): + port = self.get_tcp() + return f'tcp://127.0.0.1:{port}' + else: + # NOTE: The socket name already contains the "@kusanagi" prefix + socket_name = self.get_socket() + return f'ipc://{socket_name}' + + def get_timeout(self) -> int: + """Get the process execution timeout in milliseconds.""" + + return self.__values['timeout'] + + def is_debug(self) -> bool: + """Check if debug is enabled.""" + + return self.__values['debug'] + + def has_variable(self, name: str) -> bool: + """ + Check if an engine variable is defined. + + :param name: The name of the variable. + + """ + + return name in self.__values['var'] + + def get_variable(self, name: str) -> str: + """ + Get the value for an engine variable. + + An empty string is returned when the variable is not defined. + + :param name: The name of the variable. + + """ + + return self.__values['var'].get(name, '') + + def get_variables(self) -> dict: + """Get all the engine variables.""" + + return dict(self.__values['var']) + + def has_logging(self) -> bool: + """Check if logging is enabled.""" + + return self.__values['log_level'] is not None + + def get_log_level(self) -> int: + """Get the log level.""" + + return SYSLOG_NUMERIC.get(self.__values['log_level'], logging.INFO) diff --git a/kusanagi/sdk/lib/datatypes.py b/kusanagi/sdk/lib/datatypes.py new file mode 100644 index 0000000..d721282 --- /dev/null +++ b/kusanagi/sdk/lib/datatypes.py @@ -0,0 +1,29 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + +# Data types +TYPE_NULL = 'null' +TYPE_BOOLEAN = 'boolean' +TYPE_INTEGER = 'integer' +TYPE_FLOAT = 'float' +TYPE_ARRAY = 'array' +TYPE_OBJECT = 'object' +TYPE_STRING = 'string' +TYPE_BINARY = 'binary' + +# List of all KUSANAGI supported data types +TYPE_CHOICES = ( + TYPE_NULL, + TYPE_BOOLEAN, + TYPE_INTEGER, + TYPE_FLOAT, + TYPE_ARRAY, + TYPE_OBJECT, + TYPE_STRING, + TYPE_BINARY, +) diff --git a/kusanagi/sdk/schema/error.py b/kusanagi/sdk/lib/error.py similarity index 70% rename from kusanagi/sdk/schema/error.py rename to kusanagi/sdk/lib/error.py index c8b360c..d506726 100644 --- a/kusanagi/sdk/schema/error.py +++ b/kusanagi/sdk/lib/error.py @@ -5,8 +5,7 @@ # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. -from ...errors import KusanagiError -class ServiceSchemaError(KusanagiError): - """Base error class for Service schemas.""" +class KusanagiError(Exception): + """Base error for the SDK.""" diff --git a/kusanagi/sdk/lib/events.py b/kusanagi/sdk/lib/events.py new file mode 100644 index 0000000..4255bb4 --- /dev/null +++ b/kusanagi/sdk/lib/events.py @@ -0,0 +1,68 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import logging +from typing import Callable + +LOG = logging.getLogger(__name__) + + +class Events(object): + """Handles component events.""" + + def __init__(self, on_startup: Callable, on_shutdown: Callable, on_error: Callable): + """ + Constructor. + + :param on_startup: Callback to execute when startup is called. + :param on_shutdown: Callback to execute when shutdown is called. + :param on_error: Callback to execute when error is called. + + """ + + self.__on_startup = on_startup + self.__on_shutdown = on_shutdown + self.__on_error = on_error + + def startup(self, *args, **kwargs) -> bool: + """Call the startup callback.""" + + if self.__on_startup: + LOG.info('Running startup callback...') + try: + self.__on_startup(*args, **kwargs) + except Exception as exc: + LOG.error(f'Startup callback failed: {exc}') + return False + + return True + + def shutdown(self, *args, **kwargs) -> bool: + """Call the shutdown callback.""" + + if self.__on_shutdown: + LOG.info('Running shutdown callback...') + try: + self.__on_shutdown(*args, **kwargs) + except Exception as exc: + LOG.error(f'Shutdown callback failed: {exc}') + return False + + return True + + def error(self, *args, **kwargs) -> bool: + """Call the error callback.""" + + if self.__on_error: + LOG.info('Running error callback...') + try: + self.__on_error(*args, **kwargs) + except Exception as exc: + LOG.error(f'Error callback failed: {exc}') + return False + + return True diff --git a/kusanagi/sdk/lib/formatting.py b/kusanagi/sdk/lib/formatting.py new file mode 100644 index 0000000..6984388 --- /dev/null +++ b/kusanagi/sdk/lib/formatting.py @@ -0,0 +1,69 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import time +from datetime import date +from datetime import datetime + +DATE_FORMAT = '%Y-%m-%d' +TIME_FORMAT = '%H:%M:%S' +DATETIME_FORMAT = f'{DATE_FORMAT}T{TIME_FORMAT}.%f+00:00' + + +def str_to_datetime(value: str) -> datetime: + """ + Convert a datetime string to a datetime object. + + :param value: A datetime string value. + + """ + + return datetime.strptime(value, DATETIME_FORMAT) + + +def datetime_to_str(value: datetime) -> str: + """ + Convert a datetime object to string. + + :param value: A datetime to convert. + + """ + + return value.strftime(DATETIME_FORMAT) + + +def str_to_date(value: str) -> date: + """ + Convert a date string to a date object. + + :param value: A date string to convert. + + """ + + return datetime.strptime(value, DATE_FORMAT).date() + + +def date_to_str(value: date) -> str: + """ + Convert a date object to string. + + :param value: A date to convert. + + """ + + return value.strftime(DATE_FORMAT) + + +def time_to_str(value: time.struct_time) -> str: + """ + Convert a time struct object to string. + + :param value: A time to convert. + + """ + + return time.strftime(TIME_FORMAT, value) diff --git a/kusanagi/json.py b/kusanagi/sdk/lib/json.py similarity index 53% rename from kusanagi/json.py rename to kusanagi/sdk/lib/json.py index cdf37df..075c69f 100644 --- a/kusanagi/json.py +++ b/kusanagi/sdk/lib/json.py @@ -8,8 +8,10 @@ import datetime import decimal import json +from typing import Any -from . import utils +from .formatting import date_to_str +from .formatting import datetime_to_str class Encoder(json.JSONEncoder): @@ -17,15 +19,11 @@ class Encoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, decimal.Decimal): - # Note: Use str instead of float - # to avoid dealing with presition return str(obj) elif isinstance(obj, datetime.datetime): - return utils.date_to_str(obj) + return datetime_to_str(obj) elif isinstance(obj, datetime.date): - return obj.strftime('%Y-%m-%d') - elif hasattr(obj, '__serialize__'): - return obj.__serialize__() + return date_to_str(obj) elif isinstance(obj, bytes): return obj.decode('utf8') @@ -33,26 +31,31 @@ def default(self, obj): return json.JSONEncoder.default(self, obj) -def deserialize(json_string): - """Convert a JSON string to Python. +def loads(value: str) -> Any: + """ + Convert a JSON string to Python. - :rtype: a Python type + :param value: A JSON string to deserialize. """ - return json.loads(json_string) + return json.loads(value) + +def dumps(obj: Any, prettify: bool = False) -> bytes: + """ + Convert a python type to a JSON string. -def serialize(python_type, encoding='utf8', prettify=False): - """Serialize a Python object to JSON string. + The result value is encoded as UTF-8. - :returns: Bytes, or string when encoding is None. + :param obj: The python type to serialize. + :param prettify: Optional flag to enable formatting of the result. """ if not prettify: - value = json.dumps(python_type, separators=(',', ':'), cls=Encoder) + value = json.dumps(obj, separators=(',', ':'), cls=Encoder) else: - value = json.dumps(python_type, indent=2, cls=Encoder) + value = json.dumps(obj, indent=2, cls=Encoder) - return value.encode(encoding) if encoding else value + return value.encode('utf-8') diff --git a/kusanagi/sdk/lib/logging.py b/kusanagi/sdk/lib/logging.py new file mode 100644 index 0000000..6cb7d3a --- /dev/null +++ b/kusanagi/sdk/lib/logging.py @@ -0,0 +1,243 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import base64 +import logging +import sys +import time +import types +from datetime import datetime +from typing import Any + +from . import json + +LOG = logging.getLogger(__name__) + +# Syslog numeric levels +DEBUG = logging.DEBUG +INFO = logging.INFO +WARNING = logging.WARNING +NOTICE = WARNING + 1 +ERROR = logging.ERROR +CRITICAL = logging.CRITICAL +ALERT = CRITICAL + 1 +EMERGENCY = ALERT + 1 + +# Mappings between Syslog numeric severity levels and python logging levels +SYSLOG_NUMERIC = { + 0: EMERGENCY, + 1: ALERT, + 2: logging.CRITICAL, + 3: logging.ERROR, + 4: NOTICE, + 5: logging.WARNING, + 6: logging.INFO, + 7: logging.DEBUG, +} + + +class Logger(object): + """ + KUSANAGI logger with request ID support. + + The logger methods support and optional keyword "rid" to send the current + request ID. If the request ID is valid it is added as suffix to the log message. + + """ + + def __init__(self, name: str): + """ + Constructor. + + :param name: The logger name. + + """ + + self.__logger = logging.getLogger(name) + + def __format(self, message: str, rid: str) -> str: + # When there is no request ID return the message unchanged + if not rid: + return message + + # Add the request ID as suffix for the log message + return f'{message} |{rid}|' + + def debug(self, message: str, *args, **kwargs): + self.__logger.debug(self.__format(message, kwargs.pop('rid', '')), *args, **kwargs) + + def info(self, message: str, *args, **kwargs): + self.__logger.info(self.__format(message, kwargs.pop('rid', '')), *args, **kwargs) + + def warning(self, message: str, *args, **kwargs): + self.__logger.warning(self.__format(message, kwargs.pop('rid', '')), *args, **kwargs) + + def error(self, message: str, *args, **kwargs): + self.__logger.error(self.__format(message, kwargs.pop('rid', '')), *args, **kwargs) + + def critical(self, message: str, *args, **kwargs): + self.__logger.critical(self.__format(message, kwargs.pop('rid', '')), *args, **kwargs) + + def exception(self, message: str, *args, **kwargs): + from . import cli + + # When debug mode is enabled displat traceback, otherwise log only the message + # as an error without stack trace. This is important because normally each log + # should be a single line to avoid breaking log parsers. Debug mode is usually + # enabled during testing and development. + if cli.DEBUG: + self.__logger.exception(self.__format(message, kwargs.pop('rid', '')), *args, **kwargs) + else: + self.error(message, *args, **kwargs) + + def log(self, level: int, message: str, *args, **kwargs): + self.__logger.log(level, self.__format(message, kwargs.pop('rid', '')), *args, **kwargs) + + +class RequestLogger(Logger): + """ + Logger for requests. + + It appends the request ID to all logging messages. + + """ + + def __init__(self, name: str, rid: str): + """ + Constructor. + + :param name: The logger name. + :param rid: The ID of the current request. + + """ + + super().__init__(name) + # When the request ID is not valid use a "-" to avoid breaking the + # log format in case there is a tool being used to parse the SDK logs. + self.__rid = rid or '-' + + @property + def rid(self) -> str: + return self.__rid + + def debug(self, *args, **kwargs): + kwargs['rid'] = self.__rid + super().debug(*args, **kwargs) + + def info(self, *args, **kwargs): + kwargs['rid'] = self.__rid + super().info(*args, **kwargs) + + def warning(self, *args, **kwargs): + kwargs['rid'] = self.__rid + super().warning(*args, **kwargs) + + def error(self, *args, **kwargs): + kwargs['rid'] = self.__rid + super().error(*args, **kwargs) + + def critical(self, *args, **kwargs): + kwargs['rid'] = self.__rid + super().critical(*args, **kwargs) + + def exception(self, *args, **kwargs): + kwargs['rid'] = self.__rid + super().exception(*args, **kwargs) + + def log(self, *args, **kwargs): + kwargs['rid'] = self.__rid + super().log(*args, **kwargs) + + +class KusanagiFormatter(logging.Formatter): + """Default KUSANAGI logging formatter.""" + + def formatTime(self, record: logging.LogRecord, *args, **kwargs) -> str: + utc = time.mktime(time.gmtime(record.created)) + (record.created % 1) + return datetime.fromtimestamp(utc).isoformat()[:-3] + + +def value_to_log_string(value: Any, max_chars: int = 100000) -> str: + """ + Convert a value to a string. + + :param value: A value to log. + :param max_chars: Optional maximum number of characters to return. + + """ + + if value is None: + output = 'NULL' + elif isinstance(value, bool): + output = 'TRUE' if value else 'FALSE' + elif isinstance(value, str): + output = value + elif isinstance(value, bytes): + # Binary data is logged as base64 + output = base64.b64encode(value).decode('utf8') + elif isinstance(value, (dict, list, tuple)): + output = json.dumps(value, prettify=True).decode('utf8') + elif isinstance(value, types.FunctionType): + output = 'anonymous' if value.__name__ == '' else f'[function {value.__name__}]' + else: + output = repr(value) + + return output[:max_chars] + + +def get_output_buffer(): # pragma: no cover + """Get buffer interface to send logging output.""" + + return sys.stdout + + +def disable_logging(): # pragma: no cover + """Disable all logs.""" + + logging.disable(sys.maxsize) + + +def setup_kusanagi_logging(type_: str, name: str, version: str, framework: str, level: int): + """ + Initialize logging defaults for KUSANAGI. + + :param type_: Component type. + :param name: Component name. + :param version: Component version. + :param framework: KUSANAGI framework version. + :param level: Logging level. + + """ + + # Add new logging levels to follow KUSANAGI SDK specs + logging.addLevelName(NOTICE, 'NOTICE') + logging.addLevelName(ALERT, 'ALERT') + logging.addLevelName(EMERGENCY, 'EMERGENCY') + + # Define the format to use as prefix for all log messages + fmt = f'%(asctime)sZ {type_} {name}/{version} ({framework}) [%(levelname)s] [SDK] %(message)s' + + # Setup root logger + output = get_output_buffer() + root = logging.root + if not root.handlers: + logging.basicConfig(level=level, stream=output) + root.setLevel(level) + root.handlers[0].setFormatter(KusanagiFormatter(fmt)) + + # Setup kusanagi loggers + logger = logging.getLogger('kusanagi') + logger.setLevel(level) + if not logger.handlers: + handler = logging.StreamHandler(stream=output) + handler.setFormatter(KusanagiFormatter(fmt)) + logger.addHandler(handler) + logger.propagate = False + + # Setup other loggers + logger = logging.getLogger('asyncio') + logger.setLevel(logging.ERROR) diff --git a/kusanagi/sdk/lib/msgpack.py b/kusanagi/sdk/lib/msgpack.py new file mode 100644 index 0000000..e364ae8 --- /dev/null +++ b/kusanagi/sdk/lib/msgpack.py @@ -0,0 +1,96 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import datetime +import decimal +import time +from typing import Any +from typing import List + +import msgpack + +from .formatting import date_to_str +from .formatting import datetime_to_str +from .formatting import str_to_date +from .formatting import str_to_datetime +from .formatting import time_to_str + + +def _encode(obj: Any) -> List: + """ + Handle packing for custom types. + + Custom types are serialized as list, where first item is the string "type", + the second is the data type name and the third is the value represented as + a basic type. + + :raises: TypeError + + """ + + if isinstance(obj, decimal.Decimal): + # Decimal is represented as a tuple of strings + return ['type', 'decimal', str(obj).split('.')] + elif isinstance(obj, datetime.datetime): + return ['type', 'datetime', datetime_to_str(obj)] + elif isinstance(obj, datetime.date): + return ['type', 'date', date_to_str(obj)] + elif isinstance(obj, time.struct_time): + return ['type', 'time', time_to_str(obj)] + + raise TypeError(f'{repr(obj)} is not serializable') + + +def _decode(data: List) -> Any: + """ + Handle unpacking for custom types. + + None is returned when the type is not recognized. + + """ + + if len(data) == 3 and data[0] == 'type': + data_type = data[1] + try: + if data_type == 'decimal': + # Decimal is represented as a tuple of strings + return decimal.Decimal('.'.join(data[2])) + elif data_type == 'datetime': + return str_to_datetime(data[2]) + elif data_type == 'date': + return str_to_date(data[2]) + elif data_type == 'time': + # Use time as a string "HH:MM:SS" + return data[2] + except Exception: + # Don't fail when there are inconsistent data values. + # Invalid values will be null. + return + + return data + + +def pack(value: Any) -> bytes: + """ + Pack python data to a binary stream. + + :param value: A python object to serialize. + + """ + + return msgpack.packb(value, default=_encode, use_bin_type=True) + + +def unpack(value: bytes) -> Any: + """ + Pack python data to a binary stream. + + :param value: The binary stream to deserialize. + + """ + + return msgpack.unpackb(value, list_hook=_decode, raw=False) diff --git a/kusanagi/sdk/lib/payload/__init__.py b/kusanagi/sdk/lib/payload/__init__.py new file mode 100644 index 0000000..19c67aa --- /dev/null +++ b/kusanagi/sdk/lib/payload/__init__.py @@ -0,0 +1,226 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from typing import Any + +from .. import json + + +class Payload(dict): + """ + Handles operations on the transmitted data. + + Payloads behaves like an ordered dictionary, and their contents + can be traversed using paths. + + """ + + # Prefix to add to any path + path_prefix = None + + def __str__(self): + # Serialize the payload as a formatted JSON string + return json.dumps(self, prettify=True).decode('utf8') + + def exists(self, path: list, prefix: bool = True) -> bool: + """ + Check if a path exists in the payload. + + :param path: Path to traverse. + :param prefix: Optional flag to disable path prefixing. + + """ + + if prefix and self.path_prefix: + path = self.path_prefix + path + + item = self + for name in path: + try: + item = item[name] + except (TypeError, KeyError): + break + else: + # Return true when the full path is traversed + return True + + return False + + def equals(self, path: list, value: Any, prefix: bool = True) -> bool: + """ + Check if a value exists in the payload. + + :param path: Path to traverse. + :param value: The value to check in the given path. + :param prefix: Optional flag to disable path prefixing. + + """ + + if prefix and self.path_prefix: + path = self.path_prefix + path + + item = self + for name in path: + try: + item = item[name] + except (TypeError, KeyError): + break + else: + # When the full path is traversed compare the value + return item == value + + return False + + def get(self, path: list, default: Any = None, prefix: bool = True) -> Any: + """ + Get a value from the payload. + + :param path: Path to traverse. + :param default: Optional value to return when the path doesn't exist. + :param prefix: Optional flag to disable path prefixing. + + """ + + if prefix and self.path_prefix: + path = self.path_prefix + path + + item = self + for name in path: + try: + item = item[name] + except (TypeError, KeyError): + break + else: + return item + + return default + + def set(self, path: list, value: Any, prefix: bool = True) -> bool: + """ + Set a value in the payload. + + :param path: Path where to set the value. + :param value: The value to set. + :param prefix: Optional flag to disable path prefixing. + + """ + + if prefix and self.path_prefix: + path = self.path_prefix + path + + item = self + last_index = len(path) - 1 + for i, name in enumerate(path): + # When the element is the last use the value as default otherwise + # use a dictionary to be able to keep traversing the path. + try: + if i == last_index: + item[name] = value + else: + item = item.setdefault(name, {}) + except (AttributeError, TypeError): + # The path contains an element that is not a dictionary + return False + + return True + + def append(self, path: list, value: Any, prefix: bool = True) -> bool: + """ + Append a value to a list in the payload. + + A new list is created for the given path when a value doesn't + exist for that path. + + The value is appended when a list already exist for the path. + + :param path: Path where to append the value. + :param value: The value to append. + :param prefix: Optional flag to disable path prefixing. + + """ + + if prefix and self.path_prefix: + path = self.path_prefix + path + + item = self + last_index = len(path) - 1 + for i, name in enumerate(path): + try: + if i == last_index: + # When the last element is traversed append the value. + # Values must be a list to be able to append it. + values = item.setdefault(name, []) + if isinstance(values, list): + values.append(value) + return True + else: + item = item.setdefault(name, {}) + except (AttributeError, TypeError): + # The path contains an element that is not a dictionary + break + + return False + + def extend(self, path: list, values: list, prefix: bool = True) -> bool: + """ + Extend a list value by appending the elements of another list. + + A new list is created for the given path when a value doesn't + exist for that path. + + The values are appended when a list already exist for the path. + + :param path: Path where to append the values. + :param values: The values to append. + :param prefix: Optional flag to disable path prefixing. + + """ + + if prefix and self.path_prefix: + path = self.path_prefix + path + + item = self + last_index = len(path) - 1 + for i, name in enumerate(path): + try: + if i == last_index: + # When the last element is traversed append the values. + # Current values must be a list to be able to append it. + current_values = item.setdefault(name, []) + if isinstance(current_values, list): + current_values.extend(values) + return True + else: + item = item.setdefault(name, {}) + except (AttributeError, TypeError): + # The path contains an element that is not a dictionary + break + + return False + + def delete(self, path: list, prefix: bool = True) -> bool: + """ + Delete a value from the payload. + + Only the last element of the path is deleted from the payload. + + :param path: Path to traverse. + :param prefix: Optional flag to disable path prefixing. + + """ + + if prefix and self.path_prefix: + path = self.path_prefix + path + + *path, name = path + # NOTE: The value of path is a list + element = self.get(path, None, prefix=False) + if not isinstance(element, dict) or name not in element: + return False + + del element[name] + return True diff --git a/kusanagi/sdk/lib/payload/action.py b/kusanagi/sdk/lib/payload/action.py new file mode 100644 index 0000000..df86b3c --- /dev/null +++ b/kusanagi/sdk/lib/payload/action.py @@ -0,0 +1,127 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from typing import List + +from . import Payload +from . import ns +from .file import FileSchemaPayload +from .param import ParamSchemaPayload +from ..datatypes import TYPE_STRING + + +def payload_to_entity(payload: dict, entity: dict = None) -> dict: + """ + Create a new entity definition object from a payload. + + :param payload: Entity definition payload. + :param entity: An optional entity to update with the definition values. + + """ + + if entity is None: + entity = {} + + if not payload: + return entity + + # Add validate field only to top level entity + if not entity: + entity['validate'] = payload.get(ns.VALIDATE, False) + + # Add fields to entity + if ns.FIELD in payload: + entity['field'] = [] + for field in payload[ns.FIELD]: + entity['field'].append({ + 'name': field.get(ns.NAME), + 'type': field.get(ns.TYPE, TYPE_STRING), + 'optional': field.get(ns.OPTIONAL, False), + }) + + # Add field sets to entity + if ns.FIELDS in payload: + entity['fields'] = [] + for fields in payload[ns.FIELDS]: + fieldset = { + 'name': fields.get(ns.NAME), + 'optional': fields.get(ns.OPTIONAL, False), + } + + # Add inner field and fieldsets + if ns.FIELD in fields or ns.FIELDS in fields: + fieldset = payload_to_entity(fields, fieldset) + + entity['fields'].append(fieldset) + + return entity + + +class ActionSchemaPayload(Payload): + """Handles operation on action schema payloads.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__params = self.get([ns.PARAMS], {}) + self.__files = self.get([ns.FILES], {}) + + def get_entity(self) -> dict: + """Get the entity definition as an object.""" + + payload = self.get([ns.ENTITY]) + return payload_to_entity(payload) + + def get_relations(self) -> list: + """Get the relations.""" + + return [ + [payload.get(ns.TYPE, 'one'), payload.get(ns.NAME)] + for payload in self.get([ns.RELATIONS], []) + ] + + def get_param_names(self) -> List[str]: + """Get the list of all the action parameters.""" + + return list(self.__params.keys()) + + def get_param_schema_payload(self, name: str) -> ParamSchemaPayload: + """ + Get the schema for a parameter. + + :param name: The parameter name. + + :raises: KeyError + + """ + + return ParamSchemaPayload(name, self.__params[name]) + + def get_file_names(self) -> List[str]: + """Get the list of all the action file parameters.""" + + return list(self.__files.keys()) + + def get_file_schema_payload(self, name: str) -> FileSchemaPayload: + """ + Get the schema for a file parameter. + + :param name: The file parameter name. + + :raises: KeyError + + """ + + return FileSchemaPayload(name, self.__files[name]) + + def get_http_action_schema_payload(self) -> 'HttpActionSchemaPayload': + """Get the HTTP schema for the action.""" + + return HttpActionSchemaPayload(self.get([ns.HTTP], {})) + + +class HttpActionSchemaPayload(Payload): + """Handles operation on HTTP action schema payloads.""" diff --git a/kusanagi/sdk/lib/payload/command.py b/kusanagi/sdk/lib/payload/command.py new file mode 100644 index 0000000..b70ea23 --- /dev/null +++ b/kusanagi/sdk/lib/payload/command.py @@ -0,0 +1,128 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from . import Payload +from . import ns +from .utils import file_to_payload +from .utils import param_to_payload + +if TYPE_CHECKING: + from typing import List + + from ...file import File + from ...param import Param + from .transport import TransportPayload + + +class CommandPayload(Payload): + """Handles operations on command payloads.""" + + path_prefix = [ns.COMMAND, ns.ARGUMENTS] + + @classmethod + def new(cls, name: str, scope: str, args: dict = None) -> CommandPayload: + """ + Create a command payload. + + :param name: The name of the command to call. + :param scope: The type of component making the request. + :param args: Optional command arguments. + + """ + + p = cls({ + ns.COMMAND: { + ns.NAME: name, + }, + ns.META: { + ns.SCOPE: scope, + }, + }) + + if args: + p.set([ns.COMMAND, ns.ARGUMENTS], args, prefix=False) + + return p + + @classmethod + def new_runtime_call( + cls, + caller_action: str, + callee_service: str, + callee_version: str, + callee_action: str, + params: List[Param] = None, + files: List[File] = None, + transport: TransportPayload = None, + ) -> CommandPayload: + """ + Create a command payload for a run-time call. + + :param caller_action: The caller action. + :param callee_service: The called service. + :param callee_version: The called version. + :param callee_action: The called action. + :param params: Optional parameters to send. + :param files: Optional files to send. + :param transport: Optional transport payload. + + """ + + args = { + ns.ACTION: caller_action, + ns.CALLEE: [callee_service, callee_version, callee_action], + ns.TRANSPORT: transport or {}, + } + + if params: + args[ns.PARAMS] = [param_to_payload(p) for p in params] + + if files: + args[ns.FILES] = [file_to_payload(f) for f in files] + + return cls.new('runtime-call', 'service', args=args) + + def get_name(self) -> str: + """Get the name of the command.""" + + return self.get([ns.COMMAND, ns.NAME], '', prefix=False) + + def get_attributes(self) -> dict: + """Get the attributes.""" + + return self.get([ns.ATTRIBUTES], {}) + + def get_service_call_data(self) -> dict: + """Get the service call data.""" + + return self.get([ns.CALL], {}) + + def get_transport_data(self) -> dict: + """Get the transport data.""" + + return self.get([ns.TRANSPORT], {}) + + def get_response_data(self) -> dict: + """Get the response data.""" + + return self.get([ns.RESPONSE], {}) + + def get_request_id(self) -> str: + """Get current request ID.""" + + # The ID is available for request, response and action commands. + # Request and response payloads have a meta argument with the ID. + # Action have the ID in the transport meta instead. + rid = self.get([ns.META, ns.ID], '') + if not rid: + rid = self.get([ns.TRANSPORT, ns.META, ns.ID], '') + + return rid diff --git a/kusanagi/sdk/lib/payload/error.py b/kusanagi/sdk/lib/payload/error.py new file mode 100644 index 0000000..fc1a4f5 --- /dev/null +++ b/kusanagi/sdk/lib/payload/error.py @@ -0,0 +1,45 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +from . import Payload +from . import ns + + +class ErrorPayload(Payload): + """Handles operations on error payloads.""" + + DEFAULT_MESSAGE = 'Unknown error' + DEFAULT_STATUS = '500 Internal Server Error' + + path_prefix = [ns.ERROR] + + @classmethod + def new(cls, message: str = DEFAULT_MESSAGE, code: int = 0, status: str = DEFAULT_STATUS) -> ErrorPayload: + return cls({ + ns.ERROR: { + ns.MESSAGE: message, + ns.CODE: code, + ns.STATUS: status, + } + }) + + def get_message(self) -> str: + """Get the error message.""" + + return self.get([ns.MESSAGE], self.DEFAULT_MESSAGE) + + def get_code(self) -> int: + """Get the error code.""" + + return self.get([ns.CODE], 0) + + def get_status(self) -> str: + """Get the error status code.""" + + return self.get([ns.STATUS], self.DEFAULT_STATUS) diff --git a/kusanagi/sdk/lib/payload/file.py b/kusanagi/sdk/lib/payload/file.py new file mode 100644 index 0000000..a64ef87 --- /dev/null +++ b/kusanagi/sdk/lib/payload/file.py @@ -0,0 +1,35 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from . import Payload +from . import ns + + +class FileSchemaPayload(Payload): + """Handle operations on a file parameter schema payload.""" + + def __init__(self, name: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__name = name + + def get_name(self) -> str: + return self.__name or '' + + def get_http_file_schema_payload(self) -> 'HttpFileSchemaPayload': + name = self.get_name() + return HttpFileSchemaPayload(name, self.get([ns.HTTP], {})) + + +class HttpFileSchemaPayload(Payload): + """Handle operations on an HTTP file parameter schema payload.""" + + def __init__(self, name: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__name = name + + def get_name(self) -> str: + return self.__name diff --git a/kusanagi/sdk/lib/payload/mapping.py b/kusanagi/sdk/lib/payload/mapping.py new file mode 100644 index 0000000..a5f970e --- /dev/null +++ b/kusanagi/sdk/lib/payload/mapping.py @@ -0,0 +1,52 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from typing import Iterator + +from ..version import VersionString +from . import Payload +from .service import ServiceSchemaPayload + + +class MappingPayload(Payload): + """Handles operations on mappings payloads.""" + + def get_services(self) -> Iterator[dict]: + """Get service names and versions.""" + + for service, versions in self.items(): + for version in versions.keys(): + yield {'name': service, 'version': version} + + def get_service_schema_payload(self, name: str, version: str) -> ServiceSchemaPayload: + """ + Get the service schema payload for a service version. + + The version can be either a fixed version or a pattern that uses "*" + and resolves to the higher version available that matches. + + :param name: The name of the service. + :param version: The version of the service. + + :raises: LookupError + + """ + + if name in self: + versions = self[name] + + # When the version doesn't exist try to resolve the version pattern and get the closest + # highest version from the ones registered in the mapping for the current service. + if version not in versions: + resolved_version = VersionString(version).resolve(versions.keys()) + if resolved_version: + version = resolved_version + + if version: + return ServiceSchemaPayload(versions[version], name=name, version=version) + + raise LookupError(f'Cannot resolve schema for Service: "{name}" ({version})') diff --git a/kusanagi/sdk/lib/payload/ns.py b/kusanagi/sdk/lib/payload/ns.py new file mode 100644 index 0000000..19383f4 --- /dev/null +++ b/kusanagi/sdk/lib/payload/ns.py @@ -0,0 +1,145 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +"""Domain-specific properties used in payloads.""" + +ACTION = 'a' +ADDRESS = 'a' +ARGUMENTS = 'a' +ATTRIBUTES = 'a' +AVAILABLE = 'a' +ACTIONS = 'ac' +ARRAY_FORMAT = 'af' +BASE_PATH = 'b' +BODY = 'b' +BUFFERS = 'b' +BUSY = 'b' +CACHED = 'c' +CALL = 'c' +CALLBACK = 'c' +CALLEE = 'c' +CLIENT = 'c' +CODE = 'c' +COLLECTION = 'c' +COMMAND = 'c' +COMMIT = 'c' +COMPONENT = 'c' +CONFIG = 'c' +COUNT = 'c' +CPU = 'c' +CALLER = 'C' +CALLS = 'C' +COMPLETE = 'C' +COMMAND_REPLY = 'cr' +DATA = 'd' +DATETIME = 'd' +DEFAULT_VALUE = 'd' +DISK = 'd' +PATH_DELIMITER = 'd' +DEFERRED_CALLS = 'dc' +DEPRECATED = 'D' +DURATION = 'D' +ALLOW_EMPTY = 'e' +END_TIME = 'e' +ENTITY_PATH = 'e' +ERRORS = 'e' +ENTITY = 'E' +ERROR = 'E' +ENUM = 'em' +EXCLUSIVE_MIN = 'en' +EXCLUSIVE_MAX = 'ex' +FAMILY = 'f' +FIELD = 'f' +FILENAME = 'f' +FILES = 'f' +FORMAT = 'f' +FREE = 'f' +FALLBACK = 'F' +FALLBACKS = 'F' +FIELDS = 'F' +GATEWAY = 'g' +HEADER = 'h' +HEADERS = 'h' +HTTP = 'h' +HTTP_BODY = 'hb' +HTTP_INPUT = 'hi' +HTTP_METHOD = 'hm' +HTTP_SECURITY = 'hs' +ID = 'i' +IDLE = 'i' +IN = 'i' +INPUT = 'i' +INTERVAL = 'i' +ITEMS = 'i' +PRIMARY_KEY = 'k' +LADDR = 'l' +LEVEL = 'l' +LINKS = 'l' +MEMORY = 'm' +MESSAGE = 'm' +META = 'm' +METHOD = 'm' +MIME = 'm' +MIN = 'mn' +MULTIPLE_OF = 'mo' +MAX = 'mx' +NAME = 'n' +NETWORK = 'n' +MIN_ITEMS = 'ni' +MIN_LENGTH = 'nl' +OPTIONAL = 'o' +ORIGIN = 'o' +OUT = 'o' +PARAM = 'p' +PARAMS = 'p' +PATH = 'p' +PATTERN = 'p' +PERCENT = 'p' +PID = 'p' +POST_DATA = 'p' +PROPERTIES = 'p' +PROTOCOL = 'p' +QUERY = 'q' +RADDR = 'r' +READS = 'r' +REQUEST = 'r' +REQUIRED = 'r' +RELATIONS = 'r' +RESULT = 'r' +ROLLBACK = 'r' +REMOTE_CALLS = 'rc' +RETURN = 'rv' +RESPONSE = 'R' +SCHEMA = 's' +SCHEMES = 's' +SCOPE = 's' +SERVICE = 's' +SHARED = 's' +SIZE = 's' +START_TIME = 's' +STATUS = 's' +SWAP = 's' +SYSTEM = 's' +TAGS = 't' +TERMINATE = 't' +TOKEN = 't' +TOTAL = 't' +TRANSACTIONS = 't' +TYPE = 't' +TRANSPORT = 'T' +URL = 'u' +USED = 'u' +USER = 'u' +UNIQUE_ITEMS = 'ui' +VALUE = 'v' +VERSION = 'v' +VALIDATE = 'V' +IOWAIT = 'w' +WRITES = 'w' +TIMEOUT = 'x' +MAX_ITEMS = 'xi' +MAX_LENGTH = 'xl' diff --git a/kusanagi/sdk/lib/payload/param.py b/kusanagi/sdk/lib/payload/param.py new file mode 100644 index 0000000..d8166dc --- /dev/null +++ b/kusanagi/sdk/lib/payload/param.py @@ -0,0 +1,35 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from . import Payload +from . import ns + + +class ParamSchemaPayload(Payload): + """Handle operations on a parameter schema payload.""" + + def __init__(self, name: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__name = name + + def get_name(self) -> str: + return self.__name + + def get_http_param_schema_payload(self) -> 'HttpParamSchemaPayload': + name = self.get_name() + return HttpParamSchemaPayload(name, self.get([ns.HTTP], {})) + + +class HttpParamSchemaPayload(Payload): + """Handle operations on an HTTP parameter schema payload.""" + + def __init__(self, name: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__name = name + + def get_name(self) -> str: + return self.__name diff --git a/kusanagi/sdk/lib/payload/reply.py b/kusanagi/sdk/lib/payload/reply.py new file mode 100644 index 0000000..120ab35 --- /dev/null +++ b/kusanagi/sdk/lib/payload/reply.py @@ -0,0 +1,136 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from . import Payload +from . import ns +from .transport import TransportPayload + +if TYPE_CHECKING: + from typing import Any + + from .command import CommandPayload + + +class ReplyPayload(Payload): + """Handles operations on command reply payloads.""" + + HTTP_VERSION = '1.1' + HTTP_STATUS_OK = '200 OK' + + path_prefix = [ns.COMMAND_REPLY, ns.RESULT] + + @classmethod + def new_request_reply(cls, command: CommandPayload) -> ReplyPayload: + """Create a new command reply for a request.""" + + call = command.get_service_call_data() + return cls({ + ns.COMMAND_REPLY: { + ns.NAME: command.get_name(), + ns.RESULT: { + ns.ATTRIBUTES: command.get_attributes(), + ns.CALL: { + ns.SERVICE: call.get(ns.SERVICE, ''), + ns.VERSION: call.get(ns.VERSION, ''), + ns.ACTION: call.get(ns.ACTION, ''), + ns.PARAMS: call.get(ns.PARAMS, []), + }, + ns.RESPONSE: { + ns.VERSION: cls.HTTP_VERSION, + ns.STATUS: cls.HTTP_STATUS_OK, + ns.HEADERS: {'Content-Type': ['text/plain']}, + ns.BODY: b'', + }, + } + } + }) + + @classmethod + def new_response_reply(cls, command: CommandPayload) -> ReplyPayload: + """Create a new command reply for a response.""" + + return cls({ + ns.COMMAND_REPLY: { + ns.NAME: command.get_name(), + ns.RESULT: { + ns.ATTRIBUTES: command.get_attributes(), + ns.RESPONSE: command.get_response_data(), + } + } + }) + + @classmethod + def new_action_reply(cls, command: CommandPayload) -> ReplyPayload: + """ + Create a new command reply for a service action call. + + :param command: The command payload. + + """ + + return cls({ + ns.COMMAND_REPLY: { + ns.NAME: command.get_name(), + ns.RESULT: { + ns.TRANSPORT: command.get_transport_data(), + } + } + }) + + def set_response(self, code: int, text: str) -> ReplyPayload: + """ + Set a response in the payload. + + :param code: The HTTP status code for the response. + :param text: The HTTP status text for the response. + + """ + + self.set([ns.RESPONSE], { + ns.VERSION: self.HTTP_VERSION, + ns.STATUS: f'{code} {text}', + ns.HEADERS: {}, + ns.BODY: b'', + }) + return self + + def get_return_value(self) -> Any: + """ + Get the return value if it exists. + + None is returned when there is no return value. + + """ + + return self.get([ns.RETURN]) + + def get_transport(self) -> TransportPayload: + """ + Get the transport payload if it exists. + + None is returned when there is no transport data in the payload. + + """ + + data = self.get([ns.TRANSPORT]) + return TransportPayload(data) if data else None + + def for_request(self) -> ReplyPayload: + """Setup the reply for a request middleware.""" + + self.delete([ns.RESPONSE]) + return self + + def for_response(self) -> ReplyPayload: + """Setup the reply for a response middleware.""" + + self.delete([ns.CALL]) + return self diff --git a/kusanagi/sdk/lib/payload/request.py b/kusanagi/sdk/lib/payload/request.py new file mode 100644 index 0000000..43b0cc3 --- /dev/null +++ b/kusanagi/sdk/lib/payload/request.py @@ -0,0 +1,12 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from . import Payload + + +class HttpRequestPayload(Payload): + """Handles operations on HTTP request payloads.""" diff --git a/kusanagi/sdk/lib/payload/response.py b/kusanagi/sdk/lib/payload/response.py new file mode 100644 index 0000000..a394435 --- /dev/null +++ b/kusanagi/sdk/lib/payload/response.py @@ -0,0 +1,58 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from . import Payload +from . import ns + +if TYPE_CHECKING: + from typing import Any + + from .reply import ReplyPayload + + +class HttpResponsePayload(Payload): + """Handles operations on HTTP response payloads.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._reply = None + + def set(self, path: list, value: Any) -> bool: + result = super().set(path, value) + if self._reply: + self._reply.set([ns.RESPONSE] + path, value) + + return result + + def append(self, path: list, value: Any) -> bool: + result = super().set(path, value) + if self._reply: + self._reply.append([ns.RESPONSE] + path, value) + + return result + + def set_reply(self, reply: ReplyPayload) -> HttpResponsePayload: + """ + Set the reply payload. + + :param reply: The reply payload. + + """ + + # Clear the reply payload + self._reply = None + # Update the response values + response = reply.get([ns.RESPONSE], {}) + for name, value in response.items(): + self.set([name], value) + + self._reply = reply + return self diff --git a/kusanagi/sdk/lib/payload/service.py b/kusanagi/sdk/lib/payload/service.py new file mode 100644 index 0000000..f3b280c --- /dev/null +++ b/kusanagi/sdk/lib/payload/service.py @@ -0,0 +1,69 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from typing import List + +from . import Payload +from . import ns +from .action import ActionSchemaPayload + + +class ServiceSchemaPayload(Payload): + """Handles operations on a Service schema payload.""" + + def __init__(self, *args, name: str = None, version: str = None, **kwargs): + """ + Constructor. + + :param name: Optional service name. + :param version: Optional service version. + + """ + + super().__init__(*args, **kwargs) + self.__name = name + self.__version = version + + def get_name(self) -> str: + """Get the service name.""" + + return self.__name or '' + + def get_version(self) -> str: + """Get the service version.""" + + return self.__version or '' + + def get_action_names(self) -> List[str]: + """Get the names of the service actions.""" + + return list(self.get([ns.ACTIONS], {}).keys()) + + def get_action_schema_payload(self, name: str) -> ActionSchemaPayload: + """ + Get an action schema payload. + + :param name: The name of the action to get. + + :raises: LookupError + + """ + + actions = self.get([ns.ACTIONS], {}) + if name not in actions: + raise LookupError(f'Cannot resolve schema for action: {name}') + + return ActionSchemaPayload(actions[name]) + + def get_http_service_schema_payload(self) -> 'HttpServiceSchemaPayload': + """Get the HTTP service schema payload.""" + + return HttpServiceSchemaPayload(self.get([ns.HTTP], {})) + + +class HttpServiceSchemaPayload(Payload): + """Handles operations on a HTTP service schema payload.""" diff --git a/kusanagi/sdk/lib/payload/transport.py b/kusanagi/sdk/lib/payload/transport.py new file mode 100644 index 0000000..a76f758 --- /dev/null +++ b/kusanagi/sdk/lib/payload/transport.py @@ -0,0 +1,506 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +from . import Payload +from . import ns +from .utils import file_to_payload +from .utils import merge_dictionary +from .utils import param_to_payload + +if TYPE_CHECKING: + from typing import Any + from typing import List + from typing import Union + + from ...file import File + from ...param import Param + from .reply import ReplyPayload + + +class TransportPayload(Payload): + """Handles operations on transport payloads.""" + + # The types are mapped to payload namespaces + TRANSACTION_COMMIT = ns.COMMIT + TRANSACTION_ROLLBACK = ns.ROLLBACK + TRANSACTION_COMPLETE = ns.COMPLETE + + # Paths that can me merged from other transport payloads + MERGEABLE_PATHS = ( + [ns.DATA], + [ns.RELATIONS], + [ns.LINKS], + [ns.CALLS], + [ns.TRANSACTIONS], + [ns.ERRORS], + [ns.BODY], + [ns.FILES], + [ns.META, ns.FALLBACKS], + [ns.META, ns.PROPERTIES], + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._reply = None + + def set(self, path: list, value: Any, prefix: bool = True) -> bool: + ok = super().set(path, value, prefix=prefix) + if self._reply is not None: + self._reply.set([ns.TRANSPORT] + path, value, prefix=prefix) + + return ok + + def append(self, path: list, value: Any, prefix: bool = True) -> bool: + ok = super().append(path, value, prefix=prefix) + if self._reply is not None: + self._reply.append([ns.TRANSPORT] + path, value, prefix=prefix) + + return ok + + def extend(self, path: list, values: list, prefix: bool = True) -> bool: + ok = super().extend(path, values, prefix=prefix) + if self._reply is not None: + self._reply.extend([ns.TRANSPORT] + path, values, prefix=prefix) + + return ok + + def delete(self, path: list, prefix: bool = True) -> bool: + ok = super().delete(path, prefix=prefix) + if self._reply is not None: + self._reply.delete([ns.TRANSPORT] + path, prefix=prefix) + + return ok + + def merge_runtime_call_transport(self, transport: TransportPayload) -> bool: + """ + Merge a transport returned from a run-time call into the current transport. + + :param transport: The transport payload to merge. + + :raises: TypeError + + """ + + if not isinstance(transport, TransportPayload): + raise TypeError(f'Invalid type to merge into transport: {transport.__class__}') + + for path in self.MERGEABLE_PATHS: + # Get the value from the other transport + src_value = transport.get(path) + if src_value is None: + continue + + # Get the value from the current transport and if is not available init it as a dictionary + dest_value = self.get(path) + if dest_value is None: + dest_value = {} + # NOTE: Skip overriden set to avoid changing the reply payload + super().set(path, dest_value) + + merge_dictionary(src_value, dest_value) + + # Update the transport in the reply payload with the runtime transport + if self._reply is not None: + # TODO: See if we need to keep the transport updated, or we just need to update the + # transport in the reply, and only keep track of the new files and params in + # the transport payload class. Merging and deepcopying is expensive. + self._reply.set([ns.TRANSPORT], copy.deepcopy(self)) + + return True + + def get_public_gateway_address(self) -> str: + """Get the public Gateway address.""" + + return self.get([ns.META, ns.GATEWAY], ['', ''])[1] + + def set_reply(self, reply: ReplyPayload) -> TransportPayload: + """ + Set the reply payload. + + :param reply: The reply payload. + + """ + + self._reply = reply + return self + + def set_download(self, file: File) -> bool: + """ + Set a file for download. + + :param file: The file to use as download contents. + + """ + + return self.set([ns.BODY], file_to_payload(file)) + + def set_return(self, value: Any = None) -> bool: + """ + Set the return value. + + :param value: The value to use as return value in the payload. + + """ + + if self._reply is not None: + return self._reply.set([ns.RETURN], value) + + return False + + def add_data(self, name: str, version: str, action: str, data: Union[dict, list]) -> bool: + """ + Add transport payload data. + + When there is existing data in the payload it is not removed. The new data + is appended to the existing data in that case. + + :param name: The name of the Service. + :param version: The version of the Service. + :param action: The name of the action. + :param data: The data to add. + + """ + + gateway = self.get_public_gateway_address() + return self.append([ns.DATA, gateway, name, version, action], data) + + def add_relate_one(self, service: str, pk: str, remote: str, fk: str) -> bool: + """ + Add a "one-to-one" relation. + + :param service: The name of the local service. + :param pk: The primary key of the local entity. + :param remote: The name of the remote service. + :param fk: The primary key of the remote entity. + + """ + + gateway = self.get_public_gateway_address() + return self.set([ns.RELATIONS, gateway, service, pk, gateway, remote], fk) + + def add_relate_many(self, service: str, pk: str, remote: str, fks: List[str]) -> bool: + """ + Add a "one-to-many" relation. + + :param service: The name of the local service. + :param pk: The primary key of the local entity. + :param remote: The name of the remote service. + :param fks: The primary keys of the remote entity. + + """ + + gateway = self.get_public_gateway_address() + return self.set([ns.RELATIONS, gateway, service, pk, gateway, remote], fks) + + def add_relate_one_remote(self, service: str, pk: str, address: str, remote: str, fk: str) -> bool: + """ + Add a remote "one-to-one" relation. + + :param service: The name of the local service. + :param pk: The primary key of the local entity. + :param address: The address of the remote Gateway. + :param remote: The name of the remote service. + :param fk: The primary key of the remote entity. + + """ + + gateway = self.get_public_gateway_address() + return self.set([ns.RELATIONS, gateway, service, pk, address, remote], fk) + + def add_relate_many_remote(self, service: str, pk: str, address: str, remote: str, fks: List[str]) -> bool: + """ + Add a remote "one-to-one" relation. + + :param service: The name of the local service. + :param pk: The primary key of the local entity. + :param address: The address of the remote Gateway. + :param remote: The name of the remote service. + :param fks: The primary keys of the remote entity. + + """ + + gateway = self.get_public_gateway_address() + return self.set([ns.RELATIONS, gateway, service, pk, address, remote], fks) + + def add_link(self, service: str, link: str, uri: str) -> bool: + """ + Add a link. + + :param service: The name of the Service. + :param link: The link name. + :param uri: The URI for the link. + + """ + + gateway = self.get_public_gateway_address() + return self.set([ns.LINKS, gateway, service, link], uri) + + def add_transaction( + self, + type_: str, + service: str, + version: str, + action: str, + target: str, + params: List[Param] = None, + ) -> bool: + """ + Add a transaction to be called when the request succeeds. + + :param type_: The type of transaction. + :param service: The name of the Service. + :param version: The version of the Service. + :param action: The name of the origin action. + :param target: The name of the target action. + :param params: Optional parameters for the transaction. + + :raises: ValueError + + """ + + if type_ not in (self.TRANSACTION_COMMIT, self.TRANSACTION_ROLLBACK, self.TRANSACTION_COMPLETE): + raise ValueError(f'Invalid transaction type value: {type_}') + + transaction = { + ns.NAME: service, + ns.VERSION: version, + ns.CALLER: action, + ns.ACTION: target, + } + + if params: + transaction[ns.PARAMS] = [param_to_payload(p) for p in params] + + return self.append([ns.TRANSACTIONS, type_], transaction) + + def add_call( + self, + service: str, + version: str, + action: str, + callee_service: str, + callee_version: str, + callee_action: str, + duration: int, + params: List[Param] = None, + files: List[File] = None, + timeout: int = None, + transport: TransportPayload = None, + ) -> bool: + """ + Add a run-time call. + + Current transport payload is used when the optional transport is not given. + + :param service: The name of the Service. + :param version: The version of the Service. + :param action: The name of the action making the call. + :param callee_service: The called service. + :param callee_version: The called version. + :param callee_action: The called action. + :param duration: The call duration. + :param params: Optional parameters to send. + :param files: Optional files to send. + :param timeout: Optional timeout for the call. + :param transport: Optional transport payload. + + :raises: ValueError + + """ + + # Validate duration to make sure the calls is not treated as a deferred call by the framework + if duration is None: + raise ValueError('Duration is required when adding run-time calls to transport') + + call = { + ns.NAME: callee_service, + ns.VERSION: callee_version, + ns.ACTION: callee_action, + ns.CALLER: action, + ns.DURATION: duration, + } + + if params: + call[ns.PARAMS] = [param_to_payload(p) for p in params] + + if files: + call[ns.FILES] = [file_to_payload(f) for f in files] + + if timeout is not None: + call[ns.TIMEOUT] = timeout + + # When a transport is present add the call to it and then merge it into the current transport + if transport is not None: + transport.append([ns.CALLS, service, version], call) + return self.merge_runtime_call_transport(transport) + + # When there is no transport just add the call to current transport + return self.append([ns.CALLS, service, version], call) + + def add_defer_call( + self, + service: str, + version: str, + action: str, + callee_service: str, + callee_version: str, + callee_action: str, + params: List[Param] = None, + files: List[File] = None, + ) -> bool: + """ + Add a deferred call. + + :param service: The name of the Service. + :param version: The version of the Service. + :param action: The name of the action making the call. + :param callee_service: The called service. + :param callee_version: The called version. + :param callee_action: The called action. + :param params: Optional parameters to send. + :param files: Optional files to send. + + """ + + call = { + ns.NAME: callee_service, + ns.VERSION: callee_version, + ns.ACTION: callee_action, + ns.CALLER: action, + } + + if params: + call[ns.PARAMS] = [param_to_payload(p) for p in params] + + # TODO: Should the file be added to the call too ? Not in the specs. + file_payloads = [file_to_payload(f) for f in files] if files else None + if file_payloads: + call[ns.FILES] = file_payloads + + # Add the call to the transport payload + ok = self.append([ns.CALLS, service, version], call) + # When there are files included in the call add them to the transport payload + if ok and file_payloads: + gateway = self.get_public_gateway_address() + self.extend([ns.FILES, gateway, callee_service, callee_version, callee_action], file_payloads) + + return ok + + def add_remote_call( + self, + address: str, + service: str, + version: str, + action: str, + callee_service: str, + callee_version: str, + callee_action: str, + params: List[Param] = None, + files: List[File] = None, + timeout: int = None, + ) -> bool: + """ + Add a run-time call. + + Current transport payload is used when the optional transport is not given. + + :param address: The address of the remote Gateway. + :param service: The name of the Service. + :param version: The version of the Service. + :param action: The name of the action making the call. + :param callee_service: The called service. + :param callee_version: The called version. + :param callee_action: The called action. + :param params: Optional parameters to send. + :param files: Optional files to send. + :param timeout: Optional timeout for the call. + + """ + + call = { + ns.GATEWAY: address, + ns.NAME: callee_service, + ns.VERSION: callee_version, + ns.ACTION: callee_action, + ns.CALLER: action, + } + + if timeout is not None: + call[ns.TIMEOUT] = timeout + + if params: + call[ns.PARAMS] = [param_to_payload(p) for p in params] + + # TODO: Should the file be added to the call too ? Not in the specs. + file_payloads = [file_to_payload(f) for f in files] if files else None + if file_payloads: + call[ns.FILES] = file_payloads + + # Add the call to the transport payload + ok = self.append([ns.CALLS, service, version], call) + # When there are files included in the call add them to the transport payload + if ok and file_payloads: + gateway = self.get_public_gateway_address() + self.extend([ns.FILES, gateway, callee_service, callee_version, callee_action], file_payloads) + + return ok + + def add_error(self, service: str, version: str, message: str, code: int, status: str) -> bool: + """ + Add a Service error. + + :param service: The name of the Service. + :param version: The version of the Service. + :param message: The error message. + :param code: The error code. + :param status: The status message for the protocol. + + """ + + gateway = self.get_public_gateway_address() + return self.append([ns.ERRORS, gateway, service, version], { + ns.MESSAGE: message, + ns.CODE: code, + ns.STATUS: status, + }) + + def has_calls(self, service: str, version: str) -> bool: + """ + Check if there are any type of calls registered for a Service. + + :param service: The name of the Service. + :param version: The version of the Service. + + """ + + for call in self.get([ns.CALLS, service, version], []): + # When duration is None or there is no duration it means the call was not + # executed so is safe to assume a call that has to be executed was found. + if call.get(ns.DURATION) is None: + return True + + return False + + def has_files(self) -> bool: + """Check if there are files registered in the transport.""" + + return self.exists([ns.FILES]) + + def has_transactions(self) -> bool: + """Check if there are transactions registered in the transport.""" + + return self.exists([ns.TRANSACTIONS]) + + def has_download(self) -> bool: + """Check if there is a file download registered in the transport.""" + + return self.exists([ns.BODY]) diff --git a/kusanagi/sdk/lib/payload/utils.py b/kusanagi/sdk/lib/payload/utils.py new file mode 100644 index 0000000..f7d0806 --- /dev/null +++ b/kusanagi/sdk/lib/payload/utils.py @@ -0,0 +1,114 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import copy + +from ...file import File +from ...param import Param +from . import Payload +from . import ns + + +def payload_to_param(p: dict) -> Param: + """ + Convert a payload to an SDK parameter. + + :param p: The parameter payload. + + """ + + return Param( + p.get(ns.NAME), + value=p.get(ns.VALUE), + type=p.get(ns.TYPE), + exists=True, + ) + + +def param_to_payload(p: Param) -> Payload: + """ + Convert a parameter object to a payload. + + :param p: The parameter object. + + """ + + return Payload({ + ns.NAME: p.get_name(), + ns.VALUE: p.get_value(), + ns.TYPE: p.get_type(), + }) + + +def file_to_payload(f: File) -> Payload: + """ + Convert a file to a payload. + + :param f: The file object. + + """ + + p = Payload({ + ns.NAME: f.get_name(), + ns.PATH: f.get_path(), + ns.MIME: f.get_mime(), + ns.FILENAME: f.get_filename(), + ns.SIZE: f.get_size(), + }) + + if f.get_path() and not f.is_local(): + p[ns.TOKEN] = f.get_token() + + return p + + +def payload_to_file(p: dict) -> File: + """ + Convert payload to a file. + + :param p: The file payload. + + """ + + return File( + p.get(ns.NAME), + p.get(ns.PATH), + mime=p.get(ns.MIME), + filename=p.get(ns.FILENAME), + size=p.get(ns.SIZE), + token=p.get(ns.TOKEN), + ) + + +def merge_dictionary(src: dict, dest: dict) -> dict: + """ + Merge two dictionaries. + + :param src: A dictionary with the values to merge. + :param dest: A dictionary where to merge the values. + + """ + + for name, value in src.items(): + if name not in dest: + # When field is not available in destination add the value from the source + if isinstance(value, dict): + # A new dictionary is created to avoid keeping references + dest[name] = copy.deepcopy(value) + elif isinstance(value, list): + # A new list is created to avoid keeping references + dest[name] = copy.deepcopy(value) + else: + dest[name] = value + elif isinstance(value, dict): + # When field exists in destination and is dict merge the source value + merge_dictionary(value, dest[name]) + elif isinstance(value, list) and isinstance(dest[name], list): + # When both values are a list merge them + dest[name].extend(copy.deepcopy(value)) + + return dest diff --git a/kusanagi/sdk/lib/server.py b/kusanagi/sdk/lib/server.py new file mode 100644 index 0000000..25048c1 --- /dev/null +++ b/kusanagi/sdk/lib/server.py @@ -0,0 +1,388 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +import asyncio +import errno +import os +import signal +from typing import TYPE_CHECKING + +import zmq + +from ..action import Action +from ..request import Request +from ..response import Response +from . import cli +from .asynchronous import AsyncAction +from .asynchronous import AsyncRequest +from .asynchronous import AsyncResponse +from .error import KusanagiError +from .logging import Logger +from .logging import setup_kusanagi_logging +from .msgpack import pack +from .msgpack import unpack +from .payload.command import CommandPayload +from .payload.error import ErrorPayload +from .payload.mapping import MappingPayload +from .payload.reply import ReplyPayload +from .state import State + +if TYPE_CHECKING: + from typing import Any + from typing import Callable + from typing import List + from typing import Union + + from ..api import Api + from ..middleware import Middleware + from ..service import Service + from .payload.transport import TransportPayload + + ComponentType = Union[Middleware, Service] + ApiType = Union[ + Request, + Response, + Action, + AsyncRequest, + AsyncResponse, + AsyncAction, + ] + +# Constants for response meta +EMPTY_META = b'\x00' +SE = SERVICE_CALL = b'\x01' +FI = FILES = b'\x02' +TR = TRANSACTIONS = b'\x03' +DL = DOWNLOAD = b'\x04' + +# Allowed response meta values +META_VALUES = (EMPTY_META, SE, FI, TR, DL) + + +def create_error_stream(rid: str, message: str) -> List[bytes]: + """ + Create a new multipart error stream. + + :param rid: A request ID. + :param message: The error message. + + """ + + return [rid.encode('utf8'), EMPTY_META, pack(ErrorPayload.new(message=message))] + + +def initialize_logging(values): # pragma: no cover + component_type = values.get_component() + name = values.get_name() + version = values.get_version() + framework_version = values.get_framework_version() + level = values.get_log_level() + setup_kusanagi_logging(component_type, name, version, framework_version, level) + + +def create_server(*args, **kwargs) -> Server: + """ + Server factory. + + CLI argument values are parsed when calling this function. + + """ + + # Update the global SDK debug state + values = cli.parse_args() + cli.DEBUG = values.is_debug() + # Initialize the SDK logging + initialize_logging(values) + # Create a new server instace + return Server(*args, values=values, **kwargs) + + +class Server(object): + """SDK component server.""" + + def __init__(self, component: ComponentType, callbacks: dict, on_error: Callable, values: cli.Input = None): + """ + Constructor. + + :param component: The framework component being run. + :param callbacks: The callbacks to call to handle each requests. + :param on_error: The component error callback. + :param values: Optional CLI input values. + + """ + + self.__logger = Logger(__name__) + self.__component = component + self.__on_error = on_error + self.__callbacks = callbacks + self.__values = values + self.__schemas = MappingPayload() + self.__loop = asyncio.get_event_loop() + self.__loop.add_signal_handler(signal.SIGTERM, self.stop) + self.__loop.add_signal_handler(signal.SIGINT, self.stop) + + def __update_schemas(self, rid: str, stream: bytes): + """ + Update schemas with new service schemas. + + :param rid: The ID of the current request. + :param stream: A stream with the serialized mappings schemas. + + """ + + self.__logger.debug('Updating schemas for Services ...', rid=rid) + try: + # NOTE: The msgpack unpack function can return many type of exceptions + schemas = unpack(stream) + except asyncio.CancelledError: # pragma: no cover + # Async task canceled during unpack + raise + except Exception as exc: # pragma: no cover + self.__logger.error(f'Failed to update schemas: Stream format is not valid: {exc}', rid=rid) + + try: + self.__schemas = MappingPayload(schemas) + except TypeError: # pragma: no cover + self.__logger.error('Failed to update schemas: The schema is not a dictionary', rid=rid) + + def __process_response(self, result: Any, state: State) -> List[bytes]: + flags = EMPTY_META + if isinstance(result, Request): + payload = state.context['reply'].for_request() + elif isinstance(result, Response): + payload = state.context['reply'].for_response() + elif isinstance(result, Action): + payload: ReplyPayload = state.context['reply'] + transport: TransportPayload = payload.get_transport() + if transport: + flags = b'' + if transport.has_calls(result.get_name(), result.get_version()): + flags += SERVICE_CALL + + if transport.has_files(): + flags += FILES + + if transport.has_transactions(): + flags += TRANSACTIONS + + if transport.has_download(): + flags += DOWNLOAD + + if not flags: + flags = EMPTY_META + else: # pragma: no cover + self.__logger.error(f'Callback returned an invalid value: {result.__class__}') + payload = ErrorPayload.new(message='Invalid value returned from callback') + + return [state.id.encode('utf8'), flags, pack(payload)] + + def __handle_callback_error(self, exc: Exception, api: Api, state: State) -> Union[Response, Action]: + if isinstance(api, Action): + action = Action(self.__component, state) + action.error(str(exc), status='500 Internal Server Error') + return action + else: + # The api is either a request or a response, and in both cases a new error response is returned + response = Response(self.__component, state) + http_response = response.get_http_response() + http_response.set_status(500, 'Internal Server Error') + http_response.set_body(str(exc)) + return response + + def __create_component(self, state: State, is_async: bool) -> ApiType: + # Prepare the initial payload to use for the reply. + # The action names for request and response middlewares are fixed, while services can have any action name. + command = state.context['command'] + if state.values.get_component() == 'service': + state.context['reply'] = ReplyPayload.new_action_reply(command) + # When the callback is async use an Action component that support asyncio + if is_async: + api = AsyncAction(self.__component, state) + else: + api = Action(self.__component, state) + elif state.action == 'request': + # NOTE: The Request component can return a Request or a Response. The reply payload is initially defined + # as a request, but it is changed to a ResponsePayload when user calls Request.set_response(). + state.context['reply'] = ReplyPayload.new_request_reply(command) + if is_async: + api = AsyncRequest(self.__component, state) + else: + api = Request(self.__component, state) + elif state.action == 'response': + state.context['reply'] = ReplyPayload.new_response_reply(command) + if is_async: + api = AsyncResponse(self.__component, state) + else: + api = Response(self.__component, state) + + return api + + async def __process_action(self, state: State) -> List[bytes]: + # Get the userland callback that processes the request + callback = self.__callbacks[state.action] + is_async_callback = asyncio.iscoroutinefunction(callback) + + # Execute the userland callback and if there is an error duting + # its execution call the error callback of the component. + api = self.__create_component(state, is_async_callback) + try: + if is_async_callback: + result = await callback(api) + else: + # Run non async callbacks in the default executor + result = await self.__loop.run_in_executor(None, callback, api) + except asyncio.CancelledError: # pragma: no cover + # The task was canceled + raise + except KusanagiError as err: + self.__logger.exception(f'Callback error: {err}') + self.__on_error(err) + + # Any KusanagiError class is handled as a valid framework response. + # Depending on the running component the result is a response or an action with an error. + result = self.__handle_callback_error(err, api, state) + except Exception as exc: + self.__logger.exception(f'Callback error: {exc}') + self.__on_error(exc) + + # Return an error payload when the callback fails with an exception + payload = ErrorPayload.new(message=str(exc)) + return [state.id.encode('utf8'), EMPTY_META, pack(payload)] + + return self.__process_response(result, state) + + async def __process_request(self, state: State) -> List[bytes]: + if state.schemas: + self.__update_schemas(state.id, state.schemas) + + # Add service schemas to the state context + state.context['schemas'] = self.__schemas + + # Return an error when action doesn't exist + action = state.action + if action not in self.__callbacks: + title = state.get_component_title() + return create_error_stream(state.id, f'Invalid action for component {title}: "{action}"') + + # Create the command payload using the request payload stream + try: + command = CommandPayload(unpack(state.payload)) + except asyncio.CancelledError: # pragma: no cover + # Async task canceled during unpack + raise + except Exception as exc: # pragma: no cover + msg = 'The request contains an invalid payload' + self.__logger.critical(f'{msg}: {exc}', rid=state.id) + return create_error_stream(state.id, msg) + else: + state.context['command'] = command + + return await self.__process_action(state) + + async def __handle_request(self, state: State) -> List[bytes]: + # Process the request using the current execution timeout + timeout = self.__values.get_timeout() / 1000.0 # seconds + try: + stream = await asyncio.wait_for(self.__process_request(state), timeout=timeout) + except asyncio.TimeoutError: + timeout_ms = timeout * 1000 + msg = f'Execution timed out after {timeout_ms}ms' + pid = os.getpid() + self.__logger.warning(f'{msg}. PID: {pid}', rid=state.id) + # When the request times out return an error response + stream = create_error_stream(state.id, msg) + + return stream + + async def listen(self): + """ + Listen for incoming request. + + :raises: KusanagiError + + """ + + self.__logger.debug('Creating the socket...') + channel = self.__values.get_channel() + try: + ctx = zmq.asyncio.Context() + socket = ctx.socket(zmq.REP) + socket.bind(channel) + except zmq.error.ZMQError as err: # pragma: no cover + if err.errno == errno.EADDRINUSE: + socket_name = self.__values.get_socket() + raise KusanagiError(f'Address unavailable: {socket_name}') + elif err.errno == errno.EINTR: + # The application is exiting during a blocking socket operation + return + + raise KusanagiError(err.strerror) + + self.__logger.debug('Listening for incoming requests in channel: "%s"', channel) + try: + while True: + event = await socket.poll() + if event == zmq.POLLIN: + # Read the mulstipart stream + stream = await socket.recv_multipart() + # Parse the stream to create a state + state = State.create(self.__values, stream) + if state: + # Get a new stream with the response contents + stream = await self.__handle_request(state) + else: + # When the request format is not valid return a generic error response + stream = create_error_stream('-', 'Failed to handle request') + + # Return the response contents to the client + await socket.send_multipart(stream) + except zmq.error.ZMQError as err: # pragma: no cover + # The EINTR can happen when the application is exiting during a blocking + # socket operation and it is not a critical error in this context. + if err.errno != errno.EINTR: + raise + except asyncio.CancelledError: # pragma: no cover + pass + finally: + self.__logger.debug('Clossing the socket...') + socket.close() + + def start(self): + """Start the server.""" + + self.__logger.debug('Using PID: %s', os.getpid()) + self.__logger.debug('Starting server...') + try: + self.__loop.run_until_complete(self.listen()) + except asyncio.CancelledError: # pragma: no cover + pass + finally: # pragma: no cover + self.__logger.debug('Stopping the event loop...') + self.__loop.stop() + self.__loop.close() + self.__logger.debug('Server stopped') + + def stop(self): # pragma: no cover + """Stop the server.""" + + # Cancel all ongoing tasks before stopping the loop + self.__logger.debug('Stopping the server...') + error = None + tasks = asyncio.Task.all_tasks() + for task in tasks: + # When an exception is found in a task save it + if task.done() and task.exception() and not error: + error = task.exception() + else: + task.cancel() + + # When an exception is found raise it + if error: + self.__logger.warning('Raising exception found while stopping tasks') + raise error diff --git a/kusanagi/sdk/lib/singleton.py b/kusanagi/sdk/lib/singleton.py new file mode 100644 index 0000000..d603410 --- /dev/null +++ b/kusanagi/sdk/lib/singleton.py @@ -0,0 +1,29 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +class Singleton(type): + """ + Metaclass to make a class definition a singleton. + + The class definition using this will contain a class property + called `instance` with the only instance allowed for that class. + + Instance is, of course, only created the first time the class is called. + + """ + + def __init__(cls, name: str, bases: tuple, classdict: dict): + super().__init__(name, bases, classdict) + cls.instance = None + + def __call__(cls, *args, **kwargs): + if cls.instance is None: + cls.instance = super().__call__(*args, **kwargs) + + return cls.instance diff --git a/kusanagi/sdk/lib/state.py b/kusanagi/sdk/lib/state.py new file mode 100644 index 0000000..bd1e58e --- /dev/null +++ b/kusanagi/sdk/lib/state.py @@ -0,0 +1,95 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from __future__ import annotations + +import logging +from typing import List +from typing import Optional + +from . import cli +from .logging import RequestLogger + +LOG = logging.getLogger(__name__) + + +class State(object): + """State contains the data for a multipart request of the framework.""" + + def __init__(self, values: cli.Input, stream: List[bytes]): + """ + Constructor. + + :param values: The CLI input values. + :param stream: The stream with the multipart data. + + :raises: ValueError + + """ + + request_id, action, self.__schemas, self.__payload = stream + self.__values = values + self.__request_id = request_id.decode('utf8') + self.__action = action.decode('utf8') + self.__logger = RequestLogger('kusanagi', self.__request_id) + # Context can be used to store any data related to the request + self.context = {} + + @classmethod + def create(cls, *args, **kwargs) -> Optional[State]: + """ + Create a state from a multipart stream. + + None is returned when the stream is invalid. + + """ + + try: + return cls(*args, **kwargs) + except ValueError as err: + LOG.error(f'Received an invalid multipart request: {err}') + + @property + def id(self) -> str: + """Get the request ID.""" + + return self.__request_id + + @property + def action(self) -> str: + """Get the action name.""" + + return self.__action + + @property + def schemas(self) -> bytes: + """Get the service schemmas mapping data.""" + + return self.__schemas + + @property + def payload(self) -> bytes: + """Get the request payload data.""" + + return self.__payload + + @property + def values(self) -> cli.Input: + """Get the values for the CLI argument.""" + + return self.__values + + @property + def logger(self) -> RequestLogger: + """Get the logger for the current request.""" + + return self.__logger + + def get_component_title(self) -> str: + """Get the title for the component.""" + + return '"{}" ({})'.format(self.values.get_name(), self.values.get_version()) diff --git a/kusanagi/sdk/lib/version.py b/kusanagi/sdk/lib/version.py new file mode 100644 index 0000000..885604b --- /dev/null +++ b/kusanagi/sdk/lib/version.py @@ -0,0 +1,163 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import re +from functools import cmp_to_key +from itertools import zip_longest +from typing import List + +# Regexp to check version pattern for invalid chars +INVALID_PATTERN = re.compile(r'[^a-zA-Z0-9*.,_-]') + +# Regexp to remove duplicated '*' from version +WILDCARDS = re.compile(r'\*+') + +# Regexp to match version dot separators +VERSION_DOTS = re.compile(r'([^*])\.') + +# Regexp to match all wildcards except the last one +VERSION_WILDCARDS = re.compile(r'\*+([^$])') + +# Values to return when using comparison functions +GREATER = 1 +EQUAL = 0 +LOWER = -1 + + +def compare_none(part1: str, part2: str) -> int: + if part1 == part2: + return EQUAL + elif part2 is None: + # The one that DO NOT have more parts is greater + return GREATER + else: + return LOWER + + +def compare_sub_parts(sub1: str, sub2: str) -> int: + # Sub parts are equal + if sub1 == sub2: + return EQUAL + + # Check if any sub part is an integer + is_integer = [False, False] + for idx, value in enumerate((sub1, sub2)): + try: + int(value) + except ValueError: + is_integer[idx] = False + else: + is_integer[idx] = True + + # Compare both sub parts according to their type + if is_integer[0] != is_integer[1]: + # One is an integer. The integer is higher than the non integer. + # Check if the first sub part is an integer, and if so it means + # sub2 is lower than sub1. + return LOWER if is_integer[0] else GREATER + + # Both sub parts are of the same type + return GREATER if sub1 < sub2 else LOWER + + +def compare(ver1: str, ver2: str) -> int: + # Versions are equal + if ver1 == ver2: + return EQUAL + + for part1, part2 in zip_longest(ver1.split('.'), ver2.split('.')): + # One of the parts is None + if part1 is None or part2 is None: + return compare_none(part1, part2) + + for sub1, sub2 in zip_longest(part1.split('-'), part2.split('-')): + # One of the sub parts is None + if sub1 is None or sub2 is None: + # Sub parts are different, because one have a + # value and the other not. + return compare_none(sub1, sub2) + + # Both sub parts have a value + result = compare_sub_parts(sub1, sub2) + if result: + # Sub parts are not equal + return result + + +class VersionString(object): + """Semantic version string.""" + + def __init__(self, pattern: str): + # Remove duplicated wildcards from version pattern + self.__version = WILDCARDS.sub('*', pattern) + + if '*' in self.__version: + # Create an expression for version pattern comparisons + expr = VERSION_WILDCARDS.sub(r'[^*.]+\1', self.__version) + # Escape dots to work with the regular expression + expr = VERSION_DOTS.sub(r'\1\.', expr) + + # If there is a final wildcard left replace it with an + # expression to match any characters after the last dot. + if expr[-1] == '*': + expr = expr[:-1] + '.*' + + # Create a pattern to be use for cmparison + self.__pattern = re.compile(expr) + else: + self.__pattern = None + + def __repr__(self): # pragma: no cover + return f'' + + def __str__(self): # pragma: no cover + return self.__version + + @staticmethod + def is_valid(pattern: str) -> bool: + """ + Check if a version pattern is valid. + + :param pattern: A version pattern to validate. + + """ + + return INVALID_PATTERN.search(pattern) is None + + def match(self, version: str) -> bool: + """ + Check if a version matches the current version pattern. + + :param version: A version to check agains the current pattern. + + """ + + # Check that the version pattern is valid + if not self.is_valid(self.__version): + return False + + if not self.__pattern: + return self.__version == version + else: + return self.__pattern.fullmatch(version) is not None + + def resolve(self, versions: List[str]) -> str: + """ + Resolve the highest compatible version to the current version pattern. + + An empty string is returned when the no version is resolved. + + :param versions: List of versions to resolve against the current pattern. + + """ + + valid_versions = [ver for ver in versions if self.match(ver)] + if not valid_versions: + return '' + + valid_versions.sort(key=cmp_to_key(compare)) + return valid_versions[0] diff --git a/kusanagi/sdk/link.py b/kusanagi/sdk/link.py new file mode 100644 index 0000000..0a85bbe --- /dev/null +++ b/kusanagi/sdk/link.py @@ -0,0 +1,47 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +class Link(object): + """Represents a link object in the transport.""" + + def __init__(self, address: str, name: str, ref: str, uri: str): + """ + Constructor. + + :param address: The network address of the gateway> + :param name: The name of the service. + :param ref: The link reference. + :param uri: The URI of the link. + + """ + + self.__address = address + self.__name = name + self.__ref = ref + self.__uri = uri + + def get_address(self) -> str: + """Get the gateway address of the service.""" + + return self.__address + + def get_name(self) -> str: + """Get the name of the service.""" + + return self.__name + + def get_link(self) -> str: + """Get the link reference.""" + + return self.__ref + + def get_uri(self) -> str: + """Get the URI for the link.""" + + return self.__uri diff --git a/kusanagi/sdk/middleware.py b/kusanagi/sdk/middleware.py index f03c161..beca2e9 100644 --- a/kusanagi/sdk/middleware.py +++ b/kusanagi/sdk/middleware.py @@ -1,50 +1,54 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Python SDK for the KUSANAGI(tm) framework (http://kusanagi.io) # Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. # # Distributed under the MIT license. # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. +from __future__ import annotations + +from typing import TYPE_CHECKING + from .component import Component -from .runner import ComponentRunner -from ..middleware import MiddlewareServer +if TYPE_CHECKING: + from typing import Awaitable + from typing import Callable + from typing import Union -class Middleware(Component): - """KUSANAGI SDK Middleware component.""" + from .request import Request + from .response import Response + + # Types for the request + RequestResult = Union[Request, Response] + AsyncRequestCallback = Callable[[Request], Awaitable[RequestResult]] + RequestCallback = Callable[[Request], RequestResult] + # Types for the response + AsyncResponseCallback = Callable[[Response], Awaitable[Response]] + ResponseCallback = Callable[[Response], Response] - def __init__(self): - super().__init__() - # Create a component runner for the middleware server - help = 'Middleware component to process HTTP requests and responses' - self._runner = ComponentRunner(self, MiddlewareServer, help) - def request(self, callback): - """Set a callback for requests. +class Middleware(Component): + """KUSANAGI middleware component.""" + + def request(self, callback: Union[RequestCallback, AsyncRequestCallback]) -> Middleware: + """ + Set a callback for requests. :param callback: Callback to handle requests. - :type callback: callable """ self._callbacks['request'] = callback + return self - def response(self, callback): - """Set callback for response. + def response(self, callback: Union[ResponseCallback, AsyncResponseCallback]) -> Middleware: + """ + Set callback for response. :param callback: Callback to handle responses. - :type callback: callable """ self._callbacks['response'] = callback - - -def get_component(): - """Get global Middleware component instance. - - :rtype: Middleware - - """ - - return Middleware.instance + return self diff --git a/kusanagi/sdk/param.py b/kusanagi/sdk/param.py index 90fce3f..b50ccd9 100644 --- a/kusanagi/sdk/param.py +++ b/kusanagi/sdk/param.py @@ -5,98 +5,43 @@ # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. -from ..errors import KusanagiTypeError -from ..payload import get_path -from ..payload import Payload - -EMPTY = object() - -# Supported parameter types -TYPE_NULL = 'null' -TYPE_BOOLEAN = 'boolean' -TYPE_INTEGER = 'integer' -TYPE_FLOAT = 'float' -TYPE_ARRAY = 'array' -TYPE_OBJECT = 'object' -TYPE_STRING = 'string' -TYPE_BINARY = 'binary' -TYPE_CHOICES = ( - TYPE_NULL, - TYPE_BOOLEAN, - TYPE_INTEGER, - TYPE_FLOAT, - TYPE_ARRAY, - TYPE_OBJECT, - TYPE_STRING, - TYPE_BINARY, - ) +from __future__ import annotations -# Parameter type names to python types -TYPE_CLASSES = { - TYPE_BOOLEAN: bool, - TYPE_INTEGER: int, - TYPE_FLOAT: float, - TYPE_ARRAY: list, - TYPE_OBJECT: dict, - TYPE_STRING: str, - TYPE_BINARY: bytes, - } - - -def payload_to_param(payload): - """Convert a param payload to a Param object. - - :param payload: Parameter payload. - :type payload: Payload - - :rtype: Param - - """ +import logging +import sys +from typing import TYPE_CHECKING - return Param( - get_path(payload, 'name'), - value=get_path(payload, 'value'), - type=get_path(payload, 'type'), - exists=True, - ) +from .lib import datatypes +from .lib.payload import ns +if TYPE_CHECKING: + from typing import Any + from typing import List -def param_to_payload(param): - """Convert a Param object to a param payload. + from .lib.payload.param import HttpParamSchemaPayload + from .lib.payload.param import ParamSchemaPayload - :param param: Parameter object. - :type param: Param - - :rtype: Payload - - """ - - return Payload().set_many({ - 'name': param.get_name(), - 'value': param.get_value(), - 'type': param.get_type(), - }) +LOG = logging.getLogger(__name__) class Param(object): - """Parameter class for API. + """ + Input parameter. - A Param object represents a parameter received for an action in a call - to a Service component. + Actions receive parameters thought calls to a service component. """ - TYPE_NULL = TYPE_NULL - TYPE_BOOLEAN = TYPE_BOOLEAN - TYPE_INTEGER = TYPE_INTEGER - TYPE_FLOAT = TYPE_FLOAT - TYPE_ARRAY = TYPE_ARRAY - TYPE_OBJECT = TYPE_OBJECT - TYPE_STRING = TYPE_STRING - TYPE_BINARY = TYPE_BINARY - TYPE_CHOICES = TYPE_CHOICES + TYPE_NULL = datatypes.TYPE_NULL + TYPE_BOOLEAN = datatypes.TYPE_BOOLEAN + TYPE_INTEGER = datatypes.TYPE_INTEGER + TYPE_FLOAT = datatypes.TYPE_FLOAT + TYPE_ARRAY = datatypes.TYPE_ARRAY + TYPE_OBJECT = datatypes.TYPE_OBJECT + TYPE_STRING = datatypes.TYPE_STRING + TYPE_BINARY = datatypes.TYPE_BINARY - def __init__(self, name, value='', type=None, exists=False): + def __init__(self, name: str, value: Any = '', type: str = '', exists: bool = False): """ Constructor. @@ -105,126 +50,347 @@ def __init__(self, name, value='', type=None, exists=False): :param type: Optional type for the parameter value. :param exists: Optional flag to know if the parameter exists in the service call. - :raises: KusanagiTypeError + :raises: TypeError """ - # Invalid parameter types are treated as string parameters - if type is None: - type = self.resolve_type(value) - elif type not in TYPE_CHOICES: - type = TYPE_STRING + if not type: + type = resolve_param_type(value) + if type == self.TYPE_ARRAY and isinstance(value, (tuple, set)): + # Make sure the value is an list + value = list(value) + elif type == self.TYPE_STRING and not isinstance(value, str): + # Make sure that the value is a string value + value = str(value) + elif type not in datatypes.TYPE_CHOICES: + # Invalid parameter types are treated as string parameters + LOG.warning(f'Invalid type for parameter "{name}", using string type: "{type}"') + type = self.TYPE_STRING + value = '' # Check that the value respects the parameter type - if type == TYPE_NULL: - # When the type is null check that its value is None + if type == self.TYPE_NULL: if value is not None: - raise KusanagiTypeError('Value must be null') + raise TypeError('Value must be null') else: type_cls = TYPE_CLASSES[type] if not isinstance(value, type_cls): - raise KusanagiTypeError(f'Value must be {type}') + raise TypeError(f'Value must be {type}') self.__name = name self.__value = value self.__type = type self.__exists = exists - @classmethod - def resolve_type(cls, value): - """Converts native types to schema types. + def get_name(self) -> str: + """Get the name of the parameter.""" + + return self.__name + + def get_type(self) -> str: + """Get the type of the parameter value.""" + + return self.__type + + def get_value(self) -> Any: + """Get the parameter value.""" - :param value: The value to analyze. - :type value: mixed + return self.__value + + def exists(self) -> bool: + """Check if the parameter exists in the service call.""" - :rtype: str + return self.__exists + def copy_with_name(self, name: str) -> Param: """ + Copy the parameter with a new name. - if value is None: - return TYPE_NULL + :param name: Name of the new parameter. - value_class = value.__class__ + """ - # Resolve non standard python types - if value_class in (tuple, set): - return TYPE_ARRAY + return self.__class__(name, value=self.__value, type=self.__type, exists=self.exists()) - # Resolve standard mapped python types - for type_name, cls in TYPE_CLASSES.items(): - if value_class == cls: - return type_name + def copy_with_value(self, value: Any) -> Param: + """ + Copy the parameter with a new value. - return TYPE_STRING + :param value: Value for the new parameter. - def get_name(self): - """Get aprameter name. + """ - :rtype: str + return self.__class__(self.__name, value=value, type=self.__type, exists=self.exists()) + def copy_with_type(self, type: str) -> Param: """ + Copy the parameter with a new type. - return self.__name + :param type: Type for the new parameter. + + :raises: TypeError, ValueError + + """ + + name = self.get_name() + if type not in TYPE_CLASSES: + raise ValueError(f'Param "{name}" copy failed because the type is invalid: "{type}"') - def get_type(self): - """Get parameter data type. + # cast the value to the new type + try: + value = TYPE_CLASSES[type](self.get_value()) + except TypeError: + current = self.get_type() + raise TypeError(f'Param "{name}" copy failed: Type "{type}" is not compatible with "{current}"') - :rtype: str + return self.__class__(self.__name, value=value, type=type, exists=self.exists()) + +# Parameter type names to python types +TYPE_CLASSES = { + Param.TYPE_BOOLEAN: bool, + Param.TYPE_INTEGER: int, + Param.TYPE_FLOAT: float, + Param.TYPE_ARRAY: list, + Param.TYPE_OBJECT: dict, + Param.TYPE_STRING: str, + Param.TYPE_BINARY: bytes, +} + + +def resolve_param_type(value: Any) -> str: + """ + Resolves the parameter type to use for native python types. + + :param value: The value from where to resolve the type name. + + """ + + if value is None: + return Param.TYPE_NULL + + value_cls = value.__class__ + + # Resolve non mapped types + if value_cls in (tuple, set): + return Param.TYPE_ARRAY + + # Resolve the mapped python types + for type_name, cls in TYPE_CLASSES.items(): + if value_cls == cls: + return type_name + + return Param.TYPE_STRING + + +class ParamSchema(object): + """Parameter schema in the framework.""" + + ARRAY_FORMAT_CSV = 'csv' + ARRAY_FORMAT_SSV = 'ssv' + ARRAY_FORMAT_TSV = 'tsv' + ARRAY_FORMAT_PIPES = 'pipes' + ARRAY_FORMAT_MULTI = 'multi' + + def __init__(self, payload: ParamSchemaPayload): + self.__payload = payload + + def get_name(self) -> str: + """Get parameter name.""" + + return self.__payload.get_name() + + def get_type(self) -> str: + """Get parameter value type.""" + + return self.__payload.get([ns.TYPE], Param.TYPE_STRING) + + def get_format(self) -> str: + """Get parameter value format.""" + + return self.__payload.get([ns.FORMAT], '') + + def get_array_format(self) -> str: """ + Get format for the parameter if the type property is set to "array". - return self.__type + An empty string is returned when the parameter type is not "array". + + Formats: + - "csv" for comma separated values (default) + - "ssv" for space separated values + - "tsv" for tab separated values + - "pipes" for pipe separated values + - "multi" for multiple parameter instances instead of multiple values for a single instance. + + """ + + if self.get_type() != Param.TYPE_ARRAY: + return '' - def get_value(self, fallback=None): - """Get parameter value. + return self.__payload.get([ns.ARRAY_FORMAT], self.ARRAY_FORMAT_CSV) - Value is returned using the parameter data type for casting. + def get_pattern(self) -> str: + """Get ECMA 262 compliant regular expression to validate the parameter.""" - The optional fallback value must be null or conform to the data type. - If a callable is provided it will be evaluated if the fallback is to be returned. + return self.__payload.get([ns.PATTERN], '') - :param fallback: The optional fallback value. - :type fallback: mixed + def allow_empty(self) -> bool: + """Check if the parameter allows an empty value.""" - :raises: KusanagiTypeError + return self.__payload.get([ns.ALLOW_EMPTY], False) - :rtype: mixed + def has_default_value(self) -> bool: + """Check if the parameter has a default value defined.""" + return self.__payload.exists([ns.DEFAULT_VALUE]) + + def get_default_value(self) -> Any: + """Get default value for parameter.""" + + return self.__payload.get([ns.DEFAULT_VALUE]) + + def is_required(self) -> bool: + """Check if parameter is required.""" + + return self.__payload.get([ns.REQUIRED], False) + + def get_items(self) -> dict: """ + Get JSON schema with items object definition. - # When the parameter doesn't exists in a call try to get a default value - if not self.__exists: - # Return None by default when the param type is null and it doesn't exists - if self.__type == TYPE_NULL: - return + An empty dictionary is returned when parameter is not an "array", + otherwise the result contains a dictionary with a JSON schema definition. - # When a fallback value is given evaluate it and use it as parameter value - if fallback is not None: - name = self.__name + """ - # Fallback can be a callable, if so get its value - value = fallback() if callable(fallback) else fallback - if not isinstance(value, TYPE_CLASSES[self.__type]): - raise KusanagiTypeError(f'Invalid data type for fallback of parameter: {name}') + if self.get_type() != Param.TYPE_ARRAY: + return {} - return value + return self.__payload.get([ns.ITEMS], {}) - return self.__value + def get_max(self) -> int: + """Get maximum value for parameter.""" - def exists(self): - """Check if parameter exists. + return self.__payload.get([ns.MAX], sys.maxsize) - :rtype: bool + def is_exclusive_max(self) -> bool: + """ + Check if max value is inclusive. + + When max is not defined inclusive is False. """ - return self.__exists + if not self.__payload.exists([ns.MAX]): + return False + + return self.__payload.get([ns.EXCLUSIVE_MAX], False) + + def get_min(self) -> int: + """Get minimum value for parameter.""" + + return self.__payload.get([ns.MIN], -sys.maxsize - 1) + + def is_exclusive_min(self) -> bool: + """ + Check if minimum value is inclusive. + + When min is not defined inclusive is False. + + """ + + if not self.__payload.exists([ns.MIN]): + return False + + return self.__payload.get([ns.EXCLUSIVE_MIN], False) + + def get_max_items(self) -> int: + """ + Get maximum number of items allowed for the parameter. + + Result is -1 when type is not "array" or values is not defined. + + """ + + if self.get_type() != Param.TYPE_ARRAY: + return -1 + + return self.__payload.get([ns.MAX_ITEMS], -1) + + def get_min_items(self) -> int: + """ + Get minimum number of items allowed for the parameter. + + Result is -1 when type is not "array" or values is not defined. - def copy_with_name(self, name): - return self.__class__(name, value=self.__value, type=self.__type) + """ + + if self.get_type() != Param.TYPE_ARRAY: + return -1 + + return self.__payload.get([ns.MIN_ITEMS], -1) + + def has_unique_items(self) -> bool: + """Check if param must contain a set of unique items.""" + + return self.__payload.get([ns.UNIQUE_ITEMS], False) + + def get_enum(self) -> list: + """Get the set of unique values that parameter allows.""" + + return self.__payload.get([ns.ENUM], []) + + def get_multiple_of(self) -> int: + """ + Get value that parameter must be divisible by. + + Result is -1 when this property is not defined. + + """ + + return self.__payload.get([ns.MULTIPLE_OF], -1) + + def get_http_schema(self) -> HttpParamSchema: + """Get HTTP param schema.""" + + payload = self.__payload.get_http_param_schema_payload() + return HttpParamSchema(payload) + + +class HttpParamSchema(object): + """HTTP semantics of a parameter schema in the framework.""" + + def __init__(self, payload: HttpParamSchemaPayload): + self.__payload = payload + + def is_accessible(self) -> bool: + """Check if the Gateway has access to the parameter.""" + + return self.__payload.get([ns.GATEWAY], True) + + def get_input(self) -> str: + """Get location of the parameter.""" + + return self.__payload.get([ns.INPUT], 'query') + + def get_param(self) -> str: + """Get name as specified via HTTP to be mapped to the name property.""" + + return self.__payload.get([ns.PARAM], self.__payload.get_name()) + + +def validate_parameter_list(params: List[Param]): + """ + Check that all the items in the list are Param instances. + + :raises: ValueError + + """ - def copy_with_value(self, value): - return self.__class__(self.__name, value=value, type=self.__type) + if not params: + return - def copy_with_type(self, type): - return self.__class__(self.__name, value=self.__value, type=type) + for param in params: + if not isinstance(param, Param): + raise ValueError(f'The parameter is not an instance of Param: {param.__class__}') diff --git a/kusanagi/sdk/relation.py b/kusanagi/sdk/relation.py new file mode 100644 index 0000000..8011e8f --- /dev/null +++ b/kusanagi/sdk/relation.py @@ -0,0 +1,110 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from typing import List +from typing import Union + + +class BaseRelation(object): + """Base class for service relations.""" + + def __init__(self, address: str, name: str): + """ + Constructor. + + :param address: A gateway network address. + :param name: The name of the service. + + """ + + self.__address = address + self.__name = name + + def get_address(self) -> str: + """Get the network address of the gateway.""" + + return self.__address + + def get_name(self) -> str: + """Get the name of the service.""" + + return self.__name + + +class Relation(BaseRelation): + """Relation between two services.""" + + def __init__(self, address: str, name: str, primary_key: str, foreign_relations: dict): + """ + Constructor. + + The foreign relations is a dict where each key is the address of the remote gateway + and each of the values is another dict containing remote service names as keys and + foreign keys as values, where foreign keys is a single value or a list of values. + + :param address: A gateway network address. + :param name: The name of the service. + :param primary_key: The primary key of the local service entity. + :param foreign_relations: The foreign relations. + + """ + + super().__init__(address, name) + self.__primary_key = primary_key + self.__foreign_relations = foreign_relations + + def get_primary_key(self) -> str: + """Get the primary key of the local service entity.""" + + return self.__primary_key + + def get_foreign_relations(self) -> List['ForeignRelation']: + """Get the foreign service relations.""" + + relations = [] + # Get the remote gateway address and the foreign relations + for address, services in self.__foreign_relations.items(): + # Each relation belongs to a service in the remote gateway + for name, foreign_keys in services.items(): + relations.append(ForeignRelation(address, name, foreign_keys)) + + return relations + + +class ForeignRelation(BaseRelation): + """Foreign relation between two services.""" + + TYPE_ONE = 'one' + TYPE_MANY = 'many' + + def __init__(self, address: str, name: str, foreign_keys: Union[str, List[str]]): + """ + Constructor. + + :param address: A remote gateway network address. + :param name: The name of the remote service. + :param foreign_keys: The foreign keys. + + """ + + super().__init__(address, name) + self.__foreign_keys = foreign_keys + + def get_type(self) -> str: + """ + Get the type of the relation. + + The relation type can be either "one" or "many". + + """ + + return self.TYPE_MANY if isinstance(self.__foreign_keys, list) else self.TYPE_ONE + + def get_foreign_keys(self) -> List[str]: + """Get the foreign key values for the relation.""" + + return [self.__foreign_keys] if self.get_type() == self.TYPE_ONE else list(self.__foreign_keys) diff --git a/kusanagi/sdk/request.py b/kusanagi/sdk/request.py index 5e435c4..45d7286 100644 --- a/kusanagi/sdk/request.py +++ b/kusanagi/sdk/request.py @@ -5,312 +5,552 @@ # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. -from .. import urn -from ..errors import KusanagiTypeError -from ..logging import RequestLogger -from ..payload import get_path -from ..payload import Payload - -from .base import Api -from .http.request import HttpRequest +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from .api import Api +from .file import File +from .lib.payload import ns +from .lib.payload.request import HttpRequestPayload +from .lib.payload.utils import param_to_payload +from .lib.payload.utils import payload_to_file +from .lib.payload.utils import payload_to_param from .param import Param -from .param import param_to_payload -from .param import payload_to_param -from .response import Response -from .transport import Transport + +if TYPE_CHECKING: + from typing import Any + from typing import List + from urllib.parse import ParseResult + + from .response import Response class Request(Api): - """Request API class for Middleware component.""" + """Request API class for the middleware component.""" + + DEFAULT_RESPONSE_STATUS_CODE = 200 + DEFAULT_RESPONSE_STATUS_TEXT = 'OK' def __init__(self, *args, **kwargs): + """Constructor.""" + super().__init__(*args, **kwargs) - self.__client_address = kwargs['client_address'] - self.__attributes = kwargs['attributes'] - self.__request_id = kwargs.get('rid') - self.__request_timestamp = kwargs.get('timestamp') - self._logger = RequestLogger(self.__request_id, 'kusanagi.sdk') - self.__gateway_protocol = kwargs.get('gateway_protocol') - self.__gateway_addresses = kwargs.get('gateway_addresses') + # Index parameters by name + self.__params = {param[ns.NAME]: param for param in self._reply.get([ns.CALL, ns.PARAMS], [])} + + def get_id(self) -> str: + """Get the request UUID.""" + + return self._command.get([ns.META, ns.ID], '') + + def get_timestamp(self) -> str: + """Get the request timestamp.""" + + return self._command.get([ns.META, ns.DATETIME], '') + + def get_gateway_protocol(self) -> str: + """Get the protocol implemented by the gateway handling current request.""" + + return self._command.get([ns.META, ns.PROTOCOL], '') + + def get_gateway_address(self) -> str: + """Get public gateway address.""" - http_request = kwargs.get('http_request') - if http_request: - self.__http_request = HttpRequest(**http_request) - else: - self.__http_request = None + return self._command.get([ns.META, ns.GATEWAY], ['', ''])[1] - # Save parameters by name as payloads - self.__params = { - get_path(param, 'name'): Payload(param) - for param in (kwargs.get('params') or []) - } + def get_client_address(self) -> str: + """Get IP address and port of the client which sent the request.""" - self.set_service_name(kwargs.get('service_name', '')) - self.set_service_version(kwargs.get('service_version', '')) - self.set_action_name(kwargs.get('action_name', '')) + return self._command.get([ns.META, ns.CLIENT], '') - def get_id(self): + def set_attribute(self, name: str, value: str) -> Request: """ - Get the request UUID. + Register a request attribute. - :rtype: str + :raises: TypeError """ - return self.__request_id + if not isinstance(value, str): + raise TypeError('Attribute value must be a string') + + self._reply.set([ns.ATTRIBUTES, name], value) + return self + + def get_service_name(self) -> str: + """Get the name of the service.""" + + return self._reply.get([ns.CALL, ns.SERVICE], '') - def get_timestamp(self): + def set_service_name(self, service: str) -> Request: """ - Get the request timestamp. + Set the name of the service. - :rtype: str + :param service: The service name. """ - return self.__request_timestamp + self._reply.set([ns.CALL, ns.SERVICE], service) + return self - def get_gateway_protocol(self): - """Get the protocol implemented by the Gateway handling current request. + def get_service_version(self) -> str: + """Get the version of the service.""" - :rtype: str + return self._reply.get([ns.CALL, ns.VERSION], '') + def set_service_version(self, version: str) -> Request: """ + Set the version of the service. - return self.__gateway_protocol + :param version: The service version. + + """ + + self._reply.set([ns.CALL, ns.VERSION], version) + return self - def get_gateway_address(self): - """Get public gateway address. + def get_action_name(self) -> str: + """Get the name of the action.""" - :rtype: str + return self._reply.get([ns.CALL, ns.ACTION], '') + def set_action_name(self, action: str) -> Request: """ + Set the name of the action. - return self.__gateway_addresses[1] + :param action: The action name. + + """ + + self._reply.set([ns.CALL, ns.ACTION], action) + return self - def get_client_address(self): - """Get IP address and port of the client which sent the request. + def has_param(self, name: str) -> bool: + """ + Check if a parameter exists. - :rtype: str + :param name: The parameter name. """ - return self.__client_address + return name in self.__params - def get_service_name(self): - """Get the name of the service. + def get_param(self, name: str) -> Param: + """ + Get a request parameter. - :rtype: str + :param name: The parameter name. """ - return self.__service_name + if not self.has_param(name): + return Param(name) + + return payload_to_param(self.__params[name]) - def set_service_name(self, service): - """Set the name of the service. + def get_params(self) -> List[Param]: + """Get all request parameters.""" - Sets the name of the service passed in the HTTP request. + return [payload_to_param(payload) for payload in self.__params.values()] - :param service: The service name. - :type service: str + def set_param(self, param: Param) -> Request: + """ + Add a new param for current request. - :rtype: Request + :param param: The parameter. """ - self.__service_name = service or '' + payload = param_to_payload(param) + self.__params[param.get_name()] = payload + self._reply.append([ns.CALL, ns.PARAMS], payload) return self - def get_service_version(self): - """Get the version of the service. + def new_param(self, name: str, value: Any = '', type: str = '') -> Param: + """ + Creates a new parameter object. + + Creates an instance of Param with the given name, and optionally the value and data type. + When the value is not provided then an empty string is assumed. + If the data type is not defined then "string" is assumed. - :type version: str + :param name: The parameter name. + :param value: The parameter value. + :param type: The data type of the value. + + :raises: TypeError """ - return self.__service_version + return Param(name, value=value, type=type, exists=True) - def set_service_version(self, version): - """Set the version of the service. + def new_response( + self, + code: int = DEFAULT_RESPONSE_STATUS_CODE, + text: str = DEFAULT_RESPONSE_STATUS_TEXT, + ) -> Response: + """ + Create a new Response object. - Sets the version of the service passed in the HTTP request. + :param code: Optional status code. + :param text: Optional status text. - :param version: The service version. - :type version: str + """ + + from .response import Response + + response = Response(self._component, self._state) + response.get_http_response().set_status(code, text) + # Change the reply payload from a request payload to a response payload. + # Initially the reply for the Request component is a RequestPayload. + self._reply.set_response(code, text) + return response + + def get_http_request(self) -> HttpRequest: + """Get HTTP request for current request.""" + + payload = HttpRequestPayload(self._command.get([ns.REQUEST], {})) + return HttpRequest(payload) - :rtype: Request +class HttpRequest(object): + """HTTP request class.""" + + METHOD_CONNECT = 'CONNECT' + METHOD_TRACE = 'TRACE' + METHOD_HEAD = 'HEAD' + METHOD_OPTIONS = 'OPTIONS' + METHOD_GET = 'GET' + METHOD_POST = 'POST' + METHOD_PUT = 'PUT' + METHOD_PATCH = 'PATCH' + METHOD_DELETE = 'DELETE' + + def __init__(self, payload: HttpRequestPayload): """ + Constructor. - self.__service_version = version or '' - return self + :param payload: The payload with the HTTP request data. - def get_action_name(self): - """Get the name of the action. + """ - :rtype: str + self.__payload = payload + self.__headers = {name.upper(): values for name, values in payload.get([ns.HEADERS], {}).items()} + self.__url: ParseResult = urlparse(self.__payload.get([ns.URL], '')) + # TODO: Change this to make each file a list to support multiple files with same name + self.__files = {p[ns.NAME]: payload_to_file(p) for p in self.__payload.get([ns.FILES], [])} + def is_method(self, method: str) -> bool: """ + Determine if the request used the given HTTP method. - return self.__action_name + :param method: The HTTP method. - def set_action_name(self, action): - """Set the name of the action. + """ - Sets the name of the action passed in the HTTP request. + return self.__payload.get([ns.METHOD]) == method.upper() - :param action: The action name. - :type action: str + def get_method(self) -> str: + """Get the HTTP method.""" + + return self.__payload.get([ns.METHOD], '').upper() + + def get_url(self) -> str: + """Get request URL.""" + + return self.__url.geturl() + + def get_url_scheme(self) -> str: + """Get request URL scheme.""" + + return self.__url.scheme + + def get_url_host(self) -> str: + """Get request URL host.""" + + # The port number is ignored when present + return self.__url.netloc.split(':')[0] + + def get_url_port(self) -> int: + """Get request URL port.""" + + return self.__url.port or 0 - :rtype: Request + def get_url_path(self) -> str: + """Get request URL path.""" + return self.__url.path.rstrip('/') + + def has_query_param(self, name: str) -> bool: """ + Check if a param is defined in the HTTP query string. - self.__action_name = action or '' - return self + :param name: The HTTP param name. + + """ + + return name in self.__payload.get([ns.QUERY], {}) + + def get_query_param(self, name: str, default: str = '') -> str: + """ + Get the param value from the HTTP query string. + + The first value is returned when the parameter is present more + than once in the HTTP query string. + + :param name: The HTTP param name. + :param default: An optional default value. + + """ + + if not self.has_query_param(name): + return default + + # The query param value is always an array that contains the + # actual parameter values. A param can have many values when + # the HTTP string contains the parameter more than once. + value = self.__payload.get([ns.QUERY, name], []) + return value[0] if value else default - def new_response(self, status_code=None, status_text=None): - """Create a new Response object. + def get_query_param_array(self, name: str, default: List[str] = None) -> List[str]: + """ + Get the param value from the HTTP query string. - Arguments `status_code` and `status_text` are used when Gateway - protocol is `urn:kusanagi:protocol:http`. + The result is a list with all the values for the parameter. + A parameter can be present more than once in an HTTP query string. - :param status_code: Optional HTTP status code. - :type status_code: int - :param status_text: Optional HTTP status text. - :type status_text: str + :param name: The HTTP param name. + :param default: An optional default value. - :returns: The response object. - :rtype: `Response` + :raises: TypeError """ - http_response = None - if self.get_gateway_protocol() == urn.HTTP: - http_response = { - 'status_code': status_code or 200, - 'status_text': status_text or 'OK', - } + if default is None: + default = [] + elif not isinstance(default, list): + raise TypeError('Default value is not a list') - return Response( - Transport({}), - self._component, - self.get_path(), - self.get_name(), - self.get_version(), - self.get_framework_version(), - rid=self.get_id(), - attributes=self.__attributes, - gateway_protocol=self.get_gateway_protocol(), - gateway_addresses=self.__gateway_addresses, - http_response=http_response, - ) + values = self.__payload.get([ns.QUERY, name]) + return list(values) if values else default - def get_http_request(self): - """Get HTTP request for current request. + def get_query_params(self) -> dict: + """ + Get all HTTP query params. - :rtype: HttpRequest + The first value of each parameter is returned when the parameter + is present more than once in the HTTP query string. """ - return self.__http_request + return {key: values[0] for key, values in self.__payload.get([ns.QUERY], {}).items()} - def new_param(self, name, value=None, type=None): - """Creates a new parameter object. + def get_query_params_array(self) -> dict: + """ + Get all HTTP query params. - Creates an instance of Param with the given name, and optionally - the value and data type. If the value is not provided then - an empty string is assumed. If the data type is not defined then - "string" is assumed. + Each parameter value is returned as a list. - Valid data types are "null", "boolean", "integer", "float", "string", - "array" and "object". + """ - :param name: The parameter name. - :type name: str - :param value: The parameter value. - :type value: mixed - :param type: The data type of the value. - :type type: str + return {key: list(values) for key, values in self.__payload.get([ns.QUERY], {}).items()} - :raises: KusanagiTypeError + def has_post_param(self, name: str) -> bool: + """ + Check if a param is defined in the HTTP POST contents. - :rtype: Param + :param name: The HTTP param name. """ - if type and Param.resolve_type(value) != type: - raise KusanagiTypeError('Incorrect data type given for parameter value') - else: - type = Param.resolve_type(value) + return name in self.__payload.get([ns.POST_DATA], {}) - return Param(name, value=value, type=type, exists=True) + def get_post_param(self, name: str, default: str = '') -> str: + """ + Get the param value from the HTTP POST contents. - def set_param(self, param): - """Add a new param for current request. + The first value is returned when the parameter is present more + than once in the HTTP request. - :param param: The parameter. - :type param: Param + :param name: The HTTP param name. + :param default: An optional default value. + + """ + + if not self.has_post_param(name): + return default - :rtype: Request + # The query param value is always an array that contains the + # actual parameter values. A param can have many values when + # the HTTP request contains the parameter more than once. + value = self.__payload.get([ns.POST_DATA, name], []) + return value[0] if value else default + def get_post_param_array(self, name: str, default: List[str] = None) -> List[str]: """ + Get the param value from the HTTP POST contents. - self.__params[param.get_name()] = param_to_payload(param) - return self + The result is a list with all the values for the parameter. + A parameter can be present more than once in an HTTP request. - def has_param(self, name): - """Check if a parameter exists. + :param name: The HTTP param name. + :param default: An optional default value. - :param name: The parameter name. - :type name: str + :raises: TypeError - :rtype: bool + """ + + if default is None: + default = [] + elif not isinstance(default, list): + raise TypeError('Default value is not a list') + + values = self.__payload.get([ns.POST_DATA, name]) + return list(values) if values else default + def get_post_params(self) -> dict: """ + Get all HTTP POST params - return (name in self.__params) + The first value of each parameter is returned when the parameter + is present more than once in the HTTP request. - def get_param(self, name): - """Get a request parameter. + """ - :param name: The parameter name. - :type name: str + return {key: values[0] for key, values in self.__payload.get([ns.POST_DATA], {}).items()} - :rtype: Param + def get_post_params_array(self) -> dict: + """ + Get all HTTP POST params. + + Each parameter value is returned as a list. """ - if not self.has_param(name): - return Param(name) + return {key: list(values) for key, values in self.__payload.get([ns.POST_DATA], {}).items()} - return payload_to_param(self.__params[name]) + def is_protocol_version(self, version: str) -> bool: + """ + Check if the request used the given HTTP version. + + :param version: The HTTP version. + + """ + + return self.__payload.get([ns.VERSION]) == version - def get_params(self): - """Get all request parameters. + def get_protocol_version(self) -> str: + """Get the HTTP version.""" - :rtype: list + return self.__payload.get([ns.VERSION], '') + def has_header(self, name: str) -> bool: """ + Check if the HTTP header is defined. - return [ - payload_to_param(payload) - for payload in self.__params.values() - ] + Header name is case insensitive. + + :param name: The HTTP header name. - def set_attribute(self, name, value): """ - Register a request attribute. - Attribute value must be a string. + return name.upper() in self.__headers + + def get_header(self, name: str, default: str = '') -> str: + """ + Get an HTTP header. + + Header name is case insensitive. + + :param name: The HTTP header name. + :param default: An optional default value. + + """ + + # The header value is always an array that contains the actual header values. + # A header can have many values when the HTTP request contains the header more than once. + value = self.__headers.get(name.upper()) + return value[0] if value else default + + def get_header_array(self, name: str, default: List[str] = None) -> List[str]: + """ + Get an HTTP header. + + Header name is case insensitive. + + :param name: The HTTP header name. + :param default: An optional default value. :raises: TypeError - :rtype: Request + """ + + if default is None: + default = [] + elif not isinstance(default, list): + raise TypeError('Default value is not a list') + + values = self.__headers.get(name.upper()) + return list(values) if values else default + def get_headers(self) -> dict: """ + Get all HTTP headers. - if not isinstance(value, str): - raise TypeError('Attribute value must be a string') + The first value of each header is returned when the header + is present more than once in the HTTP request. - self.__attributes[name] = value - return self + """ + + return {key: value[0] for key, value in self.__payload.get([ns.HEADERS], {}).items()} + + def get_headers_array(self) -> dict: + """ + Get all HTTP headers. + + Each parameter value is returned as a list. + + """ + + return {key: list(values) for key, values in self.__payload.get([ns.HEADERS], {}).items()} + + def has_body(self) -> bool: + """Check if the HTTP request body has content.""" + + return self.__payload.get([ns.BODY], b'') != b'' + + def get_body(self) -> bytes: + """Get the HTTP request body.""" + + return self.__payload.get([ns.BODY], b'') + + def has_file(self, name: str) -> bool: + """ + Check if a file was uploaded in current request. + + :param name: A file name. + + """ + + return name in self.__files + + def get_file(self, name: str) -> File: + """ + Get an uploaded file. + + :param name: The name of the file. + + """ + + # When the file doesnt exist return an empty file + if not self.has_file(name): + return File(name) + + return self.__files[name] + + def get_files(self) -> List[File]: + """Get uploaded files.""" + + return list(self.__files.values()) diff --git a/kusanagi/sdk/response.py b/kusanagi/sdk/response.py index 7f1b5d6..0c73290 100644 --- a/kusanagi/sdk/response.py +++ b/kusanagi/sdk/response.py @@ -5,153 +5,298 @@ # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. -from ..errors import KusanagiError -from ..logging import RequestLogger +from __future__ import annotations -from .base import Api -from .http.request import HttpRequest -from .http.response import HttpResponse +from typing import Any +from typing import List -NO_RETURN_VALUE = object() +from .api import Api +from .lib.payload import ns +from .lib.payload.request import HttpRequestPayload +from .lib.payload.response import HttpResponsePayload +from .lib.payload.transport import TransportPayload +from .request import HttpRequest +from .transport import Transport -class NoReturnValueDefined(KusanagiError): - """Raised when no return value is defined or available for a Service.""" +class Response(Api): + """Response API class for the middleware component.""" - message = 'No return value defined on {} for action: "{}"' + def get_gateway_protocol(self) -> str: + """Get the protocol implemented by the gateway handling current request.""" - def __init__(self, service, version, action): - server = '"{}" ({})'.format(service, version) - super().__init__(self.message.format(server, action)) + return self._command.get([ns.META, ns.PROTOCOL], '') + def get_gateway_address(self) -> str: + """Get public gateway address.""" -class Response(Api): - """Response API class for Middleware component.""" - - def __init__(self, transport, *args, **kwargs): - super().__init__(*args, **kwargs) - self._logger = RequestLogger(kwargs.get('rid'), 'kusanagi.sdk') - self.__attributes = kwargs['attributes'] - self.__gateway_protocol = kwargs.get('gateway_protocol') - self.__gateway_addresses = kwargs.get('gateway_addresses') - - http_request = kwargs.get('http_request') - if http_request: - self.__http_request = HttpRequest(**http_request) - else: - self.__http_request = None + return self._command.get([ns.META, ns.GATEWAY], ['', ''])[1] - http_response = kwargs.get('http_response') - if http_response: - self.__http_response = HttpResponse(**http_response) - else: - self.__http_response = None + def get_request_attribute(self, name: str, default: str = '') -> str: + """ + Get a request attribute value. + + :param name: An attribute name. + :param default: An optional default value to use when attribute is not defined. + + """ - self.__transport = transport - self.__return_value = kwargs.get('return_value', NO_RETURN_VALUE) + return self._command.get([ns.META, ns.ATTRIBUTES, name], default) - def get_gateway_protocol(self): - """Get the protocol implemented by the Gateway handling current request. + def get_request_attributes(self) -> dict: + """Get all the request attributes.""" - :rtype: str + return self._command.get([ns.META, ns.ATTRIBUTES], {}) + def get_http_request(self) -> HttpRequest: + """Get HTTP request for current request.""" + + payload = HttpRequestPayload(self._command.get([ns.REQUEST], {})) + return HttpRequest(payload) + + def get_http_response(self) -> HttpResponse: + """Get HTTP response for current request.""" + + payload = HttpResponsePayload(self._reply.get([ns.RESPONSE], {})) + payload.set_reply(self._reply) + return HttpResponse(payload) + + def has_return(self) -> bool: """ + Check if there is a return value. - return self.__gateway_protocol + Return value is available when the initial service that is called + has a return value, and returned a value in its command reply. - def get_gateway_address(self): - """Get public gateway address. + """ - :rtype: str + return self._command.exists([ns.RETURN]) + def get_return(self) -> Any: """ + Get the return value returned by the called service. + + :raises: ValueError - return self.__gateway_addresses[1] + """ - def get_http_request(self): - """Get HTTP request for current request. + if not self.has_return(): + service, version, action = self._command.get([ns.TRANSPORT, ns.META, ns.ORIGIN]) + raise ValueError(f'No return value defined on "{service}" ({version}) for action: "{action}"') - :rtype: HttpRequest + return self._command.get([ns.RETURN]) + def get_transport(self) -> Transport: + """Get the Transport object.""" + + payload = TransportPayload(self._command.get_transport_data()) + return Transport(payload) + + +class HttpResponse(object): + """HTTP response class.""" + + def __init__(self, payload: HttpResponsePayload): """ + Constructor. - return self.__http_request + :param payload: The payload with the HTTP response data. - def get_http_response(self): - """Get HTTP response for current request. + """ - :rtype: HttpResponse + self.__payload = payload + self.__headers = {name.upper(): list(values) for name, values in payload.get([ns.HEADERS], {}).items()} + def is_protocol_version(self, version: str) -> bool: """ + Check if the response uses the given HTTP version. - return self.__http_response + :param version: The HTTP version. - def has_return(self): - """Check if there is a return value. + """ - Return value is available when the initial Service that is called - has a return value, and returned a value in its command reply. + return self.get_protocol_version() == version - :rtype: bool + def get_protocol_version(self) -> str: + """Get the HTTP version.""" + return self.__payload.get([ns.VERSION], '') + + def set_protocol_version(self, version: str) -> HttpResponse: """ + Set the HTTP version to the given protocol version. - return self.__return_value != NO_RETURN_VALUE + :param version: The HTTP version. - def get_return(self): - """Get the return value returned by the called Service. + """ - :raises: NoReturnValueDefined + self.__payload.set([ns.VERSION], version) + return self - :rtype: object + def is_status(self, status: str) -> bool: + """ + Check if the response uses the given status. + + :param status: The HTTP status. """ - if not self.has_return(): - transport = self.get_transport() - origin = transport.get_origin_service() - if not origin: - # During testing there is no origin - return + return self.get_status() == status + + def get_status(self) -> str: + """Get the HTTP status.""" - raise NoReturnValueDefined(*origin) + return self.__payload.get([ns.STATUS], '') - return self.__return_value + def get_status_code(self) -> int: + """Get HTTP status code.""" - def get_transport(self): - """Gets the Transport object. + # Get the status code from the status message + try: + return int(self.get_status().split(' ', 1)[0]) + except (IndexError, ValueError): + return 0 - Returns the Transport object returned by the Services. + def get_status_text(self) -> str: + """Get HTTP status text.""" - :rtype: `Transport` + try: + return self.get_status().split(' ', 1)[1] + except IndexError: + return '' + def set_status(self, code: int, text: str) -> HttpResponse: """ + Set the HTTP status to the given status. - return self.__transport + :param code: The HTTP status code. + :param text: The HTTP status text. - def get_request_attribute(self, name, default=''): """ - Get a request attribute value. - :param name: Attribute name. - :type name: str - :param default: Default value to use when attribute is not defined. - :type default: str + self.__payload.set([ns.STATUS], f'{code} {text}') + return self + + def has_header(self, name: str) -> bool: + """ + Check if the HTTP header is defined. + + Header name is case insensitive. + + :param name: The HTTP header name. + + """ + + return name.upper() in self.__headers + + def get_header(self, name: str, default: str = '') -> str: + """ + Get an HTTP header value. + + Returns the HTTP header with the given name, or and empty string if not defined. + + Header name is case insensitive. + + :param name: The HTTP header name. + :param default: An optional default value. + + """ + + name = name.upper() + return self.__headers[name][0] if self.__headers.get(name) else default + + def get_header_array(self, name: str, default: List[str] = None) -> List[str]: + """ + Gets an HTTP header value as an array. + + Header name is case insensitive. + + :param name: The HTTP header name. + :param default: An optional default value. :raises: TypeError - :rtype: str + """ + + if default is None: + default = [] + elif not isinstance(default, list): + raise TypeError('Default value is not a list') + + name = name.upper() + return list(self.__headers[name]) if self.__headers.get(name) else default + + def get_headers(self) -> dict: + """Get all HTTP headers.""" + + return {key: values[0] for key, values in self.__payload.get([ns.HEADERS], {}).items()} + + def get_headers_array(self) -> dict: + """Get all HTTP headers.""" + + return {key: list(values) for key, values in self.__payload.get([ns.HEADERS], {}).items()} + + def set_header(self, name: str, value: str, overwrite: bool = False) -> HttpResponse: + """ + Set an HTTP header with the given name and value. + + :param name: The HTTP header. + :param value: The header value. + :param overwrite: Optional value to allow existing headers to be overwritten. """ - return self.__attributes.get(name, default) + # Make sure the value is a string + if not isinstance(value, str): + value = str(value) + + # If it exists get the original header name from the payload headers + uppercase_name = name.upper() + original_name = None + for header_name in self.__payload.get([ns.HEADERS]).keys(): + if header_name.upper() == uppercase_name: + original_name = header_name + break + + # When a similar header exists replace the old header name with the new name + # and add the new value. This can happen when the header name casing is different. + if original_name and original_name != name: + values = [] if overwrite else list(self.__headers[uppercase_name]) + values.append(value) + # Remove the header with the previous name + self.__payload.delete([ns.HEADERS, original_name]) + # Add the header with the new name + self.__payload.set([ns.HEADERS, name], values) + elif overwrite: + self.__payload.set([ns.HEADERS, name], [value]) + else: + self.__payload.append([ns.HEADERS, name], value) + + # Update the list of cached headers + if uppercase_name in self.__headers and not overwrite: + self.__headers[uppercase_name].append(value) + else: + self.__headers[uppercase_name] = [value] + + return self + + def has_body(self) -> bool: + """Check if the response has content.""" + + return self.get_body() != b'' + + def get_body(self) -> bytes: + """Get the response body content.""" + + return self.__payload.get([ns.BODY], b'') - def get_request_attributes(self): + def set_body(self, content: bytes = b'') -> HttpResponse: """ - Get all request attributes. + Set the content of the HTTP response body. - :rtype: dict + :param content: The content for the HTTP response body. """ - return self.__attributes + self.__payload.set([ns.BODY], content) + return self diff --git a/kusanagi/sdk/runner.py b/kusanagi/sdk/runner.py deleted file mode 100644 index 59c999d..0000000 --- a/kusanagi/sdk/runner.py +++ /dev/null @@ -1,463 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -import asyncio -import functools -import inspect -import json -import logging -import os - -import click -import kusanagi.payload -import zmq.asyncio - -from ..errors import KusanagiError -from ..logging import disable_logging -from ..logging import setup_kusanagi_logging -from ..logging import SYSLOG_NUMERIC -from ..utils import EXIT_ERROR -from ..utils import EXIT_OK -from ..utils import install_uvevent_loop -from ..utils import ipc -from ..utils import RunContext -from ..utils import tcp - -LOG = logging.getLogger(__name__) - - -def key_value_strings_callback(ctx, param, values): - """Option callback to validate a list of key/value arguments. - - Converts 'NAME=VALUE' cli parameters to a dictionary. - - :rtype: dict - - """ - - params = {} - if not values: - return params - - for value in values: - parts = value.split('=', 1) - if len(parts) != 2: - raise click.BadParameter('Invalid parameter format') - - param_name, param_value = parts - params[param_name] = param_value - - return params - - -def apply_cli_options(run_method): - """Decorator to apply command line options to `run` method. - - Run is called after all command line options are parsed and validated. - - """ - - @functools.wraps(run_method) - def wrapper(self): - # Create a command object to run the SDK component. - # Component caller source file name is used as command name. - caller_frame = inspect.getouterframes(inspect.currentframe())[2] - self.source_file = caller_frame[1] - command = click.command(name=self.source_file, help=self.help) - - # Run method is called when command line options are valid - start_component = command(functools.partial(run_method, self)) - - # Apply CLI options to command - for option in self.get_argument_options(): - start_component = option(start_component) - - if not os.environ.get('TESTING'): - # Run SDK component - start_component() - else: - # Allow unit tests to properly parse CLI arguments - return start_component - - return wrapper - - -class ComponentRunner(object): - """Component runner. - - This class allows to isolate Component implementation details and - keep the Component itself consisten with KUSANAGI SDK specifications. - - """ - - def __init__(self, component, server_cls, help): - """Constructor. - - :param component: The component to run. - :type component: Component - :param server_cls: Class for the component server. - :param server_cls: ComponentServer - :param help: Help text for the CLI command. - :type help: str - - """ - - self.__component = component - self.__startup_callback = None - self.__shutdown_callback = None - self.__error_callback = None - self._args = {} - self.source_file = None - self.loop = None - self.callbacks = None - self.server_cls = server_cls - self.help = help - - @property - def args(self): - """Command line arguments. - - Command line arguments are initialized during `run` - with the values used to run the component. - - :rtype: dict - - """ - - return self._args - - @property - def socket_name(self): - """IPC socket name. - - :rtype: str - - """ - - return self._args.get('socket') or self.get_default_socket_name() - - @property - def tcp_port(self): - """TCP port number. - - :rtype: str or None - - """ - - return self._args.get('tcp') - - @property - def name(self): - """Component name. - - :rtype: str - - """ - - return self._args['name'] - - @property - def version(self): - """Component version. - - :rtype: str - - """ - - return self._args['version'] - - @property - def component_type(self): - """Component type. - - :rtype: str - - """ - - return self._args['component'] - - @property - def debug(self): - """Check if debug is enabled for current component. - - :rtype: bool - - """ - - return self._args.get('debug', False) - - @property - def compact_names(self): - """Check if payloads should use compact names. - - :rtype: bool - - """ - - return not self._args.get('disable_compact_names', False) - - def get_default_socket_name(self): - """Get a default socket name to use when socket name is missing. - - :rtype: str - - """ - - # Remove 'ipc://' from string to get socket name - return ipc(self.component_type, self.name, self.version)[6:] - - def get_argument_options(self): - """Get command line argument options. - - :rtype: list. - - """ - - return [ - click.option( - '-A', '--action', - help=( - 'Name of the action to call when request message ' - 'is given as JSON through stdin.' - ), - ), - click.option( - '-c', '--component', - type=click.Choice(['service', 'middleware']), - help='Component type.', - required=True, - ), - click.option( - '-d', '--disable-compact-names', - is_flag=True, - help='Use full property names in payloads.', - ), - click.option( - '-D', '--debug', - is_flag=True, - help='Enable component debug.', - ), - click.option( - '-L', '--log-level', - help=( - 'Enable a logging using a numeric Syslog severity ' - 'value to set the level.' - ), - type=click.IntRange(0, 7, clamp=True), - ), - click.option( - '-n', '--name', - required=True, - help='Component name.', - ), - click.option( - '-p', '--framework-version', - required=True, - help='KUSANAGI framework version.', - ), - click.option( - '-s', '--socket', - help='IPC socket name.', - ), - click.option( - '-t', '--tcp', - help='TCP port to use when IPC socket is not used.', - type=click.INT, - ), - click.option( - '-T', '--timeout', - help='Process execution timeout per request in milliseconds.', - type=click.INT, - default=30000, - ), - click.option( - '-v', '--version', - required=True, - help='Component version.', - ), - click.option( - '-V', '--var', - multiple=True, - callback=key_value_strings_callback, - help='Component variables.', - ), - ] - - def set_startup_callback(self, callback): - """Set a callback to be run during startup. - - :param callback: A callback to run on startup. - :type callback: function - - """ - - self.__startup_callback = callback - - def set_shutdown_callback(self, callback): - """Set a callback to be run during shutdown. - - :param callback: A callback to run on shutdown. - :type callback: function - - """ - - self.__shutdown_callback = callback - - def set_error_callback(self, callback): - """Set a callback to be run on message callback errors. - - :param callback: A callback to run on message callback errors. - :type callback: function - - """ - - self.__error_callback = callback - - def set_callbacks(self, callbacks): - """Set message callbacks for each component action. - - :params callbacks: Callbacks for each action. - :type callbacks: dict - - """ - - self.callbacks = callbacks - - @apply_cli_options - def run(self, **kwargs): - """Run SDK component server. - - Calling this method checks command line arguments before - component server starts, and then blocks the caller script - until component server finishes. - - """ - - # Initialize component logging - log_level = kwargs.get('log_level') - if log_level in SYSLOG_NUMERIC: - setup_kusanagi_logging( - self.server_cls.get_type(), - kwargs['name'], - kwargs['version'], - kwargs['framework_version'], - SYSLOG_NUMERIC[log_level], - ) - else: - # No logs are printed when log-level is not available - disable_logging() - - # Standard input is read only when action name is given - message = {} - if kwargs.get('action'): - contents = click.get_text_stream('stdin', encoding='utf8').read() - - # Add JSON file contents to message - try: - message['payload'] = json.loads(contents) - except: - LOG.exception('Stdin input value is not valid JSON') - os._exit(EXIT_ERROR) - - # Add action name to message - message['action'] = kwargs['action'] - - # Skip zeromq initialization when transport payload is given - # as an input file in the CLI. - if message: - # Use standard event loop to run component server without zeromq - self.loop = asyncio.get_event_loop() - else: - # Set zeromq event loop when component is run as server - install_uvevent_loop() - self.loop = zmq.asyncio.ZMQEventLoop() - asyncio.set_event_loop(self.loop) - - self._args = kwargs - - # When compact mode is enabled use long payload field names - if not self.compact_names: - kusanagi.payload.DISABLE_FIELD_MAPPINGS = True - - # Create a run context - ctx = RunContext(self.loop) - - # Create component server and run it as a task - server = self.server_cls( - self.callbacks, - self.args, - debug=self.debug, - source_file=self.source_file, - error_callback=self.__error_callback, - ) - - LOG.debug('Using PID: "%s"', os.getpid()) - - if message: - server_task = self.loop.create_task(server.process_input(message)) - else: - # Create channel for TCP or IPC conections - if self.tcp_port: - channel = tcp('127.0.0.1:{}'.format(self.tcp_port)) - else: - # Abstract domain unix socket - channel = 'ipc://{}'.format(self.socket_name) - - server_task = self.loop.create_task(server.listen(channel)) - - ctx.tasks.append(server_task) - - # By default exit successfully - exit_code = EXIT_OK - - # Call startup callback - if self.__startup_callback: - LOG.info('Running startup callback ...') - try: - self.__startup_callback(self.__component) - except: - LOG.exception('Startup callback failed') - LOG.error('Component failed') - exit_code = EXIT_ERROR - - # Run component server - if exit_code != EXIT_ERROR: - try: - self.loop.run_until_complete(ctx.run()) - except zmq.error.ZMQError as err: - exit_code = EXIT_ERROR - if err.errno == 98: - LOG.error('Address unavailable: "%s"', self.socket_name) - else: - LOG.error(err.strerror) - - LOG.error('Component failed') - except KusanagiError as err: - exit_code = EXIT_ERROR - LOG.error(err) - LOG.error('Component failed') - except Exception: - exit_code = EXIT_ERROR - LOG.exception('Component failed') - finally: - # Finally close the event loop - self.loop.close() - - # Call shutdown callback - if self.__shutdown_callback: - LOG.info('Running shutdown callback ...') - try: - self.__shutdown_callback(self.__component) - except: - LOG.exception('Shutdown callback failed') - LOG.error('Component failed') - exit_code = EXIT_ERROR - - if exit_code == EXIT_OK: - LOG.info('Operation complete') - - os._exit(exit_code) diff --git a/kusanagi/sdk/schema/__init__.py b/kusanagi/sdk/schema/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/kusanagi/sdk/schema/action.py b/kusanagi/sdk/schema/action.py deleted file mode 100644 index 84c3746..0000000 --- a/kusanagi/sdk/schema/action.py +++ /dev/null @@ -1,564 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from .error import ServiceSchemaError -from .param import ParamSchema -from .file import FileSchema -from ...payload import get_path -from ...payload import path_exists -from ...payload import Payload - - -def entity_from_payload(entity_payload, entity=None): - """Create a new entity definition object from a payload. - - :param entity_payload: Entity definition payload. - :type entity_payload: dict - - :rtype: dict - - """ - - entity = entity or {} - if not entity_payload: - return entity - - # Add validate field only to top level entity - if not entity: - entity['validate'] = get_path(entity_payload, 'validate', False) - - # Add fields to entity - if path_exists(entity_payload, 'field'): - entity['field'] = [] - for payload in get_path(entity_payload, 'field'): - entity['field'].append({ - 'name': get_path(payload, 'name'), - 'type': get_path(payload, 'type', 'string'), - 'optional': get_path(payload, 'optional', False), - }) - - # Add field sets to entity - if path_exists(entity_payload, 'fields'): - entity['fields'] = [] - for payload in get_path(entity_payload, 'fields'): - fieldset = { - 'name': get_path(payload, 'name'), - 'optional': get_path(payload, 'optional', False), - } - - # Add inner field and fieldsets - if path_exists(payload, 'field') or path_exists(payload, 'fields'): - fieldset = entity_from_payload(payload, fieldset) - - entity['fields'].append(fieldset) - - return entity - - -def relations_from_payload(relations_payload): - """Create a new relations definition list from a payload. - - :param relations_payload: Relation definitions from payload. - :type relations_payload: list - - :rtype: list - - """ - - relations = [] - if not relations_payload: - return relations - - for relation in relations_payload: - relations.append([ - get_path(relation, 'type', 'one'), - get_path(relation, 'name'), - ]) - - return relations - - -class ActionSchemaError(ServiceSchemaError): - """Error class for schema action errors.""" - - -class ActionSchema(object): - """Action schema in the framework.""" - - def __init__(self, name, payload): - self.__name = name - self.__payload = Payload(payload) - self.__params = self.__payload.get('params', {}) - self.__files = self.__payload.get('files', {}) - self.__tags = self.__payload.get('tags', []) - - def get_timeout(self): - """Get the maximum execution time defined in milliseconds for the action. - - :rtype: int - - """ - - return self.__payload.get('timeout', 30000) - - def is_deprecated(self): - """Check if action has been deprecated. - - :rtype: bool - - """ - - return self.__payload.get('deprecated', False) - - def is_collection(self): - """Check if the action returns a collection of entities. - - :rtype: bool - - """ - - return self.__payload.get('collection', False) - - def get_name(self): - """Get action name. - - :rtype: str - - """ - - return self.__name - - def get_entity_path(self): - """Get path to the entity. - - :rtype: str - - """ - - return self.__payload.get('entity_path', '') - - def get_path_delimiter(self): - """Get delimiter to use for the entity path. - - :rtype: str - - """ - - return self.__payload.get('path_delimiter', '/') - - def resolve_entity(self, data): - """Get entity from data. - - Get the entity part, based upon the `entity-path` and `path-delimiter` - properties in the action configuration. - - :param data: Object to get entity from. - :type data: dict - - :raises: ActionSchemaError - - :rtype: dict - - """ - - path = self.get_entity_path() - # When there is no path no traversing is done - if not path: - return data - - try: - return get_path(data, path, delimiter=self.get_path_delimiter()) - except KeyError: - error = 'Cannot resolve entity for action: {}' - raise ActionSchemaError(error.format(self.get_name())) - - def has_entity_definition(self): - """Check if an entity definition exists for the action. - - :rtype: bool - - """ - - return self.__payload.path_exists('entity') - - def get_entity(self): - """Get the entity definition as an object. - - :rtype: dict - - """ - - return entity_from_payload(self.__payload.get('entity', None)) - - def has_relations(self): - """Check if any relations exists for the action. - - :rtype: bool - - """ - - return self.__payload.path_exists('relations') - - def get_relations(self): - """Get action relations. - - Each item is an array containins the relation type - and the Service name. - - :rtype: list - - """ - - return relations_from_payload(self.__payload.get('relations', None)) - - def has_call(self, name, version=None, action=None): - """Check if a run-time call exists for a Service. - - :param name: Service name. - :type name: str - :param version: Optional Service version. - :type version: str - :param action: Optional action name. - :type action: str - - :rtype: bool - - """ - - for call in self.get_calls(): - if call[0] != name: - continue - - if version and call[1] != version: - continue - - if action and call[2] != action: - continue - - # When all given arguments match the call return True - return True - - # By default call does not exist - return False - - def has_calls(self): - """Check if any run-time call exists for the action. - - :rtype: bool - - """ - - return self.__payload.path_exists('calls') - - def get_calls(self): - """Get Service run-time calls. - - Each call items is a list containing the Service name, - the Service version and the action name. - - :rtype: list - - """ - - return self.__payload.get('calls', []) - - def has_defer_call(self, name, version=None, action=None): - """Check if a deferred call exists for a Service. - - :param name: Service name. - :type name: str - :param version: Optional Service version. - :type version: str - :param action: Optional action name. - :type action: str - - :rtype: bool - - """ - - for call in self.get_defer_calls(): - if call[0] != name: - continue - - if version and call[1] != version: - continue - - if action and call[2] != action: - continue - - # When all given arguments match the call return True - return True - - # By default call does not exist - return False - - def has_defer_calls(self): - """Check if any deferred call exists for the action. - - :rtype: bool - - """ - - return self.__payload.path_exists('deferred_calls') - - def get_defer_calls(self): - """Get Service deferred calls. - - Each call items is a list containing the Service name, - the Service version and the action name. - - :rtype: list - - """ - - return self.__payload.get('deferred_calls', []) - - def has_remote_call(self, address, name=None, version=None, action=None): - """Check if a remote call exists for a Service. - - :param address: Gateway address. - :type address: str - :param name: Optional Service name. - :type name: str - :param version: Optional Service version. - :type version: str - :param action: Optional action name. - :type action: str - - :rtype: bool - - """ - - for call in self.get_remote_calls(): - if call[0] != address: - continue - - if name and call[1] != name: - continue - - if version and call[2] != version: - continue - - if action and call[3] != action: - continue - - # When all given arguments match the call return True - return True - - # By default call does not exist - return False - - def has_remote_calls(self): - """Check if any remote call exists for the action. - - :rtype: bool - - """ - - return self.__payload.path_exists('remote_calls') - - def get_remote_calls(self): - """Get remote Service calls. - - Each remote call items is a list containing the public address - of the Gateway, the Service name, the Service version and the - action name. - - :rtype: list - - """ - - return self.__payload.get('remote_calls', []) - - def has_return(self): - """Check if a return value is defined for the action. - - :rtype: bool - - """ - - return self.__payload.path_exists('return') - - def get_return_type(self): - """Get the data type of the returned action value. - - :raises: ActionSchemaError - - :rtype: str - - """ - - if not self.__payload.path_exists('return/type'): - name = self.get_name() - error = 'Return value not defined for action: {}' - raise ActionSchemaError(error.format(name)) - - return self.__payload.get('return/type') - - def get_params(self): - """Get the parameters names defined for the action. - - :rtype: list - - """ - - return list(self.__params.keys()) - - def has_param(self, name): - """Check that a parameter schema exists. - - :param name: Parameter name. - :type name: str - - :rtype: bool - - """ - - return name in self.__params - - def get_param_schema(self, name): - """Get schema for a parameter. - - :param name: Parameter name. - :type name: str - - :raises: ActionSchemaError - - :rtype: ParamSchema - - """ - - if not self.has_param(name): - error = 'Cannot resolve schema for parameter: {}' - raise ActionSchemaError(error.format(name)) - - return ParamSchema(name, self.__params[name]) - - def get_files(self): - """Get the file parameter names defined for the action. - - :rtype: list - - """ - - return list(self.__files.keys()) - - def has_file(self, name): - """Check that a file parameter schema exists. - - :param name: File parameter name. - :type name: str - - :rtype: bool - - """ - - return name in self.__files - - def get_file_schema(self, name): - """Get schema for a file parameter. - - :param name: File parameter name. - :type name: str - - :raises: ActionSchemaError - - :rtype: FileSchema - - """ - - if not self.has_file(name): - error = 'Cannot resolve schema for file parameter: {}' - raise ActionSchemaError(error.format(name)) - - return FileSchema(name, self.__files[name]) - - def has_tag(self, name): - """Check that a tag is defined for the action. - - The tag name is case sensitive. - - :param name: Tag name. - :type name: str - - :rtype: bool - - """ - - return name in self.__tags - - def get_tags(self): - """Get the tags defined for the action. - - :rtype: list - - """ - - return list(self.__tags) - - def get_http_schema(self): - """Get HTTP action schema. - - :rtype: HttpActionSchema - - """ - - return HttpActionSchema(self.__payload.get('http', {})) - - -class HttpActionSchema(object): - """HTTP semantics of an action schema in the framework.""" - - def __init__(self, payload): - self.__payload = Payload(payload) - - def is_accessible(self): - """Check if the Gateway has access to the action. - - :rtype: bool - - """ - - return self.__payload.get('gateway', True) - - def get_method(self): - """Get HTTP method for the action. - - :rtype: str - - """ - - return self.__payload.get('method', 'get').lower() - - def get_path(self): - """Get URL path for the action. - - :rtype: str - - """ - - return self.__payload.get('path', '') - - def get_input(self): - """Get default location of parameters for the action. - - :rtype: str - - """ - - return self.__payload.get('input', 'query') - - def get_body(self): - """Get expected MIME type of the HTTP request body. - - Result may contain a comma separated list of MIME types. - - :rtype: str - - """ - - return ','.join(self.__payload.get('body', ['text/plain'])) diff --git a/kusanagi/sdk/schema/file.py b/kusanagi/sdk/schema/file.py deleted file mode 100644 index f8fe0ad..0000000 --- a/kusanagi/sdk/schema/file.py +++ /dev/null @@ -1,130 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -import sys - -from ...payload import Payload - - -class FileSchema(object): - """File parameter schema in the framework.""" - - def __init__(self, name, payload): - self.__name = name - self.__payload = Payload(payload) - - def get_name(self): - """Get file parameter name. - - :rtype: str - - """ - - return self.__name - - def get_mime(self): - """Get mime type. - - :rtype: str - - """ - - return self.__payload.get('mime', 'text/plain') - - def is_required(self): - """Check if file parameter is required. - - :rtype: bool - - """ - - return self.__payload.get('required', False) - - def get_max(self): - """Get minimum file size allowed for the parameter. - - Returns 0 if not defined. - - :rtype: int - - """ - - return self.__payload.get('max', sys.maxsize) - - def is_exclusive_max(self): - """Check if maximum size is inclusive. - - When max is not defined inclusive is False. - - :rtype: bool - - """ - - if not self.__payload.path_exists('max'): - return False - - return self.__payload.get('exclusive_max', False) - - def get_min(self): - """Get minimum file size allowed for the parameter. - - Returns 0 if not defined. - - :rtype: int - - """ - - return self.__payload.get('min', 0) - - def is_exclusive_min(self): - """Check if minimum size is inclusive. - - When min is not defined inclusive is False. - - :rtype: bool - - """ - - if not self.__payload.path_exists('min'): - return False - - return self.__payload.get('exclusive_min', False) - - def get_http_schema(self): - """Get HTTP file param schema. - - :rtype: HttpFileSchema - - """ - - return HttpFileSchema(self.get_name(), self.__payload.get('http', {})) - - -class HttpFileSchema(object): - """HTTP semantics of a file parameter schema in the framework.""" - - def __init__(self, name, payload): - self.__name = name - self.__payload = Payload(payload) - - def is_accessible(self): - """Check if the Gateway has access to the parameter. - - :rtype: bool - - """ - - return self.__payload.get('gateway', True) - - def get_param(self): - """Get name as specified via HTTP to be mapped to the name property. - - :rtype: str - - """ - - return self.__payload.get('param', self.__name) diff --git a/kusanagi/sdk/schema/param.py b/kusanagi/sdk/schema/param.py deleted file mode 100644 index 484caa9..0000000 --- a/kusanagi/sdk/schema/param.py +++ /dev/null @@ -1,270 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -import sys - -from ...payload import Payload - - -class ParamSchema(object): - """Parameter schema in the framework.""" - - def __init__(self, name, payload): - self.__name = name - self.__payload = Payload(payload) - - def get_name(self): - """Get parameter name. - - :rtype: str - - """ - - return self.__name - - def get_type(self): - """Get parameter value type. - - :rtype: str - - """ - - return self.__payload.get('type', 'string') - - def get_format(self): - """Get parameter value format. - - :rtype: str - - """ - - return self.__payload.get('format', '') - - def get_array_format(self): - """Get format for the parameter if the type property is set to "array". - - Formats: - - "csv" for comma separated values (default) - - "ssv" for space separated values - - "tsv" for tab separated values - - "pipes" for pipe separated values - - "multi" for multiple parameter instances instead of multiple - values for a single instance. - - :rtype: str - - """ - - return self.__payload.get('array_format', 'csv') - - def get_pattern(self): - """Get ECMA 262 compliant regular expression to validate the parameter. - - :rtype: str - - """ - - return self.__payload.get('pattern', '') - - def allow_empty(self): - """Check if the parameter allows an empty value. - - :rtype: bool - - """ - - return self.__payload.get('allow_empty', False) - - def has_default_value(self): - """Check if the parameter has a default value defined. - - :rtype: bool - - """ - - return self.__payload.path_exists('default_value') - - def get_default_value(self): - """Get default value for parameter. - - :rtype: object - - """ - - return self.__payload.get('default_value', None) - - def is_required(self): - """Check if parameter is required. - - :rtype: bool - - """ - - return self.__payload.get('required', False) - - def get_items(self): - """Get JSON schema with items object definition. - - An empty dicitonary is returned when parameter is not an "array", - otherwise the result contains a dictionary with a JSON schema - definition. - - :rtype: dict - - """ - - if self.get_type() != 'array': - return {} - - return self.__payload.get('items', {}) - - def get_max(self): - """Get maximum value for parameter. - - :rtype: int - - """ - - return self.__payload.get('max', sys.maxsize) - - def is_exclusive_max(self): - """Check if max value is inclusive. - - When max is not defined inclusive is False. - - :rtype: bool - - """ - - if not self.__payload.path_exists('max'): - return False - - return self.__payload.get('exclusive_max', False) - - def get_min(self): - """Get minimum value for parameter. - - :rtype: int - - """ - - return self.__payload.get('min', -sys.maxsize - 1) - - def is_exclusive_min(self): - """Check if minimum value is inclusive. - - When min is not defined inclusive is False. - - :rtype: bool - - """ - - if not self.__payload.path_exists('min'): - return False - - return self.__payload.get('exclusive_min', False) - - def get_max_items(self): - """Get maximum number of items allowed for the parameter. - - Result is -1 when type is not "array" or values is not defined. - - :rtype: int - - """ - - if self.get_type() != 'array': - return -1 - - return self.__payload.get('max_items', -1) - - def get_min_items(self): - """Get minimum number of items allowed for the parameter. - - Result is -1 when type is not "array" or values is not defined. - - :rtype: int - - """ - - if self.get_type() != 'array': - return -1 - - return self.__payload.get('min_items', -1) - - def has_unique_items(self): - """Check if param must contain a set of unique items. - - :rtype: bool - - """ - - return self.__payload.get('unique_items', False) - - def get_enum(self): - """Get the set of unique values that parameter allows. - - :rtype: list - - """ - - return self.__payload.get('enum', []) - - def get_multiple_of(self): - """Get value that parameter must be divisible by. - - Result is -1 when this property is not defined. - - :rtype: int - - """ - - return self.__payload.get('multiple_of', -1) - - def get_http_schema(self): - """Get HTTP param schema. - - :rtype: HttpParamSchema - - """ - - return HttpParamSchema(self.get_name(), self.__payload.get('http', {})) - - -class HttpParamSchema(object): - """HTTP semantics of a parameter schema in the framework.""" - - def __init__(self, name, payload): - self.__name = name - self.__payload = Payload(payload) - - def is_accessible(self): - """Check if the Gateway has access to the parameter. - - :rtype: bool - - """ - - return self.__payload.get('gateway', True) - - def get_input(self): - """Get location of the parameter. - - :rtype: str - - """ - - return self.__payload.get('input', 'query') - - def get_param(self): - """Get name as specified via HTTP to be mapped to the name property. - - :rtype: str - - """ - - return self.__payload.get('param', self.__name) diff --git a/kusanagi/sdk/schema/service.py b/kusanagi/sdk/schema/service.py deleted file mode 100644 index b31f433..0000000 --- a/kusanagi/sdk/schema/service.py +++ /dev/null @@ -1,120 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from .action import ActionSchema -from .error import ServiceSchemaError -from ... payload import Payload - - -class ServiceSchema(object): - """Service schema in the framework.""" - - def __init__(self, name, version, payload): - self.__name = name - self.__version = version - self.__payload = Payload(payload) - self.__actions = self.__payload.get('actions', {}) - - def get_name(self): - """Get Service name. - - :rtype: str - - """ - - return self.__name - - def get_version(self): - """Get Service version. - - :rtype: str - - """ - - return self.__version - - def has_file_server(self): - """Check if service has a file server. - - :rtype: bool - - """ - - return self.__payload.get('files', False) - - def get_actions(self): - """Get Service action names. - - :rtype: list - - """ - - return list(self.__actions.keys()) - - def has_action(self, name): - """Check if an action exists for current Service schema. - - :param name: Action name. - :type name: str - - :rtype: bool - - """ - - return name in self.__actions - - def get_action_schema(self, name): - """Get schema for an action. - - :param name: Action name. - :type name: str - - :raises: ServiceSchemaError - - :rtype: ActionSchema - - """ - - if not self.has_action(name): - error = 'Cannot resolve schema for action: {}'.format(name) - raise ServiceSchemaError(error) - - return ActionSchema(name, self.__actions[name]) - - def get_http_schema(self): - """Get HTTP Service schema. - - :rtype: HttpServiceSchema - - """ - - return HttpServiceSchema(self.__payload.get('http', {})) - - -class HttpServiceSchema(object): - """HTTP semantics of a Service schema in the framework.""" - - def __init__(self, payload): - self.__payload = Payload(payload) - - def is_accessible(self): - """Check if the Gateway has access to the Service. - - :rtype: bool - - """ - - return self.__payload.get('gateway', True) - - def get_base_path(self): - """Get base HTTP path for the Service. - - :rtype: str - - """ - - return self.__payload.get('base_path', '') diff --git a/kusanagi/sdk/service.py b/kusanagi/sdk/service.py index 2417d25..eec3576 100644 --- a/kusanagi/sdk/service.py +++ b/kusanagi/sdk/service.py @@ -5,38 +5,132 @@ # # For the full copyright and license information, please view the LICENSE # file that was distributed with this source code. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .action import ActionSchema from .component import Component -from .runner import ComponentRunner -from ..service import ServiceServer +from .lib.payload import ns +if TYPE_CHECKING: + from typing import Awaitable + from typing import Callable + from typing import List + from typing import Union -class Service(Component): - """KUSANAGI SDK Service component.""" + from .action import Action + from .lib.asynchronous import AsyncAction + from .lib.payload.service import HttpServiceSchemaPayload + from .lib.payload.service import ServiceSchemaPayload - def __init__(self): - super().__init__() - # Create a component runner for the service server - help = 'Service component action to process application logic' - self._runner = ComponentRunner(self, ServiceServer, help) + AsyncCallback = Callable[[AsyncAction], Awaitable[AsyncAction]] + Callback = Callable[[Action], Action] + + +class Service(Component): + """KUSANAGI service component.""" - def action(self, name, callback): - """Set a callback for an action. + def action(self, name: str, callback: Union[Callback, AsyncCallback]) -> Service: + """ + Set a callback for an action. - :param name: Service action name. - :type name: str + :param name: The name of the service action. :param callback: Callback to handle action calls. - :type callback: callable """ self._callbacks[name] = callback + return self + + +class ServiceSchema(object): + """Service schema in the framework.""" + + def __init__(self, payload: ServiceSchemaPayload): + """ + Constructor. + + :param payload: The payload for the service schema. + + """ + + self.__payload = payload + + def get_name(self) -> str: + """Get service name.""" + + return self.__payload.get_name() + + def get_version(self) -> str: + """Get service version.""" + + return self.__payload.get_version() + + def get_address(self) -> str: + """Get the network address of the service.""" + + return self.__payload.get([ns.ADDRESS], '') + + def has_file_server(self) -> bool: + """Check if service has a file server.""" + + return self.__payload.get([ns.FILES], False) + + def get_actions(self) -> List[str]: + """Get the names of the service actions.""" + + return self.__payload.get_action_names() + + def has_action(self, name: str) -> bool: + """ + Check if an action exists for current service schema. + + :param name: The action name. + + """ + + return name in self.get_actions() + + def get_action_schema(self, name: str) -> ActionSchema: + """ + Get schema for an action. + + :param name: The action name. + + :raises: LookupError + + """ + + payload = self.__payload.get_action_schema_payload(name) + return ActionSchema(name, payload) + + def get_http_schema(self) -> HttpServiceSchema: + """Get HTTP service schema.""" + + payload = self.__payload.get_http_service_schema_payload() + return HttpServiceSchema(payload) + + +class HttpServiceSchema(object): + """HTTP semantics of a service schema in the framework.""" + + def __init__(self, payload: HttpServiceSchemaPayload): + """ + Constructor. + + :param payload: The payload for the HTTP service schema. + + """ + self.__payload = payload -def get_component(): - """Get global Service component instance. + def is_accessible(self) -> bool: + """Check if the gateway has access to the service.""" - :rtype: Service + return self.__payload.get([ns.GATEWAY], True) - """ + def get_base_path(self) -> str: + """Get base HTTP path for the service.""" - return Service.instance + return self.__payload.get([ns.BASE_PATH], '') diff --git a/kusanagi/sdk/servicedata.py b/kusanagi/sdk/servicedata.py new file mode 100644 index 0000000..315aa21 --- /dev/null +++ b/kusanagi/sdk/servicedata.py @@ -0,0 +1,55 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from typing import List + +from .actiondata import ActionData + + +class ServiceData(object): + """Represents a service which stored data in the transport.""" + + def __init__(self, address: str, name: str, version: str, actions: dict): + """ + Constructor. + + :param address: The network address of the gateway. + :param name: The service name. + :param version: The version of the service. + :param actions: Transport data for the calls made to service. + + """ + + self.__address = address + self.__name = name + self.__version = version + self.__actions = actions + + def get_address(self) -> str: + """Get the gateway address of the service.""" + + return self.__address + + def get_name(self) -> str: + """Get the service name.""" + + return self.__name + + def get_version(self) -> str: + """Get the service version.""" + + return self.__version + + def get_actions(self) -> List[ActionData]: + """ + Get the list of action data items for current service. + + Each item represents an action on the service for which data exists. + + """ + + return [ActionData(name, data) for name, data in self.__actions.items()] diff --git a/kusanagi/sdk/transaction.py b/kusanagi/sdk/transaction.py new file mode 100644 index 0000000..99b1e88 --- /dev/null +++ b/kusanagi/sdk/transaction.py @@ -0,0 +1,79 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from typing import TYPE_CHECKING +from typing import List + +from .lib.payload import Payload +from .lib.payload import ns +from .lib.payload.utils import payload_to_param + +if TYPE_CHECKING: + from .param import Param + + +class Transaction(object): + """Represents a transaction object in the transport.""" + + TYPE_COMMIT = 'commit' + TYPE_ROLLBACK = 'rollback' + TYPE_COMPLETE = 'complete' + TYPE_CHOICES = (TYPE_COMMIT, TYPE_ROLLBACK, TYPE_COMPLETE) + + def __init__(self, type_: str, payload: dict): + """ + Constructor. + + The supported types are "commit", "rollback" or "complete". + + :param type_: The transaction type. + :param payload: The payload data with the transaction info. + + :raises: TypeError + + """ + + if type_ not in (self.TYPE_COMMIT, self.TYPE_ROLLBACK, self.TYPE_COMPLETE): + raise TypeError(f'Invalid transaction type: "{type_}"') + + self.__type = type_ + self.__payload = Payload(payload) + + def get_type(self) -> str: + """Get the transaction type.""" + + return self.__type + + def get_name(self) -> str: + """Get the name of the service.""" + + return self.__payload.get([ns.NAME], '') + + def get_version(self) -> str: + """Get the service version.""" + + return self.__payload.get([ns.VERSION], '') + + def get_caller_action(self) -> str: + """Get the name of the service action that registered the transaction.""" + + return self.__payload.get([ns.CALLER], '') + + def get_callee_action(self) -> str: + """Get the name of the action to be called by the transaction.""" + + return self.__payload.get([ns.ACTION], '') + + def get_params(self) -> List['Param']: + """ + Get the transaction parameters. + + An empty list is returned when there are no parameters defined for the transaction. + + """ + + return [payload_to_param(payload) for payload in self.__payload.get([ns.PARAMS], [])] diff --git a/kusanagi/sdk/transport.py b/kusanagi/sdk/transport.py new file mode 100644 index 0000000..b384e52 --- /dev/null +++ b/kusanagi/sdk/transport.py @@ -0,0 +1,210 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import json +from typing import Iterator +from typing import List + +from .caller import Caller +from .error import Error +from .file import File +from .lib.payload import ns +from .lib.payload.transport import TransportPayload +from .lib.payload.utils import payload_to_file +from .link import Link +from .relation import Relation +from .servicedata import ServiceData +from .transaction import Transaction + + +class Transport(object): + """Transport class encapsulates the transport object.""" + + def __init__(self, payload: TransportPayload): + """ + Constructor. + + :param payload: The transport payload. + + """ + + self.__payload = payload + + def __str__(self): # pragma: no cover + return str(self.__payload) + + def get_request_id(self) -> str: + """Get the request UUID.""" + + return self.__payload.get([ns.META, ns.ID], '') + + def get_request_timestamp(self) -> str: + """Get the request creation timestamp.""" + + return self.__payload.get([ns.META, ns.DATETIME], '') + + def get_origin_service(self) -> tuple: + """ + Get the origin of the request. + + Result is an array containing name, version and action + of the service that was the origin of the request. + + """ + + return tuple(self.__payload.get([ns.META, ns.ORIGIN], [])) + + def get_origin_duration(self) -> int: + """ + Get the service execution time in milliseconds. + + This time is the number of milliseconds spent by the service that was the origin of the request. + + """ + + return self.__payload.get([ns.META, ns.DURATION], 0) + + def get_property(self, name: str, default: str = '') -> str: + """ + Get a userland property value. + + The name of the property is case sensitive. + + An empty string is returned when a property with the specified + name does not exist, and no default value is provided. + + :param name: The name of the property. + :param default: The default value to use when the property doesn't exist. + + :raises: TypeError + + """ + + if not isinstance(default, str): + raise TypeError('Default value must be a string') + + return self.__payload.get([ns.META, ns.PROPERTIES, name], default) + + def get_properties(self) -> dict: + """Get all the properties defined in the transport.""" + + return dict(self.__payload.get([ns.META, ns.PROPERTIES], {})) + + def has_download(self) -> bool: + """Check if a file download has been registered for the response.""" + + return self.__payload.exists([ns.BODY]) + + def get_download(self) -> File: + """Get the file download defined for the response.""" + + if self.has_download(): + return payload_to_file(self.__payload.get([ns.BODY])) + else: + return File('') + + def get_data(self) -> Iterator[ServiceData]: + """ + Get the transport data. + + An empty list is returned when there is no data in the transport. + + """ + + data = self.__payload.get([ns.DATA], {}) + for address, services in data.items(): + for service, versions in services.items(): + for version, actions in versions.items(): + yield ServiceData(address, service, version, actions) + + def get_relations(self) -> Iterator[Relation]: + """ + Get the service relations. + + An empty list is returned when there are no relations defined in the transport. + + """ + + data = self.__payload.get([ns.RELATIONS], {}) + for address, services in data.items(): + for service, pks in services.items(): + for pk, foreign_services in pks.items(): + yield Relation(address, service, pk, foreign_services) + + def get_links(self) -> Iterator[Link]: + """ + Get the service links. + + An empty list is returned when there are no links defined in the transport. + + """ + + data = self.__payload.get([ns.LINKS], {}) + for address, services in data.items(): + for service, references in services.items(): + for ref, uri in references.items(): + yield Link(address, service, ref, uri) + + def get_calls(self) -> Iterator[Caller]: + """ + Get the service calls. + + An empty list is returned when there are no calls defined in the transport. + + """ + + data = self.__payload.get([ns.CALLS], {}) + for service, versions in data.items(): + for version, callees in versions.items(): + for callee in callees: + action = callee[ns.CALLER] + yield Caller(service, version, action, callee) + + def get_transactions(self, type_: str) -> List[Transaction]: + """ + Get the transactions + + The transaction type is case sensitive, and supports "commit", "rollback" or "complete" as value. + + An empty list is returned when there are no transactions defined in the transport. + + :param type_: The transaction type. + + :raises: ValueError + + """ + + if type_ not in Transaction.TYPE_CHOICES: + raise ValueError(type_) + + # Mapping between user types and payload short names + types = { + Transaction.TYPE_COMMIT: TransportPayload.TRANSACTION_COMMIT, + Transaction.TYPE_ROLLBACK: TransportPayload.TRANSACTION_ROLLBACK, + Transaction.TYPE_COMPLETE: TransportPayload.TRANSACTION_COMPLETE, + } + + data = self.__payload.get([ns.TRANSACTIONS, types[type_]], []) + return [Transaction(type_, trx) for trx in data] + + def get_errors(self) -> Iterator[Error]: + """ + Get transport errors. + + An empty list is returned when there are no errors defined in the transport. + + """ + + data = self.__payload.get([ns.ERRORS], {}) + for address, services in data.items(): + for service, versions in services.items(): + for version, errors in versions.items(): + for error in errors: + message = error.get(ns.MESSAGE, Error.DEFAULT_MESSAGE) + code = error.get(ns.CODE, Error.DEFAULT_CODE) + status = error.get(ns.STATUS, Error.DEFAULT_STATUS) + yield Error(address, service, version, message, code, status) diff --git a/kusanagi/sdk/transport/__init__.py b/kusanagi/sdk/transport/__init__.py deleted file mode 100644 index 29a803b..0000000 --- a/kusanagi/sdk/transport/__init__.py +++ /dev/null @@ -1,288 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from ...payload import get_path -from ...payload import Payload -from ..base import ApiError -from ..file import payload_to_file - -from .call import Caller -from .data import ServiceData -from .error import Error -from .link import Link -from .relation import Relation -from .transaction import Transaction - -EMPTY = object() - - -class TransactionTypeError(ApiError): - """Error raised for invalid transaction types.""" - - message = 'Unknown transaction type provided: "{}"' - - def __init__(self, type): - super().__init__(self.message.format(type)) - - -class Transport(object): - """ - The Transport class encapsulates the Transport object. - - It provides read-only access to response Middleware. - - """ - - def __init__(self, payload): - self.__transport = Payload(payload) - - def get_request_id(self): - """ - Get the request UUID. - - :rtype: str - - """ - - return self.__transport.get('meta/id') - - def get_request_timestamp(self): - """ - Get the request creation timestamp. - - :rtype: str - - """ - - return self.__transport.get('meta/datetime') - - def get_origin_service(self): - """ - Get the origin of the request. - - Result is an array containing name, version and action - of the Service that was the origin of the request. - - :rtype: tuple - - """ - - return tuple(self.__transport.get('meta/origin', [])) - - def get_origin_duration(self): - """ - Get the Service execution time in milliseconds. - - This time is the number of milliseconds spent by the Service - that was the origin of the request. - - :rtype: int - - """ - - return self.__transport.get('meta/duration', 0) - - def get_property(self, name, default=EMPTY): - """ - Get a userland property value. - - The name of the property is case sensitive. - - An empty string is returned when a property with the specified - name does not exist, and no default value is provided. - - :rtype: str - - """ - - if default == EMPTY: - default = '' - elif not isinstance(default, str): - raise TypeError('Default value must be a string') - - return self.__transport.get('meta/properties/{}'.format(name), default) - - def get_properties(self): - """ - Get all the properties defined in the Transport. - - :rtype: dict - - """ - - return self.__transport.get('meta/properties', {}) - - def has_download(self): - """ - Check if a file download has been registered for the response. - - :rtype: bool - - """ - - return self.__transport.path_exists('body') - - def get_download(self): - """ - Get the file download defined for the response. - - :rtype: `File` - - """ - - if self.has_download(): - return payload_to_file(self.__transport.get('body')) - - def get_data(self): - """ - Get the Transport data. - - An empty list is returned when there is no data in the Transport. - - :returns: A list of `SeviceData`. - :rtype: list - - """ - - data = [] - transport_data = self.__transport.get('data', {}) - if not transport_data: - return data - - for address, services in transport_data.items(): - for svc, versions in services.items(): - for ver, actions in versions.items(): - data.append(ServiceData(address, svc, ver, actions)) - - return data - - def get_relations(self): - """ - Get the Service relations. - - An empty list is returned when there are no relations defined - in the Transport. - - :returns: A list of `Relation`. - :rtype: list - - """ - - relations = [] - data = self.__transport.get('relations', {}) - if not data: - return relations - - for address, services in data.items(): - for name, pks in services.items(): - for pk, foreign_services in pks.items(): - relations.append( - Relation(address, name, pk, foreign_services) - ) - - return relations - - def get_links(self): - """ - Get the Service links. - - An empty list is returned when there are no links defined - in the Transport. - - :returns: A list of `Link`. - :rtype: list - - """ - - links = [] - data = self.__transport.get('links', {}) - if not data: - return links - - for address, services in data.items(): - for name, references in services.items(): - for ref, uri in references.items(): - links.append(Link(address, name, ref, uri)) - - return links - - def get_calls(self): - """ - Get the Service calls. - - An empty list is returned when there are no calls defined - in the Transport. - - :returns: A list of `Caller`. - :rtype: list - - """ - - calls = [] - data = self.__transport.get('calls', {}) - if not data: - return calls - - for service, versions in data.items(): - for version, service_calls in versions.items(): - for call_data in service_calls: - action = get_path(call_data, 'caller', '') - calls.append(Caller(service, version, action, call_data)) - - return calls - - def get_transactions(self, type): - """ - Get the transactions - - The transaction type is case sensitive, and supports "commit", - "rollback" or "complete" as value. - An exception is raised when the type is not valid. - - An empty list is returned when there are no transactions - defined in the Transport. - - :raises: TransactionTypeError - - :returns: A list of `Transaction`. - :rtype: list - - """ - - if type not in ('commit', 'rollback', 'complete'): - raise TransactionTypeError(type) - - data = self.__transport.get('transactions/{}'.format(type), []) - if not data: - return [] - - return [Transaction(type, tr_data) for tr_data in data] - - def get_errors(self): - """ - Get Transport errors. - - An empty list is returned when there are no errors - defined in the Transport. - - :returns: A list of `Error`. - :rtype: list - - """ - - errors = [] - data = self.__transport.get('errors', {}) - if not data: - return errors - - for address, services in data.items(): - for name, versions in services.items(): - for version, error_data in versions.items(): - for data in error_data: - errors.append(Error(address, name, version, data)) - - return errors diff --git a/kusanagi/sdk/transport/call.py b/kusanagi/sdk/transport/call.py deleted file mode 100644 index 3b45357..0000000 --- a/kusanagi/sdk/transport/call.py +++ /dev/null @@ -1,155 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from ...payload import Payload -from ..param import payload_to_param - - -class Caller(object): - """Represents a Service which registered call in the Transport.""" - - def __init__(self, service, version, action, call_data): - self.__service = service - self.__version = version - self.__action = action - self.__call_data = call_data - - def get_name(self): - """ - Get the name of the Service. - - :rtype: str - - """ - - return self.__service - - def get_version(self): - """ - Get the Service version. - - :rtype: str - - """ - - return self.__version - - def get_action(self): - """ - Get the name of the Service action. - - :rtype: str - - """ - - return self.__action - - def get_callee(self): - """ - Get the callee info for the Service being called. - - :rtype: `Callee` - - """ - - return Callee(self.__call_data) - - -class Callee(object): - """Represents a callee Service call info.""" - - def __init__(self, call_data): - self.__data = Payload(call_data) - - def get_duration(self): - """ - Get the duration of the call in milliseconds. - - :rtype: int - - """ - - return self.__data.get('duration', 0) - - def is_remote(self): - """ - Check if the call is to a Service in another Realm. - - :rtype: bool - - """ - - return self.__data.path_exists('gateway') - - def get_address(self): - """ - Get the remote Gateway address. - - :rtype: str - - """ - - return self.__data.get('gateway', '') - - def get_timeout(self): - """ - Get the timeout for the call to a Service in another Realm. - - The timeout is returned in milliseconds. - - :rtype: int - - """ - - return self.__data.get('timeout', 0) - - def get_name(self): - """ - Get the name of the Service. - - :rtype: str - - """ - - return self.__data.get('name', '') - - def get_version(self): - """ - Get the Service version. - - :rtype: str - - """ - - return self.__data.get('version', '') - - def get_action(self): - """ - Get the name of the Service action. - - :rtype: str - - """ - - return self.__data.get('action', '') - - def get_params(self): - """ - Get the call parameters. - - An empty list is returned when there are no parameters - defined for the call. - - :returns: A list of `Param`. - :rtype: list - - """ - - return [ - payload_to_param(payload) - for payload in self.__data.get('params', []) - ] diff --git a/kusanagi/sdk/transport/data.py b/kusanagi/sdk/transport/data.py deleted file mode 100644 index f777882..0000000 --- a/kusanagi/sdk/transport/data.py +++ /dev/null @@ -1,106 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. - -class ServiceData(object): - """Represents a Service which stored data in the Transport.""" - - def __init__(self, address, service, version, actions): - self.__address = address - self.__service = service - self.__version = version - self.__actions = actions - - def get_address(self): - """ - Get the Gateway address of the Service. - - :rtype: str - - """ - - return self.__address - - def get_name(self): - """ - Get the Service name. - - :rtype: str - - """ - - return self.__service - - def get_version(self): - """ - Get the Service version. - - :rtype: str - - """ - - return self.__version - - def get_actions(self): - """ - Get the list of action data items for current service. - - Each item represents an action on the Service for which data exists. - The data is added all the calls to this service. - - :returns: A list of `ActionData`. - :rtype: list - - """ - - return [ - ActionData(name, call_data) - for name, call_data in self.__actions.items() - ] - - -class ActionData(object): - """The ActionData class represents action data in the Transport.""" - - def __init__(self, name, data): - self.__name = name - self.__data = data - - def get_name(self): - """ - Get the name of the Service action that returned the data. - - :rtype: str - - """ - - return self.__name - - def is_collection(self): - """ - Checks if the data for this action is a collection. - - :rtype: bool - - """ - - return isinstance(self.__data[0], list) - - def get_data(self): - """ - Get the Transport data for the Service action. - - Each item in the list represents a call that included data - in the Transport, where each item may be a list or an object, - depending on whether the data is a collection or not. - - :returns: A list of list, or a list of dict. - :rtype: list - - """ - - return self.__data diff --git a/kusanagi/sdk/transport/error.py b/kusanagi/sdk/transport/error.py deleted file mode 100644 index 558cdee..0000000 --- a/kusanagi/sdk/transport/error.py +++ /dev/null @@ -1,84 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from ...payload import Payload - - -class Error(object): - """Represents an error object in the Transport.""" - - def __init__(self, address, service, version, data): - self.__address = address - self.__service = service - self.__version = version - self.__data = Payload(data) - - def get_address(self): - """ - Get the Gateway address of the Service. - - :rtype: str - - """ - - return self.__address - - def get_name(self): - """ - Get the name of the Service. - - :rtype: str - - """ - - return self.__service - - def get_version(self): - """ - Get the Service version. - - :rtype: str - - """ - - return self.__version - - def get_message(self): - """ - Get the error message. - - An empty string is returned when the error has no message. - - :rtype: str - - """ - - return self.__data.get('message', '') - - def get_code(self): - """ - Get the error code. - - A zero is returned when the error has no code. - - :rtype: int - - """ - - return self.__data.get('code', 0) - - def get_status(self): - """ - Get the status text for the error. - - An empty string is returned when the error has no status. - - :rtype: str - - """ - - return self.__data.get('status', '') diff --git a/kusanagi/sdk/transport/link.py b/kusanagi/sdk/transport/link.py deleted file mode 100644 index 77ac908..0000000 --- a/kusanagi/sdk/transport/link.py +++ /dev/null @@ -1,56 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. - -class Link(object): - """Represents a link object in the Transport.""" - - def __init__(self, address, service, ref, uri): - self.__address = address - self.__service = service - self.__ref = ref - self.__uri = uri - - def get_address(self): - """ - Get the Gateway address of the Service. - - :rtype: str - - """ - - return self.__address - - def get_name(self): - """ - Get the name of the Service. - - :rtype: str - - """ - - return self.__service - - def get_link(self): - """ - Get the link reference. - - :rtype: str - - """ - - return self.__ref - - def get_uri(self): - """ - Get the URI for the link. - - :rtype: str - - """ - - return self.__uri diff --git a/kusanagi/sdk/transport/relation.py b/kusanagi/sdk/transport/relation.py deleted file mode 100644 index 0e602be..0000000 --- a/kusanagi/sdk/transport/relation.py +++ /dev/null @@ -1,118 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. - - -class Relation(object): - """Represents a relation object in the Transport.""" - - def __init__(self, address, name, primary_key, foreign_services): - self.__address = address - self.__name = name - self.__primary_key = primary_key - self.__foreign_services = foreign_services - - def get_address(self): - """ - Get the Gateway address of the Service. - - :rtype: str - - """ - - return self.__address - - def get_name(self): - """ - Get the name of the Service. - - :rtype: str - - """ - - return self.__name - - def get_primary_key(self): - """ - Get the value of the primary key for the relation. - - :rtype: str - - """ - - return self.__primary_key - - def get_foreign_relations(self): - """ - Get the foreign key values for the relation. - - :returns: A list of `ForeignRelation`. - :rtype: list - - """ - - relations = [] - for address, services in self.__foreign_services.items(): - for name, foreign_keys in services.items(): - relations.append(ForeignRelation(address, name, foreign_keys)) - - return relations - - -class ForeignRelation(object): - """Represents a foreign relation object in the Transport.""" - - def __init__(self, address, name, foreign_keys): - self.__address = address - self.__name = name - self.__foreign_keys = foreign_keys - - def get_address(self): - """ - Get the Gateway address of the "foreign" Service. - - :rtype: str - - """ - - return self.__address - - def get_name(self): - """ - Get the name of the "foreign" Service. - - :rtype: str - - """ - - return self.__name - - def get_type(self): - """ - Get the type of the relation. - - The relation type can be either "one" or "many". - - :rtype: str - - """ - - return "many" if isinstance(self.__foreign_keys, list) else "one" - - def get_foreign_keys(self): - """ - Get the foreign key values for the relation. - - :returns: A list of str. - :rtype: list - - """ - - if self.get_type() == "one": - return [self.__foreign_keys] - - return self.__foreign_keys diff --git a/kusanagi/sdk/transport/transaction.py b/kusanagi/sdk/transport/transaction.py deleted file mode 100644 index 7a567d9..0000000 --- a/kusanagi/sdk/transport/transaction.py +++ /dev/null @@ -1,86 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from ...payload import Payload -from ..param import payload_to_param - - -class Transaction(object): - """Represents a transaction object in the Transport.""" - - def __init__(self, type, transaction_data): - self.__type = type - self.__data = Payload(transaction_data) - - def get_type(self): - """ - Get the transaction type. - - The supported types are "commit", "rollback" or "complete". - - :rtype: str - - """ - - return self.__type - - def get_name(self): - """ - Get the name of the Service. - - :rtype: str - - """ - - return self.__data.get('name', '') - - def get_version(self): - """ - Get the Service version. - - :rtype: str - - """ - - return self.__data.get('version', '') - - def get_caller_action(self): - """ - Get the name of the Service action that registered the transaction. - - :rtype: str - - """ - - return self.__data.get('caller', '') - - def get_callee_action(self): - """ - Get the name of the action to be called by the transaction. - - :rtype: str - - """ - - return self.__data.get('action', '') - - def get_params(self): - """ - Get the transaction parameters. - - An empty list is returned when there are no parameters - defined for the transaction. - - :returns: A list of `Param`. - :rtype: list - - """ - - return [ - payload_to_param(payload) - for payload in self.__data.get('params', []) - ] diff --git a/kusanagi/serialization.py b/kusanagi/serialization.py deleted file mode 100644 index bfd1462..0000000 --- a/kusanagi/serialization.py +++ /dev/null @@ -1,103 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -import datetime -import decimal -import time - -import msgpack - -from . import utils -from .payload import Payload - - -def encode(obj): - """Handle packing for custom types.""" - - if isinstance(obj, decimal.Decimal): - return ['type', 'decimal', str(obj).split('.')] - elif isinstance(obj, datetime.datetime): - return ['type', 'datetime', utils.date_to_str(obj)] - elif isinstance(obj, datetime.date): - return ['type', 'date', obj.strftime('%Y-%m-%d')] - elif isinstance(obj, time.struct_time): - return ['type', 'time', time.strftime('%H:%M:%S', obj)] - elif hasattr(obj, '__serialize__'): - return obj.__serialize__() - - raise TypeError('{} is not serializable'.format(repr(obj))) - - -def decode(data): - """Handle unpacking for custom types.""" - - # Custom types are serialized as list, where first item is - # "type", the second is the type name and the third is the - # value represented as a basic type. - if len(data) == 3 and data[0] == 'type': - data_type = data[1] - try: - if data_type == 'decimal': - # Decimal is represented as a tuple of strings - return decimal.Decimal('.'.join(data[2])) - elif data_type == 'datetime': - return utils.str_to_date(data[2]) - elif data_type == 'date': - return datetime.datetime.strptime(data[2], '%Y-%m-%d') - elif data_type == 'time': - # Use time as a string "HH:MM:SS" - return data[2] - except: - # Don't fail when there are inconsistent data values. - # Invalid values will be null. - return - - return data - - -def pack(data): - """Pack python data to a binary stream. - - :param data: A python object to pack. - - :rtype: bytes. - - """ - - return msgpack.packb(data, default=encode, use_bin_type=True) - - -def unpack(stream): - """Pack python data to a binary stream. - - :param stream: bytes. - - :rtype: The unpacked python object. - - """ - - return msgpack.unpackb(stream, list_hook=decode, raw=False) - - -def stream_to_payload(stream): - """Convert a packed stream to a payload. - - :param stream: Packed payload stream. - :type stream: bytes - - :raises: TypeError - - :rtype: Payload - - """ - - try: - return Payload(unpack(stream)) - except TypeError: - raise TypeError('Invalid payload') - except Exception: - raise TypeError('Invalid payload stream') diff --git a/kusanagi/server.py b/kusanagi/server.py deleted file mode 100644 index 00eccba..0000000 --- a/kusanagi/server.py +++ /dev/null @@ -1,448 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -import asyncio -import logging -import os - -from collections import namedtuple - -import zmq.asyncio - -from .errors import KusanagiError -from .json import serialize -from .logging import RequestLogger -from .payload import CommandPayload -from .payload import CommandResultPayload -from .payload import ErrorPayload -from .payload import Payload -from .schema import get_schema_registry -from .serialization import pack -from .serialization import unpack - -LOG = logging.getLogger(__name__) - -# Constants for response meta -EMPTY_META = b'\x00' -SE = SERVICE_CALL = b'\x01' -FI = FILES = b'\x02' -TR = TRANSACTIONS = b'\x03' -DL = DOWNLOAD = b'\x04' - -# Allowed response meta values -META_VALUES = (EMPTY_META, SE, FI, TR, DL) - -# Multipart request frames -Frames = namedtuple('Frames', ['id', 'action', 'mappings', 'stream']) - - -def create_error_stream(rid, message, *args, **kwargs): - """Create a new multipart error stream. - - :param rid: Request ID. - :type rid: bytes - :param message: Error message. - :type message: str - - :rtype: list - - """ - - if args or kwargs: - message = message.format(*args, **kwargs) - - return [rid, EMPTY_META, pack(ErrorPayload.new(message).entity())] - - -class ComponentServer(object): - """Server class for components.""" - - def __init__(self, callbacks, args, **kwargs): - """Constructor. - - :param callbacks: Callbacks for registered action handlers. - :type callbacks: dict - :param args: CLI arguments. - :type args: dict - :param error_callback: Callback to use when errors occur. - :type error_callback: function - :param source_file: Full path to component source file. - :type source_file: str - - """ - - self.__args = args - self.__socket = None - self.__registry = get_schema_registry() - - # Check the first callback to see if asyncio is being used, - # otherwise callbacks are standard python callables. - first_callback = next(iter(callbacks.values())) - self.__use_async = asyncio.iscoroutinefunction(first_callback) - - self.loop = asyncio.get_event_loop() - self.callbacks = callbacks - self.error_callback = kwargs.get('error_callback') - self.source_file = kwargs.get('source_file') - - self.context = None - self.poller = None - - @staticmethod - def get_type(): - """ - Get the name of the component type. - - :rtype: str - - """ - - raise NotImplementedError() - - @property - def component_name(self): - return self.__args['name'] - - @property - def component_version(self): - return self.__args['version'] - - @property - def framework_version(self): - return self.__args['framework_version'] - - @property - def debug(self): - return self.__args['debug'] - - @property - def variables(self): - return self.__args.get('var') - - @property - def component_title(self): - return '"{}" ({})'.format(self.component_name, self.component_version) - - def create_error_payload(self, exc, component, **kwargs): - """Create a payload for the error response. - - :params exc: The exception raised in user land callback. - :type exc: Exception - :params component: The component being used. - :type component: Component - - :returns: A result payload. - :rtype: Payload - - """ - - raise NotImplementedError() - - def create_component_instance(self, action, payload, extra): - """Create a component instance for a payload. - - The type of component created depends on the payload type. - - :param action: Name of action that must process payload. - :type action: str - :param payload: A payload. - :type payload: Payload - :param extra: A payload to add extra command reply values to result. - :type extra: Payload - - :returns: A component instance. - :rtype: `Component` - - """ - - raise NotImplementedError() - - def component_to_payload(self, command_name, component): - """Convert callback result to a command result payload. - - :params command_name: Name of command being executed. - :type command_name: str - :params component: The component being used. - :type component: Component - - :returns: A command result payload. - :rtype: CommandResultPayload - - """ - - raise NotImplementedError() - - def get_response_meta(self, payload): - """Get metadata for multipart response. - - By default no metadata is added to response. - - :param payload: Response payload. - :type payload: Payload - - :rtype: bytes - - """ - - return b'' - - def __update_schema_registry(self, stream): - """Update schema registry with new service schemas. - - :param stream: Mappings stream. - :type stream: bytes - - """ - - LOG.debug('Updating schemas for Services ...') - try: - self.__registry.update_registry(unpack(stream)) - except asyncio.CancelledError: - raise - except: - LOG.exception('Failed to update schemas') - - @asyncio.coroutine - def __process_request_payload(self, action, payload): - # Call request handler and send response back - cmd = CommandPayload(payload) - try: - payload = yield from self.process_payload(action, cmd) - except asyncio.CancelledError: - # Avoid logging task cancel errors by catching it here - raise - except KusanagiError as err: - payload = ErrorPayload.new(message=err.message).entity() - except: - rlog = RequestLogger(cmd.request_id, __name__) - rlog.exception('Component failed') - payload = ErrorPayload.new('Component failed').entity() - - return payload - - @asyncio.coroutine - def __process_request(self, frames): - # Update global schema registry when mappings are sent - if frames.mappings: - self.__update_schema_registry(frames.mappings) - - # Get action name - action = frames.action.decode('utf8') - if action not in self.callbacks: - # Return an error when action doesn't exist - return create_error_stream( - frames.id, - 'Invalid action for component {}: "{}"', - self.component_title, - action, - ) - - # Get command payload from request stream - try: - payload = unpack(frames.stream) - except asyncio.CancelledError: - raise - except: - LOG.exception('Received an invalid message format') - return create_error_stream( - frames.id, - 'Internal communication failed', - ) - - payload = yield from self.__process_request_payload(action, payload) - return [ - frames.id, - self.get_response_meta(payload) or EMPTY_META, - pack(payload), - ] - - @asyncio.coroutine - def process_payload(self, action, payload): - """Process a request payload. - - :param action: Name of action that must process payload. - :type action: str - :param payload: A command payload. - :type payload: CommandPayload - - :returns: A Payload with the component response. - :rtype: coroutine. - - """ - - if not payload.path_exists('command'): - LOG.error("Invalid request: Command payload is missing") - return ErrorPayload.new('Internal communication failed').entity() - - command_name = payload.get('command/name') - # Create a request logger using the request ID from the command payload - rlog = RequestLogger(payload.request_id, __name__) - # Create a variable to hold extra command reply result values. - # This is used for example to the request attributes. - # Because extra is passed by reference any modification by the - # create component modifies the extra payload. - extra = Payload() - - # Create a component instance using the command payload and - # call user land callback to process it and get a response component. - component = self.create_component_instance(action, payload, extra) - if not component: - return ErrorPayload.new('Internal communication failed').entity() - - error = None - try: - if self.__use_async: - # Call callback asynchronusly - component = yield from self.callbacks[action](component) - else: - # Call callback in a different thread - component = yield from self.loop.run_in_executor( - None, # Use default executor - self.callbacks[action], - component, - ) - except asyncio.CancelledError: - # Avoid logging task cancel errors by catching it here. - raise - except KusanagiError as exc: - error = exc - payload = self.create_error_payload( - exc, - component, - payload=payload, - ) - except Exception as exc: - rlog.exception('Component failed') - error = exc - payload = ErrorPayload.new(str(exc)).entity() - else: - payload = self.component_to_payload(payload, component) - - if error and self.error_callback: - rlog.debug('Running error callback ...') - try: - self.error_callback(error) - except: - rlog.exception('Error callback failed for "%s"', action) - - # Add extra command reply result values to payload - if extra: - payload.update(extra) - - # Convert callback result to a command payload - return CommandResultPayload.new(command_name, payload).entity() - - @asyncio.coroutine - def process_input(self, message): - """Process input message and print result payload. - - Input message is given from the CLI using the `--callback` - option. - - :param message: Input message with action name and payload. - :type message: dict - - :returns: The response payload as JSON - :rtype: str - - """ - - action = message['action'] - if action not in self.callbacks: - message = 'Invalid action for component {}: "{}"'.format( - self.component_title, - action, - ) - raise KusanagiError(message) - - payload = yield from self.__process_request_payload( - action, - message['payload'], - ) - # When an error payload is returned use its message - # to raise an exception. - error = payload.get('error/message', None) - if error: - raise KusanagiError(error) - - output = serialize(payload, prettify=True).decode('utf8') - print(output, flush=True) - - @asyncio.coroutine - def listen(self, channel): - """Start listening for incoming requests. - - :param channel: Channel to listen for incoming requests. - :type channel: str - - """ - - self.context = zmq.asyncio.Context() - self.poller = zmq.asyncio.Poller() - - LOG.debug('Listening for requests in channel: "%s"', channel) - self.__socket = self.context.socket(zmq.REP) - self.__socket.bind(channel) - self.poller.register(self.__socket, zmq.POLLIN) - - timeout = self.__args["timeout"] / 1000.0 - pid = os.getpid() - - LOG.info('Component initiated...') - try: - while 1: - events = yield from self.poller.poll() - events = dict(events) - - if events.get(self.__socket) == zmq.POLLIN: - # Get request multipart stream - stream = yield from self.__socket.recv_multipart() - - # Parse the multipart request - try: - frames = Frames(*stream) - except asyncio.CancelledError: - raise - except: - LOG.error('Received an invalid multipart stream') - stream = None - frames = None - else: - # Process request and get response stream - try: - stream = yield from asyncio.wait_for( - self.__process_request(frames), - timeout=timeout, - ) - except asyncio.TimeoutError: - msg = 'SDK execution timed out after {}ms'.format( - int(timeout * 1000), - pid, - ) - LOG.warn('{}. PID: {}'.format(msg, pid)) - stream = create_error_stream(frames.id, msg) - - # When there is no response send a generic error - if not stream: - stream = create_error_stream( - frames.id if frames else '-', - 'Failed to handle request', - ) - - yield from self.__socket.send_multipart(stream) - except: - self.stop() - raise - - def stop(self): - """Stop server.""" - - LOG.debug('Stopping Component...') - if self.__socket: - self.poller.unregister(self.__socket) - self.__socket.close() - self.__socket = None diff --git a/kusanagi/service.py b/kusanagi/service.py deleted file mode 100644 index 857e07d..0000000 --- a/kusanagi/service.py +++ /dev/null @@ -1,161 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from .sdk.action import Action -from .payload import ErrorPayload -from .payload import get_path -from .payload import path_exists -from .payload import Payload -from .payload import TransportPayload -from .server import ComponentServer -from .server import DOWNLOAD -from .server import FILES -from .server import SERVICE_CALL -from .server import TRANSACTIONS -from .utils import nomap - - -class ServiceServer(ComponentServer): - """Server class for service component.""" - - def __init__(self, *args, **kwargs): - from .sdk.service import get_component - - super().__init__(*args, **kwargs) - self.__component = get_component() - self.__return_value = None - self.__transport = None - - @staticmethod - def get_type(): - return 'service' - - @property - def component_path(self): - return '{}/{}'.format( - nomap(self.component_name), - self.component_version, - ) - - def get_response_meta(self, payload): - meta = super().get_response_meta(payload) - transport = payload.get('command_reply/result/transport', None) - if not transport: - return meta - - # When a download is registered add files flag - if get_path(transport, 'body', None): - meta += DOWNLOAD - - # Add transactions flag when any transaction is registered - if get_path(transport, 'transactions', None): - meta += TRANSACTIONS - - # Add meta for service call when inter service calls are made - calls = get_path( - transport, - 'calls/{}'.format(self.component_path), - None, - ) - if calls: - meta += SERVICE_CALL - - # TODO: Check for file paths to be sure flag is added when local - # files are used in any call. - # Check if there are files added to calls - if FILES not in meta: - # Add meta for files only when service calls are made. - # Files are setted in a service ONLY when a call to - # another service is made. - files = get_path(transport, 'files', None) - if files: - address = get_path(transport, 'meta/gateway')[1] # Public gateway address - for call in calls: - files_path = '{} {} {} {}'.format( - address, - nomap(get_path(call, 'name')), - get_path(call, 'version'), - nomap(get_path(call, 'action')), - ) - - # Add flag and exit when at least one call has files - if path_exists(files, files_path, delimiter=' '): - meta += FILES - break - - return meta - - def create_component_instance(self, action, payload, *args): - """Create a component instance for current command payload. - - :param action: Name of action that must process payload. - :type action: str - :param payload: Command payload. - :type payload: `CommandPayload` - - :rtype: `Action` - - """ - - payload = payload.get('command/arguments') - - # Save transport locally to use it for response payload - self.__transport = TransportPayload(get_path(payload, 'transport')) - # Create an empty return value - # TODO: This should use the extra argument that this method receives - # See middlewares and "attributes". - self.__return_value = Payload() - - return Action( - action, - get_path(payload, 'params', []), - self.__transport, - self.__component, - self.source_file, - self.component_name, - self.component_version, - self.framework_version, - variables=self.variables, - debug=self.debug, - return_value=self.__return_value, - ) - - def component_to_payload(self, payload, *args, **kwargs): - """Convert component to a command result payload. - - :params payload: Command payload from current request. - :type payload: `CommandPayload` - :params component: The component being used. - :type component: `Component` - - :returns: A command result payload. - :rtype: `CommandResultPayload` - - """ - - if not self.__return_value: - return self.__transport.entity() - - # Use return value as base payload and add transport entity - self.__return_value.update(self.__transport.entity()) - return self.__return_value - - def create_error_payload(self, exc, action, payload): - # Add error to transport and return transport - transport = TransportPayload( - payload.get('command/arguments/transport') - ) - transport.push( - 'errors|{}|{}|{}'.format( - transport.get('meta/gateway')[1], # Public gateway address - nomap(action.get_name()), - action.get_version(), - ), - ErrorPayload.new(str(exc)), - delimiter='|', - ) - return transport.entity() diff --git a/kusanagi/urn.py b/kusanagi/urn.py deleted file mode 100644 index e2d1105..0000000 --- a/kusanagi/urn.py +++ /dev/null @@ -1,34 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -from kusanagi.errors import KusanagiError - -# Protocols -HTTP = 'urn:kusanagi:protocol:http' -KTP = 'urn:kusanagi:protocol:ktp' - - -def url(protocol, address): - """Create a URL for a protocol. - - :param protocol: URN for a protocol. - :type protocol: str - :param address: An IP address. It can include a port. - :type address: str - - :raises: KusanagiError - - :rtype: str - - """ - - if protocol == HTTP: - return 'http://{}'.format(address) - elif protocol == KTP: - return 'ktp://{}'.format(address) - else: - raise KusanagiError('Unknown protocol: {}'.format(protocol)) diff --git a/kusanagi/utils.py b/kusanagi/utils.py deleted file mode 100644 index a11f842..0000000 --- a/kusanagi/utils.py +++ /dev/null @@ -1,829 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -import asyncio -import json -import os -import re -import signal -import socket - -from datetime import datetime -from binascii import crc32 -from uuid import uuid4 - -DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%f+00:00" - -IPC_RE = re.compile(r'[^a-zA-Z0-9]{1,}') - -LOCALHOSTS = ('localhost', '127.0.0.1', '127.0.1.1') - -# Default path delimiter -DELIMITER = '/' - -# CLI exit status codes -EXIT_OK = os.EX_OK -EXIT_ERROR = 1 - -# Marker object for empty values -EMPTY = object() - -# Make `utcnow` global so it can be imported -utcnow = datetime.utcnow - - -def uuid(): - """Generate a UUID4. - - :rtype: String. - - """ - - return str(uuid4()) - - -def tcp(*args): - """Create a tcp connection string. - - Function can have a single argument with the full address, or - 2 arguments where first is the address and second is the port. - - :rtype: str. - - """ - - if len(args) == 2: - address = '{}:{}'.format(*args) - else: - address = args[0] - - return "tcp://{}".format(address) - - -def ipc(*args): - """Create an ipc connection string. - - :rtype: str. - - """ - - name = IPC_RE.sub('-', '-'.join(args)) - return 'ipc://@kusanagi-{}'.format(name) - - -def guess_channel(local, remote): - """Guess connection channel to use to connect to a remote host. - - Function guesses what channel to use to connect from a local host - to remote host. Unix socket channel is used when remote host is in - the same IP, or TCP otherwise. - - :param local: IP address for local host (may include port). - :type local: str. - :param remote: IP address and port for remote host. - :type remote: str. - - :returns: Channel to connect to remote host. - :rtype: str. - - """ - - remote_host = remote.split(':')[0] - if remote_host in LOCALHOSTS: - return ipc(remote) - - local_host = local.split(':')[0] - return ipc(remote) if remote_host == local_host else tcp(remote) - - -def guess_channel_to_remote(remote): - """Guess connection channel to use to connect to a remote host. - - Function guesses what channel to use to connect from a local host - to remote host. Unix socket channel is used when remote host is in - the same IP, or TCP otherwise. - - All local IP are used to check if remote host can be reached using - Unix sockets. - - :param remote: IP address and port for remote host. - :type remote: str. - - :returns: Channel to connect to remote host. - :rtype: str. - - """ - - remote_host = remote.split(':')[0] - if remote_host in LOCALHOSTS: - return ipc(remote) - - # Check if remote matches a local IP - for local_host in socket.gethostbyname_ex(socket.gethostname())[2]: - if remote_host == local_host: - return ipc(remote) - - # By default TCP will be used - return tcp(remote) - - -def str_to_date(value): - """Convert a string date to a datetime object. - - :param value: String with a date value. - :rtype: Datetime object or None. - - """ - - if value: - return datetime.strptime(value, DATE_FORMAT) - - -def date_to_str(datetime): - """Convert a datetime object to string. - - :param value: Datetime object. - :rtype: String or None. - - """ - - if datetime: - return datetime.strftime(DATE_FORMAT) - - -def nomap(value): - """Disable name mapping in paths for a value. - - This is used to avoid mapping path item names. - - This just adds a '!' as value prefix. - - :rtype: str - - """ - - return '!{}'.format(value) - - -def get_path(item, path, default=EMPTY, mappings=None, delimiter=DELIMITER): - """Get dictionary value by path. - - Path can countain the name for a single or for many keys. In case - a of many keys, path must separate each key name with a '/'. - - Example path: 'key_name/another/last'. - - KeyError is raised when no default value is given. - - By default global payload field mappings are used to traverse keys. - - :param item: A dictionaty like object. - :type item: dict - :param path: Path to a value. - :type path: str - :param default: Default value to return when value is not found. - :type default: object - :param mappings: Optional field name mappings. - :type mappings: dict - :param delimiter: Optional path delimiter. - :type delimiter: str - - :raises: `KeyError` - - :returns: The value for the given path. - :rtype: object - - """ - - try: - for part in path.split(delimiter): - # Skip mappings for names starting with "!" - if part and part[0] == '!': - name = part[1:] - else: - name = part - # When path name is not available get its mapping - if mappings and (name not in item): - name = mappings.get(part, part) - - item = item[name] - except KeyError: - if default != EMPTY: - return default - else: - raise - - return item - - -def set_path(item, path, value, mappings=None, delimiter=DELIMITER): - original_item = item - parts = path.split(delimiter) - last_part_index = len(parts) - 1 - for index, part in enumerate(parts): - # Skip mappings for names starting with "!" - if part and part[0] == '!': - name = part[1:] - else: - name = mappings.get(part, part) if mappings else part - - # Current part is the last item in path - if index == last_part_index: - item[name] = value - break - - if name not in item: - item[name] = {} - item = item[name] - elif isinstance(item[name], dict): - # Only keep traversing dictionaries - item = item[name] - else: - raise TypeError(part) - - return original_item - - -def delete_path(item, path, mappings=None, delimiter=DELIMITER): - try: - name, *path = path.split(delimiter, 1) - # Skip mappings for names starting with "!" - if name and name[0] == '!': - name = name[1:] - else: - # When path name is not in item get its mapping - if mappings and (name not in item): - name = mappings.get(name, name) - - # Delete inner path items - if path: - # Stop when inner item delete failed - if not delete_path(item[name], path[0], mappings=mappings): - return False - - # Delete current path when it is empty - if not item[name]: - del item[name] - else: - # Item is removed when is the last in the path - del item[name] - except KeyError: - return False - - return True - - -def merge(from_value, to_value, mappings=None, lists=False): - """Merge two dictionaries. - - When mappings are given fields names are checked also using their - mapped names, and merged values are saved using mapped field names. - - :param from_value: Dictionary containing values to merge. - :type from_value: dict - :param to_value: Dictionary to merge values into. - :type to_value: dict - :param mappings: Field name mappings. - :type mappings: dict - :param lists: Optional flag for merging list values. - :type lists: bool - - :returns: The dictionary where values were merged. - :rtype: dict - - """ - - for key, value in from_value.items(): - # Check if key exists in destination dict and get mapped - # key name when full name does not exist in destination. - if (key not in to_value) and mappings: - # When mapped name exists use it - name = mappings.get(key, key) - else: - # Use dictionary key as name - name = key - - # When field is not available in destination - # dict add the value from the original dict. - if name not in to_value: - # Use mapped name, when available, to save value - # if name == key and mappings: - # name = mappings.get(name, name) - - # Add new value to destination and continue with next value - to_value[name] = value - elif isinstance(value, dict): - # Field exists in destination and is dict, then merge dict values - merge(value, to_value[name], mappings=mappings, lists=lists) - elif lists: - if isinstance(value, list) and isinstance(to_value[name], list): - # Field exists in destination and is a list, then extend it - to_value[name].extend(value) - - return to_value - - -# TODO: Use Cython for lookup dict ? It is used all the time. -class LookupDict(dict): - """Dictionary class that allows field value setting and lookup by path. - - It also supports key name mappings when a mappings dictionary is assigned. - - This class reimplements `get` and `set` methods to use paths instead of - simple key names like a standard dictionary. Single key names can be used - though. - - """ - - def __init__(self, *args, **kwargs): - self.__mappings = {} - self.__defaults = {} - super().__init__(*args, **kwargs) - - @staticmethod - def is_empty(value): - """Check if a value is the empty value. - - :rtype: bool - - """ - - return value is EMPTY - - def path_exists(self, path, delimiter=DELIMITER): - """Check if a path is available. - - :param path: Path to a value. - :type path: str - :param delimiter: Optional path delimiter. - :type delimiter: str - - :rtype: bool - - """ - - try: - self.get(path, delimiter=delimiter) - except KeyError: - return False - else: - return True - - def set_mappings(self, mappings): - """Set key name mappings. - - :param mappings: Key name mappings. - :type mappings: dict. - - """ - - self.__mappings = mappings - - def set_defaults(self, defaults): - """Set default values to use during get calls. - - :param mappings: Key name mappings. - :type mappings: dict. - - """ - - self.__defaults = defaults - - def get(self, path, default=EMPTY, delimiter=DELIMITER): - """Get value by key path. - - Path can countain the name for a single or for many keys. In case - a of many keys, path must separate each key name with a '/'. - - Example path: 'key_name/another/last'. - - KeyError is raised when no default value is given. - - :param path: Path to a value. - :type path: str. - :param default: Default value to return when value is not found. - :type default: object - :param delimiter: Optional path delimiter. - :type delimiter: str - - :raises: KeyError. - - :returns: The value for the given path. - - """ - - if default == EMPTY: - default = self.__defaults.get(path, EMPTY) - - return get_path(self, path, default, self.__mappings, delimiter) - - def get_many(self, *paths, delimiter=DELIMITER): - """Get multiple values by key path. - - KeyError is raised when no default value is given. - Default values can be assigned using `set_defaults`. - - :param delimiter: Optional path delimiter. - :type delimiter: str - - :raises: KeyError. - - :returns: The values for the given path. - :rtype: list - - """ - - result = [] - for path in paths: - result.append(self.get(path, delimiter=delimiter)) - - return result - - def set(self, path, value, delimiter=DELIMITER): - """Set value by key path. - - Path traversing is only done for dictionary like values. - TypeError is raised for a key when a non traversable value is found. - - When a key name does not exists a new dictionary is created for that - key name that it is used for traversing the other key names, so many - dictionaries are created inside one another to complete the given path. - - When a mapping is assigned it is used to reverse key names in path to - the original mapped key name. - - Example path: 'key_name/another/last'. - - :param path: Path to a value. - :type path: str. - :param value: Value to set in the give path. - :type value: object - :param delimiter: Optional path delimiter. - :type delimiter: str - - :raises: TypeError. - - :returns: Current instance. - :rtype: `LookupDict` - - """ - - set_path(self, path, value, self.__mappings, delimiter) - return self - - def set_many(self, values, delimiter=DELIMITER): - """Set set multiple values by key path. - - :param values: A dictionary with paths and values. - :type values: dict - :param delimiter: Optional path delimiter. - :type delimiter: str - - :raises: TypeError. - - :returns: Current instance. - :rtype: `LookupDict` - - """ - - for path, value in values.items(): - self.set(path, value, delimiter=delimiter) - - return self - - def push(self, path, value, delimiter=DELIMITER): - """Push value by key path. - - Path traversing is only done for dictionary like values. - TypeError is raised for a key when a non traversable value is found. - - When a key name does not exists a new dictionary is created for that - key name that it is used for traversing the other key names, so many - dictionaries are created inside one another to complete the given path. - - When the final key is found is is expected to be a list where the value - will be appended. TypeError is raised when final key is not a list. - A new list is created when last key does not exists. - - When a mapping is assigned it is used to reverse key names in path to - the original mapped key name. - - Example path: 'key_name/another/last'. - - :param path: Path to a value. - :type path: str - :param value: Value to set in the give path. - :type value: object - :param delimiter: Optional path delimiter. - :type delimiter: str - - :raises: TypeError - - :returns: Current instance. - :rtype: `LookupDict` - - """ - - item = self - parts = path.split(delimiter) - last_part_index = len(parts) - 1 - for index, part in enumerate(parts): - # Skip mappings for names starting with "!" - if part and part[0] == '!': - name = part[1:] - else: - name = self.__mappings.get(part, part) - - # Current part is the last item in path - if index == last_part_index: - if name not in item: - # When last key does not exists create a list - item[name] = [] - elif not isinstance(item[name], list): - # When last key exists it must be a list - raise TypeError(name) - - item[name].append(value) - break - - if name not in item: - item[name] = {} - item = item[name] - elif isinstance(item[name], dict): - # Only keep traversing dictionaries - item = item[name] - else: - raise TypeError(part) - - return self - - def merge(self, path, value, delimiter=DELIMITER): - """Merge a dictionary value into a location. - - Value must be a dictionary. Location given by path must - contain a dictionary where to merge the dictionary value. - - :param path: Path to a value. - :type path: str - :param value: Value to set in the give path. - :type value: object - :param delimiter: Optional path delimiter. - :type delimiter: str - - :raises: `TypeError` - - :returns: Current instance. - :rtype: `LookupDict` - - """ - - if not isinstance(value, dict): - raise TypeError('Merge value is not a dict') - - if self.path_exists(path, delimiter=delimiter): - item = self.get(path, delimiter=delimiter) - if not isinstance(item, dict): - raise TypeError('Value in path "{}" is not dict'.format(path)) - else: - item = {} - self.set(path, item, delimiter=delimiter) - - merge(value, item, mappings=self.__mappings, lists=True) - return self - - -class MultiDict(dict): - """Dictionary where all values are list. - - This is used to allow multiple values for the same key. - - """ - - def __init__(self, mappings=None): - super().__init__() - if mappings: - self._set_mappings(mappings) - - def __setitem__(self, key, value): - self.setdefault(key, []).append(value) - - def _set_mappings(self, mappings): - if isinstance(mappings, dict): - mappings = mappings.items() - - for key, value in mappings: - if isinstance(value, list): - self.setdefault(key, value) - else: - self[key] = value - - def getone(self, key, default=None): - if key not in self: - return default - - return self[key][0] - - def multi_items(self): - """Get a list of tuples with all items in dictionary. - - The difference between this method and `items()` is that this - one will split values inside lists as independent single items. - - :rtype: list. - - """ - - items = [] - for name, values in self.items(): - items.extend((name, str(value)) for value in values) - - return items - - -class Singleton(type): - """Metaclass to make a class definition a singleton. - - The class definition using this will contain a class property - called `instance` with the only instance allowed for that class. - - Instance is, of course, only created the first time the class is called. - - """ - - def __init__(cls, name, bases, classdict): - super(Singleton, cls).__init__(name, bases, classdict) - cls.instance = None - - def __call__(cls, *args, **kw): - if cls.instance is None: - cls.instance = super().__call__(*args, **kw) - - return cls.instance - - -# TODO: Deprecate @coroutine in favor os async/await -class RunContext(object): - """Handles graceful async process termination. - - All keyword arguments passed to context during creation are used as - properties for the context object. Aditionally context contains a - 'loop' property with the main event loop. - - On run context hooks to SIGTERM and SIGINT signals to handle graceful - process termination when process is killed or when CTRL-C is pressed - if process is run from console. - - Async callbacks can be registered to perform application cleanup tasks. - These callbacks are executed right after termination signal is received. - Callback must be a coroutine that receives a single 'context' argument - with an instance of this class. - - """ - - def __init__(self, loop, **kwargs): - self.__dict__.update(kwargs) - self.__terminate = False - self.__cleaned = False - self.__callbacks = [] - self.tasks = [] - self.loop = loop - - def hook_signals(self): - self.loop.add_signal_handler(signal.SIGTERM, self.terminate) - self.loop.add_signal_handler(signal.SIGINT, self.terminate) - - def terminate(self, *args, **kwargs): - self.__terminate = True - - def register_callback(self, callback): - if not asyncio.iscoroutinefunction(callback): - raise TypeError('Callback is not a coroutine') - - # Append callback as a task - self.__callbacks.append(callback(self)) - - @asyncio.coroutine - def cleanup(self): - if self.__cleaned: # pragma: no cover - return - - # Execute all registered callbacks - if self.__callbacks: - yield from asyncio.wait(self.__callbacks, timeout=1.5) - - self.__cleaned = True - - # Finish all pending tasks - yield from self.finish_pending_tasks() - - @asyncio.coroutine - def finish_pending_tasks(self): - """Finish all tasks that are still running. - - When an exception is found it is raised after all tasks are finished. - Only the first exception that is found is raised. - - """ - - if not self.tasks: # pragma: no cover - return - - exc = None - for task in self.tasks: - if task.done(): - # When an exception is found save it - if task.exception() and not exc: - exc = task.exception() - else: - # This task is finished, continue with next one - continue # pragma: no cover - - task.cancel() - - # Wait for all task to finish - yield from asyncio.wait(self.tasks, timeout=2) - - # When an exception is found raise it - if exc: - raise exc - - def check_tasks_exceptions(self): - """Check for task exceptions. - - Run context terminates when an exception is found. - - """ - - all_done = True - - for task in self.tasks: - if not task.done(): - all_done = False - continue - - # Task is done, check for exceptions - if task.exception(): - self.__terminate = True - break - - # Stop run context when there are no more tasks running - if all_done: - self.__terminate = True - - @asyncio.coroutine - def run(self): - """Hook to termination signals and run until terminate is called.""" - - self.__cleaned = False - self.hook_signals() - while not self.__terminate: - yield from asyncio.sleep(0.2) - self.check_tasks_exceptions() - - yield from self.cleanup() - - -def install_uvevent_loop(): # pragma: no cover - """Install uvloop as default event loop when available. - - See: http://magic.io/blog/uvloop-blazing-fast-python-networking/ - - """ - try: - import uvloop - except ImportError: - pass - else: - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) - - -def dict_crc(dict): - """Create a CRC for a dictionary like object. - - :param dict: Dictionary to use for CRC generation. - :type dict: dict - - :rtype: str - - """ - - return str(crc32(json.dumps(dict, sort_keys=True).encode('utf8'))) - - -def safe_cast(value, cast_func, default=None): - """Cast a value to another type. - - When casting fails return a default value, or None by default. - - :returns: The casted value or None. - - """ - - try: - return cast_func(value) - except: - return default diff --git a/kusanagi/versions.py b/kusanagi/versions.py deleted file mode 100644 index 9d31207..0000000 --- a/kusanagi/versions.py +++ /dev/null @@ -1,153 +0,0 @@ -# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) -# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. -# -# Distributed under the MIT license. -# -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -import re - -from functools import cmp_to_key -from itertools import zip_longest - -from .errors import KusanagiError - -# Regexp to check version pattern for invalid chars -INVALID_PATTERN = re.compile(r'[^a-zA-Z0-9*.,_-]') - -# Regexp to remove duplicated '*' from version -WILDCARDS = re.compile(r'\*+') - -# Regexp to match version dot separators -VERSION_DOTS = re.compile(r'([^*])\.') - -# Regexp to match all wildcards except the last one -VERSION_WILDCARDS = re.compile(r'\*+([^$])') - - -class InvalidVersionPattern(KusanagiError): - """Exception raised when a version pattern is not valid.""" - - message = 'Invalid version pattern: "{}"' - - def __init__(self, pattern): - super().__init__(message=self.message.format(pattern)) - self.pattern = pattern - - -class VersionNotFound(KusanagiError): - """Exception raised when a version is not found.""" - - message = 'Service version not found for pattern: "{}"' - - def __init__(self, pattern): - super().__init__(message=self.message.format(pattern)) - self.pattern = pattern - - -class VersionString(object): - def __init__(self, version): - # Validate pattern characters - if INVALID_PATTERN.search(version): - raise InvalidVersionPattern(version) - - # Remove duplicated wildcards from version - self.__version = WILDCARDS.sub('*', version) - - if '*' in self.__version: - # Create an expression for version pattern comparisons - expr = VERSION_WILDCARDS.sub(r'[^*.]+\1', self.version) - # Escape dots to work with the regular expression - expr = VERSION_DOTS.sub(r'\1\.', expr) - - # If there is a final wildcard left replace it with an - # expression to match any characters after the last dot. - if expr[-1] == '*': - expr = expr[:-1] + '.*' - - # Create a pattern to be use for cmparison - self.__pattern = re.compile(expr) - else: - self.__pattern = None - - @property - def version(self): - return self.__version - - @property - def pattern(self): - return self.__pattern - - @staticmethod - def compare_none(part1, part2): - if part1 == part2: - return 0 - elif part2 is None: - # The one that DO NOT have more parts is greater - return 1 - else: - return -1 - - @staticmethod - def compare_sub_parts(sub1, sub2): - # Sub parts are equal - if sub1 == sub2: - return 0 - - # Check if any sub part is an integer - is_integer = [False, False] - for idx, value in enumerate((sub1, sub2)): - try: - int(value) - except ValueError: - is_integer[idx] = False - else: - is_integer[idx] = True - - # Compare both sub parts according to their type - if is_integer[0] != is_integer[1]: - # One is an integer. The integer is higher than the non integer. - # Check if the first sub part is an integer, and if so it means - # sub2 is lower than sub1. - return -1 if is_integer[0] else 1 - - # Both sub parts are of the same type - return 1 if sub1 < sub2 else -1 - - @classmethod - def compare(cls, ver1, ver2): - # Versions are equal - if ver1 == ver2: - return 0 - - for part1, part2 in zip_longest(ver1.split('.'), ver2.split('.')): - # One of the parts is None - if part1 is None or part2 is None: - return cls.compare_none(part1, part2) - - for sub1, sub2 in zip_longest(part1.split('-'), part2.split('-')): - # One of the sub parts is None - if sub1 is None or sub2 is None: - # Sub parts are different, because one have a - # value and the other not. - return cls.compare_none(sub1, sub2) - - # Both sub parts have a value - result = cls.compare_sub_parts(sub1, sub2) - if result: - # Sub parts are not equal - return result - - def match(self, version): - if not self.pattern: - return self.version == version - else: - return self.pattern.fullmatch(version) is not None - - def resolve(self, versions): - valid_versions = [ver for ver in versions if self.match(ver)] - if not valid_versions: - raise VersionNotFound(self.pattern) - - valid_versions.sort(key=cmp_to_key(self.compare)) - return valid_versions[0] diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..bc8ac47 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,7 @@ +[mypy] + +[mypy-pytest.*] +ignore_missing_imports = True + +[mypy-zmq.*] +ignore_missing_imports = True diff --git a/pylama.ini b/pylama.ini index bec1651..581106f 100644 --- a/pylama.ini +++ b/pylama.ini @@ -2,13 +2,10 @@ format = pylint skip = */.tox/*,*/.venv/*,*/tests/* linters = pyflakes,mccabe,pep8 -#linters = pylint,mccabe,pep8 -ignore = E125,W0511,C0111,C901 +ignore = E125,W0511,C0111,C901,T499 [pylama:pep8] -# Github review line length max_line_length = 119 [pylama:pylint] -# Github review line length max_line_length = 119 diff --git a/pylintrc b/pylintrc index ccac3bf..b9530ca 100644 --- a/pylintrc +++ b/pylintrc @@ -271,6 +271,7 @@ generated-members= [FORMAT] # Maximum number of characters on a single line. +# Github code review uses 119 characters. max-line-length=119 # Regexp for a line that is allowed to be longer than the limit. diff --git a/pytest.ini b/pytest.ini index 9d274d9..15e2d35 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,8 +1,12 @@ [pytest] testpaths = tests +python_files = test_*.py color = yes capture = sys addopts = -p no:logging + -p no:warnings --cov kusanagi --cov-report term-missing + --pylama + --mypy diff --git a/setup.py b/setup.py index adc1431..3cfd0ef 100644 --- a/setup.py +++ b/setup.py @@ -2,8 +2,8 @@ from setuptools import find_packages from setuptools import setup -from kusanagi import __license__ -from kusanagi import __version__ +from kusanagi.sdk import __license__ +from kusanagi.sdk import __version__ setup( name='kusanagi-sdk-python', @@ -12,32 +12,36 @@ license=__license__, author='Jerónimo Albi', author_email='jeronimo.albi@kusanagi.io', - description='Python SDK for the KUSANAGI framework', + description='Python SDK for the KUSANAGI™ framework', platforms=['POSIX'], download_url='https://github.com/kusanagi/kusanagi-sdk-python/releases', + namespace_packages=['kusanagi'], packages=find_packages(exclude=['tests']), include_package_data=True, zip_safe=True, install_requires=[ - 'click==7.0', - 'pyzmq==17.1.2', - 'msgpack-python==0.5.6', + 'pyzmq==19.0.0', + 'msgpack==1.0.0', ], setup_requires=[ 'pytest-runner', ], tests_require=[ 'pytest', - 'pytest-mock', + 'pytest-asyncio', 'pytest-cov', + 'pytest-mock', + 'pytest-mypy', 'pylint', 'pylama', 'coverage', + 'isort', + 'flake8', ], classifiers=[ 'Intended Audience :: Developers', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Operating System :: POSIX :: Linux', 'Topic :: Software Development :: Libraries :: Application Frameworks', 'Topic :: Software Development :: Libraries :: Python Modules', diff --git a/tests/conftest.py b/tests/conftest.py index 25a6ae9..7cf06cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,82 +1,90 @@ -import asyncio +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. import io -import json -import logging import os +import logging -import click.testing import pytest -import kusanagi.payload -from kusanagi.logging import setup_kusanagi_logging -from kusanagi.schema import SchemaRegistry - @pytest.fixture(scope='session') -def data_path(request): - """ - Fixture to add full path to test data directory. - - """ +def DATA_DIR(request) -> str: + """Path to the tests data directory.""" return os.path.join(os.path.dirname(__file__), 'data') -@pytest.fixture(scope='function') -def read_json(data_path): - """ - Fixture to add JSON loading support to tests. - - """ - - def deserialize(name): - if not name[-4:] == 'json': - name += '.json' - - with open(os.path.join(data_path, name), 'r') as file: - return json.load(file) - - return deserialize +@pytest.fixture(scope='session') +def schemas(): + """Service mapping schemas.""" + + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.mapping import MappingPayload + + return MappingPayload({ + 'foo': { + '1.0.0': { + ns.ADDRESS: '1.2.3.4:77', + ns.HTTP: { + ns.PATH: '/test', + }, + ns.ACTIONS: { + 'bar': {}, + }, + }, + }, + }) @pytest.fixture(scope='function') -def registry(request): - """ - Fixture to add schema registry support to tests. +def input_(DATA_DIR): + """CLI input values.""" + + from kusanagi.sdk.lib.cli import Input + + return Input( + os.path.join(DATA_DIR, 'test.py'), + component='service', + name='foo', + version='1.0.0', + framework_version='3.0.0', + socket='@kusanagi-1.2.3.4-77', + timeout=10000, + debug=True, + var={'foo': 'bar', 'bar': 'baz'}, + log_level='debug', + tcp=None, + ) - """ - - def cleanup(): - SchemaRegistry.instance = None - request.addfinalizer(cleanup) - return SchemaRegistry() +@pytest.fixture(scope='session') +def stream(): + """ZMQ request stream.""" + from kusanagi.sdk.lib.msgpack import pack -@pytest.fixture(scope='function') -def cli(request): - """ - Fixture to add CLI runner support to tests. + return [b'72642c64-a37e-45cc-8f1e-7225b0b1b8e0', b'bar', b'', pack({})] - """ - disable_field_mappings = kusanagi.payload.DISABLE_FIELD_MAPPINGS +@pytest.fixture(scope='function') +def state(input_, stream): + """Framework request state.""" - def cleanup(): - kusanagi.payload.DISABLE_FIELD_MAPPINGS = disable_field_mappings - del os.environ['TESTING'] + from kusanagi.sdk.lib.state import State - request.addfinalizer(cleanup) - os.environ['TESTING'] = '1' - return click.testing.CliRunner() + return State(input_, stream) @pytest.fixture(scope='function') def logs(request, mocker): - """ - Fixture to add logging output support to tests. + """Enable logging output support in a test.""" - """ + from kusanagi.sdk.lib.logging import setup_kusanagi_logging output = io.StringIO() @@ -87,25 +95,155 @@ def cleanup(): # Cleanup kusanagi logger handlers too logging.getLogger('kusanagi').handlers = [] - logging.getLogger('kusanagi.sdk').handlers = [] - + # Close the output stream output.close() request.addfinalizer(cleanup) - mocker.patch('kusanagi.logging.get_output_buffer', return_value=output) - setup_kusanagi_logging( - 'component', 'name', 'version', 'framework-version', logging.INFO, - ) + mocker.patch('kusanagi.sdk.lib.logging.get_output_buffer', return_value=output) + setup_kusanagi_logging('component', 'name', 'version', 'framework-version', logging.DEBUG) return output @pytest.fixture(scope='function') -def async_mock(mocker): - def mock(callback): - @asyncio.coroutine - def mocked_coroutine(*args, **kwargs): - return callback() +def command(): + """Get a command payload.""" + + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + + command = CommandPayload() + command.set([ns.META], { + ns.ID: '25759c6c-8531-40d2-a415-4ff9246307c5', + ns.DATETIME: '2017-01-27T20:12:08.952811+00:00', + ns.PROTOCOL: 'http', + ns.GATEWAY: ["1.2.3.4:1234", "http://1.2.3.4:77"], + ns.CLIENT: '7.7.7.7:6666', + ns.ATTRIBUTES: {'foo': 'bar'}, + }) + command.set([ns.REQUEST], { + ns.HEADERS: {'fooh': ['barh', 'bazh']}, + ns.FILES: [{ + ns.NAME: 'foof', + }], + ns.METHOD: 'PUT', + ns.URL: 'http://6.6.6.6:7777/test', + ns.QUERY: {'fooq': ['barq', 'bazq']}, + ns.POST_DATA: {'foop': ['barp', 'bazp']}, + ns.VERSION: '2.0', + ns.BODY: b'contents', + }) + return command + + +@pytest.fixture(scope='function') +def reply(): + """Get a reply payload.""" + + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + reply = ReplyPayload() + reply.set([ns.CALL], { + ns.SERVICE: 'foo', + ns.VERSION: '1.0.0', + ns.ACTION: 'bar', + }) + return reply + + +@pytest.fixture(scope='function') +def action_reply(reply): + """Get a reply payload for an action.""" + + from kusanagi.sdk.lib.payload import ns + + reply.set([ns.TRANSPORT, ns.META, ns.ORIGIN], ['foo', '1.0.0', 'bar']) + return reply + + +@pytest.fixture(scope='function') +def response_reply(reply): + """Get a reply payload for a response.""" + + from kusanagi.sdk.lib.payload import ns + + reply.set([ns.RESPONSE], { + ns.HEADERS: {'fooh': ['barh', 'bazh']}, + ns.VERSION: '2.0', + ns.STATUS: "418 I'm a teapot", + ns.BODY: b'contents', + }) + return reply + + +@pytest.fixture(scope='function') +def action_command(command): + """Get a command payload for a service action call.""" + + from kusanagi.sdk.lib.payload import ns + + address = 'http://1.2.3.4:77' + service = 'foo' + version = '1.0.0' + action = 'bar' + command.set([ns.PARAMS], [{ + ns.NAME: 'foo', + ns.VALUE: 'bar', + }]) + command.set([ns.TRANSPORT], { + ns.META: { + ns.GATEWAY: ['1.2.3.4:1234', 'http://1.2.3.4:77'], + ns.ORIGIN: [service, version, action], + }, + ns.FILES: { + address: { + service: { + version: { + action: [{ + ns.NAME: 'foo', + }] + } + } + } + }, + }) + return command + + +@pytest.fixture(scope='function') +def service_schema(): + """Get a service schema payload.""" + + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.service import ServiceSchemaPayload + + payload = { + ns.ADDRESS: '6.6.6.6:77', + ns.FILES: True, + ns.ACTIONS: { + 'foo': {}, + }, + ns.HTTP: { + ns.GATEWAY: False, + ns.BASE_PATH: '/test', + }, + } + return ServiceSchemaPayload(payload, name='foo', version='1.0.0') + + +@pytest.fixture(scope='session') +def AsyncMock(): + """ + Mock class that support asyncio. + + NOTE: AsyncMock support exists from python >= 3.8 in "unittest.mock.AsyncMock. + + """ + + from unittest.mock import MagicMock - return mocked_coroutine + class AsyncMock(MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) - return mock + return AsyncMock diff --git a/tests/data/file.txt b/tests/data/file.txt new file mode 100644 index 0000000..257cc56 --- /dev/null +++ b/tests/data/file.txt @@ -0,0 +1 @@ +foo diff --git a/tests/data/foo.json b/tests/data/foo.json deleted file mode 100644 index 6de9330..0000000 --- a/tests/data/foo.json +++ /dev/null @@ -1 +0,0 @@ -{"id": 1, "user_id": 1, "title": "Welcome to Kusanagi"} diff --git a/tests/data/schema-file.json b/tests/data/schema-file.json deleted file mode 100644 index 4669c0e..0000000 --- a/tests/data/schema-file.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "m": "application/json,document/pdf", - "r": true, - "mx": 100444, - "ex": true, - "mn": 100, - "en": true, - "h": { - "g": false, - "p": "upload" - } -} diff --git a/tests/data/schema-param.json b/tests/data/schema-param.json deleted file mode 100644 index 3fcd46f..0000000 --- a/tests/data/schema-param.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "t": "array", - "f": "email", - "af": "ssv", - "p": "\\s{,2}aa", - "e": true, - "d": "1 2 3 4", - "r": true, - "i": {"type": "integer"}, - "mx": 5, - "ex": true, - "mn": 2, - "en": true, - "xi": 42, - "ni": 2, - "ui": true, - "em": [1, 2, 3], - "mo": 2, - "h": { - "g": false, - "i": "path", - "p": "bar" - } -} diff --git a/tests/data/schema-service.json b/tests/data/schema-service.json deleted file mode 100644 index b894dd2..0000000 --- a/tests/data/schema-service.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "h": { - "b": "/0.1.0", - "g": false - }, - "f": true, - "a": "127.0.0.1:5003", - "ac": { - "defaults": {}, - "foo": { - "x": 2000, - "e": "foo:bar", - "d": ":", - "c": true, - "t": ["foo", "bar"], - "C": [["foo", "1.0", "dummy"], ["bar", "1.1", "dummy"]], - "dc": [["foo", "1.0", "dummy"], ["bar", "1.1", "dummy"]], - "rc": [["ktp://87.65.43.21:4321", "foo", "1.0", "dummy"]], - "F": null, - "D": true, - "rv": {"t": "integer"}, - "h": { - "g": false, - "p": "/1.0/foo/{bar}", - "m": "put", - "i": "form-data", - "b": ["application/json"] - }, - "p": { - "value": { - "t": "array", - "f": "email", - "af": "ssv", - "p": "\\s{,2}aa", - "e": true, - "d": "1 2 3 4", - "r": true, - "i": {"type": "integer"}, - "mx": 5, - "ex": true, - "mn": 2, - "en": true, - "xl": 10, - "nl": 12, - "xi": 42, - "ni": 2, - "ui": true, - "em": [1, 2, 3], - "mo": 2, - "h": { - "g": false, - "i": "path", - "p": "bar" - } - } - }, - "f": { - "upload": { - "m": "application/json,document/pdf", - "r": true, - "mx": 100444, - "ex": true, - "mn": 100, - "en": true, - "h": { - "g": false, - "p": "upload" - } - } - }, - "E": { - "F": [ - { - "n": "contact", - "f": [ - { - "n": "id", - "t": "integer" - }, - { - "n": "email" - }, - { - "n": "location", - "t": "object" - } - ], - "o": false - } - ], - "f": [ - { - "n": "id", - "t": "integer" - }, - { - "n": "name", - "t": "string" - }, - { - "n": "active", - "t": "boolean" - }, - { - "n": "is_admin", - "t": "boolean", - "o": true - } - ], - "V": true, - "k": "pk" - }, - "r": [ - { - "n": "accounts" - }, - { - "t": "many", - "n": "posts" - } - ] - } - } -} diff --git a/tests/data/transport.json b/tests/data/transport.json deleted file mode 100644 index 6c56dee..0000000 --- a/tests/data/transport.json +++ /dev/null @@ -1,223 +0,0 @@ -{ - "m": { - "v": "1.0.0", - "i": "f1b27da9-240b-40e3-99dd-a567e4498ed7", - "d": "2016-04-12T02:49:05.761", - "g": ["12.34.56.78:1234", "http://127.0.0.1:80"], - "o": ["users", "1.0.0", "list"], - "l": 1, - "D": 42, - "f": [["users", "1.0.0", ["create", "update"]]], - "p": { - "foo": "bar" - } - }, - "b": { - "n": "download", - "p": "file:///tmp/document.pdf", - "f": "document.pdf", - "s": 1234567890, - "m": "application/pdf" - }, - "f": { - "http://127.0.0.1:80": { - "users": { - "1.0.0": { - "create": [ - { - "n": "avatar", - "p": "http://12.34.56.78:1234/files/ac3bd4b8-7da3-4c40-8661-746adfa55e0d", - "t": "fb9an6c46be74s425010896fcbd99e2a", - "f": "smiley.jpg", - "s": 1234567890, - "m": "image/jpeg" - }, { - "n": "document", - "p": "file:///tmp/document.pdf", - "f": "document.pdf", - "s": 1234567890, - "m": "application/pdf" - } - ] - } - } - } - }, - "d": { - "http://127.0.0.1:80": { - "users": { - "1.0.0": { - "read_users": [ - {"name": "Foo", "id": 1}, - {"name": "Mandela", "id": 2} - ], - "list_users": [ - [{"name": "Foo", "id": 1}, {"name": "Mandela", "id": 2}] - ] - } - }, - "employees": { - "1.1.0": { - "read_employees": [ - {"name": "Foo", "id": 1}, - {"name": "Mandela", "id": 2} - ] - } - } - } - }, - "r": { - "http://127.0.0.1:80": { - "users": { - "123": { - "http://127.0.0.1:80": { - "posts": ["1", "2"] - } - } - }, - "posts": { - "1": { - "http://127.0.0.1:80": { - "categories": "1" - }, - "ktp://87.65.43.21:4321": { - "comments": ["1", "2", "3"] - } - }, - "2": { - "http://127.0.0.1:80": { - "categories": "2" - }, - "ktp://87.65.43.21:4321": { - "comments": ["4"] - } - } - } - } - }, - "l": { - "http://127.0.0.1:80": { - "users": { - "self": "http://api.example.com/v1/users/123" - } - } - }, - "C": { - "foo": { - "1.0.0": [ - { - "D": 1120, - "n": "bar", - "v": "1.0.0", - "a": "list", - "C": "read" - } - ] - }, - "users": { - "1.0.0": [ - { - "D": 1120, - "n": "posts", - "v": "1.2.0", - "a": "list", - "C": "read", - "p": [ - { - "n": "X-Request-Token", - "v": "ac3bd4b8-7da3-4c40-8661-746adfa55e0d", - "t": "string" - }, - { - "n": "user_id", - "v": 123, - "t": "integer" - } - ] - }, - { - "g": "ktp://87.65.43.21:4321", - "D": 1200, - "n": "comments", - "v": "1.1.0", - "a": "list", - "C": "update", - "x": 3000, - "p": [ - { - "n": "post_id", - "v": 321, - "t": "integer" - } - ] - } - ] - } - }, - "t": { - "c": [ - { - "n": "users", - "v": "1.0.0", - "a": "create", - "C": "save", - "p": [ - { - "n": "user_id", - "v": 123, - "t": "integer" - } - ] - } - ], - "r": [ - { - "n": "users", - "v": "1.0.0", - "a": "create", - "C": "undo", - "p": [ - { - "n": "user_id", - "v": 123, - "t": "integer" - } - ] - } - ], - "C": [ - { - "n": "users", - "v": "1.0.0", - "a": "create", - "C": "cleanup", - "p": [ - { - "n": "user_id", - "v": 123, - "t": "integer" - } - ] - }, - { - "n": "foo", - "v": "1.0.0", - "a": "bar", - "C": "cleanup" - } - ] - }, - "e": { - "http://127.0.0.1:80": { - "users": { - "1.0.0": [ - { - "m": "The user does not exist", - "c": 9, - "s": "404 Not Found" - } - ] - } - } - } -} diff --git a/tests/sdk/http/test_http_request.py b/tests/sdk/http/test_http_request.py deleted file mode 100644 index 09da07f..0000000 --- a/tests/sdk/http/test_http_request.py +++ /dev/null @@ -1,153 +0,0 @@ -from kusanagi.sdk.http.request import HttpRequest -from kusanagi.sdk.file import File -from kusanagi.sdk.file import payload_to_file -from kusanagi.utils import MultiDict - - -def test_sdk_http_request(): - method = 'GET' - url = 'http://foo.com/bar/index/' - request = HttpRequest(method, url) - - assert request.is_method('DELETE') is False - assert request.is_method(method) - assert request.get_method() == method - - assert request.get_url() == url - assert request.get_url_scheme() == 'http' - assert request.get_url_host() == 'foo.com' - assert request.get_url_path() == '/bar/index' # Final "/" is removed - - assert request.get_protocol_version() == '1.1' - assert request.is_protocol_version('1.1') - assert request.is_protocol_version('2.0') is False - - # Create a new request with a protocol version - request = HttpRequest(method, url, protocol_version='2.0') - assert request.get_protocol_version() == '2.0' - assert request.is_protocol_version('2.0') - assert request.is_protocol_version('1.1') is False - - -def test_sdk_http_request_query(): - method = 'GET' - url = 'http://foo.com/bar/index/' - request = HttpRequest(method, url) - - # By default there are no query params - assert request.has_query_param('a') is False - assert request.get_query_param('a') == '' - assert request.get_query_param('a', default='B') == 'B' - assert request.get_query_param_array('a') == [] - assert request.get_query_param_array('a', default=['B']) == ['B'] - assert request.get_query_params() == {} - assert request.get_query_params_array() == {} - - # Create a request with query params - expected = 1 - query = MultiDict({'a': expected, 'b': 2}) - request = HttpRequest(method, url, query=query) - assert request.has_query_param('a') - assert request.get_query_param('a') == expected - assert request.get_query_param('a', default='B') == expected - assert request.get_query_param_array('a') == [expected] - assert request.get_query_param_array('a', default=['B']) == [expected] - assert request.get_query_params() == {'a': expected, 'b': 2} - assert request.get_query_params_array() == query - - -def test_sdk_http_request_post(): - method = 'POST' - url = 'http://foo.com/bar/index/' - request = HttpRequest(method, url) - - # By default there are no query params - assert request.has_post_param('a') is False - assert request.get_post_param('a') == '' - assert request.get_post_param('a', default='B') == 'B' - assert request.get_post_param_array('a') == [] - assert request.get_post_param_array('a', default=['B']) == ['B'] - assert request.get_post_params() == {} - assert request.get_post_params_array() == {} - - # Create a request with query params - expected = 1 - post = MultiDict({'a': expected, 'b': 2}) - request = HttpRequest(method, url, post_data=post) - assert request.has_post_param('a') - assert request.get_post_param('a') == expected - assert request.get_post_param('a', default='B') == expected - assert request.get_post_param_array('a') == [expected] - assert request.get_post_param_array('a', default=['B']) == [expected] - assert request.get_post_params() == {'a': expected, 'b': 2} - assert request.get_post_params_array() == post - - -def test_sdk_http_request_headers(): - method = 'GET' - url = 'http://foo.com/bar/index/' - request = HttpRequest(method, url) - - # By default there are no headers - assert request.has_header('X-Type') is False - assert request.get_header('X-Type') == '' - assert request.get_headers() == {} - - # Create a request with headers - expected = 'RESULT' - expected2 = 'RESULT-2' - headers = MultiDict([('X-Type', expected), ('X-Type', expected2)]) - request = HttpRequest(method, url, headers=headers) - assert request.has_header('X-Type') - assert request.has_header('X-TYPE') - assert request.get_header('X-Missing') == '' - assert request.get_header('X-Missing', default='DEFAULT') == 'DEFAULT' - assert request.get_header('X-Type') == expected # Gets first item - assert request.get_header_array('X-Type') == [expected, expected2] - assert request.get_headers() == {'X-TYPE': expected} - assert request.get_headers_array() == {'X-TYPE': [expected, expected2]} - - -def test_sdk_http_request_body(): - method = 'POST' - url = 'http://foo.com/bar/index/' - request = HttpRequest(method, url) - - # By default body is empty - assert request.has_body() is False - assert request.get_body() == '' - - # Create a request with a body - expected = 'CONTENT' - request = HttpRequest(method, url, body=expected) - assert request.has_body() - assert request.get_body() == expected - - -def test_sdk_http_request_files(): - method = 'POST' - url = 'http://foo.com/bar/index/' - request = HttpRequest(method, url) - - # By default there are no files - assert request.has_file('test') is False - assert list(request.get_files()) == [] - - # When file does not exist return an empty file object - file = request.get_file('test') - assert isinstance(file, File) - assert file.get_name() == 'test' - assert file.get_path() == '' - - # Create a request with a file - file = payload_to_file({ - 'name': 'test', - 'path': '/files', - 'mime': 'application/json', - 'filename': 'test.json', - 'size': '100', - }) - request = HttpRequest(method, url, files=MultiDict([('test', file)])) - assert request.has_file('test') - assert request.get_file('test') == file - assert list(request.get_files()) == [file] diff --git a/tests/sdk/http/test_http_response.py b/tests/sdk/http/test_http_response.py deleted file mode 100644 index 377e3e3..0000000 --- a/tests/sdk/http/test_http_response.py +++ /dev/null @@ -1,89 +0,0 @@ -from kusanagi.sdk.http.response import HttpResponse - - -def test_sdk_http_response_protocol(): - response = HttpResponse(200, 'OK') - - # Check default protocol - assert response.get_protocol_version() == '1.1' - response.set_protocol_version(None) - assert response.get_protocol_version() == '1.1' - - # Set a new protocol version - response.set_protocol_version('2.0') - assert response.get_protocol_version() == '2.0' - - assert response.is_protocol_version('1.1') is False - assert response.is_protocol_version('2.0') - - # Create a response with a protocol version - response = HttpResponse(200, 'OK', protocol_version='2.0') - assert response.get_protocol_version() == '2.0' - - -def test_sdk_http_response_status(): - response = HttpResponse(200, 'OK') - assert response.is_status('200 OK') - assert response.get_status() == '200 OK' - assert response.get_status_code() == 200 - assert response.get_status_text() == 'OK' - - response.set_status(500, 'Internal Server Error') - assert response.is_status('500 Internal Server Error') - assert response.get_status() == '500 Internal Server Error' - assert response.get_status_code() == 500 - assert response.get_status_text() == 'Internal Server Error' - - -def test_sdk_http_response_headers(): - response = HttpResponse(200, 'OK') - - # By default there are no headers - assert response.has_header('X-Type') is False - assert response.get_header('X-Type') == '' - assert response.get_headers() == {} - - # Set a new header - expected = 'RESULT' - assert response.set_header('X-Type', expected) == response - assert response.has_header('X-Type') - assert response.has_header('X-TYPE') - assert response.get_header('X-Type') == expected - assert response.get_header_array('X-Type') == [expected] - assert response.get_headers() == {'X-Type': expected} - assert response.get_headers_array() == {'X-Type': [expected]} - - # Duplicate a header - expected2 = 'RESULT-2' - response.set_header('X-Type', expected2) - assert response.has_header('X-Type') - assert response.get_header('X-Type') == expected # Gets first item - assert response.get_header_array('X-Type') == [expected, expected2] - assert response.get_headers() == {'X-Type': expected} - assert response.get_headers_array() == {'X-Type': [expected, expected2]} - - # Create a response with headers - response = HttpResponse(200, 'OK', headers={'X-Type': [expected]}) - assert response.has_header('X-Type') - assert response.get_header('X-Type') == expected - assert response.get_headers() == {'X-Type': expected} - - -def test_sdk_http_response_body(): - response = HttpResponse(200, 'OK') - - # By default body is empty - assert response.get_body() == '' - assert response.has_body() is False - - # Set a content for the response body - expected = 'CONTENT' - assert response.set_body(content=expected) == response - assert response.get_body() == expected - assert response.has_body() - - # Create a response with body - response = HttpResponse(200, 'OK', body=expected) - assert response.set_body(content=expected) == response - assert response.get_body() == expected - assert response.has_body() diff --git a/tests/sdk/schema/test_schema_action.py b/tests/sdk/schema/test_schema_action.py deleted file mode 100644 index d18a949..0000000 --- a/tests/sdk/schema/test_schema_action.py +++ /dev/null @@ -1,165 +0,0 @@ -import pytest - -from kusanagi.sdk.schema.action import ActionSchema -from kusanagi.sdk.schema.action import ActionSchemaError -from kusanagi.sdk.schema.action import HttpActionSchema -from kusanagi.sdk.schema.file import FileSchema -from kusanagi.sdk.schema.param import ParamSchema -from kusanagi.payload import Payload - - -def test_sdk_schema_action_defaults(): - action = ActionSchema('foo', {}) - - assert action.get_name() == 'foo' - assert action.get_timeout() == 30000 - assert not action.is_deprecated() - assert not action.is_collection() - assert action.get_entity_path() == '' - assert action.get_path_delimiter() == '/' - assert action.resolve_entity({}) == {} - assert not action.has_entity_definition() - assert action.get_entity() == {} - assert not action.has_relations() - assert action.get_relations() == [] - assert not action.has_call('foo') - assert not action.has_calls() - assert action.get_calls() == [] - assert not action.has_defer_call('foo') - assert not action.has_defer_calls() - assert action.get_defer_calls() == [] - assert not action.has_remote_call('ktp://87.65.43.21:4321') - assert not action.has_remote_calls() - assert action.get_remote_calls() == [] - assert not action.has_return() - assert action.get_tags() == [] - assert not action.has_tag('foo') - assert action.get_params() == [] - assert not action.has_param('foo') - - with pytest.raises(ActionSchemaError): - assert action.get_return_type() - - with pytest.raises(ActionSchemaError): - action.get_param_schema('foo') - - assert action.get_files() == [] - assert not action.has_file('foo') - - with pytest.raises(ActionSchemaError): - action.get_file_schema('foo') - - http_schema = action.get_http_schema() - assert isinstance(http_schema, HttpActionSchema) - assert http_schema.is_accessible() - assert http_schema.get_method() == 'get' - assert http_schema.get_path() == '' - assert http_schema.get_input() == 'query' - assert http_schema.get_body() == 'text/plain' - - -def test_sdk_schema_action(read_json): - # Get schema info for 'foo' action - payload = Payload(read_json('schema-service')) - assert payload.path_exists('actions/foo') - payload = payload.get('actions/foo') - assert isinstance(payload, dict) - payload = Payload(payload) - - action = ActionSchema('foo', payload) - - assert action.get_name() == 'foo' - assert action.get_timeout() == 2000 - assert action.is_deprecated() - assert action.is_collection() - assert action.get_entity_path() == payload.get('entity_path') - assert action.get_path_delimiter() == payload.get('path_delimiter') - assert action.resolve_entity({'foo': {'bar': 'OK'}}) == 'OK' - # Resolve an invalid entity - with pytest.raises(ActionSchemaError): - action.resolve_entity({'foo': {'MISSING': 'OK'}}) - - # Check entity schema related methods - assert action.has_entity_definition() - entity = action.get_entity() - assert isinstance(entity, dict) - assert len(entity) == 3 - assert sorted(entity.keys()) == ['field', 'fields', 'validate'] - - # Check return value - assert action.has_return() - assert action.get_return_type() == payload.get('return/type') - - # Check tags - tags = action.get_tags() - assert len(tags) == 2 - for tag in ['foo', 'bar']: - assert tag in tags - - # Check relations - assert action.has_relations() - assert sorted(action.get_relations()) == [ - ['many', 'posts'], - ['one', 'accounts'], - ] - - # Check runtime calls - assert action.has_calls() - assert sorted(action.get_calls()) == [ - ['bar', '1.1', 'dummy'], - ['foo', '1.0', 'dummy'], - ] - assert action.has_call('foo', '1.0', 'dummy') - # Check invalid local call arguments - assert not action.has_call('MISSING') - assert not action.has_call('foo', 'MISSING') - assert not action.has_call('foo', '1.0', 'MISSING') - - # Check deferred calls - assert action.has_defer_calls() - assert sorted(action.get_defer_calls()) == [ - ['bar', '1.1', 'dummy'], - ['foo', '1.0', 'dummy'], - ] - assert action.has_defer_call('foo', '1.0', 'dummy') - # Check invalid local call arguments - assert not action.has_defer_call('MISSING') - assert not action.has_defer_call('foo', 'MISSING') - assert not action.has_defer_call('foo', '1.0', 'MISSING') - - # Check files - assert action.has_file('upload') - assert action.get_files() == ['upload'] - - # Check params - assert action.has_param('value') - assert action.get_params() == ['value'] - - # Check remote calls - assert action.has_remote_calls() - remote = 'ktp://87.65.43.21:4321' - remote_calls = [[remote, 'foo', '1.0', 'dummy']] - assert sorted(action.get_remote_calls()) == remote_calls - assert action.has_remote_call(*remote_calls[0]) - # Check invalid remote call arguments - assert not action.has_remote_call('MISSING') - assert not action.has_remote_call(remote, 'MISSING') - assert not action.has_remote_call(remote, 'foo', 'MISSING') - assert not action.has_remote_call(remote, 'foo', '1.0', 'MISSING') - - # Check HTTP schema - http_schema = action.get_http_schema() - assert isinstance(http_schema, HttpActionSchema) - assert not http_schema.is_accessible() - assert http_schema.get_method() == payload.get('http/method') - assert http_schema.get_path() == payload.get('http/path') - assert http_schema.get_input() == payload.get('http/input') - assert http_schema.get_body().split(',') == payload.get('http/body') - - # Check file schema - file_schema = action.get_file_schema('upload') - assert isinstance(file_schema, FileSchema) - - # Check param schema - param_schema = action.get_param_schema('value') - assert isinstance(param_schema, ParamSchema) diff --git a/tests/sdk/schema/test_schema_file.py b/tests/sdk/schema/test_schema_file.py deleted file mode 100644 index 56e870f..0000000 --- a/tests/sdk/schema/test_schema_file.py +++ /dev/null @@ -1,40 +0,0 @@ -import sys - -from kusanagi.sdk.schema.file import FileSchema -from kusanagi.sdk.schema.file import HttpFileSchema -from kusanagi.payload import Payload - - -def test_sdk_schema_file(read_json): - # Check file schema defaults - schema = FileSchema('foo', {}) - assert schema.get_name() == 'foo' - assert schema.get_mime() == 'text/plain' - assert not schema.is_required() - assert schema.get_max() == sys.maxsize - assert not schema.is_exclusive_max() - assert schema.get_min() == 0 - assert not schema.is_exclusive_min() - - http_schema = schema.get_http_schema() - assert isinstance(http_schema, HttpFileSchema) - assert http_schema.is_accessible() - assert http_schema.get_param() == schema.get_name() - - # Create a payload with file schema data - payload = Payload(read_json('schema-file')) - - # Check file schema with values - schema = FileSchema('foo', payload) - assert schema.get_name() == 'foo' - assert schema.get_mime() == payload.get('mime') - assert schema.is_required() - assert schema.get_max() == payload.get('max') - assert schema.is_exclusive_max() - assert schema.get_min() == payload.get('min') - assert schema.is_exclusive_min() - - http_schema = schema.get_http_schema() - assert isinstance(http_schema, HttpFileSchema) - assert not http_schema.is_accessible() - assert http_schema.get_param() == payload.get('http/param') diff --git a/tests/sdk/schema/test_schema_param.py b/tests/sdk/schema/test_schema_param.py deleted file mode 100644 index 5ff6f8b..0000000 --- a/tests/sdk/schema/test_schema_param.py +++ /dev/null @@ -1,66 +0,0 @@ -import sys - -from kusanagi.sdk.schema.param import ParamSchema -from kusanagi.sdk.schema.param import HttpParamSchema -from kusanagi.payload import Payload - - -def test_sdk_schema_param(read_json): - # Check param schema defaults - schema = ParamSchema('foo', {}) - assert schema.get_name() == 'foo' - assert schema.get_type() == 'string' - assert schema.get_format() == '' - assert schema.get_array_format() == 'csv' - assert schema.get_pattern() == '' - assert not schema.allow_empty() - assert not schema.has_default_value() - assert schema.get_default_value() is None - assert not schema.is_required() - assert schema.get_items() == {} - assert schema.get_max() == sys.maxsize - assert not schema.is_exclusive_max() - assert schema.get_min() == -sys.maxsize - 1 - assert not schema.is_exclusive_min() - assert schema.get_max_items() == -1 - assert schema.get_min_items() == -1 - assert not schema.has_unique_items() - assert schema.get_enum() == [] - assert schema.get_multiple_of() == -1 - - http_schema = schema.get_http_schema() - assert isinstance(http_schema, HttpParamSchema) - assert http_schema.is_accessible() - assert http_schema.get_input() == 'query' - assert http_schema.get_param() == schema.get_name() - - # Create a payload with param schema data - payload = Payload(read_json('schema-param')) - - # Check param schema with values - schema = ParamSchema('foo', payload) - assert schema.get_name() == 'foo' - assert schema.get_type() == payload.get('type') - assert schema.get_format() == payload.get('format') - assert schema.get_array_format() == payload.get('array_format') - assert schema.get_pattern() == payload.get('pattern') - assert schema.allow_empty() - assert schema.has_default_value() - assert schema.get_default_value() == payload.get('default_value') - assert schema.is_required() - assert schema.get_items() == payload.get('items') - assert schema.get_max() == payload.get('max') - assert schema.is_exclusive_max() - assert schema.get_min() == payload.get('min') - assert schema.is_exclusive_min() - assert schema.get_max_items() == payload.get('max_items') - assert schema.get_min_items() == payload.get('min_items') - assert schema.has_unique_items() - assert schema.get_enum() == payload.get('enum') - assert schema.get_multiple_of() == payload.get('multiple_of') - - http_schema = schema.get_http_schema() - assert isinstance(http_schema, HttpParamSchema) - assert not http_schema.is_accessible() - assert http_schema.get_input() == payload.get('http/input') - assert http_schema.get_param() == payload.get('http/param') diff --git a/tests/sdk/schema/test_schema_service.py b/tests/sdk/schema/test_schema_service.py deleted file mode 100644 index a17864b..0000000 --- a/tests/sdk/schema/test_schema_service.py +++ /dev/null @@ -1,48 +0,0 @@ -import pytest - -from kusanagi.sdk.schema.action import ActionSchema -from kusanagi.sdk.schema.service import HttpServiceSchema -from kusanagi.sdk.schema.service import ServiceSchema -from kusanagi.sdk.schema.service import ServiceSchemaError -from kusanagi.payload import Payload - - -def test_sdk_schema_service_defaults(): - service = ServiceSchema('foo', '1.0', {}) - - assert service.get_name() == 'foo' - assert service.get_version() == '1.0' - assert not service.has_file_server() - assert service.get_actions() == [] - assert not service.has_action('bar') - - # By default there are no action schemas because payload is empty - with pytest.raises(ServiceSchemaError): - service.get_action_schema('bar') - - http_schema = service.get_http_schema() - assert isinstance(http_schema, HttpServiceSchema) - assert http_schema.is_accessible() - assert http_schema.get_base_path() == '' - - -def test_sdk_schema_service(read_json): - payload = Payload(read_json('schema-service')) - service = ServiceSchema('foo', '1.0', payload) - - assert service.get_name() == 'foo' - assert service.get_version() == '1.0' - assert service.has_file_server() - assert sorted(service.get_actions()) == ['defaults', 'foo'] - assert service.has_action('foo') - assert service.has_action('defaults') - - # Check action schema - action_schema = service.get_action_schema('foo') - assert isinstance(action_schema, ActionSchema) - - # Check HTTP schema - http_schema = service.get_http_schema() - assert isinstance(http_schema, HttpServiceSchema) - assert not http_schema.is_accessible() - assert http_schema.get_base_path() == payload.get('http/base_path') diff --git a/tests/sdk/test_action.py b/tests/sdk/test_action.py deleted file mode 100644 index 0154db8..0000000 --- a/tests/sdk/test_action.py +++ /dev/null @@ -1,823 +0,0 @@ -import pytest - -from kusanagi.sdk.action import Action -from kusanagi.sdk.action import NoFileServerError -from kusanagi.sdk.action import parse_params -from kusanagi.sdk.action import ReturnTypeError -from kusanagi.sdk.action import UndefinedReturnValueError -from kusanagi.sdk.file import File -from kusanagi.sdk.file import file_to_payload -from kusanagi.sdk.param import Param -from kusanagi.sdk.param import TYPE_INTEGER -from kusanagi.sdk.param import TYPE_STRING -from kusanagi.payload import delete_path -from kusanagi.payload import ErrorPayload -from kusanagi.payload import FIELD_MAPPINGS -from kusanagi.payload import get_path -from kusanagi.payload import Payload -from kusanagi.schema import SchemaRegistry -from kusanagi.utils import nomap - -# Mapped parameter names for payload -PARAM = { - 'name': FIELD_MAPPINGS['name'], - 'value': FIELD_MAPPINGS['value'], - 'type': FIELD_MAPPINGS['type'], - } - - -def test_sdk_parse_params(): - # Falsy value should return empty - assert parse_params(None) == [] - assert parse_params([]) == [] - - # Params must be a list - with pytest.raises(TypeError): - assert parse_params(123) - - # Params must be a list of Param instances - with pytest.raises(TypeError): - assert parse_params([123]) - - # Check that parameters are converted to payload - parsed = parse_params([ - Param('foo', value=1, type=Param.TYPE_INTEGER), - Param('bar', value=2, type=Param.TYPE_INTEGER), - ]) - assert parsed == [ - {PARAM['name']: 'foo', PARAM['value']: 1, PARAM['type']: TYPE_INTEGER}, - {PARAM['name']: 'bar', PARAM['value']: 2, PARAM['type']: TYPE_INTEGER}, - ] - - -def test_sdk_action(read_json, registry): - transport = Payload(read_json('transport.json')) - params = [ - {PARAM['name']: 'foo', PARAM['value']: 1, PARAM['type']: TYPE_INTEGER}, - {PARAM['name']: 'bar', PARAM['value']: 2, PARAM['type']: TYPE_INTEGER}, - ] - action = Action(**{ - 'action': 'test', - 'params': params, - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - }) - - assert action.get_action_name() == 'test' - - # Check request origin - assert not action.is_origin() - transport.set('meta/origin', ['dummy', '1.0', 'test']) - assert action.is_origin() - - # Check setting of transport properties - assert action.set_property('name', 'value') == action - properties = transport.get('meta/properties', default=None) - assert isinstance(properties, dict) - assert properties.get('name') == 'value' - - # Property values must be strings - with pytest.raises(TypeError): - action.set_property('other', 1) - - -def test_sdk_action_params(read_json, registry): - transport = Payload(read_json('transport.json')) - params = [ - {PARAM['name']: 'foo', PARAM['value']: 1, PARAM['type']: TYPE_INTEGER}, - {PARAM['name']: 'bar', PARAM['value']: 2, PARAM['type']: TYPE_INTEGER}, - ] - action = Action(**{ - 'action': 'test', - 'params': params, - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - }) - - # Check action parameters - assert action.has_param('foo') - assert not action.has_param('missing') - param = action.get_param('foo') - assert isinstance(param, Param) - assert param.exists() - assert param.get_name() == 'foo' - assert param.get_value() == 1 - assert param.get_type() == TYPE_INTEGER - # Get a param that does not exist - param = action.get_param('missing') - assert isinstance(param, Param) - assert not param.exists() - assert param.get_name() == 'missing' - assert param.get_value() == '' - assert param.get_type() == TYPE_STRING - - # Get all parameters - params = action.get_params() - assert isinstance(params, list) - assert len(params) == 2 - for param in params: - assert param.exists() - assert param.get_name() in ('foo', 'bar') - assert param.get_value() in (1, 2) - assert param.get_type() == TYPE_INTEGER - - # Clear all params and check result - action._Action__params = {} - params = action.get_params() - assert isinstance(params, list) - assert len(params) == 0 - - # Check param creation - param = action.new_param('foo', value=1, type=TYPE_INTEGER) - assert isinstance(params, list) - assert not param.exists() - assert param.get_name() == 'foo' - assert param.get_value() == 1 - assert param.get_type() == TYPE_INTEGER - # Check type guessing - param = action.new_param('foo', value='bar') - assert isinstance(params, list) - assert param.get_name() == 'foo' - assert param.get_value() == 'bar' - assert param.get_type() == TYPE_STRING - # Check handling of wrong type - with pytest.raises(TypeError): - action.new_param('foo', value='bar', type=TYPE_INTEGER) - - -def test_sdk_action_files(read_json, registry): - transport = Payload(read_json('transport.json')) - service_name = 'users' - service_version = '1.0.0' - action_name = 'create' - - action = Action(**{ - 'action': action_name, - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': service_version, - 'framework_version': '1.0.0', - }) - - assert action.has_file('avatar') - - # Get a file that is not available - assert not action.has_file('missing') - file = action.get_file('missing') - assert isinstance(file, File) - assert file.get_name() == 'missing' - assert file.get_path() == '' - assert not file.exists() - - # Get an existing file - assert action.has_file('document') - file = action.get_file('document') - assert isinstance(file, File) - assert file.get_name() == 'document' - assert file.get_mime() == 'application/pdf' - - # Get all files - files = action.get_files() - assert isinstance(files, list) - assert len(files) == 2 - for file in files: - assert file.get_name() in ('avatar', 'document') - assert file.get_mime() in ('application/pdf', 'image/jpeg') - - # Clear all files and check result - action._Action__files = {} - files = action.get_files() - assert isinstance(files, list) - assert len(files) == 0 - - # Check file creation - file = action.new_file('foo', path='/tmp/file.txt') - assert isinstance(file, File) - assert file.get_name() == 'foo' - - -def test_sdk_action_download(read_json, registry): - service_name = 'dummy' - service_version = '1.0' - transport = Payload(read_json('transport.json')) - action = Action(**{ - 'action': 'test', - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': service_version, - 'framework_version': '1.0.0', - }) - - # Download accepts only a File instance - with pytest.raises(TypeError): - action.set_download('') - - # Create a new file and set is as download - file = action.new_file('foo', path='/tmp/file.txt') - transport.set('body', '') - assert transport.get('body') == '' - action.set_download(file) - assert transport.get('body') == file_to_payload(file) - - # Clear download - transport.set('body', '') - assert transport.get('body') == '' - - # Check that registry does not have mappings - assert not registry.has_mappings - # Set file server mappings to False and try to set a download - registry.update_registry({ - service_name: {service_version: {'files': False}} - }) - with pytest.raises(NoFileServerError): - action.set_download(file) - - -def test_sdk_action_data(read_json, registry): - transport = Payload(read_json('transport.json')) - address = transport.get('meta/gateway')[1] - service_name = 'users' - service_version = '1.0.0' - action_name = 'create' - data_path = '|'.join([ - 'data', - address, - nomap(service_name), - service_version, - nomap(action_name), - ]) - action = Action(**{ - 'action': action_name, - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': service_version, - 'framework_version': '1.0.0', - }) - - # Clear transport data - assert FIELD_MAPPINGS['data'] in transport - del transport[FIELD_MAPPINGS['data']] - assert FIELD_MAPPINGS['data'] not in transport - assert not transport.path_exists(data_path, delimiter='|') - - # Set a transport entity - entity = {'foo': 'bar'} - assert action.set_entity(entity) == action - assert transport.path_exists(data_path, delimiter='|') - assert transport.get(data_path, delimiter='|') == [entity] - # Set another entity - assert action.set_entity(entity) == action - assert transport.get(data_path, delimiter='|') == [entity, entity] - - # Check that entity can only be a dictionary - with pytest.raises(TypeError): - action.set_entity(1) - - # Clear transport data - assert FIELD_MAPPINGS['data'] in transport - del transport[FIELD_MAPPINGS['data']] - assert FIELD_MAPPINGS['data'] not in transport - assert not transport.path_exists(data_path, delimiter='|') - - # Set a transport collection - collection = [{'foo': 1}, {'bar': 2}] - assert action.set_collection(collection) == action - assert transport.path_exists(data_path, delimiter='|') - assert transport.get(data_path, delimiter='|') == [collection] - # Set another collection - assert action.set_collection(collection) == action - assert transport.get(data_path, delimiter='|') == [collection, collection] - - # Check that collection can only be list - with pytest.raises(TypeError): - action.set_collection(1) - - # Items in a collection can only be dict - with pytest.raises(TypeError): - action.set_collection([1]) - - -def test_sdk_action_relate(read_json, registry): - transport = Payload(read_json('transport.json')) - address = transport.get('meta/gateway')[1] - service_name = 'foo' - pk = '1' - rel_path_tpl = '|'.join([ - 'relations', - address, - nomap(service_name), - nomap(pk), - '{}', # Placeholder for address - service_name, - ]) - action = Action(**{ - 'action': 'bar', - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': '1.0', - 'framework_version': '1.0.0', - }) - - # Clear transport relations - assert FIELD_MAPPINGS['relations'] in transport - del transport[FIELD_MAPPINGS['relations']] - assert FIELD_MAPPINGS['relations'] not in transport - - # Format relations path for local relations - rel_path = rel_path_tpl.format(address) - - # Check relate one - assert transport.get(rel_path, default='NO', delimiter='|') == 'NO' - assert action.relate_one(pk, service_name, '321') == action - assert transport.get(rel_path, delimiter='|') == '321' - - # Clear transport relations - del transport[FIELD_MAPPINGS['relations']] - - # Check relate many - fkeys = ['321', '123'] - assert transport.get(rel_path, default='NO', delimiter='|') == 'NO' - assert action.relate_many(pk, service_name, fkeys) == action - assert transport.get(rel_path, delimiter='|') == fkeys - - # Check that relate many fails when a list os not given - with pytest.raises(TypeError): - action.relate_many(pk, service_name, 1) - - # Clear transport relations - del transport[FIELD_MAPPINGS['relations']] - - # Format relations path for remote relations - remote = 'ktp://87.65.43.21:4321' - rel_path = rel_path_tpl.format(remote) - - # Check relate one remote - assert transport.get(rel_path, default='NO', delimiter='|') == 'NO' - assert action.relate_one_remote(pk, remote, service_name, '321') == action - assert transport.get(rel_path, delimiter='|') == '321' - - # Clear transport relations - del transport[FIELD_MAPPINGS['relations']] - - # Check relate many - assert transport.get(rel_path, default='NO', delimiter='|') == 'NO' - assert action.relate_many_remote(pk, remote, service_name, fkeys) == action - assert transport.get(rel_path, delimiter='|') == fkeys - - # Check that relate many fails when a list os not given - with pytest.raises(TypeError): - action.relate_many_remote(pk, remote, service_name, 1) - - -def test_sdk_action_links(read_json, registry): - transport = Payload(read_json('transport.json')) - address = transport.get('meta/gateway')[1] - service_name = 'foo' - link_name = 'self' - links_path = '|'.join([ - 'links', - address, - nomap(service_name), - nomap(link_name), - ]) - action = Action(**{ - 'action': 'bar', - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': '1.0', - 'framework_version': '1.0.0', - }) - - # Clear transport links - assert FIELD_MAPPINGS['links'] in transport - del transport[FIELD_MAPPINGS['links']] - assert FIELD_MAPPINGS['links'] not in transport - assert not transport.path_exists(links_path, delimiter='|') - - # Set a link - uri = 'http://api.example.com/v1/users/123' - assert action.set_link(link_name, uri) == action - assert transport.path_exists(links_path, delimiter='|') - assert transport.get(links_path, delimiter='|') == uri - - -def test_sdk_action_transactions(read_json, registry): - transport = Payload(read_json('transport.json')) - service_name = 'foo' - service_version = '1.0' - service_action = 'foo' - params = [Param('dummy', value=123, type=Param.TYPE_INTEGER)] - action = Action(**{ - 'action': service_action, - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': service_version, - 'framework_version': '1.0.0', - }) - - # Clear transport transactions - assert transport.path_exists('transactions') - del transport[FIELD_MAPPINGS['transactions']] - assert not transport.path_exists('transactions') - - tr_params = [{ - PARAM['name']: 'dummy', - PARAM['value']: 123, - PARAM['type']: TYPE_INTEGER - }] - actions = ('action-1', 'action-2') - - cases = { - 'commit': action.commit, - 'rollback': action.rollback, - 'complete': action.complete, - } - - # Check all transaction types - for type, register in cases.items(): - # Register 2 transaction actions for current type - for name in actions: - assert register(name, params=params) == action - - path = 'transactions/{}'.format(type) - assert transport.path_exists(path) - transactions = transport.get(path) - assert isinstance(transactions, list) - for tr in transactions: - assert isinstance(tr, dict) - assert get_path(tr, 'name', default='NO') == service_name - assert get_path(tr, 'version', default='NO') == service_version - assert get_path(tr, 'action', default='NO') in actions - assert get_path(tr, 'caller', default='NO') == action.get_action_name() - assert get_path(tr, 'params', default='NO') == tr_params - - -def test_sdk_action_return_value(read_json, registry): - service_name = 'foo' - service_version = '1.0' - transport = Payload(read_json('transport.json')) - return_value = Payload() - action_args = { - 'action': 'foo', - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': service_version, - 'framework_version': '1.0.0', - 'return_value': return_value, - } - - # By default return value is set when no schema is available - action = Action(**action_args) - assert action.set_return(1) == action - assert return_value.get('return') == 1 - - # Check that registry does not have mappings - assert not registry.has_mappings - # Add an empty test action to mappings - mappings = Payload(read_json('schema-service.json')) - registry.update_registry({service_name: {service_version: mappings}}) - - # Set return when mappings contain a return definition for the action - action = Action(**action_args) - assert action.set_return(1) == action - assert action_args['return_value'].get('return') == 1 - - # Set an invalid return value type - with pytest.raises(ReturnTypeError): - action.set_return('fail') - - # Set a return value when no return definition exists for the action - assert mappings.path_exists('actions/foo/return') - delete_path(mappings, 'actions/foo/return') - assert not mappings.path_exists('actions/foo/return') - action = Action(**action_args) - with pytest.raises(UndefinedReturnValueError): - action.set_return(1) - - -def test_sdk_action_defer_call(read_json, registry): - service_name = 'foo' - service_version = '1.0' - - # Check that registry does not have mappings - assert not registry.has_mappings - # Add an empty test action to mappings - registry.update_registry({ - service_name: { - service_version: { - FIELD_MAPPINGS['files']: True, - FIELD_MAPPINGS['actions']: {'test': {}}, - }, - }, - }) - - transport = Payload(read_json('transport.json')) - calls_path = 'calls/{}/{}'.format(nomap(service_name), service_version) - action = Action(**{ - 'action': 'test', - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': service_version, - 'framework_version': '1.0.0', - }) - - # Clear transport calls - assert transport.path_exists('calls') - del transport[FIELD_MAPPINGS['calls']] - assert not transport.path_exists('calls') - - # Prepare call arguments - params = [Param('dummy', value=123, type=Param.TYPE_INTEGER)] - c_name = 'foo' - c_version = '1.1' - c_action = 'bar' - c_params = [{ - PARAM['name']: 'dummy', - PARAM['value']: 123, - PARAM['type']: TYPE_INTEGER - }] - - # Make a call - assert action.defer_call(c_name, c_version, c_action, params=params) == action - assert transport.path_exists(calls_path) - calls = transport.get(calls_path) - assert isinstance(calls, list) - assert len(calls) == 1 - call = calls[0] - assert isinstance(call, dict) - assert get_path(call, 'name', default='NO') == c_name - assert get_path(call, 'version', default='NO') == c_version - assert get_path(call, 'action', default='NO') == c_action - assert get_path(call, 'caller', default='NO') == action.get_action_name() - assert get_path(call, 'params', default='NO') == c_params - - # Make a call and add files - files_path = '|'.join([ - 'files', - transport.get('meta/gateway')[1], - nomap(c_name), - c_version, - nomap(c_action), - ]) - files = [action.new_file('download', '/tmp/file.txt')] - assert action.defer_call(c_name, c_version, c_action, files=files) == action - tr_files = transport.get(files_path, delimiter='|') - assert isinstance(tr_files, list) - assert len(tr_files) == 1 - assert tr_files[0] == { - FIELD_MAPPINGS['name']: 'download', - FIELD_MAPPINGS['token']: '', - FIELD_MAPPINGS['filename']: 'file.txt', - FIELD_MAPPINGS['size']: 0, - FIELD_MAPPINGS['mime']: 'text/plain', - FIELD_MAPPINGS['path']: 'file:///tmp/file.txt', - } - - # Set file server mappings to False and try to call with local files - registry.update_registry({ - service_name: { - service_version: { - FIELD_MAPPINGS['files']: False, - FIELD_MAPPINGS['actions']: {'test': {}}, - }, - }, - }) - - # TODO: Figure out why existing action does not see new mappungs. - # Action should read the mapping values from previous statement. - action = Action(**{ - 'action': 'test', - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': service_version, - 'framework_version': '1.0.0', - }) - - with pytest.raises(NoFileServerError): - action.defer_call(c_name, c_version, c_action, files=files) - - -def test_sdk_action_call_remote(read_json, registry): - service_name = 'foo' - service_version = '1.0' - - # Check that registry does not have mappings - assert not registry.has_mappings - # Add an empty test action to mappings - registry.update_registry({ - service_name: { - service_version: { - FIELD_MAPPINGS['files']: True, - FIELD_MAPPINGS['actions']: {'test': {}}, - }, - }, - }) - - transport = Payload(read_json('transport.json')) - calls_path = 'calls/{}/{}'.format(nomap(service_name), service_version) - action = Action(**{ - 'action': 'test', - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': service_version, - 'framework_version': '1.0.0', - }) - - # Clear transport calls - assert transport.path_exists('calls') - del transport[FIELD_MAPPINGS['calls']] - assert not transport.path_exists('calls') - - # Prepare call arguments - params = [Param('dummy', value=123, type=Param.TYPE_INTEGER)] - c_addr = '87.65.43.21:4321' - c_name = 'foo' - c_version = '1.1' - c_action = 'bar' - c_params = [{ - PARAM['name']: 'dummy', - PARAM['value']: 123, - PARAM['type']: TYPE_INTEGER - }] - - # Make a remotr call - kwargs = { - 'address': c_addr, - 'service': c_name, - 'version': c_version, - 'action': c_action, - 'params': params, - 'timeout': 2.0, - } - assert action.remote_call(**kwargs) == action - assert transport.path_exists(calls_path) - calls = transport.get(calls_path) - assert isinstance(calls, list) - assert len(calls) == 1 - call = calls[0] - assert isinstance(call, dict) - assert get_path(call, 'gateway', default='NO') == 'ktp://{}'.format(c_addr) - assert get_path(call, 'name', default='NO') == c_name - assert get_path(call, 'version', default='NO') == c_version - assert get_path(call, 'action', default='NO') == c_action - assert get_path(call, 'params', default='NO') == c_params - - # Make a call and add files - files_path = '|'.join([ - 'files', - transport.get('meta/gateway')[1], - nomap(c_name), - c_version, - nomap(c_action), - ]) - kwargs['files'] = [action.new_file('download', '/tmp/file.txt')] - assert action.remote_call(**kwargs) == action - tr_files = transport.get(files_path, delimiter='|') - assert isinstance(tr_files, list) - assert len(tr_files) == 1 - assert tr_files[0] == { - FIELD_MAPPINGS['name']: 'download', - FIELD_MAPPINGS['token']: '', - FIELD_MAPPINGS['filename']: 'file.txt', - FIELD_MAPPINGS['size']: 0, - FIELD_MAPPINGS['mime']: 'text/plain', - FIELD_MAPPINGS['path']: 'file:///tmp/file.txt', - } - - # Set file server mappings to False and try to call with local files - registry.update_registry({ - service_name: { - service_version: { - FIELD_MAPPINGS['files']: False, - FIELD_MAPPINGS['actions']: {'test': {}}, - }, - }, - }) - - # TODO: Figure out why existing action does not see new mappungs. - # Action should read the mapping values from previous statement. - action = Action(**{ - 'action': 'test', - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': service_version, - 'framework_version': '1.0.0', - }) - - with pytest.raises(NoFileServerError): - action.remote_call(**kwargs) - - -def test_sdk_action_errors(read_json, registry): - transport = Payload(read_json('transport.json')) - address = transport.get('meta/gateway')[1] - service_name = 'foo' - service_version = '1.0' - errors_path = '|'.join([ - 'errors', - address, - nomap(service_name), - service_version, - ]) - action = Action(**{ - 'action': 'bar', - 'params': [], - 'transport': transport, - 'component': None, - 'path': '/path/to/file.py', - 'name': service_name, - 'version': '1.0', - 'framework_version': '1.0.0', - }) - - # Clear transport errors - assert FIELD_MAPPINGS['errors'] in transport - del transport[FIELD_MAPPINGS['errors']] - assert FIELD_MAPPINGS['errors'] not in transport - assert not transport.path_exists(errors_path, delimiter='|') - - # Set an error - msg = 'Error message' - code = 99 - status = '500 Internal Server Error' - assert action.error(msg, code=code, status=status) == action - assert transport.path_exists(errors_path, delimiter='|') - errors = transport.get(errors_path, delimiter='|') - assert isinstance(errors, list) - assert len(errors) == 1 - error = errors[0] - assert isinstance(error, ErrorPayload) - assert error.get('message') == msg - assert error.get('code') == code - assert error.get('status') == status - - # Add a second error - assert action.error(msg, code=code, status=status) == action - errors = transport.get(errors_path, delimiter='|') - assert isinstance(errors, list) - assert len(errors) == 2 - for error in errors: - assert isinstance(error, ErrorPayload) - - -def test_action_log(mocker, logs, read_json): - SchemaRegistry() - - values = { - 'action': 'bar', - 'params': [], - 'transport': Payload(read_json('transport.json')), - 'component': None, - 'path': '/path/to/file.py', - 'name': 'test', - 'version': '1.0', - 'framework_version': '1.0.0', - 'debug': True, - } - action = Action(**values) - assert action.is_debug() - log_message = 'Test log message' - action.log(log_message) - out = logs.getvalue() - assert out.rstrip().split(' |')[0].endswith(log_message) diff --git a/tests/sdk/test_base.py b/tests/sdk/test_base.py deleted file mode 100644 index 63aa007..0000000 --- a/tests/sdk/test_base.py +++ /dev/null @@ -1,159 +0,0 @@ -import pytest - -from kusanagi.sdk import base -from kusanagi.sdk.schema.service import ServiceSchema -from kusanagi.errors import KusanagiError -from kusanagi.schema import get_schema_registry -from kusanagi.schema import SchemaRegistry - - -def test_sdk_base(mocker): - SchemaRegistry() - - values = { - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - } - - component = mocker.MagicMock() - api = base.Api(component, **values) - - # Check defaults - assert not api.is_debug() - assert not api.has_variable('foo') - assert api.get_variables() == {} - - # Check values - assert api.get_framework_version() == values['framework_version'] - assert api.get_path() == values['path'] - assert api.get_name() == values['name'] - assert api.get_version() == values['version'] - - # Prepare component and check that is properly called - resource_name = 'foo' - expected_resource = 'RESOURCE' - component.has_resource.return_value = True - component.get_resource.return_value = expected_resource - assert api.has_resource(resource_name) - component.has_resource.assert_called_with(resource_name) - assert api.get_resource(resource_name) == expected_resource - component.get_resource.assert_called_with(resource_name) - - # Check other values that were defaults - variables = {'foo': 'bar'} - api = base.Api(component, variables=variables, debug=True, **values) - assert api.is_debug() - assert api.has_variable('foo') - assert api.get_variables() == variables - assert api.get_variable('foo') == variables['foo'] - - -def test_sdk_base_get_services(registry): - api = base.Api(**{ - 'component': None, - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - }) - - # Get services is empty when there are no service mappings - assert api.get_services() == [] - - # Add data to registry - svc_name = 'foo' - svc_version = '1.0.0' - registry.update_registry({svc_name: {svc_version: {}}}) - - # Get services must return service name and version - assert api.get_services() == [{'name': svc_name, 'version': svc_version}] - - -def test_sdk_base_get_service_schema(mocker): - mocker.patch('kusanagi.schema.SchemaRegistry') - - # Get the mocked SchemaRegistry - registry = get_schema_registry() - - api = base.Api(**{ - 'component': None, - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - }) - - svc_name = 'foo' - svc_version = '1.0.0' - path = '{} {}'.format(svc_name, svc_version) - payload = {'foo': 'bar'} - - # Check error for empty schemas - registry.get.return_value = None - with pytest.raises(base.ApiError): - api.get_service_schema(svc_name, svc_version) - - # Check that schema registry get was called with proper service path - registry.get.assert_called_with(path, None, delimiter=' ') - - # Check getting a service schema - init = mocker.patch( - 'kusanagi.sdk.schema.service.ServiceSchema.__init__', - return_value=None, - ) - registry.get = mocker.MagicMock(return_value=payload) - svc_schema = api.get_service_schema(svc_name, svc_version) - assert isinstance(svc_schema, ServiceSchema) - registry.get.assert_called_with(path, None, delimiter=' ') - init.assert_called_with(svc_name, svc_version, payload) - - # Check getting a service schema using a wildcard version - expected_version = '1.0.0' - resolve = mocker.patch('kusanagi.versions.VersionString.resolve') - resolve.return_value = expected_version - svc_schema = api.get_service_schema(svc_name, '*.*.*') - assert isinstance(svc_schema, ServiceSchema) - init.assert_called_with(svc_name, expected_version, payload) - - # Check unresolved wildcard versions (resolve should raise KusanagiError) - resolve.side_effect = KusanagiError - with pytest.raises(KusanagiError): - api.get_service_schema(svc_name, '*.*.*') - - -def test_sdk_base_log(mocker, logs): - SchemaRegistry() - - values = { - 'component': None, - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - } - - log_message = 'Test log message' - api = base.Api(**values) - # When debug is false no logging is done - assert not api.is_debug() - api.log(log_message) - out = logs.getvalue() - # There should be no ouput at all - assert len(out) == 0 - - -def test_sdk_base_done(): - SchemaRegistry() - - values = { - 'component': None, - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - } - api = base.Api(**values) - with pytest.raises(base.ApiError): - api.done() diff --git a/tests/sdk/test_component.py b/tests/sdk/test_component.py deleted file mode 100644 index b13ea6a..0000000 --- a/tests/sdk/test_component.py +++ /dev/null @@ -1,84 +0,0 @@ -import pytest - -from kusanagi.schema import get_schema_registry -from kusanagi.sdk.component import Component -from kusanagi.sdk.component import ComponentError - - -def test_component(): - Component.instance = None - component = Component() - assert not component.has_resource('dummy') - - # Setting a resource with a callback that return None should fail - with pytest.raises(ComponentError): - component.set_resource('dummy', lambda component: None) - - # Getting an invalid resource should fail - with pytest.raises(ComponentError): - component.get_resource('dummy') - - # Set a resource - expected = 'RESULT' - component.set_resource('dummy', lambda component: expected) - assert component.has_resource('dummy') - assert component.get_resource('dummy') == expected - - # Check callback registration - assert component.startup(lambda: 'foo') == component - assert component.shutdown(lambda: 'foo') == component - assert component.error(lambda: 'foo') == component - - -def test_component_run(mocker): - Component.instance = None - component = Component() - # Runner is defined by sub clases, when is not define component must fail - with pytest.raises(Exception): - component.run() - - runner = mocker.MagicMock() - callbacks = {'foo': lambda: None} - - # Assign a runner and callbacks to the component - component._runner = runner - component._callbacks = callbacks - component.run() - # Check that runner was run, and callbacks were assigned - runner.run.assert_called() - runner.set_callbacks.assert_called_once_with(callbacks) - # A schema registry singleton must be created on run - assert get_schema_registry() is not None - # When there are no special callbacks no set_*_callback should be called - runner.set_startup_callback.assert_not_called() - runner.set_shutdown_callback.assert_not_called() - runner.set_error_callback.assert_not_called() - - # Set callbacks for component and check that they are setted in the runner - def startup_callback(): - pass - - def shutdown_callback(): - pass - - def error_callback(): - pass - - runner = mocker.MagicMock() - component._runner = runner - component.startup(startup_callback) - component.shutdown(shutdown_callback) - component.error(error_callback) - component.run() - runner.set_startup_callback.assert_called_once_with(startup_callback) - runner.set_shutdown_callback.assert_called_once_with(shutdown_callback) - runner.set_error_callback.assert_called_once_with(error_callback) - - -def test_component_log(logs): - Component.instance = None - expected = 'Test log message' - Component().log(expected) - out = logs.getvalue() - # Output without line break should match - assert out.rstrip().endswith(expected) diff --git a/tests/sdk/test_file.py b/tests/sdk/test_file.py deleted file mode 100644 index 296c37e..0000000 --- a/tests/sdk/test_file.py +++ /dev/null @@ -1,149 +0,0 @@ -import os - -import pytest - -from kusanagi.sdk.file import File -from kusanagi.sdk.file import file_to_payload -from kusanagi.sdk.file import payload_to_file -from kusanagi.payload import FIELD_MAPPINGS - - -def test_sdk_file_to_payload(): - empty = object() - values = { - 'path': 'http://127.0.0.1:8080/ANBDKAD23142421', - 'mime': 'application/json', - 'filename': 'file.json', - 'size': '600', - 'token': 'secret', - } - payload = file_to_payload(File('foo', **values)) - assert payload is not None - - # Check that payload contains file values - for name, value in values.items(): - assert payload.get(name, default=empty) == value - - -def test_sdk_payload_to_file(): - values = { - 'name': 'foo', - 'path': 'http://127.0.0.1:8080/ANBDKAD23142421', - 'mime': 'application/json', - 'filename': 'file.json', - 'size': '600', - 'token': 'secret', - } - payload = {FIELD_MAPPINGS[name]: value for name, value in values.items()} - - file = payload_to_file(payload) - assert file is not None - assert file.get_name() == 'foo' - - # Check that file contains payload values - for name, value in values.items(): - getter = getattr(file, 'get_{}'.format(name), None) - assert getter is not None - assert getter() == value - - -def test_sdk_file(data_path, mocker): - # Empty name is invalid - with pytest.raises(TypeError): - File(' ', 'file:///tmp/foo.json') - - # HTTP file path with no token is invalid - with pytest.raises(TypeError): - File('foo', 'http://127.0.0.1:8080/ANBDKAD23142421') - - # Patch HTTP connection object and make al request "200 OK" - response = mocker.MagicMock(status=200, reason='OK') - connection = mocker.MagicMock() - connection.getresponse.return_value = response - mocker.patch('http.client.HTTPConnection', return_value=connection) - - # ... with token should work - try: - file = File('foo', 'http://127.0.0.1:8080/ANBDKAD23142421', token='xx') - except: - pytest.fail('Creation of HTTP file with token failed') - else: - assert file.exists() - - # Check HTTP file missing - response.status = 404 - response.reason = 'Not found' - assert not file.exists() - - # Check result when connection to remote HTTP file server fails - connection.request.side_effect = Exception - assert not file.exists() - - # Check remote file read - request = mocker.MagicMock() - request.read.return_value = b'CONTENT' - reader = mocker.MagicMock() - reader.__enter__.return_value = request - reader.__exit__.return_value = False - mocker.patch('urllib.request.urlopen', return_value=reader) - assert file.read() == b'CONTENT' - - # Check error during remote file read - request.read.side_effect = Exception - assert file.read() == b'' - - # A file with empty path should not exist - file = File('foo', '') - assert not file.exists() - - # Check creation of a local file - local_file = os.path.join(data_path, 'foo.json') - file = File('foo', local_file) - assert file.get_name() == 'foo' - assert file.is_local() - assert file.exists() - # Check extracted file values - assert file.get_mime() == 'application/json' - assert file.get_filename() == 'foo.json' - assert file.get_size() == 56 - - # Read file contents - with open(local_file, 'rb') as test_file: - assert file.read() == test_file.read() - - # Read should return empty when file path is not a file - mocker.patch('os.path.isfile', return_value=False) - assert file.read() == b'' - - # Try to read a file that does not exist - mocker.patch('os.path.isfile', return_value=True) - file = File('foo', 'does-not-exist') - assert file.read() == b'' - - # Check file creation where size can't be getted - mocker.patch('os.path.getsize', side_effect=OSError) - file = File('foo', local_file) - assert file.get_size() == 0 - - -def test_sdk_file_copy(data_path): - file = File('foo', os.path.join(data_path, 'foo.json')) - - # Check copy with methods - clon = file.copy_with_name('clon') - assert isinstance(clon, File) - assert clon != file - assert clon.get_name() == 'clon' - assert clon.get_name() != file.get_name() - assert clon.get_path() == file.get_path() - assert clon.get_size() == file.get_size() - assert clon.get_mime() == file.get_mime() - - clon = file.copy_with_mime('text/plain') - assert isinstance(clon, File) - assert clon != file - assert clon.get_mime() == 'text/plain' - assert clon.get_mime() != file.get_mime() - assert clon.get_name() == file.get_name() - assert clon.get_path() == file.get_path() - assert clon.get_size() == file.get_size() diff --git a/tests/sdk/test_middleware.py b/tests/sdk/test_middleware.py deleted file mode 100644 index af39e97..0000000 --- a/tests/sdk/test_middleware.py +++ /dev/null @@ -1,29 +0,0 @@ -from kusanagi.sdk.middleware import get_component -from kusanagi.sdk.middleware import Middleware - - -def test_middleware_component(): - # Check middleware component singleton creation - assert get_component() is None - middleware = Middleware() - assert get_component() == middleware - - def request_callback(): - pass - - def response_callback(): - pass - - assert middleware._callbacks == {} - - # Set request callback - assert 'request' not in middleware._callbacks - middleware.request(request_callback) - assert 'request' in middleware._callbacks - assert middleware._callbacks['request'] == request_callback - - # Set response callback - assert 'response' not in middleware._callbacks - middleware.response(response_callback) - assert 'response' in middleware._callbacks - assert middleware._callbacks['response'] == response_callback diff --git a/tests/sdk/test_param.py b/tests/sdk/test_param.py deleted file mode 100644 index 515506a..0000000 --- a/tests/sdk/test_param.py +++ /dev/null @@ -1,113 +0,0 @@ -import pytest -from kusanagi.errors import KusanagiTypeError -from kusanagi.sdk.param import Param -from kusanagi.sdk.param import param_to_payload -from kusanagi.sdk.param import payload_to_param -from kusanagi.sdk.param import TYPE_ARRAY -from kusanagi.sdk.param import TYPE_BINARY -from kusanagi.sdk.param import TYPE_BOOLEAN -from kusanagi.sdk.param import TYPE_INTEGER -from kusanagi.sdk.param import TYPE_FLOAT -from kusanagi.sdk.param import TYPE_NULL -from kusanagi.sdk.param import TYPE_OBJECT -from kusanagi.sdk.param import TYPE_STRING -from kusanagi.payload import FIELD_MAPPINGS - - -def test_sdk_param_to_payload(): - empty = object() - values = { - 'value': 'bar', - 'type': TYPE_STRING, - } - payload = param_to_payload(Param('foo', **values)) - assert payload is not None - assert payload.get('name', default=None) == 'foo' - - # Check that payload contains file values - for name, value in values.items(): - assert payload.get(name, default=empty) == value - - -def test_sdk_payload_to_param(): - values = { - 'name': 'foo', - 'value': 'bar', - 'type': TYPE_STRING, - } - payload = {FIELD_MAPPINGS[name]: value for name, value in values.items()} - param = payload_to_param(payload) - assert param is not None - assert param.exists() - - # Check that file contains payload values - for name, value in values.items(): - getter = getattr(param, 'get_{}'.format(name), None) - assert getter is not None - assert getter() == value - - -def test_sdk_param(): - # Check empty param creation - param = Param('foo') - assert param.get_name() == 'foo' - assert param.get_value() == '' - assert param.get_type() == TYPE_STRING - assert not param.exists() - - # Create a parameter with an unknown type - param = Param('foo', value='42', type='weird') - assert param.get_type() == TYPE_STRING - - # Check param creation - param = Param('foo', value=42, exists=True, type=Param.TYPE_INTEGER) - assert param.get_name() == 'foo' - assert param.get_value() == 42 - assert param.get_type() == TYPE_INTEGER - assert param.exists() - - -def test_sdk_param_resolve_type(): - class Foo(object): - pass - - cases = ( - (None, TYPE_NULL), - (True, TYPE_BOOLEAN), - (0, TYPE_INTEGER), - (0.0, TYPE_FLOAT), - ([], TYPE_ARRAY), - ((), TYPE_ARRAY), - (set(), TYPE_ARRAY), - ('', TYPE_STRING), - (b'', TYPE_BINARY), - ({}, TYPE_OBJECT), - (Foo(), TYPE_STRING), - ) - - for value, type_ in cases: - assert Param.resolve_type(value) == type_ - - -def test_sdk_param_copy(): - param = Param('foo', value=42, type=Param.TYPE_INTEGER) - - # Check copy with methods - clon = param.copy_with_name('clon') - assert isinstance(clon, Param) - assert clon != param - assert clon.get_name() == 'clon' - assert clon.get_name() != param.get_name() - assert clon.get_type() == param.get_type() - assert clon.get_value() == param.get_value() - - clon = param.copy_with_value(666) - assert isinstance(clon, Param) - assert clon != param - assert clon.get_value() == 666 - assert clon.get_value() != param.get_value() - assert clon.get_name() == param.get_name() - assert clon.get_type() == param.get_type() - - with pytest.raises(KusanagiTypeError): - param.copy_with_type(TYPE_STRING) diff --git a/tests/sdk/test_request.py b/tests/sdk/test_request.py deleted file mode 100644 index f399218..0000000 --- a/tests/sdk/test_request.py +++ /dev/null @@ -1,188 +0,0 @@ -import pytest - -from kusanagi import urn -from kusanagi.errors import KusanagiTypeError -from kusanagi.sdk.http.request import HttpRequest -from kusanagi.sdk.param import Param -from kusanagi.sdk.request import Request -from kusanagi.sdk.response import Response -from kusanagi.sdk.transport import Transport -from kusanagi.schema import SchemaRegistry - - -def test_sdk_request(): - SchemaRegistry() - - service_name = 'foo' - service_version = '1.1' - action_name = 'bar' - values = { - 'attributes': {}, - 'component': object(), - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - 'client_address': '205.81.5.62:7681', - 'gateway_protocol': urn.HTTP, - 'gateway_addresses': ['12.34.56.78:1234', 'http://127.0.0.1:80'], - } - - request = Request(**values) - assert request.get_gateway_protocol() == values['gateway_protocol'] - assert request.get_gateway_address() == values['gateway_addresses'][1] - assert request.get_client_address() == values['client_address'] - assert request.get_service_name() == '' - assert request.get_service_version() == '' - assert request.get_action_name() == '' - assert request.get_http_request() is None - - # Check service related setters - request.set_service_name(service_name) - request.set_service_version(service_version) - request.set_action_name(action_name) - assert request.get_service_name() == service_name - assert request.get_service_version() == service_version - assert request.get_action_name() == action_name - - # Check parameters - assert request.get_params() == [] - assert request.has_param('foo') is False - param = request.get_param('foo') - assert isinstance(param, Param) - assert not param.exists() - - param = Param('foo', value=42, type=Param.TYPE_INTEGER) - assert request.set_param(param) == request - assert request.has_param('foo') - - # Result is not the same parameter, but is has the same values - foo_param = request.get_param('foo') - assert foo_param != param - assert foo_param.get_name() == param.get_name() - assert foo_param.get_type() == param.get_type() - assert foo_param.get_value() == param.get_value() - - params = request.get_params() - assert len(params) == 1 - foo_param = params[0] - assert foo_param.get_name() == param.get_name() - assert foo_param.get_type() == param.get_type() - assert foo_param.get_value() == param.get_value() - - -def test_sdk_request_new_response(): - SchemaRegistry() - - values = { - 'rid': 'TEST', - 'attributes': {}, - 'component': object(), - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - 'client_address': '205.81.5.62:7681', - 'gateway_protocol': urn.HTTP, - 'gateway_addresses': ['12.34.56.78:1234', 'http://127.0.0.1:80'], - } - request = Request(**values) - - # Create an HTTP response with default values - response = request.new_response() - assert isinstance(response, Response) - assert response.get_gateway_protocol() == request.get_gateway_protocol() - assert response.get_gateway_address() == request.get_gateway_address() - assert isinstance(response.get_transport(), Transport) - # An HTTP response is created when using HTTP as protocol - http_response = response.get_http_response() - assert http_response is not None - assert http_response.get_status() == '200 OK' - - # Create a response with HTTP status values - response = request.new_response(418, "I'm a teapot") - assert isinstance(response, Response) - http_response = response.get_http_response() - assert http_response is not None - assert http_response.get_status() == "418 I'm a teapot" - - # Create a response for other ptotocol - values['gateway_protocol'] = urn.KTP - request = Request(**values) - response = request.new_response() - assert isinstance(response, Response) - assert response.get_gateway_protocol() == request.get_gateway_protocol() - assert response.get_gateway_address() == request.get_gateway_address() - assert isinstance(response.get_transport(), Transport) - # Check that no HTTP response was created - assert response.get_http_response() is None - - # Create an HTTP request with request data - values['gateway_protocol'] = urn.HTTP - values['http_request'] = { - 'method': 'GET', - 'url': 'http://foo.com/bar/index/', - } - request = Request(**values) - assert isinstance(request.get_http_request(), HttpRequest) - - -def test_sdk_request_new_param(): - SchemaRegistry() - - values = { - 'attributes': {}, - 'component': object(), - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - 'client_address': '205.81.5.62:7681', - 'gateway_protocol': urn.HTTP, - 'gateway_addresses': ['12.34.56.78:1234', 'http://127.0.0.1:80'], - } - request = Request(**values) - - # Create a param with default values - param = request.new_param('foo') - assert isinstance(param, Param) - assert param.get_name() == 'foo' - assert param.exists() - assert param.get_value() is None - assert param.get_type() == Param.TYPE_NULL - - # Create a parameter with type and value - param = request.new_param('foo', value=42, type=Param.TYPE_INTEGER) - assert isinstance(param, Param) - assert param.get_name() == 'foo' - assert param.exists() - assert param.get_value() == 42 - assert param.get_type() == Param.TYPE_INTEGER - - # Check error when a parameter has inconsisten type and value - with pytest.raises(KusanagiTypeError): - request.new_param('foo', value=True, type=Param.TYPE_INTEGER) - - -def test_request_log(mocker, logs): - SchemaRegistry() - - values = { - 'attributes': {}, - 'component': object(), - 'request_id': 'TEST', - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - 'client_address': '205.81.5.62:7681', - 'gateway_protocol': urn.HTTP, - 'gateway_addresses': ['12.34.56.78:1234', 'http://127.0.0.1:80'], - 'debug': True, - } - request = Request(**values) - assert request.is_debug() - log_message = 'Test log message' - request.log(log_message) - out = logs.getvalue() - assert out.rstrip().split(' |')[0].endswith(log_message) diff --git a/tests/sdk/test_response.py b/tests/sdk/test_response.py deleted file mode 100644 index 3178861..0000000 --- a/tests/sdk/test_response.py +++ /dev/null @@ -1,83 +0,0 @@ -import pytest - -from kusanagi import urn -from kusanagi.sdk.response import NoReturnValueDefined -from kusanagi.sdk.response import Response -from kusanagi.sdk.transport import Transport -from kusanagi.sdk.http.request import HttpRequest -from kusanagi.sdk.http.response import HttpResponse -from kusanagi.schema import SchemaRegistry - - -def test_sdk_response(): - SchemaRegistry() - - values = { - 'attributes': {}, - 'transport': Transport({'meta': {'origin': ['foo', '1.0.0', 'bar']}}), - 'component': object(), - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - 'gateway_protocol': urn.HTTP, - 'gateway_addresses': ['12.34.56.78:1234', 'http://127.0.0.1:80'], - } - - response = Response(**values) - assert response.get_gateway_protocol() == values['gateway_protocol'] - assert response.get_gateway_address() == values['gateway_addresses'][1] - assert response.get_transport() == values['transport'] - # By default no HTTP request or response are created - assert response.get_http_request() is None - assert response.get_http_response() is None - # No return value is defined - with pytest.raises(NoReturnValueDefined): - response.get_return() - - # Create a new response with HTTP request and response data - values['http_request'] = { - 'method': 'GET', - 'url': 'http://foo.com/bar/index/', - } - values['http_response'] = { - 'status_code': 200, - 'status_text': 'OK', - } - response = Response(**values) - assert isinstance(response.get_http_request(), HttpRequest) - assert isinstance(response.get_http_response(), HttpResponse) - - values['return_value'] = 42 - response = Response(**values) - assert response.get_return() == 42 - - # Remove the origin to cover a specific get return case - del values['return_value'] - values['transport'] = Transport({}) - response = Response(**values) - assert response.get_return() is None - - - -def test_response_log(mocker, logs): - SchemaRegistry() - - values = { - 'attributes': {}, - 'transport': Transport({'meta': {'id': 'TEST'}}), - 'component': object(), - 'path': '/path/to/file.py', - 'name': 'dummy', - 'version': '1.0', - 'framework_version': '1.0.0', - 'gateway_protocol': urn.HTTP, - 'gateway_addresses': ['12.34.56.78:1234', 'http://127.0.0.1:80'], - 'debug': True, - } - response = Response(**values) - assert response.is_debug() - log_message = 'Test log message' - response.log(log_message) - out = logs.getvalue() - assert out.rstrip().split(' |')[0].endswith(log_message) diff --git a/tests/sdk/test_runner.py b/tests/sdk/test_runner.py deleted file mode 100644 index 42dbae4..0000000 --- a/tests/sdk/test_runner.py +++ /dev/null @@ -1,300 +0,0 @@ -import os - -import click -import pytest - -from kusanagi import payload -from kusanagi.sdk.runner import apply_cli_options -from kusanagi.sdk.runner import ComponentRunner -from kusanagi.sdk.runner import key_value_strings_callback -from kusanagi.utils import EXIT_ERROR -from kusanagi.utils import EXIT_OK -from zmq.error import ZMQError - - -def test_key_value_strings_callback(): - ctx = None - param = None - - # Check empty result - assert key_value_strings_callback(ctx, param, None) == {} - - # Check result for a list of values - values = ['foo=bar', 'hello=world', 'wacky=value=go'] - assert key_value_strings_callback(ctx, param, values) == { - 'foo': 'bar', - 'hello': 'world', - 'wacky': 'value=go', - } - - # Check invalid parameter format error - with pytest.raises(click.BadParameter): - key_value_strings_callback(ctx, param, ['boom']) - - -def test_apply_cli_options(mocker, cli): - class Foo(ComponentRunner): - pass - - # Create a mock for the run method and apply CLI options decorator - run = mocker.MagicMock() - Foo.run = apply_cli_options(run) - - foo = Foo(None, None, 'Foo help text') - - result = cli.invoke(foo.run(), [ - '--name', 'foo', - '--version', '1.0', - '--component', 'service', - '--framework-version', '1.0.0', - '--socket', '@kusanagi-127-0-0-1-5010-foo', - '--tcp', '5010', - '--log-level', '6', - '--debug', - '--action', 'foo_action', - '--disable-compact-names', - '--var', 'foo=bar', - '--var', 'hello=world', - ]) - - # Running the component should succeed - assert result.exit_code == 0 - - # Check parsed CLI values that were sent as arguments for run - args, kwargs = run.call_args - assert args == (foo, ) - assert kwargs == { - 'name': 'foo', - 'version': '1.0', - 'component': 'service', - 'framework_version': '1.0.0', - 'socket': '@kusanagi-127-0-0-1-5010-foo', - 'tcp': 5010, - 'log_level': 6, - 'debug': True, - 'action': 'foo_action', - 'timeout': 30000, - 'disable_compact_names': True, - 'var': {'foo': 'bar', 'hello': 'world'}, - } - - # Check that by removing the TESTING environment component runs - # but ends with an exit code of 2 because argv does not have - # valid values. - exit = mocker.patch('sys.exit') - del os.environ['TESTING'] - foo.run() - exit.assert_called_once_with(2) - os.environ['TESTING'] = '1' - - -def test_component_runner(): - runner = ComponentRunner(None, None, None) - # Check that there are CLI argument definitions - assert len(runner.get_argument_options()) > 0 - # Check callbacks - callbacks = {'A': lambda action: '', 'B': lambda action: ''} - runner.set_callbacks(callbacks) - assert runner.callbacks == callbacks - - -def test_component_runner_args(): - mandatory_args = { - 'name': 'foo', - 'version': '1.0', - 'component': 'service', - } - args = { - 'socket': '@kusanagi-127-0-0-1-4001-foo', - 'tcp': '4001', - 'debug': True, - 'disable_compact_names': True, - } - args.update(mandatory_args) - - runner = ComponentRunner(None, None, None) - runner._args = args - assert runner.args == args - assert runner.name == args['name'] - assert runner.socket_name == args['socket'] - assert runner.tcp_port == args['tcp'] - assert runner.version == args['version'] - assert runner.component_type == args['component'] - assert runner.debug - assert not runner.compact_names - - expected_socket_name = '@kusanagi-service-foo-1-0' - default_socket_name = runner.get_default_socket_name() - assert default_socket_name == expected_socket_name - - # Check default values for optional arguments - runner = ComponentRunner(None, None, None) - runner._args = mandatory_args - assert runner.args == mandatory_args - assert runner.socket_name == runner.get_default_socket_name() - assert runner.tcp_port is None - assert not runner.debug - assert runner.compact_names - - -def test_component_run(mocker, cli): - mocker.patch('asyncio.set_event_loop') - exit = mocker.patch('os._exit') - loop = mocker.MagicMock() - mocker.patch('zmq.asyncio.ZMQEventLoop', return_value=loop) - - def error_callback(): - pass - - callbacks = {'A': lambda action: '', 'B': lambda action: ''} - server = mocker.MagicMock() - ServerCls = mocker.MagicMock(return_value=server) - socket = '@kusanagi-127-0-0-1-5010-foo' - cli_args = [ - '--name', 'foo', - '--version', '1.0', - '--component', 'service', - '--framework-version', '1.0.0', - '--socket', socket, - '--debug', - '--disable-compact-names', - '--log-level', '6', - '--var', 'foo=bar', - '--var', 'hello=world', - ] - - # Create a component runner and run it - runner = ComponentRunner(None, ServerCls, None) - runner.set_callbacks(callbacks) - runner.set_error_callback(error_callback) - result = cli.invoke(runner.run(), cli_args) - # Running the component should succeed - assert result.exit_code == 0 - - # Check that compact names were disabled - assert payload.DISABLE_FIELD_MAPPINGS - - # Check server creation arguments - args, kwargs = ServerCls.call_args - assert args == (callbacks, { - 'name': 'foo', - 'version': '1.0', - 'component': 'service', - 'framework_version': '1.0.0', - 'action': None, - 'socket': socket, - 'tcp': None, - 'debug': True, - 'timeout': 30000, - 'disable_compact_names': True, - 'log_level': 6, - 'var': {'foo': 'bar', 'hello': 'world'}, - }) - assert 'debug' in kwargs - assert kwargs['debug'] - assert 'source_file' in kwargs - assert len(kwargs['source_file']) > 0 - assert kwargs.get('error_callback') == error_callback - - # Check that server listen was called - server.listen.assert_called_once() - - # Check that loop was run - loop.run_until_complete.assert_called_once() - loop.close.assert_called_once() - - # Check normal exit - exit.assert_called_once_with(EXIT_OK) - - -def test_component_run_errors(mocker, cli): - side_effects = (ZMQError, ZMQError(98), Exception) - loop = mocker.MagicMock() - loop.run_until_complete.side_effect = side_effects - - mocker.patch('asyncio.set_event_loop') - mocker.patch('zmq.asyncio.ZMQEventLoop', return_value=loop) - - exit = mocker.patch('os._exit') - - ServerCls = mocker.MagicMock() - socket = '@kusanagi-127-0-0-1-5010-foo' - cli_args = [ - '--name', 'foo', - '--version', '1.0', - '--component', 'service', - '--framework-version', '1.0.0', - '--socket', socket, - '--debug', - '--disable-compact-names', - '--log-level', '6', - '--var', 'foo=bar', - '--var', 'hello=world', - ] - - # Run component twice to check ZMQError and Exception cases - for _ in range(len(side_effects)): - # Create a component runner and run it - runner = ComponentRunner(None, ServerCls, None) - result = cli.invoke(runner.run(), cli_args) - - # Running the component should succeed - assert result.exit_code == 0 - - # Check exit with error - exit.assert_called_with(EXIT_ERROR) - - -def test_component_run_callbacks(mocker, cli): - loop = mocker.MagicMock() - - mocker.patch('asyncio.set_event_loop') - mocker.patch('zmq.asyncio.ZMQEventLoop', return_value=loop) - - exit = mocker.patch('os._exit') - - # Define values to be used in component runner - startup_callback = mocker.MagicMock() - shutdown_callback = mocker.MagicMock() - component = object() - ServerCls = mocker.MagicMock() - cli_args = [ - '--name', 'foo', - '--version', '1.0', - '--component', 'service', - '--framework-version', '1.0.0', - '--tcp', '5000', - ] - - # Create the component runner and assign callbacks - runner = ComponentRunner(component, ServerCls, None) - runner.set_startup_callback(startup_callback) - runner.set_shutdown_callback(shutdown_callback) - - result = cli.invoke(runner.run(), cli_args) - # Running the component should succeed - assert result.exit_code == 0 - - # Check that callbacks were called - startup_callback.assert_called_once_with(component) - shutdown_callback.assert_called_once_with(component) - - # Check normal exit - exit.assert_called_once_with(EXIT_OK) - - # Check startup callback errors - startup_callback.side_effect = Exception - result = cli.invoke(runner.run(), cli_args) - # Running the component should succeed - assert result.exit_code == 0 - # Check error exit - exit.assert_called_with(EXIT_ERROR) - - # Check shutdown callback errors - startup_callback.side_effect = None - shutdown_callback.side_effect = Exception - result = cli.invoke(runner.run(), cli_args) - # Running the component should succeed - assert result.exit_code == 0 - # Check error exit - exit.assert_called_with(EXIT_ERROR) diff --git a/tests/sdk/test_service.py b/tests/sdk/test_service.py deleted file mode 100644 index 06a4e89..0000000 --- a/tests/sdk/test_service.py +++ /dev/null @@ -1,21 +0,0 @@ -from kusanagi.sdk.service import get_component -from kusanagi.sdk.service import Service - - -def test_service_component(): - # Check service component singleton creation - assert get_component() is None - service = Service() - assert get_component() == service - - def action_callback(): - pass - - assert service._callbacks == {} - - # Set an action callback - action_name = 'foo' - assert action_name not in service._callbacks - service.action(action_name, action_callback) - assert action_name in service._callbacks - assert service._callbacks[action_name] == action_callback diff --git a/tests/sdk/test_transport.py b/tests/sdk/test_transport.py deleted file mode 100644 index 2af4c37..0000000 --- a/tests/sdk/test_transport.py +++ /dev/null @@ -1,257 +0,0 @@ -import pytest - -from kusanagi.sdk.file import File -from kusanagi.sdk.param import Param -from kusanagi.sdk.transport import TransactionTypeError -from kusanagi.sdk.transport import Transport -from kusanagi.payload import delete_path - - -def test_sdk_transport(read_json): - transport = Transport(read_json('transport.json')) - payload = transport._Transport__transport - - assert transport.get_request_id() == 'f1b27da9-240b-40e3-99dd-a567e4498ed7' - assert transport.get_request_timestamp() == '2016-04-12T02:49:05.761' - assert transport.get_origin_service() == ('users', '1.0.0', 'list') - assert transport.get_origin_duration() == 42 - - # Default value must be a string - with pytest.raises(TypeError): - transport.get_property('missing', default=False) - - # Use default because property does not exist - assert transport.get_property('missing', default='OK') == 'OK' - # Get an existing property - assert transport.get_property('foo') == 'bar' - # Get all properties - assert transport.get_properties() == {'foo': 'bar'} - # Try to get all propertes when there is none - assert delete_path(payload, 'meta/properties') - assert transport.get_properties() == {} - - # Check download - assert transport.has_download() - file = transport.get_download() - assert isinstance(file, File) - assert file.get_name() == 'download' - assert file.get_path() == 'file:///tmp/document.pdf' - assert file.get_filename() == 'document.pdf' - assert file.get_size() == 1234567890 - assert file.get_mime() == 'application/pdf' - - # Remove download from body - assert delete_path(payload, 'body') - assert not transport.has_download() - assert transport.get_download() is None - - -def test_sdk_transport_data(read_json): - transport = Transport(read_json('transport.json')) - payload = transport._Transport__transport - - # Get data that exists in transport - data = transport.get_data() - assert isinstance(data, list) - assert len(data) == 2 - data.sort(key=lambda d: d.get_name()) - - svc_data = data[0] - assert svc_data.get_address() == 'http://127.0.0.1:80' - assert svc_data.get_name() == 'employees' - assert svc_data.get_version() == '1.1.0' - svc_data = data[1] - assert svc_data.get_address() == 'http://127.0.0.1:80' - assert svc_data.get_name() == 'users' - assert svc_data.get_version() == '1.0.0' - - actions = svc_data.get_actions() - assert isinstance(actions, list) - assert len(data) == 2 - actions.sort(key=lambda a: a.get_name()) - - action = actions[0] - assert action.get_name() == 'list_users' - assert action.is_collection() - action_data = action.get_data() - assert isinstance(action_data, list) - assert len(action_data) == 1 - assert action_data[0] == [ - {'name': "Foo", 'id': 1}, - {'name': "Mandela", 'id': 2}, - ] - action = actions[1] - assert action.get_name() == 'read_users' - assert not action.is_collection() - action_data = action.get_data() - assert isinstance(action_data, list) - assert len(action_data) == 2 - assert action_data[0] == {'name': "Foo", 'id': 1} - assert action_data[1] == {'name': "Mandela", 'id': 2} - - # Remove data - assert delete_path(payload, 'data') - assert transport.get_data() == [] - - -def test_sdk_transport_relations(read_json): - transport = Transport(read_json('transport.json')) - payload = transport._Transport__transport - - # Get relations that exists in transport - relations = transport.get_relations() - assert isinstance(relations, list) - assert len(relations) == 3 - relations.sort(key=lambda r: r.get_name() + r.get_primary_key()) - - relation = relations[0] - assert relation.get_address() == 'http://127.0.0.1:80' - assert relation.get_name() == 'posts' - assert relation.get_primary_key() == '1' - fks = relation.get_foreign_relations() - assert isinstance(fks, list) - assert len(fks) == 2 - fk = fks[0] - assert fk.get_type() == 'one' - assert fk.get_foreign_keys() == ['1'] - - relation = relations[2] - assert relation.get_address() == 'http://127.0.0.1:80' - assert relation.get_name() == 'users' - assert relation.get_primary_key() == '123' - fks = relation.get_foreign_relations() - assert isinstance(fks, list) - assert len(fks) == 1 - fk = fks[0] - assert fk.get_address() == 'http://127.0.0.1:80' - assert fk.get_name() == 'posts' - assert fk.get_foreign_keys() == ['1', '2'] - - # Remove relations - assert delete_path(payload, 'relations') - assert transport.get_relations() == [] - - -def test_sdk_transport_links(read_json): - transport = Transport(read_json('transport.json')) - payload = transport._Transport__transport - - # Get links that exists in transport - links = transport.get_links() - assert isinstance(links, list) - assert len(links) == 1 - link = links[0] - assert link.get_address() == 'http://127.0.0.1:80' - assert link.get_name() == 'users' - assert link.get_link() == 'self' - assert link.get_uri() == 'http://api.example.com/v1/users/123' - - # Remove links - assert delete_path(payload, 'links') - assert transport.get_links() == [] - - -def test_sdk_transport_calls(read_json): - transport = Transport(read_json('transport.json')) - payload = transport._Transport__transport - - calls = transport.get_calls() - assert isinstance(calls, list) - assert len(calls) == 3 - calls.sort(key=lambda c: c.get_name() + c.get_action()) - - call = calls[0] - assert call.get_name() == 'foo' - assert call.get_version() == '1.0.0' - assert call.get_action() == 'read' - callee = call.get_callee() - assert callee.get_duration() == 1120 - assert callee.get_name() == 'bar' - assert callee.get_version() == '1.0.0' - assert callee.get_action() == 'list' - assert callee.get_address() == '' - assert callee.get_timeout() == 0 - assert not callee.is_remote() - assert callee.get_params() == [] - call = calls[2] - assert call.get_name() == 'users' - assert call.get_version() == '1.0.0' - assert call.get_action() == 'update' - callee = call.get_callee() - assert callee.get_duration() == 1200 - assert callee.get_name() == 'comments' - assert callee.get_version() == '1.1.0' - assert callee.get_action() == 'list' - assert callee.get_address() == 'ktp://87.65.43.21:4321' - assert callee.get_timeout() == 3000 - assert callee.is_remote() - params = callee.get_params() - assert isinstance(params, list) - assert len(params) == 1 - assert isinstance(params[0], Param) - - # Remove all calls - assert delete_path(payload, 'calls') - assert transport.get_calls() == [] - - -def test_sdk_transport_transactions(read_json): - transport = Transport(read_json('transport.json')) - payload = transport._Transport__transport - - # Check that an exception is raised for invalid transation types - with pytest.raises(TransactionTypeError): - transport.get_transactions('foo') - - transactions = transport.get_transactions('commit') - assert isinstance(transactions, list) - assert len(transactions) == 1 - transactions = transport.get_transactions('rollback') - assert isinstance(transactions, list) - assert len(transactions) == 1 - transactions = transport.get_transactions('complete') - assert isinstance(transactions, list) - assert len(transactions) == 2 - transactions.sort(key=lambda t: t.get_name()) - - tr = transactions[0] - assert tr.get_type() == 'complete' - assert tr.get_name() == 'foo' - assert tr.get_version() == '1.0.0' - assert tr.get_callee_action() == 'bar' - assert tr.get_caller_action() == 'cleanup' - assert tr.get_params() == [] - tr = transactions[1] - assert tr.get_type() == 'complete' - assert tr.get_name() == 'users' - assert tr.get_version() == '1.0.0' - assert tr.get_callee_action() == 'create' - assert tr.get_caller_action() == 'cleanup' - params = tr.get_params() - assert isinstance(params, list) - assert len(params) == 1 - assert isinstance(params[0], Param) - - # Remove transactions - assert delete_path(payload, 'transactions') - assert transport.get_transactions('commit') == [] - - -def test_sdk_transport_errors(read_json): - transport = Transport(read_json('transport.json')) - payload = transport._Transport__transport - - errors = transport.get_errors() - assert isinstance(errors, list) - assert len(errors) == 1 - error = errors[0] - assert error.get_address() == 'http://127.0.0.1:80' - assert error.get_name() == 'users' - assert error.get_version() == '1.0.0' - assert error.get_message() == 'The user does not exist' - assert error.get_code() == 9 - assert error.get_status() == '404 Not Found' - - # Remove errors - assert delete_path(payload, 'errors') - assert transport.get_errors() == [] diff --git a/tests/test_action.py b/tests/test_action.py new file mode 100644 index 0000000..fbb0918 --- /dev/null +++ b/tests/test_action.py @@ -0,0 +1,847 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import os + +import pytest + + +def test_action_defaults(state): + from kusanagi.sdk import Action + from kusanagi.sdk import File + from kusanagi.sdk import Param + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload.command import CommandPayload + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + state.context['command'] = CommandPayload() + state.context['reply'] = ReplyPayload() + + action = Action(Service(), state) + assert not action.is_origin() + assert action.get_action_name() == state.action + assert not action.has_param('foo') + + param = action.get_param('foo') + assert isinstance(param, Param) + assert param.get_name() == 'foo' + assert action.get_params() == [] + + assert not action.has_file('foo') + file = action.get_file('foo') + assert isinstance(file, File) + assert file.get_name() == 'foo' + assert action.get_files() == [] + + +def test_action(state, schemas, action_command, action_reply): + from kusanagi.sdk import Action + from kusanagi.sdk import File + from kusanagi.sdk import Param + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['schemas'] = schemas + state.context['command'] = action_command + state.context['reply'] = action_reply + + action = Action(Service(), state) + assert action.is_origin() + assert action.get_action_name() == state.action + + assert not action_reply.exists([ns.TRANSPORT, ns.META, ns.PROPERTIES, 'foo']) + assert isinstance(action.set_property('foo', 'bar'), Action) + assert action_reply.get([ns.TRANSPORT, ns.META, ns.PROPERTIES, 'foo']) == 'bar' + # Property values can only be string + with pytest.raises(TypeError): + action.set_property('foo', 1) + + assert action.has_param('foo') + param = action.get_param('foo') + assert isinstance(param, Param) + assert param.get_name() == 'foo' + assert param.get_value() == 'bar' + params = action.get_params() + assert len(params) == 1 + assert params[0].get_name() == param.get_name() + assert params[0].get_value() == param.get_value() + assert params[0].get_type() == param.get_type() + param = action.new_param('baz', 77, Param.TYPE_INTEGER) + assert isinstance(param, Param) + assert param.get_name() == 'baz' + assert param.get_value() == 77 + assert param.get_type() == Param.TYPE_INTEGER + assert param.exists() + + assert action.has_file('foo') + file = action.get_file('foo') + assert isinstance(file, File) + assert file.get_name() == 'foo' + files = action.get_files() + assert len(files) == 1 + assert files[0].get_name() == file.get_name() + + +def test_action_download(DATA_DIR, mocker, state, schemas, action_command, action_reply): + from kusanagi.sdk import Action + from kusanagi.sdk import File + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['schemas'] = schemas + state.context['command'] = action_command + state.context['reply'] = action_reply + + action = Action(Service(), state) + + path = os.path.join(DATA_DIR, 'file.txt') + file = action.new_file('baz', path, 'mime/custom') + assert isinstance(file, File) + assert file.get_name() == 'baz' + assert file.get_path() == f'file://{path}' + assert file.get_mime() == 'mime/custom' + + # Mock the schema to control the file server setting + schema = mocker.Mock() + action.get_service_schema = mocker.Mock(return_value=schema) + + # Download can't be assigned when the service doesn;t have a file server enabled + schema.has_file_server.return_value = False + with pytest.raises(LookupError): + action.set_download(file) + + schema.has_file_server.return_value = True + assert not action._transport.has_download() + assert isinstance(action.set_download(file), Action) + assert action._transport.has_download() + assert action._transport.get([ns.BODY]).get(ns.NAME) == file.get_name() + + # Download values must be a file + with pytest.raises(TypeError): + action.set_download(1) + + +def test_action_return(mocker, state, schemas, action_reply): + from kusanagi.sdk import Action + from kusanagi.sdk import KusanagiError + from kusanagi.sdk import Service + from kusanagi.sdk.action import DEFAULT_RETURN_VALUES + from kusanagi.sdk.lib import datatypes + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + + state.context['schemas'] = schemas + state.context['command'] = CommandPayload() + state.context['reply'] = action_reply + + # Mock the schemas + schema = mocker.Mock() + Action.get_service_schema = mocker.Mock(return_value=schema) + action_schema = mocker.Mock() + action_schema.has_return.return_value = True + action_schema.get_return_type.return_value = datatypes.TYPE_INTEGER + schema.get_action_schema = mocker.Mock(return_value=action_schema) + + action = Action(Service(), state) + + assert action_reply.get([ns.RETURN]) == DEFAULT_RETURN_VALUES[datatypes.TYPE_INTEGER] + assert action.set_return(42) + assert action_reply.get([ns.RETURN]) == 42 + + # Invalud return types should not be allowed + action_schema.get_return_type.return_value = datatypes.TYPE_BOOLEAN + with pytest.raises(KusanagiError): + action.set_return(42) + + # Return value is not allowed when it is not supported by the action + action_schema.has_return.return_value = False + with pytest.raises(KusanagiError): + action.set_return(42) + + # Call must fail when the schemas are not defined + action.get_service_schema = mocker.Mock(side_effect=LookupError) + with pytest.raises(KusanagiError): + action.set_return(42) + + +def test_action_data_entity(state, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['command'] = action_command + + action = Action(Service(), state) + address = action._transport.get_public_gateway_address() + path = [ns.DATA, address, action.get_name(), action.get_version(), action.get_action_name()] + assert not action._transport.exists(path) + assert isinstance(action.set_entity({'value': 1}), Action) + assert action._transport.get(path) == [{'value': 1}] + + # Entity must be a dictionary + with pytest.raises(TypeError): + action.set_entity([]) + + +def test_action_data_collection(state, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['command'] = action_command + + action = Action(Service(), state) + address = action._transport.get_public_gateway_address() + path = [ns.DATA, address, action.get_name(), action.get_version(), action.get_action_name()] + assert not action._transport.exists(path) + assert isinstance(action.set_collection([{'value': 1}]), Action) + assert action._transport.get(path) == [[{'value': 1}]] + + # Entity must be a list + with pytest.raises(TypeError): + action.set_collection({}) + + # Entity must be a list of dicts + with pytest.raises(TypeError): + action.set_collection([1]) + + +def test_action_relations_one(state, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['command'] = action_command + + action = Action(Service(), state) + address = action._transport.get_public_gateway_address() + path = [ns.RELATIONS, address, 'foo', '1', address, 'bar'] + assert not action._transport.exists(path) + assert isinstance(action.relate_one('1', 'bar', '2'), Action) + assert action._transport.get(path) == '2' + + # No parameter must be empty + with pytest.raises(ValueError): + action.relate_one('', 'bar', '2') + + with pytest.raises(ValueError): + action.relate_one('1', '', '2') + + with pytest.raises(ValueError): + action.relate_one('1', 'bar', '') + + +def test_action_relations_many(state, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['command'] = action_command + + action = Action(Service(), state) + address = action._transport.get_public_gateway_address() + path = [ns.RELATIONS, address, 'foo', '1', address, 'bar'] + assert not action._transport.exists(path) + assert isinstance(action.relate_many('1', 'bar', ['2', '3']), Action) + assert action._transport.get(path) == ['2', '3'] + + # No parameter must be empty + with pytest.raises(ValueError): + action.relate_many('', 'bar', ['2']) + + with pytest.raises(ValueError): + action.relate_many('1', '', ['2']) + + with pytest.raises(ValueError): + action.relate_many('1', 'bar', []) + + # The foreign keys must be a list + with pytest.raises(TypeError): + action.relate_many('1', 'bar', '2') + + +def test_action_relations_one_remote(state, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['command'] = action_command + + action = Action(Service(), state) + address = action._transport.get_public_gateway_address() + remote = 'http://6.6.6.6:77' + path = [ns.RELATIONS, address, 'foo', '1', remote, 'bar'] + assert not action._transport.exists(path) + assert isinstance(action.relate_one_remote('1', remote, 'bar', '2'), Action) + assert action._transport.get(path) == '2' + + # No parameter must be empty + with pytest.raises(ValueError): + action.relate_one_remote('', remote, 'bar', '2') + + with pytest.raises(ValueError): + action.relate_one_remote('1', '', 'bar', '2') + + with pytest.raises(ValueError): + action.relate_one_remote('1', remote, '', '2') + + with pytest.raises(ValueError): + action.relate_one_remote('1', remote, 'bar', '') + + +def test_action_relations_many_remote(state, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['command'] = action_command + + action = Action(Service(), state) + address = action._transport.get_public_gateway_address() + remote = 'http://6.6.6.6:77' + path = [ns.RELATIONS, address, 'foo', '1', remote, 'bar'] + assert not action._transport.exists(path) + assert isinstance(action.relate_many_remote('1', remote, 'bar', ['2', '3']), Action) + assert action._transport.get(path) == ['2', '3'] + + # No parameter must be empty + with pytest.raises(ValueError): + action.relate_many_remote('', remote, 'bar', ['2', '3']) + + with pytest.raises(ValueError): + action.relate_many_remote('1', '', 'bar', ['2', '3']) + + with pytest.raises(ValueError): + action.relate_many_remote('1', remote, '', ['2', '3']) + + with pytest.raises(ValueError): + action.relate_many_remote('1', remote, 'bar', []) + + # The foreign keys must be a list + with pytest.raises(TypeError): + action.relate_many_remote('1', remote, 'bar', '2') + + +def test_action_links(state, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['command'] = action_command + + action = Action(Service(), state) + address = action._transport.get_public_gateway_address() + path = [ns.LINKS, address, action.get_name(), 'first'] + assert not action._transport.exists(path) + assert isinstance(action.set_link('first', '/test'), Action) + assert action._transport.get(path) == '/test' + + # No parameter must be empty + with pytest.raises(ValueError): + action.set_link('', '/test') + + with pytest.raises(ValueError): + action.set_link('first', '') + + +def test_action_transactions_commit(state): + from kusanagi.sdk import Action + from kusanagi.sdk import Param + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + from kusanagi.sdk.lib.payload.transport import TransportPayload + + state.context['command'] = CommandPayload() + params = [Param('foo', 'bar')] + + action = Action(Service(), state) + path = [ns.TRANSACTIONS, TransportPayload.TRANSACTION_COMMIT] + assert not action._transport.exists(path) + assert isinstance(action.commit('bar', params), Action) + assert action._transport.get(path) == [{ + ns.NAME: action.get_name(), + ns.VERSION: action.get_version(), + ns.CALLER: action.get_action_name(), + ns.ACTION: 'bar', + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.TYPE: Param.TYPE_STRING, + ns.VALUE: 'bar', + }], + }] + + # No parameter must be empty + with pytest.raises(ValueError): + action.commit('') + + +def test_action_transactions_rollback(state): + from kusanagi.sdk import Action + from kusanagi.sdk import Param + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + from kusanagi.sdk.lib.payload.transport import TransportPayload + + state.context['command'] = CommandPayload() + params = [Param('foo', 'bar')] + + action = Action(Service(), state) + path = [ns.TRANSACTIONS, TransportPayload.TRANSACTION_ROLLBACK] + assert not action._transport.exists(path) + assert isinstance(action.rollback('bar', params), Action) + assert action._transport.get(path) == [{ + ns.NAME: action.get_name(), + ns.VERSION: action.get_version(), + ns.CALLER: action.get_action_name(), + ns.ACTION: 'bar', + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.TYPE: Param.TYPE_STRING, + ns.VALUE: 'bar', + }], + }] + + # No parameter must be empty + with pytest.raises(ValueError): + action.rollback('') + + +def test_action_transactions_complete(state): + from kusanagi.sdk import Action + from kusanagi.sdk import Param + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + from kusanagi.sdk.lib.payload.transport import TransportPayload + + state.context['command'] = CommandPayload() + params = [Param('foo', 'bar')] + + action = Action(Service(), state) + path = [ns.TRANSACTIONS, TransportPayload.TRANSACTION_COMPLETE] + assert not action._transport.exists(path) + assert isinstance(action.complete('bar', params), Action) + assert action._transport.get(path) == [{ + ns.NAME: action.get_name(), + ns.VERSION: action.get_version(), + ns.CALLER: action.get_action_name(), + ns.ACTION: 'bar', + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.TYPE: Param.TYPE_STRING, + ns.VALUE: 'bar', + }], + }] + + # No parameter must be empty + with pytest.raises(ValueError): + action.complete('') + + +def test_action_calls_runtime(mocker, state, schemas, action_reply): + from kusanagi.sdk import Action + from kusanagi.sdk import File + from kusanagi.sdk import KusanagiError + from kusanagi.sdk import Param + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + from kusanagi.sdk.lib.payload.transport import TransportPayload + + # Create a transport that is the returned by the call + transport = TransportPayload() + # Mock the call client to return the custom transport and return value + client = mocker.Mock() + client.get_duration.return_value = 123 + client.call.return_value = (42, transport) + mocker.patch('kusanagi.sdk.action.Client', return_value=client) + + state.context['schemas'] = schemas + state.context['command'] = CommandPayload() + state.context['reply'] = action_reply + params = [Param('foo')] + files = [File('foo')] + files[0].is_local = mocker.Mock(return_value=True) + + action = Action(Service(), state) + + # Mock the schemas + schema = mocker.Mock() + action.get_service_schema = mocker.Mock(return_value=schema) + action_schema = mocker.Mock() + schema.get_action_schema = mocker.Mock(return_value=action_schema) + + schema.has_file_server.return_value = True + action_schema.has_return.return_value = True + action_schema.has_call.return_value = True + + path = [ns.CALLS, action.get_name(), action.get_version()] + assert not action._transport.exists(path) + assert action.call('baz', '1.0.0', 'blah', params, files, 1000) == 42 + assert transport.get(path) == action._transport.get(path) == [{ + ns.NAME: 'baz', + ns.VERSION: '1.0.0', + ns.ACTION: 'blah', + ns.CALLER: 'bar', + ns.TIMEOUT: 1000, + ns.DURATION: 123, + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.VALUE: '', + ns.TYPE: Param.TYPE_STRING, + }], + ns.FILES: [{ + ns.NAME: 'foo', + ns.PATH: '', + ns.MIME: '', + ns.FILENAME: '', + ns.SIZE: 0, + }], + }] + + # Call must fail when there is no file server and a local file is used + schema.has_file_server.return_value = False + with pytest.raises(KusanagiError): + action.call('baz', '1.0.0', 'blah', files=files) + + # The remote action must define a return value + action_schema.has_return.return_value = False + with pytest.raises(KusanagiError): + action.call('baz', '1.0.0', 'blah') + + # Call must fail when the call is not defined in the config + action_schema.has_call.return_value = False + with pytest.raises(KusanagiError): + action.call('baz', '1.0.0', 'blah') + + # Call must fail when the schemas are not defined + action.get_service_schema = mocker.Mock(side_effect=LookupError) + with pytest.raises(KusanagiError): + action.call('baz', '1.0.0', 'blah') + + +def test_action_calls_deferred(mocker, state, schemas, action_reply): + from kusanagi.sdk import Action + from kusanagi.sdk import File + from kusanagi.sdk import KusanagiError + from kusanagi.sdk import Param + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + + state.context['schemas'] = schemas + state.context['command'] = CommandPayload() + state.context['reply'] = action_reply + params = [Param('foo')] + files = [File('foo')] + files[0].is_local = mocker.Mock(return_value=True) + + action = Action(Service(), state) + + # Mock the schemas + schema = mocker.Mock() + action.get_service_schema = mocker.Mock(return_value=schema) + action_schema = mocker.Mock() + schema.get_action_schema = mocker.Mock(return_value=action_schema) + + schema.has_file_server.return_value = True + action_schema.has_defer_call.return_value = True + + assert not action._transport.exists([ns.CALLS, action.get_name(), action.get_version()]) + assert isinstance(action.defer_call('baz', '1.0.0', 'blah', params, files), Action) + assert action._transport.get([ns.CALLS, action.get_name(), action.get_version()]) == [{ + ns.NAME: 'baz', + ns.VERSION: '1.0.0', + ns.ACTION: 'blah', + ns.CALLER: 'bar', + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.VALUE: '', + ns.TYPE: Param.TYPE_STRING, + }], + ns.FILES: [{ + ns.NAME: 'foo', + ns.PATH: '', + ns.MIME: '', + ns.FILENAME: '', + ns.SIZE: 0, + }], + }] + + # Call must fail when there is no file server and a local file is used + schema.has_file_server.return_value = False + with pytest.raises(KusanagiError): + action.defer_call('baz', '1.0.0', 'blah', files=files) + + # Call must fail when the call is not defined in the config + action_schema.has_defer_call.return_value = False + with pytest.raises(KusanagiError): + action.defer_call('baz', '1.0.0', 'blah') + + # Call must fail when the schemas are not defined + action.get_service_schema = mocker.Mock(side_effect=LookupError) + with pytest.raises(KusanagiError): + action.defer_call('baz', '1.0.0', 'blah') + + +def test_action_calls_remote(mocker, state, schemas, action_reply): + from kusanagi.sdk import Action + from kusanagi.sdk import File + from kusanagi.sdk import KusanagiError + from kusanagi.sdk import Param + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + + state.context['schemas'] = schemas + state.context['command'] = CommandPayload() + state.context['reply'] = action_reply + params = [Param('foo')] + files = [File('foo')] + files[0].is_local = mocker.Mock(return_value=True) + + action = Action(Service(), state) + + # Mock the schemas + schema = mocker.Mock() + action.get_service_schema = mocker.Mock(return_value=schema) + action_schema = mocker.Mock() + schema.get_action_schema = mocker.Mock(return_value=action_schema) + + schema.has_file_server.return_value = True + action_schema.has_remote_call.return_value = True + + assert not action._transport.exists([ns.CALLS, action.get_name(), action.get_version()]) + assert isinstance(action.remote_call('ktp://6.6.6.6:77', 'baz', '1.0.0', 'blah', params, files, 1000), Action) + assert action._transport.get([ns.CALLS, action.get_name(), action.get_version()]) == [{ + ns.GATEWAY: 'ktp://6.6.6.6:77', + ns.NAME: 'baz', + ns.VERSION: '1.0.0', + ns.ACTION: 'blah', + ns.CALLER: 'bar', + ns.TIMEOUT: 1000, + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.VALUE: '', + ns.TYPE: Param.TYPE_STRING, + }], + ns.FILES: [{ + ns.NAME: 'foo', + ns.PATH: '', + ns.MIME: '', + ns.FILENAME: '', + ns.SIZE: 0, + }], + }] + + # Remote calls must use the KTP protocol (ktp://) + with pytest.raises(ValueError): + action.remote_call('http://6.6.6.6:77', 'baz', '1.0.0', 'blah') + + # Remote calls must fail when there is no file server and a local file is used + schema.has_file_server.return_value = False + with pytest.raises(KusanagiError): + action.remote_call('ktp://6.6.6.6:77', 'baz', '1.0.0', 'blah', files=files) + + # Call must fail when the call is not defined in the config + action_schema.has_remote_call.return_value = False + with pytest.raises(KusanagiError): + action.remote_call('ktp://6.6.6.6:77', 'baz', '1.0.0', 'blah') + + # Call must fail when the schemas are not defined + action.get_service_schema = mocker.Mock(side_effect=LookupError) + with pytest.raises(KusanagiError): + action.remote_call('ktp://6.6.6.6:77', 'baz', '1.0.0', 'blah') + + +def test_action_errors(state, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + + state.context['command'] = action_command + + action = Action(Service(), state) + address = action._transport.get_public_gateway_address() + path = [ns.ERRORS, address, action.get_name(), action.get_version()] + assert not action._transport.exists(path) + assert isinstance(action.error('Message', 77, 'Status'), Action) + assert action._transport.get(path) == [{ + ns.MESSAGE: 'Message', + ns.CODE: 77, + ns.STATUS: 'Status' + }] + + +def test_action_schema_defaults(): + from kusanagi.sdk import ActionSchema + from kusanagi.sdk import HttpActionSchema + from kusanagi.sdk.lib.payload.action import ActionSchemaPayload + + schema = ActionSchema('foo', ActionSchemaPayload()) + assert schema.get_timeout() == ActionSchema.DEFAULT_EXECUTION_TIMEOUT + assert not schema.is_deprecated() + assert not schema.is_collection() + assert schema.get_name() == 'foo' + assert schema.get_entity_path() == '' + assert schema.get_path_delimiter() == '/' + assert not schema.has_entity() + assert schema.get_entity() == {} + assert not schema.has_relations() + assert schema.get_relations() == [] + assert schema.get_calls() == [] + assert not schema.has_call('foo', '1.0.0', 'bar') + assert not schema.has_calls() + assert schema.get_defer_calls() == [] + assert not schema.has_defer_call('foo', '1.0.0', 'bar') + assert not schema.has_defer_calls() + assert schema.get_remote_calls() == [] + assert not schema.has_remote_call('ktp://6.6.6.6:77', 'foo', '1.0.0', 'bar') + assert not schema.has_remote_calls() + assert not schema.has_return() + + with pytest.raises(ValueError): + schema.get_return_type() + + assert schema.get_params() == [] + assert not schema.has_param('foo') + + with pytest.raises(LookupError): + schema.get_param_schema('foo') + + assert schema.get_files() == [] + assert not schema.has_file('bar') + + with pytest.raises(LookupError): + schema.get_file_schema('bar') + + assert schema.get_tags() == [] + assert not schema.has_tag('foo') + + http_schema = schema.get_http_schema() + assert isinstance(http_schema, HttpActionSchema) + assert http_schema.is_accessible() + assert http_schema.get_method() == HttpActionSchema.DEFAULT_METHOD + assert http_schema.get_path() == '' + assert http_schema.get_input() == HttpActionSchema.DEFAULT_INPUT + assert http_schema.get_body() == HttpActionSchema.DEFAULT_BODY + + +def test_action_schema(): + from kusanagi.sdk import ActionSchema + from kusanagi.sdk import FileSchema + from kusanagi.sdk import HttpActionSchema + from kusanagi.sdk import ParamSchema + from kusanagi.sdk.lib import datatypes + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.action import ActionSchemaPayload + + payload = ActionSchemaPayload({ + ns.TIMEOUT: 1234, + ns.DEPRECATED: True, + ns.COLLECTION: True, + ns.ENTITY_PATH: 'a|b|c', + ns.PATH_DELIMITER: '|', + ns.ENTITY: { + ns.FIELD: [{ + ns.NAME: 'foo', + }], + }, + ns.RELATIONS: [{ + ns.NAME: 'first', + }], + ns.CALLS: [['foo', '1.0.0', 'bar']], + ns.DEFERRED_CALLS: [['foo', '1.0.0', 'bar']], + ns.REMOTE_CALLS: [['ktp://6.6.6.6:77', 'foo', '1.0.0', 'bar']], + ns.RETURN: { + ns.TYPE: datatypes.TYPE_INTEGER, + }, + ns.PARAMS: { + 'foo': { + ns.NAME: 'foo', + } + }, + ns.FILES: { + 'bar': { + ns.NAME: 'bar', + } + }, + ns.TAGS: ['foo'], + ns.HTTP: { + ns.GATEWAY: False, + ns.METHOD: 'PUT', + ns.PATH: '/test', + ns.INPUT: 'path', + ns.BODY: ['custom/type'], + }, + }) + + schema = ActionSchema('foo', payload) + assert schema.get_timeout() == payload.get([ns.TIMEOUT]) + assert schema.is_deprecated() + assert schema.is_collection() + assert schema.get_name() == 'foo' + assert schema.get_entity_path() == payload.get([ns.ENTITY_PATH]) + assert schema.get_path_delimiter() == payload.get([ns.PATH_DELIMITER]) + assert schema.resolve_entity({'a': {'b': {'c': {'name': 'foo'}}}}) == {'name': 'foo'} + # Check that an exception is raised when the entity is not resolved + with pytest.raises(LookupError): + schema.resolve_entity({'a': {'b': 42}}) + + assert schema.has_entity() + assert schema.get_entity() == { + 'field': [{'name': 'foo', 'optional': False, 'type': 'string'}], + 'validate': False, + } + assert schema.has_relations() + assert schema.get_relations() == [['one', 'first']] + assert schema.get_calls() == payload.get([ns.CALLS]) + assert schema.has_call('foo', '1.0.0', 'bar') + assert not schema.has_call('foo', '1.0.0', 'invalid') + assert not schema.has_call('foo', 'invalid', 'invalid') + assert not schema.has_call('invalid', 'invalid', 'invalid') + assert schema.has_calls() + assert schema.get_defer_calls() == payload.get([ns.DEFERRED_CALLS]) + assert schema.has_defer_call('foo', '1.0.0', 'bar') + assert not schema.has_defer_call('foo', '1.0.0', 'invalid') + assert not schema.has_defer_call('foo', 'invalid', 'invalid') + assert not schema.has_defer_call('invalid', 'invalid', 'invalid') + assert schema.has_defer_calls() + assert schema.get_remote_calls() == payload.get([ns.REMOTE_CALLS]) + assert schema.has_remote_call('ktp://6.6.6.6:77', 'foo', '1.0.0', 'bar') + assert not schema.has_remote_call('ktp://6.6.6.6:77', 'foo', '1.0.0', 'invalid') + assert not schema.has_remote_call('ktp://6.6.6.6:77', 'foo', 'invalid', 'invalid') + assert not schema.has_remote_call('ktp://6.6.6.6:77', 'invalid', 'invalid', 'invalid') + assert not schema.has_remote_call('invalid', 'invalid', 'invalid', 'invalid') + assert schema.has_remote_calls() + assert schema.has_return() + assert schema.get_return_type() == datatypes.TYPE_INTEGER + + assert schema.get_params() == ['foo'] + assert schema.has_param('foo') + param_schema = schema.get_param_schema('foo') + assert isinstance(param_schema, ParamSchema) + assert param_schema.get_name() == 'foo' + + assert schema.get_files() == ['bar'] + assert schema.has_file('bar') + file_schema = schema.get_file_schema('bar') + assert isinstance(file_schema, FileSchema) + assert file_schema.get_name() == 'bar' + + assert schema.get_tags() == ['foo'] + assert schema.has_tag('foo') + + http_schema = schema.get_http_schema() + assert isinstance(http_schema, HttpActionSchema) + assert not http_schema.is_accessible() + assert http_schema.get_method() == payload.get([ns.HTTP, ns.METHOD]) + assert http_schema.get_path() == payload.get([ns.HTTP, ns.PATH]) + assert http_schema.get_input() == payload.get([ns.HTTP, ns.INPUT]) + assert http_schema.get_body() == payload.get([ns.HTTP, ns.BODY])[0] diff --git a/tests/test_actiondata.py b/tests/test_actiondata.py new file mode 100644 index 0000000..753e7a4 --- /dev/null +++ b/tests/test_actiondata.py @@ -0,0 +1,27 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_actiondata_entity(): + from kusanagi.sdk import ActionData + + data = [{'bar': 'baz'}] + action_data = ActionData('foo', data) + assert action_data.get_name() == 'foo' + assert action_data.get_data() == data + assert not action_data.is_collection() + + +def test_actiondata_collection(): + from kusanagi.sdk import ActionData + + data = [[{'bar': 'baz'}]] + action_data = ActionData('foo', data) + assert action_data.get_name() == 'foo' + assert action_data.get_data() == data + assert action_data.is_collection() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..cba391c --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,63 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import os + +import pytest + + +def test_api(state): + from kusanagi.sdk import Service + from kusanagi.sdk.api import Api + + component = Service() + component.set_resource('foo', lambda *args: 'bar') + + api = Api(component, state) + assert api.is_debug() + assert api.get_framework_version() == state.values.get_framework_version() + assert api.get_path() == os.path.dirname(state.values.get_path()) + assert api.get_name() == state.values.get_name() + assert api.get_version() == state.values.get_version() + assert not api.has_variable('invalid') + assert api.has_variable('foo') + assert api.get_variable('foo') == 'bar' + assert 'foo' in api.get_variables() + assert api.has_resource('foo') + assert api.get_resource('foo') == 'bar' + + with pytest.raises(Exception): + api.done() + + +def test_api_log(state, logs): + from kusanagi.sdk import Service + from kusanagi.sdk.api import Api + from kusanagi.sdk.lib.logging import DEBUG + + api = Api(Service(), state) + assert api.log('Test message', DEBUG) == api + output = logs.getvalue() + assert 'Test message' in output + assert '[DEBUG]' in output + + +def test_api_service_schema(state, logs, schemas): + from kusanagi.sdk import Service + from kusanagi.sdk import ServiceSchema + from kusanagi.sdk.api import Api + + state.context['schemas'] = schemas + + api = Api(Service(), state) + assert api.get_services() == [{'name': 'foo', 'version': '1.0.0'}] + + service_schema = api.get_service_schema('foo', '1.0.0') + assert isinstance(service_schema, ServiceSchema) + + with pytest.raises(LookupError): + api.get_service_schema('invalid', '1.0.0') diff --git a/tests/test_callee.py b/tests/test_callee.py new file mode 100644 index 0000000..1976de6 --- /dev/null +++ b/tests/test_callee.py @@ -0,0 +1,54 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_callee_defaults(): + from kusanagi.sdk import ActionSchema + from kusanagi.sdk import Callee + from kusanagi.sdk.lib.payload import Payload + + callee = Callee(Payload()) + assert callee.get_duration() == 0 + assert not callee.is_remote() + assert callee.get_address() == '' + assert callee.get_timeout() == ActionSchema.DEFAULT_EXECUTION_TIMEOUT + assert callee.get_name() == '' + assert callee.get_version() == '' + assert callee.get_action() == '' + assert callee.get_params() == [] + + +def test_callee(): + from kusanagi.sdk import Callee + from kusanagi.sdk import Param + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload import Payload + + payload = { + ns.DURATION: 10, + ns.GATEWAY: 'ktp://1.2.3.4:77', + ns.TIMEOUT: 10000, + ns.NAME: 'foo', + ns.VERSION: '1.0.0', + ns.ACTION: 'bar', + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.VALUE: 'bar', + }], + } + callee = Callee(Payload(payload)) + assert callee.get_duration() == payload[ns.DURATION] + assert callee.is_remote() + assert callee.get_address() == payload[ns.GATEWAY] + assert callee.get_timeout() == payload[ns.TIMEOUT] + assert callee.get_name() == payload[ns.NAME] + assert callee.get_version() == payload[ns.VERSION] + assert callee.get_action() == payload[ns.ACTION] + params = callee.get_params() + assert len(params) == 1 + assert isinstance(params[0], Param) diff --git a/tests/test_caller.py b/tests/test_caller.py new file mode 100644 index 0000000..ca5816c --- /dev/null +++ b/tests/test_caller.py @@ -0,0 +1,18 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_caller(): + from kusanagi.sdk import Callee + from kusanagi.sdk import Caller + + caller = Caller('foo', '1.0.0', 'bar', {}) + assert caller.get_name() == 'foo' + assert caller.get_version() == '1.0.0' + assert caller.get_action() == 'bar' + assert isinstance(caller.get_callee(), Callee) diff --git a/tests/test_component.py b/tests/test_component.py new file mode 100644 index 0000000..14556f3 --- /dev/null +++ b/tests/test_component.py @@ -0,0 +1,96 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import pytest + + +def test_component_resources(mocker): + from kusanagi.sdk.component import Component + + resource_factory = mocker.Mock(return_value='bar') + + component = Component() + assert not component.has_resource('foo') + component.set_resource('foo', resource_factory) + assert component.has_resource('foo') + assert component.get_resource('foo') == 'bar' + resource_factory.assert_called_once_with(component) + + # The factory must return a value + with pytest.raises(ValueError): + component.set_resource('bar', lambda *args: None) + + # Check that an exception is raised wen the resource doesn't exist + with pytest.raises(LookupError): + component.get_resource('invalid') + + +def test_component_log(logs): + from kusanagi.sdk.component import Component + from kusanagi.sdk.lib.logging import DEBUG + + component = Component() + assert component.log('Test message', DEBUG) == component + output = logs.getvalue() + assert 'Test message' in output + assert '[DEBUG]' in output + + +def test_component_run(mocker): + from kusanagi.sdk.component import Component + + server = mocker.Mock(return_value=None) + server_factory = mocker.patch('kusanagi.sdk.component.create_server', return_value=server) + startup_callback = mocker.Mock(return_value=None) + shutdown_callback = mocker.Mock(return_value=None) + error_callback = mocker.Mock(return_value=None) + + component = Component() + component.startup(startup_callback) + component.shutdown(shutdown_callback) + component.error(error_callback) + assert component.run() + + # The server factory is called with the component, the callbacks dict and the events error handler + server_factory.asser_called_once() + server_factory_args = server_factory.call_args[0] + assert len(server_factory_args) == 3 + assert server_factory_args[0] == component + # There are no callbacks so the callbacks dict must be empty + assert isinstance(server_factory_args[1], dict) + assert len(server_factory_args[1]) == 0 + + server.start.assert_called_once() + startup_callback.assert_called_once_with(component) + shutdown_callback.assert_called_once_with(component) + error_callback.assert_not_called() + + +def test_component_run_fail(mocker, logs): + from kusanagi.sdk.component import Component + + error = Exception('boom!') + mocker.patch('kusanagi.sdk.component.create_server', side_effect=error) + startup_callback = mocker.Mock(return_value=None) + shutdown_callback = mocker.Mock(return_value=None) + error_callback = mocker.Mock(return_value=None) + + component = Component() + component.startup(startup_callback) + component.shutdown(shutdown_callback) + component.error(error_callback) + assert not component.run() + + startup_callback.assert_called_once_with(component) + shutdown_callback.assert_called_once_with(component) + # NOTE: The error callback is called when the server calls the userland + # callback and it fails. That case is tested in the server. Here is + # not called because the server is mocked. + error_callback.assert_not_called() + + output = logs.getvalue() + assert str(error) in output diff --git a/tests/test_error.py b/tests/test_error.py new file mode 100644 index 0000000..1ac7686 --- /dev/null +++ b/tests/test_error.py @@ -0,0 +1,43 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_error_defaults(): + from kusanagi.sdk import Error + + error = Error('http://1.2.3.4:77', 'foo', 'bar') + assert error.get_address() == 'http://1.2.3.4:77' + assert error.get_name() == 'foo' + assert error.get_version() == 'bar' + assert error.get_message() == Error.DEFAULT_MESSAGE + assert error.get_code() == Error.DEFAULT_CODE + assert error.get_status() == Error.DEFAULT_STATUS + + text = str(error) + assert error.get_address() in text + assert error.get_name() in text + assert error.get_version() in text + assert error.get_message() in text + + +def test_error(): + from kusanagi.sdk import Error + + error = Error('http://1.2.3.4:77', 'foo', 'bar', 'Message', 404, 'Not Found') + assert error.get_address() == 'http://1.2.3.4:77' + assert error.get_name() == 'foo' + assert error.get_version() == 'bar' + assert error.get_message() == 'Message' + assert error.get_code() == 404 + assert error.get_status() == 'Not Found' + + text = str(error) + assert error.get_address() in text + assert error.get_name() in text + assert error.get_version() in text + assert error.get_message() in text diff --git a/tests/test_errors.py b/tests/test_errors.py deleted file mode 100644 index 992a18b..0000000 --- a/tests/test_errors.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest - -from kusanagi.errors import KusanagiError - - -def test_kusanagi_error(): - error = KusanagiError() - - # By default no message is used - assert error.message is None - # Class name is used as default string for error - assert str(error) == 'KusanagiError' - - # Create a new error with a message - message = 'Test error message' - error = KusanagiError(message) - assert error.message == message - assert str(error) == message - - -def test_kusanagi_error_subclass(): - # Define an error subclass with a default mesage - class TestError(KusanagiError): - message = 'Custom error message' - - error = TestError() - assert error.message == TestError.message - assert str(error) == TestError.message - - # When a message is give it overrides default one - message = 'Another custom mesage' - error = TestError(message) - assert error.message == message - assert str(error) == message - # Error message is not the default one - assert error.message != TestError.message diff --git a/tests/test_file.py b/tests/test_file.py new file mode 100644 index 0000000..a877568 --- /dev/null +++ b/tests/test_file.py @@ -0,0 +1,361 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import os +import sys +from unittest import mock + +import pytest + + +def test_file_empty_read(): + from kusanagi.sdk import File + from kusanagi.sdk import KusanagiError + + file = File('') + with pytest.raises(KusanagiError): + file.read() + + +def test_file_local(DATA_DIR): + from kusanagi.sdk import File + + path = os.path.join(DATA_DIR, 'file.txt') + file = File('foo', path=f'file://{path}', mime='text/plain', filename='file.txt', size=42) + assert file.is_local() + assert file.get_name() == 'foo' + assert file.get_path() == f'file://{path}' + assert file.get_mime() == 'text/plain' + assert file.get_filename() == 'file.txt' + assert file.get_size() == 42 + assert file.get_token() == '' + + +def test_file_local_relative_path(DATA_DIR): + from kusanagi.sdk import File + + path = os.path.join(DATA_DIR, 'file.txt') + file = File('foo', path=path, mime='text/plain') + assert file.is_local() + # The path is prefixed with "file://" + assert file.get_path() == f'file://{path}' + + +def test_file_local_invalid(DATA_DIR): + from kusanagi.sdk import File + from kusanagi.sdk import KusanagiError + + path = os.path.join(DATA_DIR, 'invalid.txt') + with pytest.raises(KusanagiError): + File('foo', path=f'file://{path}') + + +def test_file_local_no_mime(DATA_DIR): + from kusanagi.sdk import File + + filename = 'file.txt' + path = os.path.join(DATA_DIR, filename) + file = File('foo', path=f'file://{path}', size=42) + assert file.get_name() == 'foo' + assert file.get_path() == f'file://{path}' + assert file.get_mime() == 'text/plain' + assert file.get_filename() == filename + assert file.get_size() == 42 + assert file.get_token() == '' + + +def test_file_local_no_filename(DATA_DIR): + from kusanagi.sdk import File + + filename = 'file.txt' + path = os.path.join(DATA_DIR, filename) + file = File('foo', path=f'file://{path}', mime='text/plain', size=42) + assert file.get_name() == 'foo' + assert file.get_path() == f'file://{path}' + assert file.get_mime() == 'text/plain' + assert file.get_filename() == filename + assert file.get_size() == 42 + assert file.get_token() == '' + + +def test_file_local_no_size(DATA_DIR): + from kusanagi.sdk import File + + path = os.path.join(DATA_DIR, 'file.txt') + file = File('foo', path=f'file://{path}', mime='text/plain', filename='file.txt') + assert file.get_name() == 'foo' + assert file.get_path() == f'file://{path}' + assert file.get_mime() == 'text/plain' + assert file.get_filename() == 'file.txt' + assert file.get_size() == 4 # The size is calculated when is not given + assert file.get_token() == '' + + +def test_file_local_with_token(): + from kusanagi.sdk import File + + # Local file doesn't allow tokens + with pytest.raises(ValueError): + File('foo', path='file://test.json', token='XYZ') + + +def test_file_local_read(DATA_DIR): + from kusanagi.sdk import File + + path = os.path.join(DATA_DIR, 'file.txt') + file = File('foo', path=f'file://{path}', mime='text/plain', size=42) + assert file.read() == b'foo\n' + + +def test_file_local_read_fail(DATA_DIR): + from kusanagi.sdk import File + from kusanagi.sdk import KusanagiError + + # Creation check that the file exists + with pytest.raises(KusanagiError): + file = File('foo', path=f'file://invalid.txt', mime='text/plain', size=42) + + # Create a valid file and the change the file name to an invalid one + with mock.patch(f'kusanagi.sdk.file.open', create=True) as mocked_open: + mocked_open.side_effect = Exception + + path = os.path.join(DATA_DIR, 'file.txt') + file = File('foo', path=f'file://{path}', mime='text/plain', size=42) + with pytest.raises(KusanagiError): + file.read() + + +def test_file_remote(): + from kusanagi.sdk import File + + file = File( + 'foo', + path='http://127.0.0.1:8080/ANBDKAD23142421', + mime='application/json', + filename='ANBDKAD23142421', + size=42, + token='XYZ', + ) + assert not file.is_local() + assert file.get_name() == 'foo' + assert file.get_path() == 'http://127.0.0.1:8080/ANBDKAD23142421' + assert file.get_mime() == 'application/json' + assert file.get_filename() == 'ANBDKAD23142421' + assert file.get_size() == 42 + assert file.get_token() == 'XYZ' + + +def test_file_remote_no_mime(): + from kusanagi.sdk import File + + with pytest.raises(ValueError): + File('foo', path='http://127.0.0.1:8080/ANBDKAD23142421') + + +def test_file_remote_no_filename(): + from kusanagi.sdk import File + + with pytest.raises(ValueError): + File('foo', path='http://127.0.0.1:8080/ANBDKAD23142421', mime='application/json') + + +def test_file_remote_invalid_size(): + from kusanagi.sdk import File + + with pytest.raises(ValueError): + File( + 'foo', + path='http://127.0.0.1:8080/ANBDKAD23142421', + mime='application/json', + filename='ANBDKAD23142421', + size=-1, + ) + + +def test_file_remote_no_token(): + from kusanagi.sdk import File + + with pytest.raises(ValueError): + File( + 'foo', + path='http://127.0.0.1:8080/ANBDKAD23142421', + mime='application/json', + filename='ANBDKAD23142421', + size=42, + ) + + +def test_file_remote_read(DATA_DIR, mocker): + from kusanagi.sdk import File + + # Mock a request reader + request = mocker.MagicMock() + request.read.return_value = b'foo' + + # Mock the "urllib.request.urlopen" + reader = mocker.MagicMock() + reader.__enter__.return_value = request + reader.__exit__.return_value = False + mocker.patch('urllib.request.urlopen', return_value=reader) + + file = File( + 'foo', + path='http://127.0.0.1:8080/ANBDKAD23142421', + mime='application/json', + filename='ANBDKAD23142421', + size=42, + token='XYZ', + ) + assert file.read() == b'foo' + + +def test_file_remote_read_fail(DATA_DIR, mocker): + from kusanagi.sdk import File + from kusanagi.sdk import KusanagiError + + # Mock a request reader + request = mocker.MagicMock() + request.read.side_effect = Exception + + # Mock the "urllib.request.urlopen" + reader = mocker.MagicMock() + reader.__enter__.return_value = request + reader.__exit__.return_value = False + mocker.patch('urllib.request.urlopen', return_value=reader) + + file = File( + 'foo', + path='http://127.0.0.1:8080/ANBDKAD23142421', + mime='application/json', + filename='ANBDKAD23142421', + size=42, + token='XYZ', + ) + with pytest.raises(KusanagiError): + assert file.read() == b'foo' + + +def test_file_parameter_exist(DATA_DIR): + from kusanagi.sdk import File + + # Empty files doesn't exist as parameters + file = File('') + assert not file.exists() + + # Local files doesn't exist as parameters until the are uses in a call + path = os.path.join(DATA_DIR, 'file.txt') + file = File('foo', path=f'file://{path}', mime='text/plain', filename='file.txt') + assert file.is_local() + assert not file.exists() + + # Remote HTTP files exist because they were send in a request or a call + file = File( + 'foo', + path='http://127.0.0.1:8080/ANBDKAD23142421', + mime='application/json', + filename='ANBDKAD23142421', + size=42, + token='XYZ', + ) + assert not file.is_local() + assert file.exists() + + +def test_file_copy(): + from kusanagi.sdk import File + + file = File( + 'foo', + path='http://127.0.0.1:8080/ANBDKAD23142421', + mime='application/json', + filename='ANBDKAD23142421', + size=42, + token='XYZ', + ) + + new_file = file.copy_with_name('bar') + assert isinstance(new_file, File) + assert new_file != file + assert new_file.get_name() == 'bar' + assert new_file.get_path() == file.get_path() + assert new_file.get_size() == file.get_size() + assert new_file.get_mime() == file.get_mime() + assert new_file.get_token() == file.get_token() + + new_file = file.copy_with_mime('text/plain') + assert isinstance(new_file, File) + assert new_file != file + assert new_file.get_mime() == 'text/plain' + assert new_file.get_name() == file.get_name() + assert new_file.get_path() == file.get_path() + assert new_file.get_size() == file.get_size() + assert new_file.get_token() == file.get_token() + + +def test_file_schema_defaults(): + from kusanagi.sdk import FileSchema + from kusanagi.sdk import HttpFileSchema + from kusanagi.sdk.lib.payload.file import FileSchemaPayload + + schema = FileSchema(FileSchemaPayload('foo')) + assert schema.get_name() == 'foo' + assert schema.get_mime() == 'text/plain' + assert not schema.is_required() + assert schema.get_max() == sys.maxsize + assert not schema.is_exclusive_max() + assert schema.get_min() == 0 + assert not schema.is_exclusive_min() + + http_schema = schema.get_http_schema() + assert isinstance(http_schema, HttpFileSchema) + assert http_schema.is_accessible() + assert http_schema.get_param() == schema.get_name() + + +def test_file_schema(): + from kusanagi.sdk import FileSchema + from kusanagi.sdk import HttpFileSchema + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.file import FileSchemaPayload + + payload = { + ns.MIME: 'application/json', + ns.REQUIRED: True, + ns.MAX: 100444, + ns.EXCLUSIVE_MAX: True, + ns.MIN: 100, + ns.EXCLUSIVE_MIN: True, + ns.HTTP: { + ns.GATEWAY: False, + ns.PARAM: 'upload', + } + } + + schema = FileSchema(FileSchemaPayload('foo', payload)) + assert schema.get_name() == 'foo' + assert schema.get_mime() == payload[ns.MIME] + assert schema.is_required() + assert schema.get_max() == payload[ns.MAX] + assert schema.is_exclusive_max() + assert schema.get_min() == payload[ns.MIN] + assert schema.is_exclusive_min() + + http_schema = schema.get_http_schema() + assert isinstance(http_schema, HttpFileSchema) + assert not http_schema.is_accessible() + assert http_schema.get_param() == payload[ns.HTTP][ns.PARAM] + + +def test_validate_file_list(): + from kusanagi.sdk import File + from kusanagi.sdk.file import validate_file_list + + assert validate_file_list([]) is None + assert validate_file_list([File('a'), File('b')]) is None + + with pytest.raises(ValueError): + assert validate_file_list([File('a'), 'boom!']) is None diff --git a/tests/test_json.py b/tests/test_json.py deleted file mode 100644 index c3b7e17..0000000 --- a/tests/test_json.py +++ /dev/null @@ -1,69 +0,0 @@ -import datetime -import decimal - -import pytest - -from kusanagi import json - - -def test_encoder(): - # Create a class that supports serialization - class Serializable(object): - def __serialize__(self): - return {'__type__': 'object', 'value': 'OK'} - - cases = ( - (decimal.Decimal('123.321'), - '123.321'), - (datetime.date(2017, 1, 27), - '2017-01-27'), - (datetime.datetime(2017, 1, 27, 20, 12, 8, 952811), - '2017-01-27T20:12:08.952811+00:00'), - (b'value', - 'value'), - (Serializable(), - {'__type__': 'object', 'value': 'OK'}), - ) - - encoder = json.Encoder() - - for value, expected in cases: - assert encoder.default(value) == expected - - # Unknown objects should raise an error - with pytest.raises(TypeError): - encoder.default(object()) - - -def test_deserialize(): - cases = ( - ('"text"', 'text'), - ('{"foo": "bar"}', {'foo': 'bar'}), - ) - - for value, expected in cases: - assert json.deserialize(value) == expected - - -def test_serialize(): - cases = ( - ('text', b'"text"'), - ({'foo': 'bar'}, b'{"foo":"bar"}'), - ([1, 2, 3], b'[1,2,3]'), - ) - - # Check results without prettyfication - for value, expected in cases: - assert json.serialize(value) == expected - - -def test_serialize_pretty(): - cases = ( - ('text', b'"text"'), - ({'foo': 'bar'}, b'{\n "foo": "bar"\n}'), - ([1, 2, 3], b'[\n 1,\n 2,\n 3\n]'), - ) - - # Check results with prettyfication - for value, expected in cases: - assert json.serialize(value, prettify=True) == expected diff --git a/tests/test_lib_asynchronous.py b/tests/test_lib_asynchronous.py new file mode 100644 index 0000000..a8437ae --- /dev/null +++ b/tests/test_lib_asynchronous.py @@ -0,0 +1,92 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import asyncio + +import pytest + + +def test_async_action_calls_runtime(AsyncMock, mocker, state, schemas, action_reply): + from kusanagi.sdk import AsyncAction + from kusanagi.sdk import File + from kusanagi.sdk import KusanagiError + from kusanagi.sdk import Param + from kusanagi.sdk import Service + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + from kusanagi.sdk.lib.payload.transport import TransportPayload + + # Create a transport that is the returned by the call + transport = TransportPayload() + # Mock the call client to return the custom transport and return value + client = mocker.Mock() + client.get_duration.return_value = 123 + client.call = AsyncMock(return_value=[42, transport]) + mocker.patch('kusanagi.sdk.lib.asynchronous.AsyncClient', return_value=client) + + state.context['schemas'] = schemas + state.context['command'] = CommandPayload() + state.context['reply'] = action_reply + params = [Param('foo')] + files = [File('foo')] + files[0].is_local = mocker.Mock(return_value=True) + + action = AsyncAction(Service(), state) + + # Mock the schemas + schema = mocker.Mock() + action.get_service_schema = mocker.Mock(return_value=schema) + action_schema = mocker.Mock() + schema.get_action_schema = mocker.Mock(return_value=action_schema) + + schema.has_file_server.return_value = True + action_schema.has_return.return_value = True + action_schema.has_call.return_value = True + + path = [ns.CALLS, action.get_name(), action.get_version()] + assert not action._transport.exists(path) + assert asyncio.run(action.call('baz', '1.0.0', 'blah', params, files, 1000)) == 42 + assert transport.get(path) == action._transport.get(path) == [{ + ns.NAME: 'baz', + ns.VERSION: '1.0.0', + ns.ACTION: 'blah', + ns.CALLER: 'bar', + ns.TIMEOUT: 1000, + ns.DURATION: 123, + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.VALUE: '', + ns.TYPE: Param.TYPE_STRING, + }], + ns.FILES: [{ + ns.NAME: 'foo', + ns.PATH: '', + ns.MIME: '', + ns.FILENAME: '', + ns.SIZE: 0, + }], + }] + + # Call must fail when there is no file server and a local file is used + schema.has_file_server.return_value = False + with pytest.raises(KusanagiError): + asyncio.run(action.call('baz', '1.0.0', 'blah', files=files)) + + # The remote action must define a return value + action_schema.has_return.return_value = False + with pytest.raises(KusanagiError): + asyncio.run(action.call('baz', '1.0.0', 'blah')) + + # Call must fail when the call is not defined in the config + action_schema.has_call.return_value = False + with pytest.raises(KusanagiError): + asyncio.run(action.call('baz', '1.0.0', 'blah')) + + # Call must fail when the schemas are not defined + action.get_service_schema = mocker.Mock(side_effect=LookupError) + with pytest.raises(KusanagiError): + asyncio.run(action.call('baz', '1.0.0', 'blah')) diff --git a/tests/test_lib_call.py b/tests/test_lib_call.py new file mode 100644 index 0000000..b51ec43 --- /dev/null +++ b/tests/test_lib_call.py @@ -0,0 +1,250 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import asyncio + +import pytest +import zmq + + +def test_lib_call_async_client_transport_required(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import AsyncClient + from kusanagi.sdk.lib.logging import RequestLogger + + client = AsyncClient(RequestLogger('kusanagi', 'RID')) + with pytest.raises(ValueError): + asyncio.run(client.call('1.2.3.4:77', 'foo', ['bar', '1.0.0', 'baz'], 1000, None)) + + +def test_lib_call_async_client_error_payload(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import AsyncClient + from kusanagi.sdk.lib.call import CallError + from kusanagi.sdk.lib.logging import RequestLogger + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.payload.error import ErrorPayload + + payload = ErrorPayload.new() + + socket = mocker.Mock() + socket.send_multipart = AsyncMock() + socket.recv = AsyncMock(return_value=pack(payload)) + socket.poll = AsyncMock(return_value=zmq.POLLIN) + CONTEXT = mocker.patch('kusanagi.sdk.lib.call.CONTEXT') + CONTEXT.socket.return_value = socket + + # When an error payload is returned by the remote service it is used for the exception + client = AsyncClient(RequestLogger('kusanagi', 'RID')) + with pytest.raises(CallError, match=payload.get_message()): + asyncio.run(client.call('1.2.3.4:77', 'foo', ['bar', '1.0.0', 'baz'], 1000, {})) + + +def test_lib_call_async_client_invalid_payload(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import AsyncClient + from kusanagi.sdk.lib.call import CallError + from kusanagi.sdk.lib.logging import RequestLogger + from kusanagi.sdk.lib.msgpack import pack + + payload = 'Boom !' + + socket = mocker.Mock() + socket.send_multipart = AsyncMock() + socket.recv = AsyncMock(return_value=pack(payload)) + socket.poll = AsyncMock(return_value=zmq.POLLIN) + CONTEXT = mocker.patch('kusanagi.sdk.lib.call.CONTEXT') + CONTEXT.socket.return_value = socket + + # When an error payload is returned by the remote service it is used for the exception + client = AsyncClient(RequestLogger('kusanagi', 'RID')) + with pytest.raises(CallError, match='payload data is not valid'): + asyncio.run(client.call('1.2.3.4:77', 'foo', ['bar', '1.0.0', 'baz'], 1000, {})) + + +def test_lib_call_async_client_invalid_stream(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import AsyncClient + from kusanagi.sdk.lib.call import CallError + from kusanagi.sdk.lib.logging import RequestLogger + + stream = b'Boom !' + + socket = mocker.Mock() + socket.send_multipart = AsyncMock() + socket.recv = AsyncMock(return_value=stream) + socket.poll = AsyncMock(return_value=zmq.POLLIN) + CONTEXT = mocker.patch('kusanagi.sdk.lib.call.CONTEXT') + CONTEXT.socket.return_value = socket + + # When an error payload is returned by the remote service it is used for the exception + client = AsyncClient(RequestLogger('kusanagi', 'RID')) + + with pytest.raises(CallError, match='response format is invalid'): + asyncio.run(client.call('1.2.3.4:77', 'foo', ['bar', '1.0.0', 'baz'], 1000, {})) + + +def test_lib_call_async_client_timeout(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import AsyncClient + from kusanagi.sdk.lib.call import CallError + from kusanagi.sdk.lib.logging import RequestLogger + from kusanagi.sdk.lib.msgpack import pack + + socket = mocker.Mock() + socket.send_multipart = AsyncMock() + socket.recv = AsyncMock(return_value=pack({})) + socket.poll = AsyncMock(return_value=0) + CONTEXT = mocker.patch('kusanagi.sdk.lib.call.CONTEXT') + CONTEXT.socket.return_value = socket + + # When an error payload is returned by the remote service it is used for the exception + client = AsyncClient(RequestLogger('kusanagi', 'RID')) + with pytest.raises(CallError, match='Timeout'): + asyncio.run(client.call('1.2.3.4:77', 'foo', ['bar', '1.0.0', 'baz'], 1000, {})) + + +def test_lib_call_async_client_request_fail(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import AsyncClient + from kusanagi.sdk.lib.call import CallError + from kusanagi.sdk.lib.logging import RequestLogger + from kusanagi.sdk.lib.msgpack import pack + + socket = mocker.Mock() + socket.send_multipart = AsyncMock() + socket.recv = AsyncMock(return_value=pack({})) + socket.poll = AsyncMock(side_effect=Exception) + CONTEXT = mocker.patch('kusanagi.sdk.lib.call.CONTEXT') + CONTEXT.socket.return_value = socket + + # When an error payload is returned by the remote service it is used for the exception + client = AsyncClient(RequestLogger('kusanagi', 'RID')) + with pytest.raises(CallError, match='Failed to make the request'): + asyncio.run(client.call('1.2.3.4:77', 'foo', ['bar', '1.0.0', 'baz'], 1000, {})) + + +def test_lib_call_async_client_ipc(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import AsyncClient + from kusanagi.sdk.lib.call import ipc + from kusanagi.sdk.lib.logging import RequestLogger + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + address = '1.2.3.4:77' + channel = ipc(address) + transport = { + ns.META: {}, + } + payload = ReplyPayload() + payload.set([ns.RETURN], 42) + payload.set([ns.TRANSPORT], transport) + + socket = mocker.Mock() + socket.closed = False + socket.send_multipart = AsyncMock() + socket.recv = AsyncMock(return_value=pack(payload)) + socket.poll = AsyncMock(return_value=zmq.POLLIN) + CONTEXT = mocker.patch('kusanagi.sdk.lib.call.CONTEXT') + CONTEXT.socket.return_value = socket + + client = AsyncClient(RequestLogger('kusanagi', 'RID')) + return_value, transport = asyncio.run(client.call(address, 'foo', ['bar', '1.0.0', 'baz'], 1000, transport)) + assert return_value == 42 + assert transport == transport + assert client.get_duration() >= 0 + socket.connect.assert_called_once_with(channel) + socket.disconnect.assert_called_once_with(channel) + socket.close.assert_called_once() + + +def test_lib_call_async_client_tcp(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import AsyncClient + from kusanagi.sdk.lib.logging import RequestLogger + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + address = '1.2.3.4:77' + channel = f'tcp://{address}' + transport = { + ns.META: {}, + } + payload = ReplyPayload() + payload.set([ns.RETURN], 42) + payload.set([ns.TRANSPORT], transport) + + socket = mocker.Mock() + socket.closed = False + socket.send_multipart = AsyncMock() + socket.recv = AsyncMock(return_value=pack(payload)) + socket.poll = AsyncMock(return_value=zmq.POLLIN) + CONTEXT = mocker.patch('kusanagi.sdk.lib.call.CONTEXT') + CONTEXT.socket.return_value = socket + + client = AsyncClient(RequestLogger('kusanagi', 'RID'), tcp=True) + return_value, transport = asyncio.run(client.call(address, 'foo', ['bar', '1.0.0', 'baz'], 1000, transport)) + assert return_value == 42 + assert transport == transport + assert client.get_duration() >= 0 + socket.connect.assert_called_once_with(channel) + socket.disconnect.assert_called_once_with(channel) + socket.close.assert_called_once() + + +def test_lib_call_client_ipc(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import Client + from kusanagi.sdk.lib.call import ipc + from kusanagi.sdk.lib.logging import RequestLogger + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + address = '1.2.3.4:77' + transport = { + ns.META: {}, + } + payload = ReplyPayload() + payload.set([ns.RETURN], 42) + payload.set([ns.TRANSPORT], transport) + + socket = mocker.Mock() + socket.send_multipart = AsyncMock() + socket.recv = AsyncMock(return_value=pack(payload)) + socket.poll = AsyncMock(return_value=zmq.POLLIN) + CONTEXT = mocker.patch('kusanagi.sdk.lib.call.CONTEXT') + CONTEXT.socket.return_value = socket + + client = Client(RequestLogger('kusanagi', 'RID')) + return_value, transport = client.call(address, 'foo', ['bar', '1.0.0', 'baz'], 1000, transport) + assert return_value == 42 + assert transport == transport + socket.connect.assert_called_once_with(ipc(address)) + + +def test_lib_call_client_tcp(AsyncMock, mocker, logs): + from kusanagi.sdk.lib.call import Client + from kusanagi.sdk.lib.logging import RequestLogger + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + address = '1.2.3.4:77' + transport = { + ns.META: {}, + } + payload = ReplyPayload() + payload.set([ns.RETURN], 42) + payload.set([ns.TRANSPORT], transport) + + socket = mocker.Mock() + socket.send_multipart = AsyncMock() + socket.recv = AsyncMock(return_value=pack(payload)) + socket.poll = AsyncMock(return_value=zmq.POLLIN) + CONTEXT = mocker.patch('kusanagi.sdk.lib.call.CONTEXT') + CONTEXT.socket.return_value = socket + + client = Client(RequestLogger('kusanagi', 'RID'), tcp=True) + return_value, transport = client.call(address, 'foo', ['bar', '1.0.0', 'baz'], 1000, transport) + assert return_value == 42 + assert transport == transport + socket.connect.assert_called_once_with(f'tcp://{address}') diff --git a/tests/test_lib_cli.py b/tests/test_lib_cli.py new file mode 100644 index 0000000..007b26a --- /dev/null +++ b/tests/test_lib_cli.py @@ -0,0 +1,173 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import pytest + + +def test_lib_parse_args(mocker): + from kusanagi.sdk.lib import logging + from kusanagi.sdk.lib.cli import PARSER + from kusanagi.sdk.lib.cli import parse_args + + mocker.patch('inspect.getouterframes', return_value=[['', 'test.py']]) + + class Namespace(object): + pass + + namespace = Namespace() + namespace.component = 'service' + namespace.name = 'foo' + namespace.version = '1.0.0' + namespace.framework_version = '3.0.0' + namespace.socket = '@kusanagi-1.2.3.4-77' + namespace.timeout = 10000 + namespace.debug = True + namespace.var = ['foo=bar', 'bar=baz'] + namespace.tcp = None + namespace.log_level = 7 # SYSLOG_NUMERIC[7] = DEBUG + PARSER.parse_args = mocker.Mock(return_value=namespace) + + input_ = parse_args() + assert input_.get_path() == 'test.py' + assert input_.get_component() == 'service' + assert input_.get_name() == 'foo' + assert input_.get_version() == '1.0.0' + assert input_.get_framework_version() == '3.0.0' + assert input_.get_socket() == '@kusanagi-1.2.3.4-77' + assert input_.get_tcp() == 0 + assert not input_.is_tcp_enabled() + assert input_.get_channel() == 'ipc://@kusanagi-1.2.3.4-77' + assert input_.get_timeout() == 10000 + assert input_.is_debug() + assert input_.has_variable('foo') + assert not input_.has_variable('invalid') + assert input_.get_variable('foo') == 'bar' + assert input_.get_variables() == {'foo': 'bar', 'bar': 'baz'} + assert input_.has_logging() + assert input_.get_log_level() == logging.DEBUG + + +def test_lib_parse_key_value_list(): + from kusanagi.sdk.lib.cli import parse_key_value_list + + assert parse_key_value_list([]) == {} + assert parse_key_value_list(['foo=bar', 'bar=baz']) == {'foo': 'bar', 'bar': 'baz'} + + with pytest.raises(ValueError): + parse_key_value_list(['']) + + +def test_lib_input_ipc(): + from kusanagi.sdk.lib import logging + from kusanagi.sdk.lib.cli import Input + + variables = {'foo': 'bar', 'bar': 'baz'} + input_ = Input( + 'test.py', + component='service', + name='foo', + version='1.0.0', + framework_version='3.0.0', + socket='@kusanagi-1.2.3.4-77', + timeout=10000, + debug=True, + var=variables, + tcp=None, + log_level=7, # SYSLOG_NUMERIC[7] = DEBUG + ) + assert input_.get_path() == 'test.py' + assert input_.get_component() == 'service' + assert input_.get_name() == 'foo' + assert input_.get_version() == '1.0.0' + assert input_.get_framework_version() == '3.0.0' + assert input_.get_socket() == '@kusanagi-1.2.3.4-77' + assert input_.get_tcp() == 0 + assert not input_.is_tcp_enabled() + assert input_.get_channel() == 'ipc://@kusanagi-1.2.3.4-77' + assert input_.get_timeout() == 10000 + assert input_.is_debug() + assert input_.has_variable('foo') + assert not input_.has_variable('invalid') + assert input_.get_variable('foo') == 'bar' + assert input_.get_variables() == variables + assert input_.has_logging() + assert input_.get_log_level() == logging.DEBUG + + +def test_lib_input_ipc_default(): + from kusanagi.sdk.lib import logging + from kusanagi.sdk.lib.cli import Input + + variables = {'foo': 'bar', 'bar': 'baz'} + input_ = Input( + 'test.py', + component='service', + name='foo', + version='1.0.0', + framework_version='3.0.0', + socket=None, + timeout=10000, + debug=True, + var=variables, + tcp=None, + log_level=7, # SYSLOG_NUMERIC[7] = DEBUG + ) + + assert input_.get_path() == 'test.py' + assert input_.get_component() == 'service' + assert input_.get_name() == 'foo' + assert input_.get_version() == '1.0.0' + assert input_.get_framework_version() == '3.0.0' + assert input_.get_socket() == '@kusanagi-service-foo-1-0-0' + assert input_.get_tcp() == 0 + assert not input_.is_tcp_enabled() + assert input_.get_channel() == 'ipc://@kusanagi-service-foo-1-0-0' + assert input_.get_timeout() == 10000 + assert input_.is_debug() + assert input_.has_variable('foo') + assert not input_.has_variable('invalid') + assert input_.get_variable('foo') == 'bar' + assert input_.get_variables() == variables + assert input_.has_logging() + assert input_.get_log_level() == logging.DEBUG + + +def test_lib_input_tcp(): + from kusanagi.sdk.lib import logging + from kusanagi.sdk.lib.cli import Input + + variables = {'foo': 'bar', 'bar': 'baz'} + input_ = Input( + 'test.py', + component='service', + name='foo', + version='1.0.0', + framework_version='3.0.0', + socket=None, + timeout=10000, + debug=True, + var=variables, + tcp=77, + log_level=7, # SYSLOG_NUMERIC[7] = DEBUG + ) + assert input_.get_path() == 'test.py' + assert input_.get_component() == 'service' + assert input_.get_name() == 'foo' + assert input_.get_version() == '1.0.0' + assert input_.get_framework_version() == '3.0.0' + assert input_.get_socket() == '' + assert input_.get_tcp() == 77 + assert input_.is_tcp_enabled() + assert input_.get_channel() == 'tcp://127.0.0.1:77' + assert input_.get_timeout() == 10000 + assert input_.is_debug() + assert input_.has_variable('foo') + assert not input_.has_variable('invalid') + assert input_.get_variable('foo') == 'bar' + assert input_.get_variables() == variables + assert input_.has_logging() + assert input_.get_log_level() == logging.DEBUG diff --git a/tests/test_lib_events.py b/tests/test_lib_events.py new file mode 100644 index 0000000..d3168ef --- /dev/null +++ b/tests/test_lib_events.py @@ -0,0 +1,76 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_lib_events_startup(mocker, logs): + from kusanagi.sdk.lib.events import Events + + component = object() + startup_callback = mocker.Mock(return_value=None) + shutdown_callback = mocker.Mock(return_value=None) + error_callback = mocker.Mock(return_value=None) + events = Events(startup_callback, shutdown_callback, error_callback) + assert events.startup(component) + startup_callback.assert_called_once_with(component) + shutdown_callback.assert_not_called() + error_callback.assert_not_called() + + startup_callback = mocker.Mock(side_effect=Exception) + events = Events(startup_callback, shutdown_callback, error_callback) + assert not events.startup(component) + startup_callback.assert_called_once_with(component) + shutdown_callback.assert_not_called() + error_callback.assert_not_called() + + assert logs.getvalue() != '' + + +def test_lib_events_shutdown(mocker, logs): + from kusanagi.sdk.lib.events import Events + + component = object() + startup_callback = mocker.Mock(return_value=None) + shutdown_callback = mocker.Mock(return_value=None) + error_callback = mocker.Mock(return_value=None) + events = Events(startup_callback, shutdown_callback, error_callback) + assert events.shutdown(component) + startup_callback.assert_not_called() + shutdown_callback.assert_called_once_with(component) + error_callback.assert_not_called() + + shutdown_callback = mocker.Mock(side_effect=Exception) + events = Events(startup_callback, shutdown_callback, error_callback) + assert not events.shutdown(component) + startup_callback.assert_not_called() + shutdown_callback.assert_called_once_with(component) + error_callback.assert_not_called() + + assert logs.getvalue() != '' + + +def test_lib_events_error(mocker, logs): + from kusanagi.sdk.lib.events import Events + + error = object() + startup_callback = mocker.Mock(return_value=None) + shutdown_callback = mocker.Mock(return_value=None) + error_callback = mocker.Mock(return_value=None) + events = Events(startup_callback, shutdown_callback, error_callback) + assert events.error(error) + startup_callback.assert_not_called() + shutdown_callback.assert_not_called() + error_callback.assert_called_once_with(error) + + error_callback = mocker.Mock(side_effect=Exception) + events = Events(startup_callback, shutdown_callback, error_callback) + assert not events.error(error) + startup_callback.assert_not_called() + shutdown_callback.assert_not_called() + error_callback.assert_called_once_with(error) + + assert logs.getvalue() != '' diff --git a/tests/test_lib_formatting.py b/tests/test_lib_formatting.py new file mode 100644 index 0000000..e3094f2 --- /dev/null +++ b/tests/test_lib_formatting.py @@ -0,0 +1,45 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import time +from datetime import date +from datetime import datetime + + +def test_lib_str_to_datetime(): + from kusanagi.sdk.lib.formatting import str_to_datetime + + value = datetime(2017, 1, 27, 20, 12, 8, 952811) + assert str_to_datetime('2017-01-27T20:12:08.952811+00:00') == value + + +def test_lib_datetime_to_str(): + from kusanagi.sdk.lib.formatting import datetime_to_str + + value = datetime(2017, 1, 27, 20, 12, 8, 952811) + assert datetime_to_str(value) == '2017-01-27T20:12:08.952811+00:00' + + +def test_lib_str_to_date(): + from kusanagi.sdk.lib.formatting import str_to_date + + value = date(2017, 1, 27) + assert str_to_date('2017-01-27') == value + + +def test_lib_date_to_str(): + from kusanagi.sdk.lib.formatting import date_to_str + + value = date(2017, 1, 27) + assert date_to_str(value) == '2017-01-27' + + +def test_lib_time_to_str(): + from kusanagi.sdk.lib.formatting import time_to_str + + value = time.struct_time((0, 0, 0, 20, 12, 8, 0, 0, 0)) + assert time_to_str(value) == '20:12:08' diff --git a/tests/test_lib_json.py b/tests/test_lib_json.py new file mode 100644 index 0000000..caf6ade --- /dev/null +++ b/tests/test_lib_json.py @@ -0,0 +1,49 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +from datetime import date +from datetime import datetime +from decimal import Decimal + +import pytest + + +def test_lib_json_encoder(): + from kusanagi.sdk.lib.json import Encoder + + encoder = Encoder() + assert encoder.default(Decimal('123.321')) == '123.321' + assert encoder.default(date(2017, 1, 27)) == '2017-01-27' + assert encoder.default(datetime(2017, 1, 27, 20, 12, 8, 952811)) == '2017-01-27T20:12:08.952811+00:00' + assert encoder.default(b'value') == 'value' + + # Unknown objects should raise an error + with pytest.raises(TypeError): + encoder.default(object()) + + +def test_lib_json_loads(): + from kusanagi.sdk.lib.json import loads + + assert loads('"text"') == 'text' + assert loads('{"foo": "bar"}') == {'foo': 'bar'} + + +def test_lib_json_dumps(): + from kusanagi.sdk.lib.json import dumps + + assert dumps('text') == b'"text"' + assert dumps({'foo': 'bar'}) == b'{"foo":"bar"}' + assert dumps([1, 2, 3]) == b'[1,2,3]' + + +def test_lib_json_dumps_pretty(): + from kusanagi.sdk.lib.json import dumps + + assert dumps('text', prettify=True) == b'"text"' + assert dumps({'foo': 'bar'}, prettify=True) == b'{\n "foo": "bar"\n}' + assert dumps([1, 2, 3], prettify=True) == b'[\n 1,\n 2,\n 3\n]' diff --git a/tests/test_lib_logging.py b/tests/test_lib_logging.py new file mode 100644 index 0000000..e2621b2 --- /dev/null +++ b/tests/test_lib_logging.py @@ -0,0 +1,298 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import logging + + +def test_lib_logging_formatter(): + from kusanagi.sdk.lib.logging import KusanagiFormatter + + class Record(object): + pass + + record = Record() + record.created = 1485622839.2490458 # Non GMT timestamp + assert KusanagiFormatter().formatTime(record) == '2017-01-28T17:00:39.249' + + +def test_lib_logging_value_to_log_string(): + from kusanagi.sdk.lib.logging import value_to_log_string + + # Define a dummy class + class Dummy(object): + def __repr__(self): + return 'DUMMY' + + # Define a dummy function + def dummy(): + pass + + assert value_to_log_string(None) == 'NULL' + assert value_to_log_string(True) == 'TRUE' + assert value_to_log_string(False) == 'FALSE' + assert value_to_log_string('value') == 'value' + assert value_to_log_string(b'value') == 'dmFsdWU=' + assert value_to_log_string(lambda: None) == 'anonymous' + assert value_to_log_string(dummy) == '[function dummy]' + + # Dictionaries and list are serialized as pretty JSON + assert value_to_log_string({'a': 1}) == '{\n "a": 1\n}' + assert value_to_log_string(['1', '2']) == '[\n "1",\n "2"\n]' + + # For unknown types 'repr()' is used to get log string + assert value_to_log_string(Dummy()) == 'DUMMY' + + # Check maximum characters + max_chars = 100000 + assert len(value_to_log_string('*' * max_chars)) == max_chars + assert len(value_to_log_string('*' * (max_chars + 10))) == max_chars + + +def test_lib_logging_setup_kusanagi_loggingr(logs): + from kusanagi.sdk.lib.logging import KusanagiFormatter + + # Root logger must use KusanagiFormatter + assert len(logging.root.handlers) == 1 + assert isinstance(logging.root.handlers[0].formatter, KusanagiFormatter) + + # SDK logger must use KusanagiFormatter + logger = logging.getLogger('kusanagi') + assert len(logger.handlers) == 1 + assert logger.level == logging.DEBUG + assert isinstance(logger.handlers[0].formatter, KusanagiFormatter) + + assert logging.getLogger('asyncio').level == logging.ERROR + + # Basic check for logging format + message = 'Test message' + logging.getLogger('kusanagi').info(message) + out = logs.getvalue() + assert len(out) > 0 + out_parts = out.split(' ') + assert out_parts[0].endswith('Z') # Time + assert out_parts[1] == 'component' # Component type + assert out_parts[2] == 'name/version' # Component name and version + assert out_parts[3] == '(framework-version)' + assert out_parts[4] == '[INFO]' # Level + assert out_parts[5] == '[SDK]' # SDK prefix + assert ' '.join(out_parts[6:]).strip() == message + + +def test_lib_logging_logger_debug(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.debug('Test message') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[DEBUG]' in output + assert '\n' not in output + + +def test_lib_logging_logger_debug_rid(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.debug('Test message', rid='RID') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[DEBUG]' in output + assert '|RID|' in output + assert '\n' not in output + + +def test_lib_logging_logger_info(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.info('Test message') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[INFO]' in output + assert '\n' not in output + + +def test_lib_logging_logger_info_rid(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.info('Test message', rid='RID') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[INFO]' in output + assert '|RID|' in output + assert '\n' not in output + + +def test_lib_logging_logger_warning(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.warning('Test message') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[WARNING]' in output + assert '\n' not in output + + +def test_lib_logging_logger_warning_rid(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.warning('Test message', rid='RID') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[WARNING]' in output + assert '|RID|' in output + assert '\n' not in output + + +def test_lib_logging_logger_error(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.error('Test message') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[ERROR]' in output + assert '\n' not in output + + +def test_lib_logging_logger_error_rid(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.error('Test message', rid='RID') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[ERROR]' in output + assert '|RID|' in output + assert '\n' not in output + + +def test_lib_logging_logger_critical(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.critical('Test message') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[CRITICAL]' in output + assert '\n' not in output + + +def test_lib_logging_logger_critical_rid(logs): + from kusanagi.sdk.lib.logging import Logger + + logger = Logger('kusanagi') + logger.critical('Test message', rid='RID') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[CRITICAL]' in output + assert '|RID|' in output + assert '\n' not in output + + +def test_lib_logging_logger_exception(logs): + from kusanagi.sdk.lib import cli + from kusanagi.sdk.lib.logging import Logger + + cli.DEBUG = False + logger = Logger('kusanagi') + logger.exception('Test message') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[ERROR]' in output + assert '\n' not in output + + +def test_lib_logging_logger_exception_rid(logs): + from kusanagi.sdk.lib import cli + from kusanagi.sdk.lib.logging import Logger + + # Exception tracebacks are displayed when DEBUG is enabled + cli.DEBUG = True + logger = Logger('kusanagi') + logger.exception('Test message', rid='RID') + output = logs.getvalue().rstrip('\n') + assert 'Test message' in output + assert '[ERROR]' in output + assert '|RID|' in output + assert '\n' in output + + +def test_lib_logging_request_logger(logs): + from kusanagi.sdk.lib.logging import RequestLogger + + logger = RequestLogger('kusanagi', 'RID') + assert logger.rid == 'RID' + + +def test_lib_logging_request_logger_debug(logs): + from kusanagi.sdk.lib.logging import RequestLogger + + logger = RequestLogger('kusanagi', 'RID') + logger.debug('Test message') + output = logs.getvalue() + assert 'Test message' in output + assert '[DEBUG]' in output + assert '|RID|' in output + + +def test_lib_logging_request_logger_info(logs): + from kusanagi.sdk.lib.logging import RequestLogger + + logger = RequestLogger('kusanagi', 'RID') + logger.info('Test message') + output = logs.getvalue() + assert 'Test message' in output + assert '[INFO]' in output + assert '|RID|' in output + + +def test_lib_logging_request_logger_warning(logs): + from kusanagi.sdk.lib.logging import RequestLogger + + logger = RequestLogger('kusanagi', 'RID') + logger.warning('Test message') + output = logs.getvalue() + assert 'Test message' in output + assert '[WARNING]' in output + assert '|RID|' in output + + +def test_lib_logging_request_logger_error(logs): + from kusanagi.sdk.lib.logging import RequestLogger + + logger = RequestLogger('kusanagi', 'RID') + logger.error('Test message') + output = logs.getvalue() + assert 'Test message' in output + assert '[ERROR]' in output + assert '|RID|' in output + + +def test_lib_logging_request_logger_critical(logs): + from kusanagi.sdk.lib.logging import RequestLogger + + logger = RequestLogger('kusanagi', 'RID') + logger.critical('Test message') + output = logs.getvalue() + assert 'Test message' in output + assert '[CRITICAL]' in output + assert '|RID|' in output + + +def test_lib_logging_request_logger_exception(logs): + from kusanagi.sdk.lib.logging import RequestLogger + + logger = RequestLogger('kusanagi', 'RID') + logger.exception('Test message') + output = logs.getvalue() + assert 'Test message' in output + assert '[ERROR]' in output + assert '|RID|' in output diff --git a/tests/test_lib_msgpack.py b/tests/test_lib_msgpack.py new file mode 100644 index 0000000..271ac1a --- /dev/null +++ b/tests/test_lib_msgpack.py @@ -0,0 +1,61 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import time +from datetime import date +from datetime import datetime +from decimal import Decimal + +import pytest + + +def test_lib_msgpack_encode(): + from kusanagi.sdk.lib.msgpack import _encode + + time_value = '20:12:08' + date_value = '2017-01-27' + datetime_value = f'{date_value}T{time_value}.952811+00:00' + + assert _encode(Decimal('123.321')) == ['type', 'decimal', ['123', '321']] + assert _encode(date(2017, 1, 27)) == ['type', 'date', date_value] + assert _encode(datetime(2017, 1, 27, 20, 12, 8, 952811)) == ['type', 'datetime', datetime_value] + assert _encode(time.strptime("2017-01-27 20:12:08", "%Y-%m-%d %H:%M:%S")) == ['type', 'time', time_value] + + # A string is not a custom type + with pytest.raises(TypeError): + _encode('') + + +def test_lib_msgpack_decode(): + from kusanagi.sdk.lib.msgpack import _decode + + time_value = '20:12:08' + date_value = '2017-01-27' + datetime_value = f'{date_value}T{time_value}.952811+00:00' + + assert _decode(['type', 'decimal', ['123', '321']]) == Decimal('123.321') + assert _decode(['type', 'date', date_value]) == date(2017, 1, 27) + assert _decode(['type', 'datetime', datetime_value]) == datetime(2017, 1, 27, 20, 12, 8, 952811) + assert _decode(['type', 'time', time_value]) == time_value + + # Invalid format should not fail + assert _decode(['type', 'date', '']) is None + + # Values other than dictionaries are not decoded + assert _decode('NON_DICT') == 'NON_DICT' + + +def test_lib_msgpack_pack(): + from kusanagi.sdk.lib.msgpack import pack + + assert pack({}) == b'\x80' + + +def test_lib_msgpack_unpack(): + from kusanagi.sdk.lib.msgpack import unpack + + assert unpack(b'\x80') == {} diff --git a/tests/test_lib_payload.py b/tests/test_lib_payload.py new file mode 100644 index 0000000..1e1765a --- /dev/null +++ b/tests/test_lib_payload.py @@ -0,0 +1,132 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_lib_payload(): + from kusanagi.sdk.lib.payload import Payload + + payload = Payload({'foo': 'bar', 'bar': 'baz'}) + assert isinstance(payload, dict) + assert str(payload) == '{\n "foo": "bar",\n "bar": "baz"\n}' + + +def test_lib_payload_exists(): + from kusanagi.sdk.lib.payload import Payload + + payload = Payload({'foo': 'bar', 'bar': {'baz': 42}}) + assert payload.exists(['foo']) + assert payload.exists(['bar', 'baz']) + assert not payload.exists(['invalid', 'baz']) + + payload.path_prefix = ['bar'] + assert not payload.exists(['foo']) + assert payload.exists(['baz']) + + +def test_lib_payload_equals(): + from kusanagi.sdk.lib.payload import Payload + + payload = Payload({'foo': 'bar', 'bar': {'baz': 42}}) + assert payload.equals(['bar', 'baz'], 42) + # False is returned when the path is invalid + assert not payload.equals(['invalid', 'baz'], '') + + payload.path_prefix = ['bar'] + assert not payload.equals(['bar', 'baz'], 42) + assert payload.equals(['baz'], 42) + + +def test_lib_payload_get(): + from kusanagi.sdk.lib.payload import Payload + + payload = Payload({'foo': 'bar', 'bar': {'baz': 42}}) + assert payload.get(['bar', 'baz']) == 42 + assert payload.get(['invalid', 'baz']) is None + assert payload.get(['invalid', 'baz'], 'default') == 'default' + + payload.path_prefix = ['bar'] + assert not payload.get(['bar', 'baz']) == 42 + assert payload.get(['baz']) == 42 + assert payload.get(['invalid', 'baz']) is None + assert payload.get(['invalid', 'baz'], 'default') == 'default' + + +def test_lib_payload_set(): + from kusanagi.sdk.lib.payload import Payload + + payload = Payload({'foo': 'bar', 'bar': {'baz': 42, 'blah': []}}) + # Set value for an existing path + assert payload.get(['bar', 'baz']) == 42 + assert payload.set(['bar', 'baz'], 77) + assert payload.get(['bar', 'baz']) == 77 + # Set a value for a new path + assert not payload.exists(['invalid', 'baz']) + assert payload.set(['invalid', 'baz'], 77) + assert payload.get(['invalid', 'baz']) == 77 + # Set value for an existing path that is not seteable + assert payload.get(['bar', 'blah']) == [] + assert not payload.set(['bar', 'blah', 'teh'], 77) + assert payload.get(['bar', 'blah']) == [] + + assert payload.get(['bar', 'baz']) == 77 + payload.path_prefix = ['bar'] + assert payload.get(['baz']) == 77 + assert payload.set(['baz'], 42) + assert payload.get(['baz']) == 42 + assert payload.get(['bar', 'baz'], prefix=False) == 42 + + +def test_lib_payload_append(): + from kusanagi.sdk.lib.payload import Payload + + payload = Payload({'foo': 'bar', 'bar': {'baz': 42, 'blah': []}}) + assert payload.append(['bar', 'blah'], 1) + assert payload.get(['bar', 'blah']) == [1] + assert not payload.exists(['invalid', 'baz']) + assert not payload.append(['bar', 'baz', 'boom'], 1) + assert payload.get(['invalid', 'baz']) is None + assert payload.append(['invalid', 'baz'], 2) + assert payload.get(['invalid', 'baz']) == [2] + + payload.path_prefix = ['bar'] + assert payload.get(['blah']) == [1] + assert payload.append(['blah'], 3) + assert payload.get(['blah']) == [1, 3] + + +def test_lib_payload_extend(): + from kusanagi.sdk.lib.payload import Payload + + payload = Payload({'foo': 'bar', 'bar': {'baz': 42, 'blah': []}}) + assert payload.extend(['bar', 'blah'], [1]) + assert payload.get(['bar', 'blah']) == [1] + assert not payload.extend(['bar', 'baz', 'boom'], [1]) + assert not payload.exists(['invalid', 'baz']) + assert payload.get(['invalid', 'baz']) is None + assert payload.extend(['invalid', 'baz'], [2]) + assert payload.get(['invalid', 'baz']) == [2] + + payload.path_prefix = ['bar'] + assert payload.get(['blah']) == [1] + assert payload.extend(['blah'], [3]) + assert payload.get(['blah']) == [1, 3] + + +def test_lib_payload_delete(): + from kusanagi.sdk.lib.payload import Payload + + payload = Payload({'foo': 'bar', 'bar': {'baz': 42, 'blah': []}}) + assert not payload.delete(['invalid', 'baz']) + assert not payload.delete(['bar', 'blah', 'boom']) + assert payload.delete(['bar', 'blah']) + assert not payload.exists(['bar', 'blah']) + + payload.path_prefix = ['bar'] + assert payload.exists(['baz']) + assert payload.delete(['baz']) + assert not payload.exists(['baz']) diff --git a/tests/test_lib_payload_action.py b/tests/test_lib_payload_action.py new file mode 100644 index 0000000..7498a86 --- /dev/null +++ b/tests/test_lib_payload_action.py @@ -0,0 +1,113 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import pytest + + +def test_lib_payload_action_schema_defaults(): + from kusanagi.sdk.lib.payload.action import ActionSchemaPayload + from kusanagi.sdk.lib.payload.action import HttpActionSchemaPayload + + schema = ActionSchemaPayload() + assert schema.get_entity() == {} + assert schema.get_relations() == [] + assert schema.get_param_names() == [] + assert schema.get_file_names() == [] + assert isinstance(schema.get_http_action_schema_payload(), HttpActionSchemaPayload) + + with pytest.raises(KeyError): + schema.get_param_schema_payload('foo') + + with pytest.raises(KeyError): + schema.get_file_schema_payload('foo') + + +def test_lib_payload_action_schema(): + from kusanagi.sdk.lib import datatypes + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.action import ActionSchemaPayload + from kusanagi.sdk.lib.payload.file import FileSchemaPayload + from kusanagi.sdk.lib.payload.action import HttpActionSchemaPayload + from kusanagi.sdk.lib.payload.param import ParamSchemaPayload + + payload = { + ns.ENTITY: { + ns.FIELDS: [{ + ns.NAME: "contact", + ns.FIELD: [{ + ns.NAME: "id", + ns.TYPE: datatypes.TYPE_INTEGER, + }, { + ns.NAME: "email", + }], + ns.OPTIONAL: False, + }], + ns.FIELD: [{ + ns.NAME: "id", + ns.TYPE: datatypes.TYPE_INTEGER, + }, { + ns.NAME: "name", + ns.TYPE: datatypes.TYPE_STRING, + }, { + ns.NAME: "is_admin", + ns.TYPE: datatypes.TYPE_BOOLEAN, + ns.OPTIONAL: True, + }], + ns.VALIDATE: True, + ns.PRIMARY_KEY: "pk", + }, + ns.RELATIONS: [{ + ns.TYPE: 'many', + ns.NAME: 'foo', + }, { + ns.NAME: 'bar', + }], + ns.PARAMS: { + 'foo': { + ns.NAME: 'foo', + } + }, + ns.FILES: { + 'bar': { + ns.NAME: 'bar', + } + }, + ns.HTTP: { + ns.ENTITY_PATH: '/entity', + }, + } + + schema = ActionSchemaPayload(payload) + assert schema.get_entity() == { + 'field': [ + {'name': 'id', 'optional': False, 'type': 'integer'}, + {'name': 'name', 'optional': False, 'type': 'string'}, + {'name': 'is_admin', 'optional': True, 'type': 'boolean'}, + ], + 'fields': [{ + 'field': [ + {'name': 'id', 'optional': False, 'type': 'integer'}, + {'name': 'email', 'optional': False, 'type': 'string'}, + ], + 'name': 'contact', + 'optional': False, + }], + 'validate': True, + } + assert schema.get_relations() == [ + ['many', 'foo'], + ['one', 'bar'], + ] + assert schema.get_param_names() == ['foo'] + param_schema = schema.get_param_schema_payload('foo') + assert isinstance(param_schema, ParamSchemaPayload) + assert param_schema.get(ns.NAME) == 'foo' + assert schema.get_file_names() == ['bar'] + file_schema = schema.get_file_schema_payload('bar') + assert isinstance(file_schema, FileSchemaPayload) + assert file_schema.get(ns.NAME) == 'bar' + assert isinstance(schema.get_http_action_schema_payload(), HttpActionSchemaPayload) diff --git a/tests/test_lib_payload_command.py b/tests/test_lib_payload_command.py new file mode 100644 index 0000000..0c0e5fd --- /dev/null +++ b/tests/test_lib_payload_command.py @@ -0,0 +1,78 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import os + + +def test_lib_payload_command_defaults(): + from kusanagi.sdk.lib.payload.command import CommandPayload + + payload = CommandPayload() + assert payload.get_name() == '' + assert payload.get_attributes() == {} + assert payload.get_service_call_data() == {} + assert payload.get_transport_data() == {} + assert payload.get_response_data() == {} + assert payload.get_request_id() == '' + + +def test_lib_payload_command(): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + + payload = CommandPayload.new('foo', 'service', {'bar': 'baz'}) + assert isinstance(payload, CommandPayload) + assert payload.get_name() == 'foo' + assert payload.get([ns.META, ns.SCOPE], prefix=False) == 'service' + assert payload.get([ns.COMMAND, ns.ARGUMENTS], prefix=False) == {'bar': 'baz'} + assert len(payload) == 2 + assert len(payload.get([ns.COMMAND], prefix=False)) == 2 + assert len(payload.get([ns.META], prefix=False)) == 1 + + assert not payload.exists([ns.ATTRIBUTES]) + payload.set([ns.ATTRIBUTES], {'a': 1}) + assert payload.get_attributes() == {'a': 1} + + payload.set([ns.CALL], {'c': 2}) + assert payload.get_service_call_data() == {'c': 2} + + payload.set([ns.TRANSPORT], {'b': 3}) + assert payload.get_transport_data() == {'b': 3} + + payload.set([ns.RESPONSE], {'c': 4}) + assert payload.get_response_data() == {'c': 4} + + # The request ID in the meta has more precedence than the one in the transport + payload.set([ns.TRANSPORT, ns.META, ns.ID], '25759c6c-8531-40d2-a415-4ff9246307c5') + assert payload.get_request_id() == '25759c6c-8531-40d2-a415-4ff9246307c5' + payload.set([ns.META, ns.ID], 'e60d3293-5c2a-4d19-9964-24ab122b04a6') + assert payload.get_request_id() == 'e60d3293-5c2a-4d19-9964-24ab122b04a6' + + +def test_lib_payload_command_runtime_call(DATA_DIR): + from kusanagi.sdk import File + from kusanagi.sdk import Param + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + + params = [Param('foop')] + path = os.path.join(DATA_DIR, 'file.txt') + files = [File('foof', path)] + payload = CommandPayload.new_runtime_call('foo', 'bar', '1.2.3', 'baz', params, files, {'value': 1}) + assert isinstance(payload, CommandPayload) + assert payload.get_name() == 'runtime-call' + assert payload.get([ns.META, ns.SCOPE], prefix=False) == 'service' + assert payload.get([ns.ACTION]) == 'foo' + assert payload.get([ns.CALLEE]) == ['bar', '1.2.3', 'baz'] + assert payload.get([ns.TRANSPORT]) == {'value': 1} + assert len(payload.get([ns.PARAMS])) == 1 + param = payload.get([ns.PARAMS])[0] + assert param[ns.NAME] == 'foop' + assert len(payload.get([ns.FILES])) == 1 + file = payload.get([ns.FILES])[0] + assert file[ns.NAME] == 'foof' + assert file[ns.PATH] == f'file://{path}' diff --git a/tests/test_lib_payload_error.py b/tests/test_lib_payload_error.py new file mode 100644 index 0000000..ee00642 --- /dev/null +++ b/tests/test_lib_payload_error.py @@ -0,0 +1,34 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_lib_payload_error_defaults(): + from kusanagi.sdk.lib.payload.error import ErrorPayload + + payload = ErrorPayload() + assert payload.get_message() == ErrorPayload.DEFAULT_MESSAGE + assert payload.get_code() == 0 + assert payload.get_status() == ErrorPayload.DEFAULT_STATUS + + +def test_lib_payload_error(): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.error import ErrorPayload + + payload = ErrorPayload.new('Message', 42, '419 Status') + assert isinstance(payload, ErrorPayload) + assert len(payload) == 1 + assert payload.exists([ns.ERROR], prefix=False) + assert len(payload[ns.ERROR]) == 3 + assert payload.exists([ns.MESSAGE]) + assert payload.exists([ns.CODE]) + assert payload.exists([ns.STATUS]) + + assert payload.get_message() == 'Message' + assert payload.get_code() == 42 + assert payload.get_status() == '419 Status' diff --git a/tests/test_lib_payload_mapping.py b/tests/test_lib_payload_mapping.py new file mode 100644 index 0000000..5d1203f --- /dev/null +++ b/tests/test_lib_payload_mapping.py @@ -0,0 +1,45 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import pytest + + +def test_lib_payload_mapping_defaults(): + from kusanagi.sdk.lib.payload.mapping import MappingPayload + + payload = MappingPayload() + assert list(payload.get_services()) == [] + + with pytest.raises(LookupError): + payload.get_service_schema_payload('foo', '2.0.0') + + +def test_lib_payload_mapping(): + from kusanagi.sdk.lib.payload.mapping import MappingPayload + + payload = MappingPayload({ + 'foo': { + '1.1.0': {'value': 1}, + '1.2.0': {'value': 2}, + }, + 'bar': { + '1.1.0': {'value': 3}, + }, + }) + + services = list(payload.get_services()) + assert len(services) == 3 + assert {'name': 'foo', 'version': '1.1.0'} in services + assert {'name': 'foo', 'version': '1.2.0'} in services + assert {'name': 'bar', 'version': '1.1.0'} in services + + schema = payload.get_service_schema_payload('foo', '1.1.0') + assert schema == payload['foo']['1.1.0'] + + # This must resolve to the higher compatible version + schema = payload.get_service_schema_payload('foo', '1.*.0') + assert schema == payload['foo']['1.2.0'] diff --git a/tests/test_lib_payload_reply.py b/tests/test_lib_payload_reply.py new file mode 100644 index 0000000..2732bd5 --- /dev/null +++ b/tests/test_lib_payload_reply.py @@ -0,0 +1,99 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_lib_payload_reply_defaults(): + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + reply = ReplyPayload() + assert reply.get_return_value() is None + assert reply.get_transport() is None + + +def test_lib_payload_reply(): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.payload.transport import TransportPayload + + reply = ReplyPayload() + reply.set([ns.TRANSPORT], {'a': 'b'}) + reply.set([ns.RETURN], 42) + assert reply.get_return_value() == 42 + assert isinstance(reply.get_transport(), TransportPayload) + + +def test_lib_payload_reply_request(command): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + reply = ReplyPayload.new_request_reply(command) + assert isinstance(reply, ReplyPayload) + assert reply.exists([ns.COMMAND_REPLY, ns.NAME], prefix=False) + assert len(reply.get([ns.COMMAND_REPLY], {}, prefix=False)) == 2 + assert len(reply.get([ns.COMMAND_REPLY, ns.RESULT], {}, prefix=False)) == 3 + assert reply.exists([ns.ATTRIBUTES]) + assert len(reply.get([ns.CALL], {})) == 4 + assert reply.exists([ns.CALL, ns.SERVICE]) + assert reply.exists([ns.CALL, ns.VERSION]) + assert reply.exists([ns.CALL, ns.ACTION]) + assert reply.exists([ns.CALL, ns.PARAMS]) + assert len(reply.get([ns.RESPONSE], {})) == 4 + assert reply.exists([ns.RESPONSE, ns.VERSION]) + assert reply.exists([ns.RESPONSE, ns.STATUS]) + assert reply.exists([ns.RESPONSE, ns.HEADERS]) + assert reply.exists([ns.RESPONSE, ns.BODY]) + + # A response can be setted into the request reply when the middleware + # nees to stop the workflow and return a response without calling the + # service or other middlewares in the queue. + assert reply.get([ns.RESPONSE, ns.VERSION]) == ReplyPayload.HTTP_VERSION + assert reply.get([ns.RESPONSE, ns.STATUS]) == ReplyPayload.HTTP_STATUS_OK + assert reply.get([ns.RESPONSE, ns.HEADERS]) == {'Content-Type': ['text/plain']} + assert reply.get([ns.RESPONSE, ns.BODY]) == b'' + assert isinstance(reply.set_response(500, 'Error'), ReplyPayload) + assert reply.get([ns.RESPONSE, ns.VERSION]) == ReplyPayload.HTTP_VERSION + assert reply.get([ns.RESPONSE, ns.STATUS]) == '500 Error' + assert reply.get([ns.RESPONSE, ns.HEADERS]) == {} + assert reply.get([ns.RESPONSE, ns.BODY]) == b'' + + # When the payload is used as a request payload it must not contain a response + assert reply.exists([ns.RESPONSE]) + assert isinstance(reply.for_request(), ReplyPayload) + assert not reply.exists([ns.RESPONSE]) + + +def test_lib_payload_reply_response(command): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + reply = ReplyPayload.new_response_reply(command) + assert isinstance(reply, ReplyPayload) + assert len(reply.get([ns.COMMAND_REPLY], {}, prefix=False)) == 2 + assert reply.exists([ns.COMMAND_REPLY, ns.NAME], prefix=False) + assert len(reply.get([ns.COMMAND_REPLY, ns.RESULT], {}, prefix=False)) == 2 + assert reply.exists([ns.ATTRIBUTES]) + assert reply.exists([ns.RESPONSE]) + + # When the payload is used as a response payload it must not contain the call info + assert reply.set([ns.CALL], {}) + assert reply.exists([ns.CALL]) + assert isinstance(reply.for_response(), ReplyPayload) + assert not reply.exists([ns.CALL]) + + +def test_lib_payload_reply_action(command): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + + reply = ReplyPayload.new_action_reply(command) + assert isinstance(reply, ReplyPayload) + assert len(reply) == 1 + assert len(reply.get([ns.COMMAND_REPLY], {}, prefix=False)) == 2 + assert reply.exists([ns.COMMAND_REPLY, ns.NAME], prefix=False) + assert len(reply.get([ns.COMMAND_REPLY, ns.RESULT], {}, prefix=False)) == 1 + assert reply.exists([ns.TRANSPORT]) diff --git a/tests/test_lib_payload_transport.py b/tests/test_lib_payload_transport.py new file mode 100644 index 0000000..7a8ac6f --- /dev/null +++ b/tests/test_lib_payload_transport.py @@ -0,0 +1,370 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import pytest + + +def test_lib_payload_transport_defaults(): + from kusanagi.sdk.lib.payload.transport import TransportPayload + + payload = TransportPayload() + assert payload.get_public_gateway_address() == '' + assert not payload.has_calls('foo', '1.2.3') + assert not payload.has_files() + assert not payload.has_transactions() + assert not payload.has_download() + assert not payload.set_return(42) + + +def test_lib_payload_transport(): + from kusanagi.sdk import File + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.payload.transport import TransportPayload + + reply = ReplyPayload() + + payload = TransportPayload({ + ns.META: { + ns.GATEWAY: ['', 'http://1.2.3.4:77'], + }, + ns.CALLS: { + 'foo': { + '1.2.3': [{}], + }, + }, + ns.FILES: {}, + ns.TRANSACTIONS: {}, + ns.BODY: {}, + }) + assert isinstance(payload.set_reply(reply), TransportPayload) + assert payload.get_public_gateway_address() == payload.get([ns.META, ns.GATEWAY])[1] + assert payload.has_calls('foo', '1.2.3') + assert payload.has_files() + assert payload.has_transactions() + assert payload.set_download(File('foo')) + assert payload.has_download() + assert payload.get([ns.BODY, ns.NAME]) == 'foo' + assert payload.set_return(42) + assert reply.get([ns.RETURN]) == 42 + + # The "set" method must update the transport payload and the reply + assert reply.get([ns.TRANSPORT, 'foo']) is None + assert payload.get(['foo']) is None + assert payload.set(['foo'], 42) + assert payload.get(['foo']) == 42 + assert reply.get([ns.TRANSPORT, 'foo']) == 42 + + # The "append" method must update the transport payload and the reply + assert reply.get([ns.TRANSPORT, 'bar']) is None + assert payload.get(['bar']) is None + assert payload.append(['bar'], 42) + assert payload.get(['bar']) == [42] + assert reply.get([ns.TRANSPORT, 'bar']) == [42] + + # The "extend" method must update the transport payload and the reply + assert payload.extend(['bar'], [77]) + assert payload.get(['bar']) == [42, 77] + assert reply.get([ns.TRANSPORT, 'bar']) == [42, 77] + + # The "delete" method must update the transport payload and the reply + assert payload.delete(['bar']) + assert payload.get(['bar']) is None + assert reply.get([ns.TRANSPORT, 'bar']) is None + + +def test_lib_payload_transport_merge_runtime_transport(): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.payload.transport import TransportPayload + + # Create a reply and a transport payload where a new payload must be merged + reply = ReplyPayload() + payload = TransportPayload({ + ns.META: { + ns.ID: '25759c6c-8531-40d2-a415-4ff9246307c5', + }, + ns.DATA: {'a': {'b': [2]}}, + }) + payload.set_reply(reply) + # Merge the data from a new payload + assert payload.merge_runtime_call_transport(TransportPayload({ + ns.META: { + ns.ID: '25759c6c-8531-40d2-a415-4ff9246307c5', + }, + ns.DATA: {'a': {'b': [3], 'c': 4}}, + })) + # Both, the transport and the reply must contain their original data and the new data + assert payload == reply.get([ns.TRANSPORT]) == { + ns.META: { + ns.ID: '25759c6c-8531-40d2-a415-4ff9246307c5', + }, + ns.DATA: {'a': {'b': [2, 3], 'c': 4}}, + } + + # The transport to merge must be a transport payload + with pytest.raises(TypeError): + payload.merge_runtime_call_transport({}) + + +def test_lib_payload_transport_data(): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.transport import TransportPayload + + address = 'http://1.2.3.4:77' + payload = TransportPayload({ + ns.META: { + ns.GATEWAY: ['', address], + }, + }) + assert payload.add_data('foo', '1.2.3', 'bar', {}) + assert payload.get([ns.DATA, address, 'foo', '1.2.3', 'bar']) == [{}] + + +def test_lib_payload_transport_relations(): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.transport import TransportPayload + + address = 'http://1.2.3.4:77' + remote = 'http://6.6.6.6:77' + payload = TransportPayload({ + ns.META: { + ns.GATEWAY: ['', address], + }, + }) + assert payload.add_relate_one('foo', '1', 'bar', '2') + assert payload.get([ns.RELATIONS, address, 'foo', '1', address, 'bar']) == '2' + assert payload.add_relate_many('foo', '1', 'bar', ['2', '3']) + assert payload.get([ns.RELATIONS, address, 'foo', '1', address, 'bar']) == ['2', '3'] + assert payload.add_relate_one_remote('foo', '1', remote, 'bar', '4') + assert payload.get([ns.RELATIONS, address, 'foo', '1', remote, 'bar']) == '4' + assert payload.add_relate_many_remote('foo', '1', remote, 'bar', ['4', '5']) + assert payload.get([ns.RELATIONS, address, 'foo', '1', remote, 'bar']) == ['4', '5'] + + +def test_lib_payload_transport_link(): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.transport import TransportPayload + + address = 'http://1.2.3.4:77' + payload = TransportPayload({ + ns.META: { + ns.GATEWAY: ['', address], + }, + }) + assert payload.add_link('foo', 'self', '/test') + assert payload.get([ns.LINKS, address, 'foo', 'self']) == '/test' + + +def test_lib_payload_transport_calls_runtime(): + from kusanagi.sdk import File + from kusanagi.sdk import Param + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.transport import TransportPayload + + params = [Param('foo')] + files = [File('bar')] + + payload = TransportPayload() + assert not payload.has_calls('foo', '1.2.3') + assert payload.add_call('foo', '1.2.3', 'bar', 'baz', '3.2.1', 'blah', 42, params, files, 1000) + assert payload.get([ns.CALLS, 'foo', '1.2.3']) == [{ + ns.NAME: 'baz', + ns.VERSION: '3.2.1', + ns.ACTION: 'blah', + ns.CALLER: 'bar', + ns.DURATION: 42, + ns.TIMEOUT: 1000, + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.VALUE: '', + ns.TYPE: Param.TYPE_STRING, + }], + ns.FILES: [{ + ns.NAME: 'bar', + ns.PATH: '', + ns.MIME: '', + ns.FILENAME: '', + ns.SIZE: 0, + }], + }] + # Runtime calls are no accounted as calls during userland actions + assert not payload.has_calls('foo', '1.2.3') + + # Runtime calls must require a valid duration + with pytest.raises(ValueError): + payload.add_call('foo', '1.2.3', 'bar', 'baz', '3.2.1', 'blah', None) + + +def test_lib_payload_transport_calls_runtime_with_transport(): + from kusanagi.sdk import File + from kusanagi.sdk import Param + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.transport import TransportPayload + + params = [Param('foo')] + files = [File('bar')] + + transport = TransportPayload() + payload = TransportPayload() + assert payload.add_call('foo', '1.2.3', 'bar', 'baz', '3.2.1', 'blah', 42, params, files, 1000, transport) + # Call info must also be added to the transport argument value + assert transport.get([ns.CALLS, 'foo', '1.2.3']) == [{ + ns.NAME: 'baz', + ns.VERSION: '3.2.1', + ns.ACTION: 'blah', + ns.CALLER: 'bar', + ns.DURATION: 42, + ns.TIMEOUT: 1000, + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.VALUE: '', + ns.TYPE: Param.TYPE_STRING, + }], + ns.FILES: [{ + ns.NAME: 'bar', + ns.PATH: '', + ns.MIME: '', + ns.FILENAME: '', + ns.SIZE: 0, + }], + }] + + +def test_lib_payload_transport_calls_deferred(): + from kusanagi.sdk import File + from kusanagi.sdk import Param + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.transport import TransportPayload + + params = [Param('foo')] + files = [File('bar')] + payload = TransportPayload() + assert not payload.has_calls('foo', '1.2.3') + + assert payload.add_defer_call('foo', '1.2.3', 'bar', 'baz', '3.2.1', 'blah', params, files) + assert payload.get([ns.CALLS, 'foo', '1.2.3']) == [{ + ns.NAME: 'baz', + ns.VERSION: '3.2.1', + ns.ACTION: 'blah', + ns.CALLER: 'bar', + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.VALUE: '', + ns.TYPE: Param.TYPE_STRING, + }], + ns.FILES: [{ + ns.NAME: 'bar', + ns.PATH: '', + ns.MIME: '', + ns.FILENAME: '', + ns.SIZE: 0, + }], + }] + assert payload.has_calls('foo', '1.2.3') + + +def test_lib_payload_transport_calls_remote(): + from kusanagi.sdk import File + from kusanagi.sdk import Param + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.transport import TransportPayload + + address = 'http://1.2.3.4:77' + params = [Param('foo')] + files = [File('bar')] + payload = TransportPayload() + assert not payload.has_calls('foo', '1.2.3') + + assert payload.add_remote_call(address, 'foo', '1.2.3', 'bar', 'baz', '3.2.1', 'blah', params, files, 1000) + assert payload.get([ns.CALLS, 'foo', '1.2.3']) == [{ + ns.GATEWAY: address, + ns.NAME: 'baz', + ns.VERSION: '3.2.1', + ns.ACTION: 'blah', + ns.CALLER: 'bar', + ns.TIMEOUT: 1000, + ns.PARAMS: [{ + ns.NAME: 'foo', + ns.VALUE: '', + ns.TYPE: Param.TYPE_STRING, + }], + ns.FILES: [{ + ns.NAME: 'bar', + ns.PATH: '', + ns.MIME: '', + ns.FILENAME: '', + ns.SIZE: 0, + }], + }] + assert payload.has_calls('foo', '1.2.3') + + +def test_lib_payload_transport_transactions(): + from kusanagi.sdk import Param + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.transport import TransportPayload + + transaction_type = TransportPayload.TRANSACTION_COMMIT + payload = TransportPayload() + assert not payload.has_transactions() + assert payload.add_transaction(transaction_type, 'foo', '1.2.3', 'bar', 'baz', [Param('blah')]) + assert payload.get([ns.TRANSACTIONS, transaction_type]) == [{ + ns.NAME: 'foo', + ns.VERSION: '1.2.3', + ns.CALLER: 'bar', + ns.ACTION: 'baz', + ns.PARAMS: [{ + ns.NAME: 'blah', + ns.TYPE: Param.TYPE_STRING, + ns.VALUE: '', + }], + }] + assert payload.has_transactions() + assert len(payload.get([ns.TRANSACTIONS])) == 1 + + transaction_type = TransportPayload.TRANSACTION_ROLLBACK + assert payload.add_transaction(transaction_type, 'other', '1.2.3', 'bar', 'baz') + assert payload.get([ns.TRANSACTIONS, transaction_type]) == [{ + ns.NAME: 'other', + ns.VERSION: '1.2.3', + ns.CALLER: 'bar', + ns.ACTION: 'baz', + }] + assert len(payload.get([ns.TRANSACTIONS])) == 2 + + with pytest.raises(ValueError): + payload.add_transaction('invalid', 'foo', '1.2.3', 'bar', 'baz') + + +def test_lib_payload_transport_error(): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.transport import TransportPayload + + address = 'http://1.2.3.4:77' + payload = TransportPayload({ + ns.META: { + ns.GATEWAY: ['', address], + }, + }) + assert payload.add_error('foo', '1.2.3', 'Message', 42, 'Error') + assert payload.add_error('foo', '1.2.3', 'Other', 77, 'Exception') + assert payload.get([ns.ERRORS, address, 'foo', '1.2.3']) == [{ + ns.MESSAGE: 'Message', + ns.CODE: 42, + ns.STATUS: 'Error', + }, { + ns.MESSAGE: 'Other', + ns.CODE: 77, + ns.STATUS: 'Exception', + }] + assert payload.add_error('other', '2.2.3', 'Other', 77, 'Exception') + assert payload.get([ns.ERRORS, address, 'other', '2.2.3']) == [{ + ns.MESSAGE: 'Other', + ns.CODE: 77, + ns.STATUS: 'Exception', + }] diff --git a/tests/test_lib_payload_utils.py b/tests/test_lib_payload_utils.py new file mode 100644 index 0000000..30a651b --- /dev/null +++ b/tests/test_lib_payload_utils.py @@ -0,0 +1,84 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_file_to_payload(): + from kusanagi.sdk import File + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.utils import file_to_payload + + file = File( + 'foo', + path='http://127.0.0.1:8080/ANBDKAD23142421', + mime='application/json', + filename='file.json', + size=600, + token='secret', + ) + payload = file_to_payload(file) + assert payload is not None + assert payload.get([ns.NAME]) == file.get_name() + assert payload.get([ns.PATH]) == file.get_path() + assert payload.get([ns.MIME]) == file.get_mime() + assert payload.get([ns.FILENAME]) == file.get_filename() + assert payload.get([ns.SIZE]) == file.get_size() + assert payload.get([ns.TOKEN]) == file.get_token() + + +def test_payload_to_file(): + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.utils import payload_to_file + + payload = { + ns.NAME: 'foo', + ns.PATH: 'http://127.0.0.1:8080/ANBDKAD23142421', + ns.MIME: 'application/json', + ns.FILENAME: 'file.json', + ns.SIZE: 600, + ns.TOKEN: 'secret', + } + file = payload_to_file(payload) + assert file is not None + assert file.get_name() == payload[ns.NAME] + assert file.get_path() == payload[ns.PATH] + assert file.get_mime() == payload[ns.MIME] + assert file.get_filename() == payload[ns.FILENAME] + assert file.get_size() == payload[ns.SIZE] + assert file.get_token() == payload[ns.TOKEN] + + +def test_merge_dictionary(): + from kusanagi.sdk.lib.payload.utils import merge_dictionary + + src = { + '1': 1, + '2': [1], + '3': {'a': 1}, + '4': {'a': {'b': 2}}, + '5': {'a': {'b': [2]}}, + '6': {}, + '7': [], + } + dst = { + '2': [3], + '3': {'c': 3}, + '4': {'a': {'c': 3}}, + '5': {'a': {'b': [3]}}, + '8': 3, + } + dst = merge_dictionary(src, dst) + assert dst == { + '1': 1, + '2': [3, 1], + '3': {'a': 1, 'c': 3}, + '4': {'a': {'b': 2, 'c': 3}}, + '5': {'a': {'b': [3, 2]}}, + '6': {}, + '7': [], + '8': 3, + } diff --git a/tests/test_lib_server.py b/tests/test_lib_server.py new file mode 100644 index 0000000..b65f387 --- /dev/null +++ b/tests/test_lib_server.py @@ -0,0 +1,647 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import asyncio + +import pytest +import zmq + + +def test_lib_server_create(mocker, input_): + from kusanagi.sdk.lib.server import Server + from kusanagi.sdk.lib.server import create_server + + mocker.patch('kusanagi.sdk.lib.cli.parse_args', return_value=input_) + setup_kusanagi_logging = mocker.patch('kusanagi.sdk.lib.server.setup_kusanagi_logging') + mocker.patch('kusanagi.sdk.lib.server.asyncio.get_event_loop') + + server = create_server(mocker.Mock(), {}, None) + assert isinstance(server, Server) + setup_kusanagi_logging.assert_called_once_with( + input_.get_component(), + input_.get_name(), + input_.get_version(), + input_.get_framework_version(), + input_.get_log_level(), + ) + + +def test_lib_server_start(mocker, input_): + from kusanagi.sdk.lib.server import Server + + loop = mocker.Mock() + mocker.patch('kusanagi.sdk.lib.server.asyncio.get_event_loop', return_value=loop) + server = Server(mocker.Mock(), {}, None, input_) + server.listen = mocker.Mock(return_value='listen') + server.start() + server.listen.assert_called_once() + loop.run_until_complete.assert_called_once_with('listen') + loop.stop.assert_called_once() + loop.close.assert_called_once() + + +def test_lib_server_stop(mocker, input_): + from kusanagi.sdk.lib.server import Server + + mocker.patch('kusanagi.sdk.lib.server.asyncio.get_event_loop') + task = mocker.Mock() + Task = mocker.patch('kusanagi.sdk.lib.server.asyncio.Task') + Task.all_tasks.return_value = [task] + server = Server(mocker.Mock(), {}, None, input_) + + # Unfinished tasks must be canceled + task.done.return_value = False + server.stop() + task.done.assert_called_once() + task.cancel.assert_called_once() + + # When there is an exception it must be raised + task.done.return_value = True + task.exception.return_value = Exception('Test') + with pytest.raises(Exception, match='Test'): + server.stop() + + +def test_lib_server_timeout(AsyncMock, mocker, logs, input_): + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.server import Server + from kusanagi.sdk.lib.payload.error import ErrorPayload + + stream = [b'RID', b'foo', b'', pack({})] + + mocker.patch('kusanagi.sdk.lib.server.asyncio.get_event_loop') + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + server = Server(mocker.Mock(), {}, None, input_) + server._Server__process_request = AsyncMock(side_effect=asyncio.TimeoutError) + asyncio.run(server.listen()) + socket.close.assert_called_once() + + # Get the error payload from the response stream + socket.send_multipart.assert_called_once() + assert len(socket.send_multipart.call_args_list) == 1 + stream = socket.send_multipart.call_args[0][0] + assert len(stream) == 3 + payload = ErrorPayload(unpack(stream[2])) + assert payload.get_message().startswith('Execution timed out') + + +def test_lib_server_invalid_request_stream(AsyncMock, mocker, logs, input_): + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.server import Server + from kusanagi.sdk.lib.payload.error import ErrorPayload + + mocker.patch('kusanagi.sdk.lib.server.asyncio.get_event_loop') + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=[]) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + server = Server(mocker.Mock(), {}, None, input_) + asyncio.run(server.listen()) + socket.close.assert_called_once() + + # Get the error payload from the response stream + socket.send_multipart.assert_called_once() + assert len(socket.send_multipart.call_args_list) == 1 + stream = socket.send_multipart.call_args[0][0] + assert len(stream) == 3 + payload = ErrorPayload(unpack(stream[2])) + assert payload.get_message() == 'Failed to handle request' + + +@pytest.mark.asyncio +async def test_lib_server_middleware_request(AsyncMock, mocker, input_): + from kusanagi.sdk import Middleware + from kusanagi.sdk import Request + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.server import EMPTY_META + from kusanagi.sdk.lib.server import Server + + # Prepare a command payload for the request + command = CommandPayload() + command.set([ns.CALL], { + ns.SERVICE: 'foo', + ns.VERSION: '1.0.0', + ns.ACTION: 'bar', + }) + command.set([ns.COMMAND, ns.NAME], 'request', prefix=False) + + stream = [b'RID', b'request', pack({}), pack(command)] + + # Change the input values to return middleware as component + input_.get_component = mocker.Mock(return_value='middleware') + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + callback = mocker.Mock(side_effect=lambda request: request) + server = Server(Middleware(), {'request': callback}, None, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + # Check that the callback was called with a Request instance + callback.assert_called_once() + args, _ = callback.call_args + assert isinstance(args[0], Request) + + # Check that the result is a reply payload containing the call info + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + payload = unpack(stream[2]) + assert isinstance(payload, dict) + reply = ReplyPayload(payload) + assert reply.exists([ns.CALL]) + + flags = stream[1] + assert EMPTY_META == flags + + +@pytest.mark.asyncio +async def test_lib_server_middleware_request_response(AsyncMock, mocker, input_): + from kusanagi.sdk import Middleware + from kusanagi.sdk import Request + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.server import EMPTY_META + from kusanagi.sdk.lib.server import Server + + # Prepare a command payload for the request + command = CommandPayload() + command.set([ns.CALL], { + ns.SERVICE: 'foo', + ns.VERSION: '1.0.0', + ns.ACTION: 'bar', + }) + command.set([ns.COMMAND, ns.NAME], 'request', prefix=False) + + stream = [b'RID', b'request', pack({}), pack(command)] + + # Change the input values to return middleware as component + input_.get_component = mocker.Mock(return_value='middleware') + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + # Instead of a request the middleware callback returns a response + callback = mocker.Mock(side_effect=lambda request: request.new_response()) + + server = Server(Middleware(), {'request': callback}, None, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + # Check that the callback was called with a Request instance + callback.assert_called_once() + args, _ = callback.call_args + assert isinstance(args[0], Request) + + # Check that the result is a reply payload containing the response + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + payload = unpack(stream[2]) + assert isinstance(payload, dict) + reply = ReplyPayload(payload) + assert reply.get([ns.RESPONSE, ns.STATUS]) == '200 OK' + assert reply.get([ns.RESPONSE, ns.VERSION]) == '1.1' + + flags = stream[1] + assert EMPTY_META == flags + + +@pytest.mark.asyncio +async def test_lib_server_middleware_response(AsyncMock, mocker, input_): + from kusanagi.sdk import Middleware + from kusanagi.sdk import Response + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.command import CommandPayload + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.server import EMPTY_META + from kusanagi.sdk.lib.server import Server + + # Prepare a command payload for the request + command = CommandPayload() + command.set([ns.RESPONSE], { + ns.STATUS: '200 OK', + ns.VERSION: '1.1', + }) + command.set([ns.COMMAND, ns.NAME], 'response', prefix=False) + + stream = [b'RID', b'response', pack({}), pack(command)] + + # Change the input values to return middleware as component + input_.get_component = mocker.Mock(return_value='middleware') + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + callback = mocker.Mock(side_effect=lambda response: response) + server = Server(Middleware(), {'response': callback}, None, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + # Check that the callback was called with a Request instance + callback.assert_called_once() + args, _ = callback.call_args + assert isinstance(args[0], Response) + + # Check that the result is a reply payload containing the response + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + payload = unpack(stream[2]) + assert isinstance(payload, dict) + reply = ReplyPayload(payload) + assert reply.get([ns.RESPONSE, ns.STATUS]) == '200 OK' + assert reply.get([ns.RESPONSE, ns.VERSION]) == '1.1' + + flags = stream[1] + assert EMPTY_META == flags + + +@pytest.mark.asyncio +async def test_lib_server_service_action(AsyncMock, mocker, input_, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.server import DOWNLOAD + from kusanagi.sdk.lib.server import EMPTY_META + from kusanagi.sdk.lib.server import FILES + from kusanagi.sdk.lib.server import SERVICE_CALL + from kusanagi.sdk.lib.server import TRANSACTIONS + from kusanagi.sdk.lib.server import Server + + # Prepare a command payload for the request + action_command.set([ns.COMMAND, ns.NAME], 'action', prefix=False) + action_command.set([ns.TRANSPORT, ns.TRANSACTIONS], {}) + action_command.set([ns.TRANSPORT, ns.BODY], {}) + action_command.set([ns.TRANSPORT, ns.CALLS], {'foo': {'1.0.0': [{}]}}) + + stream = [b'RID', b'action', pack({}), pack(action_command)] + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + callback = mocker.Mock(side_effect=lambda action: action) + server = Server(Service(), {'action': callback}, None, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + # Check that the callback was called with a Request instance + callback.assert_called_once() + args, _ = callback.call_args + assert isinstance(args[0], Action) + + # Check that the result is a reply payload containing the transport + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + reply = ReplyPayload(unpack(stream[2])) + assert reply.exists([ns.TRANSPORT]) + + flags = stream[1] + assert EMPTY_META not in flags + assert DOWNLOAD in flags + assert FILES in flags + assert SERVICE_CALL in flags + assert TRANSACTIONS in flags + + +@pytest.mark.asyncio +async def test_lib_server_service_action_empty_transport_flags(AsyncMock, mocker, input_, action_command): + from kusanagi.sdk import Action + from kusanagi.sdk import Service + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.server import EMPTY_META + from kusanagi.sdk.lib.server import Server + + # Prepare a command payload for the request + action_command.set([ns.COMMAND, ns.NAME], 'action', prefix=False) + action_command.delete([ns.TRANSPORT, ns.FILES]) + + stream = [b'RID', b'action', pack({}), pack(action_command)] + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + callback = mocker.Mock(side_effect=lambda action: action) + server = Server(Service(), {'action': callback}, None, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + # Check that the callback was called with a Request instance + callback.assert_called_once() + args, _ = callback.call_args + assert isinstance(args[0], Action) + + # Check that the result is a reply payload containing the transport + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + reply = ReplyPayload(unpack(stream[2])) + assert reply.exists([ns.TRANSPORT]) + + flags = stream[1] + assert EMPTY_META == flags + + +@pytest.mark.asyncio +async def test_lib_server_service_async_action(AsyncMock, mocker, input_, action_command): + from kusanagi.sdk import AsyncAction + from kusanagi.sdk import Service + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.server import Server + + # Prepare a command payload for the request + action_command.set([ns.COMMAND, ns.NAME], 'action', prefix=False) + + stream = [b'RID', b'action', pack({}), pack(action_command)] + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + callback_mock = mocker.Mock() + + async def callback(action): + callback_mock(action) + return action + + server = Server(Service(), {'action': callback}, None, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + # Check that the callback was called with a Request instance + callback_mock.assert_called_once() + args, _ = callback_mock.call_args + assert isinstance(args[0], AsyncAction) + + # Check that the result is a reply payload containing the transport + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + reply = ReplyPayload(unpack(stream[2])) + assert reply.exists([ns.TRANSPORT]) + + +@pytest.mark.asyncio +async def test_lib_server_invalid_request_action(AsyncMock, mocker, input_): + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload.error import ErrorPayload + from kusanagi.sdk.lib.server import EMPTY_META + from kusanagi.sdk.lib.server import Server + + stream = [b'RID', b'boom!', b'', pack({})] + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + callback = mocker.Mock(side_effect=lambda component: component) + server = Server(mocker.Mock(), {'action': callback}, None, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + # Check that the callback was not called + callback.assert_not_called() + + # Check that the result is a reply payload containing the error + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + payload = ErrorPayload(unpack(stream[2])) + assert payload.get_message().startswith('Invalid action for component') + + flags = stream[1] + assert EMPTY_META == flags + + +@pytest.mark.asyncio +async def test_lib_server_middleware_callback_error(AsyncMock, mocker, input_, logs, action_command): + from kusanagi.sdk import KusanagiError + from kusanagi.sdk import Middleware + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.server import Server + + # Prepare a command payload for the request + action_command.set([ns.COMMAND, ns.NAME], 'request', prefix=False) + + stream = [b'RID', b'request', pack({}), pack(action_command)] + + # Change the input values to return middleware as component + input_.get_component = mocker.Mock(return_value='middleware') + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + # Define a custom KUSANAGI error to be raised from the callback + error = KusanagiError('Test Error') + + on_error = mocker.Mock() + callback = mocker.Mock(side_effect=error) + server = Server(Middleware(), {'request': callback}, on_error, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + on_error.assert_called_once_with(error) + + # Check that the result is a reply payload containing an error response + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + reply = ReplyPayload(unpack(stream[2])) + assert reply.get([ns.RESPONSE, ns.STATUS]) == '500 Internal Server Error' + assert reply.get([ns.RESPONSE, ns.BODY]) == str(error) + + +@pytest.mark.asyncio +async def test_lib_server_service_callback_error(AsyncMock, mocker, input_, logs, action_command): + from kusanagi.sdk import KusanagiError + from kusanagi.sdk import Service + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.reply import ReplyPayload + from kusanagi.sdk.lib.server import Server + + # Prepare a command payload for the request + action_command.set([ns.COMMAND, ns.NAME], 'action', prefix=False) + + stream = [b'RID', b'action', pack({}), pack(action_command)] + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + # Define a custom KUSANAGI error to be raised from the callback + error = KusanagiError('Test Error') + + on_error = mocker.Mock() + callback = mocker.Mock(side_effect=error) + server = Server(Service(), {'action': callback}, on_error, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + on_error.assert_called_once_with(error) + + # Check that the result is a reply payload containing the transport + # and inside the transport the custom error. + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + reply = ReplyPayload(unpack(stream[2])) + address = reply.get([ns.TRANSPORT, ns.META, ns.GATEWAY])[1] + errors = reply.get([ns.TRANSPORT, ns.ERRORS, address, 'foo', '1.0.0']) + assert isinstance(errors, list) + assert len(errors) == 1 + assert errors[0].get(ns.MESSAGE) == str(error) + + +@pytest.mark.asyncio +async def test_lib_server_component_callback_exception(AsyncMock, mocker, input_, logs, action_command): + from kusanagi.sdk import Service + from kusanagi.sdk.lib.msgpack import pack + from kusanagi.sdk.lib.msgpack import unpack + from kusanagi.sdk.lib.payload import ns + from kusanagi.sdk.lib.payload.error import ErrorPayload + from kusanagi.sdk.lib.server import Server + + # Prepare a command payload for the request + action_command.set([ns.COMMAND, ns.NAME], 'action', prefix=False) + + stream = [b'RID', b'action', pack({}), pack(action_command)] + + # Mock the ZMQ socket and use the "send_multipart" to stop + # the server by raising the asyncio.CancelledError. + socket = mocker.Mock() + socket.poll = AsyncMock(return_value=zmq.POLLIN) + socket.recv_multipart = AsyncMock(return_value=stream) + socket.send_multipart = AsyncMock(side_effect=asyncio.CancelledError) + context = mocker.Mock() + context.socket.return_value = socket + mocker.patch('zmq.asyncio.Context', return_value=context) + + # Define a generic exception + error = Exception('Test Error') + + on_error = mocker.Mock() + callback = mocker.Mock(side_effect=error) + server = Server(Service(), {'action': callback}, on_error, input_) + await server.listen() + socket.close.assert_called_once() + socket.send_multipart.assert_called_once() + + on_error.assert_called_once_with(error) + + # Check that the result is an error reply + args, _ = socket.send_multipart.call_args + stream = args[0] + assert len(stream) == 3 + payload = ErrorPayload(unpack(stream[2])) + assert payload.get_message() == str(error) diff --git a/tests/test_lib_singleton.py b/tests/test_lib_singleton.py new file mode 100644 index 0000000..1915138 --- /dev/null +++ b/tests/test_lib_singleton.py @@ -0,0 +1,21 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_lib_singleton(): + from kusanagi.sdk.lib.singleton import Singleton + + class Test(metaclass=Singleton): + pass + + test = Test() + assert hasattr(test, 'instance') + assert test.instance == test + + other_test = Test() + assert other_test == test diff --git a/tests/test_versions.py b/tests/test_lib_version.py similarity index 51% rename from tests/test_versions.py rename to tests/test_lib_version.py index e77c76b..003228a 100644 --- a/tests/test_versions.py +++ b/tests/test_lib_version.py @@ -1,53 +1,74 @@ -import pytest +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. -from kusanagi.versions import InvalidVersionPattern -from kusanagi.versions import VersionNotFound -from kusanagi.versions import VersionString -GREATER = 1 -EQUAL = 0 -LOWER = -1 +def test_lib_version_is_valid(): + from kusanagi.sdk.lib.version import VersionString + assert VersionString.is_valid('1.0.1') + assert VersionString.is_valid('*') + assert VersionString.is_valid('1.0.*') + assert VersionString.is_valid('1.*') + # The @ is not a valid version pattern character + assert not VersionString.is_valid('1.0.@') -def test_simple_version_match(): - """ - Check versions matches for a fixed version string. - """ +def test_lib_version_simple_match(): + """Check versions matches for a fixed version string.""" + + from kusanagi.sdk.lib.version import VersionString # Create a version string without wildcards version_string = VersionString('1.2.3') # Versions match assert version_string.match('1.2.3') - # Versions don't match + # Versions doesn't match assert not version_string.match('A.B.C') + # Invalid versions also doesn't match + assert not version_string.match('A.B.@') -def test_wildcards_version_match(): - """ - Check versions matches for a version pattern with wildcards. +def test_lib_version_wildcards_match(): + """Check versions matches for a version pattern with wildcards.""" - """ + from kusanagi.sdk.lib.version import VersionString # Create a version with wildcards version_string = VersionString('1.*.*') - # Version formats match + # iValid versions match for version in ('1.2.3', '1.4.3', '1.2.3-alpha'): assert version_string.match(version) - # Version formats don't match + # Invalid versions don't match for version in ('A.B.C', '2.2.3'): assert not version_string.match(version) -def test_compare_none(): - """ - Check none comparison results. +def test_lib_version_invalid_match(): + """Check versions matches for a version pattern that is invalid.""" + + from kusanagi.sdk.lib.version import VersionString + + # Create a version that is not valid + version_string = VersionString('1.@.1') + + # Valid and invalid versions won't match + for version in ('1.2.3', '1.4.3', '1.2.3-alpha', 'A.B.C', '2.2.3'): + assert not version_string.match(version) + - """ +def test_lib_version_compare_none(): + """Check none comparison results.""" - compare_none = VersionString.compare_none + from kusanagi.sdk.lib.version import EQUAL + from kusanagi.sdk.lib.version import GREATER + from kusanagi.sdk.lib.version import compare_none assert compare_none(None, None) == EQUAL # Version with less parts is higher, which means @@ -55,13 +76,13 @@ def test_compare_none(): assert compare_none('A', None) == GREATER -def test_compare_sub_part(): - """ - Check comparison results for version string sub parts. +def test_lib_version_compare_sub_part(): + """Check comparison results for version string sub parts.""" - """ - - compare_sub_parts = VersionString.compare_sub_parts + from kusanagi.sdk.lib.version import EQUAL + from kusanagi.sdk.lib.version import GREATER + from kusanagi.sdk.lib.version import LOWER + from kusanagi.sdk.lib.version import compare_sub_parts # Parts are equal assert compare_sub_parts('A', 'A') == EQUAL @@ -81,11 +102,13 @@ def test_compare_sub_part(): assert compare_sub_parts('1', 'A') == LOWER -def test_compare_versions(): - """ - Check comparisons between different versions. +def test_lib_version_compare(): + """Check comparisons between different versions.""" - """ + from kusanagi.sdk.lib.version import EQUAL + from kusanagi.sdk.lib.version import GREATER + from kusanagi.sdk.lib.version import LOWER + from kusanagi.sdk.lib.version import compare cases = ( ('A.B.C', LOWER, 'A.B'), @@ -96,19 +119,16 @@ def test_compare_versions(): ('A.B', GREATER, 'A.B.C'), ('A.B', GREATER, 'A.B-alpha'), ('A.B-beta', GREATER, 'A.B-alpha'), - ) - - compare = VersionString.compare + ) for ver2, expected, ver1 in cases: assert compare(ver1, ver2) == expected -def test_resolve_versions(): - """ - Check version pattern resolution. +def test_lib_version_resolve(): + """Check version pattern resolution.""" - """ + from kusanagi.sdk.lib.version import VersionString # Format: pattern, expected, versions cases = ( @@ -123,26 +143,13 @@ def test_resolve_versions(): ('3.4.*', '3.4.0', ('3.4.0', '3.4.0-a', '3.4.0-0')), ('3.4.*', '3.4.0-1', ('3.4.0-0', '3.4.0-a', '3.4.0-1')), ('3.4.*', '3.4.0-1', ('3.4.0-0', '3.4.0-1-0', '3.4.0-1')), - ) + ) for pattern, expected, versions in cases: - version_string = VersionString(pattern) - # Compare pattern against all versions + version_string = VersionString(pattern) for version in versions: assert version_string.resolve(versions) == expected # Check for a non maching pattern - with pytest.raises(VersionNotFound): - VersionString('3.4.*.*').resolve(['1.0', 'A.B.C.D', '3.4.1']) - - -def test_invalid_pattern(): - """ - Check invalid version pattern error. - - """ - - with pytest.raises(InvalidVersionPattern): - # The @ is not a valid version character - VersionString('1.0.@') + assert VersionString('3.4.*.*').resolve(['1.0', 'A.B.C.D', '3.4.1']) == '' diff --git a/tests/test_link.py b/tests/test_link.py new file mode 100644 index 0000000..4b3a591 --- /dev/null +++ b/tests/test_link.py @@ -0,0 +1,17 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_link(): + from kusanagi.sdk import Link + + link = Link('1.2.3.4:77', 'foo', 'bar', '/test') + assert link.get_address() == '1.2.3.4:77' + assert link.get_name() == 'foo' + assert link.get_link() == 'bar' + assert link.get_uri() == '/test' diff --git a/tests/test_logging.py b/tests/test_logging.py deleted file mode 100644 index afa0571..0000000 --- a/tests/test_logging.py +++ /dev/null @@ -1,77 +0,0 @@ -import logging - -from kusanagi.logging import KusanagiFormatter -from kusanagi.logging import value_to_log_string - - -def test_kusanagi_formatter(): - class Record(object): - pass - - record = Record() - record.created = 1485622839.2490458 # Non GMT timestamp - assert KusanagiFormatter().formatTime(record) == '2017-01-28T17:00:39.249' - - -def test_value_to_los_string(): - # Define a dummy class - class Dummy(object): - def __repr__(self): - return 'DUMMY' - - # Define a dummy function - def dummy(): - pass - - assert value_to_log_string(None) == 'NULL' - assert value_to_log_string(True) == 'TRUE' - assert value_to_log_string(False) == 'FALSE' - assert value_to_log_string('value') == 'value' - assert value_to_log_string(b'value') == 'dmFsdWU=' - assert value_to_log_string(lambda: None) == 'anonymous' - assert value_to_log_string(dummy) == '[function dummy]' - - # Dictionaries and list are serialized as pretty JSON - assert value_to_log_string({'a': 1}) == '{\n "a": 1\n}' - assert value_to_log_string(['1', '2']) == '[\n "1",\n "2"\n]' - - # For unknown types 'repr()' is used to get log string - assert value_to_log_string(Dummy()) == 'DUMMY' - - # Check maximum characters - max_chars = 100000 - assert len(value_to_log_string('*' * max_chars)) == max_chars - assert len(value_to_log_string('*' * (max_chars + 10))) == max_chars - - -def test_setup_kusanagi_logging(logs): - # Root logger must use KusanagiFormatter - assert len(logging.root.handlers) == 1 - assert isinstance(logging.root.handlers[0].formatter, KusanagiFormatter) - - # SDK loggers must use KusanagiFormatter - for name in ('kusanagi', 'kusanagi.sdk'): - assert len(logging.getLogger(name).handlers) == 1 - - logger = logging.getLogger('kusanagi') - assert logger.level == logging.INFO - assert isinstance(logger.handlers[0].formatter, KusanagiFormatter) - - logger = logging.getLogger('kusanagi.sdk') - assert isinstance(logger.handlers[0].formatter, logging.Formatter) - - assert logging.getLogger('asyncio').level == logging.ERROR - - # Basic check for logging format - message = 'Test message' - logging.getLogger('kusanagi').info(message) - out = logs.getvalue() - assert len(out) > 0 - out_parts = out.split(' ') - assert out_parts[0].endswith('Z') # Time - assert out_parts[1] == 'component' # Component type - assert out_parts[2] == 'name/version' # Component name and version - assert out_parts[3] == '(framework-version)' - assert out_parts[4] == '[INFO]' # Level - assert out_parts[5] == '[SDK]' # SDK prefix - assert ' '.join(out_parts[6:]).strip() == message diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..63f828d --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,31 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. + + +def test_middleware_response(): + from kusanagi.sdk import Middleware + + def callback(request): + pass + + middleware = Middleware() + assert 'request' not in middleware._callbacks + assert middleware.request(callback) == middleware + assert middleware._callbacks.get('request') == callback + + +def test_middleware_request(): + from kusanagi.sdk import Middleware + + def callback(request): + pass + + middleware = Middleware() + assert 'response' not in middleware._callbacks + assert middleware.response(callback) == middleware + assert middleware._callbacks.get('response') == callback diff --git a/tests/test_param.py b/tests/test_param.py new file mode 100644 index 0000000..4a6de31 --- /dev/null +++ b/tests/test_param.py @@ -0,0 +1,242 @@ +# Python 3 SDK for the KUSANAGI(tm) framework (http://kusanagi.io) +# Copyright (c) 2016-2020 KUSANAGI S.L. All rights reserved. +# +# Distributed under the MIT license. +# +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +import sys + +import pytest + + +def test_param_defaults(): + from kusanagi.sdk import Param + + param = Param('foo') + assert param.get_name() == 'foo' + assert param.get_value() == '' + assert param.get_type() == Param.TYPE_STRING + assert not param.exists() + + +def test_param(): + from kusanagi.sdk import Param + + param = Param('foo', 42, Param.TYPE_INTEGER, True) + assert param.get_name() == 'foo' + assert param.get_value() == 42 + assert param.get_type() == Param.TYPE_INTEGER + assert param.exists() + + +def test_param_guess_type(): + from kusanagi.sdk import Param + + param = Param('foo', False, type=None) + assert param.get_name() == 'foo' + assert param.get_value() is False + assert param.get_type() == Param.TYPE_BOOLEAN + assert not param.exists() + + param = Param('foo', None, type=None) + assert param.get_name() == 'foo' + assert param.get_value() is None + assert param.get_type() == Param.TYPE_NULL + assert not param.exists() + + param = Param('foo', (1, 2), type=None) + assert param.get_name() == 'foo' + assert param.get_value() == [1, 2] + assert param.get_type() == Param.TYPE_ARRAY + assert not param.exists() + + # Unsupported value types without a type resolves to a string + param = Param('foo', lambda: None, type=None) + assert param.get_name() == 'foo' + assert isinstance(param.get_value(), str) + assert param.get_value().startswith('