diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b68c6c8..8926936 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,7 +19,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/example/desktop_app.py b/example/desktop_app.py new file mode 100644 index 0000000..5490802 --- /dev/null +++ b/example/desktop_app.py @@ -0,0 +1,32 @@ +# [developer-docs.sdk.python.sdk-import]-start +from onepassword import * +import asyncio + + +async def main(): + # [developer-docs.sdk.python.client-initialization]-start + # Connects to the 1Password desktop app. + client = await Client.authenticate( + auth=DesktopAuth( + account_name="AndiTituTest" # Set to your 1Password account name. + ), + # Set the following to your own integration name and version. + integration_name="My 1Password Integration", + integration_version="v1.0.0", + ) + + # [developer-docs.sdk.python.list-vaults]-start + vaults = await client.vaults.list() + for vault in vaults: + print(vault) + # [developer-docs.sdk.python.list-vaults]-end + + # [developer-docs.sdk.python.list-items]-start + overviews = await client.items.list("xw33qlvug6moegr3wkk5zkenoa") + for overview in overviews: + print(overview.title) + # [developer-docs.sdk.python.list-items]-end + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/onepassword/__init__.py b/src/onepassword/__init__.py index 040ae01..26d4fcb 100644 --- a/src/onepassword/__init__.py +++ b/src/onepassword/__init__.py @@ -1,6 +1,6 @@ # Code generated by op-codegen - DO NO EDIT MANUALLY -from .client import Client +from .client import Client, DesktopAuth from .defaults import DEFAULT_INTEGRATION_NAME, DEFAULT_INTEGRATION_VERSION from .types import * # noqa F403 from .errors import * # noqa F403 @@ -20,6 +20,7 @@ "Vaults", "DEFAULT_INTEGRATION_NAME", "DEFAULT_INTEGRATION_VERSION", + "DesktopAuth", ] for name, obj in inspect.getmembers(sys.modules["onepassword.types"]): diff --git a/src/onepassword/client.py b/src/onepassword/client.py index ae5377f..88f1e72 100644 --- a/src/onepassword/client.py +++ b/src/onepassword/client.py @@ -2,8 +2,9 @@ from __future__ import annotations import weakref -from .core import _init_client, _release_client -from .defaults import new_default_config +from .core import Core, UniffiCore +from .desktop_core import DesktopCore +from .defaults import new_default_config, DesktopAuth from .secrets import Secrets from .items import Items from .vaults import Vaults @@ -16,23 +17,28 @@ class Client: @classmethod async def authenticate( - cls, auth: str, integration_name: str, integration_version: str + cls, auth: str | DesktopAuth, integration_name: str, integration_version: str ) -> Client: config = new_default_config( - auth=auth or "", + auth=auth, integration_name=integration_name, integration_version=integration_version, ) - client_id = int(await _init_client(config)) + if isinstance(auth, DesktopAuth): + core = DesktopCore(auth.account_name) + else: + core = UniffiCore() + + client_id = int(await core.init_client(config)) authenticated_client = cls() - authenticated_client.secrets = Secrets(client_id) - authenticated_client.items = Items(client_id) - authenticated_client.vaults = Vaults(client_id) + authenticated_client.secrets = Secrets(client_id, core) + authenticated_client.items = Items(client_id, core) + authenticated_client.vaults = Vaults(client_id, core) authenticated_client._finalizer = weakref.finalize( - cls, _release_client, client_id + cls, core.release_client, client_id ) return authenticated_client diff --git a/src/onepassword/core.py b/src/onepassword/core.py index b1592e1..d47068a 100644 --- a/src/onepassword/core.py +++ b/src/onepassword/core.py @@ -1,6 +1,6 @@ import json import platform - +from typing import Protocol from onepassword.errors import raise_typed_exception # In empirical tests, we determined that maximum message size that can cross the FFI boundary @@ -8,52 +8,65 @@ # We set the limit to 50MB to be safe and consistent with the other SDKs (where this limit is 64MB), to be reconsidered upon further testing MESSAGE_LIMIT = 50 * 1024 * 1024 -machine_arch = platform.machine().lower() - -if machine_arch in ["x86_64", "amd64"]: - import onepassword.lib.x86_64.op_uniffi_core as core -elif machine_arch in ["aarch64", "arm64"]: - import onepassword.lib.aarch64.op_uniffi_core as core -else: - raise ImportError( - f"Your machine's architecture is not currently supported: {machine_arch}" - ) - - -# InitClient creates a client instance in the current core module and returns its unique ID. -async def _init_client(client_config): - try: - return await core.init_client(json.dumps(client_config)) - except Exception as e: - raise_typed_exception(e) - - -# Invoke calls specified business logic from the SDK core. -async def _invoke(invoke_config): - serialized_config = json.dumps(invoke_config) - if len(serialized_config.encode()) > MESSAGE_LIMIT: - raise ValueError( - f"message size exceeds the limit of {MESSAGE_LIMIT} bytes, please contact 1Password at support@1password.com or https://developer.1password.com/joinslack if you need help." - ) - try: - return await core.invoke(serialized_config) - except Exception as e: - raise_typed_exception(e) - - -# Invoke calls specified business logic from the SDK core. -def _invoke_sync(invoke_config): - serialized_config = json.dumps(invoke_config) - if len(serialized_config.encode()) > MESSAGE_LIMIT: - raise ValueError( - f"message size exceeds the limit of {MESSAGE_LIMIT} bytes, please contact 1Password at support@1password.com or https://developer.1password.com/joinslack if you need help." - ) - try: - return core.invoke_sync(serialized_config) - except Exception as e: - raise_typed_exception(e) - - -# ReleaseClient releases memory in the SDK core associated with the given client ID. -def _release_client(client_id): - return core.release_client(json.dumps(client_id)) +class Core(Protocol): + async def init_client(self, client_config: dict) -> str: ... + async def invoke(self, invoke_config: dict) -> str: ... + def invoke_sync(self, invoke_config: dict) -> str: ... + def release_client(self, client_id: int) -> None: ... + +class UniffiCore: + def __init__(self): + machine_arch = platform.machine().lower() + + if machine_arch in ["x86_64", "amd64"]: + import onepassword.lib.x86_64.op_uniffi_core as core + elif machine_arch in ["aarch64", "arm64"]: + import onepassword.lib.aarch64.op_uniffi_core as core + else: + raise ImportError( + f"Your machine's architecture is not currently supported: {machine_arch}" + ) + + self.core = core + + async def init_client(self, client_config: dict): + """Creates a client instance in the current core module and returns its unique ID.""" + try: + return await self.core.init_client(json.dumps(client_config)) + except Exception as e: + raise_typed_exception(e) + + async def invoke(self, invoke_config: dict): + """Invoke business logic asynchronously.""" + serialized_config = json.dumps(invoke_config) + if len(serialized_config.encode()) > MESSAGE_LIMIT: + raise ValueError( + f"message size exceeds the limit of {MESSAGE_LIMIT} bytes, " + "please contact 1Password at support@1password.com or " + "https://developer.1password.com/joinslack if you need help." + ) + try: + return await self.core.invoke(serialized_config) + except Exception as e: + raise_typed_exception(e) + + def invoke_sync(self, invoke_config: dict): + """Invoke business logic synchronously.""" + serialized_config = json.dumps(invoke_config) + if len(serialized_config.encode()) > MESSAGE_LIMIT: + raise ValueError( + f"message size exceeds the limit of {MESSAGE_LIMIT} bytes, " + "please contact 1Password at support@1password.com or " + "https://developer.1password.com/joinslack if you need help." + ) + try: + return self.core.invoke_sync(serialized_config) + except Exception as e: + raise_typed_exception(e) + + def release_client(self, client_id: int): + """Releases memory in the SDK core associated with the given client ID.""" + try: + return self.core.release_client(json.dumps(client_id)) + except Exception as e: + raise_typed_exception(e) diff --git a/src/onepassword/defaults.py b/src/onepassword/defaults.py index cfa4048..8e30ba1 100644 --- a/src/onepassword/defaults.py +++ b/src/onepassword/defaults.py @@ -9,11 +9,19 @@ DEFAULT_REQUEST_LIBRARY_VERSION = "0.11.24" DEFAULT_OS_VERSION = "0.0.0" +class DesktopAuth: + def __init__(self, account_name: str): + """ + Initialize a DesktopAuth instance. + + Args: + account_name (str): The name of the account. + """ + self.account_name = account_name # Generates a configuration dictionary with the user's parameters -def new_default_config(auth, integration_name, integration_version): +def new_default_config(auth: DesktopAuth | str, integration_name, integration_version): client_config_dict = { - "serviceAccountToken": auth, "programmingLanguage": SDK_LANGUAGE, "sdkVersion": SDK_VERSION, "integrationName": integration_name, @@ -24,4 +32,7 @@ def new_default_config(auth, integration_name, integration_version): "osVersion": DEFAULT_OS_VERSION, "architecture": platform.machine(), } + if isinstance(auth, str): + client_config_dict["serviceAccountToken"] = auth + return client_config_dict diff --git a/src/onepassword/desktop_core.py b/src/onepassword/desktop_core.py new file mode 100644 index 0000000..c50ded8 --- /dev/null +++ b/src/onepassword/desktop_core.py @@ -0,0 +1,99 @@ +import ctypes +import json +import os +import base64 +from ctypes import c_uint8, c_size_t, c_int32, POINTER, byref, c_void_p +from .core import UniffiCore +from onepassword.errors import raise_typed_exception + + +def find_1password_lib_path(): + locations = [ + "/Users/andititu/core/target/debug/libop_sdk_ipc_client.dylib" + ] + + for lib_path in locations: + if os.path.exists(lib_path): + return lib_path + + raise FileNotFoundError("1Password desktop application not found") + +class DesktopCore: + def __init__(self, account_name: str): + # Determine the path to the desktop app. + path = find_1password_lib_path() + + self.lib = ctypes.CDLL(path) + self.account_name = account_name + + # Bind the Rust-exported functions + self.send_message = self.lib.op_sdk_ipc_send_message + self.send_message.argtypes = [ + POINTER(c_uint8), # msg_ptr + c_size_t, # msg_len + POINTER(POINTER(c_uint8)), # out_buf + POINTER(c_size_t), # out_len + POINTER(c_size_t), # out_cap + ] + self.send_message.restype = c_int32 + + self.free_message = self.lib.op_sdk_ipc_free_response + self.free_message.argtypes = [POINTER(c_uint8), c_size_t, c_size_t] + self.free_message.restype = None + + def call_shared_library(self, payload: str, operation_kind: str) -> bytes: + # Prepare the input + encoded_payload = base64.b64encode(payload.encode("utf-8")).decode("utf-8") + data = { + "kind": operation_kind, + "account_name": self.account_name, + "payload": encoded_payload, + } + message = json.dumps(data).encode("utf-8") + + # Prepare output parameters + out_buf = POINTER(c_uint8)() + out_len = c_size_t() + out_cap = c_size_t() + + ret = self.send_message( + (ctypes.cast(message, POINTER(c_uint8))), + len(message), + byref(out_buf), + byref(out_len), + byref(out_cap), + ) + + if ret != 0: + raise RuntimeError(f"send_message failed with code {ret}. Please make sure the Desktop app intehration setting is enabled, or contact 1Password support.") + + # Copy bytes into Python + data = ctypes.string_at(out_buf, out_len.value) + + # Free memory via Rust's exported function + self.free_message(out_buf, out_len, out_cap) + + parsed = json.loads(data) + payload = bytes(parsed.get("payload", [])).decode("utf-8") + + success = parsed.get("success", False) + if not success: + raise_typed_exception(Exception(str(payload))) + + return payload + + async def init_client(self, config: dict) -> int: + payload = json.dumps(config) + resp = self.call_shared_library(payload, "init_client") + return json.loads(resp) + + async def invoke(self, invoke_config: dict) -> str: + payload = json.dumps(invoke_config) + return self.call_shared_library(payload, "invoke") + + def release_client(self, client_id: int): + payload = json.dumps(client_id) + try: + self.call_shared_library(payload, "release_client") + except Exception as e: + print(f"failed to release client: {e}") diff --git a/src/onepassword/items.py b/src/onepassword/items.py index 0093e00..5c1f190 100644 --- a/src/onepassword/items.py +++ b/src/onepassword/items.py @@ -1,6 +1,5 @@ # Code generated by op-codegen - DO NO EDIT MANUALLY -from .core import _invoke, _invoke_sync from typing import Optional, List from pydantic import TypeAdapter from .items_shares import ItemsShares @@ -13,17 +12,17 @@ class Items: The Items API holds all operations the SDK client can perform on 1Password items. """ - def __init__(self, client_id): + def __init__(self, client_id, core): self.client_id = client_id - self.shares = ItemsShares(client_id) - - self.files = ItemsFiles(client_id) + self.core = core + self.shares = ItemsShares(client_id, core) + self.files = ItemsFiles(client_id, core) async def create(self, params: ItemCreateParams) -> Item: """ Create a new item. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -42,7 +41,7 @@ async def get(self, vault_id: str, item_id: str) -> Item: """ Get an item by vault and item ID """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -61,7 +60,7 @@ async def put(self, item: Item) -> Item: """ Update an existing item. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -80,7 +79,7 @@ async def delete(self, vault_id: str, item_id: str) -> None: """ Delete an item. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -98,7 +97,7 @@ async def archive(self, vault_id: str, item_id: str) -> None: """ Archive an item. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -116,7 +115,7 @@ async def list(self, vault_id: str, *filters: ItemListFilter) -> List[ItemOvervi """ List items based on filters. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, diff --git a/src/onepassword/items_files.py b/src/onepassword/items_files.py index 28213c9..68d83ff 100644 --- a/src/onepassword/items_files.py +++ b/src/onepassword/items_files.py @@ -1,20 +1,21 @@ # Code generated by op-codegen - DO NO EDIT MANUALLY -from .core import _invoke, _invoke_sync +from .core import Core from typing import Optional, List from pydantic import TypeAdapter from .types import DocumentCreateParams, FileAttributes, FileCreateParams, Item class ItemsFiles: - def __init__(self, client_id): + def __init__(self, client_id, core): self.client_id = client_id + self.core = core async def attach(self, item: Item, file_params: FileCreateParams) -> Item: """ Attach files to Items """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -36,7 +37,7 @@ async def read(self, vault_id: str, item_id: str, attr: FileAttributes) -> bytes """ Read file content from the Item """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -59,7 +60,7 @@ async def delete(self, item: Item, section_id: str, field_id: str) -> Item: """ Delete a field file from Item using the section and field IDs """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -84,7 +85,7 @@ async def replace_document( """ Replace the document file within a document item """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, diff --git a/src/onepassword/items_shares.py b/src/onepassword/items_shares.py index 473292d..31eaada 100644 --- a/src/onepassword/items_shares.py +++ b/src/onepassword/items_shares.py @@ -1,14 +1,14 @@ # Code generated by op-codegen - DO NO EDIT MANUALLY -from .core import _invoke, _invoke_sync from typing import Optional, List from pydantic import TypeAdapter from .types import Item, ItemShareAccountPolicy, ItemShareParams, ValidRecipient class ItemsShares: - def __init__(self, client_id): + def __init__(self, client_id, core): self.client_id = client_id + self.core = core async def get_account_policy( self, vault_id: str, item_id: str @@ -16,7 +16,7 @@ async def get_account_policy( """ Get the item sharing policy of your account. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -37,7 +37,7 @@ async def validate_recipients( """ Validate the recipients of an item sharing link. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -61,7 +61,7 @@ async def create( """ Create a new item sharing link. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, diff --git a/src/onepassword/secrets.py b/src/onepassword/secrets.py index c5f77a2..758a967 100644 --- a/src/onepassword/secrets.py +++ b/src/onepassword/secrets.py @@ -1,6 +1,6 @@ # Code generated by op-codegen - DO NO EDIT MANUALLY -from .core import _invoke, _invoke_sync +from .core import Core from typing import Optional, List from pydantic import TypeAdapter from .types import GeneratePasswordResponse, PasswordRecipe, ResolveAllResponse @@ -12,14 +12,15 @@ class Secrets: Use secret reference URIs to securely load secrets from 1Password: op:///[/]/ """ - def __init__(self, client_id): + def __init__(self, client_id, core): self.client_id = client_id + self.core = core async def resolve(self, secret_reference: str) -> str: """ Resolve returns the secret the provided secret reference points to. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -38,7 +39,7 @@ async def resolve_all(self, secret_references: List[str]) -> ResolveAllResponse: """ Resolve takes in a list of secret references and returns the secrets they point to or errors if any. """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, @@ -58,7 +59,7 @@ def validate_secret_reference(secret_reference: str) -> None: """ Validate the secret reference to ensure there are no syntax errors. """ - response = _invoke_sync( + response = self.core.invoke_sync( { "invocation": { "parameters": { @@ -73,7 +74,7 @@ def validate_secret_reference(secret_reference: str) -> None: @staticmethod def generate_password(recipe: PasswordRecipe) -> GeneratePasswordResponse: - response = _invoke_sync( + response = self.core.invoke_sync( { "invocation": { "parameters": { diff --git a/src/onepassword/types.py b/src/onepassword/types.py index 37d2e33..012fbec 100644 --- a/src/onepassword/types.py +++ b/src/onepassword/types.py @@ -1159,3 +1159,8 @@ class WordListType(str, Enum): """ Three (random) letter "words" """ + +class VaultListParams(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + decrypt_details: Optional[bool] = Field(alias="decryptDetails", default=None) diff --git a/src/onepassword/vaults.py b/src/onepassword/vaults.py index 2b066d5..f851fa5 100644 --- a/src/onepassword/vaults.py +++ b/src/onepassword/vaults.py @@ -1,9 +1,9 @@ # Code generated by op-codegen - DO NO EDIT MANUALLY -from .core import _invoke, _invoke_sync +from .core import Core from typing import Optional, List from pydantic import TypeAdapter -from .types import VaultOverview +from .types import VaultOverview, VaultListParams class Vaults: @@ -11,18 +11,19 @@ class Vaults: The Vaults API holds all the operations the SDK client can perform on 1Password vaults. """ - def __init__(self, client_id): + def __init__(self, client_id, core): self.client_id = client_id + self.core = core - async def list(self) -> List[VaultOverview]: + async def list(self, params: Optional[VaultListParams] = None) -> List[VaultOverview]: """ List all vaults """ - response = await _invoke( + response = await self.core.invoke( { "invocation": { "clientId": self.client_id, - "parameters": {"name": "VaultsList", "parameters": {}}, + "parameters": {"name": "VaultsList", "parameters": {"params": params.model_dump(by_alias=True) if params else {}}}, } } )