diff --git a/README.md b/README.md index a1834ad..8c0b468 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Check the [Python Connect SDK Example](example/README.md) to see an example of i ```sh export OP_CONNECT_HOST= && \ - export OP_CONNECT_TOKEN= + export OP_CONNECT_TOKEN= ``` 3. Use the SDK: diff --git a/USAGE.md b/USAGE.md index d53fb58..902b71b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -21,6 +21,12 @@ connect_client_from_env: Client = new_client_from_environment() connect_client_from_token: Client = new_client( "{1Password_Connect_Host}", "{1Password_Connect_API_Token}") + +# creates async client +connect_async_client: Client = new_client( + "{1Password_Connect_Host}", + "{1Password_Connect_API_Token}", + True) ``` ## Environment Variables @@ -32,6 +38,10 @@ connect_client_from_token: Client = new_client( - `http://localhost:8080` if the Connect server is running in Docker on the same host. - `http(s)://:8080` or `http(s)://:8080` if the Connect server is running on another host. - **OP_VAULT** - The default vault to fetch items from if not specified. +- **OP_CONNECT_CLIENT_ASYNC** - Whether to use async client or not. Possible values are: + - True - to use async client + - False - to use synchronous client (this is used by default) + ## Working with Vaults @@ -136,3 +146,24 @@ CONFIG = Config() values_object = onepasswordconnectsdk.load(connect_client, CONFIG) ``` + +## Async client + +All the examples above can work using an async client. +```python +import asyncio + +# initialize async client by passing `is_async = True` +async_client: Client = new_client( + "{1Password_Connect_Host}", + "{1Password_Connect_API_Token}", + True) + +async def main(): + vaults = await async_client.get_vaults() + item = await async_client.get_item("{item_id}", "{vault_id}") + # do something with vaults and item + await async_client.session.aclose() # close the client gracefully when you are done + +asyncio.run(main()) +``` \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 60cfaeb..6ccac8c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,27 +1,33 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] -name = "attrs" -version = "22.2.0" -description = "Classes Without Boilerplate" +name = "anyio" +version = "3.7.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, - {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, ] +[package.dependencies] +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + [package.extras] -cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] -tests = ["attrs[tests-no-zope]", "zope.interface"] -tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] [[package]] name = "certifi" version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -29,24 +35,11 @@ files = [ {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] -[[package]] -name = "charset-normalizer" -version = "2.1.1" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.6.0" -files = [ - {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, - {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, -] - -[package.extras] -unicode-backport = ["unicodedata2"] - [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -56,62 +49,72 @@ files = [ [[package]] name = "coverage" -version = "7.0.1" +version = "7.2.7" description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "coverage-7.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3695c4f4750bca943b3e1f74ad4be8d29e4aeab927d50772c41359107bd5d5c"}, - {file = "coverage-7.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa6a5a224b7f4cfb226f4fc55a57e8537fcc096f42219128c2c74c0e7d0953e1"}, - {file = "coverage-7.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74f70cd92669394eaf8d7756d1b195c8032cf7bbbdfce3bc489d4e15b3b8cf73"}, - {file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b66bb21a23680dee0be66557dc6b02a3152ddb55edf9f6723fa4a93368f7158d"}, - {file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d87717959d4d0ee9db08a0f1d80d21eb585aafe30f9b0a54ecf779a69cb015f6"}, - {file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:854f22fa361d1ff914c7efa347398374cc7d567bdafa48ac3aa22334650dfba2"}, - {file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e414dc32ee5c3f36544ea466b6f52f28a7af788653744b8570d0bf12ff34bc0"}, - {file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6c5ad996c6fa4d8ed669cfa1e8551348729d008a2caf81489ab9ea67cfbc7498"}, - {file = "coverage-7.0.1-cp310-cp310-win32.whl", hash = "sha256:691571f31ace1837838b7e421d3a09a8c00b4aac32efacb4fc9bd0a5c647d25a"}, - {file = "coverage-7.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:89caf4425fe88889e2973a8e9a3f6f5f9bbe5dd411d7d521e86428c08a873a4a"}, - {file = "coverage-7.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:63d56165a7c76265468d7e0c5548215a5ba515fc2cba5232d17df97bffa10f6c"}, - {file = "coverage-7.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f943a3b2bc520102dd3e0bb465e1286e12c9a54f58accd71b9e65324d9c7c01"}, - {file = "coverage-7.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:830525361249dc4cd013652b0efad645a385707a5ae49350c894b67d23fbb07c"}, - {file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd1b9c5adc066db699ccf7fa839189a649afcdd9e02cb5dc9d24e67e7922737d"}, - {file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00c14720b8b3b6c23b487e70bd406abafc976ddc50490f645166f111c419c39"}, - {file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6d55d840e1b8c0002fce66443e124e8581f30f9ead2e54fbf6709fb593181f2c"}, - {file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:66b18c3cf8bbab0cce0d7b9e4262dc830e93588986865a8c78ab2ae324b3ed56"}, - {file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:12a5aa77783d49e05439fbe6e6b427484f8a0f9f456b46a51d8aac022cfd024d"}, - {file = "coverage-7.0.1-cp311-cp311-win32.whl", hash = "sha256:b77015d1cb8fe941be1222a5a8b4e3fbca88180cfa7e2d4a4e58aeabadef0ab7"}, - {file = "coverage-7.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb992c47cb1e5bd6a01e97182400bcc2ba2077080a17fcd7be23aaa6e572e390"}, - {file = "coverage-7.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e78e9dcbf4f3853d3ae18a8f9272111242531535ec9e1009fa8ec4a2b74557dc"}, - {file = "coverage-7.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e60bef2e2416f15fdc05772bf87db06c6a6f9870d1db08fdd019fbec98ae24a9"}, - {file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9823e4789ab70f3ec88724bba1a203f2856331986cd893dedbe3e23a6cfc1e4e"}, - {file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9158f8fb06747ac17bd237930c4372336edc85b6e13bdc778e60f9d685c3ca37"}, - {file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:486ee81fa694b4b796fc5617e376326a088f7b9729c74d9defa211813f3861e4"}, - {file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1285648428a6101b5f41a18991c84f1c3959cee359e51b8375c5882fc364a13f"}, - {file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2c44fcfb3781b41409d0f060a4ed748537557de9362a8a9282182fafb7a76ab4"}, - {file = "coverage-7.0.1-cp37-cp37m-win32.whl", hash = "sha256:d6814854c02cbcd9c873c0f3286a02e3ac1250625cca822ca6bc1018c5b19f1c"}, - {file = "coverage-7.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f66460f17c9319ea4f91c165d46840314f0a7c004720b20be58594d162a441d8"}, - {file = "coverage-7.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b373c9345c584bb4b5f5b8840df7f4ab48c4cbb7934b58d52c57020d911b856"}, - {file = "coverage-7.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d3022c3007d3267a880b5adcf18c2a9bf1fc64469b394a804886b401959b8742"}, - {file = "coverage-7.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92651580bd46519067e36493acb394ea0607b55b45bd81dd4e26379ed1871f55"}, - {file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cfc595d2af13856505631be072835c59f1acf30028d1c860b435c5fc9c15b69"}, - {file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b4b3a4d9915b2be879aff6299c0a6129f3d08a775d5a061f503cf79571f73e4"}, - {file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b6f22bb64cc39bcb883e5910f99a27b200fdc14cdd79df8696fa96b0005c9444"}, - {file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72d1507f152abacea81f65fee38e4ef3ac3c02ff8bc16f21d935fd3a8a4ad910"}, - {file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a79137fc99815fff6a852c233628e735ec15903cfd16da0f229d9c4d45926ab"}, - {file = "coverage-7.0.1-cp38-cp38-win32.whl", hash = "sha256:b3763e7fcade2ff6c8e62340af9277f54336920489ceb6a8cd6cc96da52fcc62"}, - {file = "coverage-7.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:09f6b5a8415b6b3e136d5fec62b552972187265cb705097bf030eb9d4ffb9b60"}, - {file = "coverage-7.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:978258fec36c154b5e250d356c59af7d4c3ba02bef4b99cda90b6029441d797d"}, - {file = "coverage-7.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:19ec666533f0f70a0993f88b8273057b96c07b9d26457b41863ccd021a043b9a"}, - {file = "coverage-7.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfded268092a84605f1cc19e5c737f9ce630a8900a3589e9289622db161967e9"}, - {file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07bcfb1d8ac94af886b54e18a88b393f6a73d5959bb31e46644a02453c36e475"}, - {file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397b4a923cc7566bbc7ae2dfd0ba5a039b61d19c740f1373791f2ebd11caea59"}, - {file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aec2d1515d9d39ff270059fd3afbb3b44e6ec5758af73caf18991807138c7118"}, - {file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c20cfebcc149a4c212f6491a5f9ff56f41829cd4f607b5be71bb2d530ef243b1"}, - {file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fd556ff16a57a070ce4f31c635953cc44e25244f91a0378c6e9bdfd40fdb249f"}, - {file = "coverage-7.0.1-cp39-cp39-win32.whl", hash = "sha256:b9ea158775c7c2d3e54530a92da79496fb3fb577c876eec761c23e028f1e216c"}, - {file = "coverage-7.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:d1991f1dd95eba69d2cd7708ff6c2bbd2426160ffc73c2b81f617a053ebcb1a8"}, - {file = "coverage-7.0.1-pp37.pp38.pp39-none-any.whl", hash = "sha256:3dd4ee135e08037f458425b8842d24a95a0961831a33f89685ff86b77d378f89"}, - {file = "coverage-7.0.1.tar.gz", hash = "sha256:a4a574a19eeb67575a5328a5760bbbb737faa685616586a9f9da4281f940109c"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, + {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, + {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, + {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, + {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, + {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, + {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, + {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, + {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, + {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, + {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, + {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, + {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, + {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, + {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, + {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, + {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, ] [package.dependencies] @@ -122,22 +125,85 @@ toml = ["tomli"] [[package]] name = "exceptiongroup" -version = "1.1.0" +version = "1.1.3" description = "Backport of PEP 654 (exception groups)" +category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, - {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, ] [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "httpcore" +version = "0.16.3" +description = "A minimal low-level HTTP client." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, + {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, +] + +[package.dependencies] +anyio = ">=3.0,<5.0" +certifi = "*" +h11 = ">=0.13,<0.15" +sniffio = ">=1.0.0,<2.0.0" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "httpx" +version = "0.23.3" +description = "The next generation HTTP client." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, + {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, +] + +[package.dependencies] +certifi = "*" +httpcore = ">=0.15.0,<0.17.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + [[package]] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -147,13 +213,14 @@ files = [ [[package]] name = "importlib-metadata" -version = "5.2.0" +version = "6.7.0" description = "Read metadata from Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "importlib_metadata-5.2.0-py3-none-any.whl", hash = "sha256:0eafa39ba42bf225fc00e67f701d71f85aead9f878569caf13c3724f704b970f"}, - {file = "importlib_metadata-5.2.0.tar.gz", hash = "sha256:404d48d62bba0b7a77ff9d405efd91501bef2e67ff4ace0bed40a0cf28c3c7cd"}, + {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, + {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, ] [package.dependencies] @@ -163,39 +230,42 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] [[package]] name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" files = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] [[package]] name = "packaging" -version = "22.0" +version = "23.1" description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"}, - {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"}, + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] [[package]] name = "pluggy" -version = "1.0.0" +version = "1.2.0" description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, ] [package.dependencies] @@ -207,17 +277,17 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "7.2.0" +version = "7.4.0" description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, - {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, + {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, + {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, ] [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} @@ -227,17 +297,38 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.20.3" +description = "Pytest support for asyncio" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, + {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, +] + +[package.dependencies] +pytest = ">=6.1.0" +typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] [[package]] name = "pytest-cov" -version = "4.0.0" +version = "4.1.0" description = "Pytest plugin for measuring coverage." +category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, - {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, ] [package.dependencies] @@ -251,6 +342,7 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -262,30 +354,43 @@ files = [ six = ">=1.5" [[package]] -name = "requests" -version = "2.31.0" -description = "Python HTTP for Humans." +name = "respx" +version = "0.20.2" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "respx-0.20.2-py2.py3-none-any.whl", hash = "sha256:ab8e1cf6da28a5b2dd883ea617f8130f77f676736e6e9e4a25817ad116a172c9"}, + {file = "respx-0.20.2.tar.gz", hash = "sha256:07cf4108b1c88b82010f67d3c831dae33a375c7b436e54d87737c7f9f99be643"}, +] + +[package.dependencies] +httpx = ">=0.21.0" + +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, + {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, ] [package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} [package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +idna2008 = ["idna"] [[package]] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -293,10 +398,23 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + [[package]] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -306,47 +424,33 @@ files = [ [[package]] name = "typing-extensions" -version = "4.4.0" +version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, - {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] -[[package]] -name = "urllib3" -version = "1.26.13" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -files = [ - {file = "urllib3-1.26.13-py2.py3-none-any.whl", hash = "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc"}, - {file = "urllib3-1.26.13.tar.gz", hash = "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] - [[package]] name = "zipp" -version = "3.11.0" +version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"}, - {file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"}, + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] -testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "cac4a5815b13269da64f86e0b0481742124eba1200928ffb3fdd605c12d5c3c8" +content-hash = "5c06df5db167647617c8fb7afc9da451dcec9d10e24df37e6024124cccd6da60" diff --git a/pyproject.toml b/pyproject.toml index 7f2595e..4b9d1c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,12 +13,14 @@ repository = "https://github.com/1Password/connect-sdk-python" [tool.poetry.dependencies] python = "^3.7" -requests = "^2.24.0" python-dateutil = "^2.8.1" +httpx = "^0.23.3" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pytest = "^7.2.0" +pytest-asyncio = "^0.20.3" pytest-cov = "^4.0.0" +respx = "^0.20.1" [build-system] requires = ["poetry>=0.12"] diff --git a/src/onepasswordconnectsdk/async_client.py b/src/onepasswordconnectsdk/async_client.py new file mode 100644 index 0000000..415924c --- /dev/null +++ b/src/onepasswordconnectsdk/async_client.py @@ -0,0 +1,373 @@ +"""Python AsyncClient for connecting to 1Password Connect""" +import httpx +from httpx import HTTPError +import json +import os + +from onepasswordconnectsdk.serializer import Serializer +from onepasswordconnectsdk.utils import build_headers, is_valid_uuid, PathBuilder +from onepasswordconnectsdk.errors import ( + FailedToRetrieveItemException, + FailedToRetrieveVaultException, +) +from onepasswordconnectsdk.models import Item, ItemVault + + +class AsyncClient: + """Python Async Client Class""" + + def __init__(self, url: str, token: str): + """Initialize async client""" + self.url = url + self.token = token + self.session = self.create_session(url, token) + self.serializer = Serializer() + + def create_session(self, url: str, token: str): + return httpx.AsyncClient(base_url=url, headers=self.build_headers(token)) + + def build_headers(self, token: str): + return build_headers(token) + + async def __aexit__(self): + await self.session.aclose() + + async def get_file(self, file_id: str, item_id: str, vault_id: str): + url = PathBuilder().vaults(vault_id).items(item_id).files(file_id).build() + response = await self.build_request("GET", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveItemException( + f"Unable to retrieve item. Received {response.status_code}\ + for {url} with message: {response.json().get('message')}" + ) + return self.serializer.deserialize(response.content, "File") + + async def get_files(self, item_id: str, vault_id: str): + url = PathBuilder().vaults(vault_id).items(item_id).files().build() + response = await self.build_request("GET", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveItemException( + f"Unable to retrieve item. Received {response.status_code}\ + for {url} with message: {response.json().get('message')}" + ) + return self.serializer.deserialize(response.content, "list[File]") + + async def get_file_content(self, file_id: str, item_id: str, vault_id: str, content_path: str = None): + url = content_path + if content_path is None: + url = PathBuilder().vaults(vault_id).items(item_id).files(file_id).content().build() + response = await self.build_request("GET", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveItemException( + f"Unable to retrieve items. Received {response.status_code} \ + for {url} with message: {response.json().get('message')}" + ) + return response.content + + async def download_file(self, file_id: str, item_id: str, vault_id: str, path: str): + file_object = await self.get_file(file_id, item_id, vault_id) + filename = file_object.name or "1password_item_file.txt" + content = await self.get_file_content(file_id, item_id, vault_id, file_object.content_path) + global_path = os.path.join(path, filename) + + file = open(global_path, "wb") + file.write(content) + file.close() + + async def get_item(self, item: str, vault: str): + """Get a specific item + + Args: + item (str): the id or title of the item to be fetched + vault (str): the id or name of the vault in which to get the item from + + Raises: + FailedToRetrieveItemException: Thrown when a HTTP error is returned + from the 1Password Connect API + + Returns: + Item object: The found item + """ + + vault_id = vault + if not is_valid_uuid(vault): + vault = await self.get_vault_by_title(vault) + vault_id = vault.id + + if is_valid_uuid(item): + return await self.get_item_by_id(item, vault_id) + else: + return await self.get_item_by_title(item, vault_id) + + async def get_item_by_id(self, item_id: str, vault_id: str): + """Get a specific item by uuid + + Args: + item_id (str): The id of the item to be fetched + vault_id (str): The id of the vault in which to get the item from + + Raises: + FailedToRetrieveItemException: Thrown when a HTTP error is returned + from the 1Password Connect API + + Returns: + Item object: The found item + """ + url = PathBuilder().vaults(vault_id).items(item_id).build() + response = await self.build_request("GET", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveItemException( + f"Unable to retrieve item. Received {response.status_code}\ + for {url} with message: {response.json().get('message')}" + ) + return self.serializer.deserialize(response.content, "Item") + + async def get_item_by_title(self, title: str, vault_id: str): + """Get a specific item by title + + Args: + title (str): The title of the item to be fetched + vault_id (str): The id of the vault in which to get the item from + + Raises: + FailedToRetrieveItemException: Thrown when a HTTP error is returned + from the 1Password Connect API + + Returns: + Item object: The found item + """ + filter_query = f'title eq "{title}"' + url = PathBuilder().vaults(vault_id).items().query("filter", filter_query).build() + response = await self.build_request("GET", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveItemException( + f"Unable to retrieve items. Received {response.status_code} \ + for {url} with message: {response.json().get('message')}" + ) + + if len(response.json()) != 1: + raise FailedToRetrieveItemException( + f"Found {len(response.json())} items in vault {vault_id} with \ + title {title}" + ) + + item_summary = self.serializer.deserialize(response.content, "list[SummaryItem]")[0] + return await self.get_item_by_id(item_summary.id, vault_id) + + async def get_items(self, vault_id: str, filter_query: str = None): + """Returns a list of item summaries for the specified vault + + Args: + vault_id (str): The id of the vault in which to get the items from + + Raises: + FailedToRetrieveItemException: Thrown when a HTTP error is returned + from the 1Password Connect API + + Returns: + List[SummaryItem]: A list of summarized items + """ + if filter_query is None: + url = PathBuilder().vaults(vault_id).items().build() + else: + url = PathBuilder().vaults(vault_id).items().query("filter", filter_query).build() + response = await self.build_request("GET", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveItemException( + f"Unable to retrieve items. Received {response.status_code} \ + for {url} with message: {response.json().get('message')}" + ) + + return self.serializer.deserialize(response.content, "list[SummaryItem]") + + async def delete_item(self, item_id: str, vault_id: str): + """Deletes a specified item from a specified vault + + Args: + item_id (str): The id of the item in which to delete the item from + vault_id (str): The id of the vault in which to delete the item + from + + Raises: + FailedToRetrieveItemException: Thrown when a HTTP error is returned + from the 1Password Connect API + """ + url = PathBuilder().vaults(vault_id).items(item_id).build() + response = await self.build_request("DELETE", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveItemException( + f"Unable to delete item. Received {response.status_code}\ + for {url} with message: {response.json().get('message')}" + ) + + async def create_item(self, vault_id: str, item: Item): + """Creates an item at the specified vault + + Args: + vault_id (str): The id of the vault in which add the item to + item (Item): The item to create + + Raises: + FailedToRetrieveItemException: Thrown when a HTTP error is returned + from the 1Password Connect API + + Returns: + Item: The created item + """ + + url = PathBuilder().vaults(vault_id).items().build() + response = await self.build_request("POST", url, item) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveItemException( + f"Unable to post item. Received {response.status_code}\ + for {url} with message: {response.json().get('message')}" + ) + return self.serializer.deserialize(response.content, "Item") + + async def update_item(self, item_uuid: str, vault_id: str, item: Item): + """Update the specified item at the specified vault. + + Args: + item_uuid (str): The id of the item in which to update + vault_id (str): The id of the vault in which to update the item + item (Item): The updated item + + Raises: + FailedToRetrieveItemException: Thrown when a HTTP error is returned + from the 1Password Connect API + + Returns: + Item: The updated item + """ + url = PathBuilder().vaults(vault_id).items(item_uuid).build() + item.id = item_uuid + item.vault = ItemVault(id=vault_id) + + response = await self.build_request("PUT", url, item) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveItemException( + f"Unable to post item. Received {response.status_code}\ + for {url} with message: {response.json().get('message')}" + ) + return self.serializer.deserialize(response.content, "Item") + + async def get_vault(self, vault_id: str): + """Returns the vault with the given vault_id + + Args: + vault_id (str): The id of the vault in which to fetch + + Raises: + FailedToRetrieveVaultException: Thrown when a HTTP error is + returned from the 1Password Connect API + + Returns: + Vault: The specified vault + """ + url = PathBuilder().vaults(vault_id).build() + response = await self.build_request("GET", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveVaultException( + f"Unable to retrieve vault. Received {response.status_code} \ + for {url} with message {response.json().get('message')}" + ) + + return self.serializer.deserialize(response.content, "Vault") + + async def get_vault_by_title(self, name: str): + """Returns the vault with the given name + + Args: + name (str): The name of the vault in which to fetch + + Raises: + FailedToRetrieveVaultException: Thrown when a HTTP error is + returned from the 1Password Connect API + + Returns: + Vault: The specified vault + """ + filter_query = f'name eq "{name}"' + url = PathBuilder().vaults().query("filter", filter_query).build() + response = await self.build_request("GET", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveVaultException( + f"Unable to retrieve vaults. Received {response.status_code} \ + for {url} with message {response.json().get('message')}" + ) + + if len(response.json()) != 1: + raise FailedToRetrieveItemException( + f"Found {len(response.json())} vaults with \ + name {name}" + ) + + return self.serializer.deserialize(response.content, "list[Vault]")[0] + + async def get_vaults(self): + """Returns all vaults for service account set in client + + Raises: + FailedToRetrieveVaultException: Thrown when a HTTP error is + returned from the 1Password Connect API + + Returns: + List[Vault]: All vaults for the service account in use + """ + url = PathBuilder().vaults().build() + response = await self.build_request("GET", url) + try: + response.raise_for_status() + except HTTPError: + raise FailedToRetrieveVaultException( + f"Unable to retrieve vaults. Received {response.status_code} \ + for {url} with message {response.json().get('message')}" + ) + + return self.serializer.deserialize(response.content, "list[Vault]") + + def build_request(self, method: str, path: str, body=None): + """Builds a http request + Parameters: + method (str): The rest method to be used + path (str): The request path + body (str): The request body + + Returns: + Response object: The request response + """ + + if body: + serialized_body = json.dumps(self.serializer.sanitize_for_serialization(body)) + response = self.session.request(method, path, data=serialized_body) + else: + response = self.session.request(method, path) + return response + + def deserialize(self, response, response_type): + return self.serializer.deserialize(response, response_type) + + def sanitize_for_serialization(self, obj): + return self.serializer.sanitize_for_serialization(obj) diff --git a/src/onepasswordconnectsdk/client.py b/src/onepasswordconnectsdk/client.py index 694da33..4202ccf 100644 --- a/src/onepasswordconnectsdk/client.py +++ b/src/onepasswordconnectsdk/client.py @@ -1,58 +1,46 @@ """Python Client for connecting to 1Password Connect""" -from dateutil.parser import parse +import httpx +from httpx import HTTPError import json import os -import re -import requests -import datetime -from requests.exceptions import HTTPError -import onepasswordconnectsdk + +from onepasswordconnectsdk.async_client import AsyncClient +from onepasswordconnectsdk.serializer import Serializer +from onepasswordconnectsdk.utils import build_headers, is_valid_uuid, PathBuilder +from onepasswordconnectsdk.errors import ( + FailedToRetrieveItemException, + FailedToRetrieveVaultException, + EnvironmentHostNotSetException, + EnvironmentTokenNotSetException, +) from onepasswordconnectsdk.models import Item, ItemVault from onepasswordconnectsdk.models.constants import CONNECT_HOST_ENV_VARIABLE ENV_SERVICE_ACCOUNT_JWT_VARIABLE = "OP_CONNECT_TOKEN" -UUIDLength = 26 +ENV_IS_ASYNC_CLIENT = "OP_CONNECT_CLIENT_ASYNC" class Client: - PRIMITIVE_TYPES = (float, bool, bytes, str, int) - NATIVE_TYPES_MAPPING = { - "int": int, - "float": float, - "str": str, - "bool": bool, - "date": datetime.date, - "datetime": datetime.datetime, - "object": object, - } - """Python Client Class""" def __init__(self, url: str, token: str): """Initialize client""" self.url = url self.token = token - self.session = self.create_session() + self.session = self.create_session(url, token) + self.serializer = Serializer() - def create_session(self): - session = requests.Session() - session.headers.update(self.build_headers()) - return session + def create_session(self, url: str, token: str): + return httpx.Client(base_url=url, headers=self.build_headers(token)) - def build_headers(self): - """Builds the headers needed to make a request to the server + def build_headers(self, token: str): + return build_headers(token) - Returns: - dict: The 1Password Connect API request headers - """ - - headers = {} - headers["Authorization"] = f"Bearer {self.token}" - headers["Content-Type"] = "application/json" - return headers + def __del__(self): + self.session.close() def get_file(self, file_id: str, item_id: str, vault_id: str): - url = f"/v1/vaults/{vault_id}/items/{item_id}/files/{file_id}" + url = PathBuilder().vaults(vault_id).items(item_id).files(file_id).build() response = self.build_request("GET", url) try: response.raise_for_status() @@ -61,11 +49,10 @@ def get_file(self, file_id: str, item_id: str, vault_id: str): f"Unable to retrieve item. Received {response.status_code}\ for {url} with message: {response.json().get('message')}" ) - return self.deserialize(response.content, "File") + return self.serializer.deserialize(response.content, "File") def get_files(self, item_id: str, vault_id: str): - url = f"/v1/vaults/{vault_id}/items/{item_id}/files" - + url = PathBuilder().vaults(vault_id).items(item_id).files().build() response = self.build_request("GET", url) try: response.raise_for_status() @@ -74,11 +61,12 @@ def get_files(self, item_id: str, vault_id: str): f"Unable to retrieve item. Received {response.status_code}\ for {url} with message: {response.json().get('message')}" ) - return self.deserialize(response.content, "list[File]") + return self.serializer.deserialize(response.content, "list[File]") def get_file_content(self, file_id: str, item_id: str, vault_id: str, content_path: str = None): - url = content_path if content_path is not None else f"/v1/vaults/{vault_id}/items/{item_id}/files/{file_id}/content" - + url = content_path + if content_path is None: + url = PathBuilder().vaults(vault_id).items(item_id).files(file_id).content().build() response = self.build_request("GET", url) try: response.raise_for_status() @@ -115,10 +103,10 @@ def get_item(self, item: str, vault: str): """ vault_id = vault - if not self._is_valid_UUID(vault): + if not is_valid_uuid(vault): vault_id = self.get_vault_by_title(vault).id - if self._is_valid_UUID(item): + if is_valid_uuid(item): return self.get_item_by_id(item, vault_id) else: return self.get_item_by_title(item, vault_id) @@ -137,8 +125,7 @@ def get_item_by_id(self, item_id: str, vault_id: str): Returns: Item object: The found item """ - url = f"/v1/vaults/{vault_id}/items/{item_id}" - + url = PathBuilder().vaults(vault_id).items(item_id).build() response = self.build_request("GET", url) try: response.raise_for_status() @@ -147,7 +134,7 @@ def get_item_by_id(self, item_id: str, vault_id: str): f"Unable to retrieve item. Received {response.status_code}\ for {url} with message: {response.json().get('message')}" ) - return self.deserialize(response.content, "Item") + return self.serializer.deserialize(response.content, "Item") def get_item_by_title(self, title: str, vault_id: str): """Get a specific item by title @@ -164,8 +151,7 @@ def get_item_by_title(self, title: str, vault_id: str): Item object: The found item """ filter_query = f'title eq "{title}"' - url = f"/v1/vaults/{vault_id}/items?filter={filter_query}" - + url = PathBuilder().vaults(vault_id).items().query("filter", filter_query).build() response = self.build_request("GET", url) try: response.raise_for_status() @@ -181,15 +167,15 @@ def get_item_by_title(self, title: str, vault_id: str): title {title}" ) - item_summary = self.deserialize(response.content, "list[SummaryItem]")[0] + item_summary = self.serializer.deserialize(response.content, "list[SummaryItem]")[0] return self.get_item_by_id(item_summary.id, vault_id) - def get_items(self, vault_id: str, filter_query=None): + def get_items(self, vault_id: str, filter_query: str = None): """Returns a list of item summaries for the specified vault Args: vault_id (str): The id of the vault in which to get the items from - filter_query (str): A optional query statement. `title eq foo.bar` + filter_query (str): A optional query statement. `title eq "Example Item"` Raises: FailedToRetrieveItemException: Thrown when a HTTP error is returned @@ -199,9 +185,9 @@ def get_items(self, vault_id: str, filter_query=None): List[SummaryItem]: A list of summarized items """ if filter_query is None: - url = f"/v1/vaults/{vault_id}/items" + url = PathBuilder().vaults(vault_id).items().build() else: - url = f"/v1/vaults/{vault_id}/items?filter={filter_query}" + url = PathBuilder().vaults(vault_id).items().query("filter", filter_query).build() response = self.build_request("GET", url) try: @@ -212,7 +198,7 @@ def get_items(self, vault_id: str, filter_query=None): for {url} with message: {response.json().get('message')}" ) - return self.deserialize(response.content, "list[SummaryItem]") + return self.serializer.deserialize(response.content, "list[SummaryItem]") def delete_item(self, item_id: str, vault_id: str): """Deletes a specified item from a specified vault @@ -220,14 +206,12 @@ def delete_item(self, item_id: str, vault_id: str): Args: item_id (str): The id of the item in which to delete the item from vault_id (str): The id of the vault in which to delete the item - from Raises: FailedToRetrieveItemException: Thrown when a HTTP error is returned from the 1Password Connect API """ - url = f"/v1/vaults/{vault_id}/items/{item_id}" - + url = PathBuilder().vaults(vault_id).items(item_id).build() response = self.build_request("DELETE", url) try: response.raise_for_status() @@ -252,9 +236,8 @@ def create_item(self, vault_id: str, item: Item): Item: The created item """ - url = f"/v1/vaults/{vault_id}/items" - - response: requests.Response = self.build_request("POST", url, item) + url = PathBuilder().vaults(vault_id).items().build() + response = self.build_request("POST", url, item) try: response.raise_for_status() except HTTPError: @@ -262,7 +245,7 @@ def create_item(self, vault_id: str, item: Item): f"Unable to post item. Received {response.status_code}\ for {url} with message: {response.json().get('message')}" ) - return self.deserialize(response.content, "Item") + return self.serializer.deserialize(response.content, "Item") def update_item(self, item_uuid: str, vault_id: str, item: Item): """Update the specified item at the specified vault. @@ -279,11 +262,11 @@ def update_item(self, item_uuid: str, vault_id: str, item: Item): Returns: Item: The updated item """ - url = f"/v1/vaults/{vault_id}/items/{item_uuid}" + url = PathBuilder().vaults(vault_id).items(item_uuid).build() item.id = item_uuid item.vault = ItemVault(id=vault_id) - response: requests.Response = self.build_request("PUT", url, item) + response = self.build_request("PUT", url, item) try: response.raise_for_status() except HTTPError: @@ -291,7 +274,7 @@ def update_item(self, item_uuid: str, vault_id: str, item: Item): f"Unable to post item. Received {response.status_code}\ for {url} with message: {response.json().get('message')}" ) - return self.deserialize(response.content, "Item") + return self.serializer.deserialize(response.content, "Item") def get_vault(self, vault_id: str): """Returns the vault with the given vault_id @@ -306,7 +289,7 @@ def get_vault(self, vault_id: str): Returns: Vault: The specified vault """ - url = f"/v1/vaults/{vault_id}" + url = PathBuilder().vaults(vault_id).build() response = self.build_request("GET", url) try: response.raise_for_status() @@ -316,7 +299,7 @@ def get_vault(self, vault_id: str): for {url} with message {response.json().get('message')}" ) - return self.deserialize(response.content, "Vault") + return self.serializer.deserialize(response.content, "Vault") def get_vault_by_title(self, name: str): """Returns the vault with the given name @@ -332,8 +315,7 @@ def get_vault_by_title(self, name: str): Vault: The specified vault """ filter_query = f'name eq "{name}"' - url = f"/v1/vaults?filter={filter_query}" - + url = PathBuilder().vaults().query("filter", filter_query).build() response = self.build_request("GET", url) try: response.raise_for_status() @@ -349,7 +331,7 @@ def get_vault_by_title(self, name: str): name {name}" ) - return self.deserialize(response.content, "list[Vault]")[0] + return self.serializer.deserialize(response.content, "list[Vault]")[0] def get_vaults(self): """Returns all vaults for service account set in client @@ -361,9 +343,8 @@ def get_vaults(self): Returns: List[Vault]: All vaults for the service account in use """ - url = "/v1/vaults" + url = PathBuilder().vaults().build() response = self.build_request("GET", url) - try: response.raise_for_status() except HTTPError: @@ -372,7 +353,7 @@ def get_vaults(self): for {url} with message {response.json().get('message')}" ) - return self.deserialize(response.content, "list[Vault]") + return self.serializer.deserialize(response.content, "list[Vault]") def build_request(self, method: str, path: str, body=None): """Builds a http request @@ -384,227 +365,34 @@ def build_request(self, method: str, path: str, body=None): Returns: Response object: The request response """ - url = f"{self.url}{path}" if body: - serialized_body = json.dumps(self.sanitize_for_serialization(body)) - response = self.session.request(method, url, data=serialized_body) + serialized_body = json.dumps(self.serializer.sanitize_for_serialization(body)) + response = self.session.request(method, path, data=serialized_body) else: - response = self.session.request(method, url) + response = self.session.request(method, path) return response def deserialize(self, response, response_type): - """Deserializes response into an object. - - :param response: RESTResponse object to be deserialized. - :param response_type: class literal for - deserialized object, or string of class name. - - :return: deserialized object. - """ - # fetch data from response object - try: - data = json.loads(response) - except ValueError: - data = response - - return self.__deserialize(data, response_type) + return self.serializer.deserialize(response, response_type) def sanitize_for_serialization(self, obj): - """Builds a JSON POST object. - - If obj is None, return None. - If obj is str, int, long, float, bool, return directly. - If obj is datetime.datetime, datetime.date convert to string - in iso8601 format. - If obj is list, sanitize each element in the list. - If obj is dict, return the dict. - If obj is OpenAPI model, return the properties dict. - - :param obj: The data to serialize. - :return: The serialized form of data. - """ - if obj is None: - return None - elif isinstance(obj, self.PRIMITIVE_TYPES): - return obj - elif isinstance(obj, list): - return [self.sanitize_for_serialization(sub_obj) for sub_obj in obj] # noqa: E501 - elif isinstance(obj, tuple): - return tuple(self.sanitize_for_serialization(sub_obj) for sub_obj in obj) # noqa: E501 - elif isinstance(obj, (datetime.datetime, datetime.date)): - return obj.isoformat() - - if isinstance(obj, dict): - obj_dict = obj - else: - # Convert model obj to dict except - # attributes `openapi_types`, `attribute_map` - # and attributes which value is not None. - # Convert attribute name to json key in - # model definition for request. - obj_dict = { - obj.attribute_map[attr]: getattr(obj, attr) - for attr in obj.openapi_types.keys() - if getattr(obj, attr) is not None - } - - return { - key: self.sanitize_for_serialization(val) - for key, val in obj_dict.items() - } - - def __deserialize(self, data, klass): - """Deserializes dict, list, str into an object. - - :param data: dict, list or str. - :param klass: class literal, or string of class name. - - :return: object. - """ - if data is None: - return None - - if type(klass) == str: - if klass.startswith("list["): - sub_kls = re.match(r"list\[(.*)\]", klass).group(1) - return [self.__deserialize(sub_data, sub_kls) for sub_data in data] # noqa: E501 - - if klass.startswith("dict("): - sub_kls = re.match(r"dict\(([^,]*), (.*)\)", klass).group(2) - return { - k: self.__deserialize(v, sub_kls) for k, v in data.items() # noqa: E501 - } - - # convert str to class - if klass in self.NATIVE_TYPES_MAPPING: - klass = self.NATIVE_TYPES_MAPPING[klass] - else: - klass = getattr(onepasswordconnectsdk.models, klass) - - if klass in self.PRIMITIVE_TYPES: - return self.__deserialize_primitive(data, klass) - elif klass == object: - return self.__deserialize_object(data) - elif klass == datetime.date: - return self.__deserialize_date(data) - elif klass == datetime.datetime: - return self.__deserialize_datetime(data) - else: - return self.__deserialize_model(data, klass) - - def __deserialize_primitive(self, data, klass): - """Deserializes string to primitive type. - - :param data: str. - :param klass: class literal. - - :return: int, long, float, str, bool. - """ - try: - return klass(data) - except UnicodeEncodeError: - return str(data) - except TypeError: - return data + return self.serializer.sanitize_for_serialization(obj) - def __deserialize_object(self, value): - """Return an original value. - :return: object. - """ - return value - - def __deserialize_date(self, string): - """Deserializes string to date. - - :param string: str. - :return: date. - """ - try: - return parse(string).date() - except ImportError: - return string - except ValueError: - raise FailedToDeserializeException( - f'Failed to parse `{0}`\ - as date object".format(string)' - ) - - def __deserialize_datetime(self, string): - """Deserializes string to datetime. - - The string should be in iso8601 datetime format. - - :param string: str. - :return: datetime. - """ - try: - return parse(string) - except ImportError: - return string - except ValueError: - raise FailedToDeserializeException( - f'Failed to parse `{0}`\ - as date object".format(string)' - ) - - def __deserialize_model(self, data, klass): - """Deserializes list or dict to model. - - :param data: dict, list. - :param klass: class literal. - :return: model object. - """ - has_discriminator = False - if ( - hasattr(klass, "get_real_child_model") - and klass.discriminator_value_class_map - ): - has_discriminator = True - - if not klass.openapi_types and has_discriminator is False: - return data - - kwargs = {} - if ( - data is not None - and klass.openapi_types is not None - and isinstance(data, (list, dict)) - ): - for attr, attr_type in klass.openapi_types.items(): - if klass.attribute_map[attr] in data: - value = data[klass.attribute_map[attr]] - kwargs[attr] = self.__deserialize(value, attr_type) - - instance = klass(**kwargs) - - if has_discriminator: - klass_name = instance.get_real_child_model(data) - if klass_name: - instance = self.__deserialize(data, klass_name) - return instance - - def _is_valid_UUID(self, uuid): - if len(uuid) is not UUIDLength: - return False - for c in uuid: - valid = (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') - if valid is False: - return False - return True - - -def new_client(url: str, token: str): +def new_client(url: str, token: str, is_async: bool = False): """Builds a new client for interacting with 1Password Connect Parameters: url: The url of the 1Password Connect API token: The 1Password Service Account token + is_async: Initialize async or sync client Returns: Client: The 1Password Connect client """ - return Client(url=url, token=token) + if is_async: + return AsyncClient(url, token) + return Client(url, token) def new_client_from_environment(url: str = None): @@ -619,6 +407,7 @@ def new_client_from_environment(url: str = None): Client: The 1Password Connect client """ token = os.environ.get(ENV_SERVICE_ACCOUNT_JWT_VARIABLE) + is_async = os.environ.get(ENV_IS_ASYNC_CLIENT) == "True" if url is None: url = os.environ.get(CONNECT_HOST_ENV_VARIABLE) @@ -633,28 +422,4 @@ def new_client_from_environment(url: str = None): f"{ENV_SERVICE_ACCOUNT_JWT_VARIABLE} variable" ) - return Client(url=url, token=token) - - -class OnePasswordConnectSDKError(RuntimeError): - pass - - -class EnvironmentTokenNotSetException(OnePasswordConnectSDKError, TypeError): - pass - - -class EnvironmentHostNotSetException(OnePasswordConnectSDKError, TypeError): - pass - - -class FailedToRetrieveItemException(OnePasswordConnectSDKError): - pass - - -class FailedToRetrieveVaultException(OnePasswordConnectSDKError): - pass - - -class FailedToDeserializeException(OnePasswordConnectSDKError, TypeError): - pass + return new_client(url, token, is_async) diff --git a/src/onepasswordconnectsdk/connect.py b/src/onepasswordconnectsdk/connect.py new file mode 100644 index 0000000..e69de29 diff --git a/src/onepasswordconnectsdk/errors.py b/src/onepasswordconnectsdk/errors.py new file mode 100644 index 0000000..add5db0 --- /dev/null +++ b/src/onepasswordconnectsdk/errors.py @@ -0,0 +1,22 @@ +class OnePasswordConnectSDKError(RuntimeError): + pass + + +class EnvironmentTokenNotSetException(OnePasswordConnectSDKError, TypeError): + pass + + +class EnvironmentHostNotSetException(OnePasswordConnectSDKError, TypeError): + pass + + +class FailedToRetrieveItemException(OnePasswordConnectSDKError): + pass + + +class FailedToRetrieveVaultException(OnePasswordConnectSDKError): + pass + + +class FailedToDeserializeException(OnePasswordConnectSDKError, TypeError): + pass diff --git a/src/onepasswordconnectsdk/serializer.py b/src/onepasswordconnectsdk/serializer.py new file mode 100644 index 0000000..db07568 --- /dev/null +++ b/src/onepasswordconnectsdk/serializer.py @@ -0,0 +1,211 @@ +from dateutil.parser import parse +import json +import re +import datetime +import onepasswordconnectsdk +from onepasswordconnectsdk.errors import FailedToDeserializeException + + +class Serializer: + PRIMITIVE_TYPES = (float, bool, bytes, str, int) + NATIVE_TYPES_MAPPING = { + "int": int, + "float": float, + "str": str, + "bool": bool, + "date": datetime.date, + "datetime": datetime.datetime, + "object": object, + } + + def deserialize(self, response, response_type): + """Deserializes response into an object. + + :param response: RESTResponse object to be deserialized. + :param response_type: class literal for + deserialized object, or string of class name. + + :return: deserialized object. + """ + # fetch data from response object + try: + data = json.loads(response) + except ValueError: + data = response + + return self.__deserialize(data, response_type) + + def sanitize_for_serialization(self, obj): + """Builds a JSON POST object. + + If obj is None, return None. + If obj is str, int, long, float, bool, return directly. + If obj is datetime.datetime, datetime.date convert to string + in iso8601 format. + If obj is list, sanitize each element in the list. + If obj is dict, return the dict. + If obj is OpenAPI model, return the properties dict. + + :param obj: The data to serialize. + :return: The serialized form of data. + """ + if obj is None: + return None + elif isinstance(obj, self.PRIMITIVE_TYPES): + return obj + elif isinstance(obj, list): + return [self.sanitize_for_serialization(sub_obj) for sub_obj in obj] # noqa: E501 + elif isinstance(obj, tuple): + return tuple(self.sanitize_for_serialization(sub_obj) for sub_obj in obj) # noqa: E501 + elif isinstance(obj, (datetime.datetime, datetime.date)): + return obj.isoformat() + + if isinstance(obj, dict): + obj_dict = obj + else: + # Convert model obj to dict except + # attributes `openapi_types`, `attribute_map` + # and attributes which value is not None. + # Convert attribute name to json key in + # model definition for request. + obj_dict = { + obj.attribute_map[attr]: getattr(obj, attr) + for attr in obj.openapi_types.keys() + if getattr(obj, attr) is not None + } + + return { + key: self.sanitize_for_serialization(val) + for key, val in obj_dict.items() + } + + def __deserialize(self, data, klass): + """Deserializes dict, list, str into an object. + + :param data: dict, list or str. + :param klass: class literal, or string of class name. + + :return: object. + """ + if data is None: + return None + + if type(klass) == str: + if klass.startswith("list["): + sub_kls = re.match(r"list\[(.*)\]", klass).group(1) + return [self.__deserialize(sub_data, sub_kls) for sub_data in data] # noqa: E501 + + if klass.startswith("dict("): + sub_kls = re.match(r"dict\(([^,]*), (.*)\)", klass).group(2) + return { + k: self.__deserialize(v, sub_kls) for k, v in data.items() # noqa: E501 + } + + # convert str to class + if klass in self.NATIVE_TYPES_MAPPING: + klass = self.NATIVE_TYPES_MAPPING[klass] + else: + klass = getattr(onepasswordconnectsdk.models, klass) + + if klass in self.PRIMITIVE_TYPES: + return self.__deserialize_primitive(data, klass) + elif klass == object: + return self.__deserialize_object(data) + elif klass == datetime.date: + return self.__deserialize_date(data) + elif klass == datetime.datetime: + return self.__deserialize_datetime(data) + else: + return self.__deserialize_model(data, klass) + + def __deserialize_primitive(self, data, klass): + """Deserializes string to primitive type. + + :param data: str. + :param klass: class literal. + + :return: int, long, float, str, bool. + """ + try: + return klass(data) + except UnicodeEncodeError: + return str(data) + except TypeError: + return data + + def __deserialize_object(self, value): + """Return an original value. + + :return: object. + """ + return value + + def __deserialize_date(self, string): + """Deserializes string to date. + + :param string: str. + :return: date. + """ + try: + return parse(string).date() + except ImportError: + return string + except ValueError: + raise FailedToDeserializeException( + f'Failed to parse `{0}`\ + as date object".format(string)' + ) + + def __deserialize_datetime(self, string): + """Deserializes string to datetime. + + The string should be in iso8601 datetime format. + + :param string: str. + :return: datetime. + """ + try: + return parse(string) + except ImportError: + return string + except ValueError: + raise FailedToDeserializeException( + f'Failed to parse `{0}`\ + as date object".format(string)' + ) + + def __deserialize_model(self, data, klass): + """Deserializes list or dict to model. + + :param data: dict, list. + :param klass: class literal. + :return: model object. + """ + has_discriminator = False + if ( + hasattr(klass, "get_real_child_model") + and klass.discriminator_value_class_map + ): + has_discriminator = True + + if not klass.openapi_types and has_discriminator is False: + return data + + kwargs = {} + if ( + data is not None + and klass.openapi_types is not None + and isinstance(data, (list, dict)) + ): + for attr, attr_type in klass.openapi_types.items(): + if klass.attribute_map[attr] in data: + value = data[klass.attribute_map[attr]] + kwargs[attr] = self.__deserialize(value, attr_type) + + instance = klass(**kwargs) + + if has_discriminator: + klass_name = instance.get_real_child_model(data) + if klass_name: + instance = self.__deserialize(data, klass_name) + return instance diff --git a/src/onepasswordconnectsdk/utils.py b/src/onepasswordconnectsdk/utils.py new file mode 100644 index 0000000..da35d50 --- /dev/null +++ b/src/onepasswordconnectsdk/utils.py @@ -0,0 +1,61 @@ +UUIDLength = 26 + + +def is_valid_uuid(uuid): + if len(uuid) is not UUIDLength: + return False + for c in uuid: + valid = (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') + if valid is False: + return False + return True + + +def build_headers(token: str): + """Builds the headers needed to make a request to the server + + Returns: + dict: The 1Password Connect API request headers + """ + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +class PathBuilder: + def __init__(self, version: str = "/v1"): + self.path: str = version + + def build(self) -> str: + return self.path + + def vaults(self, uuid: str = None) -> 'PathBuilder': + self._append_path("vaults") + if uuid is not None: + self._append_path(uuid) + return self + + def items(self, uuid: str = None) -> 'PathBuilder': + self._append_path("items") + if uuid is not None: + self._append_path(uuid) + return self + + def files(self, uuid: str = None) -> 'PathBuilder': + self._append_path("files") + if uuid is not None: + self._append_path(uuid) + return self + + def content(self) -> 'PathBuilder': + self._append_path("content") + return self + + def query(self, key: str, value: str) -> 'PathBuilder': + key_value_pair = f"{key}={value}" + self._append_path(query=key_value_pair) + return self + + def _append_path(self, path_chunk: str = None, query: str = None) -> 'PathBuilder': + if path_chunk is not None: + self.path += f"/{path_chunk}" + if query is not None: + self.path += f"?{query}" diff --git a/src/tests/test_client_items.py b/src/tests/test_client_items.py index 0a494eb..1f5f74d 100644 --- a/src/tests/test_client_items.py +++ b/src/tests/test_client_items.py @@ -1,203 +1,274 @@ -import json -from requests import Session, Response -from unittest.mock import patch +import pytest +from httpx import Response from onepasswordconnectsdk import client, models VAULT_ID = "hfnjvi6aymbsnfc2xeeoheizda" VAULT_TITLE = "VaultA" ITEM_ID = "wepiqdxdzncjtnvmv5fegud4qy" ITEM_TITLE = "Test Login" -HOST = "mock_host" +HOST = "https://mock_host" TOKEN = "jwt_token" SS_CLIENT = client.new_client(HOST, TOKEN) +SS_CLIENT_ASYNC = client.new_client(HOST, TOKEN, True) -@patch.object(Session, 'request') -def test_get_item_by_id(mock): +def test_get_item_by_id(respx_mock): expected_item = get_item() - expected_path = f"{HOST}/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + expected_path = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" - mock.return_value.ok = True - response = Response() - response.status_code = 200 - response._content = json.dumps(expected_item).encode("utf8") - mock.return_value = response + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_item)) item = SS_CLIENT.get_item_by_id(ITEM_ID, VAULT_ID) compare_items(expected_item, item) - mock.assert_called_with("GET", expected_path) + assert mock.called -@patch.object(Session, 'request') -def test_get_item_by_title(mock): +@pytest.mark.asyncio +async def test_get_item_by_id_async(respx_mock): expected_item = get_item() - expected_path_item_title = f"{HOST}/v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_TITLE}\"" - expected_path_item = f"{HOST}/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + expected_path = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" - mock.return_value.ok = True + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_item)) - response_item_summary = Response() - response_item_summary.status_code = 200 - response_item_summary._content = json.dumps(get_items()).encode("utf8") + item = await SS_CLIENT_ASYNC.get_item_by_id(ITEM_ID, VAULT_ID) + compare_items(expected_item, item) + assert mock.called - response_item = Response() - response_item.status_code = 200 - response_item._content = json.dumps(get_item()).encode("utf8") - mock.side_effect = [response_item_summary, response_item] +def test_get_item_by_title(respx_mock): + expected_item = get_item() + expected_path_item_title = f"/v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_TITLE}\"" + expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + + items_summary_mock = respx_mock.get(expected_path_item_title).mock(return_value=Response(200, json=get_items())) + item_mock = respx_mock.get(expected_path_item).mock(return_value=Response(200, json=expected_item)) item = SS_CLIENT.get_item_by_title(ITEM_TITLE, VAULT_ID) compare_items(expected_item, item) - mock.assert_any_call("GET", expected_path_item_title) - mock.assert_called_with("GET", expected_path_item) + assert items_summary_mock.called + assert item_mock.called + + +@pytest.mark.asyncio +async def test_get_item_by_title_async(respx_mock): + expected_item = get_item() + expected_path_item_title = f"/v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_TITLE}\"" + expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + + items_summary_mock = respx_mock.get(expected_path_item_title).mock(return_value=Response(200, json=get_items())) + item_mock = respx_mock.get(expected_path_item).mock(return_value=Response(200, json=expected_item)) + + item = await SS_CLIENT_ASYNC.get_item_by_title(ITEM_TITLE, VAULT_ID) + compare_items(expected_item, item) + assert items_summary_mock.called + assert item_mock.called -@patch.object(Session, 'request') -def test_get_item_by_item_id_vault_id(mock): +def test_get_item_by_item_id_vault_id(respx_mock): expected_item = get_item() - expected_path = f"{HOST}/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + expected_path = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" - mock.return_value.ok = True - response = Response() - response.status_code = 200 - response._content = json.dumps(expected_item).encode("utf8") - mock.return_value = response + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_item)) item = SS_CLIENT.get_item(ITEM_ID, VAULT_ID) compare_items(expected_item, item) - mock.assert_called_with("GET", expected_path) + assert mock.called -@patch.object(Session, 'request') -def test_get_item_by_item_id_vault_title(mock): +@pytest.mark.asyncio +async def test_get_item_by_item_id_vault_id_async(respx_mock): expected_item = get_item() - expected_path_vault_title = f"{HOST}/v1/vaults?filter=name eq \"{VAULT_TITLE}\"" - expected_path_item = f"{HOST}/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + expected_path = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_item)) - mock.return_value.ok = True + item = await SS_CLIENT_ASYNC.get_item(ITEM_ID, VAULT_ID) + compare_items(expected_item, item) + assert mock.called - response_vault = Response() - response_vault.status_code = 200 - response_vault._content = json.dumps(get_vaults()).encode("utf8") - response_item = Response() - response_item.status_code = 200 - response_item._content = json.dumps(expected_item).encode("utf8") +def test_get_item_by_item_id_vault_title(respx_mock): + expected_item = get_item() + expected_path_vault_title = f"/v1/vaults?filter=name eq \"{VAULT_TITLE}\"" + expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" - mock.side_effect = [response_vault, response_item] + vaults_by_title_mock = respx_mock.get(expected_path_vault_title).mock( + return_value=Response(200, json=get_vaults())) + item_mock = respx_mock.get(expected_path_item).mock(return_value=Response(200, json=expected_item)) item = SS_CLIENT.get_item(ITEM_ID, VAULT_TITLE) compare_items(expected_item, item) - mock.assert_any_call("GET", expected_path_vault_title) - mock.assert_called_with("GET", expected_path_item) + assert vaults_by_title_mock.called + assert item_mock.called -@patch.object(Session, 'request') -def test_get_item_by_item_title_vault_id(mock): +@pytest.mark.asyncio +async def test_get_item_by_item_id_vault_title_async(respx_mock): expected_item = get_item() - expected_path_item_title = f"{HOST}/v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_TITLE}\"" - expected_path_item = f"{HOST}/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + expected_path_vault_title = f"/v1/vaults?filter=name eq \"{VAULT_TITLE}\"" + expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" - mock.return_value.ok = True + vaults_by_title_mock = respx_mock.get(expected_path_vault_title).mock( + return_value=Response(200, json=get_vaults())) + item_mock = respx_mock.get(expected_path_item).mock(return_value=Response(200, json=expected_item)) - response_item_summary = Response() - response_item_summary.status_code = 200 - response_item_summary._content = json.dumps(get_items()).encode("utf8") + item = await SS_CLIENT_ASYNC.get_item(ITEM_ID, VAULT_TITLE) + compare_items(expected_item, item) + assert vaults_by_title_mock.called + assert item_mock.called - response_item = Response() - response_item.status_code = 200 - response_item._content = json.dumps(get_item()).encode("utf8") - mock.side_effect = [response_item_summary, response_item] +def test_get_item_by_item_title_vault_id(respx_mock): + expected_item = get_item() + expected_path_item_title = f"/v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_TITLE}\"" + expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + + items_by_title_mock = respx_mock.get(expected_path_item_title).mock( + return_value=Response(200, json=get_items())) + item_mock = respx_mock.get(expected_path_item).mock(return_value=Response(200, json=expected_item)) item = SS_CLIENT.get_item(ITEM_TITLE, VAULT_ID) compare_items(expected_item, item) - mock.assert_any_call("GET", expected_path_item_title) - mock.assert_called_with("GET", expected_path_item) + assert items_by_title_mock.called + assert item_mock.called -@patch.object(Session, 'request') -def test_get_item_by_item_title_vault_title(mock): +@pytest.mark.asyncio +async def test_get_item_by_item_title_vault_id_async(respx_mock): expected_item = get_item() - expected_path_vault_title = f"{HOST}/v1/vaults?filter=name eq \"{VAULT_TITLE}\"" - expected_path_item_title = f"{HOST}/v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_TITLE}\"" - expected_path_item = f"{HOST}/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + expected_path_item_title = f"/v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_TITLE}\"" + expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" - mock.return_value.ok = True + items_by_title_mock = respx_mock.get(expected_path_item_title).mock( + return_value=Response(200, json=get_items())) + item_mock = respx_mock.get(expected_path_item).mock(return_value=Response(200, json=expected_item)) - response_vault = Response() - response_vault.status_code = 200 - response_vault._content = json.dumps(get_vaults()).encode("utf8") + item = await SS_CLIENT_ASYNC.get_item(ITEM_TITLE, VAULT_ID) + compare_items(expected_item, item) + assert items_by_title_mock.called + assert item_mock.called - response_item_summary = Response() - response_item_summary.status_code = 200 - response_item_summary._content = json.dumps(get_items()).encode("utf8") - response_item = Response() - response_item.status_code = 200 - response_item._content = json.dumps(get_item()).encode("utf8") +def test_get_item_by_item_title_vault_title(respx_mock): + expected_item = get_item() + expected_path_vault_title = f"/v1/vaults?filter=name eq \"{VAULT_TITLE}\"" + expected_path_item_title = f"/v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_TITLE}\"" + expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" - mock.side_effect = [response_vault, response_item_summary, response_item] + vaults_by_title_mock = respx_mock.get(expected_path_vault_title).mock( + return_value=Response(200, json=get_vaults())) + items_by_title_mock = respx_mock.get(expected_path_item_title).mock( + return_value=Response(200, json=get_items())) + item_mock = respx_mock.get(expected_path_item).mock(return_value=Response(200, json=expected_item)) item = SS_CLIENT.get_item(ITEM_TITLE, VAULT_TITLE) compare_items(expected_item, item) - mock.assert_any_call("GET", expected_path_vault_title) - mock.assert_any_call("GET", expected_path_item_title) - mock.assert_called_with("GET", expected_path_item) + assert vaults_by_title_mock.called + assert items_by_title_mock.called + assert item_mock.called -@patch.object(Session, 'request') -def test_get_items(mock): +@pytest.mark.asyncio +async def test_get_item_by_item_title_vault_title_async(respx_mock): + expected_item = get_item() + expected_path_vault_title = f"/v1/vaults?filter=name eq \"{VAULT_TITLE}\"" + expected_path_item_title = f"/v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_TITLE}\"" + expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + + vaults_by_title_mock = respx_mock.get(expected_path_vault_title).mock( + return_value=Response(200, json=get_vaults())) + items_by_title_mock = respx_mock.get(expected_path_item_title).mock( + return_value=Response(200, json=get_items())) + item_mock = respx_mock.get(expected_path_item).mock(return_value=Response(200, json=expected_item)) + + item = await SS_CLIENT_ASYNC.get_item(ITEM_TITLE, VAULT_TITLE) + compare_items(expected_item, item) + assert vaults_by_title_mock.called + assert items_by_title_mock.called + assert item_mock.called + + +def test_get_items(respx_mock): expected_items = get_items() - expected_path = f"{HOST}/v1/vaults/{VAULT_ID}/items" + expected_path = f"/v1/vaults/{VAULT_ID}/items" - mock.return_value.ok = True - response = Response() - response.status_code = 200 - response._content = json.dumps(expected_items).encode("utf8") - mock.return_value = response + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_items)) items = SS_CLIENT.get_items(VAULT_ID) assert len(expected_items) == len(items) compare_summary_items(expected_items[0], items[0]) - mock.assert_called_with("GET", expected_path) + assert mock.called + + +@pytest.mark.asyncio +async def test_get_items_async(respx_mock): + expected_items = get_items() + expected_path = f"/v1/vaults/{VAULT_ID}/items" + + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_items)) + + items = await SS_CLIENT_ASYNC.get_items(VAULT_ID) + assert len(expected_items) == len(items) + compare_summary_items(expected_items[0], items[0]) + assert mock.called -@patch.object(Session, 'request') -def test_delete_item(mock): +def test_delete_item(respx_mock): expected_items = get_items() - expected_path = f"{HOST}/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + expected_path = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" - mock.return_value.ok = True - response = Response() - response.status_code = 200 - response._content = json.dumps(expected_items).encode("utf8") - mock.return_value = response + mock = respx_mock.delete(expected_path).mock(return_value=Response(200, json=expected_items)) SS_CLIENT.delete_item(ITEM_ID, VAULT_ID) - mock.assert_called_with("DELETE", expected_path) + assert mock.called + + +@pytest.mark.asyncio +async def test_delete_item_async(respx_mock): + expected_items = get_items() + expected_path = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + + mock = respx_mock.delete(expected_path).mock(return_value=Response(200, json=expected_items)) + + await SS_CLIENT_ASYNC.delete_item(ITEM_ID, VAULT_ID) + assert mock.called + -@patch.object(Session, 'request') -def test_create_item(mock): - mock.return_value.ok = True - mock.side_effect = create_item_side_effect +def test_create_item(respx_mock): + item = generate_full_item() + mock = respx_mock.post(f"/v1/vaults/{item.vault.id}/items").mock(return_value=Response(201, json=item.to_dict())) + + created_item = SS_CLIENT.create_item(item.vault.id, item) + assert mock.called + compare_full_items(item, created_item) + +@pytest.mark.asyncio +async def test_create_item_async(respx_mock): item = generate_full_item() + mock = respx_mock.post(f"/v1/vaults/{item.vault.id}/items").mock(return_value=Response(201, json=item.to_dict())) - created_item = SS_CLIENT.create_item(VAULT_ID, item) + created_item = await SS_CLIENT_ASYNC.create_item(item.vault.id, item) assert mock.called compare_full_items(item, created_item) -@patch.object(Session, 'request') -def test_update_item(mock): - mock.return_value.ok = True - mock.side_effect = create_item_side_effect +def test_update_item(respx_mock): + item = generate_full_item() + mock = respx_mock.put(f"/v1/vaults/{item.vault.id}/items/{item.id}").mock(return_value=Response(200, json=item.to_dict())) + + updated_item = SS_CLIENT.update_item(item.id, item.vault.id, item) + assert mock.called + compare_full_items(item, updated_item) + +@pytest.mark.asyncio +async def test_update_item_async(respx_mock): item = generate_full_item() + mock = respx_mock.put(f"/v1/vaults/{item.vault.id}/items/{item.id}").mock(return_value=Response(200, json=item.to_dict())) - updated_item = SS_CLIENT.update_item(ITEM_ID, VAULT_ID, item) + updated_item = await SS_CLIENT_ASYNC.update_item(item.id, item.vault.id, item) assert mock.called compare_full_items(item, updated_item) @@ -220,13 +291,6 @@ def compare_full_items(expected_item, returned_item): compare_full_item_fields(expected_item.fields[i], returned_item.fields[i]) -def create_item_side_effect(method, url, data): - response = Response() - response.status_code = 200 - response._content = data - return response - - def compare_full_item_fields(expected_field, returned_field): assert expected_field.id == returned_field.id assert expected_field.label == returned_field.label diff --git a/src/tests/test_client_vaults.py b/src/tests/test_client_vaults.py index 92f5979..9a3eab3 100644 --- a/src/tests/test_client_vaults.py +++ b/src/tests/test_client_vaults.py @@ -1,61 +1,82 @@ -import json -from requests import Session, Response -from unittest.mock import patch +import pytest +from httpx import Response from onepasswordconnectsdk import client VAULT_ID = "hfnjvi6aymbsnfc2xeeoheizda" VAULT_NAME = "VaultA" -HOST = "mock_host" +HOST = "https://mock_host" TOKEN = "jwt_token" SS_CLIENT = client.new_client(HOST, TOKEN) +SS_CLIENT_ASYNC = client.new_client(HOST, TOKEN, True) -@patch.object(Session, 'request') -def test_get_vaults(mock): +def test_get_vaults(respx_mock): expected_vaults = list_vaults() - expected_path = f"{HOST}/v1/vaults" + expected_path = "/v1/vaults" - mock.return_value.ok = True - response = Response() - response.status_code = 200 - response._content = json.dumps(expected_vaults).encode("utf8") - mock.return_value = response + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_vaults)) vaults = SS_CLIENT.get_vaults() compare_vaults(expected_vaults[0], vaults[0]) - mock.assert_called_with("GET", expected_path) + assert mock.called -@patch.object(Session, 'request') -def test_get_vault(mock): +@pytest.mark.asyncio +async def test_get_vaults_async(respx_mock): + expected_vaults = list_vaults() + expected_path = "/v1/vaults" + + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_vaults)) + + vaults = await SS_CLIENT_ASYNC.get_vaults() + compare_vaults(expected_vaults[0], vaults[0]) + assert mock.called + + +def test_get_vault(respx_mock): expected_vault = get_vault() - expected_path = f"{HOST}/v1/vaults/{VAULT_ID}" + expected_path = f"/v1/vaults/{VAULT_ID}" - mock.return_value.ok = True - response = Response() - response.status_code = 200 - response._content = json.dumps(expected_vault).encode("utf8") - mock.return_value = response + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_vault)) vault = SS_CLIENT.get_vault(VAULT_ID) compare_vaults(expected_vault, vault) - mock.assert_called_with("GET", expected_path) + assert mock.called + + +@pytest.mark.asyncio +async def test_get_vault_async(respx_mock): + expected_vault = get_vault() + expected_path = f"/v1/vaults/{VAULT_ID}" + + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_vault)) + + vault = await SS_CLIENT_ASYNC.get_vault(VAULT_ID) + compare_vaults(expected_vault, vault) + assert mock.called -@patch.object(Session, 'request') -def test_get_vault_by_title(mock): +def test_get_vault_by_title(respx_mock): expected_vaults = list_vaults() - expected_path = f"{HOST}/v1/vaults?filter=name eq \"{VAULT_NAME}\"" + expected_path = f"/v1/vaults?filter=name eq \"{VAULT_NAME}\"" - mock.return_value.ok = True - response = Response() - response.status_code = 200 - response._content = json.dumps(expected_vaults).encode("utf8") - mock.return_value = response + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_vaults)) vault = SS_CLIENT.get_vault_by_title(VAULT_NAME) compare_vaults(expected_vaults[0], vault) - mock.assert_called_with("GET", expected_path) + assert mock.called + + +@pytest.mark.asyncio +async def test_get_vault_by_title_async(respx_mock): + expected_vaults = list_vaults() + expected_path = f"/v1/vaults?filter=name eq \"{VAULT_NAME}\"" + + mock = respx_mock.get(expected_path).mock(return_value=Response(200, json=expected_vaults)) + + vault = await SS_CLIENT_ASYNC.get_vault_by_title(VAULT_NAME) + compare_vaults(expected_vaults[0], vault) + assert mock.called def list_vaults(): diff --git a/src/tests/test_config.py b/src/tests/test_config.py index 2c15f13..551cbcf 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -1,13 +1,13 @@ -import json -from requests import Session, Response -from unittest.mock import patch +from httpx import Response import onepasswordconnectsdk from onepasswordconnectsdk import client VAULT_ID = "abcdefghijklmnopqrstuvwxyz" ITEM_NAME1 = "TEST USER" +ITEM_ID1 = "wepiqdxdzncjtnvmv5fegud4q1" ITEM_NAME2 = "Another User" -HOST = "mock_host" +ITEM_ID2 = "wepiqdxdzncjtnvmv5fegud4q2" +HOST = "https://mock_host" TOKEN = "jwt_token" SS_CLIENT = client.new_client(HOST, TOKEN) @@ -25,20 +25,29 @@ class Config: CONFIG_CLASS = Config() -@patch.object(Session, 'request') -def test_load(mock): - mock.return_value.ok = True - mock.side_effect = get_item_side_effect +def test_load(respx_mock): + mock_items_list1 = respx_mock.get(f"v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_NAME1}\"").mock( + return_value=Response(200, json=[item]) + ) + mock_item1 = respx_mock.get(f"v1/vaults/{VAULT_ID}/items/{ITEM_ID1}").mock(return_value=Response(200, json=item)) + mock_items_list2 = respx_mock.get(f"v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_NAME2}\"").mock( + return_value=Response(200, json=[item2]) + ) + mock_item2 = respx_mock.get(f"v1/vaults/{VAULT_ID}/items/{ITEM_ID2}").mock(return_value=Response(200, json=item2)) config_with_values = onepasswordconnectsdk.load(SS_CLIENT, CONFIG_CLASS) - assert mock.called + + assert mock_items_list1.called + assert mock_item1.called + assert mock_items_list2.called + assert mock_item2.called + assert config_with_values.username == USERNAME_VALUE assert config_with_values.password == PASSWORD_VALUE assert config_with_values.host == HOST_VALUE -@patch.object(Session, 'request') -def test_load_dict(mock): +def test_load_dict(respx_mock): config_dict = { "username": { "opitem": ITEM_NAME1, @@ -51,76 +60,61 @@ def test_load_dict(mock): "opvault": VAULT_ID } } - mock.return_value.ok = True - mock.side_effect = get_item_side_effect + + mock_item_list = respx_mock.get(f"v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_NAME1}\"").mock( + return_value=Response(200, json=[item])) + mock_item = respx_mock.get(f"v1/vaults/{VAULT_ID}/items/{ITEM_ID1}").mock(return_value=Response(200, json=item)) config_with_values = onepasswordconnectsdk.load_dict(SS_CLIENT, config_dict) - assert mock.called + + assert mock_item_list.called + assert mock_item.called assert config_with_values['username'] == USERNAME_VALUE assert config_with_values['password'] == PASSWORD_VALUE -def get_item_side_effect(method, url): - response = Response() - response.status_code = 200 - - item = { - "id": ITEM_NAME1, - "title": ITEM_NAME1, - "vault": { - "id": VAULT_ID - }, - "category": "LOGIN", - "sections": [ - { - "id": "section1", - "label": "section1" - } - ], - "fields": [ - { - "id": "password", - "label": "password", - "value": PASSWORD_VALUE, - "section": { - "id": "section1" - } - }, - { - "id": "716C5B0E95A84092B2FE2CC402E0DDDF", - "label": "username", - "value": USERNAME_VALUE +item = { + "id": ITEM_ID1, + "title": ITEM_NAME1, + "vault": { + "id": VAULT_ID + }, + "category": "LOGIN", + "sections": [ + { + "id": "section1", + "label": "section1" + } + ], + "fields": [ + { + "id": "password", + "label": "password", + "value": PASSWORD_VALUE, + "section": { + "id": "section1" } - ] - } - - item2 = { - "id": ITEM_NAME2, - "title": ITEM_NAME2, - "vault": { - "id": VAULT_ID }, - "category": "LOGIN", - "fields": [ - { - "id": "716C5B0E95A84092B2FE2CC402E0DDDF", - "label": "host", - "value": HOST_VALUE - } - ] - } - - if ITEM_NAME1 in url: - if "eq" in url: - item = [item] - else: - item = item - elif ITEM_NAME2 in url: - if "eq" in url: - item = [item2] - else: - item = item2 - - response._content = str.encode(json.dumps(item)) - - return response + { + "id": "username", + "label": "username", + "value": USERNAME_VALUE + } + ] +} + +item2 = { + "id": ITEM_ID2, + "title": ITEM_NAME2, + "vault": { + "id": VAULT_ID + }, + "category": "LOGIN", + "fields": [ + { + "id": "716C5B0E95A84092B2FE2CC402E0DDDF", + "label": "host", + "value": HOST_VALUE + } + ] +} diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py new file mode 100644 index 0000000..2ed0374 --- /dev/null +++ b/src/tests/test_utils.py @@ -0,0 +1,45 @@ +from onepasswordconnectsdk.utils import PathBuilder + +VAULT_ID = "hfnjvi6aymbsnfc2xeeoheizda" +ITEM_ID = "wepiqdxdzncjtnvmv5fegud4qy" +FILE_ID = "fileqdxczsc2tn32vsfegud123" + + +def test_all_vaults_path(): + path = PathBuilder().vaults().build() + assert path == "/v1/vaults" + + +def test_single_vault_path(): + path = PathBuilder().vaults(VAULT_ID).build() + assert path == f"/v1/vaults/{VAULT_ID}" + + +def test_all_items_path(): + path = PathBuilder().vaults(VAULT_ID).items().build() + assert path == f"/v1/vaults/{VAULT_ID}/items" + + +def test_filter_items_path(): + path = PathBuilder().vaults(VAULT_ID).items().query("filter", "title").build() + assert path == f"/v1/vaults/{VAULT_ID}/items?filter=title" + + +def test_single_item_path(): + path = PathBuilder().vaults(VAULT_ID).items(ITEM_ID).build() + assert path == f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}" + + +def test_all_files_path(): + path = PathBuilder().vaults(VAULT_ID).items(ITEM_ID).files().build() + assert path == f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}/files" + + +def test_single_file_path(): + path = PathBuilder().vaults(VAULT_ID).items(ITEM_ID).files(FILE_ID).build() + assert path == f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}/files/{FILE_ID}" + + +def test_file_conten_path(): + path = PathBuilder().vaults(VAULT_ID).items(ITEM_ID).files(FILE_ID).content().build() + assert path == f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}/files/{FILE_ID}/content"