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 + + -[![Python](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9-blue)](https://www.python.org) -[![PyPI version](https://badge.fury.io/py/onepasswordconnectsdk.svg)](https://badge.fury.io/py/onepasswordconnectsdk) -![CI](https://github.com/1Password/connect-sdk-python/workflows/Test/badge.svg) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://en.wikipedia.org/wiki/MIT_License) -[![codecov](https://codecov.io/gh/1Password/connect-sdk-python/branch/main/graph/badge.svg?token=VBPCH0CU2E)](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.

+ + Get started + +
-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"