diff --git a/.github/workflows/pr-check-signed-commits.yml b/.github/workflows/pr-check-signed-commits.yml
new file mode 100644
index 0000000..584bac2
--- /dev/null
+++ b/.github/workflows/pr-check-signed-commits.yml
@@ -0,0 +1,15 @@
+name: Check signed commits in PR
+
+on: pull_request_target
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ build:
+ name: Check signed commits in PR
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check signed commits in PR
+ uses: 1Password/check-signed-commits-action@v1
diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml
index e4340c6..f0125c9 100644
--- a/.github/workflows/release-pr.yml
+++ b/.github/workflows/release-pr.yml
@@ -1,8 +1,12 @@
+name: Open Release PR for review
+
on:
create:
branches:
-name: Open Release PR for review
+permissions:
+ contents: read
+ pull-requests: write
jobs:
# This job is necessary because GitHub does not (yet) support
@@ -16,7 +20,7 @@ jobs:
steps:
- id: is_release_branch_without_pr
name: Find matching PR
- uses: actions/github-script@v3
+ uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -27,7 +31,7 @@ jobs:
if(!releaseBranchName) { return false }
- const {data: prs} = await github.pulls.list({
+ const {data: prs} = await github.rest.pulls.list({
...context.repo,
state: 'open',
head: `1Password:${releaseBranchName}`,
@@ -42,11 +46,11 @@ jobs:
name: Create Release Pull Request
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v5
- name: Parse release version
id: get_version
- run: echo "::set-output name=version::$(echo $GITHUB_REF | sed 's|^refs/heads/release/v?*||g')"
+ run: echo "::set-output name=version::`echo "${GITHUB_REF}" | sed 's|^refs/heads/release/v?*||g'`"
- name: Prepare Pull Request
id: prep_pr
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ed5978a..bfe2942 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,34 +5,39 @@ on:
branches: main
types: closed
+permissions:
+ contents: write
+
jobs:
release:
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true && contains(github.event.pull_request.head.ref, 'release/')
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Python
- uses: actions/setup-python@v1
+ uses: actions/setup-python@v6
with:
- python-version: 3.8
+ python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install poetry
poetry install
- name: Build the package
- run: poetry build --format sdist
+ run: poetry build
- name: Publish to PyPi
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
- name: Create Release tag
id: create-tag
+ env:
+ PR_REF: ${{ github.event.pull_request.head.ref }}
run: |
- release_tag=$(echo ${{ github.event.pull_request.head.ref }} | cut -d "/" -f2)
+ release_tag=$(echo "$PR_REF" | cut -d "/" -f2)
echo "::set-output name=release-tag::$release_tag"
- name: Make new release
uses: ncipollo/release-action@v1
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8a396fb..308e584 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,10 +1,12 @@
name: Tests
+
+on:
+ push:
+ branches: main
+ pull_request:
+
permissions:
contents: read
-on:
- push:
- branches: main
- pull_request:
jobs:
test:
@@ -12,11 +14,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Setup Python
- uses: actions/setup-python@v1
+ uses: actions/setup-python@v6
with:
- python-version: 3.8
+ python-version: '3.10'
- name: Check out code
- uses: actions/checkout@v2
+ uses: actions/checkout@v5
- name: Install dependencies
run: |
python -m pip install --upgrade pip
@@ -25,9 +27,3 @@ jobs:
- name: Test
run: |
poetry run pytest src/tests --cov=./ --cov-report=xml
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@v2
- with:
- fail_ci_if_error: true
- files: ./coverage.xml
- token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 228ac15..4269893 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,18 +1,105 @@
-[//]: # "START/LATEST"
-
+[//]: # (START/LATEST)
# Latest
## Features
+ * A user-friendly description of a new feature. {issue-number}
+
+## Fixes
+ * A user-friendly description of a fix. {issue-number}
+
+## Security
+ * A user-friendly description of a security fix. {issue-number}
+
+---
-- A user-friendly description of a new feature. {issue-number}
+[//]: # (START/v2.1.0)
+# v2.1.0
+
+## Features
+ * Update `new_client_from_environment()` to support optional token parameter, matching URL parameter behavior. {#88}
## Fixes
+ * Fix `get_item()` to successfully retrieve items with 26-character titles by adding fallback to title search when ID lookup fails. {#80}
+ * Updated Slack developer link in documentation. {#130}
+
+---
-- A user-friendly description of a fix. {issue-number}
+[//]: # (START/v2.0.0)
+# v2.0.0
-## Security
+## 🔴 Breaking changes
+* Requires Python 3.10 or newer. {#125}
+
+## 🚀 Features
+ * Added ConfigClass for HTTPX to simplify configuration. {#118}
+
+## 🔧 Fixes
+ * Updated to HTTPX v0.28.1. {#121}
+ * Bumped certifi from 2023.7.22 to 2024.7.4. {#114}
+ * Bumped idna from 3.4 to 3.7. {#107}
+
+---
+
+[//]: # (START/v1.5.1)
+# v1.5.1
+
+## Fixes
+ * Fix default http client timeout. {#102}
+ * Update override http client timeout env var name in readme. {#105}
+
+---
+
+[//]: # (START/v1.5.0)
+# v1.5.0
+
+## Features
+ * Allow custom timeout using env vars. {#94}
+
+---
+
+[//]: # (START/v1.4.1)
+# v1.4.1
+
+Credits to @mjpieters for the contribution! :rocket:
+
+## Fixes
+ * Undeclared dependency: six (in generated code). {#85}
+ * Leave JSON encoding to HTTPX. {#87}
+ * Correct function name in documentation. {#90}
+ * Update example main.py to handle 'n' input. {#91}
+
+---
+
+[//]: # "START/v1.4.0"
+
+# v1.4.0
+
+## Features
+
+- Support async operations `(async/await)`. {#62}
+- Enable filter usage on `get_items`. Credits to @ITJamie for the contribution! {#76}
+
+## Fixes
+
+- Drop support for python 2. {#61}
+- 'download_file' function uses content path now {#65}
+- Enhance README and move usage content to USAGE.md. {#74}
+- Fix README typos. Credits to @ITJamie for the contribution! {#75}
+
+---
+
+[//]: # "START/v1.3.0"
+
+# v1.3.0
+
+## Features
+
+- The TOTP code of a OTP field can now be accessed using the `.totp` property of a field. {#33}
+
+## Fixes
-- A user-friendly description of a security fix. {issue-number}
+- Sections without a label can now be correctly accessed. {#49}
+- Retrieving an item no longer returns "Invalid value for `type`" or "Invalid value for `category`" when retrieving an item with a field type or item category that is not defined in the SDK. {#52,#54}
---
diff --git a/README.md b/README.md
index 4d2f888..ebc10d1 100644
--- a/README.md
+++ b/README.md
@@ -1,235 +1,100 @@
-# 1Password Connect Python SDK
+
+
-[](https://www.python.org)
-[](https://badge.fury.io/py/onepasswordconnectsdk)
-
-[](https://en.wikipedia.org/wiki/MIT_License)
-[](https://codecov.io/gh/1Password/connect-sdk-python)
+
+
1Password Connect SDK for Python
+
Access your 1Password items in your Python applications through your self-hosted 1Password Connect server.
+
+
+
+
-The 1Password Connect SDK provides access to 1Password via [1Password Connect](https://support.1password.com/secrets-automation/) hosted in your infrastructure. The library is intended to be used by Python applications to simplify accessing items in 1Password vaults.
+---
-## Prerequisites
+The 1Password Connect SDK provides access to 1Password via [1Password Connect](https://developer.1password.com/docs/connect) hosted in your infrastructure. The library is intended to be used by Python applications to simplify accessing items in 1Password vaults.
-- [1Password Connect](https://support.1password.com/secrets-automation/#step-2-deploy-a-1password-connect-server) deployed in your infrastructure
-## Installation
+*This project is licensed under [MIT](./LICENSE.md). Use of the 1Password APIs and services accessed through these tools is governed by the [1Password API Terms of Service](https://1password.com/legal/api-sdk-terms-of-service).*
-To install the 1Password Connect Python SDK:
-```bash
-$ pip install onepasswordconnectsdk
-```
+## 🪄 See it in action
-To install a specific release of the 1Password Connect Python SDK:
-```bash
-$ pip install onepasswordconnectsdk==1.0.1
-```
+Check the [Python Connect SDK Example](example/README.md) to see an example of item manipulation using the SDK that you can execute on your machine.
-## Usage
+## ✨ Get started
-**Import 1Password Connect Python SDK**
+1. Install the 1Password Connect Python SDK:
-```python
-import onepasswordconnectsdk
-```
+ ```sh
+ pip install onepasswordconnectsdk
+ ```
-**Environment Variables**
+2. Export the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` environment variables:
-- **OP_CONNECT_TOKEN** – The token to be used to authenticate with the 1Password Connect API.
-- **OP_CONNECT_HOST** - The hostname of the 1Password Connect API.
- Possible values include:
- - `http(s)://connect-api:8080` if the Connect server is running in the same Kubernetes cluster as your application.
- - `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.
+ ```sh
+ export OP_CONNECT_HOST= && \
+ export OP_CONNECT_TOKEN=
+ ```
-**Create a Client**
+ 2.1 If you need a higher timeout on the client requests you can export `OP_CONNECT_CLIENT_REQ_TIMEOUT` environment variable:
-There are two methods available for creating a client:
+ ```sh
+ # set the timeout to 90 seconds
+ export OP_CONNECT_CLIENT_REQ_TIMEOUT=90
+ ```
-- `new_client_from_environment`: Builds a new client for interacting with 1Password Connect using the `OP_CONNECT_TOKEN` and `OP_CONNECT_HOST` *environment variables*.
-- `new_client`: Builds a new client for interacting with 1Password Connect. Accepts the hostname of 1Password Connect and the API token generated for the application.
+3. Use the SDK:
-```python
-from onepasswordconnectsdk.client import (
- Client,
- new_client_from_environment,
- new_client
-)
+ - Read a secret:
-# creating client using OP_CONNECT_TOKEN and OP_CONNECT_HOST environment variables
-client_from_env: Client = new_client_from_environment()
+ ```python
+ from onepasswordconnectsdk.client import (
+ Client,
+ new_client_from_environment,
+ )
-# creates a client by supplying hostname and 1Password Connect API token
-client_from_token: Client = new_client(
- "{1Password_Connect_Host}",
- "{1Password_Connect_API_Token}")
-```
+ connect_client: Client = new_client_from_environment()
-**Get Item**
+ client.get_item("{item_id}", "{vault_id}")
+ ```
-Get a specific item by item and vault ids:
+ - Write a secret:
-```python
-client.get_item("{item_id}", "{vault_id}")
-```
+ ```python
+ from onepasswordconnectsdk.client import (
+ Client,
+ new_client_from_environment,
+ }
-**Get Item By Title**
+ from onepasswordconnectsdk.models import (
+ Item,
+ ItemVault,
+ Field
+ )
-Get a specific item by item title and vault id:
+ connect_client: Client = new_client_from_environment()
-```python
-client.get_item_by_title("{item_title}", "{vault_id}")
-```
+ # Example item creation. Create an item with your desired arguments.
+ item = Item(
+ vault=ItemVault(id=op_vault),
+ id="custom_id",
+ title="newtitle",
+ category="LOGIN",
+ tags=["1password-connect"],
+ fields=[Field(value="new_user", purpose="USERNAME")],
+ )
-**Get All Items**
+ new_item = connect_client.create_item(op_vault, item)
+ ```
-Get a summarized list of all items for a specified vault:
+For more examples of how to use the SDK, check out [USAGE.md](USAGE.md).
-```python
-client.get_items("{vault_id}")
-```
+## 💙 Community & Support
-**Delete Item**
+- File an [issue](https://github.com/1Password/connect-sdk-python/issues) for bugs and feature requests.
+- Join the [Developer Slack workspace](https://developer.1password.com/joinslack).
+- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
-Delete an item by item and vault ids:
+## 🔐 Security
-```python
-client.delete_item("{item_id}", "{vault_id}")
-```
+1Password requests you practice responsible disclosure if you discover a vulnerability.
-**Create Item**
-
-Create an item in a specified vault:
-
-```python
-from onepasswordconnectsdk.models import (ItemVault, Field)
-
-# Example item creation. Create an item with your desired arguments.
-item = onepasswordconnectsdk.models.Item(vault=ItemVault(id="av223f76ydutdngislnkbz6z5u"),
- id="kp2td65r4wbuhocwhhijpdbfqq",
- title="newtitle",
- category="LOGIN",
- tags=["1password-connect"],
- fields=[Field(value="new_user",
- purpose="USERNAME")],
- )
-client.create_item("{vault_id}", item)
-```
-
-**Update Item**
-
-Update the item identified by the specified item and vault ids. The existing item will be overwritten with the newly supplied item.
-
-```python
-from onepasswordconnectsdk.models import (ItemVault, Field)
-
-# Example item creation. Create an item with your desired arguments.
-item = onepasswordconnectsdk.models.Item(vault=ItemVault(id="av223f76ydutdngislnkbz6z5u"),
- id="kp2td65r4wbuhocwhhijpdbfqq",
- title="newtitle",
- category="LOGIN",
- tags=["1password-connect"],
- fields=[Field(value="new_user",
- purpose="USERNAME")],
- )
-client.update_item("{item_id}", "{vault_id}", item)
-```
-
-**Get Specific Vault**
-
-Get a vault by vault id:
-
-```python
-client.get_vault("{vault_id}")
-```
-
-**Get Vaults**
-
-Retrieve all vaults available to the service account:
-
-```python
-client.get_vaults()
-```
-
-**List Files**
-List summary information on all files stored in a given item, including file ids.
-
-```python
-client.get_files("{item_id}", "{vault_id}")
-```
-
-**Get File Details**
-
-Get details on a specific file.
-
-```python
-client.get_file("{file_id}", "{item_id}", "{vault_id}")
-```
-
-**Download File**
-
-Returns the contents of a given file.
-
-```python
-client.download_file("{file_id}", "{item_id}", "{vault_id}", "{content_path}")
-```
-
-**Load Configuration**
-
-Users can create `classes` or `dicts` that describe fields they wish to get the values from in 1Password. Two convienience methods are provided that will handle the fetching of values for these fields:
-
-- **load_dict**: Takes a dictionary with keys specifying the user desired naming scheme of the values to return. Each key's value is a dictionary that includes information on where to find the item field value in 1Password. This returns a dictionary of user specified keys with values retrieved from 1Password
-- **load**: Takes an object with class attributes annotated with tags describing where to find desired fields in 1Password. Manipulates given object and fills attributes in with 1Password item field values.
-
-```python
-# example dict configuration for onepasswordconnectsdk.load_dict(client, CONFIG)
-CONFIG = {
- "server": {
- "opitem": "My database item",
- "opfield": "specific_section.hostname",
- "opvault": "some_vault_id",
- },
- "database": {
- "opitem": "My database item",
- "opfield": ".database",
- },
- "username": {
- "opitem": "My database item",
- "opfield": ".username",
- },
- "password": {
- "opitem": "My database item",
- "opfield": ".password",
- },
-}
-
-values_dict = onepasswordconnectsdk.load_dict(client, CONFIG)
-```
-
-```python
-# example class configuration for onepasswordconnectsdk.load(client, CONFIG)
-class Config:
- server: 'opitem:"My database item" opvault:some_vault_id opfield:specific_section.hostname' = None
- database: 'opitem:"My database item" opfield:.database' = None
- username: 'opitem:"My database item" opfield:.username' = None
- password: 'opitem:"My database item" opfield:.password' = None
-
-CONFIG = Config()
-
-values_object = onepasswordconnectsdk.load(client, CONFIG)
-```
-
-## Development
-
-**Testing**
-
-```bash
-make test
-```
-
-## Security
-
-1Password requests you practice responsible disclosure if you discover a vulnerability.
-
-Please file requests via [**BugCrowd**](https://bugcrowd.com/agilebits).
-
-For information about security practices, please visit our [Security homepage](https://bugcrowd.com/agilebits).
+Please file requests by sending an email to bugbounty@agilebits.com.
diff --git a/USAGE.md b/USAGE.md
new file mode 100644
index 0000000..c078151
--- /dev/null
+++ b/USAGE.md
@@ -0,0 +1,236 @@
+# Usage
+
+## Creating a Connect API Client
+
+There are two methods available for creating a client:
+
+- `new_client_from_environment`: Builds a new client for interacting with 1Password Connect using the `OP_CONNECT_TOKEN` and `OP_CONNECT_HOST` environment variables.
+- `new_client`: Builds a new client for interacting with 1Password Connect. Accepts the hostname of 1Password Connect and the API token generated for the application.
+
+```python
+from onepasswordconnectsdk.client import (
+ Client,
+ new_client_from_environment,
+ new_client
+)
+
+# creating client using OP_CONNECT_TOKEN and OP_CONNECT_HOST environment variables
+connect_client_from_env: Client = new_client_from_environment()
+
+# creates a client by supplying hostname and 1Password Connect API token
+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)
+```
+
+## Client Configuration
+
+The SDK provides a `ClientConfig` class that allows you to configure the underlying httpx client. This includes SSL certificate verification and all other httpx client options.
+
+### SSL Certificate Verification
+
+When connecting to a 1Password Connect server using HTTPS, you may need to configure SSL certificate verification:
+
+```python
+from onepasswordconnectsdk.config import ClientConfig
+
+# Verify SSL using a custom CA certificate
+config = ClientConfig(ca_file="path/to/ca.pem")
+client = new_client("https://connect.example.com", "your-token", config=config)
+
+# Disable SSL verification (not recommended for production)
+config = ClientConfig(verify=False)
+client = new_client("https://connect.example.com", "your-token", config=config)
+```
+
+### Additional Configuration Options
+
+The ClientConfig class accepts all httpx client options as keyword arguments. These options are passed directly to the underlying httpx client:
+
+```python
+# Configure timeouts and redirects
+config = ClientConfig(
+ ca_file="path/to/ca.pem",
+ timeout=30.0, # 30 second timeout
+ follow_redirects=True, # Follow HTTP redirects
+ max_redirects=5 # Maximum number of redirects to follow
+)
+
+# Configure proxy settings
+config = ClientConfig(
+ proxies={
+ "http://": "http://proxy.example.com",
+ "https://": "https://proxy.example.com"
+ }
+)
+
+# Configure custom headers
+config = ClientConfig(
+ headers={
+ "User-Agent": "CustomApp/1.0",
+ "X-Custom-Header": "value"
+ }
+)
+```
+
+### Async Client Configuration
+
+The same configuration options work for both synchronous and asynchronous clients:
+
+```python
+config = ClientConfig(
+ ca_file="path/to/ca.pem",
+ timeout=30.0
+)
+async_client = new_client("https://connect.example.com", "your-token", is_async=True, config=config)
+```
+
+For a complete list of available configuration options, see the [httpx client documentation](https://www.python-httpx.org/api/#client).
+
+## Environment Variables
+
+- **OP_CONNECT_TOKEN** – The token to be used to authenticate with the 1Password Connect API.
+- **OP_CONNECT_HOST** - The hostname of the 1Password Connect API.
+ Possible values include:
+ - `http(s)://connect-api:8080` if the Connect server is running in the same Kubernetes cluster as your application.
+ - `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
+
+```python
+# Get a list of all vaults
+vaults = connect_client.get_vaults()
+
+# Get a specific vault
+vault = connect_client.get_vault("{vault_id}")
+vault_by_title = connect_client.get_vault_by_title("{vault_title}")
+```
+
+## Working with Items
+
+```python
+from onepasswordconnectsdk.models import (Item, ItemVault, Field)
+
+vault_id = "{vault_id}"
+
+# Get a list of all items in a vault
+items = connect_client.get_items("{vault_id}")
+
+# Create an item
+new_item = Item(
+ title="Example Login Item",
+ category="LOGIN",
+ tags=["1password-connect"],
+ fields=[Field(value="new_user", purpose="USERNAME")],
+)
+
+created_item = connect_client.create_item(vault_id, new_item)
+
+# Get an item
+item = connect_client.get_item("{item_id}", vault_id)
+item_by_title = connect_client.get_item_by_title("{item_title}", vault_id)
+
+# Update an item
+created_item.title = "New Item Title"
+updated_item = connect_client.update_item(created_item.id, vault_id, created_item)
+
+# Delete an item
+connect_client.delete_item(updated_item.id, vault_id)
+```
+
+### Working with Items that contain files
+
+```python
+item_id = "{item_id}"
+vault_id = "{vault_id}"
+
+# Get summary information on all files stored in a given item
+files = connect_client.get_files(item_id, vault_id)
+
+# Get a file's contents
+file = connect_client.get_file_content(files[0].id, item_id, vault_id)
+
+# Download a file's contents
+connect_client.download_file(files[1].id, item_id, vault_id, "local/path/to/file")
+```
+
+## Load Configuration
+
+Users can create `classes` or `dicts` that describe fields they wish to get the values from in 1Password. Two convenience methods are provided that will handle the fetching of values for these fields:
+
+- **load_dict**: Takes a dictionary with keys specifying the user desired naming scheme of the values to return. Each key's value is a dictionary that includes information on where to find the item field value in 1Password. This returns a dictionary of user specified keys with values retrieved from 1Password.
+- **load**: Takes an object with class attributes annotated with tags describing where to find desired fields in 1Password. Manipulates given object and fills attributes in with 1Password item field values.
+
+```python
+# example dict configuration for onepasswordconnectsdk.load_dict(connect_client, CONFIG)
+CONFIG = {
+ "server": {
+ "opitem": "My database item",
+ "opfield": "specific_section.hostname",
+ "opvault": "some_vault_id",
+ },
+ "database": {
+ "opitem": "My database item",
+ "opfield": ".database",
+ "opvault": "some_vault_id",
+ },
+ "username": {
+ "opitem": "My database item",
+ "opfield": ".username",
+ "opvault": "some_vault_id",
+ },
+ "password": {
+ "opitem": "My database item",
+ "opfield": ".password",
+ "opvault": "some_vault_id",
+ },
+}
+
+values_dict = onepasswordconnectsdk.load_dict(connect_client, CONFIG)
+```
+
+```python
+# example class configuration for onepasswordconnectsdk.load(connect_client, CONFIG)
+class Config:
+ server: 'opitem:"My database item" opvault:some_vault_id opfield:specific_section.hostname' = None
+ database: 'opitem:"My database item" opfield:.database' = None
+ username: 'opitem:"My database item" opfield:.username' = None
+ password: 'opitem:"My database item" opfield:.password' = None
+
+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())
+```
diff --git a/example/ca_file_example/list_secrets.py b/example/ca_file_example/list_secrets.py
new file mode 100644
index 0000000..a10243c
--- /dev/null
+++ b/example/ca_file_example/list_secrets.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""
+Example script demonstrating how to connect to a 1Password Connect server
+using CA certificate verification and list all secrets in a vault.
+
+Shows both synchronous and asynchronous usage.
+Update the configuration variables below with your values.
+"""
+
+import asyncio
+from onepasswordconnectsdk.client import new_client
+from onepasswordconnectsdk.config import ClientConfig
+
+# Configuration
+CONNECT_URL = "https://connect.example.com" # Your 1Password Connect server URL
+TOKEN = "eyJhbGc..." # Your 1Password Connect token
+VAULT_ID = "vaults_abc123" # ID of the vault to list secrets from
+CA_FILE = "path/to/ca.pem" # Path to your CA certificate file
+
+def list_vault_secrets():
+ """
+ Connect to 1Password Connect server and list all secrets in the specified vault.
+ Uses CA certificate verification for secure connection.
+ """
+ try:
+ # Configure client with CA certificate verification
+ config = ClientConfig(
+ ca_file=CA_FILE,
+ timeout=30.0 # 30 second timeout
+ )
+
+ # Initialize client with configuration
+ client = new_client(CONNECT_URL, TOKEN, config=config)
+
+ # Get all items in the vault
+ items = client.get_items(VAULT_ID)
+
+ # Print items
+ print(f"\nSecrets in vault {VAULT_ID}:")
+ print("-" * 40)
+ for item in items:
+ print(f"- {item.title} ({item.category})")
+
+ except Exception as e:
+ print(f"Error: {str(e)}")
+
+
+async def list_vault_secrets_async():
+ """
+ Async version: Connect to 1Password Connect server and list all secrets in the specified vault.
+ Uses CA certificate verification for secure connection.
+ """
+ try:
+ # Configure client with CA certificate verification
+ config = ClientConfig(
+ ca_file=CA_FILE,
+ timeout=30.0 # 30 second timeout
+ )
+
+ # Initialize async client with configuration
+ client = new_client(CONNECT_URL, TOKEN, is_async=True, config=config)
+
+ # Get all items in the vault
+ items = await client.get_items(VAULT_ID)
+
+ # Print items
+ print(f"\nSecrets in vault {VAULT_ID} (async):")
+ print("-" * 40)
+ for item in items:
+ print(f"- {item.title} ({item.category})")
+
+ # Close the client gracefully
+ await client.session.aclose()
+
+ except Exception as e:
+ print(f"Error: {str(e)}")
+
+if __name__ == "__main__":
+ # Run sync version
+ print("Running synchronous example...")
+ list_vault_secrets()
+
+ # Run async version
+ print("\nRunning asynchronous example...")
+ asyncio.run(list_vault_secrets_async())
diff --git a/example/main.py b/example/main.py
index 1bc64dd..3619150 100644
--- a/example/main.py
+++ b/example/main.py
@@ -3,7 +3,7 @@
import time
import onepasswordconnectsdk
-from onepasswordconnectsdk.models import (ItemVault, Field)
+from onepasswordconnectsdk.models import Field, GeneratorRecipe, Item
op_connect_token = os.environ["OP_CONNECT_TOKEN"]
default_vault = os.environ["OP_VAULT"]
@@ -17,11 +17,21 @@
print(steps.steps["step1"])
# CREATE A NEW ITEM
-username_item = onepasswordconnectsdk.models.Item(
+username_item = Item(
title="Secret String",
category="LOGIN",
tags=["1password-connect"],
- fields=[Field(value=secret_string)])
+ fields=[
+ Field(
+ value=secret_string
+ ),
+ Field(
+ purpose="PASSWORD",
+ generate=True,
+ recipe=GeneratorRecipe(length=10, character_sets=['LETTERS'])
+ )
+ ]
+)
print(steps.steps["step2"])
# ADD THE ITEM TO THE 1P VAULT
@@ -38,12 +48,12 @@
print(steps.steps["confirmation"])
answer = input()
-while answer != ('y' or 'n'):
+while answer.lower() not in {'y', 'n'}:
print(steps.steps["confirmation2"])
answer = input()
# DELETE THE ITEM FROM THE VAULT
-if answer == 'y':
+if answer.lower() == 'y':
client.delete_item(posted_item.id, default_vault)
print(steps.steps["step5"])
diff --git a/poetry.lock b/poetry.lock
index d5dcdda..405dfec 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,389 +1,491 @@
-[[package]]
-name = "atomicwrites"
-version = "1.4.0"
-description = "Atomic file writes."
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
-name = "attrs"
-version = "20.3.0"
-description = "Classes Without Boilerplate"
-category = "dev"
+name = "anyio"
+version = "4.10.0"
+description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.9"
+groups = ["main", "dev"]
+files = [
+ {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"},
+ {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"},
+]
+
+[package.dependencies]
+exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
+idna = ">=2.8"
+sniffio = ">=1.1"
+typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"]
-docs = ["furo", "sphinx", "zope.interface"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
+trio = ["trio (>=0.26.1)"]
[[package]]
-name = "certifi"
-version = "2020.11.8"
-description = "Python package for providing Mozilla's CA Bundle."
-category = "main"
+name = "backports-asyncio-runner"
+version = "1.2.0"
+description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle."
optional = false
-python-versions = "*"
+python-versions = "<3.11,>=3.8"
+groups = ["dev"]
+markers = "python_version == \"3.10\""
+files = [
+ {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"},
+ {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"},
+]
[[package]]
-name = "chardet"
-version = "3.0.4"
-description = "Universal encoding detector for Python 2 and 3"
-category = "main"
+name = "certifi"
+version = "2025.8.3"
+description = "Python package for providing Mozilla's CA Bundle."
optional = false
-python-versions = "*"
+python-versions = ">=3.7"
+groups = ["main", "dev"]
+files = [
+ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
+ {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
+]
[[package]]
name = "colorama"
-version = "0.4.4"
+version = "0.4.6"
description = "Cross-platform colored terminal text."
-category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
+markers = "sys_platform == \"win32\""
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
[[package]]
name = "coverage"
-version = "6.1.1"
+version = "7.10.6"
description = "Code coverage measurement for Python"
-category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
+ {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
+ {file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"},
+ {file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"},
+ {file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"},
+ {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"},
+ {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"},
+ {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"},
+ {file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"},
+ {file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"},
+ {file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"},
+ {file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"},
+ {file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"},
+ {file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"},
+ {file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"},
+ {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"},
+ {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"},
+ {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"},
+ {file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"},
+ {file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"},
+ {file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"},
+ {file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"},
+ {file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"},
+ {file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"},
+ {file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"},
+ {file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"},
+ {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"},
+ {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"},
+ {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"},
+ {file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"},
+ {file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"},
+ {file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"},
+ {file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"},
+ {file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"},
+ {file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"},
+ {file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"},
+ {file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"},
+ {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"},
+ {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"},
+ {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"},
+ {file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"},
+ {file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"},
+ {file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"},
+ {file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"},
+ {file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"},
+ {file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"},
+ {file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"},
+ {file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"},
+ {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"},
+ {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"},
+ {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"},
+ {file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"},
+ {file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"},
+ {file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"},
+ {file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"},
+ {file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"},
+ {file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"},
+ {file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"},
+ {file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"},
+ {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"},
+ {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"},
+ {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"},
+ {file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"},
+ {file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"},
+ {file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"},
+ {file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"},
+ {file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"},
+ {file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"},
+ {file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"},
+ {file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"},
+ {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"},
+ {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"},
+ {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"},
+ {file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"},
+ {file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"},
+ {file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"},
+ {file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"},
+ {file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"},
+ {file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"},
+ {file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"},
+ {file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"},
+ {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"},
+ {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"},
+ {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"},
+ {file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"},
+ {file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"},
+ {file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"},
+ {file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"},
+]
[package.dependencies]
-tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
+tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
-toml = ["tomli"]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
-name = "idna"
-version = "2.10"
-description = "Internationalized Domain Names in Applications (IDNA)"
-category = "main"
+name = "exceptiongroup"
+version = "1.3.0"
+description = "Backport of PEP 654 (exception groups)"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.7"
+groups = ["main", "dev"]
+markers = "python_version == \"3.10\""
+files = [
+ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
+ {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
+
+[package.extras]
+test = ["pytest (>=6)"]
[[package]]
-name = "importlib-metadata"
-version = "3.1.1"
-description = "Read metadata from Python packages"
-category = "dev"
+name = "h11"
+version = "0.16.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.8"
+groups = ["main", "dev"]
+files = [
+ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
+ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+description = "A minimal low-level HTTP client."
+optional = false
+python-versions = ">=3.8"
+groups = ["main", "dev"]
+files = [
+ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
+ {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
+]
[package.dependencies]
-zipp = ">=0.5"
+certifi = "*"
+h11 = ">=0.16"
[package.extras]
-docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+asyncio = ["anyio (>=4.0,<5.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+trio = ["trio (>=0.22.0,<1.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+description = "The next generation HTTP client."
+optional = false
+python-versions = ">=3.8"
+groups = ["main", "dev"]
+files = [
+ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
+ {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
+]
+
+[package.dependencies]
+anyio = "*"
+certifi = "*"
+httpcore = "==1.*"
+idna = "*"
+
+[package.extras]
+brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
+cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "idna"
+version = "3.10"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.6"
+groups = ["main", "dev"]
+files = [
+ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
+ {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
+]
+
+[package.extras]
+all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
-version = "1.1.1"
-description = "iniconfig: brain-dead simple config-ini parsing"
-category = "dev"
+version = "2.1.0"
+description = "brain-dead simple config-ini parsing"
optional = false
-python-versions = "*"
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
+ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
+]
[[package]]
name = "packaging"
-version = "20.7"
+version = "25.0"
description = "Core utilities for Python packages"
-category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-
-[package.dependencies]
-pyparsing = ">=2.0.2"
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
+ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
+]
[[package]]
name = "pluggy"
-version = "0.13.1"
+version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
-category = "dev"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-
-[package.dependencies]
-importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
+ {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
+]
[package.extras]
dev = ["pre-commit", "tox"]
+testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
-name = "py"
-version = "1.10.0"
-description = "library with cross-python path, ini-parsing, io, code, log facilities"
-category = "dev"
+name = "pygments"
+version = "2.19.2"
+description = "Pygments is a syntax highlighting package written in Python."
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
+ {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
+]
-[[package]]
-name = "pyparsing"
-version = "2.4.7"
-description = "Python parsing module"
-category = "dev"
-optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
-version = "6.1.2"
+version = "8.4.2"
description = "pytest: simple powerful testing with Python"
-category = "dev"
optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
+ {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
+iniconfig = ">=1"
+packaging = ">=20"
+pluggy = ">=1.5,<2"
+pygments = ">=2.7.2"
+tomli = {version = ">=1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.1.0"
+description = "Pytest support for asyncio"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"},
+ {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"},
+]
[package.dependencies]
-atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
-attrs = ">=17.4.0"
-colorama = {version = "*", markers = "sys_platform == \"win32\""}
-importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
-iniconfig = "*"
-packaging = "*"
-pluggy = ">=0.12,<1.0"
-py = ">=1.8.2"
-toml = "*"
+backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""}
+pytest = ">=8.2,<9"
[package.extras]
-checkqa_mypy = ["mypy (==0.780)"]
-testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
+testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
-version = "3.0.0"
+version = "6.3.0"
description = "Pytest plugin for measuring coverage."
-category = "dev"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"},
+ {file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"},
+]
[package.dependencies]
-coverage = {version = ">=5.2.1", extras = ["toml"]}
-pytest = ">=4.6"
+coverage = {version = ">=7.5", extras = ["toml"]}
+pluggy = ">=1.2"
+pytest = ">=6.2.5"
[package.extras]
-testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
+testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "python-dateutil"
-version = "2.8.1"
+version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
-category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
[package.dependencies]
six = ">=1.5"
[[package]]
-name = "requests"
-version = "2.25.0"
-description = "Python HTTP for Humans."
-category = "main"
+name = "respx"
+version = "0.22.0"
+description = "A utility for mocking out the Python HTTPX and HTTP Core libraries."
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"},
+ {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"},
+]
[package.dependencies]
-certifi = ">=2017.4.17"
-chardet = ">=3.0.2,<4"
-idna = ">=2.5,<3"
-urllib3 = ">=1.21.1,<1.27"
-
-[package.extras]
-security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
-socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
+httpx = ">=0.25.0"
[[package]]
name = "six"
-version = "1.15.0"
+version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
-category = "main"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
[[package]]
-name = "toml"
-version = "0.10.2"
-description = "Python Library for Tom's Obvious, Minimal Language"
-category = "dev"
+name = "sniffio"
+version = "1.3.1"
+description = "Sniff out which async library your code is running under"
optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+python-versions = ">=3.7"
+groups = ["main", "dev"]
+files = [
+ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
+ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
+]
[[package]]
name = "tomli"
-version = "1.2.2"
+version = "2.2.1"
description = "A lil' TOML parser"
-category = "dev"
optional = false
-python-versions = ">=3.6"
-
-[[package]]
-name = "urllib3"
-version = "1.26.7"
-description = "HTTP library with thread-safe connection pooling, file post, and more."
-category = "main"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
-
-[package.extras]
-brotli = ["brotlipy (>=0.6.0)"]
-secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
-socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
+python-versions = ">=3.8"
+groups = ["dev"]
+markers = "python_full_version <= \"3.11.0a6\""
+files = [
+ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
+ {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
+ {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
+ {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
+ {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
+ {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
+ {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
+ {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
+ {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
+ {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
+ {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
+ {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
+ {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
+ {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
+ {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
+ {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
+ {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
+ {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
+ {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
+ {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
+ {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
+ {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
+ {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
+ {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
+ {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
+ {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
+ {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
+ {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
+ {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
+ {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
+ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
+ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
+]
[[package]]
-name = "zipp"
-version = "3.4.0"
-description = "Backport of pathlib-compatible object wrapper for zip files"
-category = "dev"
+name = "typing-extensions"
+version = "4.15.0"
+description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
-python-versions = ">=3.6"
-
-[package.extras]
-docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
+python-versions = ">=3.9"
+groups = ["main", "dev"]
+markers = "python_version < \"3.13\""
+files = [
+ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
+ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
+]
[metadata]
-lock-version = "1.1"
-python-versions = "^3.7"
-content-hash = "8059de2d8d207fe0a668fb358a951cde011defd526fd486f713c71c17f7fab22"
-
-[metadata.files]
-atomicwrites = [
- {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
- {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
-]
-attrs = [
- {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"},
- {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"},
-]
-certifi = [
- {file = "certifi-2020.11.8-py2.py3-none-any.whl", hash = "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd"},
- {file = "certifi-2020.11.8.tar.gz", hash = "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"},
-]
-chardet = [
- {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
- {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
-]
-colorama = [
- {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
- {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
-]
-coverage = [
- {file = "coverage-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:42a1fb5dee3355df90b635906bb99126faa7936d87dfc97eacc5293397618cb7"},
- {file = "coverage-6.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a00284dbfb53b42e35c7dd99fc0e26ef89b4a34efff68078ed29d03ccb28402a"},
- {file = "coverage-6.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:51a441011a30d693e71dea198b2a6f53ba029afc39f8e2aeb5b77245c1b282ef"},
- {file = "coverage-6.1.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e76f017b6d4140a038c5ff12be1581183d7874e41f1c0af58ecf07748d36a336"},
- {file = "coverage-6.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7833c872718dc913f18e51ee97ea0dece61d9930893a58b20b3daf09bb1af6b6"},
- {file = "coverage-6.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8186b5a4730c896cbe1e4b645bdc524e62d874351ae50e1db7c3e9f5dc81dc26"},
- {file = "coverage-6.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbca34dca5a2d60f81326d908d77313816fad23d11b6069031a3d6b8c97a54f9"},
- {file = "coverage-6.1.1-cp310-cp310-win32.whl", hash = "sha256:72bf437d54186d104388cbae73c9f2b0f8a3e11b6e8d7deb593bd14625c96026"},
- {file = "coverage-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:994ce5a7b3d20981b81d83618aa4882f955bfa573efdbef033d5632b58597ba9"},
- {file = "coverage-6.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ab6a0fe4c96f8058d41948ddf134420d3ef8c42d5508b5a341a440cce7a37a1d"},
- {file = "coverage-6.1.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10ab138b153e4cc408b43792cb7f518f9ee02f4ff55cd1ab67ad6fd7e9905c7e"},
- {file = "coverage-6.1.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7e083d32965d2eb6638a77e65b622be32a094fdc0250f28ce6039b0732fbcaa8"},
- {file = "coverage-6.1.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:359a32515e94e398a5c0fa057e5887a42e647a9502d8e41165cf5cb8d3d1ca67"},
- {file = "coverage-6.1.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:bf656cd74ff7b4ed7006cdb2a6728150aaad69c7242b42a2a532f77b63ea233f"},
- {file = "coverage-6.1.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:dc5023be1c2a8b0a0ab5e31389e62c28b2453eb31dd069f4b8d1a0f9814d951a"},
- {file = "coverage-6.1.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:557594a50bfe3fb0b1b57460f6789affe8850ad19c1acf2d14a3e12b2757d489"},
- {file = "coverage-6.1.1-cp36-cp36m-win32.whl", hash = "sha256:9eb0a1923354e0fdd1c8a6f53f5db2e6180d670e2b587914bf2e79fa8acfd003"},
- {file = "coverage-6.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:04a92a6cf9afd99f9979c61348ec79725a9f9342fb45e63c889e33c04610d97b"},
- {file = "coverage-6.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:479228e1b798d3c246ac89b09897ee706c51b3e5f8f8d778067f38db73ccc717"},
- {file = "coverage-6.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78287731e3601ea5ce9d6468c82d88a12ef8fe625d6b7bdec9b45d96c1ad6533"},
- {file = "coverage-6.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c95257aa2ccf75d3d91d772060538d5fea7f625e48157f8ca44594f94d41cb33"},
- {file = "coverage-6.1.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9ad5895938a894c368d49d8470fe9f519909e5ebc6b8f8ea5190bd0df6aa4271"},
- {file = "coverage-6.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:326d944aad0189603733d646e8d4a7d952f7145684da973c463ec2eefe1387c2"},
- {file = "coverage-6.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e7d5606b9240ed4def9cbdf35be4308047d11e858b9c88a6c26974758d6225ce"},
- {file = "coverage-6.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:572f917267f363101eec375c109c9c1118037c7cc98041440b5eabda3185ac7b"},
- {file = "coverage-6.1.1-cp37-cp37m-win32.whl", hash = "sha256:35cd2230e1ed76df7d0081a997f0fe705be1f7d8696264eb508076e0d0b5a685"},
- {file = "coverage-6.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:65ad3ff837c89a229d626b8004f0ee32110f9bfdb6a88b76a80df36ccc60d926"},
- {file = "coverage-6.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:977ce557d79577a3dd510844904d5d968bfef9489f512be65e2882e1c6eed7d8"},
- {file = "coverage-6.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62512c0ec5d307f56d86504c58eace11c1bc2afcdf44e3ff20de8ca427ca1d0e"},
- {file = "coverage-6.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2e5b9c17a56b8bf0c0a9477fcd30d357deb486e4e1b389ed154f608f18556c8a"},
- {file = "coverage-6.1.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:666c6b32b69e56221ad1551d377f718ed00e6167c7a1b9257f780b105a101271"},
- {file = "coverage-6.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:fb2fa2f6506c03c48ca42e3fe5a692d7470d290c047ee6de7c0f3e5fa7639ac9"},
- {file = "coverage-6.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f0f80e323a17af63eac6a9db0c9188c10f1fd815c3ab299727150cc0eb92c7a4"},
- {file = "coverage-6.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:738e823a746841248b56f0f3bd6abf3b73af191d1fd65e4c723b9c456216f0ad"},
- {file = "coverage-6.1.1-cp38-cp38-win32.whl", hash = "sha256:8605add58e6a960729aa40c0fd9a20a55909dd9b586d3e8104cc7f45869e4c6b"},
- {file = "coverage-6.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:6e994003e719458420e14ffb43c08f4c14990e20d9e077cb5cad7a3e419bbb54"},
- {file = "coverage-6.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e3c4f5211394cd0bf6874ac5d29684a495f9c374919833dcfff0bd6d37f96201"},
- {file = "coverage-6.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e14bceb1f3ae8a14374be2b2d7bc12a59226872285f91d66d301e5f41705d4d6"},
- {file = "coverage-6.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0147f7833c41927d84f5af9219d9b32f875c0689e5e74ac8ca3cb61e73a698f9"},
- {file = "coverage-6.1.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b1d0a1bce919de0dd8da5cff4e616b2d9e6ebf3bd1410ff645318c3dd615010a"},
- {file = "coverage-6.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ae6de0e41f44794e68d23644636544ed8003ce24845f213b24de097cbf44997f"},
- {file = "coverage-6.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2797ed7a7e883b9ab76e8e778bb4c859fc2037d6fd0644d8675e64d58d1653"},
- {file = "coverage-6.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c40966b683d92869b72ea3c11fd6b99a091fd30e12652727eca117273fc97366"},
- {file = "coverage-6.1.1-cp39-cp39-win32.whl", hash = "sha256:a11a2c019324fc111485e79d55907e7289e53d0031275a6c8daed30690bc50c0"},
- {file = "coverage-6.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:4d8b453764b9b26b0dd2afb83086a7c3f9379134e340288d2a52f8a91592394b"},
- {file = "coverage-6.1.1-pp36-none-any.whl", hash = "sha256:3b270c6b48d3ff5a35deb3648028ba2643ad8434b07836782b1139cf9c66313f"},
- {file = "coverage-6.1.1-pp37-none-any.whl", hash = "sha256:ffa8fee2b1b9e60b531c4c27cf528d6b5d5da46b1730db1f4d6eee56ff282e07"},
- {file = "coverage-6.1.1-pp38-none-any.whl", hash = "sha256:4cd919057636f63ab299ccb86ea0e78b87812400c76abab245ca385f17d19fb5"},
- {file = "coverage-6.1.1.tar.gz", hash = "sha256:b8e4f15b672c9156c1154249a9c5746e86ac9ae9edc3799ee3afebc323d9d9e0"},
-]
-idna = [
- {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
- {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
-]
-importlib-metadata = [
- {file = "importlib_metadata-3.1.1-py3-none-any.whl", hash = "sha256:6112e21359ef8f344e7178aa5b72dc6e62b38b0d008e6d3cb212c5b84df72013"},
- {file = "importlib_metadata-3.1.1.tar.gz", hash = "sha256:b0c2d3b226157ae4517d9625decf63591461c66b3a808c2666d538946519d170"},
-]
-iniconfig = [
- {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
- {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
-]
-packaging = [
- {file = "packaging-20.7-py2.py3-none-any.whl", hash = "sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376"},
- {file = "packaging-20.7.tar.gz", hash = "sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236"},
-]
-pluggy = [
- {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
- {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
-]
-py = [
- {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
- {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
-]
-pyparsing = [
- {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
- {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
-]
-pytest = [
- {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"},
- {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"},
-]
-pytest-cov = [
- {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
- {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
-]
-python-dateutil = [
- {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
- {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
-]
-requests = [
- {file = "requests-2.25.0-py2.py3-none-any.whl", hash = "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"},
- {file = "requests-2.25.0.tar.gz", hash = "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8"},
-]
-six = [
- {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
- {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
-]
-toml = [
- {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
- {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
-]
-tomli = [
- {file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"},
- {file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"},
-]
-urllib3 = [
- {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"},
- {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},
-]
-zipp = [
- {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"},
- {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"},
-]
+lock-version = "2.1"
+python-versions = "^3.10"
+content-hash = "b775718374313e9c464aae71aa93a7aef133f272e3b28233f20b0bf8c17b559e"
diff --git a/pyproject.toml b/pyproject.toml
index b0fb9b7..3d3acf7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "onepasswordconnectsdk"
-version = "1.2.0"
+version = "2.1.0"
description = "Python SDK for 1Password Connect"
license = "MIT"
authors = ["1Password"]
@@ -12,15 +12,16 @@ repository = "https://github.com/1Password/connect-sdk-python"
"Report Security Issue" = "https://bugcrowd.com/agilebits"
[tool.poetry.dependencies]
-python = "^3.7"
-requests = "^2.24.0"
-six = "^1.10"
+python = "^3.10"
python-dateutil = "^2.8.1"
+httpx = "^0.28.1"
-[tool.poetry.dev-dependencies]
-pytest = "^6.0"
-pytest-cov = "^3.0.0"
+[tool.poetry.group.dev.dependencies]
+pytest = "^8.4.2"
+pytest-asyncio = "^1.1.0"
+pytest-cov = "^6.3.0"
+respx = "^0.22.0"
[build-system]
-requires = ["poetry>=0.12"]
-build-backend = "poetry.masonry.api"
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/src/onepasswordconnectsdk/__init__.py b/src/onepasswordconnectsdk/__init__.py
index ab7dcd4..5a83802 100644
--- a/src/onepasswordconnectsdk/__init__.py
+++ b/src/onepasswordconnectsdk/__init__.py
@@ -1,8 +1,15 @@
-# coding: utf-8
-
-# flake8: noqa
-
+from onepasswordconnectsdk import client
+from onepasswordconnectsdk import models
from onepasswordconnectsdk.config import load
from onepasswordconnectsdk.config import load_dict
from onepasswordconnectsdk.client import new_client
from onepasswordconnectsdk.client import new_client_from_environment
+
+__all__ = [
+ "client",
+ "load",
+ "load_dict",
+ "models",
+ "new_client",
+ "new_client_from_environment"
+]
diff --git a/src/onepasswordconnectsdk/async_client.py b/src/onepasswordconnectsdk/async_client.py
new file mode 100644
index 0000000..57a2268
--- /dev/null
+++ b/src/onepasswordconnectsdk/async_client.py
@@ -0,0 +1,393 @@
+"""Python AsyncClient for connecting to 1Password Connect"""
+import httpx
+from httpx import HTTPError
+from typing import Dict, List, Union, Optional
+import os
+
+from onepasswordconnectsdk.serializer import Serializer
+from onepasswordconnectsdk.config import ClientConfig
+from onepasswordconnectsdk.utils import build_headers, is_valid_uuid, PathBuilder, get_timeout
+from onepasswordconnectsdk.errors import (
+ FailedToRetrieveItemException,
+ FailedToRetrieveVaultException,
+)
+from onepasswordconnectsdk.models import File, Item, ItemVault, SummaryItem, Vault
+
+
+class AsyncClient:
+ """Python Async Client Class"""
+
+ def __init__(self, url: str, token: str, config: Optional[ClientConfig] = None) -> None:
+ """Initialize async client
+
+ Args:
+ url (str): The url of the 1Password Connect API
+ token (str): The 1Password Service Account token
+ config (Optional[ClientConfig]): Optional configuration for httpx client
+ """
+ self.url = url
+ self.token = token
+ self.config = config
+ self.session = self.create_session(url, token)
+ self.serializer = Serializer()
+
+ def create_session(self, url: str, token: str) -> httpx.AsyncClient:
+ headers = self.build_headers(token)
+ timeout = get_timeout()
+
+ if self.config:
+ client_args = self.config.get_client_args(url, headers, timeout)
+ return httpx.AsyncClient(**client_args)
+
+ return httpx.AsyncClient(base_url=url, headers=headers, timeout=timeout)
+
+ def build_headers(self, token: str) -> Dict[str, 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) -> File:
+ 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) -> List[File]:
+ 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) -> Union[bytes, str]:
+ 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) -> None:
+ 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) -> Item:
+ """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 not is_valid_uuid(item):
+ return await self.get_item_by_title(item, vault_id)
+ try:
+ return await self.get_item_by_id(item, vault_id)
+ except FailedToRetrieveItemException as exc:
+ if exc.status_code == 404:
+ return await self.get_item_by_title(item, vault_id)
+ raise
+
+ async def get_item_by_id(self, item_id: str, vault_id: str) -> Item:
+ """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')}",
+ status_code=response.status_code,
+ )
+ return self.serializer.deserialize(response.content, "Item")
+
+ async def get_item_by_title(self, title: str, vault_id: str) -> Item:
+ """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) -> List[SummaryItem]:
+ """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) -> None:
+ """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) -> 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) -> 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) -> Vault:
+ """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) -> Vault:
+ """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) -> List[Vault]:
+ """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) -> httpx.Response:
+ """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:
+ sanitized_body = self.serializer.sanitize_for_serialization(body)
+ response = self.session.request(method, path, json=sanitized_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 15d4b85..3457c4e 100644
--- a/src/onepasswordconnectsdk/client.py
+++ b/src/onepasswordconnectsdk/client.py
@@ -1,60 +1,62 @@
"""Python Client for connecting to 1Password Connect"""
-from dateutil.parser import parse
+import httpx
+from httpx import HTTPError, USE_CLIENT_DEFAULT
import json
+from typing import Dict, List, Union, Optional
import os
-import re
-import six
-import requests
-import datetime
-from requests.exceptions import HTTPError
-import onepasswordconnectsdk
-from onepasswordconnectsdk.models import Item, ItemVault
+
+from onepasswordconnectsdk.async_client import AsyncClient
+from onepasswordconnectsdk.config import ClientConfig
+from onepasswordconnectsdk.serializer import Serializer
+from onepasswordconnectsdk.utils import build_headers, is_valid_uuid, PathBuilder, get_timeout
+from onepasswordconnectsdk.errors import (
+ FailedToRetrieveItemException,
+ FailedToRetrieveVaultException,
+ EnvironmentHostNotSetException,
+ EnvironmentTokenNotSetException,
+)
+from onepasswordconnectsdk.models import File, Item, ItemVault, SummaryItem, Vault
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, six.text_type) + six.integer_types
- NATIVE_TYPES_MAPPING = {
- "int": int,
- "long": int if six.PY3 else long, # type: ignore # noqa: F821
- "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"""
+ def __init__(self, url: str, token: str, config: Optional[ClientConfig] = None) -> None:
+ """Initialize client
+
+ Args:
+ url (str): The url of the 1Password Connect API
+ token (str): The 1Password Service Account token
+ config (Optional[ClientConfig]): Optional configuration for httpx client
+ """
self.url = url
self.token = token
- self.session = self.create_session()
+ self.config = config
+ 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) -> httpx.Client:
+ headers = self.build_headers(token)
+ timeout = get_timeout()
- def build_headers(self):
- """Builds the headers needed to make a request to the server
+ if self.config:
+ client_args = self.config.get_client_args(url, headers, timeout)
+ return httpx.Client(**client_args)
- Returns:
- dict: The 1Password Connect API request headers
- """
+ return httpx.Client(base_url=url, headers=headers, timeout=timeout)
- headers = {}
- headers["Authorization"] = f"Bearer {self.token}"
- headers["Content-Type"] = "application/json"
- return headers
+ def build_headers(self, token: str) -> Dict[str, str]:
+ return build_headers(token)
- 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}"
+ def __del__(self) -> None:
+ self.session.close()
+
+ def get_file(self, file_id: str, item_id: str, vault_id: str) -> File:
+ url = PathBuilder().vaults(vault_id).items(item_id).files(file_id).build()
response = self.build_request("GET", url)
try:
response.raise_for_status()
@@ -63,11 +65,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")
-
- def get_files(self, item_id: str, vault_id: str):
- url = f"/v1/vaults/{vault_id}/items/{item_id}/files"
+ return self.serializer.deserialize(response.content, "File")
+ def get_files(self, item_id: str, vault_id: str) -> List[File]:
+ url = PathBuilder().vaults(vault_id).items(item_id).files().build()
response = self.build_request("GET", url)
try:
response.raise_for_status()
@@ -76,11 +77,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]")
-
- def get_file_content(self, file_id: str, item_id: str, vault_id: str):
- url = f"/v1/vaults/{vault_id}/items/{item_id}/files/{file_id}/content"
+ 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) -> Union[bytes, str]:
+ 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()
@@ -91,17 +93,17 @@ def get_file_content(self, file_id: str, item_id: str, vault_id: str):
)
return response.content
- def download_file(self, file_id: str, item_id: str, vault_id: str, path: str):
+ def download_file(self, file_id: str, item_id: str, vault_id: str, path: str) -> None:
file_object = self.get_file(file_id, item_id, vault_id)
- filename = file_object.name
- content = self.get_file_content(file_id, item_id, vault_id)
+ filename = file_object.name or "1password_item_file.txt"
+ content = 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()
- def get_item(self, item: str, vault: str):
+ def get_item(self, item: str, vault: str) -> Item:
"""Get a specific item
Args:
@@ -117,17 +119,21 @@ 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):
- return self.get_item_by_id(item, vault_id)
- else:
+ if not is_valid_uuid(item):
return self.get_item_by_title(item, vault_id)
+ try:
+ return self.get_item_by_id(item, vault_id)
+ except FailedToRetrieveItemException as exc:
+ if exc.status_code == 404:
+ return self.get_item_by_title(item, vault_id)
+ raise
- def get_item_by_id(self, item_id: str, vault_id: str):
+ def get_item_by_id(self, item_id: str, vault_id: str) -> Item:
"""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
@@ -139,21 +145,21 @@ 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()
except HTTPError:
raise FailedToRetrieveItemException(
f"Unable to retrieve item. Received {response.status_code}\
- for {url} with message: {response.json().get('message')}"
+ for {url} with message: {response.json().get('message')}",
+ status_code=response.status_code,
)
- return self.deserialize(response.content, "Item")
+ return self.serializer.deserialize(response.content, "Item")
- def get_item_by_title(self, title: str, vault_id: str):
+ def get_item_by_title(self, title: str, vault_id: str) -> Item:
"""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
@@ -166,8 +172,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()
@@ -183,14 +188,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):
+ def get_items(self, vault_id: str, filter_query: str = None) -> List[SummaryItem]:
"""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 "Example Item"`
Raises:
FailedToRetrieveItemException: Thrown when a HTTP error is returned
@@ -199,7 +205,10 @@ def get_items(self, vault_id: str):
Returns:
List[SummaryItem]: A list of summarized items
"""
- url = f"/v1/vaults/{vault_id}/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 = self.build_request("GET", url)
try:
@@ -210,22 +219,20 @@ def get_items(self, vault_id: str):
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):
+ def delete_item(self, item_id: str, vault_id: str) -> None:
"""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 = 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()
@@ -235,7 +242,7 @@ def delete_item(self, item_id: str, vault_id: str):
for {url} with message: {response.json().get('message')}"
)
- def create_item(self, vault_id: str, item: Item):
+ def create_item(self, vault_id: str, item: Item) -> Item:
"""Creates an item at the specified vault
Args:
@@ -250,9 +257,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:
@@ -260,9 +266,9 @@ 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):
+ def update_item(self, item_uuid: str, vault_id: str, item: Item) -> Item:
"""Update the specified item at the specified vault.
Args:
@@ -277,11 +283,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:
@@ -289,9 +295,9 @@ 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):
+ def get_vault(self, vault_id: str) -> Vault:
"""Returns the vault with the given vault_id
Args:
@@ -304,7 +310,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()
@@ -314,14 +320,14 @@ 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):
+ def get_vault_by_title(self, name: str) -> Vault:
"""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
@@ -330,8 +336,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()
@@ -347,9 +352,9 @@ 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):
+ def get_vaults(self) -> List[Vault]:
"""Returns all vaults for service account set in client
Raises:
@@ -359,9 +364,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:
@@ -370,9 +374,9 @@ 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):
+ def build_request(self, method: str, path: str, body=None) -> httpx.Response:
"""Builds a http request
Parameters:
method (str): The rest method to be used
@@ -382,241 +386,52 @@ 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)
+ sanitized_body = self.serializer.sanitize_for_serialization(body)
+ response = self.session.request(method, path, json=sanitized_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 six.iteritems(obj.openapi_types)
- if getattr(obj, attr) is not None
- }
-
- return {
- key: self.sanitize_for_serialization(val)
- for key, val in six.iteritems(obj_dict)
- }
-
- 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 six.iteritems(data) # 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)
+ return self.serializer.sanitize_for_serialization(obj)
- 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 six.text_type(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 six.iteritems(klass.openapi_types):
- 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, config: Optional[ClientConfig] = None) -> Union[AsyncClient, Client]:
"""Builds a new client for interacting with 1Password Connect
- Parameters:
- url: The url of the 1Password Connect API
- token: The 1Password Service Account token
+
+ Args:
+ url (str): The url of the 1Password Connect API
+ token (str): The 1Password Service Account token
+ is_async (bool): Initialize async or sync client
+ config (Optional[ClientConfig]): Optional configuration for httpx client
Returns:
- Client: The 1Password Connect client
+ Union[AsyncClient, Client]: The 1Password Connect client
"""
- return Client(url=url, token=token)
+ if is_async:
+ return AsyncClient(url, token, config)
+ return Client(url, token, config)
-def new_client_from_environment(url: str = None):
+def new_client_from_environment(
+ url: Optional[str] = None, token: Optional[str] = None
+) -> Union[AsyncClient, Client]:
"""Builds a new client for interacting with 1Password Connect
- using the OP_TOKEN environment variable
+ using OP_CONNECT_HOST and OP_CONNECT_TOKEN when url or token are omitted.
Parameters:
- url: The url of the 1Password Connect API
- token: The 1Password Service Account token
+ url: The url of the 1Password Connect API; if omitted, read from OP_CONNECT_HOST.
+ token: The Connect token; if omitted, read from OP_CONNECT_TOKEN.
Returns:
- Client: The 1Password Connect client
+ Union[AsyncClient, Client]: The 1Password Connect client (async if OP_CONNECT_CLIENT_ASYNC is True).
"""
- 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)
@@ -626,33 +441,12 @@ def new_client_from_environment(url: str = None):
)
if token is None:
- raise EnvironmentTokenNotSetException(
- "There is no token available in the "
- 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
+ token = os.environ.get(ENV_SERVICE_ACCOUNT_JWT_VARIABLE)
+ if token is None:
+ raise EnvironmentTokenNotSetException(
+ "There is no token available in the "
+ f"{ENV_SERVICE_ACCOUNT_JWT_VARIABLE} variable"
+ )
+ return new_client(url, token, is_async)
-class FailedToDeserializeException(OnePasswordConnectSDKError, TypeError):
- pass
diff --git a/src/onepasswordconnectsdk/config.py b/src/onepasswordconnectsdk/config.py
index 535faa8..9de206b 100644
--- a/src/onepasswordconnectsdk/config.py
+++ b/src/onepasswordconnectsdk/config.py
@@ -1,9 +1,11 @@
import os
import shlex
-from typing import List, Dict
-from onepasswordconnectsdk.client import Client
+from typing import List, Dict, Optional, TYPE_CHECKING
+import httpx
+
+if TYPE_CHECKING:
+ from onepasswordconnectsdk.client import Client
from onepasswordconnectsdk.models import (
- SummaryItem,
Item,
ParsedField,
ParsedItem,
@@ -17,7 +19,48 @@
)
-def load_dict(client: Client, config: dict):
+class ClientConfig:
+ """Configuration class for 1Password Connect client.
+ Inherits from httpx.BaseClient to support all httpx client options.
+ """
+ def __init__(self, ca_file: Optional[str] = None, **kwargs):
+ """Initialize client configuration
+
+ Args:
+ ca_file (Optional[str]): Path to CA certificate file for SSL verification
+ **kwargs: Additional httpx client options
+ """
+ self.ca_file = ca_file
+ self.httpx_options = kwargs
+
+ def get_client_args(self, base_url: str, headers: Dict[str, str], timeout: float) -> Dict:
+ """Get arguments for httpx client initialization
+
+ Args:
+ base_url (str): Base URL for the client
+ headers (Dict[str, str]): Headers to include in requests
+ timeout (float): Request timeout in seconds
+
+ Returns:
+ Dict: Arguments for httpx client initialization
+ """
+ args = {
+ 'base_url': base_url,
+ 'headers': headers,
+ 'timeout': timeout,
+ }
+
+ # Set verify from ca_file first
+ if self.ca_file:
+ args['verify'] = self.ca_file
+
+ # Allow httpx_options (including verify) to override
+ args.update(self.httpx_options)
+
+ return args
+
+
+def load_dict(client: "Client", config: dict):
"""Load: Takes a dictionary with keys specifiying the user
desired naming scheme of the values to return. Each key's
value is a dictionary that includes information on where
@@ -57,7 +100,7 @@ def load_dict(client: Client, config: dict):
"""
items: dict = {}
- config_values: Dict[str, str] = {}
+ config_values: Dict[str, Optional[str]] = {}
for field, tags in config.items():
item_tag = tags.get(ITEM_TAG)
@@ -84,7 +127,7 @@ def load_dict(client: Client, config: dict):
return config_values
-def load(client: Client, config: object):
+def load(client: "Client", config: object):
"""Load: Takes a an object with class attributes annotated with tags
describing where to find desired fields in 1Password. Manipulates given object
and fills attributes in with 1Password item field values.
@@ -163,7 +206,7 @@ def _vault_uuid_for_field(field: str, vault_tag: dict):
def _set_values_for_item(
- client: Client,
+ client: "Client",
parsed_item: ParsedItem,
config_dict={},
config_object: object = None,
@@ -194,16 +237,19 @@ def _set_values_for_item(
except AttributeError:
section_id = None
- if field.label == path_parts[1] and (
- section_id is None or section_id == sections[path_parts[0]]
- ):
- value_found = True
-
- if config_object:
- setattr(config_object, parsed_field.name, field.value)
- else:
- config_dict[parsed_field.name] = field.value
- break
+ if field.label == path_parts[1]:
+ if (
+ section_id is None
+ or (section_id == sections.get(path_parts[0]))
+ or path_parts[0] in sections.values()
+ ):
+ value_found = True
+
+ if config_object:
+ setattr(config_object, parsed_field.name, field.value)
+ else:
+ config_dict[parsed_field.name] = field.value
+ break
if not value_found:
raise UnknownSectionAndFieldTag(
f"There is no section {path_parts[0]} \
@@ -214,6 +260,7 @@ def _set_values_for_item(
def _convert_sections_to_dict(sections: List[Section]):
if not sections:
return {}
+
section_dict = {section.label: section.id for section in sections}
return section_dict
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..41db50d
--- /dev/null
+++ b/src/onepasswordconnectsdk/errors.py
@@ -0,0 +1,24 @@
+class OnePasswordConnectSDKError(RuntimeError):
+ pass
+
+
+class EnvironmentTokenNotSetException(OnePasswordConnectSDKError, TypeError):
+ pass
+
+
+class EnvironmentHostNotSetException(OnePasswordConnectSDKError, TypeError):
+ pass
+
+
+class FailedToRetrieveItemException(OnePasswordConnectSDKError):
+ def __init__(self, message, *, status_code=None):
+ super().__init__(message)
+ self.status_code = status_code
+
+
+class FailedToRetrieveVaultException(OnePasswordConnectSDKError):
+ pass
+
+
+class FailedToDeserializeException(OnePasswordConnectSDKError, TypeError):
+ pass
diff --git a/src/onepasswordconnectsdk/models/__init__.py b/src/onepasswordconnectsdk/models/__init__.py
index 60cf4d3..85e2deb 100644
--- a/src/onepasswordconnectsdk/models/__init__.py
+++ b/src/onepasswordconnectsdk/models/__init__.py
@@ -1,10 +1,3 @@
-# coding: utf-8
-
-# flake8: noqa
-
-
-from __future__ import absolute_import
-
# import models into model package
from onepasswordconnectsdk.models.error import Error
from onepasswordconnectsdk.models.item import Item
@@ -12,6 +5,7 @@
from onepasswordconnectsdk.models.field import Field
from onepasswordconnectsdk.models.field_section import FieldSection
from onepasswordconnectsdk.models.file import File
+from onepasswordconnectsdk.models.generator_recipe import GeneratorRecipe
from onepasswordconnectsdk.models.section import Section
from onepasswordconnectsdk.models.summary_item import SummaryItem
from onepasswordconnectsdk.models.item_urls import ItemUrls
@@ -19,3 +13,20 @@
from onepasswordconnectsdk.models.parsed_field import ParsedField
from onepasswordconnectsdk.models.parsed_item import ParsedItem
from onepasswordconnectsdk.models.vault import Vault
+
+__all__ = [
+ "Error",
+ "Field",
+ "FieldSection",
+ "File",
+ "GeneratorRecipe",
+ "Item",
+ "ItemDetails",
+ "ItemUrls",
+ "ItemVault",
+ "ParsedField",
+ "ParsedItem",
+ "Section",
+ "SummaryItem",
+ "Vault",
+]
\ No newline at end of file
diff --git a/src/onepasswordconnectsdk/models/error.py b/src/onepasswordconnectsdk/models/error.py
index b1fc6ae..eed602d 100644
--- a/src/onepasswordconnectsdk/models/error.py
+++ b/src/onepasswordconnectsdk/models/error.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,12 +9,9 @@
import pprint
-import re # noqa: F401
-
-import six
-class Error(object):
+class Error:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -94,7 +89,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(
diff --git a/src/onepasswordconnectsdk/models/field.py b/src/onepasswordconnectsdk/models/field.py
index 8be07e2..0b64346 100644
--- a/src/onepasswordconnectsdk/models/field.py
+++ b/src/onepasswordconnectsdk/models/field.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,12 +9,9 @@
import pprint
-import re # noqa: F401
-
-import six
-class Field(object):
+class Field:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -38,7 +33,9 @@ class Field(object):
'label': 'str',
'value': 'str',
'generate': 'bool',
- 'entropy': 'float'
+ 'recipe': 'GeneratorRecipe',
+ 'entropy': 'float',
+ 'totp': 'str'
}
attribute_map = {
@@ -49,10 +46,12 @@ class Field(object):
'label': 'label',
'value': 'value',
'generate': 'generate',
- 'entropy': 'entropy'
+ 'recipe': 'recipe',
+ 'entropy': 'entropy',
+ 'totp': 'totp'
}
- def __init__(self, id=None, section=None, type='STRING', purpose=None, label=None, value=None, generate=False, entropy=None): # noqa: E501
+ def __init__(self, id=None, section=None, type='STRING', purpose=None, label=None, value=None, generate=False, recipe=None, entropy=None, totp=None): # noqa: E501
self._id = None
self._section = None
self._type = None
@@ -60,7 +59,9 @@ def __init__(self, id=None, section=None, type='STRING', purpose=None, label=Non
self._label = None
self._value = None
self._generate = None
+ self._recipe = None
self._entropy = None
+ self._totp = None
self.discriminator = None
self.id = id
@@ -76,8 +77,12 @@ def __init__(self, id=None, section=None, type='STRING', purpose=None, label=Non
self.value = value
if generate is not None:
self.generate = generate
+ if recipe is not None:
+ self.recipe = recipe
if entropy is not None:
self.entropy = entropy
+ if totp is not None:
+ self.totp = totp
@property
def id(self):
@@ -139,13 +144,6 @@ def type(self, type):
:param type: The type of this Field. # noqa: E501
:type: str
"""
- allowed_values = ["STRING", "EMAIL", "CONCEALED", "URL", "OTP", "DATE", "MONTH_YEAR", "MENU"] # noqa: E501
- if type not in allowed_values: # noqa: E501
- raise ValueError(
- "Invalid value for `type` ({0}), must be one of {1}" # noqa: E501
- .format(type, allowed_values)
- )
-
self._type = type
@property
@@ -171,7 +169,7 @@ def purpose(self, purpose):
allowed_values = ["", "USERNAME", "PASSWORD", "NOTES"] # noqa: E501
if purpose not in allowed_values: # noqa: E501
raise ValueError(
- "Invalid value for `purpose` ({0}), must be one of {1}" # noqa: E501
+ "Invalid value for `purpose` ({}), must be one of {}" # noqa: E501
.format(purpose, allowed_values)
)
@@ -242,6 +240,27 @@ def generate(self, generate):
self._generate = generate
+ @property
+ def recipe(self):
+ """Gets the recipe of this Field. # noqa: E501
+
+
+ :return: The recipe of this Field. # noqa: E501
+ :rtype: GeneratorRecipe
+ """
+ return self._recipe
+
+ @recipe.setter
+ def recipe(self, recipe):
+ """Sets the recipe of this Field.
+
+
+ :param recipe: The recipe of this Field. # noqa: E501
+ :type: GeneratorRecipe
+ """
+
+ self._recipe = recipe
+
@property
def entropy(self):
"""Gets the entropy of this Field. # noqa: E501
@@ -264,12 +283,35 @@ def entropy(self, entropy):
"""
self._entropy = entropy
+
+ @property
+ def totp(self):
+ """Gets the TOTP. # noqa: E501
+
+ For fields of type 'OTP' this is the one-time password code # noqa: E501
+
+ :return: The TOTP of the Field. # noqa: E501
+ :rtype: str
+ """
+ return self._totp
+
+ @totp.setter
+ def totp(self, totp):
+ """Sets the totp of this Field.
+
+ For fields of type 'OTP' this is the one-time password code # noqa: E501
+
+ :param totp: The TOTP of this Field. # noqa: E501
+ :type: str
+ """
+
+ self._totp = totp
def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
diff --git a/src/onepasswordconnectsdk/models/field_section.py b/src/onepasswordconnectsdk/models/field_section.py
index 7ae2768..c8efaf6 100644
--- a/src/onepasswordconnectsdk/models/field_section.py
+++ b/src/onepasswordconnectsdk/models/field_section.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,12 +9,9 @@
import pprint
-import re # noqa: F401
-
-import six
-class FieldSection(object):
+class FieldSection:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -70,7 +65,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
diff --git a/src/onepasswordconnectsdk/models/file.py b/src/onepasswordconnectsdk/models/file.py
index 3c43115..f326d0d 100644
--- a/src/onepasswordconnectsdk/models/file.py
+++ b/src/onepasswordconnectsdk/models/file.py
@@ -1,10 +1,7 @@
import pprint
-import re # noqa: F401
-import six
-
-class File(object):
+class File:
openapi_types = {
'id': 'str',
'name': 'str',
@@ -71,7 +68,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
diff --git a/src/onepasswordconnectsdk/models/generator_recipe.py b/src/onepasswordconnectsdk/models/generator_recipe.py
new file mode 100644
index 0000000..02bd9f4
--- /dev/null
+++ b/src/onepasswordconnectsdk/models/generator_recipe.py
@@ -0,0 +1,203 @@
+"""
+ 1Password Connect
+
+ REST API interface for 1Password Connect. # noqa: E501
+
+ The version of the OpenAPI document: 1.3.0
+ Contact: support@1password.com
+ Generated by: https://openapi-generator.tech
+"""
+
+
+from inspect import getfullargspec
+import pprint
+
+class GeneratorRecipe:
+ """NOTE: This class is auto generated by OpenAPI Generator.
+ Ref: https://openapi-generator.tech
+
+ Do not edit the class manually.
+ """
+
+ """
+ Attributes:
+ openapi_types (dict): The key is attribute name
+ and the value is attribute type.
+ attribute_map (dict): The key is attribute name
+ and the value is json key in definition.
+ """
+ openapi_types = {
+ 'length': 'int',
+ 'character_sets': 'list[str]',
+ 'exclude_characters': 'str'
+ }
+
+ attribute_map = {
+ 'length': 'length',
+ 'character_sets': 'characterSets',
+ 'exclude_characters': 'excludeCharacters'
+ }
+
+ def __init__(self, length=32, character_sets=None, exclude_characters=None, local_vars_configuration=None): # noqa: E501
+ """GeneratorRecipe - a model defined in OpenAPI""" # noqa: E501
+
+ self._length = None
+ self._character_sets = None
+ self._exclude_characters = None
+ self.discriminator = None
+
+ if length is not None:
+ self.length = length
+ if character_sets is not None:
+ self.character_sets = character_sets
+ if exclude_characters is not None:
+ self.exclude_characters = exclude_characters
+
+ @property
+ def length(self):
+ """Gets the length of this GeneratorRecipe. # noqa: E501
+
+ Length of the generated value # noqa: E501
+
+ :return: The length of this GeneratorRecipe. # noqa: E501
+ :rtype: int
+ """
+ return self._length
+
+ @length.setter
+ def length(self, length):
+ """Sets the length of this GeneratorRecipe.
+
+ Length of the generated value # noqa: E501
+
+ :param length: The length of this GeneratorRecipe. # noqa: E501
+ :type length: int
+ """
+ if (length is not None and length > 64): # noqa: E501
+ raise ValueError("Invalid value for `length`, must be a value less than or equal to `64`") # noqa: E501
+ if (length is not None and length < 1): # noqa: E501
+ raise ValueError("Invalid value for `length`, must be a value greater than or equal to `1`") # noqa: E501
+
+ self._length = length
+
+ @property
+ def character_sets(self):
+ """Gets the character_sets of this GeneratorRecipe. # noqa: E501
+
+
+ :return: The character_sets of this GeneratorRecipe. # noqa: E501
+ :rtype: list[str]
+ """
+ return self._character_sets
+
+ @character_sets.setter
+ def character_sets(self, character_sets):
+ """Sets the character_sets of this GeneratorRecipe.
+
+
+ :param character_sets: The character_sets of this GeneratorRecipe. # noqa: E501
+ :type character_sets: list[str]
+ """
+ allowed_values = ["LETTERS", "DIGITS", "SYMBOLS"] # noqa: E501
+ if (not set(character_sets).issubset(set(allowed_values))): # noqa: E501
+ raise ValueError(
+ "Invalid values for `character_sets` [{}], must be a subset of [{}]" # noqa: E501
+ .format(", ".join(map(str, set(character_sets) - set(allowed_values))), # noqa: E501
+ ", ".join(map(str, allowed_values)))
+ )
+
+ self._character_sets = character_sets
+
+ @property
+ def exclude_characters(self):
+ """Gets the exclude_characters of this GeneratorRecipe.
+
+
+ :return: The exclude_characters of this GeneratorRecipe.
+ :rtype: str
+ """
+ return self._exclude_characters
+
+ @exclude_characters.setter
+ def exclude_characters(self, exclude_characters):
+ """Sets the exclude_characters of this GeneratorRecipe.
+
+
+ :param exclude_characters: The exclude_characters of this GeneratorRecipe.
+ :type character_sets: str
+ """
+ duplicates = self.find_duplicates(exclude_characters)
+ if duplicates:
+ raise ValueError(
+ "Invalid values for `exclude_characters` {0}, must not contain duplicate characters"
+ .format(", ".join(map(str, duplicates)))
+ )
+
+ self._exclude_characters = exclude_characters
+
+ def to_dict(self, serialize=False):
+ """Returns the model properties as a dict"""
+ result = {}
+
+ def convert(x):
+ if hasattr(x, "to_dict"):
+ args = getfullargspec(x.to_dict).args
+ if len(args) == 1:
+ return x.to_dict()
+ else:
+ return x.to_dict(serialize)
+ else:
+ return x
+
+ for attr in self.openapi_types:
+ value = getattr(self, attr)
+ attr = self.attribute_map.get(attr, attr) if serialize else attr
+ if isinstance(value, list):
+ result[attr] = list(map(
+ lambda x: convert(x),
+ value
+ ))
+ elif isinstance(value, dict):
+ result[attr] = dict(map(
+ lambda item: (item[0], convert(item[1])),
+ value.items()
+ ))
+ else:
+ result[attr] = convert(value)
+
+ return result
+
+ def to_str(self):
+ """Returns the string representation of the model"""
+ return pprint.pformat(self.to_dict())
+
+ def __repr__(self):
+ """For `print` and `pprint`"""
+ return self.to_str()
+
+ def __eq__(self, other):
+ """Returns true if both objects are equal"""
+ if not isinstance(other, GeneratorRecipe):
+ return False
+
+ return self.to_dict() == other.to_dict()
+
+ def __ne__(self, other):
+ """Returns true if both objects are not equal"""
+ if not isinstance(other, GeneratorRecipe):
+ return True
+
+ return self.to_dict() != other.to_dict()
+
+ def find_duplicates(self, s):
+ char_count = {} # Dictionary to store count of each character
+ duplicates = set() # Set to store duplicate characters
+
+ for char in s:
+ char_count[char] = char_count.get(char, 0) + 1 # Increment count of char
+
+ for char, count in char_count.items():
+ if count > 1:
+ duplicates.add(char) # If count is more than 1, it's a duplicate
+
+ return duplicates
\ No newline at end of file
diff --git a/src/onepasswordconnectsdk/models/item.py b/src/onepasswordconnectsdk/models/item.py
index ede5942..b0ec3c6 100644
--- a/src/onepasswordconnectsdk/models/item.py
+++ b/src/onepasswordconnectsdk/models/item.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,12 +9,9 @@
import pprint
-import re # noqa: F401
-
-import six
-class Item(object):
+class Item:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -190,13 +185,6 @@ def category(self, category):
:param category: The category of this Item. # noqa: E501
:type: str
"""
- allowed_values = ["LOGIN", "PASSWORD", "SERVER", "DATABASE", "CREDIT_CARD", "MEMBERSHIP", "PASSPORT", "SOFTWARE_LICENSE", "OUTDOOR_LICENSE", "SECURE_NOTE", "WIRELESS_ROUTER", "BANK_ACCOUNT", "DRIVER_LICENSE", "IDENTITY", "REWARD_PROGRAM", "DOCUMENT", "EMAIL_ACCOUNT", "SOCIAL_SECURITY_NUMBER", "API_CREDENTIAL", "CUSTOM"] # noqa: E501
- if category not in allowed_values: # noqa: E501
- raise ValueError(
- "Invalid value for `category` ({0}), must be one of {1}" # noqa: E501
- .format(category, allowed_values)
- )
-
self._category = category
@property
@@ -413,7 +401,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
diff --git a/src/onepasswordconnectsdk/models/item_details.py b/src/onepasswordconnectsdk/models/item_details.py
index 72fc277..2e97b46 100644
--- a/src/onepasswordconnectsdk/models/item_details.py
+++ b/src/onepasswordconnectsdk/models/item_details.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,12 +9,9 @@
import pprint
-import re # noqa: F401
-
-import six
-class ItemDetails(object):
+class ItemDetails:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -96,7 +91,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
diff --git a/src/onepasswordconnectsdk/models/item_urls.py b/src/onepasswordconnectsdk/models/item_urls.py
index d529b7b..29bd6b7 100644
--- a/src/onepasswordconnectsdk/models/item_urls.py
+++ b/src/onepasswordconnectsdk/models/item_urls.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,12 +9,9 @@
import pprint
-import re # noqa: F401
-
-import six
-class ItemUrls(object):
+class ItemUrls:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -96,7 +91,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
diff --git a/src/onepasswordconnectsdk/models/item_vault.py b/src/onepasswordconnectsdk/models/item_vault.py
index 609c57e..3f1d469 100644
--- a/src/onepasswordconnectsdk/models/item_vault.py
+++ b/src/onepasswordconnectsdk/models/item_vault.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,12 +9,9 @@
import pprint
-import re # noqa: F401
-
-import six
-class ItemVault(object):
+class ItemVault:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -70,7 +65,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
diff --git a/src/onepasswordconnectsdk/models/section.py b/src/onepasswordconnectsdk/models/section.py
index 33f1ff0..d1b8794 100644
--- a/src/onepasswordconnectsdk/models/section.py
+++ b/src/onepasswordconnectsdk/models/section.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,12 +9,9 @@
import pprint
-import re # noqa: F401
-
-import six
-class Section(object):
+class Section:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -95,7 +90,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
diff --git a/src/onepasswordconnectsdk/models/summary_item.py b/src/onepasswordconnectsdk/models/summary_item.py
index 109f1cf..793fc40 100644
--- a/src/onepasswordconnectsdk/models/summary_item.py
+++ b/src/onepasswordconnectsdk/models/summary_item.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,14 +9,12 @@
import pprint
-import re # noqa: F401
-import six
from onepasswordconnectsdk.models import Item
-class SummaryItem(object):
+class SummaryItem:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -182,13 +178,6 @@ def category(self, category):
:param category: The category of this Item. # noqa: E501
:type: str
"""
- allowed_values = ["LOGIN", "PASSWORD", "SERVER", "DATABASE", "CREDIT_CARD", "MEMBERSHIP", "PASSPORT", "SOFTWARE_LICENSE", "OUTDOOR_LICENSE", "SECURE_NOTE", "WIRELESS_ROUTER", "BANK_ACCOUNT", "DRIVER_LICENSE", "IDENTITY", "REWARD_PROGRAM", "DOCUMENT", "EMAIL_ACCOUNT", "SOCIAL_SECURITY_NUMBER", "API_CREDENTIAL", "CUSTOM"] # noqa: E501
- if category not in allowed_values: # noqa: E501
- raise ValueError(
- "Invalid value for `category` ({0}), must be one of {1}" # noqa: E501
- .format(category, allowed_values)
- )
-
self._category = category
@property
@@ -363,7 +352,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
diff --git a/src/onepasswordconnectsdk/models/vault.py b/src/onepasswordconnectsdk/models/vault.py
index 255e58f..8c5fa63 100644
--- a/src/onepasswordconnectsdk/models/vault.py
+++ b/src/onepasswordconnectsdk/models/vault.py
@@ -1,5 +1,3 @@
-# coding: utf-8
-
"""
1Password Connect
@@ -11,12 +9,9 @@
import pprint
-import re # noqa: F401
-
-import six
-class Vault(object):
+class Vault:
"""NOTE: This class is auto generated by OpenAPI Generator.
Ref: https://openapi-generator.tech
@@ -239,7 +234,7 @@ def type(self, type):
allowed_values = ["USER_CREATED", "PERSONAL", "EVERYONE", "TRANSFER"] # noqa: E501
if type not in allowed_values: # noqa: E501
raise ValueError(
- "Invalid value for `type` ({0}), must be one of {1}" # noqa: E501
+ "Invalid value for `type` ({}), must be one of {}" # noqa: E501
.format(type, allowed_values)
)
@@ -291,7 +286,7 @@ def to_dict(self):
"""Returns the model properties as a dict"""
result = {}
- for attr, _ in six.iteritems(self.openapi_types):
+ for attr in self.openapi_types.keys():
value = getattr(self, attr)
if isinstance(value, list):
result[attr] = list(map(
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..124aadd
--- /dev/null
+++ b/src/onepasswordconnectsdk/utils.py
@@ -0,0 +1,78 @@
+import os
+
+from httpx._client import DEFAULT_TIMEOUT_CONFIG, Timeout
+
+UUIDLength = 26
+ENV_CLIENT_REQUEST_TIMEOUT = "OP_CONNECT_CLIENT_REQ_TIMEOUT"
+
+
+def is_valid_uuid(uuid):
+ if len(uuid) != 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}"
+
+
+def get_timeout() -> Timeout:
+ """Get the timeout to be used in the HTTP Client"""
+ raw_timeout = os.getenv(ENV_CLIENT_REQUEST_TIMEOUT, '0.0')
+ if raw_timeout == 'None':
+ return Timeout(None) # disable all timeouts
+ elif raw_timeout.isnumeric():
+ timeout = float(raw_timeout)
+ return timeout if timeout else DEFAULT_TIMEOUT_CONFIG
+ else:
+ return DEFAULT_TIMEOUT_CONFIG
diff --git a/src/tests/test_client_config.py b/src/tests/test_client_config.py
new file mode 100644
index 0000000..524f1f4
--- /dev/null
+++ b/src/tests/test_client_config.py
@@ -0,0 +1,44 @@
+import pytest
+from onepasswordconnectsdk.config import ClientConfig
+import httpx
+
+def test_client_config_with_ca_file():
+ config = ClientConfig(ca_file="path/to/ca.pem")
+ args = config.get_client_args("https://test.com", {"Authorization": "Bearer token"}, 30.0)
+
+ assert args["verify"] == "path/to/ca.pem"
+ assert args["base_url"] == "https://test.com"
+ assert args["headers"] == {"Authorization": "Bearer token"}
+ assert args["timeout"] == 30.0
+
+def test_client_config_with_kwargs():
+ config = ClientConfig(
+ ca_file="path/to/ca.pem",
+ follow_redirects=True,
+ timeout=60.0
+ )
+ args = config.get_client_args("https://test.com", {"Authorization": "Bearer token"}, 30.0)
+
+ assert args["verify"] == "path/to/ca.pem"
+ assert args["follow_redirects"] == True
+ # kwargs should override default timeout
+ assert args["timeout"] == 60.0
+
+def test_client_config_verify_override():
+ # When verify is explicitly set in kwargs, it should override ca_file
+ config = ClientConfig(
+ ca_file="path/to/ca.pem",
+ verify=False
+ )
+ args = config.get_client_args("https://test.com", {"Authorization": "Bearer token"}, 30.0)
+
+ assert args["verify"] == False
+
+def test_client_config_no_ca_file():
+ config = ClientConfig()
+ args = config.get_client_args("https://test.com", {"Authorization": "Bearer token"}, 30.0)
+
+ assert "verify" not in args
+ assert args["base_url"] == "https://test.com"
+ assert args["headers"] == {"Authorization": "Bearer token"}
+ assert args["timeout"] == 30.0
diff --git a/src/tests/test_client_items.py b/src/tests/test_client_items.py
index fcdb2cf..eae6634 100644
--- a/src/tests/test_client_items.py
+++ b/src/tests/test_client_items.py
@@ -1,203 +1,333 @@
-import json
-from requests import Session, Response
-from unittest.mock import patch
+import os
+import pytest
+from unittest import mock
+
+from httpx import Response
+from httpx._client import DEFAULT_TIMEOUT_CONFIG
from onepasswordconnectsdk import client, models
+from onepasswordconnectsdk.utils import ENV_CLIENT_REQUEST_TIMEOUT
VAULT_ID = "hfnjvi6aymbsnfc2xeeoheizda"
VAULT_TITLE = "VaultA"
ITEM_ID = "wepiqdxdzncjtnvmv5fegud4qy"
ITEM_TITLE = "Test Login"
-HOST = "mock_host"
+# 26 lowercase alphanumeric chars: treated as item id by is_valid_uuid but may be a title (#80)
+ITEM_TITLE_26_CHARS = "abcdefghijklmnop1234567890"
+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 = 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_by_id(ITEM_ID, VAULT_ID)
+ compare_items(expected_item, item)
+ assert 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_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}"
- mock.side_effect = [response_item_summary, response_item]
+ 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_item_26_char_title_falls_back_from_id_to_title(respx_mock):
+ """Item titles matching the SDK item-id shape should resolve via title after 404 on id."""
+ expected_item = get_item()
+ expected_path_by_id = f"/v1/vaults/{VAULT_ID}/items/{ITEM_TITLE_26_CHARS}"
+ expected_path_by_title = (
+ f'/v1/vaults/{VAULT_ID}/items?filter=title eq "{ITEM_TITLE_26_CHARS}"'
+ )
+ expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}"
+
+ by_id_mock = respx_mock.get(expected_path_by_id).mock(
+ return_value=Response(404, json={"message": "not found"})
+ )
+ items_by_title_mock = respx_mock.get(expected_path_by_title).mock(
+ return_value=Response(200, json=get_items_with_title(ITEM_TITLE_26_CHARS))
+ )
+ item_mock = respx_mock.get(expected_path_item).mock(
+ return_value=Response(200, json=expected_item)
+ )
+
+ item = SS_CLIENT.get_item(ITEM_TITLE_26_CHARS, VAULT_ID)
+ compare_items(expected_item, item)
+ assert by_id_mock.called
+ assert items_by_title_mock.called
+ assert item_mock.called
+
+
+@pytest.mark.asyncio
+async def test_get_item_26_char_title_falls_back_from_id_to_title_async(respx_mock):
+ expected_item = get_item()
+ expected_path_by_id = f"/v1/vaults/{VAULT_ID}/items/{ITEM_TITLE_26_CHARS}"
+ expected_path_by_title = (
+ f'/v1/vaults/{VAULT_ID}/items?filter=title eq "{ITEM_TITLE_26_CHARS}"'
+ )
+ expected_path_item = f"/v1/vaults/{VAULT_ID}/items/{ITEM_ID}"
+
+ by_id_mock = respx_mock.get(expected_path_by_id).mock(
+ return_value=Response(404, json={"message": "not found"})
+ )
+ items_by_title_mock = respx_mock.get(expected_path_by_title).mock(
+ return_value=Response(200, json=get_items_with_title(ITEM_TITLE_26_CHARS))
+ )
+ 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_26_CHARS, VAULT_ID)
+ compare_items(expected_item, item)
+ assert by_id_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(VAULT_ID, item)
+ created_item = SS_CLIENT.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
+@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 = await SS_CLIENT_ASYNC.create_item(item.vault.id, item)
+ assert mock.called
+ compare_full_items(item, created_item)
+
+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, VAULT_ID, item)
+ 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 = await SS_CLIENT_ASYNC.update_item(item.id, item.vault.id, item)
assert mock.called
compare_full_items(item, updated_item)
@@ -220,13 +350,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
@@ -263,17 +386,24 @@ def compare_items(expected_item, returned_item):
def compare_fields(expected_field, returned_field):
- assert expected_field["id"] == returned_field.id
- assert expected_field["label"] == returned_field.label
- assert expected_field["value"] == returned_field.value
- assert expected_field["purpose"] == returned_field.purpose
- assert expected_field["section"]["id"] == returned_field.section.id
- assert expected_field["type"] == returned_field.type
+ assert expected_field.get("id") == returned_field.id
+ assert expected_field.get("label") == returned_field.label
+ assert expected_field.get("value") == returned_field.value
+ assert expected_field.get("purpose") == returned_field.purpose
+ assert expected_field.get("section").get("id") == returned_field.section.id
+ assert expected_field.get("type") == returned_field.type
+ assert expected_field.get("totp") == returned_field.totp
def compare_sections(expected_section, returned_section):
- assert expected_section["id"] == returned_section.id
- assert expected_section["label"] == returned_section.label
+ assert expected_section.get("id") == returned_section.id
+ assert expected_section.get("label") == returned_section.label
+
+
+def get_items_with_title(title: str):
+ row = dict(get_items()[0])
+ row["title"] = title
+ return [row]
def get_items():
@@ -331,6 +461,16 @@ def get_item():
"type": "STRING",
"label": "something",
"value": "test"
+ },
+ {
+ "id": "TOTP_acf2fgvsa312c9sd4vs8jhkli",
+ "section": {
+ "id": "Section_47DC4DDBF26640AB8B8618DA36D5A492"
+ },
+ "type": "OTP",
+ "label": "one-time password",
+ "value": "otpauth://totp=testop?secret=test",
+ "totp": "134253"
}
],
"lastEditedBy": "DOIHOHSV2NHK5HMSOLCWJUXFDM",
@@ -365,3 +505,57 @@ def generate_full_item():
id="Section_47DC4DDBF26640AB8B8618DA36D5A499"))],
sections=[models.Section(id="id", label="label")])
return item
+
+
+def test_default_timeout():
+ client_instance = client.new_client(HOST, TOKEN)
+ assert client_instance.session.timeout.read == DEFAULT_TIMEOUT_CONFIG.read
+
+
+def test_set_timeout_using_env_variable():
+ with mock.patch.dict(os.environ, {ENV_CLIENT_REQUEST_TIMEOUT: '120'}):
+ client_instance = client.new_client(HOST, TOKEN)
+ assert client_instance.session.timeout.read == 120
+
+
+@pytest.mark.asyncio
+def test_set_timeout_using_env_variable_async():
+ with mock.patch.dict(os.environ, {ENV_CLIENT_REQUEST_TIMEOUT: '120'}):
+ client_instance = client.new_client(HOST, TOKEN, is_async=True)
+ assert client_instance.session.timeout.read == 120
+
+
+def test_disable_all_timeouts():
+ with mock.patch.dict(os.environ, {ENV_CLIENT_REQUEST_TIMEOUT: 'None'}):
+ client_instance = client.new_client(HOST, TOKEN)
+ assert client_instance.session.timeout.read is None
+
+
+def test_env_client_request_timeout_env_var_is_empty_string():
+ with mock.patch.dict(os.environ, {ENV_CLIENT_REQUEST_TIMEOUT: ''}):
+ client_instance = client.new_client(HOST, TOKEN)
+ assert client_instance.session.timeout.read == DEFAULT_TIMEOUT_CONFIG.read
+
+
+def test_env_client_request_timeout_env_var_is_single_space_string():
+ with mock.patch.dict(os.environ, {ENV_CLIENT_REQUEST_TIMEOUT: ' '}):
+ client_instance = client.new_client(HOST, TOKEN)
+ assert client_instance.session.timeout.read == DEFAULT_TIMEOUT_CONFIG.read
+
+
+def test_env_client_request_timeout_env_var_is_not_numeric_string():
+ with mock.patch.dict(os.environ, {ENV_CLIENT_REQUEST_TIMEOUT: 'abc'}):
+ client_instance = client.new_client(HOST, TOKEN)
+ assert client_instance.session.timeout.read == DEFAULT_TIMEOUT_CONFIG.read
+
+
+def test_env_client_request_timeout_env_var_is_zero():
+ with mock.patch.dict(os.environ, {ENV_CLIENT_REQUEST_TIMEOUT: '0'}):
+ client_instance = client.new_client(HOST, TOKEN)
+ assert client_instance.session.timeout.read == DEFAULT_TIMEOUT_CONFIG.read
+
+
+def test_env_client_request_timeout_env_var_is_negative_number():
+ with mock.patch.dict(os.environ, {ENV_CLIENT_REQUEST_TIMEOUT: '-10'}):
+ client_instance = client.new_client(HOST, TOKEN)
+ assert client_instance.session.timeout.read == DEFAULT_TIMEOUT_CONFIG.read
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..04835d5 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,106 @@ 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
- }
- ]
- }
-
- item2 = {
- "id": ITEM_NAME2,
- "title": ITEM_NAME2,
- "vault": {
- "id": VAULT_ID
+def test_load_dict_empty_field_returns_none(respx_mock):
+ config_dict = {
+ "username": {
+ "opitem": ITEM_NAME1,
+ "opfield": ".username",
+ "opvault": VAULT_ID
},
- "category": "LOGIN",
- "fields": [
- {
- "id": "716C5B0E95A84092B2FE2CC402E0DDDF",
- "label": "host",
- "value": HOST_VALUE
- }
- ]
+ "empty": {
+ "opitem": ITEM_NAME1,
+ "opfield": ".empty_field",
+ "opvault": VAULT_ID
+ }
}
- 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
+ respx_mock.get(f"v1/vaults/{VAULT_ID}/items?filter=title eq \"{ITEM_NAME1}\"").mock(
+ return_value=Response(200, json=[item_with_empty_field]))
+ respx_mock.get(f"v1/vaults/{VAULT_ID}/items/{ITEM_ID1}").mock(
+ return_value=Response(200, json=item_with_empty_field))
- response._content = str.encode(json.dumps(item))
+ config_with_values = onepasswordconnectsdk.load_dict(SS_CLIENT, config_dict)
- return response
+ assert config_with_values['username'] == USERNAME_VALUE
+ assert config_with_values['empty'] is None
+
+
+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"
+ }
+ },
+ {
+ "id": "username",
+ "label": "username",
+ "value": USERNAME_VALUE
+ }
+ ]
+}
+
+item_with_empty_field = {
+ "id": ITEM_ID1,
+ "title": ITEM_NAME1,
+ "vault": {
+ "id": VAULT_ID
+ },
+ "category": "LOGIN",
+ "fields": [
+ {
+ "id": "username",
+ "label": "username",
+ "value": USERNAME_VALUE
+ },
+ {
+ "id": "empty_field",
+ "label": "empty_field"
+ }
+ ]
+}
+
+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"