diff --git a/.gitignore b/.gitignore index ab3e8ce..3101d2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,15 @@ # ---> Python +# ignore IDE files +.vscode +.idea +venv.* +.repo +.git +.git_shadow + +# ignore folders +protos + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/frostfs_api/__init__.py b/frostfs_api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/frostfs_api/cryptography/key_extension.py b/frostfs_api/cryptography/key_extension.py deleted file mode 100644 index 5844e89..0000000 --- a/frostfs_api/cryptography/key_extension.py +++ /dev/null @@ -1,44 +0,0 @@ -import base58 -import ecdsa - - -class KeyExtension: - def get_private_key_from_wif(self, wif: str) -> bytes: - """ - Converts a WIF private key to a byte array. - - :param wif: WIF private key in string format. - :return: Private key in byte format (32 bytes). - :raises ValueError: If the WIF key is incorrect. - """ - assert not self.is_empty(wif) - - decoded = base58.b58decode_check(wif) - if len(decoded) != 34 or decoded[0] != 0x80 or decoded[-1] != 0x01: - raise ValueError("Incorrect WIF private key") - - private_key = decoded[1:-1] - return private_key - - def get_public_key(self, private_key: bytes) -> bytes: - """ - Extract public key from Private key - - :param private_key: Private key in byte format (32 bytes). - :return: compressed public key in byte format (33 bytes). - :raises ValueError: If the private_key key is empty or null. - """ - assert not self.is_empty(private_key) - - if len(private_key) != 32: - raise ValueError(f"Incorrect len of private key, Expected: 32, Actual: {len(private_key)}") - - public_key = ecdsa.SigningKey.from_string(private_key, curve=ecdsa.NIST256p).get_verifying_key() - compressed_public_key = public_key.to_string("compressed") - return compressed_public_key - - @staticmethod - def is_empty(sequence_symbols: bytes | str): - if len(sequence_symbols) == 0 or sequence_symbols is None: - raise ValueError(f"Empty sequence symbols of key: {sequence_symbols}") - return False diff --git a/frostfs_api/cryptography/signer.py b/frostfs_api/cryptography/signer.py deleted file mode 100644 index f8c7ef6..0000000 --- a/frostfs_api/cryptography/signer.py +++ /dev/null @@ -1,26 +0,0 @@ -import ecdsa -from hashlib import sha256, sha512 - - -class Signer: - def sign_rfc6979(self, private_key: bytes, message: bytes) -> bytes: - if len(private_key) == 0 or private_key is None: - raise ValueError(f"Incorrect private_key: {private_key}") - - sk = ecdsa.SigningKey.from_string(private_key, curve=ecdsa.NIST256p, hashfunc=sha256) - - signature = sk.sign_deterministic(message) - - return signature - - def sign(self, private_key: bytes, message: bytes) -> bytes: - if len(private_key) == 0 or private_key is None: - raise ValueError(f"Incorrect private key: {private_key}") - - sk = ecdsa.SigningKey.from_string(private_key, curve=ecdsa.NIST256p, hashfunc=sha512) - signature = sk.sign(message) - - # the first byte indicates the node version marker - signature_with_marker = bytes([0x04]) + signature - - return signature_with_marker diff --git a/frostfs_sdk/__init__.py b/frostfs_sdk/__init__.py new file mode 100644 index 0000000..4934e5f --- /dev/null +++ b/frostfs_sdk/__init__.py @@ -0,0 +1,2 @@ +from frostfs_sdk.client import * +from frostfs_sdk.models import * diff --git a/frostfs_sdk/client/__init__.py b/frostfs_sdk/client/__init__.py new file mode 100644 index 0000000..f9d922d --- /dev/null +++ b/frostfs_sdk/client/__init__.py @@ -0,0 +1,10 @@ +from frostfs_sdk.client.frostfs_client import FrostfsClient + +from frostfs_sdk.client.models.client_environment import ClientEnvironment +from frostfs_sdk.client.models.client_settings import ClientSettings +from frostfs_sdk.client.models.ecdsa_model import ECDSA + +from frostfs_sdk.client.parameters.container_param import ContainerCreateParam +from frostfs_sdk.client.parameters.wait_param import WaitParam + +from frostfs_sdk.client.services.container import ContainerClient diff --git a/frostfs_sdk/client/frostfs_client.py b/frostfs_sdk/client/frostfs_client.py new file mode 100644 index 0000000..5113587 --- /dev/null +++ b/frostfs_sdk/client/frostfs_client.py @@ -0,0 +1,33 @@ +# Create channel and Stubs +import grpc + +from frostfs_sdk.client.utils.session_cache import SessionCache +from frostfs_sdk.client.models.client_environment import ClientEnvironment +from frostfs_sdk.client.models.client_settings import ClientSettings +from frostfs_sdk.client.models.ecdsa_model import ECDSA +from frostfs_sdk.client.services.container import ContainerClient +from frostfs_sdk.models.dto.version import Version + + +class FrostfsClient: + def __init__(self, client_settings: ClientSettings): + self.channel = grpc.insecure_channel(client_settings.address) + self.ecdsa: ECDSA = ECDSA(wif=client_settings.wif) + + client_environment = ClientEnvironment(self.ecdsa, self.channel, client_settings.address, Version(), SessionCache(0)) + self.container = ContainerClient(client_environment) + + def close(self): + self.channel.close() + + + +""" +import frostfs_sdk + +WIF = "L5XNVUzPnma6m4mPrWEN6CcTscJERcfX3yvb1cdffdxe1iriAshU" +address = "10.78.128.25:8080" +client = frostfs_sdk.FrostfsClient(ClientSettings(WIF, address)) +params = frostfs_sdk.models.PrmsCreateContainer(name="1234") +client.container.create(params) +""" diff --git a/frostfs_sdk/client/models/client_environment.py b/frostfs_sdk/client/models/client_environment.py new file mode 100644 index 0000000..29631a6 --- /dev/null +++ b/frostfs_sdk/client/models/client_environment.py @@ -0,0 +1,22 @@ +import grpc +from frostfs_sdk.cryptography.key_extension import KeyExtension +from frostfs_sdk.client.models.ecdsa_model import ECDSA +from frostfs_sdk.models.dto.version import Version +from frostfs_sdk.models.dto.owner_id import OwnerId +from frostfs_sdk.client.utils.session_cache import SessionCache + + +class ClientEnvironment: + def __init__(self, ecdsa: ECDSA, channel: grpc.Channel, address: str, version: Version, session_cache: SessionCache): + self.ecdsa = ecdsa + self.channel = channel + self.version = version + self.owner_id = OwnerId(KeyExtension().get_owner_id_by_public_key(ecdsa.public_key)) + self.session_cache = session_cache + self.address = address + self._session_key = None + + def get_session_key(self): + if not self._session_key: + self._session_key = SessionCache.form_cache_key(self.address, KeyExtension.get_hex_string(self.ecdsa.public_key)) + return self._session_key diff --git a/frostfs_sdk/client/models/client_settings.py b/frostfs_sdk/client/models/client_settings.py new file mode 100644 index 0000000..b7bd1df --- /dev/null +++ b/frostfs_sdk/client/models/client_settings.py @@ -0,0 +1,19 @@ +class ClientSettings: + def __init__(self, wif: str = None, address: str = None): + """ + Initializes client settings with validation. + + Args: + wif: Wallet import format string + address: FrostFS node host address + """ + self.wif = wif + self.address = address + + # Perform validation after initialization + self.validate() + + def validate(self): + """Performs runtime validation of the settings""" + if not (self.address and self.wif): + raise ValueError("The value must be specified ADDRESS and WIF") diff --git a/frostfs_sdk/client/models/ecdsa_model.py b/frostfs_sdk/client/models/ecdsa_model.py new file mode 100644 index 0000000..c04d116 --- /dev/null +++ b/frostfs_sdk/client/models/ecdsa_model.py @@ -0,0 +1,8 @@ +from frostfs_sdk.cryptography.key_extension import KeyExtension + + +class ECDSA: + def __init__(self, wif: str): + self.wif = wif + self.private_key: bytes = KeyExtension().get_private_key_from_wif(wif) + self.public_key: bytes = KeyExtension().get_public_key(self.private_key) diff --git a/frostfs_sdk/client/parameters/call_context_param.py b/frostfs_sdk/client/parameters/call_context_param.py new file mode 100644 index 0000000..4b15546 --- /dev/null +++ b/frostfs_sdk/client/parameters/call_context_param.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +DEFAULT_GRPC_TIMEOUT = 5 + + +class TimeUnit(Enum): + MINUTES = "MINUTES" + SECONDS = "SECONDS" + MILLISECONDS = "MILLISECONDS" + +@dataclass +class CallContextParam: + timeout: int = DEFAULT_GRPC_TIMEOUT + time_unit: TimeUnit = TimeUnit.SECONDS + + @classmethod + def default(cls): + return cls() diff --git a/frostfs_sdk/client/parameters/container_param.py b/frostfs_sdk/client/parameters/container_param.py new file mode 100644 index 0000000..464c070 --- /dev/null +++ b/frostfs_sdk/client/parameters/container_param.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass, field +from typing import Optional, Dict + +from frostfs_sdk.models.dto.container import Container +from frostfs_sdk.client.utils.session_cache import SessionToken +from frostfs_sdk.client.parameters.wait_param import WaitParam + + +@dataclass(frozen=True) +class ContainerCreateParam: + container: Container + wait_params: Optional[WaitParam] = None + session_token: Optional[SessionToken] = None + x_headers: Dict[str, str] = field(default_factory=dict) + + def __post_init__(self): + if self.wait_params is None: + object.__setattr__(self, 'wait_params', WaitParam()) diff --git a/frostfs_sdk/client/parameters/create_session_param.py b/frostfs_sdk/client/parameters/create_session_param.py new file mode 100644 index 0000000..a694974 --- /dev/null +++ b/frostfs_sdk/client/parameters/create_session_param.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass, field +from typing import Dict, Optional + +@dataclass +class CreateSessionParam: + """ + Represents parameters for creating a session. + """ + expiration: int # -1 indicates maximum expiration + x_headers: Optional[Dict[str, str]] = field(default_factory=dict) + + def __init__(self, expiration: int, x_headers: Optional[Dict[str, str]] = None): + self.expiration = expiration + self.x_headers = x_headers if x_headers is not None else {} diff --git a/frostfs_sdk/client/parameters/wait_param.py b/frostfs_sdk/client/parameters/wait_param.py new file mode 100644 index 0000000..e05fcb7 --- /dev/null +++ b/frostfs_sdk/client/parameters/wait_param.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Optional + + +@dataclass(frozen=True) +class WaitParam: + DEFAULT_TIMEOUT: timedelta = field(default=timedelta(seconds=120), init=False) + DEFAULT_POLL_INTERVAL: timedelta = field(default=timedelta(seconds=5), init=False) + + timeout: timedelta = DEFAULT_TIMEOUT + poll_interval: timedelta = DEFAULT_POLL_INTERVAL + + def __post_init__(self): + if self.timeout is None: + object.__setattr__(self, 'timeout', self.DEFAULT_TIMEOUT) + if self.poll_interval is None: + object.__setattr__(self, 'poll_interval', self.DEFAULT_POLL_INTERVAL) + + def get_deadline(self) -> datetime: + return datetime.now() + self.timeout diff --git a/frostfs_sdk/client/services/container.py b/frostfs_sdk/client/services/container.py new file mode 100644 index 0000000..4d7beca --- /dev/null +++ b/frostfs_sdk/client/services/container.py @@ -0,0 +1,64 @@ +# implementation Conainer methods +from frostfs_sdk.client.models.client_environment import ClientEnvironment +from frostfs_sdk.client.services.context_accessor import ContextAccessor +from frostfs_sdk.client.services.session import SessionClient +from frostfs_sdk.client.parameters.container_param import ContainerCreateParam +from frostfs_sdk.client.parameters.call_context_param import CallContextParam +from frostfs_sdk.client.utils.session_cache import SessionToken +from frostfs_sdk.client.utils.request_constructor import RequestConstructor +from frostfs_sdk.cryptography.signer import Signer +from frostfs_sdk.models.dto.container import ContainerId, Container +from frostfs_sdk.models.mappers.container_mapper import ContainerMapper +from frostfs_sdk.models.mappers.owner_id_mapper import OwnerIdMapper +from frostfs_sdk.models.mappers.version_mapper import VersionMapper +from frostfs_sdk.client.parameters.create_session_param import CreateSessionParam + +from frostfs_sdk.protos.models.container import service_pb2 as service_pb2_container +from frostfs_sdk.protos.models.container import service_pb2_grpc as service_pb2_grpc_container + + +class ContainerClient(ContextAccessor): + def __init__(self, client_environment: ClientEnvironment): + super().__init__(client_environment) + self.container_stub = service_pb2_grpc_container.ContainerServiceStub(client_environment.channel) + self.ecdsa = client_environment.ecdsa + + def create_container(self, container_create_param: ContainerCreateParam, ctx: CallContextParam) -> ContainerId: + request = self._create_put_request(container_create_param, ctx) + response: service_pb2_container.PutResponse = self.container_stub.Put(request) + return ContainerId(value=response.body.container_id.value) + + def _create_put_request(self, param: ContainerCreateParam, ctx: CallContextParam) -> service_pb2_container.PutRequest: + """ + Creates a PUT request for creating a container. + """ + + grpc_container=ContainerMapper().to_grpc_message(param.container, self.get_context) + + body = service_pb2_container.PutRequest.Body( + container=grpc_container, + signature=Signer.sign_message_rfc_6979(self.get_context.ecdsa, grpc_container) + ) + + request = service_pb2_container.PutRequest(body=body) + session_token = self.get_or_create_session(param.session_token, ctx) + proto_token = session_token + RequestConstructor.add_meta_header(request, param.x_headers, proto_token) + + signed_request = Signer.sign(self.ecdsa.private_key, request) + return signed_request + + def get_or_create_session(self, session_ctx: SessionToken, ctx: CallContextParam) -> bytes: + if session_ctx: + return session_ctx.token + + session_token_from_cache = self.get_context.session_cache.try_get_value(self.get_context.get_session_key()) + if session_token_from_cache: + return session_token_from_cache.token + + new_session_token = SessionClient(self.get_context).create_session(CreateSessionParam(expiration=-1), ctx) + if new_session_token: + self.get_context.session_cache.set_value(self.get_context.get_session_key(), new_session_token) + return new_session_token.token + + raise ValueError("cannot create session") diff --git a/frostfs_sdk/client/services/context_accessor.py b/frostfs_sdk/client/services/context_accessor.py new file mode 100644 index 0000000..913bcaa --- /dev/null +++ b/frostfs_sdk/client/services/context_accessor.py @@ -0,0 +1,12 @@ +from frostfs_sdk.client.models.client_environment import ClientEnvironment + +class ContextAccessor: + def __init__(self, context: ClientEnvironment): + """ + Initializes a ContextAccessor with a given ClientEnvironment. + """ + self.context: ClientEnvironment = context + + @property + def get_context(self) -> ClientEnvironment: + return self.context diff --git a/frostfs_sdk/client/services/session.py b/frostfs_sdk/client/services/session.py new file mode 100644 index 0000000..5d191fd --- /dev/null +++ b/frostfs_sdk/client/services/session.py @@ -0,0 +1,38 @@ +from typing import Optional +from dataclasses import dataclass + +from frostfs_sdk.client.utils.session_cache import SessionToken +from frostfs_sdk.cryptography.signer import Signer +from frostfs_sdk.models.mappers.session_mapper import SessionMapper +from frostfs_sdk.models.mappers.owner_id_mapper import OwnerIdMapper +from frostfs_sdk.client.models.client_environment import ClientEnvironment +from frostfs_sdk.client.services.context_accessor import ContextAccessor +from frostfs_sdk.client.utils.request_constructor import RequestConstructor +from frostfs_sdk.client.parameters.call_context_param import CallContextParam +from frostfs_sdk.client.parameters.create_session_param import CreateSessionParam + +from frostfs_sdk.protos.models.session import service_pb2_grpc as service_pb2_grpc_session +from frostfs_sdk.protos.models.session import service_pb2 as service_pb2_session +from frostfs_sdk.protos.models.session import types_pb2 as types_pb2_session + + +class SessionClient(ContextAccessor): + def __init__(self, client_environment: ClientEnvironment): + super().__init__(client_environment) + self.session_stub = service_pb2_grpc_session.SessionServiceStub(client_environment.channel) + + def create_session(self, param: CreateSessionParam, ctx: CallContextParam) -> SessionToken: + body = service_pb2_session.CreateRequest.Body( + owner_id=OwnerIdMapper.to_grpc_message(self.get_context.owner_id), + expiration=param.expiration + ) + request = service_pb2_session.CreateRequest( + body=body + ) + RequestConstructor.add_meta_header(request, None, None) + signed_request = Signer.sign_message(self.get_context.ecdsa.private_key, request) + response: service_pb2_session.CreateResponse = self.session_stub.Create(request) + + session_token_grpc = types_pb2_session.SessionToken(response.body) + token = SessionMapper.serialize(session_token_grpc) + return SessionToken(token=token) diff --git a/frostfs_sdk/client/utils/message_helper.py b/frostfs_sdk/client/utils/message_helper.py new file mode 100644 index 0000000..2806109 --- /dev/null +++ b/frostfs_sdk/client/utils/message_helper.py @@ -0,0 +1,30 @@ +from typing import Any +from google.protobuf.message import Message + +class MessageHelper: + @staticmethod + def get_field(message: Message, field_name: str): + """ + Retrieves the value of a field from a Protobuf message. + + :param message: A Protobuf Message object. + :param field_name: The name of the field to retrieve. + :return: The value of the specified field. + :raises ValueError: If the input parameters are invalid. + """ + if not message or not field_name or not isinstance(field_name, str) or not field_name.strip(): + raise ValueError("Some parameter is missing") + + descriptor = message.DESCRIPTOR + field_descriptor = descriptor.fields[field_name] + if not field_descriptor: + raise ValueError(f"Field '{field_name}' not found in message descriptor") + + return getattr(field_descriptor, field_name) + + @staticmethod + def set_field(message: Message, field_name: str, value: Any) -> None: + if message is None or not field_name.strip() or value is None: + raise ValueError("Some parameter is missing") + + setattr(message, message.DESCRIPTOR.fields[field_name], value) diff --git a/frostfs_sdk/client/utils/request_constructor.py b/frostfs_sdk/client/utils/request_constructor.py new file mode 100644 index 0000000..ab3174b --- /dev/null +++ b/frostfs_sdk/client/utils/request_constructor.py @@ -0,0 +1,47 @@ +from google.protobuf.message import Message +from typing import Dict, Optional + +from frostfs_sdk.models.mappers.meta_header_mapper import MetaHeaderMapper +from frostfs_sdk.models.mappers.session_mapper import SessionMapper +from frostfs_sdk.models.dto.meta_header import MetaHeader +from frostfs_sdk.protos.models.session import types_pb2 as types_pb2_session + + +META_HEADER_FIELD_NAME = "meta_header" + + +class RequestConstructor: + @staticmethod + def add_meta_header(request: Message, x_headers: Optional[Dict[str, str]] = None, session_token: types_pb2_session.SessionToken = None): + """ + Adds a meta header to the request. + + :param request: A Protobuf Message.Builder object. + :param x_headers: Optional dictionary of custom headers. + :param session_token: Optional session token. + :raises ValueError: If the request or required fields are missing. + """ + if request is None: + return + + descriptor = request.DESCRIPTOR + if getattr(descriptor, META_HEADER_FIELD_NAME) is None: + raise ValueError(f"Required Protobuf field is missing: {META_HEADER_FIELD_NAME}") + + meta_header = getattr(request, META_HEADER_FIELD_NAME) + if meta_header.ByteSize() > 0: + return + + meta_header_builder = MetaHeaderMapper.to_grpc_message(MetaHeader()) + + if session_token and session_token.ByteSize() > 0: + meta_header_builder.session_token = session_token + + if x_headers: + grpc_x_headers = [ + types_pb2_session.XHeader(key=key, value=value) + for key, value in x_headers.items() + ] + meta_header_builder.x_headers.extend(grpc_x_headers) + + setattr(request, META_HEADER_FIELD_NAME, meta_header_builder.build()) diff --git a/frostfs_sdk/client/utils/session_cache.py b/frostfs_sdk/client/utils/session_cache.py new file mode 100644 index 0000000..f77b671 --- /dev/null +++ b/frostfs_sdk/client/utils/session_cache.py @@ -0,0 +1,39 @@ + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class SessionToken: + token: bytes + + +class SessionCache: + def __init__(self, session_expiration_duration): + self.cache = {} + self.token_duration = session_expiration_duration + self.current_epoch = 0 + + + def contains(self, key: str): + return key in self.cache + + def try_get_value(self, key: str) -> Optional[SessionToken]: + if not key: + return None + return self.cache.get(key) + + + def set_value(self, key: str, value: SessionToken): + if key is not None: + self.cache[key] = value + + def delete_by_prefix(self, prefix: str): + # Collect keys to avoid modifying dictionary during iteration + keys_to_delete = [key for key in self.cache if key.startswith(prefix)] + for key in keys_to_delete: + del self.cache[key] + + @staticmethod + def form_cache_key(address: str, key: str): + return address + key diff --git a/frostfs_api/cryptography/__init__.py b/frostfs_sdk/cryptography/__init__.py similarity index 100% rename from frostfs_api/cryptography/__init__.py rename to frostfs_sdk/cryptography/__init__.py diff --git a/frostfs_sdk/cryptography/key_extension.py b/frostfs_sdk/cryptography/key_extension.py new file mode 100644 index 0000000..cd8b07f --- /dev/null +++ b/frostfs_sdk/cryptography/key_extension.py @@ -0,0 +1,100 @@ +import base58 +import ecdsa +import hashlib +from struct import pack, unpack +from Crypto.Hash import RIPEMD160 + + +COMPRESSED_PUBLIC_KEY_LENGTH = 33 +NEO_ADDRESS_VERSION = 0x35 +UNCOMPRESSED_PUBLIC_KEY_LENGTH = 65 +DECODE_ADDRESS_LENGTH = 21 +PS_IN_HASH160 = 0x0C +CHECK_SIG_DESCRIPTOR = int.from_bytes( + hashlib.sha256("System.Crypto.CheckSig".encode('ascii')).digest()[:4], byteorder='little' + ) + + +class KeyExtension: + def get_private_key_from_wif(self, wif: str) -> bytes: + """ + Converts a WIF private key to a byte array. + + :param wif: WIF private key in string format. + :return: Private key in byte format (32 bytes). + :raises ValueError: If the WIF key is incorrect. + """ + assert not self.is_empty(wif) + + decoded = base58.b58decode_check(wif) + if len(decoded) != 34 or decoded[0] != 0x80 or decoded[-1] != 0x01: + raise ValueError("Incorrect WIF private key") + + private_key = decoded[1:-1] + return private_key + + def get_public_key(self, private_key: bytes) -> bytes: + """ + Extract public key from Private key + + :param private_key: Private key in byte format (32 bytes). + :return: compressed public key in byte format (33 bytes). + :raises ValueError: If the private_key key is empty or null. + """ + assert not self.is_empty(private_key) + + if len(private_key) != 32: + raise ValueError(f"Incorrect len of private key, Expected: 32, Actual: {len(private_key)}") + + public_key = ecdsa.SigningKey.from_string(private_key, curve=ecdsa.NIST256p).get_verifying_key() + compressed_public_key = public_key.to_string("compressed") + return compressed_public_key + + def get_owner_id_by_public_key(self, public_key: bytes) -> str: + if len(public_key) != COMPRESSED_PUBLIC_KEY_LENGTH: + raise ValueError(f"Encoded compressed public key has wrong length. Expected {COMPRESSED_PUBLIC_KEY_LENGTH}, got {len(public_key)}") + + script_hash = self.get_script_hash(public_key) + data = bytearray(DECODE_ADDRESS_LENGTH) + data[0] = NEO_ADDRESS_VERSION + data[1:] = script_hash + return base58.b58encode_check(data).decode('utf-8') + + def get_script_hash(self, public_key: bytes): + script = self.create_signature_redeem_script(public_key) + sha256_hash = hashlib.sha256(script).digest() + return self.get_ripemd160(sha256_hash) + + @staticmethod + def create_signature_redeem_script(public_key: bytes): + if len(public_key) != COMPRESSED_PUBLIC_KEY_LENGTH: + raise ValueError(f"Encoded compressed public key has wrong length. Expected {COMPRESSED_PUBLIC_KEY_LENGTH}, got {len(public_key)}") + + script = bytearray([PS_IN_HASH160, COMPRESSED_PUBLIC_KEY_LENGTH]) + script.extend(public_key) + script.append(UNCOMPRESSED_PUBLIC_KEY_LENGTH) + script.extend(pack(" SignatureRFC6979: + return SignatureRFC6979( + key=key.public_key, + sign=Signer.sign_rfc6979(key.private_key, message.SerializeToString()) + ) + + @staticmethod + def sign_rfc6979(private_key: bytes, message: bytes) -> bytes: + if len(private_key) == 0 or private_key is None: + raise ValueError(f"Incorrect private_key: {private_key}") + + sk = ecdsa.SigningKey.from_string(private_key, curve=ecdsa.NIST256p, hashfunc=sha256) + + signature = sk.sign_deterministic(message) + + return signature + + @staticmethod + def sign_message(key: ECDSA, message: Message) -> SignatureRFC6979: + return SignatureRFC6979( + key=key.public_key, + sign=Signer.sign(key.private_key, message.SerializeToString()) + ) + + @staticmethod + def sign(private_key: bytes, message: bytes) -> bytes: + if len(private_key) == 0 or private_key is None: + raise ValueError(f"Incorrect private key: {private_key}") + + sk = ecdsa.SigningKey.from_string(private_key, curve=ecdsa.NIST256p, hashfunc=sha512) + signature = sk.sign(message) + + # the first byte indicates the node version marker + signature_with_marker = bytes([0x04]) + signature + + return signature_with_marker + + @staticmethod + def _sign_message_part(key: ECDSA, data: Message) -> Signature: + return Signature( + key=key.public_key, + sign=Signature.sign(key.private_key, data.SerializeToString()) + ) diff --git a/frostfs_api/cryptography/verifier.py b/frostfs_sdk/cryptography/verifier.py similarity index 100% rename from frostfs_api/cryptography/verifier.py rename to frostfs_sdk/cryptography/verifier.py diff --git a/frostfs_sdk/models/__init__.py b/frostfs_sdk/models/__init__.py new file mode 100644 index 0000000..aa4cd69 --- /dev/null +++ b/frostfs_sdk/models/__init__.py @@ -0,0 +1,9 @@ +from frostfs_sdk.models.dto.container import Container, ContainerId +from frostfs_sdk.models.dto.filter import Filter +from frostfs_sdk.models.dto.placement_policy import PlacementPolicy +from frostfs_sdk.models.dto.replica import Replica +from frostfs_sdk.models.dto.selector import Selector + +from frostfs_sdk.models.enums.basic_acl import BasicAcl +from frostfs_sdk.models.enums.filter_operation import FilterOperation +from frostfs_sdk.models.enums.selector_clause import SelectorClause diff --git a/frostfs_sdk/models/dto/container.py b/frostfs_sdk/models/dto/container.py new file mode 100644 index 0000000..e369b35 --- /dev/null +++ b/frostfs_sdk/models/dto/container.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from typing import Dict, Optional +import uuid + +from frostfs_sdk.models.dto.owner_id import OwnerId +from frostfs_sdk.models.dto.version import Version +from frostfs_sdk.models.enums.basic_acl import BasicAcl +from frostfs_sdk.models.dto.placement_policy import PlacementPolicy + + + +@dataclass +class Container: + # basicAcl: BasicAcl # TODO: will remove it? + placementPolicy: PlacementPolicy + nonce: uuid.UUID = field(default_factory=uuid.uuid4) + version: Optional[Version] = None + owner_id: Optional[OwnerId] = None + attributes: Dict[str, str] = field(default_factory=dict) + + def __init__(self, placementPolicy: PlacementPolicy): + self.nonce = uuid.uuid4() + self.placementPolicy = placementPolicy + self.attributes = {} + + +@dataclass +class ContainerId: + value: str diff --git a/frostfs_sdk/models/dto/filter.py b/frostfs_sdk/models/dto/filter.py new file mode 100644 index 0000000..0819648 --- /dev/null +++ b/frostfs_sdk/models/dto/filter.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from frostfs_sdk.models.enums.filter_operation import FilterOperation + + +@dataclass(frozen=True) +class Filter: + """ + Data Transfer Object for Filter configuration + """ + name: str + key: str + operation: FilterOperation + value: str diff --git a/frostfs_sdk/models/dto/meta_header.py b/frostfs_sdk/models/dto/meta_header.py new file mode 100644 index 0000000..ea9572e --- /dev/null +++ b/frostfs_sdk/models/dto/meta_header.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from typing import Optional + +from frostfs_sdk.models.dto.version import Version, DEFAULT_MAJOR_VERSION, DEFAULT_MINOR_VERSION + + +class MetaHeader: + def __init__(self, version=None, epoch: int = 0, ttl: int = 2): + """ + Initializes a MetaHeader object. + + :param version: A Version object representing the version. + :param epoch: An integer representing the epoch (default: 0). + :param ttl: An integer representing the time-to-live (default: 2). + """ + self._version = None + self._epoch = None + self._ttl = None + + # Set default values if not provided + self.set_version(version or Version(DEFAULT_MAJOR_VERSION, DEFAULT_MINOR_VERSION)) + self.set_epoch(epoch) + self.set_ttl(ttl) + + def get_version(self): + return self._version + + def get_epoch(self): + return self._epoch + + def get_ttl(self): + return self._ttl + + def set_version(self, version): + if version is None: + raise ValueError(f"Input parameter is missing: {Version.__name__}") + self._version = version + + def set_epoch(self, epoch: int): + if epoch < 0: + raise ValueError("Epoch must be greater than or equal to zero") + self._epoch = epoch + + def set_ttl(self, ttl: int): + if ttl <= 0: + raise ValueError("TTL must be greater than zero") + self._ttl = ttl diff --git a/frostfs_sdk/models/dto/owner_id.py b/frostfs_sdk/models/dto/owner_id.py new file mode 100644 index 0000000..cccb7f3 --- /dev/null +++ b/frostfs_sdk/models/dto/owner_id.py @@ -0,0 +1,21 @@ +from base58 import b58decode +from dataclasses import dataclass + + +@dataclass(frozen=True) +class OwnerId: + value: str + + def __post_init__(self): + if not self.value or self.value.strip() == "": + raise ValueError(f"{self.__class__.__name__} value is not present") + + def to_hash(self) -> bytes: + """Decodes the Base58-encoded value into a byte array.""" + try: + return b58decode(self.value) + except Exception as e: + raise ValueError(f"Failed to decode Base58 value: {self.value}") from e + + def __str__(self) -> str: + return self.value diff --git a/frostfs_sdk/models/dto/placement_policy.py b/frostfs_sdk/models/dto/placement_policy.py new file mode 100644 index 0000000..f3c95f0 --- /dev/null +++ b/frostfs_sdk/models/dto/placement_policy.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from typing import List + +from frostfs_sdk.models.dto.replica import Replica +from frostfs_sdk.models.dto.selector import Selector +from frostfs_sdk.models.dto.filter import Filter + +@dataclass +class PlacementPolicy: + replicas: List[Replica] + unique: bool + backup_factory: int + filters: List[Filter] = None + selectors: List[Selector] = None diff --git a/frostfs_sdk/models/dto/replica.py b/frostfs_sdk/models/dto/replica.py new file mode 100644 index 0000000..f3946c0 --- /dev/null +++ b/frostfs_sdk/models/dto/replica.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass, field + + +EMPTY_STRING = "" + +@dataclass +class Replica: + count: int + selector: str = field(default=EMPTY_STRING) + + def __post_init__(self): + self.selector = self.selector if self.selector else EMPTY_STRING diff --git a/frostfs_sdk/models/dto/selector.py b/frostfs_sdk/models/dto/selector.py new file mode 100644 index 0000000..a03f885 --- /dev/null +++ b/frostfs_sdk/models/dto/selector.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +from frostfs_sdk.models.enums.selector_clause import SelectorClause + + +@dataclass(frozen=True) +class Selector: + """ + Data Transfer Object for Selector configuration + """ + name: str + count: int + clause: SelectorClause + attribute: str + filter: str diff --git a/frostfs_sdk/models/dto/version.py b/frostfs_sdk/models/dto/version.py new file mode 100644 index 0000000..b492109 --- /dev/null +++ b/frostfs_sdk/models/dto/version.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + +DEFAULT_MAJOR_VERSION = 2 +DEFAULT_MINOR_VERSION = 13 + + +@dataclass(frozen=True) +class Version: + major: int = DEFAULT_MAJOR_VERSION + minor: int = DEFAULT_MINOR_VERSION + + def __str__(self) -> str: + return f"v{self.major}.{self.minor}" + + def is_supported(self, other): + if not isinstance(other, Version): + return False + return self.major == other.major + diff --git a/frostfs_sdk/models/enums/basic_acl.py b/frostfs_sdk/models/enums/basic_acl.py new file mode 100644 index 0000000..0058a25 --- /dev/null +++ b/frostfs_sdk/models/enums/basic_acl.py @@ -0,0 +1,7 @@ +from enum import Enum + +class BasicAcl(Enum): + PRIVATE = 0x1C8C8CCC + PUBLIC_RO = 0x1FBF8CFF + PUBLIC_RW = 0x1FBFBFFF + PUBLIC_APPEND = 0x1FBF9FFF diff --git a/frostfs_sdk/models/enums/filter_operation.py b/frostfs_sdk/models/enums/filter_operation.py new file mode 100644 index 0000000..e447962 --- /dev/null +++ b/frostfs_sdk/models/enums/filter_operation.py @@ -0,0 +1,42 @@ +class FilterOperation: + """ + Enum for filter operations with integer value mapping + """ + OPERATION_UNSPECIFIED = 0 + EQ = 1 + NE = 2 + GT = 3 + GE = 4 + LT = 5 + LE = 6 + OR = 7 + AND = 8 + NOT = 9 + LIKE = 10 + + _value_map = { + 0: OPERATION_UNSPECIFIED, + 1: EQ, + 2: NE, + 3: GT, + 4: GE, + 5: LT, + 6: LE, + 7: OR, + 8: AND, + 9: NOT, + 10: LIKE + } + + @classmethod + def get(cls, value: int) -> 'FilterOperation': + """ + Get enum instance by integer value + + Args: + value: Integer value of the operation + + Returns: + Corresponding FilterOperation instance + """ + return cls._value_map.get(value) diff --git a/frostfs_sdk/models/enums/selector_clause.py b/frostfs_sdk/models/enums/selector_clause.py new file mode 100644 index 0000000..880a0f5 --- /dev/null +++ b/frostfs_sdk/models/enums/selector_clause.py @@ -0,0 +1,14 @@ +from enum import Enum, unique + +@unique +class SelectorClause(Enum): + CLAUSE_UNSPECIFIED = 0 + SAME = 1 + DISTINCT = 2 + + @classmethod + def get(cls, value: int): + try: + return cls(value) + except ValueError: + raise KeyError(f"Unknown enum value: {value}") diff --git a/frostfs_sdk/models/mappers/container_mapper.py b/frostfs_sdk/models/mappers/container_mapper.py new file mode 100644 index 0000000..c1913aa --- /dev/null +++ b/frostfs_sdk/models/mappers/container_mapper.py @@ -0,0 +1,77 @@ +from typing import Optional + +import grpc + +from frostfs_sdk.client.models.client_environment import ClientEnvironment +from frostfs_sdk.client.services.context_accessor import ContextAccessor +from frostfs_sdk.models.mappers.placement_policy_mapper import PlacementPolicyMapper +from frostfs_sdk.models.mappers.owner_id_mapper import OwnerIdMapper +from frostfs_sdk.models.mappers.version_mapper import VersionMapper +from frostfs_sdk.models.mappers.uuid_extension import UuidExtension +from frostfs_sdk.models.dto.container import Container +from frostfs_sdk.protos.models.container import types_pb2 as types_pb2_container + + +class ContainerMapper: + @staticmethod + def to_grpc_message(container: Container, client_context: ClientEnvironment) -> Optional[types_pb2_container.Container]: + """ + Converts Container DTO to gRPC message + + Args: + container: Container DTO object + + Returns: + gRPC Container message builder + """ + if not container: + return None + + attributes = [ + types_pb2_container.Container.Attribute(key=k, value=v) + for k, v in container.attributes.items() + ] + + if container.owner_id: + owner_id = OwnerIdMapper.to_grpc_message(container.owner_id) + else: + owner_id = OwnerIdMapper.to_grpc_message(client_context.owner_id) + + if container.version: + version = VersionMapper.to_grpc_message(container.version) + else: + version = VersionMapper.to_grpc_message(client_context.version) + + grpc_container = types_pb2_container.Container( + nonce=container.nonce.bytes, + placement_policy=PlacementPolicyMapper.to_grpc_message(container.placementPolicy), + owner_id=owner_id, + version=version, + attributes=attributes + ) + + return grpc_container + + @staticmethod + def to_model(container_grpc: types_pb2_container.Container) -> Optional[Container]: + """ + Converts gRPC message to Container DTO + + Args: + container_grpc: gRPC Container message + + Returns: + Container DTO object + """ + if not container_grpc or container_grpc.ByteSize() == 0: + return None + + attributes = {attr.key: attr.value for attr in container_grpc.attributes} + + return Container( + nonce=UuidExtension.to_uuid(container_grpc.nonce), + placement_policy=PlacementPolicyMapper.to_model(container_grpc.placement_policy), + version=VersionMapper.to_model(container_grpc.version), + owner_id=OwnerIdMapper.to_model(container_grpc.owner_id), + attributes=attributes + ) diff --git a/frostfs_sdk/models/mappers/filter_mapper.py b/frostfs_sdk/models/mappers/filter_mapper.py new file mode 100644 index 0000000..a3f2f11 --- /dev/null +++ b/frostfs_sdk/models/mappers/filter_mapper.py @@ -0,0 +1,60 @@ +from typing import List, Optional + +from frostfs_sdk.models.enums.filter_operation import FilterOperation +from frostfs_sdk.models.dto.filter import Filter +from frostfs_sdk.protos.models.netmap import types_pb2 as types_pb2_netmap + + +class FilterMapper: + @staticmethod + def to_grpc_messages(filters: List[Filter]) -> List[types_pb2_netmap.Filter]: + """ + Converts list of Filter DTOs to gRPC messages with nested conversion + """ + if not filters: + return [] + + return [FilterMapper.to_grpc_message(f) for f in filters] + + @staticmethod + def to_grpc_message(filter_dto: Filter) -> types_pb2_netmap.Filter: + """ + Converts Filter DTO to gRPC message with nested filters + """ + + operation = types_pb2_netmap.Filter.Operation.Value(filter_dto.operation.value) + return types_pb2_netmap.Filter( + name=filter_dto.name, + key=filter_dto.key, + op=operation, + value=filter_dto.value, + filters=FilterMapper.to_grpc_messages(filter_dto.filters) + ) + + @staticmethod + def to_models(filters_grpc: List[types_pb2_netmap.Filter]) -> Optional[List[Filter]]: + """ + Converts gRPC messages to Filter DTOs with nested conversion + """ + if not filters_grpc: + return None + + return [FilterMapper.to_model(f) for f in filters_grpc] + + @staticmethod + def to_model(filter_grpc: types_pb2_netmap.Filter) -> Optional[Filter]: + """ + Converts gRPC message to Filter DTO with nested filters + """ + if not filter_grpc or filter_grpc.ByteSize() == 0: + return None + + operation = FilterOperation(filter_grpc.op) + + return Filter( + name=filter_grpc.name, + key=filter_grpc.key, + operation=operation, + value=filter_grpc.value, + filters=FilterMapper.to_models(filter_grpc.filters) + ) diff --git a/frostfs_sdk/models/mappers/meta_header_mapper.py b/frostfs_sdk/models/mappers/meta_header_mapper.py new file mode 100644 index 0000000..340a19f --- /dev/null +++ b/frostfs_sdk/models/mappers/meta_header_mapper.py @@ -0,0 +1,26 @@ +from frostfs_sdk.models.mappers.version_mapper import VersionMapper +from frostfs_sdk.models.dto.meta_header import MetaHeader +from frostfs_sdk.protos.models.session import types_pb2 as types_pb2_session + + +class MetaHeaderMapper: + """ + Maps a MetaHeader object to a Protobuf RequestMetaHeader object. + """ + @staticmethod + def to_grpc_message(meta_header: MetaHeader): + """ + Converts a MetaHeader object to a Protobuf RequestMetaHeader object. + + :param meta_header: A MetaHeader object. + :return: A Protobuf RequestMetaHeader object. + :raises ValueError: If the input meta_header is None. + """ + if meta_header is None: + raise ValueError(f"Input parameter is missing: {MetaHeader.__name__}") + + return types_pb2_session.RequestMetaHeader( + version=VersionMapper.to_grpc_message(meta_header.get_version()), + epoch=meta_header.get_epoch(), + ttl=meta_header.get_ttl() + ) diff --git a/frostfs_sdk/models/mappers/owner_id_mapper.py b/frostfs_sdk/models/mappers/owner_id_mapper.py new file mode 100644 index 0000000..8606673 --- /dev/null +++ b/frostfs_sdk/models/mappers/owner_id_mapper.py @@ -0,0 +1,32 @@ +from base58 import b58encode + +from frostfs_sdk.models.dto.owner_id import OwnerId +from frostfs_sdk.protos.models.refs import types_pb2 as types_pb2_refs + +class OwnerIdMapper: + @staticmethod + def to_grpc_message(owner_id: OwnerId) -> types_pb2_refs.OwnerID: + """ + Converts OwnerId DTO to gRPC message + """ + if not owner_id: + return None + + return types_pb2_refs.OwnerID( + value=owner_id.to_hash(), + ) + + @staticmethod + def to_model(owner_id_grpc: types_pb2_refs.OwnerID) -> OwnerId: + """ + Converts gRPC message to OwnerId DTO + """ + if not owner_id_grpc or owner_id_grpc.ByteSize() == 0: + return None + + try: + return OwnerId( + value=b58encode(owner_id_grpc.value).decode('utf-8') + ) + except Exception as e: + raise ValueError(f"Failed to encode Base58 value: {owner_id_grpc.value}") diff --git a/frostfs_sdk/models/mappers/placement_policy_mapper.py b/frostfs_sdk/models/mappers/placement_policy_mapper.py new file mode 100644 index 0000000..66a6d2d --- /dev/null +++ b/frostfs_sdk/models/mappers/placement_policy_mapper.py @@ -0,0 +1,53 @@ +from typing import Optional + +from frostfs_sdk.models.mappers.filter_mapper import FilterMapper +from frostfs_sdk.models.mappers.selector_mapper import SelectorMapper +from frostfs_sdk.models.mappers.replica_mapper import ReplicaMapper +from frostfs_sdk.models.dto.placement_policy import PlacementPolicy +from frostfs_sdk.protos.models.netmap import types_pb2 as types_pb2_netmap + + +class PlacementPolicyMapper: + @staticmethod + def to_grpc_message(policy: PlacementPolicy) -> Optional[types_pb2_netmap.PlacementPolicy]: + """ + Converts PlacementPolicy DTO to gRPC message + + Args: + policy: PlacementPolicy DTO object + + Returns: + gRPC PlacementPolicy message + """ + if not policy: + return None + + return types_pb2_netmap.PlacementPolicy( + unique=policy.unique, + container_backup_factor=policy.backup_factory, + filters=FilterMapper.to_grpc_messages(policy.filters), + selectors=SelectorMapper.to_grpc_messages(policy.selectors), + replicas=ReplicaMapper.to_grpc_messages(policy.replicas) + ) + + @staticmethod + def to_model(policy_grpc: types_pb2_netmap.PlacementPolicy) -> Optional[PlacementPolicy]: + """ + Converts gRPC message to PlacementPolicy DTO + + Args: + policy_grpc: gRPC PlacementPolicy message + + Returns: + PlacementPolicy DTO object + """ + if not policy_grpc or policy_grpc.ByteSize() == 0: + return None + + return PlacementPolicy( + replicas=ReplicaMapper.to_models(policy_grpc.replicas), + unique=policy_grpc.unique, + backup_factory=policy_grpc.container_backup_factor, + filters=FilterMapper.to_models(policy_grpc.filters), + selectors=SelectorMapper.to_models(policy_grpc.selectors) + ) diff --git a/frostfs_sdk/models/mappers/replica_mapper.py b/frostfs_sdk/models/mappers/replica_mapper.py new file mode 100644 index 0000000..8823ca0 --- /dev/null +++ b/frostfs_sdk/models/mappers/replica_mapper.py @@ -0,0 +1,56 @@ +from typing import List, Optional + +from frostfs_sdk.models.dto.replica import Replica +from frostfs_sdk.protos.models.netmap import types_pb2 as types_pb2_netmap + + +class ReplicaMapper: + @staticmethod + def to_grpc_messages(replicas: List[Replica]) -> Optional[List[types_pb2_netmap.Replica]]: + if not replicas: + return None + return [ReplicaMapper.to_grpc_message(selector) for selector in replicas] + + @staticmethod + def to_grpc_message(replica: Replica) -> Optional[types_pb2_netmap.Replica]: + """ + Converts Replica DTO to gRPC message + + Args: + replice: Replica DTO object + + Returns: + gRPC Replica message + """ + if not replica: + return None + + return types_pb2_netmap.Replica( + count=replica.count, + selector=replica.selector + ) + + @staticmethod + def to_models(grpc_replicas: List[types_pb2_netmap.Replica]) -> Optional[List[Replica]]: + if not grpc_replicas: + return None + return [ReplicaMapper.to_model(grpc_replica) for grpc_replica in grpc_replicas] + + @staticmethod + def to_model(grpc_replica: types_pb2_netmap.Replica) -> Optional[Replica]: + """ + Converts gRPC message to Replica DTO + + Args: + grpc_replica: gRPC Replica message + + Returns: + Replica DTO object + """ + if not grpc_replica or grpc_replica.ByteSize() == 0: + return None + + return Replica( + count=grpc_replica.count, + selectors=grpc_replica.selector + ) diff --git a/frostfs_sdk/models/mappers/selector_mapper.py b/frostfs_sdk/models/mappers/selector_mapper.py new file mode 100644 index 0000000..d536f3c --- /dev/null +++ b/frostfs_sdk/models/mappers/selector_mapper.py @@ -0,0 +1,73 @@ +from typing import List, Optional + +from frostfs_sdk.models.dto.selector import Selector +from frostfs_sdk.models.enums.selector_clause import SelectorClause +from frostfs_sdk.protos.models.netmap import types_pb2 as types_pb2_netmap + + +class SelectorMapper: + @staticmethod + def to_grpc_messages(selectors: List[Selector]) -> Optional[List[types_pb2_netmap.Selector]]: + if not selectors: + return None + return [SelectorMapper.to_grpc_message(selector) for selector in selectors] + + @staticmethod + def to_grpc_message(selector: Selector) -> Optional[types_pb2_netmap.Selector]: + """ + Converts Selector DTO to gRPC message + + Args: + selector: Selector DTO object + + Returns: + gRPC Selector message + """ + if not selector: + return None + + clause_grpc = types_pb2_netmap.Clause(selector.clause.value) + if clause_grpc is None: + raise ValueError(f"Unknown enum value: {selector.clause.name} for {types_pb2_netmap.Clause.__name__}") + + + return types_pb2_netmap.Selector( + name=selector.name, + count=selector.count, + clause=clause_grpc, + attribute=selector.attribute, + filter=selector.filter + ) + + @staticmethod + def to_models(grpc_selectors: List[types_pb2_netmap.Selector]) -> Optional[List[Selector]]: + if not grpc_selectors: + return None + return [SelectorMapper.to_model(grpc_selector) for grpc_selector in grpc_selectors] + + @staticmethod + def to_model(selector_grpc: types_pb2_netmap.Selector) -> Optional[Selector]: + """ + Converts gRPC message to Selector DTO + + Args: + selector_grpc: gRPC Selector message + + Returns: + Selector DTO object + """ + if not selector_grpc or selector_grpc.ByteSize() == 0: + return None + + clause = SelectorClause.get(selector_grpc.clause) + if clause is None: + raise ValueError(f"Unknown enum value: {selector_grpc.clause} for {SelectorClause.__name__}") + + + return Selector( + name=selector_grpc.name, + count=selector_grpc.count, + clause=clause, + attribute=selector_grpc.attribute, + filter=selector_grpc.filter + ) diff --git a/frostfs_sdk/models/mappers/session_mapper.py b/frostfs_sdk/models/mappers/session_mapper.py new file mode 100644 index 0000000..4006771 --- /dev/null +++ b/frostfs_sdk/models/mappers/session_mapper.py @@ -0,0 +1,44 @@ +from google.protobuf.message import DecodeError +from frostfs_sdk.protos.models.session import types_pb2 as types_pb2_session + + +class SessionMapper: + @staticmethod + def serialize(token: types_pb2_session.SessionToken) -> bytes: + """ + Serializes a SessionToken object into a byte array. + + :param token: A SessionToken Protobuf object. + :return: A byte array representing the serialized SessionToken. + :raises ValueError: If the input token is None. + :raises Exception: If serialization fails. + """ + if token is None: + raise ValueError(f"Input parameter is missing: {types_pb2_session.SessionToken.__name__}") + + try: + # Serialize the token to bytes + return token.SerializeToString() + except Exception as e: + raise Exception(f"Serialization failed: {str(e)}") + + @staticmethod + def deserialize_session_token(bytes_data: bytes) -> types_pb2_session.SessionToken: + """ + Deserializes a byte array into a SessionToken object. + + :param bytes_data: A byte array representing the serialized SessionToken. + :return: A SessionToken Protobuf object. + :raises ValueError: If the input byte array is None or empty. + :raises Exception: If deserialization fails. + """ + if not bytes_data: + raise ValueError(f"Input parameter is missing: {types_pb2_session.SessionToken.__name__}") + + try: + # Deserialize the byte array into a SessionToken object + session_token = types_pb2_session.SessionToken() + session_token.ParseFromString(bytes_data) + return session_token + except DecodeError as e: + raise Exception(f"Deserialization failed: {str(e)}") diff --git a/frostfs_sdk/models/mappers/uuid_extension.py b/frostfs_sdk/models/mappers/uuid_extension.py new file mode 100644 index 0000000..e1bdbdf --- /dev/null +++ b/frostfs_sdk/models/mappers/uuid_extension.py @@ -0,0 +1,31 @@ +import uuid +from typing import Optional + +UUID_BYTE_ARRAY_LENGTH = 16 + + +class UuidExtension: + @staticmethod + def to_uuid(bytes_: Optional[bytes]) -> uuid.UUID: + """ + Converts a byte array into a UUID object. + """ + if bytes_ is None or len(bytes_) != UUID_BYTE_ARRAY_LENGTH: + raise ValueError(f"Wrong UUID size: expected {UUID_BYTE_ARRAY_LENGTH} bytes, got {len(bytes_)}") + + # Unpack the byte array into two 64-bit integers + most_sig_bits = int.from_bytes(bytes_[:8], byteorder='big', signed=False) + least_sig_bits = int.from_bytes(bytes_[8:], byteorder='big', signed=False) + + return uuid.UUID(int=(most_sig_bits << 64) | least_sig_bits) + + @staticmethod + def to_bytes(uuid_: Optional[uuid.UUID]) -> bytes: + """ + Converts a UUID object into a byte array. + """ + if uuid_ is None: + raise ValueError(f"Input parameter is missing: {uuid.UUID.__name__}") + + # Pack the UUID into a 16-byte array + return (uuid_.int >> 64).to_bytes(8, byteorder='big') + (uuid_.int & 0xFFFFFFFFFFFFFFFF).to_bytes(8, byteorder='big') diff --git a/frostfs_sdk/models/mappers/version_mapper.py b/frostfs_sdk/models/mappers/version_mapper.py new file mode 100644 index 0000000..6eedcda --- /dev/null +++ b/frostfs_sdk/models/mappers/version_mapper.py @@ -0,0 +1,31 @@ +from typing import Optional +from frostfs_sdk.models.dto.version import Version +from frostfs_sdk.protos.models.refs import types_pb2 as types_pb2_refs + + +class VersionMapper: + @staticmethod + def to_grpc_message(version: Optional[Version]) -> Optional[types_pb2_refs.Version]: + """ + Converts a Version object to a gRPC Version message. + """ + if version is None: + return None + + return types_pb2_refs.Version( + major=version.major, + minor=version.minor + ) + + @staticmethod + def to_model(grpc_version: Optional[types_pb2_refs.Version]) -> Optional[Version]: + """ + Converts a gRPC Version message to a Version object. + """ + if grpc_version is None or grpc_version.ByteSize() == 0: + return None + + return Version( + major=grpc_version.major, + minor=grpc_version.minor + ) diff --git a/generate_proto.sh b/generate_proto.sh new file mode 100755 index 0000000..6695de4 --- /dev/null +++ b/generate_proto.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# TODO: add to MakeFile +# chmod +x generate_proto.sh +# ./generate_proto.sh + + +# Define directories +PROTOS_DIR="./frostfs_sdk/protos" +SOURCE_DIR="${PROTOS_DIR}/source" +MODELS_DIR="${PROTOS_DIR}/models" +REPO_FROSTFS_API_PROTOS="https://git.frostfs.info/TrueCloudLab/frostfs-api.git" + +rm -rf "$PROTOS_DIR" + +echo "1. Create folder ./protos" +mkdir -p "$PROTOS_DIR" + +echo "2. Cloning repository into ./protos/source..." +mkdir -p "$SOURCE_DIR" +git clone "$REPO_FROSTFS_API_PROTOS" "$SOURCE_DIR" + +echo "3. Generating Python code from .proto files to $MODELS_DIR" +mkdir -p "$MODELS_DIR" +find "$SOURCE_DIR" -name "*.proto" | while read -r proto_file; do + python -m grpc_tools.protoc -I "$SOURCE_DIR" --python_out="$MODELS_DIR" --pyi_out="$MODELS_DIR" --grpc_python_out="$MODELS_DIR" "$proto_file" +done + +echo "4. Making ./protos/models a Python package..." +touch "$MODELS_DIR/__init__.py" + +echo "All steps completed successfully." \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2d8c473..726ddf6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ base58==2.1.1 ecdsa==0.19.0 +grpcio==1.70.0 +grpcio-tools==1.70.0 +pycryptodome==3.22.0 pytest==8.3.4 diff --git a/tests/client/test_create_container.py b/tests/client/test_create_container.py new file mode 100644 index 0000000..0876a38 --- /dev/null +++ b/tests/client/test_create_container.py @@ -0,0 +1,15 @@ +import pytest + +from frostfs_sdk import FrostfsClient, ContainerCreateParam, ContainerId +from frostfs_sdk.client.parameters.call_context_param import CallContextParam, TimeUnit +from tests.helpers.models import Helpers + + +@pytest.mark.container +class TestContainer: + def test_create_container(self, default_frostfs_client: FrostfsClient, helpers: Helpers): + call_context = CallContextParam(timeout=1, time_unit=TimeUnit.MINUTES) + req_body: ContainerCreateParam = helpers.container.create_params_container_create() + + cid: ContainerId = default_frostfs_client.container.create_container(req_body, call_context) + print(cid.value) diff --git a/tests/conftest.py b/tests/conftest.py index 3e26bbe..de465cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,23 @@ import pytest -from tests.helpers.models import ClientCryptograpy +from frostfs_sdk import ClientSettings, FrostfsClient +from tests.helpers.models import ClientCryptograpy, Helpers @pytest.fixture(scope="session") def client_cryptography(): return ClientCryptograpy() + + +@pytest.fixture(scope="session") +def default_frostfs_client(): + client_settings = ClientSettings( + wif="KxnEZ7FsPgKMdL9PYt9vsDkXiSw6qP9J8dpR4eVMsDpJyJxcYpve", + address="localhost:8080" + ) + return FrostfsClient(client_settings) + + +@pytest.fixture(scope="session") +def helpers(): + return Helpers() diff --git a/tests/helpers/models.py b/tests/helpers/models.py index 56071ad..641710c 100644 --- a/tests/helpers/models.py +++ b/tests/helpers/models.py @@ -1,6 +1,7 @@ -from frostfs_api.cryptography.key_extension import KeyExtension -from frostfs_api.cryptography.verifier import Verifier -from frostfs_api.cryptography.signer import Signer +from frostfs_sdk.cryptography.key_extension import KeyExtension +from frostfs_sdk.cryptography.verifier import Verifier +from frostfs_sdk.cryptography.signer import Signer +from tests.helpers.params_container import ParamsContainerHelper class ClientCryptograpy: @@ -8,3 +9,8 @@ class ClientCryptograpy: self.key_extension = KeyExtension() self.signer = Signer() self.verifier = Verifier() + + +class Helpers: + def __init__(self): + self.container = ParamsContainerHelper() diff --git a/tests/helpers/params_container.py b/tests/helpers/params_container.py new file mode 100644 index 0000000..93651d9 --- /dev/null +++ b/tests/helpers/params_container.py @@ -0,0 +1,16 @@ +from frostfs_sdk import ContainerCreateParam, WaitParam, Container, PlacementPolicy, Replica, BasicAcl + + +class ParamsContainerHelper: + def create_params_container_create(self): + req_container_create = ContainerCreateParam( + container=Container( + placementPolicy=PlacementPolicy( + replicas=[Replica(count=1)], + unique=True, + backup_factory=0 + ) + ), + wait_params=WaitParam() + ) + return req_container_create